Des objets plus complexes

Dans les chapitres précédents, nous avons appris à créer des objets, des classes, puis des modules. Ici, nous allons approfondir notre maîtrise des classes et apprendre à spécialiser des classes et partager du code entre plusieurs classes.

Héritage et composition

Héritage

L’héritage est une relation entre deux classes, la super-classe (ou classe mère) et la sous-classe (ou classe fille). Lorsqu’une classe F hérite d’une classe C, toutes les instances de la sous-classes possèdent alors les méthodes définies dans la superclasse.

L’idée est de spécialiser la classe mère pour créer une nouvelle classe. Par exemple, on pourrait spécialiser une classe Book en une classe Novel. Pour cela, nous allons utiliser cette syntaxe lors de la définition de la classe Novel.

class Book
  attr_reader :content

  def initialize(content)
    @content = content
  end
end

class Novel < Book
  attr_reader :author
end

Avec <, on indique que Novel est une sous-classe de Book et donc lorsqu’on crée un objet de classe Novel, il a une méthode content et une méthode initialize qui prend en paramètre un argument et fait l’opération @content = content.

novel = Novel.new('Un beau roman.')
puts novel.content
Redéfinition de méthodes

L’héritage nous permet de disposer des méthodes de la classe mère dans la classe fille. Cependant, il peut arriver que l’on veuille que la méthode soit un peu différente dans la classe fille. Pour cela, on peut redéfinir la méthode dans la classe fille. C’est alors cette nouvelle méthode qui sera appelée si elle est utilisée sur une instance de la classe fille. Par exemple, nous pouvons définir la méthode initialize de Novel pour qu’elle prenne en paramètre l’auteur.

class Novel < Book
  attr_reader :author
  
  def initialize(content, author)
    @content = content
    @author = author
  end
end

class Magazine < Book
  attr_reader :publisher
 
  def initialize(content, publisher)
    @content = content
    @publisher = publisher
  end
end

novel = Novel.new('Un beau roman.', 'Clem')
magazine = Novel.new('Contenu.', 'ZdS')
book = Book.new('Un beau livre.')
La méthode super

Lorsque nous redéfinissons une méthode, nous pouvons faire appel à la méthode correspondante de la super-classe. Pour cela, nous allons utiliser le mot-clé super.

class Book
  # ...
  def read
    puts "Contenu du livre : #{@content}"
  end
end

class Novel < Book
  # ...
  def read
    puts "Lecture d'un roman de #{@author}."
    super
  end
end

Novel.new("contenu du roman de Zeste de Savoir", "Clem")

De plus, il est à noter qu’en utilisantsuper, la méthode de la super-classe est appelée automatiquement avec les arguments passés à la méthode de la classe.

class Book
  def read_page(n)
    puts "Lecture de la page #{n}."
  end
end

class Novel < Book
  def read_page(n)
    puts "Lecture du roman de #{@author}."
    super # Ici, `read_page` de `Book` prend `n` en argument.
  end
end

Cette « magie » de Ruby peut cependant poser problème si la méthode de la super-classe ne prend pas exactement le même nombre de paramètres. Nous obtiendrons alors une erreur disant que la méthode est appelée avec le mauvais nombre d’arguments. Pour régler cela, il nous suffit de passer à super les paramètres voulus.

class Book
  def initialize(content)
    @content = content
  end
end

class Novel < Book
  def initialize(content, author)
    super
    @author = author
  end
end

Si nous voulons appeler super et qu’il nous faut préciser qu’aucun argument ne doit être envoyé à la méthode, nous devrons utiliser une paire de parenthèse ; nous emploierons donc super().

Utiliser super est par exemple utile quand la méthode de la super-classe fait déjà une partie de ce qui doit être fait dans la méthode correspondante de la sous-classe. En particulier, cela permet de ne pas écrire plusieurs fois le même code (respect du DRY) et de ne changer que ce qui est nécessaire.

L’arbre d’héritage de Ruby

