Heredar métodos de clase de módulos / mixins en Ruby

95

Se sabe que en Ruby, los métodos de clase se heredan:

class P
  def self.mm; puts 'abc' end
end
class Q < P; end
Q.mm # works

Sin embargo, me sorprende que no funcione con mixins:

module M
  def self.mm; puts 'mixin' end
end
class N; include M end
M.mm # works
N.mm # does not work!

Sé que el método #extend puede hacer esto:

module X; def mm; puts 'extender' end end
Y = Class.new.extend X
X.mm # works

Pero estoy escribiendo un mixin (o, más bien, me gustaría escribir) que contiene métodos de instancia y métodos de clase:

module Common
  def self.class_method; puts "class method here" end
  def instance_method; puts "instance method here" end
end

Ahora lo que me gustaría hacer es esto:

class A; include Common
  # custom part for A
end
class B; include Common
  # custom part for B
end

Quiero que A, B herede los métodos de instancia y clase del Commonmódulo. Pero, por supuesto, eso no funciona. Entonces, ¿no hay una forma secreta de hacer que esta herencia funcione desde un solo módulo?

Me parece poco elegante dividir esto en dos módulos diferentes, uno para incluir y el otro para ampliar. Otra posible solución sería utilizar una clase en Commonlugar de un módulo. Pero esto es solo una solución. (¿Qué pasa si hay dos conjuntos de funcionalidades comunes Common1y Common2realmente necesitamos tener mixins?) ¿Existe alguna razón profunda por la que la herencia del método de clase no funcione a partir de mixins?

Boris Stitnicky
fuente
1
Con la distinción de que aquí sé que es posible, estoy preguntando por la forma menos fea de hacerlo y por las razones por las que la elección ingenua no funciona.
Boris Stitnicky
1
Con más experiencia, entendí que Ruby iría demasiado lejos adivinando la intención del programador si incluir un módulo también agregaba los métodos del módulo a la clase singleton del includer. Esto se debe a que los "métodos de módulo" no son más que métodos singleton. Los módulos no son especiales por tener métodos singleton, son especiales por ser espacios de nombres donde se definen métodos y constantes. El espacio de nombres no tiene ninguna relación con los métodos singleton de un módulo, por lo que en realidad la herencia de clases de los métodos singleton es más asombrosa que la falta de ella en los módulos.
Boris Stitnicky

Respuestas:

171

Un modismo común es usar includedmétodos de clase de enganche e inyección desde allí.

module Foo
  def self.included base
    base.send :include, InstanceMethods
    base.extend ClassMethods
  end

  module InstanceMethods
    def bar1
      'bar1'
    end
  end

  module ClassMethods
    def bar2
      'bar2'
    end
  end
end

class Test
  include Foo
end

Test.new.bar1 # => "bar1"
Test.bar2 # => "bar2"
Sergio Tulentsev
fuente
26
includeagrega métodos de instancia, extendagrega métodos de clase. Así es como funciona. No veo inconsistencias, solo expectativas no
cumplidas
1
Poco a poco estoy soportando el hecho de que su sugerencia es tan elegante como la solución práctica de este problema. Pero agradecería saber la razón por la que algo que funciona con clases no funciona con módulos.
Boris Stitnicky
6
@BorisStitnicky Confía en esta respuesta. Este es un modismo muy común en Ruby, que resuelve precisamente el caso de uso sobre el que preguntas y precisamente por las razones que experimentaste. Puede parecer "poco elegante", pero es su mejor opción. (Si hace esto con frecuencia, puede mover la includeddefinición del método a otro módulo e incluir ESO en su módulo principal;)
Phrogz
2
Lea este hilo para obtener más información sobre el "¿por qué?" .
Phrogz
2
@werkshy: incluye el módulo en una clase ficticia.
Sergio Tulentsev
47

Aquí está la historia completa, explicando los conceptos de metaprogramación necesarios para comprender por qué la inclusión de módulos funciona de la manera en que lo hace en Ruby.

