Le principe de Liskov, ou ce que ça veut dire d'être un enfant

Temps de lecture estimé : 6 minutes

En programmation orientée objet, les participants de l’environnement sont représentés comme des encapsulations autonomes décrivant complètement les attributs et les comportements du sujet. Ainsi, sans que ce soit un prérequis absolu à la POO, il est pratique de faire hériter des objets d’autres pour éviter la répétition ; on dit alors que l’objet A est fils de l’objet B. Seulement voilà, il arrive qu’en réalité A n’est pas vraiment un fils de B. Comment reconnaître ce cas, en quoi c’est grave et comment régler le problème ?

wall spork

C’est là que Barbara Liskov intervient : pour elle, un fils digne de ce nom doit satisfaire la propriété suivante :

Toute propriété vraie pour un objet B doit rester vraie pour un objet A si A est fils de B.
~ B. Liskov

Dit d’une façon informatique, on dit que Liskov impose que l’héritage soit :

  • contravariant (plus général) sur les entrées,
  • covariant (plus spécifique) sur les sorties.

Cela paraît anodin, mais d’expérience ce principe est le plus compliqué à reconnaître et corriger parmi tous les principes SOLID. Pourquoi ? Pour deux raisons :

  1. Parce que le Diable est dans les détails : le principe de Liskov établi que toute propriété vraie pour le parent doit le rester sur l’enfant, autrement dit toute condition d’entrée de fonction, tout résultat, toute exception,
  2. Parce que Liskov est « viral » : dès lors que vous commencez à muter la nature du lien entre A et B, la substituabilité entre les deux change et il devient nécessaire de réparer les dépendances de A. La nature de la dépendance peut également changer à cette occasion.

D’un carré…

L’exemple le plus connu pour expliquer Liskov est la modélisation d’un carré. Imaginons que nous devons décrire un rectangle et concentrons-nous sur les critères principaux d’un rectangle ABCD :

  • c’est un polygone à quatre côtés,
  • les segments sont égaux deux à deux (AB = CD et AD = BC) : une longueur et une largeur indépendantes.

On modélise donc notre rectangle comme ceci :

  class Rectangle(protected var _longueur: Int, protected var _largeur: Int) {
    def longueur(): Int = _longueur

    def largeur(): Int = _largeur

    def setLargeur(valeur: Int): Unit = {
      _largeur = valeur
    }

    def setLongueur(valeur: Int): Unit = {
      _longueur = valeur
    }
  }

Puis un jour, nous devons décrire un carré. Facile, on sait qu’en mathématique un carré est un rectangle particulier où :

  • tous les segments sont égaux (AB = BC = CD = DA)

C’est dans la modélisation que les choses se corsent.

class Carre(protected var _cote: Int) extends Rectangle(_cote, _cote) {}

Ça compile, mais plusieurs problèmes apparaissent à l’usage :

  1. nous ne pouvons plus utiliser un carré dans les mêmes conditions qu’un rectangle puisque la signature du constructeur a changé,
  2. nous avons deux variables qui disent la même chose, ça n’a pas de sens en terme informatique,
  3. la représentation est aussi un problème puisque “longueur” et “largeur” n’ont pas de sens en mathématique non plus,
  4. pour conserver l’identité « Carré », on couple les deux attributs en renforçant les contraintes d’altération, devenant donc covariant sur les entrées :
class Carre(protected var _cote: Int) extends Rectangle(_cote, _cote) {
  override def setLargeur(valeur: Int): Unit = {
    assume(_largeur == _longueur, "PRE: largeur et longueur sont différentes")
    super.setLargeur(valeur)
    super.setLongueur(valeur)
  } ensuring (_largeur == _longueur, "POST: largeur et longueur sont différentes")
}

L’interprétation à faire ici est la suivante : la modélisation de notre rectangle ici est bonne, c’est l’héritage qui ne l’est pas. Nous avons voulu à tout prix coller à la définition mathématique alors que la modélisation des objets diverge.

Pour empêcher ça, nous devons au moment de la modélisation nous astreindre à ne pas réfléchir en terme « réel » mais purement en terme informatique : Quels sont les attributs et comportements de mon participant ? Ces propriétés sont-elles l’extension d’autres ? Ce n’est pas le cas avec un carré, il ne se décrit pas pareil qu’un rectangle. En conséquence, rectangle et carré ont des propriétés communes, mais sont deux entités indépendantes. Autrement dit, ils sont frères, pas parent / fils :

