First-class collections : Encapsulons nos collections

La Programmation Orientée Objet (POO) comprend quelques principes de base dont l’encapsulation. Le respect de l’encapsulation consiste à veiller à ce qu’une classe soit responsable de la manipulation de ses données. Pour un meilleur contrôle, l’API de la classe n’expose que le strict nécessaire. Les détails d’implémentation sont dissimulés.

Mais quand une donnée est représentée sous la forme d’une collection, qui est responsable de sa manipulation ?

Récemment, dans le cadre de l’ajout d’une nouvelle fonctionnalité, j’ai fait face à une spécification qui nécessite le recours à une certaine collection. Lors de l'implémentation, plusieurs questions se sont posées. Ma réponse fut la first-class collection. Cette pratique me paraît peu exploitée mais fortement recommandable. Je vous invite dans ma réflexion à travers un exemple fictif.

Analyse du code

Imaginons une nouvelle fonctionnalité qui doit faire appel à la fonction suivante :

public interface BookRepository {
    List<Book> getCurrentBooks();
}

Nous investiguons pour comprendre les enjeux de cette fonction. Nous cherchons à nous assurer qu’elle répond bien au besoin de la fonctionnalité. Comment cette fonction est-elle utilisée ? Quelle est sa logique métier ?

Nous trouvons ses utilisations éparpillées parmi différentes classes. Cette lecture nous aide à appréhender au mieux la liste de livres. Nous découvrons que le traitement que nous devons appliquer à la collection est déjà implémenté. Notre nouvelle fonctionnalité a besoin des genres favoris, tout comme d’autres use cases.

public class AnotherUseCase {
  private static final int FAVORITE_GENRE_COUNT = 3;

  private final BookRepository bookRepository;

  public void execute() {...}

  private void displayFavoriteGenres() {...}

  private List<Genre> extractFavoriteGenres() {...}

  private Map<Genre, Long> countBooksByGenre() {
    return this.getCurrentBooks()
            .stream()
            .collect(groupingBy(
                    Book::genre,
                    Collectors.counting()
            ));
  }

  private List<Book> getCurrentBooks() {
    return bookRepository.getCurrentBooks();
  }
}

L’heure d’un petit copier-coller ? L’adage DRY (Don’t Repeat Yourself) nous pousse à questionner la pertinence de cette duplication. Est-ce qu’un refactoring s’impose ? Et si une classe était dédiée à la collection ? Une first-class collection

First-class collection

Dans l’essai Object Calisthenics, Jeff Bay fait le triste constat que le paradigme Orienté Objet est peu respecté. Pourtant, selon lui, la POO comporte la promesse de logiciels de meilleure qualité en termes de compréhension et maintenabilité. La faute aux habitudes héritées du procédural ?

Pour nous encourager à challenger notre rapport à la conception, Bay propose neuf règles avec lesquelles s’amuser. L’une d’elles est Use first-class collections :

Any class that contains a collection should contain no other member variables. Each collection gets wrapped in its own class, so now behaviors related to the collection have a home.

Object Calisthenics

Refactoring

Créons notre first-class collection et rapatrions son comportement.

public record Library(List<Book> books) {
  private static final int FAVORITE_GENRE_COUNT = 3;

  public void displayFavoriteGenres() {...}

  private List<Genre> extractFavoriteGenres() {...}

  private Map<Genre, Long> countBooksByGenre() {
    this.books
            .stream()
            .collect(groupingBy(
                    Book::genre,
                    Collectors.counting()
            ));
  }

  // other behaviors
}

Les méthodes qui portent sur notre liste de livres se retrouvent dans la first-class collection nommée Library. Les autres classes qui ont recours à la collection sont débarrassées des préoccupations propres à la bibliothèque. Library n’expose que les méthodes requises à son maniement. Nous n’ajoutons pas de simples accesseurs à la donnée. Elle est cachée, sous contrôle.

Le nombre de genres favoris s’avère être une notion métier qui ne varie pas d’un use case à un autre. Équivalente pour toute bibliothèque, cette constante relève de la responsabilité de la nouvelle classe.

À présent, nous récupérons une bibliothèque.

public interface BookRepository {
  Library getCurrentLibrary();
}
public class AnotherUseCase {

  private final BookRepository bookRepository;

  public void execute() {...}

  private void displayFavoriteGenres() {
    this.getCurrentLibrary().displayFavoriteGenres();
  }

  private Library getCurrentLibrary() {
    return bookRepository.getCurrentLibrary();
  }

}

Nous pouvons désormais nous focaliser sur notre nouvelle fonctionnalité et réutiliser le code existant sereinement.

Avantages

Emballer notre liste dans une classe dédiée comporte plusieurs vertus.

  • Mise en évidence du sens métier

    Lorsque nous manipulons des valeurs primitives ou des String, nous pouvons les encapsuler dans une classe qui leur est propre afin de contrer l’anti-pattern Primitive Obsession. Cela permet de fortifier le typage de nos données et d’expliciter leur sens métier. Il en va de même avec l’encapsulation d’une collection.

  • Law of Demeter (LoD)

    De bonnes clôtures font de bons voisins.

    La Law of Demeter (Loi de Déméter), aussi appelée Principe de Connaissance minimale, nous invite à réduire le couplage entre nos classes. La connaissance de la structure interne des autres classes qui ne sont pas “amis immédiats” doit être limitée.

  • Tell, don’t ask !

    Le principe Tell, don’t ask (Racontez, ne demandez pas) vise aussi le découplage et la connaissance minimale entre les classes. Plutôt que de récupérer la donnée qui nous intéresse, nous spécifions l’action souhaitée pour cette donnée. La charge de savoir quel comportement adopter selon les conditions revient à notre first-class collection. Elle est responsable d'elle-même. Ce principe a aussi le bénéfice de nous pousser à affiner le nommage pour toujours plus de clarté.

  • DRY

    Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

    The Pragmatic Programmer

    Don’t Repeat Yourself (Ne vous répétez pas) nous pousse à interroger le bien-fondé du code dupliqué. Dans un souci de cohérence et de modularité, il est préférable de supprimer les répétitions qui constituent un système consistant. Le code est alors centralisé dans un espace qui répond à une responsabilité unique (Single Responsibility Principle), la gestion de la donnée de la first-class collection.

Quel concept se cache derrière notre collection de livres ? Une bibliothèque ! La nouvelle classe Library ne se contente pas de contenir une liste de livres (modèle anémique). Elle contient également la gestion de cette collection (modèle riche). Sa logique métier a maintenant un foyer. Le contrôle de la donnée est optimisé. Nous abstrayons son traitement vis-à-vis des classes qui lui sont clientes. L’évolution de la bibliothèque ne devrait plus impacter les différentes parties qui l’utilisent. Notre métier est plus flexible et limpide.

Conclusion

Grâce à notre refactoring, nous avons retiré de la redondance, du couplage fort et de la complexité. En mettant à profit un concept clé de la conception Orientée Objet, l’encapsulation, notre code devient plus lisible, plus expressif et plus maintenable.

En tant que développeuses et développeurs, nous passons plus de temps à lire du code existant qu’à implémenter. Un temps non négligeable a été consacré à analyser la manipulation de la collection. Désormais, la durée de cette lecture est réduite pour les prochaines personnes amenées à travailler avec notre first-class collection.

Références