Hériter des méthodes de classe des modules / mixins dans Ruby

95

On sait que dans Ruby, les méthodes de classe sont héritées:

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

Cependant, je suis surpris que cela ne fonctionne pas avec les mixins:

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

Je sais que la méthode #extend peut faire ceci:

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

Mais j'écris un mixin (ou, plutôt, j'aimerais écrire) contenant à la fois des méthodes d'instance et des méthodes de classe:

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

Maintenant, ce que je voudrais faire est ceci:

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

Je veux que A, B héritent à la fois des méthodes d'instance et de classe du Commonmodule. Mais, bien sûr, cela ne fonctionne pas. Alors, n'y a-t-il pas un moyen secret de faire fonctionner cet héritage à partir d'un seul module?

Il me semble peu élégant de diviser cela en deux modules différents, l'un à inclure, l'autre à étendre. Une autre solution possible serait d'utiliser une classe Commonau lieu d'un module. Mais ce n'est qu'une solution de contournement. (Et s'il y a deux ensembles de fonctionnalités communes Common1et Common2que nous avons vraiment besoin de mixins?) Y a-t-il une raison profonde pour laquelle l'héritage de méthode de classe ne fonctionne pas à partir de mixins?

Boris Stitnicky
la source
1
Avec la distinction, qu'ici, je sais que c'est possible - je demande la manière la moins laide de le faire et les raisons pour lesquelles le choix naïf ne fonctionne pas.
Boris Stitnicky
1
Avec plus d'expérience, j'ai compris que Ruby irait trop loin en devinant l'intention du programmeur si l'inclusion d'un module ajoutait également les méthodes de module à la classe singleton de l'inclus. Ceci est dû au fait que les "méthodes de module" ne sont en fait que des méthodes singleton. Les modules ne sont pas spéciaux pour avoir des méthodes singleton, ils sont spéciaux pour être des espaces de noms où des méthodes et des constantes sont définies. L'espace de noms n'a aucun rapport avec les méthodes singleton d'un module, donc en réalité l'héritage de classe des méthodes singleton est plus étonnant que son absence dans les modules.
Boris Stitnicky

Réponses:

171

Un idiome courant est d'utiliser includeddes méthodes de hook et d'injecter des classes à partir de là.

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
la source
26
includeajoute des méthodes d'instance, extendajoute des méthodes de classe. Voilà comment cela fonctionne. Je ne vois pas d'incohérence, seulement des attentes non satisfaites :)
Sergio Tulentsev
1
J'accepte lentement le fait que votre suggestion est aussi élégante que la solution pratique de ce problème peut l'être. Mais j'apprécierais de connaître la raison pour laquelle quelque chose qui fonctionne avec les classes ne fonctionne pas avec les modules.
Boris Stitnicky
6
@BorisStitnicky Faites confiance à cette réponse. C'est un idiome très courant dans Ruby, résolvant précisément le cas d'utilisation que vous posez et pour les raisons précises que vous avez rencontrées. Cela peut paraître "inélégant", mais c'est votre meilleur pari. (Si vous faites cela souvent, vous pouvez déplacer la includeddéfinition de la méthode vers un autre module et inclure CELA dans votre module principal;)
Phrogz
2
Lisez ce fil pour plus d'informations sur le "pourquoi?" .
Phrogz
2
@werkshy: incluez le module dans une classe factice.
Sergio Tulentsev
47

Voici l'histoire complète, expliquant les concepts de métaprogrammation nécessaires pour comprendre pourquoi l'inclusion de module fonctionne comme elle le fait dans Ruby.

Que se passe-t-il lorsqu'un module est inclus?

L'inclusion d'un module dans une classe ajoute le module aux ancêtres de la classe. Vous pouvez regarder les ancêtres de n'importe quelle classe ou module en appelant sa ancestorsméthode:

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!

Lorsque vous appelez une méthode sur une instance de C, Ruby examinera chaque élément de cette liste d'ancêtres afin de trouver une méthode d'instance avec le nom fourni. Puisque nous avons inclus Mdans C, Mest maintenant un ancêtre de C, donc lorsque nous appelons fooune instance de C, Ruby trouvera cette méthode dans M:

C.new.foo
#=> "foo"

Notez que l'inclusion ne copie aucune instance ou méthode de classe dans la classe - elle ajoute simplement une "note" à la classe indiquant qu'elle doit également rechercher des méthodes d'instance dans le module inclus.

Qu'en est-il des méthodes "class" de notre module?

