A metaprogramação Ruby é ainda mais legal do que parece

Publicados: 2022-03-11

Você costuma ouvir que metaprogramação é algo que apenas ninjas Ruby usam, e que simplesmente não é para mortais comuns. Mas a verdade é que a metaprogramação não é nada assustador. Esta postagem no blog servirá para desafiar esse tipo de pensamento e aproximar a metaprogramação do desenvolvedor Ruby médio para que eles também possam colher seus benefícios.

Metaprogramação Ruby: Escrevendo Código
Tweet

Deve-se notar que a metaprogramação pode significar muito e muitas vezes pode ser muito mal utilizada e ir ao extremo quando se trata de uso, então tentarei lançar alguns exemplos do mundo real que todos poderiam usar na programação cotidiana.

Metaprogramação

Metaprogramação é uma técnica pela qual você pode escrever código que escreve código por si só dinamicamente em tempo de execução. Isso significa que você pode definir métodos e classes durante o tempo de execução. Louco, certo? Em poucas palavras, usando metaprogramação você pode reabrir e modificar classes, capturar métodos que não existem e criá-los em tempo real, criar código DRY evitando repetições e muito mais.

O básico

Antes de mergulharmos em metaprogramação séria, devemos explorar o básico. E a melhor maneira de fazer isso é pelo exemplo. Vamos começar com um e entender a metaprogramação Ruby passo a passo. Você provavelmente pode adivinhar o que este código está fazendo:

 class Developer def self.backend "I am backend developer" end def frontend "I am frontend developer" end end

Definimos uma classe com dois métodos. O primeiro método nesta classe é um método de classe e o segundo é um método de instância. Isso é básico em Ruby, mas há muito mais acontecendo por trás desse código que precisamos entender antes de prosseguirmos. Vale ressaltar que a própria classe Developer é na verdade um objeto. Em Ruby tudo é um objeto, incluindo classes. Como Developer é uma instância, é uma instância da classe Class . Aqui está como o modelo de objeto Ruby se parece:

Modelo de objeto Ruby

p Developer.class # Class p Class.superclass # Module p Module.superclass # Object p Object.superclass # BasicObject

Uma coisa importante a entender aqui é o significado de self . O método frontend é um método regular que está disponível em instâncias da classe Developer , mas por que o método backend é um método de classe? Cada pedaço de código executado em Ruby é executado em um self específico. Quando o interpretador Ruby executa qualquer código, ele sempre acompanha o valor self de qualquer linha. self está sempre se referindo a algum objeto, mas esse objeto pode mudar com base no código executado. Por exemplo, dentro de uma definição de classe, o self se refere à própria classe que é uma instância da classe Class .

 class Developer p self end # Developer

Dentro dos métodos de instância, self se refere a uma instância da classe.

 class Developer def frontend self end end p Developer.new.frontend # #<Developer:0x2c8a148>

Dentro dos métodos de classe, self se refere à própria classe de uma maneira (que será discutida com mais detalhes posteriormente neste artigo):

 class Developer def self.backend self end end p Developer.backend # Developer

Isso é bom, mas o que é um método de classe afinal? Antes de responder a essa pergunta, precisamos mencionar a existência de algo chamado metaclasse, também conhecido como classe singleton e autoclasse. O frontend de método de classe que definimos anteriormente nada mais é do que um método de instância definido na metaclasse para o objeto Developer ! Uma metaclasse é essencialmente uma classe que Ruby cria e insere na hierarquia de herança para conter métodos de classe, não interferindo com instâncias que são criadas a partir da classe.

Metaclasses

Cada objeto em Ruby tem sua própria metaclasse. É de alguma forma invisível para um desenvolvedor, mas está lá e você pode usá-lo com muita facilidade. Como nossa classe Developer é essencialmente um objeto, ela tem sua própria metaclasse. Como exemplo vamos criar um objeto da classe String e manipular sua metaclasse:

 example = "I'm a string object" def example.something self.upcase end p example.something # I'M A STRING OBJECT

O que fizemos aqui foi adicionar um método singleton a something objeto. A diferença entre métodos de classe e métodos singleton é que os métodos de classe estão disponíveis para todas as instâncias de um objeto de classe, enquanto os métodos singleton estão disponíveis apenas para essa única instância. Os métodos de classe são amplamente usados, enquanto os métodos singleton nem tanto, mas ambos os tipos de métodos são adicionados a uma metaclasse desse objeto.