¿Qué sucede cuando se incluye un módulo?

La inclusión de un módulo en una clase agrega el módulo a los antepasados de la clase. Puede ver los antepasados ​​de cualquier clase o módulo llamando a su ancestorsmétodo:

module M
  def foo; "foo"; end
end

class C
  include M

  def bar; "bar"; end
end

C.ancestors
#=> [C, M, Object, Kernel, BasicObject]
#       ^ look, it's right here!

Cuando llamas a un método en una instancia de C, Ruby buscará en cada elemento de esta lista de ancestros para encontrar un método de instancia con el nombre proporcionado. Dado que incluimos Men C, Mahora es un antepasado de C, por lo que cuando invocamos foouna instancia de C, Ruby encontrará ese método en M:

C.new.foo
#=> "foo"

Tenga en cuenta que la inclusión no copia ningún método de instancia o clase a la clase ; simplemente agrega una "nota" a la clase de que también debe buscar métodos de instancia en el módulo incluido.

¿Qué pasa con los métodos de "clase" en nuestro módulo?

Debido a que la inclusión solo cambia la forma en que se distribuyen los métodos de instancia, incluir un módulo en una clase solo hace que sus métodos de instancia estén disponibles en esa clase. Los métodos de "clase" y otras declaraciones del módulo no se copian automáticamente en la clase:

module M
  def instance_method
    "foo"
  end

  def self.class_method
    "bar"
  end
end

class C
  include M
end

M.class_method
#=> "bar"

C.new.instance_method
#=> "foo"

C.class_method
#=> NoMethodError: undefined method `class_method' for C:Class

¿Cómo implementa Ruby los métodos de clase?

En Ruby, las clases y los módulos son objetos simples: son instancias de la clase Classy Module. Esto significa que puede crear dinámicamente nuevas clases, asignarlas a variables, etc .:

klass = Class.new do
  def foo
    "foo"
  end
end
#=> #<Class:0x2b613d0>

klass.new.foo
#=> "foo"

También en Ruby, tiene la posibilidad de definir los llamados métodos singleton en objetos. Estos métodos se agregan como nuevos métodos de instancia a la clase singleton oculta especial del objeto:

obj = Object.new

# define singleton method
def obj.foo
  "foo"
end

# here is our singleton method, on the singleton class of `obj`:
obj.singleton_class.instance_methods(false)
#=> [:foo]

¿Pero no son las clases y los módulos simplemente objetos simples también? ¡De hecho lo son! ¿Eso significa que también pueden tener métodos singleton? ¡Sí, lo hace! Y así es como nacen los métodos de clase:

class Abc
end

# define singleton method
def Abc.foo
  "foo"
end

Abc.singleton_class.instance_methods(false)
#=> [:foo]

O bien, la forma más común de definir un método de clase es utilizarlo selfdentro del bloque de definición de clase, que se refiere al objeto de clase que se está creando:

class Abc
  def self.foo
    "foo"
  end
end

Abc.singleton_class.instance_methods(false)
#=> [:foo]

¿Cómo incluyo los métodos de clase en un módulo?

Como acabamos de establecer, los métodos de clase son en realidad solo métodos de instancia en la clase singleton del objeto de clase. ¿Significa esto que podemos incluir un módulo en la clase singleton para agregar un montón de métodos de clase? ¡Sí, lo hace!

module M
  def new_instance_method; "hi"; end

  module ClassMethods
    def new_class_method; "hello"; end
  end
end

class HostKlass
  include M
  self.singleton_class.include M::ClassMethods
end

HostKlass.new_class_method
#=> "hello"

Esta self.singleton_class.include M::ClassMethodslínea no se ve muy bien, así que Ruby agregó Object#extend, que hace lo mismo, es decir, incluye un módulo en la clase singleton del objeto:

class HostKlass
  include M
  extend M::ClassMethods
end