Parce que l'inclusion ne change que la façon dont les méthodes d'instance sont distribuées, l'inclusion d'un module dans une classe ne rend ses méthodes d'instance disponibles que sur cette classe. Les méthodes "class" et autres déclarations du module ne sont pas automatiquement copiées dans la classe:

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

Comment Ruby implémente-t-il les méthodes de classe?

Dans Ruby, les classes et les modules sont des objets simples - ce sont des instances de la classe Classet Module. Cela signifie que vous pouvez créer dynamiquement de nouvelles classes, les affecter à des variables, etc.:

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

klass.new.foo
#=> "foo"

Aussi dans Ruby, vous avez la possibilité de définir des méthodes dites singleton sur des objets. Ces méthodes sont ajoutées en tant que nouvelles méthodes d'instance à la classe singleton spéciale et cachée de l'objet:

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]

Mais les classes et les modules ne sont-ils pas également de simples objets? En fait, ils le sont! Cela signifie-t-il qu'ils peuvent également avoir des méthodes singleton? Oui! Et c'est ainsi que naissent les méthodes de classe:

class Abc
end

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

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

Ou, la manière la plus courante de définir une méthode de classe est de l'utiliser selfdans le bloc de définition de classe, qui fait référence à l'objet de classe en cours de création:

class Abc
  def self.foo
    "foo"
  end
end

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

Comment inclure les méthodes de classe dans un module?

Comme nous venons de l'établir, les méthodes de classe ne sont en réalité que des méthodes d'instance sur la classe singleton de l'objet de classe. Cela signifie-t-il que nous pouvons simplement inclure un module dans la classe singleton pour ajouter un tas de méthodes de classe? Oui!

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"

Cette self.singleton_class.include M::ClassMethodsligne n'a pas l'air très jolie, donc Ruby a ajouté Object#extend, qui fait de même - c'est-à-dire inclut un module dans la classe singleton de l'objet:

class HostKlass
  include M
  extend M::ClassMethods
end

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

Déplacer l' extendappel dans le module

Cet exemple précédent n'est pas un code bien structuré, pour deux raisons:

  1. Nous devons maintenant appeler les deux include et extenddans la HostClassdéfinition pour que notre module soit correctement inclus. Cela peut devenir très fastidieux si vous devez inclure de nombreux modules similaires.
  2. HostClassréférences directes M::ClassMethods, qui est un détail de mise en œuvre du module Mqui HostClassne devrait pas avoir besoin de connaître ou de se soucier.

Alors que diriez-vous de ceci: lorsque nous appelons includesur la première ligne, nous notifions en quelque sorte le module qu'il a été inclus, et lui donnons également notre objet de classe, afin qu'il puisse s'appeler extendlui - même. De cette façon, c'est le travail du module d'ajouter les méthodes de classe s'il le souhaite.

C'est exactement à cela que sert la méthode spécialeself.included . Ruby appelle automatiquement cette méthode chaque fois que le module est inclus dans une autre classe (ou module), et passe l'objet de classe hôte comme premier argument:

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!

Bien sûr, l'ajout de méthodes de classe n'est pas la seule chose que nous pouvons faire self.included. Nous avons l'objet de classe, nous pouvons donc appeler n'importe quelle autre méthode (de classe) dessus:

def self.included(base)  # `base` is `HostClass` in our case
  base.existing_class_method
  #=> "cool"
end
Máté Solymosi
la source
2
Merveilleuse réponse! A finalement pu comprendre le concept après une journée de lutte. Je vous remercie.
Sankalp
1
Je pense que c'est peut-être la meilleure réponse écrite que j'aie jamais vue sur SO. Merci pour l'incroyable clarté et pour étendre ma compréhension de Ruby. Si je pouvais offrir un bonus de 100 points, je le ferais!
Peter Nixey le
7

Comme Sergio l'a mentionné dans les commentaires, pour les gars qui sont déjà dans Rails (ou cela ne dérange pas selon support actif ), Concernest utile ici:

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
la source
3

Vous pouvez avoir votre gâteau et le manger aussi en faisant ceci:

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 vous avez l'intention d'ajouter des variables d'instance et de classe, vous finirez par vous arracher les cheveux car vous rencontrerez un tas de code cassé à moins que vous ne le fassiez de cette façon.

Bryan Colvin
la source
Il y a quelques choses étranges qui ne fonctionnent pas lorsque vous passez un bloc class_eval, comme la définition de constantes, la définition de classes imbriquées et l'utilisation de variables de classe en dehors des méthodes. Pour supporter ces choses, vous pouvez donner à class_eval un heredoc (string) au lieu d'un bloc: base.class_eval << - 'END'
Paul Donohue