VOLVER

SOLID - Principios del Diseño Orientado a Objetos

9 min de lectura

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.

Aplicar estos principios ayuda a desarrollar código mantenible y extensible. También ayudan a detectar code smells, refactorizar código fácilmente, y practicar un buen 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.

Este principio significa que una entidad debe hacer solo una cosa. Así que responsabilidad única denota algún trabajo en aislamiento. Por lo tanto si tenemos una entidad de software que realiza algunos cálculos la única razón para cambiarla es si estos cálculos necesitan cambiar.

Para entender mejor el principio, podemos hacer un ejemplo. Digamos que tenemos que implementar una aplicación que dadas algunas formas calcula la suma del área de estas formas e imprime la salida. Así que, empecemos creando 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 el AreaCalculator tenemos que crear un array de formas, instanciar la clase y mostrar la salida.

const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];
const areas = new AreaCalculator(shapes);
console.log(areas.output());

Pero esta implementación tiene un problema. En este ejemplo, AreaCalculator maneja la lógica para calcular la suma de las áreas y para mostrar los datos. ¿Qué pasa si el usuario quiere la salida en JSON?

Aquí es cuando el Principio de Responsabilidad Única entra en juego. AreaCalculator solo debería cambiar si cambiamos cómo calculamos la suma de las áreas, no cuando queremos una salida o representación diferente.

Podemos arreglar esto implementando una clase cuya única responsabilidad es mostrar los datos.

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 tenemos dos clases con una responsabilidad cada una, si queremos cambiar cómo se hacen los cálculos solo AreaCalculator cambiará y lo mismo para cambiar la salida, afectará 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.

Una propiedad deseable que nuestras entidades de software es ser fácil de extender su funcionalidad sin la necesidad de cambiar la entidad en sí misma.

Usando el ejemplo anterior, ahora queremos introducir una nueva forma elegante: el Triángulo. Pero primero, echa un vistazo más de cerca a la parte de suma de nuestra clase 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;
}
}

Aquí estamos violando el Principio Abierto/Cerrado, porque para añadir soporte para triángulos tenemos que modificar AreaCalculator añadiendo un nuevo bloque else if para manejar el cálculo de la nueva área.

Para arreglar esto podemos mover el código que calcula el área a las formas correspondientes, y hacer que esas formas implementen una interfaz que describa mejor 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 el AreaCalculator se parece al código de abajo, que nos permite crear nuevo tipo de formas y funcionará siempre que esta nueva forma 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.

Lo que este principio significa es que los objetos en un programa deben ser reemplazables con instancias de sus subtipos sin alterar la corrección de ese programa. Así que si pasas una subclase de una abstracción necesitas asegurarte de que no alteras ningún comportamiento o semántica de estado de la 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
}
}

Para entender mejor este ejemplo hagamos una versión más detallada de la clase Outputter.

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 no va a fallar pero la salida no será consistente ya que una salida será algo como Areas - Suma de las formas proporcionadas: 42, y la otra Volúmenes - Suma de las formas proporcionadas: 13, 15, 14. Esto no es lo que esperamos de nuestro programa.

Esto sucede porque la violación del Principio de Sustitución de Liskov, el método sum de la clase VolumeCalculator es un array de números mientras que el AreaCalculator es solo un número.

Para arreglar esto tenemos que reimplementar el método sum de VolumeCalculator para devolver un número en lugar de un array.

class VolumeCalculator extends AreaCalculator {
// constructor
public function sum(): number {
// lógica para calcular los volúmenes y luego devolver
// un array de salida
return sum;
}
}

ISP - Principio de Segregación de Interfaz

Haz interfaces de grano fino que sean específicas del cliente.

En este caso, queremos mantener las interfaces lo más pequeñas posible, para que los clientes no sean forzados a implementar métodos que realmente no necesitan.

Así que, volviendo a nuestra interfaz de forma, ahora que podemos calcular volúmenes nuestra interfaz se ve similar a esto:

interface Shape {
area(): number;
volume(): number;
}

Pero sabemos que no todas nuestras formas tienen un volumen, Square es una forma 2D pero debido a la interfaz, estamos forzados a implementar un método volume.

Aplicando el Principio de Segregación de Interfaz dividimos la interfaz Shape en dos interfaces diferentes, una para definir formas 2D y otra para formas 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.

Lo que este principio viene a decir es que los módulos de alto nivel no deben depender de módulos de bajo nivel, sino que deben depender de abstracciones.

Este principio permite el desacoplamiento, un ejemplo que parece la mejor manera de explicar este principio. Veamos una nueva clase para guardar nuestras formas ShapeManager:

class ShapeManager {
private database;
constructor(database: MySQL) {
this.database = database;
}
public load(name: string): Shape {}
}

En este caso, ShapeManager es un módulo de alto nivel mientras que MySQL es un módulo de bajo nivel, pero esto es una violación del Principio de Inversión de Dependencia ya que estamos forzados a depender de MySQL.

Si queremos cambiar nuestra base de datos en el futuro tendríamos que editar la clase ShapeManager y así viola el Principio Abierto-Cerrado. En este caso no debería importarnos qué tipo de base de datos estamos usando, así que para depender de abstracciones en este caso haremos uso de una interfaz:

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

Al empezar a escribir programación Orientada a Objetos los principios SOLID pueden ser difíciles de entender y, si se entienden, ver dónde y cuándo aplicarlos no es trivial. Pero son un ejemplo de una de las cosas más importantes en el desarrollo de software, la práctica y la experiencia te harán aplicar estos principios de una manera muy natural e intuitiva.