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.
- On regarde dans les méthodes d’instance de
D
, non trouvé. - On regarde dans les modules inclus dans
D
, non trouvé (M
n’a pas été inclus à nouveau dansD
). - 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.
- Regarder dans les méthodes singletons de l’objet.
- Regarder dans les méthodes définies dans la classe de l’objet.
- Regarder dans les modules inclus dans la classe de l’objet dans l’ordre inverse d’inclusion.
- 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 hérite de , alors on peut donner un objet de la classe à n’importe quel endroit où un objet de classe 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.
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
.
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.