L'extensibilité dans différents langages

/img/post/modularity.jpg

Il arrive fréquemment qu’avec l’évolution des besoins dans le temps, les objets métiers qui ont été définis auparavant nécessitent d’être étendus par l’ajout de nouvelles fonctionnalités. Selon la nature des langages de programmation, mais aussi selon les caractéristiques propres aux langages, les méthodes d’extension varient et peuvent être plus ou moins aisées à mettre en œuvre, en particulier lorsque les extensions sont fournies dans des modules (paquetages, bibliothèques, …) à part et que l’existant ne doit pas être impacté par ces ajouts (ou du moins le minimum possible).

Dans ce petit billet, je voudrais vous présenter certaines d’entre elles, et en particulier dans le contexte présenté ci-dessus, et ceci avec trois langages de programmations différents : Java, un langage impératif (orienté classe), Smalltalk, un des rares langages qui soit vraiment orienté objet, et Haskell, un langage fonctionnel.

Pour illustrer les différentes techniques d’extensibilité, je vous propose un exemple simple de gestion des utilisateurs. Celui-ci s’articule autour du type User et des opérations classiques liées à son profil et à son authentification. Par exemple, en Java :

public class User {
  private Identifier id;
  private final String firstName;
  private String lastName;
  private Credentials credentials = null;

  protected User() {
    // for the persistence engine
  }
  
  public User(final String firstName, final String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.credentials = Credentials.none();
  }

  public String getFirstName() {
    return this.firstName;
  }

  public String getLastName() {
    return this.lastName;
  }

  public String getFullName() {
    return this.firstName + ' ' + this.lastName;
  }
  
  public void setLastName(final String newLastName) {
    this.lastName = newLastName
  }

  public void setCredentials(final Credentials newCredentials) {
    this.credentials = new Credentials(newCredentials);
  }

  public void authenticate(final Credentials auth) throws AuthenticationException {
    this.credentials.check(auth);
  }
}

Nous omettons dans notre exemple, pour plus de clarté, tout ce qui est en rapport avec la persistance.

Smalltalk est plus qu’un langage. C’est avant tout un environnement de développement et d’exécution interactif virtuel complet. La programmation se fait essentiellement via ses outils (en l’occurrence le navigateur de classes et le débogueur) et celle-ci n’est pas structurée autour de fichiers comme avec les autres langages. Tout est maintenu dans une image de machine virtuelle (à l’image des VM VMWare ou VirtualBox) . Aussi, pour plus clarté, dans ce qui suit, j’adopte la notation en vigueur dans la documentation de code de ce langage :

Object subclasses: #User
instanceVariableNames:'firstName lastName credentials'
classVariableNames:''
poolDictionaries:''

User >> initializeFirstName: aFirstName lastName: aLastName
  firstName := aFirstName.
  lastName := aLastName

User class >> firstName: aFirstName lastName: aLastName
  |user| 
  user := self new initializeFirstName: aFirstName lastName: aLastName.
  ^ user

User >> lastName: newLastName
  lastName := newLastName
  
User >> lastName 
  ^ lastName

User >> fullName 
  ^ self firstName, ' ', self lastName

User >> credentials: newCredentials
  credentials := newCredentials copy

User >> authenticateWith: inputCredentials
  credentials check: inputCredentials 

Et pour finir en Haskell :

data User = User { firstName :: String,
                   lastName :: String,  
                   credentials :: Credentials  
                 } deriving (Show)   

fullName u = firstName u ++ ' ' ++ lastName u

authenticate u c = checkCredentials (credentials u) c 

Quelque temps après, il est décidé de rajouter une fonctionnalité de messagerie instantanée et le choix a été de déléguer sa gestion à un service extérieur, l’application proposant juste le client. Pour ce faire, il est nécessaire de rajouter à l’utilisateur les informations relatives à la messagerie instantanée. Par exemple, avec un service XMPP, à minima le domaine et les crédences propres à la connexion au service Jabber distant. Avec des langages comme Java il existe différentes façon de faire :

  • rajouter directement à la classe User les informations nécessaires,
  • étendre la classe User par une nouvelle, par exemple ChatUser,
  • décorer la classe User par une nouvelle classe ou mieux par un trait pour les langages le supportant (un ensemble transverse de propriétés qui peut être tissé à des objets enfin de les enrichir en comportement) et qui serait propre au service de messagerie instantanée.