En Ruby, nous avons la méthode is_a? et son alias kind_of? qui prend en paramètre une classe et permettent de savoir si un objet est une instance d’une sous-classe de cette classe (ou une instance de cette classe), et instance_of? qui permet de savoir si un objet est exactement une instance d’une classe.

Novel.new.kind_of?(Novel) # => true
Novel.new.kind_of?(Book) # => true
Novel.new.instance?(Novel) # => false
Novel.new.instance?(Book) # => false

En fait, lorsque nous utilisons l’héritage, par exemple pour avoir une classe Novel qui hérite de Book, nous devons noter qu’une instance de Novel est aussi une instance de Book (en clair, un roman est en particulier un livre).

Sachant cela, nous pouvons créer ce que l’on appelle un arbre d’héritage. L’idée est simplement que les nœuds sont des classes et que si F est une sous-classe de M, on a une branche qui va de M à F (en fait, c’est l’arbre généalogique des classes). Concrètement, on pourrait quasiment construire cet arbre dans Ruby grâce à la méthode superclass qui nous donne la super-classe d’une classe.

Novel.superclass # => Book

Et en fait, cet arbre a une racine ; il existe une classe qui est un ancêtre de toutes les classes ! Il s’agit de la classe Object (ce n’est pas si étonnant, tous les objets sont des objets). En fait, quand on crée une classe, elle hérite par défaut de Object. Ainsi, Object est la super-classe de Book.

Novel.superclass # => Book
Book.superclass # => Object

On a donc que toutes les méthodes disponibles pour Object le sont pour les classes que nous créons. En particulier, cela explique que nous ayons par défaut des méthodes to_s, inspect, etc. En fait, on pourrait s’amuser à regarder l’arbre d’héritage de certains objets.

Les classes

En réalité c’est un peu plus compliqué. En particulier, il y a deux classes, BasicObject et Class dont nous n’allons pas parler en détail. Toutes les classes sont de la classe Class (Object.class, Class.class, Array.class valent Class). De même, il y a une classe Module dont tous les modules sont des instances. Et en fait, on a que Object est la super-classe de Module qui est elle-même la super-classe de Class.

L’idée c’est que les classes sont des Class, les modules sont des Module, et les instances de classes sont des Object. Et avec cet arbre d’héritage, on retrouve que tout est objet.

Avec ça on a un petit paradoxe ; Object est un objet, mais est aussi une classe, et Class est un objet, mais aussi une classe. Ruby fait sa tambouille en interne pour que tout ceci ne pose pas problème. Nous n’allons donc pas plus nous en occuper.

Composition

La composition, c’est quelque chose que nous avons déjà utilisé plusieurs fois (et que nous utilisons en fait quasiment à chaque fois que nous utilisons un objet). La composition est le fait d’avoir un objet qui est un attribut d’un autre objet. Vu que les nombres, les chaînes de caractères, et tout le reste en fait, sont des objets, nous comprenons qu’elle est omniprésente.

Si l’héritage correspond à une relation « être un », la composition correspond à une relation « a un ». Par exemple, un message a un auteur. Et on représente cela en donnant à la classe Message un attribut author. Et c’est de la composition… Oui, la composition c’est juste ça.

Néanmoins, si pour le moment nous avons juste fait de la composition avec des objets « simples » (nombres et chaînes de caractères), mais nous pouvons bien sûr le faire avec n’importe quel type d’objets. Par exemple, nous pouvons tout à fait créer une classe Person et faire en sorte que l’auteur d’un message et son destinataire soient des instances de Person.

class Person
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def to_s
    @name
  end
end

class Message
  attr_reader :author, :content, :date, :recipient

  def initialize(content, author, recipient, date = '21/12/2112')
    # ...
  end
end

clem = Person.new("Clem")
moi = Person.new("Moi")
m = Message.new("Bonjour", moi, clem)

Ici, ça permet en particulier de permettre de différencier facilement deux personnes qui ont le même nom. De même, on pourrait avoir une classe Date pour représenter des dates avec des opérations dessus, notamment de comparaison (à noter que des classes pour gérer les dates existent déjà en Ruby).

Mixer un module dans une classe

Les mixins

