Para me aprofundar no estudo de Ruby e dar uma folga ao Rails, resolvi criar uma gem bem simples. Nessa atividade me deparei com vários aprendizados, pois querendo ou não o Rails toma muitas decisões por você, além de sobrescrever vários comportamentos padrões do Ruby (como o autoloading de código).
Entre essas dificuldades, posso destacar uma que tem tudo a ver com interfaces e com Ruby: eu não sabia como era estruturada uma gem e nem como organizar meu código no geral. Quando usar classes, quando usar módulos, quais nomes de variáveis, módulos, namespaces e classes utilizar.
Acredito que para quem está começando essas dificuldades são extremamente comuns (como foram para mim) e se relacionam muito com a essência da própria Engenharia de Software, sendo citadas em quase todos os conteúdos sobre o assunto.
Nesse post, como sempre, não pretendo ditar como e quando cada ferramenta deve ser utilizada, pois não me sinto capacitado para isso, além de não existir bala de prata que resolva todas situações; cada caso é um caso!
Logo, podemos começar a estudar quais ferramentas o Ruby oferece para a organização do código de maneira geral.
- Métodos especiais
- Assinaturas de métodos
- Módulos, Classes e Constantes (esse post)
- Duck typing (em breve…)
- Blocos (em breve…)
Módulos
Definidos pela documentação simplesmente como “coleções de métodos e constantes”, os módulos servem para agrupar código, que também segundo a documentação, pode servir de namespace ou como mixin (que falaremos mais adiante).
# exemplo de módulo
module Sounds
DOG = 'bark'
def self.play(sound)
puts sound
end
def cat
'purrrrrrr'
end
end
Módulos como namespace
Namespaces são contextos feitos para aumentar o nível de organização e evitar conflitos de nomes, de forma semelhante a pastas e arquivos de computador:
X = 42
module Different
X = 1
end
X
# => 42
Different::X
# => 1
Outro detalhe é que os métodos definidos com self
são considerados métodos de classe (ou de módulo) e podem ser chamados diretamente:
# módulos podem ser "reabertos"
# para extender suas funcionalidades
module Different
def self.sum_x(other)
X + other
end
def self.times_x(other)
X * other
end
end
# lembre do valor de X
# do exemplo passado
Different.sum_x(4)
# => 5
Different.times_x(4)
# => 4
Módulos também podem ser aninhados:
module A
module B
module C
D = 'nested'
def self.hi
puts 'hi from C'
end
end
end
end
A::B::C::D
# => "nested"
A::B::C.hi
# => "hi from C"
Mixins
Apesar da linguagem Ruby não apresentar herança múltipla, é possível extender classes para além de sua herança com essa funcionalidade, em que os todos os métodos de instância e constantes de um módulo incluído são incluídas em uma classe:
module Playable
SUPPORTED_FORMATS = %w[mp3 flac aac]
def play
sound
end
end
class Dog
include Playable
def sound
'bark'
end
end
dog = Dog.new
dog.play
# => "bark"
Dog::SUPPORTED_FORMATS
# => ["mp3", "flac", "aac"]
Nesse exemplo, quando o mixin Playable
é incluído em Dog
, os métodos de instância e constantes do módulo (no caso play
e SUPPORTED_FORMATS
) são incluídos na classe Dog
e podem ser usadas como no exemplo acima.
Note que acabamos de criar mais um tipo de interface, pois o módulo
Playable
espera que a classe em que foi incluída defina o métodosound
. Se esse método não for definido,play
falhará.
A própria biblioteca padrão do Ruby disponibiliza dois mixins extramemente úteis, o Enumerable e o Comparable, que definem vários métodos, porém dependem da implementação dos métodos #each
e #<=>
respectivamente para funcionar.
Classes
Apesar de já termos usado classes nos exemplos anteriores, sempre é bom começarmos do básico.
Classes são o molde para a criação de objetos e definem propriedades e comportamentos. Porém, em Ruby, classes também são objetos (da classe Class
) e tem como superclasse a classe Module
, que é a classe utilizada como molde para criar os módulos que estudamos acima. E sim, isso é extremamante confuso 🤔
class Animal
end
Animal.class
# => Class
Class.superclass
# => Module
Module.class
# => Class
Porém, o intuito é mostrar que as classes em Ruby são superclasse, ou seja, herdam de Module
. Isso significa que todos os métodos de instância de Module podem ser usados em classes. Um exemplo disso são os métodos attr_accessor
, include
, private
, entre outros.
Note que apesar disso, classes não podem ser incluídas como mixins.
Da mesma maneira que módulos, classes podem ser namespace:
class Mammals
class Dog
attr_reader :name
def initialize(name:)
@name = name
end
end
class Cat
attr_reader :name
def initialize(name:)
@name = name
end
end
attr_reader :collection
def initialize(collection)
@collection = collection
end
end
bud = Mammals::Dog.new name: 'Bud'
# => <Mammals::Dog @name="Bud">
meowth = Mammals::Cat.new name: 'Meowth'
# => <Mammals::Cat @name="Meowth">
my_animals = Mammals.new([bud, meowth])
# => <Mammals @collection=[<Mammals::Dog @name="Bud">, <Mammals::Cat @name="Meowth">]>
Também podemos misturar classes e módulos sem nenhum problema:
class A
module B
class C
def initialize
'hi'
end
end
end
end
A::B::C.new
# => "hi"
Outra diferença entre classes e módulos é que a classe também herda métodos e constantes de sua superclasse:
# exemplo tirado da
# documentação oficial.
class A
Z = 1
def z
Z
end
end
class B < A
end
b = B.new
b.z
# => 1
Além disso, outros detalhes da Orientação a Objetos também se aplicam, como a sobrescrita de métodos e a utilização da palavra reservado super
.
Constantes
Apesar de não parecer muito relacionado com esses dois conceitos que acabamos de ver, entender constantes em Ruby é essencial para utilizar classes e módulos de maneira efetiva, além de nos ensinar um pouco sobre o funcionamento interno da linguagem.
Uma das justificativas para esse ponto é que classes e módulos não são nada mais que instâncias de Class
e Module
atribuidas a constantes, veja:
class A
end
# é exatamente a
# mesma coisa de:
A = Class.new
# ou
module B
end
# ==
B = Module.new
Nas duas formas a propriedade #name
é atribuída com o nome da constante com que foi associada e nunca muda:
String.name
# => "String"
# porém
MyString = String
MyString.name
# => "String"
Outra coisa para lembrarmos é que constantes são identificadores que não podem ser reatribuídos, porém podem ser modificados.
NUMBERS = [1, 2, 3, 4]
NUMBERS.delete_at(0)
NUMBERS
# => [2, 3, 4]
# constantes podem guardar
# qualquer tipo de objeto.
LAMBDA = ->x,y {x + y}
# => <Proc (lambda)>
STRING = 'olá'
# => "olá"
Por último, vale lembrar que as constantes são armazenadas e pertencem a módulos (lembrando que classes também são módulos!)
Contexto e resolução de constantes
Como virmos anteriormente, módulos e classes alteram o contexto de execução ao definirem namespaces, logo é natural que passemos a nos preocupar com quais constantes estão acessíveis no contexto atual e quais não estão, pois não queremos introduzir erros e bugs nas nossas aplicações.
Os exemplos a seguir mostram como apesar de simples, podemos facilmente levar erros na cara ao lidar com constantes:
Z = 1
module A
Z = 100
module B
p Z # escreve 100
end
end
# --
module C
X = 3
module D
p X # escreve 3
end
end
# porém
module C::D
p X # dá erro
end
Nesting
Sendo mais ou menos o que chamamos de contexto, Module.nesting
é um método que nos informa em qual contexto estamos e portanto, quais constantes estão disponíveis.
module A
Z = 20
module B
Module.nesting # => [A::B, A]
p Z # => 20
end
end
module A::B
Module.nesting # => [A::B]
p Z # dá erro pois A não
end # está no Module.nesting
Com isso podemos concluir que apesar de estarmos em um contexto bastante aninhado, ainda assim podemos acessar as constantes de outros contextos, desde que o aninhamento esteja explícito:
module A
X = 19
module B
module C
module D
end
end
end
end
module A::B
module C::D
Module.nesting
# => [A::B::C::D, A::B]
p X # dá erro, pois A
end # não está no nesting
end
module A
module B::C::D
Module.nesting
# => [A::B::C::D, A]
p X # => 19
end
end
Vemos que não precisamos abrir todos os módulos, um de cada vez, para não levarmos erros, só precisamos abrir o módulo em que a constante está definida.
Constantes relativas
São constantes chamadas sem nenhum prefixo, como as do exemplo anterior.
Sua resolução, como vimos (em parte) se dá da seguinte maneira:
- Procura-se a constante nos elementos de
Module.nesting
; - Procura-se a constante nas sua superclasse e outros ancestrais (
self.ancestors
); - Se não encontrada em nenhum dos passos, chama-se o método
const_missing
(similar aomethod_missing
).
Logo, podemos ver como a herança e o contexto dão acesso às constantes.
Constantes qualificadas
São aquelas que tem uma constante relativa na frente, como A::X
do exemplo anterior, em que A
é uma referência relativa e X
uma constante qualificada.
Sua resolução inclui:
- Procurar
A
(referência relativa) como no algoritmo anterior; - Procurar
X
(referência qualificada) nos ancestrais deA
(referência relativa); - Se não encontrada, chamar o método
const_missing
deA
.
Exemplo (considerando o exemplo anterior):
Z = 0
module E
Z = 1
module F
Z = 2
Z # => 2
E::Z # => 1
end
end
Contexto global e referências absolutas
No exemplo anterior para referenciar Z
do módulo F
, utilizamos uma referência relativa, para referênciar Z
do módulo E
, usamos E::Z
. Porém, e se quiséssemos acessar a constante Z
definida no escopo global?
Para isso, podemos utilizar ::
antes do nome da constante para acessar o contexto principal de maneira absoluta. Logo, reabrindo os módulos do exemplo anterior:
module E
module F
Z # => 2
E::Z # => 1
::Z # => 0
end
end
Exemplos e casos de uso
Como já vimos bastante coisa até agora, vamos ver alguns exemplos práticos.
Funções utilitárias
É bem provável que você já tenha se deparado com blocos de código que não dependem do estado ou da instância de nenhum objeto, mesmo assim se encontram em classes. Isso pode ser considerado um code smell, pois se o código não depende do objeto, não há porque seu uso depender de um instanciamento. Códigos como:
class Calculator
def sum(a, b)
a + b
end
def mult(a, b)
a * b
end
end
calc = Calculator.new
calc.sum(1, 3)
# => 4
São candidatos fortes para a refatoração, porém, como podemos fazer isso com os recursos que acabamos de aprender?
Como não precisamos de instâncias, podemos utilizar um módulo e métodos de classe:
module Calculator
class << self
def sum(a, b)
a + b
end
def mult(a, b)
a * b
end
end
end
Calculator.sum(1, 2)
# => 3
# Outra opção:
module Calculator
Sum = ->x,y {x + y}
Mult = ->x,y { x * y }
end
Calculator::Sum[1, 3]
# => 4
# ou
Calculator::Mult.call(1, 3)
# => 3
# ou
Calculator::Mult.(4, 5)
# => 20
Desse jeito, outros contextos podem reutilizar nossas definições, muito melhor 😊
Classes como namespaces
Em quais situações é válido usar classes como namespaces? Aproveitando código da postagem passada, podemos ver um exemplo da própria biblioteca padrão do Ruby: a classe Net::HTTP
require 'net/http'
HTTP_METHODS = {
get: Net::HTTP::Get,
post: Net::HTTP::Post
}.freeze
def request(method:, url:)
uri = URI(url)
http_method = HTTP_METHODS.fetch(method)
request = http_method.new(uri)
connection = Net::HTTP.new(uri.host, uri.port)
connection.use_ssl = true if uri.scheme == 'https'
connection.request(request).read_body
end
Nesse código é possível observar que para realizar a requisição, nós tivemos que instanciar dois objetos, Net::HTTP::Get
, Net::HTTP::Post
(dependendo do valor da variável http_method
) e Net::HTTP
.
Em objetos da classe Net::HTTP::(Get|Post)
são guardadas informações da requisição, como corpo, cabeçalhos e o caminho da requisição. Já objetos da classe Net::HTTP
estão relacionados com a própria conexão TCP e podem ser usados para controlar quando a conexão é fechada (útil para fazer várias requisições de uma vez com a mesma conexão).
Utilizando Mixins e misturando tudo
Nesse exemplo, vamos criar pontos no espaço cartesiano e uma coleção de pontos, que irão ser comparados pelo seu módulo (comprimento). Para isso utilizaremos os dois mixins oferecidos pela biblioteca padrão do Ruby.
class Points
include Enumerable
class Point
include Comparable
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
# sobrescreve a conversão explícita
# para array com *. Exemplos serão
# apresentados.
def to_a
[x, y]
end
# definição obrigatória para os métodos de
# Comparable funcionarem
def <=>(other)
Module[x, y] <=> Module[other.x, other.y]
end
end
# Módulo do vetor
Module = ->x,y { Math.sqrt(x**2 + y**2) }
attr_reader :collection
# mesmo código da postagem
# passada para facilitar
# o instanciamento
def self.[](*args)
new(args)
end
def initialize(collection)
@collection = collection
end
# definição obrigatória para os
# métodos de Enumerable funcionarem
def each(&block)
@collection.each(&block)
end
end
Para que o exemplo ficasse mais completo, foram utilizados alguns recursos que já foram vistos e outros que ainda serão discutidos.
A seguir vamos ver algumas explicações.
Para implementar sua própria função de comparação (<=>
), necessária para o módulo Comparable
, é necessário se atentar ao seu comportamento esperado:
# se a > b
a <=> b
# => 1
# se a < b
a <=> b
# => -1
# se a == b
a <=> b
# => 0
Porém, nesse exemplo, aplicamos a função Points::Module
e delegamos o resultado dessa operação para o método <=>
da classe Float
.
Já o módulo Enumerable
é pensado para objetos que implementam a noção de algum tipo de coleção, como nossa classe Points
. A especificação que nós precisamos atender para usar esse módulo é a implementação de um método each
que dê yield em todos os elementos de sua coleção sucessivamente, um exemplo de código com essa funcionalidade é o seguinte:
def each
while next_item
yield next_item
end
end
def next_item
# resgata o próximo item...
end
Porém, no nosso exemplo, também delegamos o método each
, dessa vez para @collection
, que é instância de Array
.
Dadas essas explicações, podemos testar:
p1 = Points::Point.new(3, 4)
p2 = Points::Point.new(5, 12)
p3 = Points::Point.new(8, 15)
# alguns metodos adicionados
# pelo Comparable:
p1 > p2 # => false
p1 < p2 # => true
# será que é verdade?
Point::Module[*p1]
# => 5.0
Point::Module[*p2]
# => 13.0
collection = Points[p1, p2, p3]
# => <Points @collection=[#<Points::Point @x=3, @y=4>, ...]>
# podemos testar os métodos de
# Enumerable:
collection.any? {|p| p.x == 10 }
# => false
collection.filter { |p| Points::Module[*p] > 13.0 }
# => [<Points::Point @x=8, @y=15>]
Como implementamos tanto Enumerable
na coleção, quanto Comparable
no item, podemos também usar os métodos de ordenação de Enumarable
:
collection.max
# => <Points::Point @x=8, @y=15>
collection.min
# => <Points::Point @x=3, @y=4>
Referências
Apesar dessa postagem ser exaustiva, vários detalhes ficaram de fora. Por isso, recomendo a leitura do material em que me baseei para escrever essa postagem:
- Constants Refresher
- Modules and Classes
- Documentação de Module
- Lista de métodos de Enumerable
- Lista de métodos de Comparable
Agradecimentos
Gostaria de agradecer mais uma vez ao @serradura que me incentivou muito a escrever sobre isso e por sempre me ajudar com sugestões e dicas para a minha gem. Obrigado!
Qualquer dúvida, erro, feedback ou sugestão são bem vindos nos comentários 😁😁😁