En esta clase veremos otro ejercicio integrador con el mismo fin que la clase pasada. Para ello realizaremos el ejercicio de Multimethods.
Es un sistema que permite que una serie de métodos sean polimórficos en al menos uno o más de sus argumentos. Esta idea se puede ver en CLOS (Common Lisp Object System) y algunos lenguajes como Dylan. CLOS extiende a Common Lisp para agregar un sistema de objectos http://c2.com/cgi/wiki?TheArtOfTheMetaObjectProtocol extendiendo el lenguaje y para dar soporte a objetos.
En Ruby como en Python, ambos tienen sistemas de single dispatch, en el que la firma es esencialmente el nombre del método, sin importar en temas como la aridad, por lo que dos métodos como
def saraza(a)
….
end
y
def saraza(a, b, c)
….
end
no serán dos métodos sino que dependiendo de cómo estén declarados en el caso de Ruby, la segunda declaración pisa a la primera, en el caso de Python, en general sucede lo mismo en un intérprete o directamente nos lanzará un error en el código. Más allá de eso, nosotros queremos que saraza(a) y saraza(a, b) puedan coexistir y el método a ejecutar dependa de cuáles sean los parámetros recibidos. Por lo que deberemos chequear esto en tiempo de ejecución y dirigir el flujo al método con la firma adecuada. Esto se relaciona un poco con el concepto de polimorfismo del receptor, por ej. si tenemos algo como:
a.saraza(unParam)
a ejecutará un método u otro dependiendo de la clase a la que pertenece, queremos abrir la puerta a que también se decida en base al tipo de unParam. Vamos a hablar más sobre firmas de métodos y la búsqueda de la definición para un mensaje a partir de la misma en la segunda parte de la materia, cuando trabajemos con un lenguaje con tipado estático. De momento quédense con la idea de que lo que queremos implementar va a resolver qué definición usar en tiempo de ejecución, a partir del tipo de todos los objetos involucrados en el envío del mensaje, no sólo el receptor.
En este caso extenderemos a Ruby agregándole multimethods, de esta manera estamos un poco más del lado de intercession que es la capacidad de extender el lenguaje en el que estamos para agregarle más features interesantes (o locos?). Además como otros ejercicios usaremos reflection y self modification para llegar a resolver este problema.
Sobre el ejercicio
El enunciado del ejercicio esta acá
El código al que llegamos al final de la clase esta en este repo
En el primer punto implementamos algo del estilo
helloBlock = PartialBlock.new([String]) do |who|
"Hello #{who}"
end
helloBlock.matches("a") #true
helloBlock.matches(1) #false
helloBlock.matches("a", "b") #false
Acá lo que hicimos fue algo bastante simple que es crear la abstracción del PartialBlock que contenga los tipos de los parámetros esperados y el bloque que se espera poder ejecutar, y después le definimos el método matches devolviendo true o false dependiendo de si los tipos esperados coinciden con los de los argumentos que le pasamos al método. Este método debía definirse con varargs para poder recibir múltiples argumentos como se indicaba en el ejemplo de uso.
class PartialBlock
attr_accessor :block, :types
def initialize types, &block
self.types = types
self.block = block
end
def matches(*values)
unless values.length == types.length
return false
end
return true
end
end
Además para poder evaluarlos como se pide a continuación usando call le agregamos:
class PartialBlock
def call(*args)
raise ArgumentError unless self.matches(*args)
self.block.call(*args)
end
end
class A
partial_def :concat, [String, String] do |s1,s2|
s1 + s2
end
partial_def :concat, [String, Integer] do |s1,n|
s1 * n
end
partial_def :concat, [Array] do |a|
a.join
end
end
A.new.concat('hello', ' world') # devuelve 'helloworld'
A.new.concat('hello', 3) # devuelve 'hellohellohello'
A.new.concat(['hello', ' world', '!']) # devuelve 'hello world!'
A.new.concat('hello', 'world', '!') # Lanza una excepción!
Ahora debemos definir el comportamiento del partial_def, abriendo una clase del metamodelo, pero cual? Podría pensarse de hacerlo sobre Object, al hacerlo sobre esta clase funcionaría para cualquier clase, pero a su vez le estaría dando este comportamiento también a cualquier objeto, lo cual no tendría sentido, sólo lo querríamos para las clases. Una opción válida sería Class aunque si queremos usar partial_def en un módulo no podremos, por lo que la otra opción es hacerlo sobre Module y permitirle tanto a clases como a módulos el de poder utilizar partial_def.
Otra cosa que tuvimos que decidir fue cómo íbamos a representar a los multimethods y cómo almacenar la información de cada definición que se haga usando partial_def. Una primer idea fue tener un atributo que guardara un diccionario donde el selector del mensaje a definir fuera la clave y se le asociara una lista con los partial blocks. Otra alternativa, por la que decidimos ir, era reificar la idea de Multimethod, de esa forma el atributo que terminamos llamando @actual_multimethods tendría directamente una lista de instancias de Multimethod (una por cada selector) de modo que se pudiera delegar también a estos objetos en vez de mantener toda la lógica en Module.
Para poder mandarle el mensaje definido usando partial_def a las instancias de la clase también surgieron ideas distintas. Una de ellas era definir un método en la clase/módulo que recibió partial_def que se llame igual que el símbolo recibido por parámetro de modo que triggeree la búsqueda de la implementación correspondiente en base a los parámetros que reciba, la otra era redefinir method_missing de modo que obtenga el multimethod con el símbolo correspondiente al mensaje no entendido y luego triggeree esa misma búsqueda en base a los parámetros recibidos.
Fuimos por la primer alternativa porque no hay una verdadera necesidad de caer en el method_missing, después de todo ya sabemos de antemano cuál es el mensaje que tiene que poder entender, y en general vamos a optar por no usar method_missing en esos casos ya que es más complejo (tenemos que asegurarnos de mantener consistente la interfaz de reflection también, cosa que si definimos el método usando define_method se da solo).
Además definimos la lógica necesaria para poder responder a los mensajes multimethod y multimethods:
A.multimethods() #[:concat]
A.multimethod(:concat) #Representación del multimethod
… cuya implementación, al modelar al multimethod como un objeto, es trivial. Finalmente llegamos al siguiente código:
class Module
def partial_def(sym, types, &block)
partial_block = PartialBlock.new(types, &block)
multimethod = get_multimethod(sym)
multimethod.definitions << partial_block
self.send(:define_method, sym) do |*args|
multimethod.call(*args)
end
end
def actual_multimethods
@actual_multimethods ||= []
end
def multimethod(sym)
self.actual_multimethods.find { |mm| mm.selector.eql?(sym) }
end
def multimethods
self.actual_multimethods.map { |mm| mm.selector }
end
private
def has_multimethod?(multimethod)
self.actual_multimethods.include?(multimethod)
end
def get_multimethod(sym)
multimethod = self.multimethod(sym) || MultiMethod.new(sym)
actual_multimethods << multimethod unless has_multimethod?(multimethod)
multimethod
end
end
class MultiMethod
attr_accessor :selector, :definitions
def initialize(sym)
self.selector = sym
self.definitions = []
end
def call(*args)
definition = self.definitions
.select { |definition| definition.matches(*args) }
.min_by { |definition| definition.distance_to(*args) }
definition ? definition.call(*args) : raise(NoMethodError)
end
end
class PartialBlock
def distance_to(*args)
args.zip(types).each_with_index do |tuple, index|
case tuple[1]
when Array then
1 #because classroom-related reasons
else
tuple[0].class.ancestors.index(tuple[1]) * index
end
end
end
end
Cabe destacar que esa solución de partial_def fue posible gracias a que el bloque conoce el contexto en el cual fue creado, por eso no es necesario buscar el multimethod en la lista.
A su vez, se pide extender la interfaz de reflection con métodos que indiquen si un objeto responde a un determinado mensaje con cierta firma:
A.new.respond_to?(:concat) # true, define el método como multimethod
A.new.respond_to?(:to_s) # true, define el método normalmente
A.new.respond_to?(:concat, false, [String,String]) # true, los tipos coinciden
A.new.respond_to?(:concat, false, [Integer,A]) # true, matchea con [Object, Object]
A.new.respond_to?(:to_s, false, [String]) # false, no es un multimethod
A.new.respond_to?(:concat, false, [String,String,String]) # false, los tipos no coinciden
Entonces también debemos redefinir el respond_to?. Hay que tener siempre cuidado con este tipo de extensiones ya que debemos estar atentos de no modificar el comportamiento para aquellos métodos que no fueron definidos por medio de un partial_def. Las opciones propuestas fueron: Definir respond_to? usando partial_def, de modo que que la definición original de respond_to? (la cual deberíamos asegurarnos de no perder mediante un alias o pidiendo el unbound method y guardándolo en una variable para poder invocarlo más adelante) se use si matchea con los tipos [Symbol] o [Symbol, Object], y una tercer definición para [Symbol, Object, Array] que haga lo que nosotros queremos. Redefinir respond_to? como un método normal con un if, de modo que si nos pasan el tercer parámetro, se use la definición para multimethods y sino la original.
Tratamos de ir por la primera porque era más divertida, pero lamentablemente no funcionó por un loop infinito (respond_to? se usa en el core del method lookup, no fue por un error de la solución en sí, simplemente justo con en respond_to? no se puede, se las dejamos comentada de todos modos). Luego fuimos por la otra alternativa:
class Object
def respond_to?(sym, include_private = false, signature = nil)
signature.nil? ? super(sym, include_private) : self.class.actual_multimethods
.any? { |mm| mm.matches?(sym, signature) }
end
=begin
partial_def :respond_to?, [Symbol] do |sym|
self.old_respond_to?(sym)
end
partial_def :respond_to?, [Symbol, Object] do |sym, bool|
self.old_respond_to?(sym, bool)
end
partial_def :respond_to?, [Symbol, Object, Array] do |sym, bool, types|
false unless self.class.multimethods.include?(sym)
multimethod = self.class.multimethod(sym)
multimethod.matches_signature?(types)
end
=end
end
Para saber si existe alguna definición para el multimethod cuya firma matchee con la lista de tipos recibida refactorizamos un poco PartialBlock para evitar la repetición de lógica.
class Multimethod
def matches?(sym, types)
self.selector.eql?(sym) && self.definitions
.any? { |definition|definition.matches_signature?(types)}
end
end
class PartialBlock
def matches(*args)
arg_types = args.map { |arg| arg.class }
matches_signature?(arg_types)
end
def matches_signature?(signature)
return false unless signature.size.eql?(self.types.size)
self.types.zip(signature).all? do |my_type, sign_type| sign_type <= my_type end
end
end
Algo que quedó en el tintero para poder ir al siguiente punto es la aclaración de que se pueda usar self dentro de la definición de un método declarado mediante partial_def. Con el código actual eso no funcionará como querríamos, si te animás, c
class Duck:
def quack(self):
print("Quaaaaaack!")
def feathers(self):
print("The duck has white and gray feathers.")
class Person:
def quack(self):
print("The person imitates a duck.")
def feathers(self):
print("The person takes a feather from the ground and shows it.")
def name(self):
print("John Smith")
def in_the_forest(duck):
duck.quack()
duck.feathers()
def game():
donald = Duck()
john = Person()
in_the_forest(donald)
in_the_forest(john)
game()
Ahora volviendo al ejercicio, vamos a definir duck typing de la siguiente manera
class B
partial_def :concat, [String, [:m, :n], Integer] do |o1, o2, o3|
'Objetos Concatenados'
end
end
Si el argumento que debería ser un tipo es un array de símbolos, representando el nombre de los selectores que debería entender, entonces se debe aplicar duck typing. Para implementar este agregado entonces debemos extender el matches del partial block de la siguiente manera.
class PartialBlock
def matches_signature?(signature)
return false unless signature.size.eql?(self.types.size)
self.types.zip(signature).all? do |my_type, sign_type|
case my_type
when Array then
my_type.all? { |method| sign_type.instance_methods.include?(method) }
else
sign_type <= my_type
end
end
end
end
A esta altura debería volverse más evidente que el refactor que hicimos en el punto anterior para no repetir lógica era muy importante. Si no lo hacíamos antes, lo íbamos a tener que hacer ahora.
Algunas alternativas que no hubieran estado buenas para resolver este ejercicio: Definir todo en términos de duck typing obteniendo todos los mensajes que definen las clases correspondientes. Eso rompería la funcionalidad porque el tipo en base a los mensajes que entiene puede abarcar objetos que no están en la jerarquía que se pedía inicialmente. Resolver el if/switch con polimorfismo abriendo Array y Module. Esto es algo muy particular de nuestro framework, y ensuciar más la interfaz de Array y Module para evitar ese if no es una buena idea.