trait Forme {
  def surface(): Int
}

class Rectangle(private var _longueur: Int, private var _largeur: Int)
    extends Forme {
  def surface(): Int = {
    _longueur * _largeur
  }
}

class Carre(private var _cote: Int) extends Forme {
  def surface(): Int = {
    _cote * _cote
  }
}

…Aux yeux vairons

Autre exemple: on peut modéliser un humain comme suit :

object Couleur extends Enumeration {
  type Couleur = Value

  val Bleu = Value("Bleu")
  val Rouge = Value("Rouge")
  var Marron = Value("Marron")
}

class Humain(protected var couleurYeux: Couleur.Couleur) {
  def getCouleurYeux() = {
    couleurYeux
  }
}

Nous découvrons ensuite que certains humains ont une hétérochromie irienne (ou yeux vairons). Puisque le mutant vairon ne se décrit pas pareil que « Humain », appliquons Liskov :

class Vairon(
    private var _couleurOeilGauche: Couleur.Couleur,
    private var _couleurOeilDroit: Couleur.Couleur
) {
  def couleurOeilGauche() = _couleurOeilGauche

  def couleurOeilDroit() = _couleurOeilDroit
}

Une autre mutation arrive : certains humains ont un sixième doigt. Nous pourrions suivre le même procédé, mais ces mutations sont compatibles entre elles, or on ne peut instancier en simultanée en tant que fils de B et de C. Cette fois-ci c’est une tout autre interprétation qui s’impose : si cette application bête et méchante de Liskov est une impasse, c’est parce qu’en réalité c’est notre compréhension d’être « Humain » qui est incomplète. De la même façon qu’un arbre phylogénétique évolue en fonction des connaissances en Biologie, il est parfaitement légitime de réorganiser le rapport entre nos objets.

arbre phylogénétique

Ici, les yeux vairons ou le sixième doigt deviennent des variations dans les propriétés de « Humain ». Il est alors préférable de remettre en question l’héritage lui-même :

// Yeux
object Couleur extends Enumeration {
  type Couleur = Value

  val Bleu = Value("Bleu")
  val Rouge = Value("Rouge")
  var Marron = Value("Marron")
}

trait Yeux {
  def getCouleurs(): Array[Couleur.Couleur]
}

class YeuxCommuns(private var _couleurYeux: Couleur.Couleur) extends Yeux {
  def getCouleurs() = Array(_couleurYeux)
}

class YeuxVairons(
    private var _couleurOeilGauche: Couleur.Couleur,
    private var _couleurOeilDroit: Couleur.Couleur
) extends Yeux {
  def getCouleurs() = Array(_couleurOeilGauche, _couleurOeilDroit)
}

// Main
trait Main {
  def nombreDoigts(): Int
}

class MainCommune extends Main {
  def nombreDoigts() = 5
}

class MainPolydactile(private var _nombreDoigts: Int) extends Main {
  def nombreDoigts() = _nombreDoigts
}
// Humain
class Humain(private var _yeux: Yeux, private var _main: Main) {
  def couleurYeux(): String = {
    _yeux.getCouleurs().mkString(" / ")
  }

  def nombreDoigtsMain(): Int = {
    _main.nombreDoigts()
  }
}

En conclusion, Liskov est un excellent outil, mais il est complexe, et demande un peu d’habitude pour être reconnu et géré de la bonne manière. En effet, ce que Liskov nous apprend, c’est la définition formelle d’être un enfant, elle ne nous dit pas quoi faire de cette information. J’espère donc vous avoir donné les repères pour y parvenir à travers ces exemples. La maintenabilité et l’évolutivité de votre logiciel en dépend.

Comme d’habitude, vous pouvez retrouver le code pour essayer par vous-même sur le dépôt d’exemple.


Sources :


    Ce billet vous a plu ? Partagez-le sur les réseaux…


    … Ou inscrivez-vous à la newsletter pour ne manquer aucun article (Si vous ne voyez pas le formulaire, désactivez temporairement uBlock).

    Voir aussi