SOLID - Principis del Disseny Orientat a Objectes
Aquest article està basat en el treball realitzat per Samuel Oloruntoba en el seu article S.O.L.I.D: The First 5 Principles of Object Oriented Design però utilitzant TypeScript en lloc de PHP per als exemples.
SOLID és un acrònim per als primers cinc principis de l'article Principles of Object-Oriented Design per Robert C. Martin.
Aquests principis ajuden a escriure codi mantenible i extensible. També permeten detectar code smells, refactoritzar fàcilment i practicar desenvolupament àgil.
- S significa SRP - Principi de Responsabilitat Única (Single Responsibility Principle)
- O significa OCP - Principi Obert-Tancat (Open-Closed Principle)
- L significa LSP - Principi de Substitució de Liskov (Liskov Substitution Principle)
- I significa ISP - Principi de Segregació d'Interfície (Interface Segregation Principle)
- D significa DIP - Principi d'Inversió de Dependència (Dependency Inversion Principle)
SRP - Principi de Responsabilitat Única
Una entitat de programari (classes, mòduls, funcions, etc.) ha de tenir una, i només una, raó per canviar.
Una entitat ha de fer només una cosa. Responsabilitat única significa feina en aïllament. Si una entitat de programari realitza càlculs, l'única raó per canviar-la és quan aquests càlculs necessiten canviar.
Un exemple clarifica aquest principi. Suposem que hem d'implementar una aplicació que calcula l'àrea total de diverses formes i imprimeix el resultat. Comencem amb les nostres classes de formes:
class Circle {public readonly radius: number;constructor(radius: number) {this.radius = radius;}}class Square {public readonly side: number;constructor(side: number) {this.side = side;}}
Ara creem una classe AreaCalculator que tindrà la lògica per sumar l'àrea de
les nostres formes.
class AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum(): number {// lògica per sumar les àrees}public output(): string {return `Suma de les àrees de les formes proporcionades: ${this.sum()}`}
Per utilitzar AreaCalculator, creem un array de formes, instanciem la classe i
mostrem la sortida.
const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];const areas = new AreaCalculator(shapes);console.log(areas.output());
Aquesta implementació té un problema: AreaCalculator gestiona tant la lògica
de càlcul d'àrees com el format de sortida. Què passa si l'usuari vol la
sortida en JSON?
El Principi de Responsabilitat Única aborda això. AreaCalculator només
hauria de canviar quan canvia la lògica de càlcul, no quan volem diferents
formats de sortida.
Ho solucionem creant una classe la única responsabilitat de la qual és el format de sortida.
const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];const areas = new AreaCalculator(shapes);const output = new Outputter(areas);console.log(output.text());console.log(output.json());
Ara cada classe té una responsabilitat. Canviar la lògica de càlcul afecta només
a AreaCalculator; canviar el format de sortida afecta només a Outputter.
OCP - Principi Obert-Tancat
Les entitats de programari (classes, mòduls, funcions, etc.) han d'estar obertes per a extensió, però tancades per a modificació.
Les entitats de programari haurien de ser fàcils d'estendre sense modificar l'entitat en si.
Utilitzant l'exemple anterior, volem afegir una nova forma: el Triangle.
Primer, examinem el mètode sum a AreaCalculator.
class AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum() {let sum: number = 0;for (let shape of this.shapes) {if (shape instanceof Circle) {sum += Math.PI * Math.pow(shape.radius, 2);} else if (shape instanceof Square) {sum += shape.side * shape.side;}}return sum;}}
Això viola el Principi Obert/Tancat: afegir suport per a triangles requereix
modificar AreaCalculator amb un nou bloc else if.
Per solucionar-ho, movem el càlcul de l'àrea a cada classe de forma i definim una interfície que descriu què pot fer una forma.
interface Shape {area(): number;}class Circle implements Shape {public readonly radius: number;constructor(radius: number) {this.radius = radius;}public area(): number {return Math.PI * Math.pow(this.radius, 2);}}
Ara AreaCalculator accepta qualsevol forma que implementi la interfície
Shape:
class AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum(): number {let sum: number = 0;for (let shape of this.shapes) {sum += shape.area();}return sum;}}
LSP - Principi de Substitució de Liskov
La classe derivada ha de ser substituïble per la seva classe base.
Els objectes d'un programa han de ser reemplaçables per instàncies dels seus subtipus sense alterar la correcció del programa. Una subclasse ha de preservar el comportament i la semàntica d'estat de la seva abstracció pare.
Continuant amb la classe AreaCalculator, ara volem crear una classe
VolumeCalculator que estengui AreaCalculator:
class VolumeCalculator extends AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum(): number[] {// lògica per calcular els volums i després retornar// un array de sortida}}
Una classe Outputter detallada clarifica aquest exemple:
class Outputer {private calculator;constructor(calculator: AreaCalculator) {this.calculator = calculator;}public json(): string {return JSON.stringify({sum: this.calculator.sum();})}public text(): string {return `Suma de les formes proporcionades: ${this.calculator.sum()}`;}}
Amb aquesta implementació, si intentem executar un codi com aquest:
const areas = new AreaCalculator(shapes2D);const volumes = new VolumeCalculator(shapes3D);console.log("Areas - ", new Ouputter(areas).text());console.log("Volums - ", new Ouputter(volumes).text());
El programa executa però produeix sortida inconsistent:
Areas - Suma de les formes proporcionades: 42 versus
Volums - Suma de les formes proporcionades: 13, 15, 14. Això trenca les
nostres expectatives.
Es viola el Principi de Substitució de Liskov: VolumeCalculator.sum()
retorna un array de nombres, mentre que AreaCalculator.sum() retorna un sol
nombre.
La solució: VolumeCalculator.sum() ha de retornar un nombre, no un array.
class VolumeCalculator extends AreaCalculator {// constructorpublic function sum(): number {// lògica per calcular els volums i després retornar// un array de sortidareturn sum;}}
ISP - Principi de Segregació d'Interfície
Fes interfícies de gra fi que siguin específiques del client.
Mantén les interfícies petites perquè els clients implementin només els mètodes que necessiten.
La nostra interfície de forma ara inclou càlcul de volum:
interface Shape {area(): number;volume(): number;}
Però Square és una forma 2D sense volum, i tot i així la interfície la força a
implementar un mètode volume.
El Principi de Segregació d'Interfície ens porta a dividir Shape en
interfícies separades per a formes 2D i 3D:
interface Shape2D {area(): number;}interface Shape3D {volume(): number;}class Cuboid implements Shape2D, Shape3D {public area(): number {// calcular l'àrea de superfície del cuboide}public volume(): number {// calcular el volum del cuboide}}
DIP - Principi d'Inversió de Dependència
Depèn d'abstraccions, no de concrecions.
Els mòduls d'alt nivell han de dependre d'abstraccions, no de mòduls de baix nivell.
Aquest principi permet el desacoblament. Considerem una classe ShapeManager
que guarda formes:
class ShapeManager {private database;constructor(database: MySQL) {this.database = database;}public load(name: string): Shape {}}
ShapeManager (alt nivell) depèn directament de MySQL (baix nivell), violant
el Principi d'Inversió de Dependència.
Canviar de base de dades requeriria editar ShapeManager, violant també el
Principi Obert-Tancat. La solució: dependre d'una interfície Database:
interface Database {connect(): Connection;}class MySQL implements Database {public connect(): Connetion {// crea una connexió}}class ShapeManager {private database;constructor(database: Database) {this.database = database;}public load(name: string): Shape {}}
I ara els nostres mòduls d'alt i baix nivell depenen d'abstraccions.
Conclusió
Els principis SOLID poden semblar difícils al principi, i saber quan aplicar-los requereix pràctica. Però amb experiència, aplicar aquests principis es torna natural i intuïtiu.