Principios SOLID
Los principios SOLID son un conjunto de principios de diseño orientado a objetos resumidos y difundidos por Robert C. Martin y M. Feathers. SOLID proviene de las iniciales de los cinco principios a los que se refiere:
- Single responsibility principle
- Open-closed principle
- Liskov substitution principle
- Interface segregation principle
- Dependency inversion principle
Single responsibility principle
En este caso, una clase debería ser responsable de una única tarea. Además, solo debería tener un, y solo un, motivo para cambiar. Respetar este principio conlleva una serie de ventajas como, por ejemplo, que el diseño de las pruebas de cada clase se vuelva más sencillo o la reducción del número de dependencias entre clases, entre otras.
De este modo, supongamos que hemos diseñado algunas clases que permiten trabajar con una colección de libros:
class Book {
constructor(private name: string, private author: string,
private editorial: string) {
}
getName(): string {
return this.name;
}
// More getters and setters
}
class BookCollection {
constructor(private books: Book[]) {
}
searchByName(searchTerm: string): Book[] {
return this.books.filter((book) => book.getName() === searchTerm);
}
}
Tal y como puede observarse, la clase Book solo debería ser responsable de almacenar los elementos
de información de un libro, por lo que solo debería cambiar o ser modificada teniendo eso en mente.
Al mismo tiempo, la clase BookCollection solo debería ser responsable de gestionar un conjunto de
libros a través de operaciones como, por ejemplo, buscar un libro por su nombre. Ambas clases podrían
modificarse, por ejemplo, para añadir el año de publicación en la clase Book o incluir un método en
BookCollection que permita añadir un nuevo libro:
class Book {
constructor(private name: string, private author: string,
private editorial: string, private year: number) {
}
getName(): string {
return this.name;
}
// More getters and setters
}
class BookCollection {
constructor(private books: Book[]) {
}
addBook(book: Book) {
this.books.push(book);
}
searchByName(searchTerm: string): Book[] {
return this.books.filter((book) => book.getName() === searchTerm);
}
}
Si por ejemplo, ya se quisiera añadir una nueva funcionalidad como imprimir una colección de libros,
podríamos incluir un método print en las clases Book y BookCollection, pero lo anterior
violaría este primer principio, dado que las clases Book y BookCollection pasarían a ser
responsables de una segunda funcionalidad o tarea, que es la de imprimir. Además, si se deseara
modificar en el futuro la manera en la que se imprimen los libros o se deseara una funcionalidad
adicional como, por ejemplo, escribir una colección de libros en un fichero, dichas clases deberían
cambiar.
La alternativa a lo anterior, y que permitiría seguir cumpliendo con este primer principio de un modo ideal, sería crear nuevas clases responsables de imprimir tanto un libro como una colección de libros:
class Book {
constructor(private name: string, private author: string,
private editorial: string, private year: number) {
}
getName(): string {
return this.name;
}
// More getters and setters
}
class BookPrinter {
constructor(private book: Book) {
}
print() {
console.log(`Name: ${this.book.getName()}`);
}
}
class BookCollection {
constructor(private books: Book[]) {
}
addBook(book: Book) {
this.books.push(book);
}
getBook(index: number): Book {
return this.books[index];
}
getNumberOfBooks() {
return this.books.length;
}
searchByName(searchTerm: string): Book[] {
return this.books.filter((book) => book.getName() === searchTerm);
}
}
class BookCollectionPrinter {
constructor(private bookCollection: BookCollection) {
}
print() {
for (let index = 0; index < this.bookCollection.getNumberOfBooks(); index++) {
const myBookPrinter = new BookPrinter(this.bookCollection.getBook(index));
myBookPrinter.print();
}
}
}
Open-closed principle
Este principio sugiere que una clase debería estar abierta a la extensión pero cerrada a la modificación. Esto viene a significar que, si un desarrollador X escribe la clase A, y más adelante, un desarrollador Y desea incluir algún tipo de modificación en la clase A, lo que deberá hacer Y es extender A y no modificar A.
Tal y como se puede estar pensando, este principio está directamente relacionado con la funcionalidad de herencia de clases que proporcionan casi todos los lenguajes modernos de programación orientada a objetos.
Gracias a que el código desarrollado con anterioridad no se modifica, no se introducen nuevos errores en dicho código si se respeta este principio.
Ya hemos visto numerosos ejemplos en apartados anteriores de estos apuntes, pero uno podría ser el siguiente:
class Figure {
constructor(private name: string) {
}
getName(): string {
return this.name;
}
setName(name: string) {
this.name = name;
}
}
Supongamos que tenemos la clase Figure a la cual deseamos añadir un atributo que indique el color
de la figura, así como un atributo que especifique el número de dimensiones de la misma. Para respetar
este segundo principio, no deberíamos modificar la clase Figure directamente, sino extenderla:
type ColorType = 'red' | 'yellow' | 'green';
class Figure {
constructor(protected name: string) {
}
getName(): string {
return this.name;
}
setName(name: string) {
this.name = name;
}
}
class FigureWithColorAndDimensions extends Figure {
constructor(name: string, private color: ColorType,
private readonly numDim: number) {
super(name);
}
getColor(): ColorType {
return this.color;
}
getNumDim() {
return this.numDim;
}
setColor(color: ColorType) {
this.color = color;
}
}
El único cambio al que se ha visto sujeta la clase Figure del ejemplo anterior es que se ha
modificado el nivel de acceso del atributo name, que ha pasado a ser protected para que
la subclase tenga acceso a dicho atributo. Sin embargo, se podría haber mantenido como privado
y haber accedido a través de los métodos correspondientes.
Liskov substitution principle
Este es el principio más complicado de satisfacer. Sugiere que una superclase debería poder sustituirse por sus subclases sin afectar al comportamiento general del programa. En otras palabras, si la clase A es una subclase de B, entonces deberíamos poder reemplazar B por A en nuestro código sin que el comportamiento del programa se vea afectado.
Consideremos el siguiente ejemplo:
class Animal {
constructor(protected name: string) {
}
getName(): string {
return this.name;
}
setName(name: string) {
this.name = name;
}
makeNoise() {
console.log(`My name is ${this.name} and I am an animal`);
}
}
const myAnimals: Animal[] = [new Animal('Balto'), new Animal('Kitty')];
myAnimals.forEach((animal) => {
animal.makeNoise();
});
Ahora, añadamos las subclases Dog y Cat, las cuales son subclases de Animal. Además,
cambiemos la definición del array myAnimals para, en lugar de hacer uso de la superclase
Animal, hagamos uso de las subclases Dog y Cat:
class Animal {
constructor(protected name: string) {
}
getName(): string {
return this.name;
}
setName(name: string) {
this.name = name;
}
makeNoise() {
console.log(`My name is ${this.name} and I am an animal`);
}
}
class Dog extends Animal {
constructor(name: string) {
super(name);
}
makeNoise() {
console.log(`My name is ${this.name}: bow bow`);
}
}
class Cat extends Animal {
constructor(name: string) {
super(name);
}
makeNoise() {
console.log(`My name is ${this.name}: meow meow`);
}
}
const myAnimals: (Dog|Cat)[] = [new Dog('Balto'), new Cat('Kitty')];
myAnimals.forEach((animal) => {
animal.makeNoise();
});
Se puede observar como la sustitución no ha afectado al comportamiento del programa, por lo que el código anterior respeta este principio.
Interface segregation principle
Este es el primer principio relacionado con el diseño de interfaces, mientras que los tres anteriores se encontraban relacionados con el diseño de clases.
Básicamente, sugiere que es mejor tener un número elevado de interfaces simples y específicas que tener una interfaz compleja y genérica.
Partamos del siguiente ejemplo, donde se ha definido la interfaz PrintableAndSearchable:
class Figure {
constructor(protected name: string) {
}
getName() {
return this.name;
}
setName(name: string) {
this.name = name;
}
print() {
console.log(`I am a figure and my name is ${this.name}`);
}
}
interface PrintableAndSearchable {
search(searchTerm: string): any[];
print(): void;
}
class FigureCollection implements PrintableAndSearchable {
constructor(private figures: Figure[]) {
}
addFigure(figure: Figure) {
this.figures.push(figure);
}
getFigure(index: number) {
return this.figures[index];
}
getNumberOfFigures() {
return this.figures.length;
}
search(name: string): Figure[] {
return this.figures.filter((figure) => figure.getName() === name);
}
print() {
this.figures.forEach((figure) => {
figure.print();
});
}
Toda clase que implemente la interfaz PrintableAndSearchable deberá implementar los métodos
print y search obligatoriamente, con independencia de que, luego, los objetos de la clase
no hagan uso de alguno de ambos métodos.
Para respetar este principio, lo ideal sería dividir la interfaz PrintableAndSearchable en dos
interfaces Printable y Searchable, cada una de ellas con un método print y search,
respectivamente.
De este modo, una clase podrá implementar ninguna, alguna o ambas interfaces y, en cada caso, solo se tendrá que definir el método necesario, sin necesidad de añadir código que no va a utilizarse, al menos, a corto plazo:
class Figure {
constructor(protected name: string) {
}
getName() {
return this.name;
}
setName(name: string) {
this.name = name;
}
print() {
console.log(`I am a figure and my name is ${this.name}`);
}
}
interface Printable {
print(): void;
}
interface Searchable {
search(searchTerm: string) any[];
}
class FigureCollection implements Printable, Searchable {
constructor(private figures: Figure[]) {
}
addFigure(figure: Figure) {
this.figures.push(figure);
}
getFigure(index: number) {
return this.figures[index];
}
getNumberOfFigures() {
return this.figures.length;
}
search(name: string): Figure[] {
return this.figures.filter((figure) => figure.getName() === name);
}
print() {
this.figures.forEach((figure) => {
figure.print();
});
}
}
Dependency inversion principle
Este principio sugiere que las clases deberían depender de la abstracción y no de la particularización. Lo que viene a significar es que el código de alto nivel que desarrollamos no debería depender de implementaciones concretas de clases, sino de aquellas abstracciones, ya sean clases o interfaces, que luego, se particularicen en implementaciones de las mismas.
Un ejemplo que ilustra lo anterior podría ser el siguiente:
class NumericIndividual {
constructor(private decisionVariables: number[]) {
}
}
class EvolutionaryAlgorithm {
constructor(private population: NumericIndividual[]) {
}
}
La clase EvolutionaryAlgorithm declara como atributo un array de tipo NumericIndividual[]
que, a su vez, es una clase que implementa un array de valores numéricos que representa
variables de decisión numéricas.
Supongamos que también tuviésemos la posibilidad de representar una solución o individuo
mediante valores booleanos. Una primera solución sería añadir la clase BooleanIndividual y
ampliar la definición de population mediante una unión de tipos:
class NumericIndividual {
constructor(private decisionVariables: number[]) {
}
getDecisionVariables() {
return this.decisionVariables;
}
}
class BooleanIndividual {
constructor(private decisionVariables: boolean[]) {
}
getDecisionVariables() {
return this.decisionVariables;
}
}
class EvolutionaryAlgorithm {
constructor(private population: (NumericIndividual|BooleanIndividual)[]) {
}
runGeneration() {
this.population.forEach((individual)=> {
individual.getDecisionVariables();
});
}
}
El problema de la anterior solución es que tendríamos que estar ampliando la unión
de tipos en la clase EvolutionaryAlgorithm siempre que deseemos lidiar con
una nueva representación. Además, esta implementación nos permitiría tener una
población de individuos numéricos y booleanos, simultáneamente, algo que no es
muy común. Por último, estaríamos violando el quinto principio, dado que la clase
EvolutionaryAlgorithm depende de la implementación concreta de las clases
BooleanIndividual y NumericIndividual.
Una solución mucho mejor, desde el punto de vista del diseño con principios SOLID, sería la siguiente:
interface Individual {
getDecisionVariables(): unknown[];
}
class NumericIndividual implements Individual {
constructor(private decisionVariables: number[]) {
}
getDecisionVariables() {
return this.decisionVariables;
}
}
class BooleanIndividual implements Individual {
constructor(private decisionVariables: boolean[]) {
}
getDecisionVariables() {
return this.decisionVariables;
}
}
class EvolutionaryAlgorithm {
constructor(private population: Individual[]) {
}
runGeneration() {
this.population.forEach((individual)=> {
individual.getDecisionVariables();
});
}
}
Se puede observar como se ha definido una interfaz Individual con un método getDecisionVariables.
Además, ahora, las clases NumericIndividual y BooleanIndividual implementan dicha interfaz, por
lo que deben definir, obligatoriamente, un método getDecisionVariables. Luego, en la clase
EvolutionaryAlgorithm ya no existe ninguna dependencia respecto a la implementación concreta de
clases, tal y como sucedía en el ejemplo anterior. Ahora, existe una dependencia respecto a una
abstracción, que es la interfaz Individual.
Por último, cabe mencionar que el principio también se satisfaría en el caso de tener una superclase
Individual(en lugar de una interfaz) y que las clases NumericIndividual y BooleanIndividual
extendieran a Individual:
abstract class Individual {
constructor(protected decisionVariables: unknown[]) {
}
abstract getDecisionVariables(): unknown[];
}
class NumericIndividual extends Individual {
constructor(decisionVariables: number[]) {
super(decisionVariables)
}
getDecisionVariables(): number[] {
return this.decisionVariables as number[];
}
}
class BooleanIndividual extends Individual {
constructor(decisionVariables: boolean[]) {
super(decisionVariables)
}
getDecisionVariables(): boolean[] {
return this.decisionVariables as boolean[];
}
}
class EvolutionaryAlgorithm {
constructor(private population: Individual[]) {
}
runGeneration() {
this.population.forEach((individual)=> {
individual.getDecisionVariables();
});
}
}