Comme nous l’avons vu, nous ne pouvons pas instancier de modules ; la création d’objets nécessite une classe. Néanmoins, grâce à la méthode include qui, rappelons-le, permet de copier les méthodes d’instances d’un module pour les rajouter aux méthodes d’instance de l’objet courent, nous pouvons rajouter les méthodes d’instance d’un module à une classe. On parle alors du module en tant que mixin.

module M
  def f(x, y)
    x + y
  end
end

class C
  include M
end

puts C.new.f(1, 2)

Vu comme ça, les mixins n’ont en effet pas l’air très utile. L’idée première que nous devons retenir est qu’ils nous permettent de réunir du code utilisé dans plusieurs classes à l’intérieur d’un module (principe DRY).

Mais qu’est-ce que cela apporte par rapport à l’héritage ? Pourquoi ne pas juste créer une classe et faire de l’héritage ?

Il y a plusieurs réponses à cette question, et c’est autant une question de philosophie de Ruby que de conception de programmes. Pour commencer, contrairement à certains langages comme C++, il n’y a pas d’héritage multiple en Ruby. Cela signifie qu’on ne peut pas faire une classe hériter de plus d’une classe. Ainsi, partager du code entre deux classes en utilisant l’héritage nécessite de créer une god-classe, une classe avec plein de méthodes. Et cette classe n’a pas forcément de sens au niveau de la conception (en plus de sûrement contrevenir au principe du SRP).

Les mixins permettent de contrevenir à cela. En effet, on peut mixer autant de modules que l’on veut à une classe. L’idée est alors d’avoir une relation d’héritage qui a du sens, et des mixins qui ont eux aussi du sens. Ainsi, on a du code modulable et plus simple. En fait, les mixins servent souvent à implémenter des comportements.

Ceci est d’autant plus puissant que dans le module, nous pouvons tout à fait utiliser des variables d’instances puisque qu’en incluant le module, les méthodes d’instances sont copiés).

Supposons par exemple que l’on ait toujours des messages, mais on rajoute des lettres (des messages livrables), des SMS, des colis, etc. On pourrait avoir un module pour les objets livrables (avec une méthode pour livrer, une méthode pour suivre le colis, etc.).

class Message
end

module Deliverable
  def deliver
    puts "Livraison de la part de #{@author} pour #{@recipient}." 
  end
end

class Letter < Message
  include Deliverable
end

class SMS < Message
end

class PostalPacket
  include Deliverable
end

Ici, les classes Letter et PostalPacket ont accès à la méthode deliver, mais pas la classe SMS. Ceci est d’autant plus puissant que si une méthode avait déjà été définie dans la classe Message (disons par exemple une méthode send), nous pouvons la définir dans Deliverable et dans les sous-classes de Message qui incluent Deliverable, c’est la méthode send de Deliverable qui sera appelée.

class Message
  def send
    puts "Envoi du message de #{@author} pour #{@recipient}."
  end
end

module Deliverable
  def send
    puts "Envoi du message de #{@author} pour #{@recipient} par service postal."
  end
end

class Letter < Message
  include Deliverable
end

Letter.new('Contenu', 'Auteur', 'Destinataire').send
Un peu de lookup ?

Jusqu’à maintenant, l’envoi d’un message m à un objet obj semblait assez simple : obj reçoit le message m et il exécute la méthode m si sa classe définit cette méthode. Mais en rajoutant l’héritage, puis les *mixins les choses se compliquent un peu. Si la classe de obj ne définit pas obj, il va regarder dans sa super-classe, si elle na la définit pas, il va encore remonter et ce jusqu’à arriver à Object. Ou il va regarder si la méthode n’est pas disponible dans un module qui est mixé par la classe.

Mais dans quel ordre regarde-t-il cela ? Si une classe et un module mixé dans cette classe définissent tous les deux une méthode m, laquelle de ces méthodes est appelée ?

L’ordre dans lequel Ruby cherche la méthode est défini par un algorithme dit de lookup. Cet algorithme permet donc de savoir quelle méthode sera prioritaire, et nous allons voir un peu comment il fonctionne ici.

Héritage et mixins