Dans notre contexte, tout ce qui a trait aux utilisateurs est défini dans un module cœur de l’application et le service de messagerie instantanée est apporté par autre un module, optionnel. De plus on veut étendre le module cœur par les nouvelles fonctionnalités mais sans à entacher celui-ci. Au regard de ceci, la première solution est à éviter. Reste les deux dernières. La dernière solution est séduisante parce qu’elle permet à différents modules de décorer les objets métiers en fonctionnalités additionnelles sans que ceux-ci en aient connaissance. En Java, en général, une classe wrapper est écrite pour ce faire :

public class ChatUser extends User { 
  
  private User wrapped;
  private Credentials chatCredentials;
  private String chatDomain;

  protected ChatUser() {
    // for the persistence engine
  }

  private ChatUser(User user) {
    this.wrapped = user;
  }

  public static ChatUser decorate(final User user) {
    return new ChatUser(user);
  }

  public Credentials getChatCredentials() {
    return this.chatCredentials;
  }

  public void setChatCredentials(final Credentials credentials) {
    this.chatCredentials = credentials;
  }

  public String getChatDomain() {
    return this.chatDomain;
  }

  public void setChatDomain(final String domain) {
    this.chatDomain = domain;
  }
 
  ...
}

Dans notre exemple, la décoration a lieu à l’instanciation via une méthode statique ; ceci laisse l’opportunité d’ajouter des choses supplémentaires dedans, comme par exemple le chargement à partir de la source de données des informations relatives à la messagerie instantanée pour l’utilisateur à décorer. Les autres méthodes héritées de la classe User délèguent leurs appels aux méthodes correspondantes de l’objet encapsulé. Les IDE en général permettent de générer rapidement celles-ci. Comme les nouvelles propriétés ne seront utilisées que par le code du module responsable de la messagerie instantanée, la décoration ne se fera donc que par lui. Là où cette approche est plus délicate à mettre en œuvre est lorsqu’il y a plusieurs modules qui décorent chacun l’objet métier et que certaines décorations peuvent être transverses et utilisées conjointement par un même code :

User aUser = ...; // get in fact a ChatUser decorating the DelegationUser that wraps the User
String fullName = aUser.getFullName(); // ok
String chatDomain = ((ChatUser)aUser).getChatDomain(); // ok
User delegate = ((DeletgationUser)aUser).getDelegation().getDelegate(); // ooops aUser isn't a DelegationUser in first place and hence it doesn't understand this invocation!

On peut s’en sortir par différentes approches, par exemple :

  • le code utilisateur utilise explicitement chaque décoration de l’objet métier, par exemple en décorant à nouveau l’objet métier
  • on met en place de façon simplifiée un POM (Protocol Object Model) dans lequel on associe à chaque objet métier son modèle, un objet générique. Celui-ci encapsule en fait l’objet concret auquel il est associé et fait appel à la rétrospection pour requêter celui-ci. Avec cette approche, il suffit juste de demander au modèle telle propriété de décoration ; on passe donc d’une approche de programmation statique à une approche dynamique avec les problèmes que ça peut soulever dans un langage à typage statique.

Avec Smalltalk, les choses sont beaucoup plus simples. La solution la plus directe est d’enrichir indirectement l’objet métier, chose que l’on peut faire aussi en Ruby, en Groovy, et même en Kotlin. Toutefois, en Smalltalk, non seulement chaque module peut enrichir les objets d’autres modules (et ceux du langage), mais on peut aussi savoir qui a rajouté quoi. Avec notre notation, ça donnerai ceci :

User addInstVarNamed: 'chatDomain chatCredentials'.

User >> chatDomain
  ^ chatDomain

User >> chatDomain: domain
  chatDomain := domain

User >> chatCredentials
  ^ chatCredentials

User >> chatCredentials: newCredentials
  chatCredentials := newCredentials

Cette solution a l’avantage de répondre de façon simple au cas de décorations transverses et utilisées conjointement par un même code. Une autre solution serait, par ce même mécanisme, d’ajouter comme variable d’instance dans la classe User l’objet qui rassemble les propriétés à enrichir, par exemple un objet ChatProfile, et d’utiliser le mécanisme interne de délégation de Smalltalk pour rediriger les envois de messages non compris par User à ses délégués (en l’occurrence ChatProfile) ; ceci se fait simplement en surchargeant la méthode doesNotUnderstand: dont un exemple est donné ci-dessous :

