Principios de diseño orientado a objetos en Java

P

 

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 los 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 mucho más concretos y prácticos de bajo nivel y están relacionados con clases enteras 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 esta no es de ninguna manera una lista exhaustiva.

  • No se repita el principio (SECO)
  • El principio Keep It Simple and Stupid (KISS)
  • El principio de responsabilidad única (SRP)
  • El principio abierto / cerrado
  • Principio de sustitución de Liskov (LSP)
  • El principio de segregación de interfaces (ISP)
  • El principio de inversión de dependencia (DIP)
  • El principio de composición sobre herencia

Los principios SRP, LSP, Open / Closed y DIP a menudo se agrupan y se denominan principios SOLID.

No se repita el principio (SECO)

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:

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

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

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 una Catcomo una Dognecesidad de comer, pero hablan de manera diferente. Dado que comer comida es una funcionalidad común para ellos, podemos abstraerlo en una clase principal como Animaly 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.

Cat cat = new Cat();
cat.eatFood();
cat.meow();

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

La salida sería:

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:

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 finalmodificador.

Nota: Debido a la convención de nomenclatura en Java, estos deben escribirse en mayúscula 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 también garantiza que no se cometan errores en el futuro. Es posible que olvide editar el código en varios lugares, o que alguien más que no esté tan familiarizado con su proyecto no sepa que ha repetido el código y puede terminar editándolo en un solo lugar.

Sin embargo, es importante aplicar el sentido común al utilizar 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 utiliza el mismo código para manejarlas. El código también puede estar ‘demasiado seco’, haciéndolo esencialmente ilegible porque los métodos se denominan 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 se denominan a menudo soluciones WET . 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 la repetición a veces es aconsejable en clases intrínsecamente diferentes, o para hacer que el código sea más legible, menos interdependiente, etc.

El 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 en la mayoría de los casos, a menos que la eficiencia sea extremadamente crucial, otra llamada de pila no afectará gravemente el rendimiento de su programa. De hecho, algunos compiladores o entornos de ejecución incluso simplificarán una llamada a un método en una ejecución en línea.

Por otro lado, los métodos largos e ilegibles 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 se encuentre violando DRY porque si una función hace dos cosas, no puede llamarla para haz solo uno de ellos, así crearás otro método.

Con todo, si te encuentras enredado en tu propio código y no estás 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 aún está fresco en su mente, piense en cómo actuará 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 debería haber dos funcionalidades en una clase. A veces, se parafrasea como:

“Una clase solo debe tener una, y solo una, razón para cambiarse”.

Donde una “razón para cambiar” es responsabilidad de la clase. Si hay más de una responsabilidad, hay más razones para cambiar esa 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 hace que sea más fácil lidiar con errores, implementar cambios sin confundir las co-dependencias y heredar 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 confiar mucho en las dependencias, este tipo de modularidad es mucho más importante. Es inevitable cierto nivel de dependencia entre clases, por lo que también tenemos principios y patrones para lidiar con eso.

Por ejemplo, digamos que nuestra aplicación debería recuperar información del producto de la base de datos, luego procesarla y finalmente mostrarla al usuario final.

Podríamos usar una sola clase para manejar la llamada a la base de datos, procesar la información y enviarla 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 es definir una clase, como la ProductServiceque obtendría el producto de la base de datos, ProductControllerpara procesar la información y luego la mostraríamos en una capa de presentación, ya sea una página HTML 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 extensión, pero cerrados para 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 que no necesite 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 los métodos antiguos, eliminar los campos antiguos y modificar el código antiguo para que vuelva a funcionar. Pensar en el futuro le ayudará a escribir código estable, antes y después de una actualización de requisitos.

Este principio es importante para garantizar la compatibilidad con versiones anteriores y evitar regresiones , 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)

Según 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 Interfaces y el Principio de Responsabilidad Única, lo que significa que es probable que una violación de cualquiera de ellos sea (o se convierta) también 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 extienden implementen de manera significativa esas dos o más funcionalidades.

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

Por ejemplo:

  • Car es un Vehicle
  • TeachingAssistaint es un CollegeEmployee

Es importante notar que estas relaciones no van en ambas direcciones. El hecho de que Cares un Vehicleno podría significar que Vehiclees un Car– que puede ser un Motorcycle, Bicycle, Truck

