La programación orientada a objetos ha traído nuevos enfoques al diseño de aplicaciones en el desarrollo de software. En particular, OOP permitió a los programadores combinar entidades, unidas por un objetivo o funcionalidad común, en clases separadas, diseñadas para resolver problemas independientes e independientes de otras partes de la aplicación. Sin embargo, el uso de OOP solo no significa que el desarrollador esté a salvo de la posibilidad de crear código oscuro y confuso que sea difícil de mantener. Robert Martin, para ayudar a todos los que desean desarrollar aplicaciones OOP de alta calidad, desarrolló cinco principios de programación y diseño orientado a objetos, hablando de que, con la ayuda de Michael Fazers, usan el acrónimo SOLID.

El material, cuya traducción publicamos hoy, está dedicado a los conceptos básicos de SOLID y está destinado a desarrolladores principiantes.
¿Qué es sólido?
Así es como el acrónimo SOLID significa:
- S: Principio de responsabilidad única.
- O: Principio abierto-cerrado.
- L: Principio de sustitución de Liskov (Principio de sustitución de Barbara Liskov).
- I: Principio de segregación de interfaz.
- D: Principio de inversión de dependencia.
Ahora consideraremos estos principios en ejemplos esquemáticos. Tenga en cuenta que el objetivo principal de los ejemplos es ayudar al lector a comprender los principios de SOLID, aprender cómo aplicarlos y cómo seguirlos al diseñar aplicaciones. El autor del material no se esforzó por alcanzar un código de trabajo que pudiera usarse en proyectos reales.
Principio de responsabilidad exclusiva
“Un recado. Solo una cosa. - Loki le dice a Skurge en la película Thor: Ragnarok.
Cada clase debe resolver solo un problema.Una clase solo debe ser responsable de una cosa. Si una clase es responsable de resolver varios problemas, sus subsistemas que implementan la solución de estos problemas se relacionan entre sí. Los cambios en uno de esos subsistemas conducen a cambios en otro.
Tenga en cuenta que este principio se aplica no solo a las clases, sino también a los componentes de software en un sentido más amplio.
Por ejemplo, considere este código:
class Animal { constructor(name: string){ } getAnimalName() { } saveAnimal(a: Animal) { } }
La clase
Animal
presentada aquí describe algún tipo de animal. Esta clase viola el principio de responsabilidad exclusiva. ¿Cómo se viola este principio exactamente?
De acuerdo con el principio de responsabilidad exclusiva, una clase debe resolver una sola tarea. Resuelve los dos trabajando con el almacén de datos en el método
saveAnimal
y manipulando las propiedades del objeto en el constructor y en el método
getAnimalName
.
¿Cómo puede una estructura de clase así conducir a problemas?
Si el procedimiento para trabajar con el almacén de datos utilizado por la aplicación cambia, deberá realizar cambios en todas las clases que funcionan con el almacén. Esta arquitectura no es flexible, los cambios en algunos subsistemas afectan a otros, lo que se asemeja al efecto dominó.
Para alinear el código anterior con el principio de responsabilidad exclusiva, crearemos otra clase cuya única tarea es trabajar con el repositorio, en particular, almacenar objetos de la clase
Animal
en él:
class Animal { constructor(name: string){ } getAnimalName() { } } class AnimalDB { getAnimal(a: Animal) { } saveAnimal(a: Animal) { } }
Esto es lo que dice Steve Fenton sobre esto: “Al diseñar clases, debemos esforzarnos por integrar componentes relacionados, es decir, aquellos en los que se producen cambios por las mismas razones. Deberíamos tratar de separar los componentes, cambios en los que se producen varias razones ".
La correcta aplicación del principio de responsabilidad exclusiva conduce a un alto grado de conectividad de los elementos dentro del módulo, es decir, al hecho de que las tareas resueltas dentro de él corresponden bien a su objetivo principal.
Principio abierto-cerrado
Las entidades de software (clases, módulos, funciones) deben estar abiertas para expansión, pero no para modificación.Seguimos trabajando en la clase
Animal
.
class Animal { constructor(name: string){ } getAnimalName() { } }
Queremos clasificar la lista de animales, cada uno de los cuales está representado por un objeto de la clase
Animal
, y descubrir qué sonidos emiten. Imagine que resolvemos este problema usando la función
AnimalSounds
:
//... const animals: Array<Animal> = [ new Animal('lion'), new Animal('mouse') ]; function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(a[i].name == 'lion') return 'roar'; if(a[i].name == 'mouse') return 'squeak'; } } AnimalSound(animals);
El principal problema con esta arquitectura es que la función determina qué tipo de sonido hace un animal al analizar objetos específicos. La función
AnimalSound
no cumple con el principio de apertura-cierre, ya que, por ejemplo, cuando aparecen nuevos tipos de animales, debemos cambiarlo para poder utilizarlo y reconocer los sonidos que emiten.
Agregue un nuevo elemento a la matriz:
//... const animals: Array<Animal> = [ new Animal('lion'), new Animal('mouse'), new Animal('snake') ] //...
Después de eso, tenemos que cambiar el código de la función
AnimalSound
:
//... function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(a[i].name == 'lion') return 'roar'; if(a[i].name == 'mouse') return 'squeak'; if(a[i].name == 'snake') return 'hiss'; } } AnimalSound(animals);
Como puede ver, al agregar un nuevo animal a la matriz, tendrá que complementar el código de función. Un ejemplo es muy simple, pero si se utiliza una arquitectura similar en un proyecto real, la función tendrá que expandirse constantemente, agregando nuevas expresiones
if
.
¿Cómo alinear la función
AnimalSound
con el principio de abierto-cerrado? Por ejemplo, así:
class Animal { makeSound(); //... } class Lion extends Animal { makeSound() { return 'roar'; } } class Squirrel extends Animal { makeSound() { return 'squeak'; } } class Snake extends Animal { makeSound() { return 'hiss'; } } //... function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { a[i].makeSound(); } } AnimalSound(animals);
Puede notar que la clase
Animal
ahora tiene un método virtual
makeSound
. Con este enfoque, es necesario que las clases diseñadas para describir animales específicos expandan la clase
Animal
e implementen este método.
Como resultado, cada clase que describa un animal tendrá su propio método
makeSound
, y al iterar sobre una matriz con animales en la función
AnimalSound
, será suficiente llamar a este método para cada elemento de la matriz.
Si ahora agrega un objeto que describe el nuevo animal a la matriz, no tendrá que cambiar la función
AnimalSound
. Lo alineamos con el principio de apertura-cercanía.
Considere otro ejemplo.
Supongamos que tenemos una tienda. Les damos a los clientes un 20% de descuento usando esta clase:
class Discount { giveDiscount() { return this.price * 0.2 } }
Ahora se decidió dividir a los clientes en dos grupos. Los clientes favoritos (
fav
) reciben un descuento del 20% y los clientes VIP (
vip
), el doble del descuento, es decir, el 40%. Para implementar esta lógica, se decidió modificar la clase de la siguiente manera:
class Discount { giveDiscount() { if(this.customer == 'fav') { return this.price * 0.2; } if(this.customer == 'vip') { return this.price * 0.4; } } }
Este enfoque viola el principio de apertura-cercanía. Como puede ver, aquí, si necesitamos otorgar un descuento especial a cierto grupo de clientes, tenemos que agregar un nuevo código a la clase.
Para procesar este código de acuerdo con el principio de apertura-cercanía, agregamos una nueva clase al proyecto que extiende la clase
Discount
. En esta nueva clase, estamos implementando un nuevo mecanismo:
class VIPDiscount: Discount { getDiscount() { return super.getDiscount() * 2; } }
Si decide dar un descuento del 80% a los clientes "súper VIP", debería verse así:
class SuperVIPDiscount: VIPDiscount { getDiscount() { return super.getDiscount() * 2; } }
Como puede ver, aquí se usa el empoderamiento de las clases, no su modificación.
El principio de sustitución de Barbara Liskov
Es necesario que las subclases sirvan como sustitutos de sus superclases.El propósito de este principio es que las clases de herencia se pueden usar en lugar de las clases primarias a partir de las cuales se forman sin interrumpir el programa. Si resulta que el tipo de clase está marcado en el código, entonces se viola el principio de sustitución.
Considere la aplicación de este principio, volviendo al ejemplo con la clase
Animal
. Escribiremos una función diseñada para devolver información sobre el número de extremidades de un animal.
//... function AnimalLegCount(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(typeof a[i] == Lion) return LionLegCount(a[i]); if(typeof a[i] == Mouse) return MouseLegCount(a[i]); if(typeof a[i] == Snake) return SnakeLegCount(a[i]); } } AnimalLegCount(animals);
La función viola el principio de sustitución (y el principio de apertura-cierre). Este código debe conocer los tipos de todos los objetos procesados por él y, según el tipo, usar la función correspondiente para calcular las extremidades de un animal en particular. Como resultado, al crear un nuevo tipo de animal, la función deberá reescribirse:
//... class Pigeon extends Animal { } const animals[]: Array<Animal> = [ //..., new Pigeon(); ] function AnimalLegCount(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(typeof a[i] == Lion) return LionLegCount(a[i]); if(typeof a[i] == Mouse) return MouseLegCount(a[i]); if(typeof a[i] == Snake) return SnakeLegCount(a[i]); if(typeof a[i] == Pigeon) return PigeonLegCount(a[i]); } } AnimalLegCount(animals);
Para que esta función no viole el principio de sustitución, la transformamos utilizando los requisitos formulados por Steve Fenton. Consisten en el hecho de que los métodos que aceptan o devuelven valores con el tipo de una determinada superclase (
Animal
en nuestro caso) también deberían aceptar y devolver valores cuyos tipos son sus subclases (
Pigeon
).
Armados con estas consideraciones, podemos rehacer la función
AnimalLegCount
:
function AnimalLegCount(a: Array<Animal>) { for(let i = 0; i <= a.length; i++) { a[i].LegCount(); } } AnimalLegCount(animals);
Ahora esta función no está interesada en los tipos de objetos que se le pasan. Ella simplemente llama a sus métodos
LegCount
. Todo lo que sabe sobre los tipos es que los objetos que procesa deben pertenecer a la clase
Animal
o sus subclases.
El método
LegCount
ahora debería aparecer en la clase
Animal
:
class Animal {
Y sus subclases necesitan implementar este método:
//... class Lion extends Animal{ //... LegCount() { //... } } //...
Como resultado, por ejemplo, al acceder al método
LegCount
para una instancia de la clase
Lion
, se llama al método implementado en esta clase y se devuelve exactamente lo que se puede esperar al llamar a dicho método.
Ahora la función
AnimalLegCount
no necesita saber qué objeto de una subclase particular de la clase
Animal
procesa para encontrar información sobre el número de extremidades en el animal representado por este objeto. La función simplemente llama al método
LegCount
de la clase
Animal
, ya que las subclases de esta clase deben implementar este método para que puedan usarse en su lugar, sin violar la operación correcta del programa.
Principio de separación de interfaz
Cree interfaces altamente especializadas diseñadas para un cliente específico. Los clientes no deben depender de las interfaces que no usan.Este principio tiene como objetivo abordar las deficiencias asociadas con la implementación de interfaces grandes.
Considere la interfaz de
Shape
:
interface Shape { drawCircle(); drawSquare(); drawRectangle(); }
Describe métodos para dibujar círculos (
drawCircle
), cuadrados (
drawSquare
) y rectángulos (
drawRectangle
). Como resultado, las clases que implementan esta interfaz y representan formas geométricas individuales, como un círculo, un cuadrado y un rectángulo, deben contener una implementación de todos estos métodos. Se ve así:
class Circle implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } } class Square implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } } class Rectangle implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } }
Código extraño resultó. Por ejemplo, la clase
Rectangle
representa un rectángulo implementa métodos (
drawCircle
y
drawSquare
) que no necesita en absoluto. Lo mismo se puede ver al analizar el código de otras dos clases.
Supongamos que decidimos agregar otro método a la interfaz
Shape
,
drawTriangle
, diseñado para dibujar triángulos:
interface Shape { drawCircle(); drawSquare(); drawRectangle(); drawTriangle(); }
Esto dará como resultado que las clases que representan formas geométricas específicas tengan que implementar también el método
drawTriangle
. De lo contrario, se producirá un error.
Como puede ver, con este enfoque es imposible crear una clase que implemente un método para generar un círculo, pero no implementa métodos para derivar un cuadrado, un rectángulo y un triángulo. Dichos métodos se pueden implementar de modo que cuando se emiten, se produce un error que indica que tal operación no se puede realizar.
El principio de separación de interfaz nos advierte contra la creación de interfaces como
Shape
partir de nuestro ejemplo. Los clientes (tenemos las clases
Circle
,
Square
y
Rectangle
) no deben implementar métodos que no necesitan usar. Además, este principio indica que la interfaz debe resolver solo una tarea en particular (en esto es similar al principio de responsabilidad exclusiva), por lo tanto, todo lo que va más allá del alcance de esta tarea debe transferirse a otra interfaz o interfaces.
En nuestro caso, la interfaz
Shape
resuelve problemas para cuya solución es necesario crear interfaces separadas. Siguiendo esta idea, reelaboramos el código creando interfaces separadas para resolver varias tareas altamente especializadas:
interface Shape { draw(); } interface ICircle { drawCircle(); } interface ISquare { drawSquare(); } interface IRectangle { drawRectangle(); } interface ITriangle { drawTriangle(); } class Circle implements ICircle { drawCircle() {
Ahora la interfaz
ICircle
usa solo para dibujar círculos, así como otras interfaces especializadas para dibujar otras formas. La interfaz
Shape
se puede utilizar como una interfaz universal.
Principio de inversión de dependencia
El objeto de dependencia debe ser una abstracción, no algo específico.- Los módulos de nivel superior no deben depender de los módulos de nivel inferior. Ambos tipos de módulos deberían depender de abstracciones.
- Las abstracciones no deberían depender de los detalles. Los detalles deben depender de las abstracciones.
En el proceso de desarrollo de software, hay un momento en que la funcionalidad de la aplicación deja de encajar en el mismo módulo. Cuando esto sucede, tenemos que resolver el problema de las dependencias del módulo. Como resultado, por ejemplo, puede resultar que los componentes de alto nivel dependan de los componentes de bajo nivel.
class XMLHttpService extends XMLHttpRequestService {} class Http { constructor(private xmlhttpService: XMLHttpService) { } get(url: string , options: any) { this.xmlhttpService.request(url,'GET'); } post() { this.xmlhttpService.request(url,'POST'); }
Aquí, la clase
Http
es un componente de alto nivel, y
XMLHttpService
es un componente de bajo nivel. Tal arquitectura viola la cláusula A del principio de inversión de dependencia: “Los módulos de niveles superiores no deberían depender de módulos de niveles inferiores. Ambos tipos de módulos deberían depender de abstracciones ".
La clase
Http
se ve obligada a depender de la clase
XMLHttpService
. Si decidimos cambiar el mecanismo utilizado por la clase
Http
para interactuar con la red, digamos que será un servicio Node.js o, por ejemplo, un servicio de código auxiliar utilizado para fines de prueba, tendremos que editar todas las instancias de la clase
Http
cambiando el código correspondiente. Esto viola el principio de apertura-cercanía.
La clase
Http
no debe saber qué se usa exactamente para establecer una conexión de red. Por lo tanto, crearemos la interfaz de
Connection
:
interface Connection { request(url: string, opts:any); }
La interfaz de
Connection
contiene una descripción del método de
request
y pasamos el argumento de tipo de
Connection
a la clase
Http
:
class Http { constructor(private httpConnection: Connection) { } get(url: string , options: any) { this.httpConnection.request(url,'GET'); } post() { this.httpConnection.request(url,'POST'); }
Ahora, independientemente de lo que se use para organizar la interacción con la red, la clase
Http
puede usar lo que se le pasó, sin preocuparse por lo que está oculto detrás de la interfaz de
Connection
.
Reescribimos la clase
XMLHttpService
para que implemente esta interfaz:
class XMLHttpService implements Connection { const xhr = new XMLHttpRequest();
Como resultado, podemos crear muchas clases que implementan la interfaz de
Connection
y son adecuadas para su uso en la clase
Http
para organizar el intercambio de datos a través de la red:
class NodeHttpService implements Connection { request(url: string, opts:any) {
Como puede ver, aquí los módulos de alto y bajo nivel dependen de abstracciones. La clase
Http
(módulo de alto nivel) depende de la interfaz de
Connection
(abstracción). Las
XMLHttpService
,
NodeHttpService
y
MockHttpService
(módulos de bajo nivel) también dependen de la interfaz de
Connection
.
Además, vale la pena señalar que siguiendo el principio de inversión de dependencia, observamos el principio de sustitución Barbara Liskov. A saber, resulta que los tipos
XMLHttpService
,
NodeHttpService
y
MockHttpService
pueden servir como un reemplazo para el tipo básico
Connection
.
Resumen
Aquí observamos cinco principios SÓLIDOS que todo desarrollador de OOP debe cumplir. Al principio, esto puede no ser fácil, pero si se esfuerza por lograr esto, reforzando los deseos de práctica, estos principios se convierten en una parte natural del flujo de trabajo, lo que tiene un gran impacto positivo en la calidad de las aplicaciones y facilita enormemente su apoyo.
Estimados lectores! ¿Utiliza principios SOLIDOS en sus proyectos?
