Ontem eu estava entrevistando para um cargo de desenvolvedor Ruby em uma empresa aqui em Denver quando meu entrevistador colocou um bom desafio de metaprogramação: ele me pediu para escrever um auditor de método automatizado.
Ele descreveu um método de classe que pegaria o nome de um método de instância e geraria uma trilha de papel automática e logada a qualquer momento que o método alvo fosse chamado. Algo como isto:
Que produziria uma saída parecida com isto:
Produzi uma solução que funcionava mas envolvia alguns hacks potencialmente perigosos para Object#send. Antes de apresentar minha solução, eu disse “isso provavelmente é uma má idéia, mas…” e eu estava certo. Boas ideias raramente começam assim.
A solução que ele propôs envolvia o método de invólucro frequentemente ignorado Module#prepend e eu achei que era fixe o suficiente para justificar um write-up.
A estratégia era usar o comportamento de Module#prepend para criar um método de invólucro gerado dinamicamente para Messenger#share.
Naquele wrapper, teremos uma mensagem “Performing” disparada antes do corpo do método ser executado, a própria execução do método, e depois uma mensagem final “Exiting” assim que a execução do método tiver terminado com sucesso.
But to understand how to use Module#prepend to do this, we need first a good understanding of how method inheritance works in Ruby.
Ancestor Chains
Cada objecto Ruby fica no fim do que se chama uma Ancestor Chain. É uma lista dos objetos dos antepassados dos quais o objeto herda. Ruby usa esta Corrente Ancestral para determinar qual versão de um método (se houver alguma) é executada quando o objeto recebe uma mensagem.
Pode realmente ver a árvore dos antepassados para qualquer objeto chamando Module#ancestors. Por exemplo, aqui está a cadeia de ancestrais para a nossa Messenger class:
Quando nós #include ou #prepend um módulo em uma classe, Ruby faz alterações na cadeia de ancestrais dessa classe, ajustando quais métodos são encontrados e em que ordem. A grande diferença entre #include e #prepend é onde essa mudança é feita. Vamos criar um módulo chamado Auditable que irá (eventualmente) conter o código que faz a nossa magia de auditoria:
Se usarmos Module#include para importar os métodos (actualmente inexistentes) de Auditable, o Ruby irá apertar esse módulo como um antepassado da nossa classe Messenger. Aqui, veja por si mesmo:
Quando chamamos um método de Messenger, o Ruby irá olhar para o que está definido na classe Messenger e – se não conseguir encontrar um método que corresponda à chamada – subirá a cadeia de ancestrais para Auditable para procurar novamente. Se ele não encontrar o que está procurando em Auditable ele passa para Object e assim por diante.
Isso é #include. Se em vez disso usarmos Module#prepend para importar o conteúdo do módulo, obtemos um efeito totalmente diferente:
#prepend faz de Messenger um antepassado de Auditable. Espere. O quê?
Isto pode parecer que Messenger é agora uma superclasse de Auditable, mas não é exactamente isso que está a acontecer aqui. Instâncias da classe Messenger ainda são instâncias da classe Messenger mas o Ruby irá agora procurar métodos para usar para Messenger no módulo Auditable antes de os procurar na própria classe Messenger.
E isso, amigos, é o que vamos aproveitar para construir este auditor: se criarmos um método chamado Auditable#share, o Ruby irá descobrir isso antes de encontrar Messenger#share. Podemos então usar um super chamar em Auditable#share para acessar (e executar!) o método original definido em Messenger.
O Módulo em si mesmo
Não vamos realmente criar um método chamado Auditable#share. Por que não? Porque queremos que este seja um utilitário flexível. Se codificarmos o método Auditable#share, poderemos usá-lo apenas em métodos que implementem o método #share. Ou pior, teríamos que reimplementar este mesmo padrão de auditor para cada método que queremos auditar. Não obrigado.
Então, ao invés disso, vamos definir nosso método dinamicamente e o método da classe audit_method para dispará-lo:
Quando audit_method é chamado em uma classe implementadora, um método é criado no módulo Auditable com o mesmo nome do método para auditoria. No nosso caso, ele irá criar um método chamado Auditable#share. Como mencionado, Ruby irá encontrar este método antes de encontrar o método original em Messenger, porque estamos a pré-utilizar o módulo Auditable na classe de implementação.
Isso significa que podemos usar uma super chamada para alcançar a cadeia dos antepassados e executar Messenger#send. Quando fazemos isso, passamos os argumentos que coletamos (*arguments) pela chain também.
Após termos chamado o método original, imprimimos nossa mensagem de saída e a chamamos de um dia. Bom trabalho, gang!
>
Arrasar tudo junto
Agora é só uma questão de prepend levar este módulo para a nossa classe Messenger e devemos estar prontos para ir:
E bom Deus funciona:
As implicações aqui são enormes para a auditoria, mas há mais neste truque. Você pode usar o prepending para mudar o comportamento dos objetos sem alterar os objetos em si. Esta é uma forma adaptável e clara de criar componentes de maior ordem em Ruby. Teste de desempenho, tratamento de erros. Você pode fazer muito aqui.
Conclusion
Módulos Ruby são mais complicados do que a maioria das pessoas pensam. Não é tão simples como “despejar” código de um lugar para o outro, e compreender essas diferenças desbloqueia algumas ferramentas realmente limpas para o seu cinto de utilidades.
>
Você deve ter notado que eu só falei sobre Module#include e Module#prepend hoje, e que eu não toquei em Module#extend. Isso é porque funciona muito diferente dos seus primos. Vou escrever uma explicação detalhada de Module#extend em breve para completar o conjunto.
Por agora, se quiserem saber mais, recomendo a leitura dos módulos Ruby: Incluir vs Pré-Gaste vs Extender por Léonard Hetsch. Foi muito útil para juntar tudo isto.