Diseño de software

[SOLID #1] Las cinco letras de SOLID: entiéndelas en lugar de memorizarlas (SRP · OCP · LSP)

Seguro que alguna vez os han preguntado en una entrevista: «¿Puedes explicar los principios SOLID?»

4 min de lectura
Imagen de portada de [SOLID #1] Las cinco letras de SOLID: entiéndelas en lugar de memorizarlas (SRP · OCP · LSP)

Seguro que alguna vez os han preguntado en una entrevista: «¿Puedes explicar los principios SOLID?»

Pero, siendo sinceros, aunque muchos pueden recitar los cinco principios, pocos llegan a responder: «Entonces, ¿cómo los aplico a mi código?»

Esta vez explicaré SOLID mediante situaciones prácticas, no con definiciones de libro. Como hay bastante contenido, lo dividiré en dos partes. Hoy veremos las tres primeras letras: SRP, OCP y LSP.

SOLID es el acrónimo de los cinco principios de diseño orientado a objetos recopilados por Robert Martin (Uncle Bob).

  • SRP — Principio de responsabilidad única
  • OCP — Principio de abierto/cerrado
  • LSP — Principio de sustitución de Liskov
  • ISP — Principio de segregación de interfaces
  • DIP — Principio de inversión de dependencias

Los cinco principios parecen hablar de cosas distintas, pero comparten un mensaje: reducir el alcance del código que hay que modificar cuando se produce un cambio.


SRP — Principio de responsabilidad única

La definición de libro dice que «una clase debe tener una sola responsabilidad», pero el propio Uncle Bob la reformuló después de una manera más acertada.

Un módulo debe ser responsable de un único actor.

Aquí, un actor es la persona o el departamento que solicita cambios en ese código. Supongamos que tenemos una clase como esta.

class Employee {
    func calculatePay() { ... }   // El equipo de contabilidad solicita un cambio
    func reportHours() { ... }    // El equipo de recursos humanos solicita un cambio
    func save() { ... }           // El equipo de desarrollo(DBA)solicita un cambio
}

Los tres métodos tienen propietarios distintos. Si al modificar calculatePaypor una petición del equipo de contabilidad tocamos lógica compartida, el informe de recursos humanos puede quedar incorrecto sin que nadie lo note. Cuando ocurre algo así, encontrar la causa resulta difícil.

SRP lo resume así: «Si las personas que solicitan cambios son distintas, separa también el código». Si dividimos el cálculo de nóminas, los informes de trabajo y el almacenamiento en clases distintas, una petición de contabilidad no afectará a la funcionalidad de recursos humanos.


OCP — Principio de abierto/cerrado

La definición es: «abierto a la extensión y cerrado a la modificación». Al principio puede sonar críptico, pero resulta claro al verlo en código.

// Si hay que modificar esta función cada vez que aumenta un método de pago
func pay(method: String, amount: Int) {
    if method == "card" { ... }
    else if method == "kakaopay" { ... }
    else if method == "naverpay" { ... }
    // Nuevo método de pago = modificar else if = el código existente
}

Con esta estructura, para añadir Toss Pay hay que abrir y modificar una función que ya funcionaba. Cada modificación implica el riesgo de romper los métodos de pago existentes.

// Una estructura en la que basta con «añadir» el nuevo método de pago
let payments: [String: Payment] = [
    "card": CardPayment(),
    "kakaopay": KakaoPayment(),
    "naverpay": NaverPayment(),
    // Añadir Toss Pay = registrar una clase nueva en una línea
]

func pay(method: String, amount: Int) {
    payments[method]?.process(amount: amount)
}

El código existente no se toca (cerrado a la modificación) y la funcionalidad crece solo con añadir una clase nueva (abierto a la extensión). La clave es aplicar esta estructura únicamente en los puntos que cambian con frecuencia. Si la aplicas a todo el código, incumplirás YAGNI, como vimos en el artículo anterior.

Añadir Toss Pay se resuelve agregando una sola clase
Añadir Toss Pay se resuelve agregando una sola clase
OCP consiste en responder con extensiones en lugar de modificaciones
OCP consiste en responder con extensiones en lugar de modificaciones

LSP — Principio de sustitución de Liskov

El nombre parece el más intimidante, pero su significado es sencillo.

Una clase hija debe poder sustituir a su clase padre allí donde se use, sin romper el programa.

El contraejemplo más famoso es el problema del rectángulo y el cuadrado. Si heredamos siguiendo la idea matemática de que «un cuadrado es un rectángulo», ocurre lo siguiente.

class Rectangle {
    var width = 0
    var height = 0
    func setWidth(_ w: Int) { width = w }
    func setHeight(_ h: Int) { height = h }
}

class Square: Rectangle {
    override func setWidth(_ w: Int) { width = w; height = w }  // Porque es un cuadrado
    override func setHeight(_ h: Int) { width = h; height = h }
}

// Código que espera un rectángulo
func test(_ rect: Rectangle) {
    rect.setWidth(5)
    rect.setHeight(4)
    print(rect.width * rect.height) // 20espera Squarepero si es un cuadrado 16
}

En cuanto introduces Square, se rompe la expectativa obvia de que «con una anchura de 5 y una altura de 4, el área es 20». Aunque la relación is-a parezca válida, LSP indica que no debemos usar herencia si se rompe el contrato de comportamiento.

La señal práctica es esta: si una clase hija sobrescribe un método del padre para lanzar una excepción o lo deja vacío, es muy probable que esa herencia viole LSP.

Resulta que un cuadrado no puede ocupar el lugar de un rectángulo
Resulta que un cuadrado no puede ocupar el lugar de un rectángulo

Cierre de la primera parte

Dejemos una frase para cada uno de los tres principios vistos hoy.

  • SRP — Si las personas que solicitan cambios son distintas, separa también el código.
  • OCP — Haz que los puntos que cambian con frecuencia respondan mediante extensiones en lugar de modificaciones.
  • LSP — Una clase hija no debe romper los contratos de su padre. Si tiene que hacerlo, la herencia es incorrecta.

En el próximo artículo trataré las dos letras restantes: ISP (segregación de interfaces) y DIP (inversión de dependencias). DIP será especialmente interesante porque se relaciona directamente con por qué los frameworks actuales utilizan inyección de dependencias.