Posts recentes


Comentários


Arquivos


Categorias


RSS RSS 2



Ruby: Metaprogramming, Open Classes e o Object Model (Parte II)

Dando continuidade ao post anterior, disponível aqui, vejamos como Ruby acha os métodos quando estes são invocados, e como Module, Class e Object se relacionam de verdade.

Module, Class e Object

No último post, encerrei o tópico com a seguinte pergunta: Module herda de quem? Object possui classe? E BasicObject? E qual a classe de Class? E quem é o Kernel? Bom, aqui estão as respostas, na forma de um diagrama (exceto pela resposta de quem é o Kernel):

Ruby Object Model - Expandido

Ruby Object Model – Expandido

Aqui observamos que a classe de toda classe é Class! Se isso é confuso, melhor pensarmos em termos de objetos. Se todo objeto pertence a uma classe, e todas as classes são na realidade objetos – instâncias de class – então faz sentido que todo e qualquer objeto que é uma classe seja uma instância de Class, inclusive Class. Por tal razão, observa-se no diagrama que todas as setas rosas com a pergunta #class convergem para Class.

Como tudo em Ruby é um objeto, observa-se também que todas as setas pretas #superclass levam a Object, e que Object é na realidade um tipo de BasicObject. Podemos perguntar para qualquer um dos objetos listados se eles são um Object, e a resposta é imediata. Então Class.is_a? Object , A.is_a? Object, Module.is_a? Object retornam true.

Observamos também que BasicObject não herda de ninguém. BasicObject foi criado na versão 1.9 de Ruby, e é uma versão minimalista de Object, atuando quase como um BlankSlate. BlankSlates são objetos extremamente simples, que definem em si apenas os métodos necessários para a sua existência, no caso, BasicObject define 8 métodos de instância, enquanto Object define 56. O seu uso é dado principalmente em Dynamic Proxing, onde atuam como a Proxy class. Se quiser saber mais sobre o Proxy Pattern, na Wikipédia existe uma explicação bem sucinta e fácil da ideia, é só clicar aqui e dar uma conferida! Mas voltando ao assunto, como os BlankSlates definem poucos métodos, minimizam a possibilidade de haver colisão entre os métodos de instância da classe e os métodos que serão invocados nas instância dessa classe e passados adiante para o(s) objeto(s) encapsulado(s) no Proxy, mas falaremos mais sobre Dynamic Proxing em outros tópicos.

Por fim, quem é o Kernel? O Kernel é um módulo de Ruby incluído diretamente em Object. Como vimos, já que tudo é um Object, todos os métodos definidos no Kernel são automaticamente incluídos em Object, e consequentemente, em todos os objetos, dessa forma, permite dar transparência a alguns métodos que queremos disponibilizar, como é o caso que citamos, da AwesomePrint. O método #ap pode ser utilizando em qualquer lugar, já que o propósito desse método é imprimir, com Syntax Highlight, demarcadores, quebras de linha, entre outros, objetos Ruby, o que é algo que queremos utilizar em todo o nosso código durante o processo de desenvolvimento, principalmente através de um terminal.

Bom, com isso finalizamos as noções gerais de como se relacionam os principais language constructs em Ruby. Vamos agora ver, então, como Ruby aborda essa relação quando invocamos um método.

O Method Lookup e a Ancestors Chain

Agora que entendemos um pouco mais a fundo como Ruby organiza suas construções da linguagem, queremos saber, quando eu invoco um método num objeto, como Ruby sabe aonde esse método se encontra e, em seguida, quais os passos necessários para a execução desse método. Se lembrarmos da postagem anterior, vimos que a classe C que criamos, e que não implementava nenhum método, herdava de uma classe B, que implementava um único método. Quando criamos uma instância de C, no entanto, podíamos livremente invocar nesse objeto o método que B implementava, mesmo ele não residindo em C. Vejamos então como funciona esse processo em detalhes.

Em toda linguagem orientada a objetos, quando invocamos um método, acionamos duas etapas: 1) A busca pelo método solicitado (method lookup) 2) A execução desse método (através da noção de self). Nos aprofundemos então na primeira etapa. Primeiro, precisamos definir algumas palavras chaves. Aqui chamaremos de receiver o objeto no qual o método foi invocado. Então, no caso "some text".capitalize, o receiver é a string “some text”. A segunda noção que precisamos ter chama-se ancestors chain. Ela descreve a relação de herança (por isso ancestors) de um objeto, ou seja, a cadeia (chain) de classes e módulos que deveríamos percorrer se ficarmos usando o método #superclass no objeto até chegar em BasicObject. Vejamos isso através de um exemplo:


