De l'utilisation des variables d'instance, et de l'intérêt des accesseurs
Nous sommes nombreux⋅ses à nous interroger sur la pertinence de l’utilisation directe de variables d’instances au sein d’une classe, ou s’il ne faudrait pas au contraire passer systématiquement par des accesseurs ? Ruby, comme à son habitude, nous laisse une grande liberté à ce sujet. Il nous faut alors nous demander quelles sont les implications et les incidences d’un choix ou d’un autre ? Que véhicule chacun de ces choix en matière d’intention ? Quelles sont les recommandations de la communauté et, plus largement, en ce qui concerne l’art de la programmation orientée objet ?
De quoi parle-t-on ?
Prenons dès à présent un exemple pour poser le cadre.
class User
def initialize(firstnmae:, lastname:)
@firstname = firstname
@lastname = lastname
end
def fullname
"#{@firstname} #{@lastname}"
end
end
Dans l’exemple ci-dessus, nous utilisons les variables d’instance dans la
méthode User#fullname
. L’auteur⋅ice de ce code s’arrêta, le regarda, et cela
lui sembla juste et bon.
Mais… car oui, il y a un mais, les paroles de Dave Thomas & Andy Hunt, auteurs de “The Pragmatic Programmer” lui revinrent à l’esprit ! Elles disaient ceci:
[…] whenever a module exposes a data structure, you’re coupling all the code that uses that structure to the implementation of that module. Where possible, always use accessor functions to read and write the attributes of objects. It will make it easier to add functionality in the future.
Et de poursuivre :
This use of accessor functions ties in with Meyer’s Uniform Access principle, described in Object-Oriented Software Construction1, which states that:
All services offered by a module should be available through a uniform notation, which does not betray whether they are implemented through storage or through computation.
— The Pragmatic Programmer, Dave Thomas & Andy Hunt
Et effectivement, nous exposons ici les structures de données portées par notre
classe ! Admettons alors, pour filer notre exemple, que nous souhaitions nous
assurer que la méthode User#fullname
retourne les noms en majuscules et les
prénoms ornés d’une majuscule sur la seule première lettre. En poursuivant sur
notre lancée, nous ferions certainement ceci :
class User
def fullname
"#{@firstname.titleize} #{@lastname.capitalize}"
end
end
Pourquoi pas, mais nous voyons dores et déjà poindre une faiblesse dans cette approche !
Découpler pour préparer l’avenir
En effet, la prochaine demande d’évolution stipulera, dans un souci d’harmonisation et de cohérence, que les noms et prénoms soient toujours représentés sous cette forme, qu’ils fussent affichés sous leur forme concaténée ou individuellement. Dès lors, on se voit obligé de changer d’approche. Commençons par un petit réusinage :
class User
attr_reader :firstname, :lastname
def fullname
"#{firstname} #{lastname}"
end
end
Pour rappel, le réusinage ne doit en aucun cas changer le comportement initial. Ce n’est pas le cas ici, puisqu’on retrouve le comportement premier, sans gestion de la casse. Mais cela nous permet, pour les besoins de l’exercice, de mettre l’accent sur un point : si nous étions partis dès le début sur cette voie, celle de l’utilisation systématique d’accesseurs, alors pour répondre au besoin ici exprimé nous n’aurions eu qu’à adapter nos accesseurs, et cela présente deux avantages ! Le premier est que, comme l’entièreté du code repose sur ces accesseurs (l’interface que nous présentons au monde), il n’est nécessaire de réaliser de changement qu’au seul endroit de leur déclaration, et non à une myriade d’endroits éparpillés dans la classe. Le second est que cela nous permet de distinguer structure de données et manipulation de celle-ci ; en d’autres termes, il nous est possible de présenter la donnée de différentes manière et de conserver l’originale intacte. Cette dernière affirmation n’est vérifiée qu’à une seule condition, tout traitement de la donnée fournie à l’instantiation de l’objet se fera en dehors de l’initialiseur, celui-ci devant rester le plus basique possible. Prenons un exemple pour illustrer ce point. Au lieu d’écrire ceci :
class User
attr_reader :firstname, :lastname
def initialize(firstname:, lastname:)
@firstname = firstname.titleize
@lastname = lastname.capitalize
end
end
Nous lui préférerons cela :
class User
def initialize(firstname:, lastname:)
@firstname = firstname
@lastname = lastname
end
def firstname
@firstname.titleize
end
def lastname
@lastname.capitalize
end
end
Dès lors, nous avons la possibilité d’auditer notre code avec beaucoup de précision, car aucune information n’est perdue. Cela est notamment très utile lorsque nous avons besoin diagnostiquer un comportement et de distinguer une donnée mal formée à l’initialisation de l’objet ou un mésusage lors de sa manipulation.
D’aucuns me rétorqueront qu’en utilisant les variables d’instances, on a l’assurance que celle-ci ne seront pas utilisées en dehors de la classe qui les héberge. C’est un point intéressant, mais partiellement faux tant Ruby a une notion très laxiste de la visibilité.
user = User.new(firstname: "Ada", lastname: "Lovelace")
=> #<User:0x00007fbdd11499c0 @firstname="Ada", @lastname="Lovelace">
user.instance_variable_get(:@firstname)
=> "Ada"
Par ailleurs, il est tout à fait possible et recommandé de rendre nos accesseurs privés, non pas pour empêcher strictement leur utilisation — on vient de voir qu’il est très facile de contourner cela — mais pour exprimer notre intention de ne pas voir ceux-ci utilisés en dehors de leur classe.
class User
def initialize(firstname:, lastname:)
@firstname = firstname
@lastname = lastname
end
private
attr_reader :firstname, :lastname
end
Et si l’arobase qui préfixe une variable d’instance permet d’un seul coup d’œil de différencier cette dernière d’une méthode, portant ainsi à notre compréhension que nous sommes en train de manipuler un attribut de notre objet, cette même arobase ne nous permet en aucun cas de distinguer un attribut assigné à l’initialisation d’une variable servant à la mémoïsation.
class User
def initialize(firstname:, lastname:)
@firstname = firstname
@lastname = lastname
end
def fullname
@fullname ||= "#{firstname} #{lastname}"
end
end
Ici @fullname
est déclaré pour de la mémoïsation et il serait bien mal avisé
de l’utiliser explicitement plutôt que de passer par la méthode User#fullname
.
Résumons-nous
Les accesseurs servent, comme le rappelle Sandy Metz dans “99 Bottles of OOP”, à encapsuler les données primitives de nos objets. Ce faisant, nous n’exposons pas les entrailles de nos objets, leurs structures de données internes, au lieu de cela nous présentons une interface harmonieuse, tout en nous préparant pour l’avenir qui ne manquera pas de nous bousculer !
-
Bertrand Meyer. Object-Oriented Software Construction. Prentice Hall, Upper Saddle River, NJ, Second, 1997. ↩