Skip to the content.

Welcome here! | Articles | Main projects | About me

(FR) SOLID 2/5 - L’OCP


Introduction

Cet article est le second d’une série dédiée aux principes SOLID. Pour consulter le premier, c’est par ici !

L’OCP (Open / Closed Principle, soit “principe ouvert / fermé” en français) énonce la règle suivante :

Toute entité (classe, fonction, …) doit être ouverte aux extensions, mais fermée aux modifications.

Formulé comme ça, l’OCP est encore moins clair que la définition d’une monade. Pourtant, la bonne pratique derrière cette règle est extrêmement simple :

Tout code testé et validé ne doit pas être modifié afin de ne pas intégrer de régression.

Mais du coup, comment étendre un code étant à la fois “ouvert” et “fermé” ?

Voyons tout ça avec quelques exemples ! À cet effet, j’emploierai le langage Go parce que je fais ce que je veux afin de varier un peu.


Cas d’école : l’aire d’une forme géométrique

L’exemple le plus classique, très évocateur, est celui du calcul de l’aire d’une forme géométrique sans connaître ladite forme. Pimentons un peu l’exercice en créant plutôt une fonction faisant la somme des aires d’une liste de formes géométriques :

type Rectangle struct {
    Width  float32
    Height float32
}

type Circle struct {
    Radius float32
}

// Note : le type 'interface{}' signifie "n'importe quel type"
func sumAreas(values ...interface{}) float32 {
    var sum float32
    for _, value := range values {
        switch shape := value.(type) {
        case Rectangle:
            sum += shape.Width * shape.Height
        case Circle:
            sum += math.Pi * shape.Radius * shape.Radius
        // ...
        }
        panic("unknown shape :(") // Si l'on ne connait pas la forme
    }
    return sum
}

Mettons de côté l’absence d’invariants liés au type float32, cet exemple n’étant là qu’à but illustratif.

L’approche de cette fonction est simple : vérifier le type de chaque forme via un switch, et appliquer une formule en conséquence. Par ailleurs, la vérification du type de value étant effectuée au runtime, le code suivant est parfaitement valide :

rectangle := Rectangle{Width: 10, Height: 20}
circle := Circle{Radius: 20}
impostor := "red" // Pas une forme géométrique !

fmt.Println(sumAreas(rectangle, circle, impostor)) // Compile, mais...

Output :

200
125.66371
panic: unknown shape :(

goroutine 1 [running]:
main.area(...)
	/path/to/file.go:X
main.main()
	/path/to/file.go:Y +0x13a

Program exited: status 2.

Comme prévu, notre imposteur s’est fait démasquer et l’appel à panic() a bien eu lieu.

Outre le problème lié à la généricité trop permissive, notre fonction sumAreas() a une autre faille : si l’on doit rajouter une forme, il faut la modifier. Cela induit que l’OCP n’est pas respecté, car sumAreas() n’est pas fermée aux modifications. Or, la modifier induit que l’on peut introduire une régression, ce qui est précisément la problématique à laquelle répond l’OCP.


Solution

La solution au problème évoqué ci-dessus est évidente : utiliser une couche d’abstraction (ici une interface) plutôt que de vérifier à la main le type de notre variable.

Note - D’une manière générale, le RTTI est bien souvent une mauvaise idée et son utilisation reste par défaut à prohiber.

Pour ce faire, il nous faut d’abord créer l’interface adéquate :

type Shape interface {
    area() float32
}

Cette interface apporte deux garanties :

À présent, implémentons la méthode area() pour nos types Rectangle et Circle !

func (rectangle Rectangle) area() float32 {
    return rectangle.Width * rectangle.Height
}

func (circle Circle) area() float32 {
    return math.Pi * circle.Radius * circle.Radius
}

Enfin, notre fonction sumAreas() devient quant à elle :

func sumAreas(shapes ...Shape) float32 {
    var sum float32
    for _, shape := range shapes {
        sum += shape.area()
    }
    return sum
}

Retentons l’exemple de la partie précédente :

rectangle := Rectangle{Width: 10, Height: 20}
circle := Circle{Radius: 20}
impostor := "red"

fmt.Println(sumAreas(rectangle, circle, impostor))

Sortie :

/path/to/file.go:X:Y: cannot use impostor (type string) as type Shape in argument to sumAreas:
	string does not implement Shape (missing area method)

Paf ! Notre imposteur ne passe pas le contrôle technique et le code ne compile pas !

Mais surtout, cette option propose deux avantages :

Ainsi, notre fonction respecte l’OCP : elle est ouverte aux extensions (par le biais de Shape), mais fermée aux modifications car son code ne changera pas ! Une fois celle-ci correctement testée, nous aurons alors la garantie qu’elle fera bien ce que l’on attend d’elle !


Conclusion

Soyons clairs : avoir un code qui respecte à 100% l’OCP n’est pas réaliste. Sur un projet conséquent, il n’est pas possible d’avoir un code complètement extensible sans être modifiable. Toutefois, il est possible d’en concevoir une partie qui, elle, respecte l’OCP.

Pour ce faire, une solution possible est d’employer l’héritage (l’implémentation d’interfaces étant une forme d’héritage), de manière à ce que seul le code client soit modifié pour appeler une fonction ou méthode, qui, elle, ne changera pas. Par exemple, on peut imposer des invariants à une entité afin d’en assurer la cohérence avec notre code client, comme le type Shape vu ci-dessus qui impose la présence d’une méthode area() afin d’appeler sumAreas(). Notons toutefois qu’il est préférable d’avoir le moins de méthodes possibles au sein d’une même interface (comme le suggère l’ISP, que nous verrons plus tard) afin de ne pas introduire de comportement inutile à un type.