Principios de diseño orientado a objetos en Java

Los principios de diseño son consejos generalizados, que se utilizan como reglas generales al tomar decisiones de diseño. En este artículo, cubriremos los principios de diseño más importantes relacionados con el diseño orientado a objetos y los implementaremos en Java.

Introducción

Los principios de diseño son consejos generalizados o buenas prácticas de codificación comprobadas que se utilizan como reglas generales al tomar decisiones de diseño.

Son un concepto similar a patrones de diseño, la principal diferencia es que los principios de diseño son más abstractos y generalizados. Son consejos de alto nivel, a menudo aplicables a muchos lenguajes de programación diferentes o incluso a diferentes paradigmas.

Los patrones de diseño también son abstracciones o buenas prácticas generalizadas, pero brindan consejos de bajo nivel mucho más concretos y prácticos, y están relacionados con clases completas de problemas en lugar de solo prácticas de codificación generalizadas.

Algunos de los principios de diseño más importantes en el paradigma orientado a objetos se enumeran en este artículo, pero de ninguna manera es una lista exhaustiva.

Los principios SRP, LSP, Abierto/Cerrado y DIP a menudo se agrupan y se denominan principios SÓLIDOS.

Principio de no repetirse (DRY)

El principio Don’t Repeat Yourself (DRY) es un principio común en todos los paradigmas de programación, pero es especialmente importante en OOP. Según el principio:

Cada pieza de conocimiento o lógica debe tener una representación única e inequívoca dentro de un sistema.

Cuando se trata de programación orientada a objetos, esto significa utilizar clases abstractas, interfaces y constantes públicas. Siempre que haya una funcionalidad común entre las clases, podría tener sentido abstraerlas en una clase principal común o usar interfaces para acoplar su funcionalidad:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Animal {
    public void eatFood() {
        System.out.println("Eating food...");
    }
}

public class Cat extends Animal {
    public void meow() {
        System.out.println("Meow! *purrs*");
    }
}

public class Dog extends Animal {
    public void woof() {
        System.out.println("Woof! *wags tail*");
    }
}

Tanto un ‘Gato’ como un ‘Perro’ necesitan comer, pero hablan de manera diferente. Dado que comer comida es una funcionalidad común para ellos, podemos abstraerla en una clase principal como “Animal” y luego hacer que amplíen la clase.

Ahora, en lugar de que ambas clases implementen la misma funcionalidad de comer alimentos, cada una puede enfocarse en su propia lógica única.

1
2
3
4
5
6
7
Cat cat = new Cat();
cat.eatFood();
cat.meow();

Dog dog = new Dog();
dog.eatFood();
dog.woof();

La salida sería:

1
2
3
4
Eating food...
Meow! *purrs*
Eating food...
Woof! *wags tail*

Siempre que haya una constante que se use varias veces, es una buena práctica definirla como una constante pública:

1
2
3
4
5
static final int GENERATION_SIZE = 5000;
static final int REPRODUCTION_SIZE = 200;
static final int MAX_ITERATIONS = 1000;
static final float MUTATION_SIZE = 0.1f;
static final int TOURNAMENT_SIZE = 40;

Por ejemplo, usaremos estas constantes varias veces y eventualmente cambiaremos sus valores manualmente para optimizar un algoritmo genético. Sería fácil cometer un error si tuviéramos que actualizar cada uno de estos valores en varios lugares.

Además, no queremos cometer un error y cambiar estos valores mediante programación durante la ejecución, por lo que también estamos introduciendo el modificador final.

Nota: Debido a la convención de nomenclatura en Java, estos deben escribirse en mayúsculas con palabras separadas por un guión bajo ("_").

El propósito de este principio es garantizar un fácil mantenimiento del código, porque cuando una funcionalidad o una constante cambia, debe editar el código solo en un lugar. Esto no solo facilita el trabajo, sino que garantiza que no se cometerán errores en el futuro. Es posible que olvide editar el código en varios lugares, o que alguien que no esté tan familiarizado con su proyecto no sepa que ha repetido el código y termine editándolo en un solo lugar.

Sin embargo, es importante aplicar el sentido común al usar este principio. Si usa la misma pieza de código para hacer dos cosas diferentes inicialmente, eso no significa que esas dos cosas siempre deban tratarse de la misma manera.

