samedi 9 mars 2024

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 !

  1. Bertrand Meyer. Object-Oriented Software Construction. Prentice Hall, Upper Saddle River, NJ, Second, 1997.