SOLID - Principios del Diseño Orientado a Objetos
Este artículo está basado en el trabajo realizado por Samuel Oloruntoba en su artículo S.O.L.I.D: The First 5 Principles of Object Oriented Design pero usando TypeScript en lugar de PHP para los ejemplos.
SOLID es un acrónimo para los primeros cinco principios del artículo Principles of Object-Oriented Design por Robert C. Martin.
Estos principios ayudan a escribir código mantenible y extensible. También permiten detectar code smells, refactorizar fácilmente y practicar desarrollo ágil.
- S significa SRP - Principio de Responsabilidad Única (Single Responsibility Principle)
- O significa OCP - Principio Abierto-Cerrado (Open-Closed Principle)
- L significa LSP - Principio de Sustitución de Liskov (Liskov Substitution Principle)
- I significa ISP - Principio de Segregación de Interfaz (Interface Segregation Principle)
- D significa DIP - Principio de Inversión de Dependencia (Dependency Inversion Principle)
SRP - Principio de Responsabilidad Única
Una entidad de software (clases, módulos, funciones, etc.) debe tener una, y solo una, razón para cambiar.
Una entidad debe hacer solo una cosa. Responsabilidad única significa trabajo en aislamiento. Si una entidad de software realiza cálculos, la única razón para cambiarla es cuando esos cálculos necesitan cambiar.
Un ejemplo clarifica este principio. Supongamos que debemos implementar una aplicación que calcula el área total de varias formas e imprime el resultado. Empecemos con nuestras clases de formas:
class Circle {public readonly radius: number;constructor(radius: number) {this.radius = radius;}}class Square {public readonly side: number;constructor(side: number) {this.side = side;}}
Ahora creamos una clase AreaCalculator que va a tener la lógica para sumar el
área de nuestras formas.
class AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum(): number {// lógica para sumar las áreas}public output(): string {return `Suma de las áreas de las formas proporcionadas: ${this.sum()}`}
Para usar AreaCalculator, creamos un array de formas, instanciamos la clase y
mostramos la salida.
const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];const areas = new AreaCalculator(shapes);console.log(areas.output());
Esta implementación tiene un problema: AreaCalculator maneja tanto la lógica
de cálculo de áreas como el formato de salida. ¿Qué pasa si el usuario
quiere la salida en JSON?
El Principio de Responsabilidad Única aborda esto. AreaCalculator solo
debería cambiar cuando cambia la lógica de cálculo, no cuando queremos
diferentes formatos de salida.
Lo solucionamos creando una clase cuya única responsabilidad es el formato de salida.
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());
Ahora cada clase tiene una responsabilidad. Cambiar la lógica de cálculo afecta
solo a AreaCalculator; cambiar el formato de salida afecta solo a Outputter.
OCP - Principio Abierto-Cerrado
Las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para extensión, pero cerradas para modificación.
Las entidades de software deberían ser fáciles de extender sin modificar la entidad en sí.
Usando el ejemplo anterior, queremos añadir una nueva forma: el Triángulo.
Primero, examinemos el método sum en 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;}}
Esto viola el Principio Abierto/Cerrado: añadir soporte para triángulos
requiere modificar AreaCalculator con un nuevo bloque else if.
Para solucionarlo, movemos el cálculo del área a cada clase de forma y definimos una interfaz que describe qué puede hacer 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);}}
Ahora AreaCalculator acepta cualquier forma que implemente la interfaz
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 - Principio de Sustitución de Liskov
La clase derivada debe ser sustituible por su clase base.
Los objetos de un programa deben ser reemplazables por instancias de sus subtipos sin alterar la corrección del programa. Una subclase debe preservar el comportamiento y la semántica de estado de su abstracción padre.
Continuando con la clase AreaCalculator, ahora queremos crear una clase
VolumeCalculator que extienda AreaCalculator:
class VolumeCalculator extends AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum(): number[] {// lógica para calcular los volúmenes y luego devolver// un array de salida}}
Una clase Outputter detallada clarifica este ejemplo:
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 las formas proporcionadas: ${this.calculator.sum()}`;}}
Con esta implementación, si intentamos ejecutar un código como este:
const areas = new AreaCalculator(shapes2D);const volumes = new VolumeCalculator(shapes3D);console.log("Areas - ", new Ouputter(areas).text());console.log("Volúmenes - ", new Ouputter(volumes).text());
El programa ejecuta pero produce salida inconsistente:
Areas - Suma de las formas proporcionadas: 42 versus
Volúmenes - Suma de las formas proporcionadas: 13, 15, 14. Esto rompe nuestras
expectativas.
Se viola el Principio de Sustitución de Liskov: VolumeCalculator.sum()
devuelve un array de números, mientras que AreaCalculator.sum() devuelve un
solo número.
La solución: VolumeCalculator.sum() debe devolver un número, no un array.
class VolumeCalculator extends AreaCalculator {// constructorpublic function sum(): number {// lógica para calcular los volúmenes y luego devolver// un array de salidareturn sum;}}
ISP - Principio de Segregación de Interfaz
Haz interfaces de grano fino que sean específicas del cliente.
Mantén las interfaces pequeñas para que los clientes implementen solo los métodos que necesitan.
Nuestra interfaz de forma ahora incluye cálculo de volumen:
interface Shape {area(): number;volume(): number;}
Pero Square es una forma 2D sin volumen, y aún así la interfaz la fuerza a
implementar un método volume.
El Principio de Segregación de Interfaz nos lleva a dividir Shape en
interfaces separadas para formas 2D y 3D:
interface Shape2D {area(): number;}interface Shape3D {volume(): number;}class Cuboid implements Shape2D, Shape3D {public area(): number {// calcular el área de superficie del cuboide}public volume(): number {// calcular el volumen del cuboide}}
DIP - Principio de Inversión de Dependencia
Depende de abstracciones, no de concreciones.
Los módulos de alto nivel deben depender de abstracciones, no de módulos de bajo nivel.
Este principio permite el desacoplamiento. Consideremos una clase ShapeManager
que guarda formas:
class ShapeManager {private database;constructor(database: MySQL) {this.database = database;}public load(name: string): Shape {}}
ShapeManager (alto nivel) depende directamente de MySQL (bajo nivel),
violando el Principio de Inversión de Dependencia.
Cambiar de base de datos requeriría editar ShapeManager, violando también el
Principio Abierto-Cerrado. La solución: depender de una interfaz Database:
interface Database {connect(): Connection;}class MySQL implements Database {public connect(): Connetion {// crea una conexión}}class ShapeManager {private database;constructor(database: Database) {this.database = database;}public load(name: string): Shape {}}
Y ahora nuestros módulos de alto y bajo nivel dependen de abstracciones.
Conclusión
Los principios SOLID pueden parecer difíciles al principio, y saber cuándo aplicarlos requiere práctica. Pero con experiencia, aplicar estos principios se vuelve natural e intuitivo.