Esto suele suceder si las estructuras son realmente diferentes, a pesar de que se usa el mismo código para manejarlas. El código también puede estar 'demasiado seco', haciéndolo esencialmente ilegible porque los métodos se llaman desde lugares incomprensibles y no relacionados.

Una buena arquitectura puede amortizar esto, pero el problema puede surgir en la práctica.

Violaciones del principio DRY

Las violaciones del principio DRY a menudo se denominan [soluciones HÚMEDAS] (https://en.wikipedia.org/wiki/Don't_repeat_yourself#DRY_vs_WET_solutions). WET puede ser una abreviatura de varias cosas:

  • Disfrutamos escribiendo
  • Perder el tiempo de todos
  • Escribe cada vez
  • Escribe todo dos veces

Las soluciones WET no siempre son malas, ya que a veces se recomienda la repetición en clases inherentemente diferentes, o para hacer que el código sea más legible, menos interdependiente, etc.

Principio Keep It Simple and Stupid (KISS)

El principio Keep it Simple and Stupid (KISS) es un recordatorio para mantener su código simple y legible para los humanos. Si su método maneja múltiples casos de uso, divídalos en funciones más pequeñas. Si realiza múltiples funcionalidades, cree múltiples métodos en su lugar.

El núcleo de este principio es que para la mayoría de los casos, a menos que la eficiencia sea extremadamente crucial, otra llamada a la pila no afectará gravemente el rendimiento de su programa. De hecho, algunos compiladores o entornos de tiempo de ejecución incluso simplificarán una llamada de método a una ejecución en línea.

Por otro lado, los métodos ilegibles y largos serán muy difíciles de mantener para los programadores humanos, los errores serán más difíciles de encontrar y es posible que también viole DRY porque si una función hace dos cosas, no puede llamarla para hacer solo uno de ellos, entonces harás otro método.

Considerándolo todo, si se encuentra enredado en su propio código y no está seguro de lo que hace cada parte, es hora de una reevaluación.

Es casi seguro que el diseño podría modificarse para hacerlo más legible. Y si tiene problemas como el que lo diseñó mientras todavía está fresco en su mente, piense en cómo se desempeñará alguien que lo vea por primera vez en el futuro.

El principio de responsabilidad única (SRP)

El Principio de responsabilidad única (SRP) establece que nunca debe haber dos funcionalidades en una clase. A veces, se parafrasea como:

"Una clase solo debe tener una, y solo una, razón para ser cambiada."

Donde un "motivo para ser cambiado" es responsabilidad de la clase. Si hay más de una responsabilidad, hay más motivos para cambiar de clase en algún momento.

Esto significa que, en el caso de que una funcionalidad necesite una actualización, no debería haber varias funcionalidades separadas en esa misma clase que puedan verse afectadas.

Este principio facilita el manejo de errores, la implementación de cambios sin confundir las codependencias y la herencia de una clase sin tener que implementar o heredar métodos que su clase no necesita.

Si bien puede parecer que esto lo alienta a depender mucho de las dependencias, este tipo de modularidad es mucho más importante. Cierto nivel de dependencia entre clases es inevitable, por lo que también tenemos principios y patrones para lidiar con eso.

Por ejemplo, supongamos que nuestra aplicación debe recuperar información del producto de la base de datos, luego procesarla y finalmente mostrársela al usuario final.

Podríamos usar una sola clase para manejar la llamada a la base de datos, procesar la información y enviar la información a la capa de presentación. Sin embargo, agrupar estas funcionalidades hace que nuestro código sea ilegible e ilógico.

En su lugar, lo que haríamos sería definir una clase, como ProductService que recuperaría el producto de la base de datos, un ProductController para procesar la información y luego la mostraríamos en una capa de presentación, ya sea un HTML página u otra clase/GUI.

El principio abierto/cerrado

El principio Abierto/Cerrado establece que las clases u objetos y métodos deben estar abiertos para la extensión, pero cerrados para las modificaciones.

Lo que esto significa en esencia es que debe diseñar sus clases y módulos teniendo en cuenta posibles actualizaciones futuras, por lo que deben tener un diseño genérico en el que no necesitará cambiar la clase en sí para extender su comportamiento.

Puede agregar más campos o métodos, pero de tal manera que no necesite volver a escribir métodos antiguos, eliminar campos antiguos y modificar el código anterior para que funcione nuevamente. Pensar en el futuro lo ayudará a escribir un código estable, antes y después de una actualización de los requisitos.

Este principio es importante para garantizar la compatibilidad con versiones anteriores y evitar [regresiones] (https://en.wikipedia.org/wiki/Software_regression), un error que ocurre cuando las funciones o la eficiencia de sus programas se interrumpen después de una actualización.

Principio de sustitución de Liskov (LSP)

De acuerdo con el Principio de sustitución de Liskov (LSP), las clases derivadas deberían poder sustituir sus clases base sin que cambie el comportamiento de su código.

Este principio está estrechamente relacionado con el Principio de segregación de la interfaz y el Principio de responsabilidad única, lo que significa que es probable que una violación de cualquiera de ellos también sea (o se convierta) en una violación de LSP. Esto se debe a que si una clase hace más de una cosa, es menos probable que las subclases que la amplían implementen de manera significativa esas dos o más funcionalidades.

Una forma común en que la gente piensa acerca de las relaciones de objetos (que a veces puede ser un poco engañoso) es que debe haber una relación is entre las clases.

Por ejemplo:

  • Coche es un Vehículo
  • TeachingAssistaint es un CollegeEmployee

Es importante notar que estas relaciones no van en ambas direcciones. El hecho de que ‘Coche’ sea un ‘Vehículo’ puede no significar que ‘Vehículo’ sea un ‘Coche’ - puede ser una ‘Motocicleta’, ‘Bicicleta’, ‘Camión’…

La razón por la que esto puede ser engañoso es un error común que comete la gente cuando piensa en ello en lenguaje natural. Por ejemplo, si te pregunto si Square tiene una relación "is" con Rectangle, podrías decir automáticamente que sí.

Después de todo, sabemos por la geometría que un cuadrado es un caso especial de rectángulo. Pero dependiendo de cómo se implementen sus estructuras, este podría no ser el caso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class Rectangle {
    protected double a;
    protected double b;

    public Rectangle(double a, double b) {
        this.a = a;
        this.b = b;
    }

    public void setA(double a) {
        this.a = a;
    }

    public void setB(double b) {
        this.b = b;
    }

    public double calculateArea() {
        return a*b;
    }
}

Ahora intentemos heredar de él para nuestro Square dentro del mismo paquete:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Square extends Rectangle {
    public Square(double a) {
        super(a, a);
    }

    @Override
    public void setA(double a) {
        this.a = a;
        this.b = a;
    }

    @Override
    public void setB(double b) {
        this.a = b;
        this.b = b;
    }
}

Notarás que los configuradores aquí en realidad configuran tanto a como b. Algunos de ustedes ya pueden adivinar el problema. Digamos que inicializamos nuestro Cuadrado y aplicamos polimorfismo para contenerlo dentro de una variable Rectángulo:

1
Rectangle rec = new Square(5);

Y digamos que en algún momento más adelante en el programa, tal vez en una función completamente separada, otro programador que no tuvo nada que ver con la implementación de estas clases, decide que quiere cambiar el tamaño de su rectángulo. Pueden intentar algo como esto:

1
2
rec.setA(6);
rec.setB(3);

Obtendrán un comportamiento completamente inesperado y puede ser difícil rastrear cuál es el problema.

Si intentan usar rec.calculateArea(), el resultado no será 18 como podrían esperar de un rectángulo con lados de longitud 6 y 3.

En cambio, el resultado sería 9 porque su rectángulo es en realidad un cuadrado y tiene dos lados iguales, de longitud 3.

Puede decir que este es exactamente el comportamiento que quería porque así es como funciona un cuadrado, pero no es el comportamiento esperado de un rectángulo.

Entonces, cuando heredamos, debemos tener en cuenta el comportamiento de nuestras clases y si son realmente funcionalmente intercambiables dentro del código, en lugar de que solo los conceptos sean similares fuera del contexto de su uso en el programa.

El principio de segregación de interfaz (ISP)

El Principio de segregación de la interfaz (ISP) establece que el cliente nunca debe verse obligado a depender de una interfaz que no está utilizando en su totalidad. Esto significa que una interfaz debe tener un conjunto mínimo de métodos necesarios para la funcionalidad que garantiza y debe limitarse a una sola funcionalidad.

Por ejemplo, no se debería requerir una interfaz Pizza para implementar un método addPepperoni(), porque no tiene que estar disponible para cada tipo de pizza. Por el bien de este tutorial, supongamos que todas las pizzas tienen salsa y deben hornearse y no hay una sola excepción.

Aquí es cuando podemos definir una interfaz:

1
2
3
4
public interface Pizza {
    void addSauce();
    void bake();
}

Y luego, implementemos esto a través de un par de clases:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class VegetarianPizza implements Pizza {
    public void addMushrooms() {System.out.println("Adding mushrooms");}

    @Override
    public void addSauce() {System.out.println("Adding sauce");}

    @Override
    public void bake() {System.out.println("Baking the vegetarian pizza");}
}

public class PepperoniPizza implements Pizza {
    public void addPepperoni() {System.out.println("Adding pepperoni");}

    @Override
    public void addSauce() {System.out.println("Adding sauce");}

    @Override
    public void bake() {System.out.println("Baking the pepperoni pizza");}
}

La VegetarianPizza tiene champiñones mientras que la PepperoniPizza tiene pepperoni. Ambos, por supuesto, necesitan salsa y deben hornearse, lo que también se define en la interfaz.

Si los métodos addMushrooms() o addPepperoni() estuvieran ubicados en la interfaz, ambas clases tendrían que implementarlos aunque no necesitan ambos, sino solo uno cada uno.

Deberíamos despojar a las interfaces de todas las funcionalidades excepto las absolutamente necesarias.

El principio de inversión de dependencia (DIP)

De acuerdo con el Principio de Inversión de Dependencia (DIP), los módulos de alto y bajo nivel deben desacoplarse de tal manera que cambiar (o incluso reemplazar) los módulos de bajo nivel no requiera (mucho) reelaboración de los módulos de alto nivel. módulos de nivel. Dado eso, los módulos de bajo y alto nivel no deberían depender unos de otros, sino que deberían depender de abstracciones, como las interfaces.

Otra cosa importante que dice DIP es:

Las abstracciones no deberían depender de los detalles. Los detalles (implementaciones concretas) deben depender de abstracciones.

Este principio es importante porque desacopla los módulos, lo que hace que el sistema sea menos complejo, más fácil de mantener y actualizar, más fácil de probar y más reutilizable. No puedo enfatizar lo suficiente lo revolucionario que es esto, especialmente para las pruebas unitarias y la reutilización. Si el código está escrito de forma suficientemente genérica, puede encontrar aplicación fácilmente en otro proyecto, mientras que el código que es demasiado específico e interdependiente con otros módulos del proyecto original será difícil de desacoplar de él.

Este principio está íntimamente relacionado con la inyección de dependencia, que es prácticamente la implementación o mejor dicho, el objetivo del DIP. DI se reduce a: si dos clases son dependientes, sus características deben abstraerse y ambas deben depender de la abstracción, en lugar de la una de la otra. Esto esencialmente debería permitirnos cambiar los detalles de la implementación mientras conservamos su funcionalidad.

El Principio de Inversión de Dependencia y la Inversión de Control (IoC) son usados ​​indistintamente por algunas personas, aunque técnicamente no es cierto.

La inversión de dependencias nos guía hacia la desacoplamiento mediante el uso de inyección de dependencias a través de una inversión del contenedor de control. Otro nombre de IoC Containers bien podría ser Contenedores de inyección de dependencia, aunque el antiguo nombre se mantiene.

El principio de composición sobre herencia

A menudo, se debe preferir la composición a la herencia al diseñar sus sistemas. En Java, esto significa que deberíamos definir interfaces e implementarlas más a menudo, en lugar de definir clases y extenderlas.

Ya hemos mencionado que Coche es un Vehículo como un principio rector común que la gente usa para determinar si las clases deben heredarse entre sí o no.

A pesar de que es complicado pensar en ello y tiende a violar el principio de sustitución de Liskov, esta forma de pensar es extremadamente problemática cuando se trata de reutilizar y readaptar el código más adelante en el desarrollo.

El problema aquí se ilustra con el siguiente ejemplo:

object relationship diagram

Spaceship y Airplane amplían una clase abstracta FlyingVehicle, mientras que Car y Truck amplían GroundVehicle. Cada uno tiene sus respectivos métodos que tienen sentido para el tipo de vehículo, y naturalmente los agruparíamos con abstracción al pensar en ellos en estos términos.

Esta estructura de herencia se basa en pensar en los objetos en términos de lo que son en lugar de lo que hacen.

El problema con esto es que los nuevos requisitos pueden desequilibrar toda la jerarquía. En este ejemplo, ¿qué pasaría si su jefe entrara y le informara que un cliente quiere un auto volador ahora? Si heredas de FlyingVehicle, tendrás que implementar drive() nuevamente aunque esa misma funcionalidad ya exista, violando así el principio DRY, y viceversa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class FlyingVehicle {
    public void fly() {}
    public void land() {}
}

public class GroundVehicle {
    public void drive() {}
}

public class FlyingCar extends FlyingVehicle {

    @Override
    public void fly() {}

    @Override
    public void land() {}

    public void drive() {}
}

public class FlyingCar2 extends GroundVehicle {

    @Override
    public void drive() {}

    public void fly() {}
    public void land() {}
}

Dado que la mayoría de los lenguajes, incluido Java, no permiten la herencia múltiple, podemos optar por extender cualquiera de estas clases. Aunque, en ambos casos, no podemos heredar la funcionalidad del otro y tenemos que reescribirlo.

Puede encontrar una manera de cambiar toda la arquitectura para que se ajuste a esta nueva clase FlyingCar, pero dependiendo de qué tan avanzado esté en el desarrollo, puede ser un proceso costoso.

Dado este problema, podríamos tratar de evitar todo este lío basando nuestras generalidades en funcionalidad común en lugar de similitud inherente. Esta es la forma en que se han desarrollado una gran cantidad de mecanismos integrados de Java.

Si su clase va a implementar todas las funcionalidades y su clase secundaria puede usarse como sustituto de su clase principal, use herencia.

Si tu clase va a implementar algunas funcionalidades específicas, usa composición.

Usamos Runnable, Comparable, etc. en lugar de usar algunas clases abstractas que implementan sus métodos porque es más limpio, hace que el código sea más reutilizable y facilita la creación de una nueva clase que se ajuste a lo que necesitamos. para utilizar funcionalidades creadas anteriormente.

Esto también resuelve el problema de las dependencias que destruyen funcionalidades importantes y provocan una reacción en cadena en todo nuestro código. En lugar de tener un gran problema cuando necesitamos hacer que nuestro código funcione para un nuevo tipo de cosa, simplemente podemos hacer que esa nueva cosa se ajuste a los estándares establecidos previamente y funcione tan bien como la anterior.

En nuestro ejemplo de vehículo, podríamos simplemente implementar las interfaces Flyable y Drivable en lugar de introducir abstracción y herencia.

Nuestro Avión y Spaceship podrían implementar Flyable, nuestro Car y Truck podrían implementar Drivable, y nuestro nuevo FlyingCar podría implementar ambos.

No se necesitan cambios en la estructura de clases, no hay violaciones importantes de DRY, no hay confusión de colegas. Si necesita exactamente la misma funcionalidad en varias clases, puede implementarla usando un método predeterminado en su interfaz , para evitar violar DRY.

Conclusión

Los principios de diseño son una parte importante del conjunto de herramientas de un desarrollador, y tomar decisiones más conscientes al diseñar su software lo ayudará a precisar los matices de un diseño cuidadoso y preparado para el futuro.

La mayoría de los desarrolladores realmente aprenden esto a través de la experiencia en lugar de la teoría, pero la teoría puede ayudarlo al brindarle un nuevo punto de vista y orientarlo hacia hábitos de diseño más reflexivos, especialmente en esa entrevista en esa empresa que construyó todos sus sistemas en estos principios pios

Licensed under CC BY-NC-SA 4.0