O exemplo anterior poderia ser reescrito assim:

 example = "I'm a string object" class << example def something self.upcase end end

A sintaxe é diferente, mas efetivamente faz a mesma coisa. Agora vamos voltar ao exemplo anterior onde criamos a classe Developer e explorar algumas outras sintaxes para definir um método de classe:

 class Developer def self.backend "I am backend developer" end end

Esta é uma definição básica que quase todo mundo usa.

 def Developer.backend "I am backend developer" end

É a mesma coisa, estamos definindo o método de classe de backend para Developer . Nós não usamos self , mas definir um método como esse efetivamente o torna um método de classe.

 class Developer class << self def backend "I am backend developer" end end end

Novamente, estamos definindo um método de classe, mas usando uma sintaxe semelhante à que usamos para definir um método singleton para um objeto String . Você pode notar que usamos self aqui, que se refere ao próprio objeto Developer . Primeiro abrimos a classe Developer , tornando self igual à classe Developer . Em seguida, fazemos a class << self , tornando self igual à metaclasse do Developer . Em seguida, definimos um backend de método na metaclasse do Developer .

 class << Developer def backend "I am backend developer" end end

Ao definir um bloco como este, estamos definindo self para a metaclasse do Developer pela duração do bloco. Como resultado, o método backend é adicionado à metaclasse do Developer , em vez da própria classe.

Vamos ver como essa metaclasse se comporta na árvore de herança:

Metaclasse na árvore de herança

Como você viu nos exemplos anteriores, não há nenhuma prova real de que a metaclasse exista. Mas podemos usar um pequeno hack que pode nos mostrar a existência dessa classe invisível:

 class Object def metaclass_example class << self self end end end

Se definirmos um método de instância na classe Object (sim, podemos reabrir qualquer classe a qualquer momento, essa é outra beleza da metaprogramação), teremos um self referindo-se ao objeto Object dentro dela. Podemos então usar a sintaxe class << self para alterar o self atual para apontar para a metaclasse do objeto atual. Como o objeto atual é a própria classe Object , essa seria a metaclasse da instância. O método retorna self que neste momento é uma metaclasse. Então, chamando esse método de instância em qualquer objeto, podemos obter uma metaclasse desse objeto. Vamos definir nossa classe Developer novamente e começar a explorar um pouco:

 class Developer def frontend p "inside instance method, self is: " + self.to_s end class << self def backend p "inside class method, self is: " + self.to_s end end end developer = Developer.new developer.frontend # "inside instance method, self is: #<Developer:0x2ced3b8>" Developer.backend # "inside class method, self is: Developer" p "inside metaclass, self is: " + developer.metaclass_example.to_s # "inside metaclass, self is: #<Class:#<Developer:0x2ced3b8>>"

E para o crescendo, vamos ver a prova de que frontend é um método de instância de uma classe e backend é um método de instância de uma metaclasse:

 p developer.class.instance_methods false # [:frontend] p developer.class.metaclass_example.instance_methods false # [:backend]

Embora, para obter a metaclasse, você não precise reabrir Object e adicionar este hack. Você pode usar singleton_class que Ruby fornece. É o mesmo que metaclass_example que adicionamos, mas com este hack você pode realmente ver como o Ruby funciona nos bastidores:

 p developer.class.singleton_class.instance_methods false # [:backend]

Definindo métodos usando “class_eval” e “instance_eval”

Há mais uma maneira de criar um método de classe, e isso é usando instance_eval :

 class Developer end Developer.instance_eval do p "instance_eval - self is: " + self.to_s def backend p "inside a method self is: " + self.to_s end end # "instance_eval - self is: Developer" Developer.backend # "inside a method self is: Developer"

Este trecho de código interpretador Ruby avalia no contexto de uma instância, que neste caso é um objeto Developer . E quando você está definindo um método em um objeto, você está criando um método de classe ou um método singleton. Neste caso, é um método de classe - para ser exato, métodos de classe são métodos singleton, mas métodos singleton de uma classe, enquanto os outros são métodos singleton de um objeto.

Por outro lado, class_eval avalia o código no contexto de uma classe em vez de uma instância. Praticamente reabre a aula. Aqui está como class_eval pode ser usado para criar um método de instância:

 Developer.class_eval do p "class_eval - self is: " + self.to_s def frontend p "inside a method self is: " + self.to_s end end # "class_eval - self is: Developer" p developer = Developer.new # #<Developer:0x2c5d640> developer.frontend # "inside a method self is: #<Developer:0x2c5d640>"