La razón por la que esto puede ser engañoso es un error común que la gente comete al pensar en ello en lenguaje natural. Por ejemplo, si le pregunto si Squaretiene una “relación con él” Rectangle, podría responder 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:

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 heredarlo para nuestro Squaredentro del mismo paquete:

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 establecedores aquí en realidad establecen tanto ay b. Algunos de ustedes ya pueden adivinar el problema. Digamos que inicializamos nuestro Squarepolimorfismo y aplicamos para contenerlo dentro de una Rectanglevariable:

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:

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á el 18que podrían esperar de un rectángulo con lados de longitudes 6y 3.

En cambio, el resultado sería 9porque 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 deseaba 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 realmente son funcionalmente intercambiables dentro del código, en lugar de que los conceptos sean similares fuera del contexto de su uso en el programa.

El principio de segregación de interfaces (ISP)

El Principio de Segregación de Interfaces (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 asegura, y debe limitarse a una sola funcionalidad.

Por ejemplo, Pizzano se debería requerir una interfaz para implementar un addPepperoni()método, porque no tiene que estar disponible para todos los tipos 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:

public interface Pizza {
    void addSauce();
    void bake();
}

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

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");}
}

El VegetarianPizzatiene setas mientras que la PepperoniPizzatiene pepperoni. Ambos, por supuesto, necesitan salsa y necesitan ser horneados, 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 necesiten 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) módulos de bajo nivel no requiera (mucho) retrabajo de los módulos de alto nivel. Dado eso, tanto los módulos de bajo nivel como los de alto nivel no deberían depender entre sí, sino que deberían depender de abstracciones, como interfaces.

Otra cosa importante que declara DIP es:

Las abstracciones no deberían depender de los detalles. Los detalles (implementaciones concretas) deberían 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 cambiante que es esto, especialmente para las pruebas unitarias y la reutilización. Si el código está escrito de manera suficientemente genérica, puede encontrar aplicaciones 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 más bien el objetivo de 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 una de la otra. Básicamente, esto debería permitirnos cambiar los detalles de la implementación conservando su funcionalidad.

Algunas personas utilizan indistintamente el principio de inversión de dependencia y la inversión de control (IoC), aunque técnicamente no es cierto.

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

El principio de composición sobre herencia

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

Ya hemos mencionado que Cares un Vehicleprincipio rector común que la gente usa para determinar si las clases deben heredarse entre sí o no.

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

El problema aquí se ilustra con el siguiente ejemplo:

Spaceshipy Airplaneextender una clase abstracta FlyingVehicle, mientras Cary Truckextender 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 automóvil volador ahora? Si hereda de FlyingVehicle, tendrá que implementar drive()nuevamente aunque esa misma funcionalidad ya existe, violando así el Principio DRY, y viceversa:

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 ampliar 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 adapte a esta nueva FlyingCarclase, pero dependiendo de qué tan avanzado esté en el desarrollo, puede ser un proceso costoso.

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

Si su clase va a implementar todas las funcionalidades y su clase secundaria se puede utilizar como sustituto de su clase principal, utilice la herencia.

Si su clase va a implementar algunas funcionalidades específicas, use composición.

Nosotros usamos Runnable, Comparable, etc en lugar de utilizar algunas clases abstractas aplicación de sus métodos, porque es más limpio, hace que el código sea más reutilizable, y hace que sea fácil crear una nueva clase que se ajusta a lo que necesitamos para poder utilizar las funcionalidades realizadas 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 vieja.

En nuestro ejemplo de vehículo, podríamos simplemente implementar interfaces Flyabley en Drivablelugar de introducir abstracción y herencia.

Nuestro Airplaney Spaceshippodría implementar Flyable, nuestro Cary Truckpodría implementar Drivable, y nuestro nuevo FlyingCarpodrí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 ayudar 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 sobre estos principios.

 

About the author

Ramiro de la Vega

Bienvenido a Pharos.sh

Soy Ramiro de la Vega, Estadounidense con raíces Españolas. Empecé a programar hace casi 20 años cuando era muy jovencito.

Espero que en mi web encuentres la inspiración y ayuda que necesitas para adentrarte en el fantástico mundo de la programación y conseguir tus objetivos por difíciles que sean.

Add comment

Sobre mi

Últimos Post

Etiquetas

Esta web utiliza cookies propias para su correcto funcionamiento. Al hacer clic en el botón Aceptar, aceptas el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Más información
Privacidad