User >> doesNotUnderstand: aMessage
	|return|
	return := aMessage sendTo: chatProfile.
	^ (return class = ChatProfile) 
		ifTrue: [ ^ self ]
		ifFalse: [ ^ return  ]

Évidemment, nous pouvons rendre le code plus générique en définissant, en lieu et place d’une variable d’instance chatProfile, une liste de délégués dans laquelle est rajouté par le module de la messagerie instantanée une instance de ChatProfile. Il suffit alors dans l’implémentation de doesNotUnderstand: de parcourir l’ensemble des délégués pour vérifier si l’un d’eux sait répondre au message reçu en vue de le lui envoyer. Avec cette approche, on rend l’objet métier User fortement extensible en permettant à chaque module de pouvoir ajouter aisément un délégué.

Pour finir, voyons comment faire en Haskell. La solution la plus simple est de définir directement les fonctions qui permettent d’obtenir les informations additionnelles à un utilisateur dans le module correspondant à la nouvelle fonctionnalité. En effet, dans l’approche fonctionnelle, les fonctions sont les briques de base de la composition. Par exemple, de façon simplifiée :

module MyApp.Chat(chatCredentials, chatDomain) where

import MyApp.Core.User
import MyApp.Core.Security
...

data ChatInfo = { domain :: String,
                  credentials :: Credentials }
                deriving (Show, Read)

loadChatInfo u = do
  ...

chatCredentials user = do
  let c <- loadChatInfo user
  return credentials c

chatDomain user = do
  let c <- loadChatInfo user
  return domain c 

Toutefois l’approche la plus correcte et la plus extensible serait de profiter des types algébriques pour définir l’ensemble des propriétés qui sont propre d’une part au profil des utilisateurs de l’application, et d’autre part celles qui sont propre au profil des utilisateurs de la messagerie instantanée, sachant qu’il existe un lien entre un compte utilisateur et celui de la messagerie instantanée. Une solution pourrait être celle-ci :

module MyApp.Core.User (
  Profile(..),
  User(..)
) where

import MyApp.Core.Security
...

class Profile a where
  firstName :: a -> String
  lastName  :: a -> String
  fullName  :: a -> String
  credentials :: a -> Credentials

data User = User String String Credentials deriving (Show, Read)

instance Profile User where
  firstName (User f _ _) = f
  lastName (User _ l _)  = l
  fullName (User f l _) = f ++ " " ++ l
  credentials (User _ _ c) = c

et

module MyApp.Chat (
 ChatUser(..),
 ChatProfile(..)
) where

import MyApp.Core.User
import MyApp.Core.Security
...

class Profile c => ChatProfile c where
 chatDomain :: c -> String
 chatCredentials :: c -> Credentials

data ChatUser u = ChatUser u String Credentials deriving (Show, Read)

instance Profile u => Profile (ChatUser u) where
 firstName (ChatUser u _ _) = firstName u
 lastName (ChatUser u _ _) = lastName u
 fullName (ChatUser u _ _) = fullName u 
 credentials (ChatUser u _ _) = credentials u

instance Profile u => ChatProfile (ChatUser u) where
 chatDomain (ChatUser _ domain _) = domain
 chatCredentials (ChatUser _ _ credentials) = credentials

Ceci nécessite évidemment de devoir réécrire le module des utilisateurs si cette approche n’a pas été initialement adoptée. Mais cette réécriture vaut le coup car, ensuite, les profils utilisateurs pourront être enrichis par des modules extérieurs sans nécessiter de modification des modules cœurs. De plus, en général, dans une application en Haskell, l’approche de modélisation des fonctions métiers par les types algébriques est couramment utilisée.

Ce petit billet est terminé et j’espère que vous aurez apprécié celui-ci. Ce dernier vous donne d’un simple regard les techniques usuelles d’extensibilité de l’existant dans différents langages et la facilité avec laquelle celles-ci peuvent se faire selon, non seulement la nature du langage, mais aussi le paradigme de programmation sous-jacent. ​


comments powered by Disqus