Para resumir, quando você chama o método class_eval , você muda self para se referir à classe original e quando você chama instance_eval , self muda para se referir à metaclass da classe original.

Definindo métodos ausentes em tempo real

Mais uma peça do quebra-cabeça de metaprogramação é method_missing . Quando você chama um método em um objeto, o Ruby primeiro entra na classe e procura seus métodos de instância. Se não encontrar o método lá, ele continua a procurar na cadeia de ancestrais. Se o Ruby ainda não encontrar o método, ele chama outro método chamado method_missing que é um método de instância do Kernel que todo objeto herda. Já que temos certeza de que Ruby vai chamar esse método eventualmente para métodos ausentes, podemos usar isso para implementar alguns truques.

define_method é um método definido na classe Module que você pode usar para criar métodos dinamicamente. Para usar define_method , você o chama com o nome do novo método e um bloco onde os parâmetros do bloco se tornam os parâmetros do novo método. Qual é a diferença entre usar def para criar um método e define_method ? Não há muita diferença, exceto que você pode usar define_method em combinação com method_missing para escrever código DRY. Para ser exato, você pode usar define_method em vez de def para manipular escopos ao definir uma classe, mas isso é outra história. Vejamos um exemplo simples:

 class Developer define_method :frontend do |*my_arg| my_arg.inject(1, :*) end class << self def create_backend singleton_class.send(:define_method, "backend") do "Born from the ashes!" end end end end developer = Developer.new p developer.frontend(2, 5, 10) # => 100 p Developer.backend # undefined method 'backend' for Developer:Class (NoMethodError) Developer.create_backend p Developer.backend # "Born from the ashes!"

Isso mostra como define_method foi usado para criar um método de instância sem usar um def . No entanto, há muito mais que podemos fazer com eles. Vamos dar uma olhada neste trecho de código:

 class Developer def coding_frontend p "writing frontend" end def coding_backend p "writing backend" end end developer = Developer.new developer.coding_frontend # "writing frontend" developer.coding_backend # "writing backend"

Este código não é DRY, mas usando define_method podemos torná-lo DRY:

 class Developer ["frontend", "backend"].each do |method| define_method "coding_#{method}" do p "writing " + method.to_s end end end developer = Developer.new developer.coding_frontend # "writing frontend" developer.coding_backend # "writing backend"

Isso é muito melhor, mas ainda não é perfeito. Por quê? Se quisermos adicionar um novo método coding_debug , por exemplo, precisamos colocar esse "debug" no array. Mas usando method_missing podemos corrigir isso:

 class Developer def method_missing method, *args, &block return super method, *args, &block unless method.to_s =~ /^coding_\w+/ self.class.send(:define_method, method) do p "writing " + method.to_s.gsub(/^coding_/, '').to_s end self.send method, *args, &block end end developer = Developer.new developer.coding_frontend developer.coding_backend developer.coding_debug

Este pedaço de código é um pouco complicado, então vamos dividi-lo. Chamar um método que não existe method_missing . Aqui, queremos criar um novo método somente quando o nome do método começar com "coding_" . Caso contrário, apenas chamamos super para fazer o trabalho de relatar um método que está realmente ausente. E estamos simplesmente usando define_method para criar esse novo método. É isso! Com este pedaço de código podemos criar literalmente milhares de novos métodos começando com "coding_" , e esse fato é o que torna nosso código DRY. Como define_method é privado para Module , precisamos usar send para invocá-lo.

Empacotando

Esta é apenas a ponta do iceberg. Para se tornar um Ruby Jedi, este é o ponto de partida. Depois de dominar esses blocos de construção da metaprogramação e realmente entender sua essência, você pode prosseguir para algo mais complexo, por exemplo, criar sua própria Linguagem Específica de Domínio (DSL). DSL é um tópico em si, mas esses conceitos básicos são um pré-requisito para a compreensão de tópicos avançados. Algumas das gems mais usadas no Rails foram construídas dessa forma e você provavelmente usou o DSL dele mesmo sem saber, como RSpec e ActiveRecord.

Espero que este artigo possa levá-lo um passo mais perto de entender a metaprogramação e talvez até construir sua própria DSL, que você pode usar para codificar com mais eficiência.