module Y; end

class A; end

class B < A
  include Y
end

B.superclass           # => A
A.superclass           # => Object
Object.superclass      # => BasicObject
BasicObject.superclass # => nil

B.ancestors            # => [B, Y, A, Object, Kernel, BasicObject]

Nesse exemplo, criamos um módulo Y e duas classes A e B, sendo que B herda de A. Se chamarmos em B #superclass, temos como resposta A. Se para cada resposta, continuarmos chamando #superclass, começamos a formar a ancestors chain de B. Para visualizar a ancestors chain, podemos chamar o método #ancestors diretamente na classe. Observamos que na ancestors chain também estão contemplados os módulos Y e Kernel. Isso porque o conceito de #include em Ruby significa adicionar o módulo na ancestors chain, logo em seguida da Classe que o inclui. Então, no caso, B inclui Y e Object inclui Kernel, por isso, Y e Kernel aparecem logo depois de seus includers.

Com esses dois conceitos em mãos, podemos facilmente deduzir como Ruby encontra um método. Quando enviamos o método ao receiver, este prontamente procura o método em si mesmo, e não o encontrando, percorre a ancestors chain na qual a classe do objeto está inserida e pergunta se naquela posição ele está definido, até encontrar o método. Se esse método não existir, então Ruby lança um erro chamado NoMethodError. Esse conceito é, em geral, representado por um diagrama. Para o caso acima, o diagrama seria algo como (supondo que chamamos o método #say_hi em uma instância de B, por exemplo):

Um detalhe importante aqui é que, a partir do Ruby 2.0, não existe mais apenas o método #include para adicionar módulos. Podemos utilizar o método #prepend também, que adiciona o módulo antes do seu includer na ancestors chain. Ou seja, naquele mesmo exemplo, se criarmos um módulo Z, que é adicionado em B utilizando #prepend, teríamos a seguinte ancestors chain: [Z, B, Y, A, Object, Kernel, BasicObject].

Uma dúvida que deve surgir é em relação a múltiplas inclusões. O que acontece com a ancestors chain? Ruby faz o controle automático de seus includes e prepends, de modo que ele ignora elegantemente tentativas de inclusão de um mesmo módulo na mesma ancestors chain.

A execução de métodos e o conceito de self

Agora que sabemos como Ruby encontra os métodos, como é feita a execução do método? Primeiro, se pararmos pra pensar, a medida que subimos na ancestors chain procurando um método, como saberemos o contexto em que ele foi chamado? Eu posso ter um objeto de uma classe A, cujo método que eu chamei se encontre em Z, por exemplo. Para isso, Ruby mantém uma referência para o receiver quando invocamos o método, isso permite a ele retornar a execução desse método no contexto do receiver, e não no contexto de onde se encontra o método na ancestors chain.

Todo o código que é executado em Ruby pertence a um contexto específico chamado de current object, ele recebe esse nome porque todo código é executado dentro de um objeto. Para referenciarmos esse objeto quando dá execução, podemos usar a palavra reservada self, self aponta automaticamente para o current object na qual se encontra aquele código. Vejamos um exemplo:


module Y
  def create_instance_variable
    @some_inst_var = "something"
    self
  end

  def say_something
    puts 'hi'
  end
end

class A; end

class B < A
  include Y
end

obj = B.new
obj.say_something             # => prints 'hi' in console
obj.create_instance_variable  # => 
obj.instance_variables        # => [:@some_inst_var]

Nesse exemplo, usamos uma série de conceitos vistos até agora. Primeiramente, B herda de A e inclui o módulo Y, sabemos então, que a partir de B, a ancestors chain deve ser algo do tipo [B, Y, A, …, BasicObject]. Segundo, sabemos que quando invocarmos um método numa instância de B, obrigatoriamente Ruby subirá na ancestors chain, porque B em si não define nenhum método. Por fim, vemos que o method lookup funciona e encontra o método logo no primeiro módulo, Y, e retorna o valor executado. Vamos analisar então o que os dois métodos fizeram. Primeiro, o método #say_something foi encontrado em Y, e este não faz nada de especial, simplesmente imprime na tela ‘hi’. Já o segundo método, #create_instance_variable, faz duas coisas interessantes, primeiro, ele cria uma variável de instância chamada @some_inst_var, e depois retorna o contexto na qual ele foi executado. Como esperado, observa-se que o valor retornado por esse método é uma instância de B, na realidade, a instância na qual invocamos esse método. Isso porque, como vimos, quando chamamos um método, Ruby guarda uma referência para o receiver e executa esse método no contexto do current_object (self), que passa a ser o receiver. Não só isso, a variável de instância, sendo definido no contexto do current_object, que é o receiver, também vai pertencer ao receiver, ou seja, essa variável que foi criada existe em B, e não no módulo. Incrível como Ruby consegue facilmente gerenciar tudo isso, não?!

Mas talvez, agora que sabemos que todo o código é executado dentro de um objeto, possamos nos ver pensando, quem é o current object quando abrimos um terminal utilizando o IRB? A resposta é simples, como todo o código é executado dentro de um objeto, não haveria de ser diferente nesse caso, o código é executado num objeto chamado main, que nada tem a ver com o main de outras linguagens, a não ser pelo nome. Mas o que é o main? Main é uma instância da classe Object e é o objeto de nível mais alto quando se executa um programa em Ruby, por isso é chamado de top-level context (já que o contexto fundamental de self recai nesse objeto). Toda vez que executamos um programa em Ruby, ele é o primeiro objeto a existir. A partir do momento em que definirmos uma nova classe ou módulo, se chamarmos self fora de qualquer método, diretamente no corpo da classe / módulo, então self já é o contexto da classe / módulo em que estamos inseridos, a partir do momento em que chamamos self dentro de um método, ai o valor de self depende do receiver.

Para encerrarmos essa parte, vejamos o que significa a keyword private em Ruby, do ponto de vista do que sabemos. Bom, quando tentamos executar um método definido como private num objeto, não podemos chamá-lo diretamente. Essa tentativa retorna imediatamente um erro de NoMethodError com a mensagem, private method 'nome_do_metodo' called for 'minha_instancia'. Mas então, o que significa private? Um jeito de entender private em Ruby é pensar que os métodos private só podem ser chamados com o receiver implícito. Mas como assim, receiver implícito? Quando executamos um método, a execução se dá dentro do contexto do current object, que fica disponível através da palavra reservada self. Então, quando fazemos obj.some_method, o receiver está explícito, e nesse caso é obj. A diferença é que os métodos privados só podem ser executados utilizando o contexto que é inferido do current object, ou seja, dentro do contexto de outros métodos, utilizando o receiver implícito daquele contexto, o que impede que chamemos eles diretamente, já que eles não podem responder com um receiver explícito. Na realidade, dentro de uma classe, se tentarmos chamar um método privado explicitando que o contexto é self, algo do tipo self.call_my_private_method, recebemos um erro, porque a partir desse momento explicitamos que o receiver é self. Ou seja, eu não posso chamar um método de privado de um objeto estando em outro objeto, porque isso me forçaria a mudar o current_object para o outro objeto, o que não é possível. No entanto, eu consigo chamar um método private através de herança, já que os objetos com certeza estão na mesma ancestors chain, de uma maneira mais genérica, eu posso chamar dentro de qualquer objeto um método private, desde que eu não explicite o receiver e este método esteja definido em algum ponto da ancestors chain, independente do método ser público ou privado.

Conclusão

Bom, nesses dois artigos fomos um pouco mais fundo em como Ruby gerencia seus language constructs, como ele invoca métodos e como ele executa esses métodos. Vimos como o private funciona, quem é o top-level context de Ruby, onde os métodos são executados, e como incluir módulos. Vimos também que é o Kernel, e que a classe de todos os objetos inevitavelmente converge para Class. Vimos que o BasicObject atua praticamente como um BlankSlate, e retornaremos isso mais para frente. Por fim, vimos a ancestors chain e a palavra reservada self. Espero que esses dois artigos tenham sido úteis para compreender um pouco mais de como Ruby funciona. Nos próximos tópicos o objetivo será então se utilizar desse conhecimento para alterar a forma como as construções da linguagem se relacionam, adicionando etapas intermediárias, injetando, trocando e manipulando essas relações para começar a escrever código mais inteligente. Até o próximo artigo 🙂


Comentários

2
  • Pedro Bruno

    Pedro Bruno Pedro Bruno

    Responder Autor

    Ótimo material, ajuda demais a entender “por tras” da linguagem, espero que a próxima parte saia logo! Valeu

    Postado em

  • Igor Melão

    Igor Melão Igor Melão

    Responder Autor

    Nossa que legal esse sub-mundo! Assim como Pedro, espero que ainda dê tempo de sair a terceira parte e aprender mais!

    Postado em