Commençons par créer un module M avec une méthode f.

module M
  def f
    puts 'Méthode du module M.'
  end
end

Le cas le plus simple est celui où la méthode est également déclarée dans la classe de l’objet. Dans ce cas, on s’attend à ce que ce l’appel se fasse avec la méthode de la classe, et c’est bien ce qui se passe.

class C
  include M
  
  def f
    puts 'Méthode de la classe C.'
  end
end

C.new.f # => Méthode de la classe C

Et si maintenant on crée une classe D qui hérite de C ?

class D < C
end

D.new.f

Là encore, c’est la méthode de la classe C qui est appelée et pas celle du module.

Les méthodes définies dans les classes semblent prioritaires. Néanmoins, comme nous l’avons vu plus haut avec l’exemple de send définie dans Message et dans Deliverable mais pas dans Letter, si une classe inclut un module, les méthodes de ce module sont prioritaires sur les méthodes de la super-classe.

class C
  def f
    puts 'Méthode de la classe C.'
  end
end

class E < C
  include M
end

E.new.f

En fait, Ruby regarde dans les méthodes de la classe de l’objet, puis dans les méthodes des modules inclus dans cette classe, et s’il ne trouve pas, alors il passe à la super-classe et tente la même chose, et ce jusqu’à trouver la méthode ou à arriver à BasicObject et là il ne peut pas monter plus haut dans la hiérarchie des classes s’il ne trouve pas et nous obtenons une erreur.

Plusieurs modules avec la même méthode

Dans le cas où plusieurs modules inclus dans la classe définissent la même méthode, c’est le dernier module inclus qui a la priorité.

module M
  def f
    puts 'Méthode du module M.'
  end
end

module N
  def f
    puts 'Méthode du module N.'
  end
end

class C
  include M
  include N
end

C.new.f # => Dans le module N

Ici, c’est le module N qui est inclus en dernier, c’est donc sa méthode qui est appelée.

Que se passe-t-il si un module est inclus plusieurs fois ?

Faisons le test.

class C
  include M
  include N
  include M
end

C.new.f

Ici, le module M est inclus en dernier, on s’attend donc à ce que sa méthode soit exécutée, et pourtant ce n’est pas le cas. En fait, lorsqu’un module est déjà inclus, la seconde inclusion ne fait rien.

Notons que cela est aussi vrai si le module a été inclus par un ancêtre de la classe.

module M
  def f
    puts 'Méthode du module M.'
  end
end

class C
  include M

  def f
    'Méthode de la classe C'
  end
end

class D < C
  include M
end

D.new.f

Ici, on pourrait s’attendre à ce que ce soit la méthode de M qui soit exécutée, conformément aux règles vues plus haut, mais l’inclusion de M dans C n’a rien fait, vu que M est déjà inclus dans C et que D hérite de C. Ainsi, la recherche de la méthode f d’un objet de la classe D va mener grosso-modo à ceci.

  1. On regarde dans les méthodes d’instance de D, non trouvé.
  2. On regarde dans les modules inclus dans D, non trouvé (M n’a pas été inclus à nouveau dans D).
  3. On passe à la super-classe, on regarde dans les méthodes d’instance de C, on trouve.
Les méthodes singletons

Les méthodes singletons sont un peu à part dans tout ça. En fait, elles ont la priorité absolue, même sur les méthodes d’instances.

class C
  def f
    puts 'Méthode de la classe C.'
  end
end

o = C.new

def o.f
  puts 'Méthode singleton de f.'
end

puts o.f # => Méthode singleton de f.
Et finalement ?

Finalement, voici l’ordre utilisé par Ruby pour trouver la méthode à appeler.

  1. Regarder dans les méthodes singletons de l’objet.
  2. Regarder dans les méthodes définies dans la classe de l’objet.
  3. Regarder dans les modules inclus dans la classe de l’objet dans l’ordre inverse d’inclusion.
  4. Retourner à l’étape 2, mais avec la super-classe.