HostKlass.singleton_class.included_modules
#=> [M::ClassMethods, Kernel]
#    ^ there it is!

Mover la extendllamada al módulo

Este ejemplo anterior no es un código bien estructurado, por dos razones:

  1. Ahora tenemos que llamar a ambos include y extenden la HostClassdefinición para que nuestro módulo se incluya correctamente. Esto puede resultar muy engorroso si tiene que incluir muchos módulos similares.
  2. HostClassreferencias directas M::ClassMethods, que es un detalle de implementación del módulo Mque HostClassno debería ser necesario conocer o preocupar.

Entonces, ¿qué tal esto? Cuando llamamos includeen la primera línea, de alguna manera notificamos al módulo que se ha incluido, y también le damos nuestro objeto de clase, para que pueda llamarse a extendsí mismo. De esta manera, es el trabajo del módulo agregar los métodos de clase si así lo desea.

Para eso es exactamente el método especialself.included . Ruby llama automáticamente a este método siempre que el módulo se incluye en otra clase (o módulo) y pasa el objeto de la clase de host como primer argumento:

module M
  def new_instance_method; "hi"; end

  def self.included(base)  # `base` is `HostClass` in our case
    base.extend ClassMethods
  end

  module ClassMethods
    def new_class_method; "hello"; end
  end
end

class HostKlass
  include M

  def self.existing_class_method; "cool"; end
end

HostKlass.singleton_class.included_modules
#=> [M::ClassMethods, Kernel]
#    ^ still there!

Por supuesto, agregar métodos de clase no es lo único que podemos hacer self.included. Tenemos el objeto de clase, por lo que podemos llamar a cualquier otro método (clase) sobre él:

def self.included(base)  # `base` is `HostClass` in our case
  base.existing_class_method
  #=> "cool"
end
Máté Solymosi
fuente
2
¡Maravillosa respuesta! Finalmente pude entender el concepto después de un día de lucha. Gracias.
Sankalp
1
Creo que esta podría ser la mejor respuesta escrita que he visto en SO. Gracias por la increíble claridad y por ampliar mi comprensión de Ruby. ¡Si pudiera regalar esto un bono de 100 puntos, lo haría!
Peter Nixey
7

Como mencionó Sergio en los comentarios, para los chicos que ya están en Rails (o que no les importa dependiendo de Active Support ), Concernes útil aquí:

require 'active_support/concern'

module Common
  extend ActiveSupport::Concern

  def instance_method
    puts "instance method here"
  end

  class_methods do
    def class_method
      puts "class method here"
    end
  end
end

class A
  include Common
end
Franklin Yu
fuente
3

Puedes tener tu pastel y comértelo también haciendo esto:

module M
  def self.included(base)
    base.class_eval do # do anything you would do at class level
      def self.doit #class method
        @@fred = "Flintstone"
        "class method doit called"
      end # class method define
      def doit(str) #instance method
        @@common_var = "all instances"
        @instance_var = str
        "instance method doit called"
      end
      def get_them
        [@@common_var,@instance_var,@@fred]
      end
    end # class_eval
  end # included
end # module

class F; end
F.include M

F.doit  # >> "class method doit called"
a = F.new
b = F.new
a.doit("Yo") # "instance method doit called"
b.doit("Ho") # "instance method doit called"
a.get_them # >> ["all instances", "Yo", "Flintstone"]
b.get_them # >> ["all instances", "Ho", "Flintstone"]

Si tiene la intención de agregar variables de instancia y de clase, terminará tirando de su cabello ya que se encontrará con un montón de código roto a menos que lo haga de esta manera.

Bryan Colvin
fuente
Hay algunas cosas extrañas que no funcionan cuando se pasa class_eval un bloque, como definir constantes, definir clases anidadas y usar variables de clase fuera de los métodos. Para respaldar estas cosas, puede darle a class_eval un heredoc (cadena) en lugar de un bloque: base.class_eval << - 'END'
Paul Donohue