Outra possibilidade, se a linguagem possui "classes de primeira classe", é aplicar transformações em uma classe como um todo (ex.: metaclasses).
Além desses casos, que eu particularmente chamo de "caixa preta" (pois somente a "fronteira" do código é alterado, sem o conhecimento do seu conteúdo), linguagens homoicônicas (como Lisp, Prolog ou XSLT) também permitem a transformação do código como um todo - incluindo instruções específicas, expressões, chegando até os símbolos terminais. Essas transformações são genericamente chamadas de "macros" (embora sejam bem mais flexíveis que as "macros" da família do C).
O código acima pega toda instrução "somar duas variáveis" e inverte a ordem dos operandos. Se há duas ou mais instruções separadas por E, ele transforma tanto a primeira quanto a segunda parte. Embora esse código execute "em tempo de compilação" (i.e. antes da definição da função ser incorporada ao programa), ele já atua sobre uma representação abstrata do código fonte, não sobre o texto em si (seria como a AST, mas em Prolog não há diferença entre "código" e "dados", então a AST do programa é representada por estruturas de dados da própria linguagem).
Por fim, mesmo linguagens sem "X de primeira classe" ainda podem dar suporte a alguma forma de metaprogramação, na forma de reflexão (reflection). Ela consiste em se inspecionar a estrutura de seus dados e atuar nos mesmos de forma genérica:
Esse exemplo define uma função que pode chamar qualquer outra função [pública, zero parâmetros] em um objeto, bastando conhecer o nome dele. Sem isso, seria necessário que o objeto callback implementasse uma interface específica, e definisse um único método em conformidade com essa interface. Dessa forma, objetos de classes totalmente distintas e com métodos também distintos podem ser usados igualmente.
Nota: vários dos exemplos acima não podem ser considerados boa prática, e provavelmente existem melhores maneiras de se atingir o mesmo objetivo. Estão aí somente para exemplificar as técnicas mencionadas.