Notons que la méthode super suit également ce même chemin. Ceci nous permet d’écrire ce code (qui nous permet en passant de vérifier que les étapes sont bien celles que nous avons données.

module M
  def f
    puts 'Dans le module M.'
    super
  end
end

class C
  def f
    puts 'Dans la classe C.'
  end  
end

class D < C
  include M
  
  def f
    puts 'Dans la classe D.'
    super
  end  
end

o = D.new

def o.f
  puts 'Méthode singleton de o'
  super
end

o.f

Nous n’avons pas encore tout vu sur ce sujet. En particulier, d’autres méthodes extend et prepend viennent avec include et permettent d’inclure des méthodes de module dans des objets et rajoutent d’autres étapes dans l’algorithme de lookup de Ruby. Nous n’en parleront pas pour le moment, mais nous pouvons tout à fait aller nous renseigner à leur sujet ; nous avons le niveau pour comprendre ce qu’elles font. Nous pourrons alors regarder ce document par exemple pour l’algorithme de lookup.

Quelle technique utiliser ?

Bien utiliser l’héritage
Le Principe de substitution de Liskov

Après avoir vu toutes ces techniques, il convient d’apprendre à bien les utiliser. Commençons par voir quelques notions de conception liées à l’héritage. C’est l’heure de voir un autre principe SOLID. Cete fois il s’agit du principe de substitution de Liskov (LSP pour Liskov Substitution Principle). On l’exprime grossièrement en disant que si FF hérite de MM, alors on peut donner un objet de la classe FF à n’importe quel endroit où un objet de classe MM est attendu.

Regardons comme d’habitude par l’image de l’article humoristique. Cette fois, il s’agit de quelqu’un qui dîne de la salade. Il y a donc des aliments comestibles dans son assiette… Regardons-y de près : dans sa salade, il y a de la tomate, de l’oignon, des carottes, mais aussi de la ciguë (qui est une plante toxique).

Ici, on considère une méthode qui prend en paramètre une liste d’aliments comestibles et en fait une salade. On a alors une classe mère pour les aliments comestibles. La carotte, l’oignon, la tomate sont des aliments comestibles. Malheureusement, si on fait la ciguë hériter de cette classe mère (et c’est en gros ce qui s’est passé dans ce malheureux dîner), alors on peut l’utiliser pour faire de la salade, ce qui est clairement une mauvaise idée.

Un exemple classique de violation du SRP est celui du carré et du rectangle. En effet, si on a une classe Rectangle dont on peut modifier la hauteur et la largeur, on peut imaginer une classe Square qui hérite de Rectangle (un carré est un rectangle). Malheureusement, les méthodes de modification de la hauteur et de la largeur ne peuvent pas être utilisées directement car modifier la largeur d’un carré sans en modifier la hauteur pose problème (ce n’est plus un carré dans ce cas).

L’héritage et les variables de classe

Nous allons revenir sur les variables de classe. Rappelons-nous, plus tôt dans le tutoriel nous avions dit ceci.

Les variables de classes sont à éviter car elles peuvent s’avérer dangereuses. Nous verrons pourquoi dans les chapitres suivants, mais il nous faut savoir que c’est en partie parce qu’elles peuvent être modifiées là où on ne s’y attend pas.

Chapitre sur les classes

Ici, nous allons écrire une classe M et une classe F qui hérite de M. M a une variable de classe @@number qui comptabilise le nombre d’instances de M créées. Ainsi, F aura également une variable de classe @@number qui comptabilisera le nombre d’instances de F créées.

class M 
  @@number = 0
  
  def initialize
    @@number += 1
  end
  
  def self.number
    @@number
  end
end

class F < M
  def initialize
    @@number += 1
  end
end

Un code simple, n’est-ce pas ? Nous définissons les constructeurs pour qu’ils incrémentent @@number et nous définissons également une méthode de classe number pour avoir accès à @@number. Testons-le.

M.new
M.new
F.new
puts M.number
puts F.number

Le résultat qui est attendu est le suivant.

2
1

En effet, deux instances de M ont été créées, et donc le constructeur de M a été appelée deux fois, et une instance de F a été créé, appelant le constructeur de F une fois (notons qu’il serait peut-être préférable d’avoir 3 pour le nombre d’instances de M, vu qu’une instance de F est une instance de M). Et pourtant, le résultat obtenu est le suivant !

3
3

En fait, les variables @@number de M et de F sont les mêmes. Et avec ça, nous comprenons le problème. Une modification dans M affectera aussi F puisque ce sont les mêmes variables. Ce code (tiré de ce guide) l’illustre bien.

class Parent
  @@class_var = 'parent'

  def self.print_class_var
    puts @@class_var
  end
end

class Child < Parent
  @@class_var = 'child'
end

Parent.print_class_var # => will print 'child'

La solution à ce problème est également donnée dans le guide, il faut utiliser des variables d’instance de classe.

Quoi, des variables d’instance de classe ? Mais qu’est-ce que c’est ?

Tous les objets on des variables d’instance. Mais une classe est aussi un objet, et a donc des variables d’instance.

class C
  @x = 2
end

puts C.instance_variables

Ici, nous créons une classe C avec une variable d’instance @x. Remarquons qu’il s’agit de la seule variable d’instance de C.

En utilisant les variables d’instance de classe, nous avons de quoi régler notre souci. En fait, on utilise les variables d’instance de classe pour simuler des variables de classe ; chaque classe aura sa variable d’instance et elles seront indépendantes. On écrit alors ce code.

class M 
  @number = 0
  
  def initialize
    M.number += 1
  end
  
  def self.number
    @number
  end

  private def self.number=(x)
    @number = x
  end
end

class F < M
  @number = 0

  def initialize
    self.class.number += 1
    super
  end
end

Ici, nous avons également appelé super dans la méthode initialize de F. Ainsi, le compteur de M sera incrémenté même lors de la création d’une instance de F. Nous pouvons alors essayer le code qui posait problème.

M.new
M.new
F.new
puts M.number
puts F.number

Et nous obtenons le résultat attendu !

La conclusion de tout ceci est que nous préférerons utiliser des variables d’instance de classe plutôt que des variables de classe.

Le typage de canard

Maintenant que nous avons vu comment bien faire de l’héritage, il nous reste à parler du typage de canard ou duck typing. L’idée du typage de canard est de profiter du typage de Ruby pour créer des méthodes qui acceptent des paramètres ayant un certain comportement et non un certain type.

Et concrètement, qu’est-ce que ça veut dire ?

Plus concrètement, plutôt que de considérer qu’une méthode prend en paramètre un objet de telle classe, nous allons considérer qu’il prend en paramètre un objet qui dispose d’une certaine interface, et présente donc certaines méthodes.

def duck_typing(duck)
  duck.fly
  duck.swim
end

Ici, on se dit que seul un objet de la classe Duck peut être envoyé à la méthode duck_typing, mais c’est faux. Il suffit que l’objet passé en paramètre ait une méthode fly et une méthode swim.

Si ça vole comme un canard, que ça nage comme un canard, et que ça fait coin-coin comme un canard, alors c’est un canard.

Le typage de canard.

Grâce à ceci, nous pouvons nous concentrer sur l’interface de nos objets plutôt que sur nos types.

Supplanter l’héritage ?

Le duck-typing permet de supplanter l’héritage sur certains points. Par exemple, plutôt que de créer une classe Animal, des classes Duck et Cat héritant de Animal, et une méthode speak, nous pouvons juste créer les classes Duck et Cat.

class Duck
  def speak
    puts 'Coin-coin.'
  end
end

class Cat
  def speak
    puts 'Miaou.'
  end
end

def stroke(animal)
  puts 'On caresse un animal.'
  animal.speak
end

stroke(Duck.new)
stroke(Cat.new)

Ici, on peut envoyer n’importe quel argument à stroke, pourvu qu’il implémente speak. En fait, créer une classe Animal ici, juste pour que Cat et Duck en héritent est une mauvaise idée. Nous allons donc éviter de créer des classes juste pour faire de l’héritage et préférer du duck-typing dans ce genre de cas. Plus précisément, l’héritage sera préféré s’il y a vraiment des caractéristiques, des attributs, des méthodes à partager.

Par exemple, si nous ne considérons que des animaux domestiques qui ont un propriétaire, un nom, etc., nous pouvons créer une classe Pet, dont héritera Dog et Cat.

class Pet
  attr_reader :name, :master, :age

  def initialize(name, master, age)
    @name = name
    @master = master
    @age = age
  end

  def welcome
    puts "#{name} accueille #{master}."
    speak
  end
end

class Dog < Pet
  def speak
    puts 'Ouaf !'
  end
end

class Cat < Pet
  def speak
    puts 'Miaou !'
  end
end

Dog.new('Rex', 'Clem', 3).welcome

Ici, nous avons même fait encore mieux, nous avons utilisé de l’héritage (avec initialize, welcome et les attributs) et du duck-typing avec l’utilisation de speak dans welcome.

Néanmoins, les classes qui héritent de Pet doivent implémenter une méthode speak, sinon on aura un problème. En fait, Pet.new('N', 'M', 2).welcome provoque une erreur, et nous ne voulons pas de cela, donc il faudrait mieux avoir une méthode speak pour Pet

Héritage, mixins ou composition ?

E rajoutant le typage de canard, nous avons un arc plutôt bien fourni entre l’héritage, les mixins et la composition. Et il peut donc être compliqué de savoir lequel privilégier. Pour commencer, il est raisonnable de dire que cela dépendra de la situation. Mais rappelons quelques idées que nous avons déjà abordées.

Néanmoins, ce qu’il nous faut comprendre, c’est que l’utilisation d’une technique a un sens et donne à notre code du sens. Par exemple, lorsque nous faisons une classe F hériter d’une classe M, le sens donné est que les instances de F sont aussi des instances de M (ceci est encore plus vrai avec le LSP).

Ainsi, cela n’aurait pas de sens de faire une classe pour une roue hériter d’une classe représentant une voiture. Entre une roue et une voiture, il y aurait plutôt une relation de composition ; une voiture a une roue.

Et finalement, mixer un module M dans une classe C indique que la classe se comporte comme M, agit comme M. C’est la sémantique principale du mixin. Par exemple, on pourrait avoir un module Drivable qu’on mixerait dans la classe Car (une voiture se comporte comme quelque chose qu’on peut conduire).

Pour bien appréhender les subtilités derrière tout cela, nous renvoyons vers cet article de Synbioz. Bien entendu, il n’est pas suffisant et c’est la pratique qui nous permettra de progresser. De plus, il nous faut bien garder à l’esprit qu’il y a rarement une seule bonne solution à un problème.

Exercices

Pour ce chapitre, nous pouvons commencer par jouer avec la classe Message (définir d’autres classes et modules pour les personnes, les livreurs, mais aussi pour avoir d’autres types de messages). Par exemple, nous pourrions gérer des carnets d’adresses, des répertoires de téléphones, et lorsqu’on décide d’envoyer une lettre, on l’envoie à une adresse.

Après s’être amusé avec les messages, voici un exercice assez connu ici pour pratiquer la POO : le Javaquarium (pour l’occasion, nous le renommerons Rubaquarium qui est un nom bien plus classe). Nous n’allons pas redonner les règles ici, il suffit d’aller voir le lien. Bonne programmtion !


Ce chapitre conclut quasiment les bases que nous avons besoin de connaître pour organiser du code et devenir des Rubyistes convaincus. Bien sûr, il nous faut mettre tout ça en pratique, mais récapitulons déjà tout ce que nous avons appris ici.

  • L’héritage permet d’étendre (ou plutôt de spécialiser) une classe (la classe-mère) en créant une nouvelle classe (la classe-fille) qui bénéficie des méthodes et attributs de la classe-mère qu’elle peut redéfinir.
  • Le typage de canard est à préférer à l’héritage.
  • Les mixins permettent d’ajouter les méthodes d’un module à une classe ; ils permettent d’implémenter des comportements.
  • La composition est elle aussi un outil appréciable. Avec l’héritage et la composition, elle nous permet de concevoir du code robuste, lisible et extensible.