En términos generales, todos los lenguajes de programación se pueden clasificar en dos paradigmas:
Programación imperativa/ orientada a objetos : sigue una secuencia de instrucciones que se ejecutan línea por línea.
Programación declarativa / funcional: no secuencial, pero se preocupa más por el propósito del programa. Todo el programa es como una función que además tiene subfunciones, cada una de las cuales realiza una determinada tarea.
Como desarrollador junior, me doy cuenta de la manera difícil (léase: estaba mirando nerviosamente miles de líneas de código) que no se trata solo de escribir código funcional, sino también de código semánticamente fácil y flexible.
Si bien existen múltiples mejores prácticas para escribir código limpio en ambos paradigmas, voy a hablar sobre los principios de diseño SOLID relacionados con el paradigma de programación orientado a objetos.
S — Responsabilidad Única
O—Principio abierto-cerrado
L — Principio de sustitución de Liskov
I — Segregación de la interfaz
D — Inversión de dependencia
La principal razón de cualquier dificultad para comprender estos conceptos no es que su profundidad técnica sea insondable, sino que son pautas abstractas que se generalizan para escribir código limpio en la programación orientada a objetos. Veamos algunos diagramas de clase de alto nivel para llevar estos conceptos a casa.
Estos no son diagramas de clase precisos, pero son planos básicos para ayudar a comprender qué métodos están presentes en una clase.
Consideremos un ejemplo de un café a lo largo del artículo.
Una clase debe tener una sola razón para cambiar
Considere esta clase que maneja los pedidos en línea recibidos por el café.
¿Qué tiene de malo?
Esta única clase es responsable de múltiples funciones. ¿Qué pasa si tienes que añadir otros métodos de pago? ¿Qué sucede si tiene varias formas de enviar la confirmación? Cambiar la lógica de pago en la clase que se encarga de procesar los pedidos no tiene muy buen diseño. Conduce a un código altamente no flexible.
Una mejor manera sería separar estas funcionalidades específicas en clases concretas y llamar a una instancia de ellas, como se muestra en la siguiente ilustración.
Las entidades deben estar abiertas a la extensión pero cerradas a la modificación.
En la cafetería, debe elegir los condimentos para su café de una lista de opciones y hay una clase que se encarga de esto.
El café decidió agregar un nuevo condimento, mantequilla. Observe cómo cambiará el precio según el condimento seleccionado y la lógica para el cálculo del precio está en la clase Café. No solo tenemos que agregar una nueva clase de condimento cada vez que crea posibles cambios de código en la clase principal, sino también manejar la lógica de manera diferente cada vez.
Una mejor manera sería crear una interfaz de condimentos que a su vez pueda tener clases secundarias que anulen sus métodos. Y la clase principal puede simplemente usar la interfaz de condimentos para pasar los parámetros y obtener la cantidad y el precio de cada pedido.
Esto tiene dos ventajas:
1. Puede cambiar dinámicamente su pedido para tener diferentes o incluso múltiples condimentos (café con moka y chocolate suena celestial).
2. La clase Condimentos va a tener una relación tiene con la clase Café, en lugar de es-a. Por lo tanto, su café puede tener moca/mantequilla/leche en lugar de que su café sea un tipo de café moca/mantequilla/leche.
Cada subclase o clase derivada debe ser sustituible por su clase base o padre.
Esto significa que la subclase debería poder reemplazar directamente a la clase principal; debe tener la misma funcionalidad. Me resultó difícil entender esto porque suena como una fórmula matemática compleja. Pero intentaré dejarlo claro en este artículo.
Piense en el personal de la cafetería. Hay baristas, gerentes y servidores. Todos ellos tienen una funcionalidad similar.
Por lo tanto, podemos crear una clase Staff base con nombre, posición, getName, getPostion, takeOrder(), serve().
Cada una de las clases concretas, Waiter, Barista y Manager puede derivar de esto y anular los mismos métodos para implementarlos según sea necesario para el puesto.
En este ejemplo, se usa el principio de sustitución de Liskov (LSP) para garantizar que cualquier clase derivada de pentagrama se pueda usar indistintamente con la clase base de pentagrama sin afectar la corrección del código.
Por ejemplo, la clase Waiter amplía la clase Staff y anula los métodos takeOrder y serveOrder para incluir funciones adicionales específicas del rol de un camarero. Sin embargo, lo que es más importante, a pesar de las diferencias en la funcionalidad, cualquier código que espere un objeto de la clase Staff también puede funcionar correctamente con un objeto de la clase Waiter.
public class Cafe { public void serveCustomer (Staff staff) { staff.takeOrder(); staff.serveOrder(); } } public class Main { public static void main (String[] args) { Cafe cafe = new Cafe(); Staff staff1 = new Staff( "John" , "Staff" ); Waiter waiter1 = new Waiter( "Jane" ); restaurant.serveCustomer(staff1); // Works correctly with Staff object
restaurant.serveCustomer(waiter1); // Works correctly with Waiter object
} }
Aquí el método serveCustomer() en la clase Cafe, toma un objeto Staff como parámetro. El método serveCustomer() llama a los métodos takeOrder() y serveOrder() del objeto Staff para atender al cliente.
En la clase Main, creamos un objeto Staff y un objeto Waiter. Luego llamamos dos veces al método serveCustomer() de la clase Café: una vez con el objeto Staff y otra vez con el objeto Waiter.
Debido a que la clase Waiter se deriva de la clase Staff, cualquier código que espere un objeto de la clase Staff también puede funcionar correctamente con un objeto de la clase Waiter. En este caso, el método serveCustomer() de la clase Cafe funciona correctamente tanto con el objeto Staff como con el objeto Waiter, aunque el objeto Waiter tiene una funcionalidad adicional específica para el rol de un camarero.
Las clases no deberían verse obligadas a depender de métodos que no utilizan.
Entonces, existe esta máquina expendedora muy versátil en la cafetería que puede dispensar café, té, refrigerios y refrescos.
¿Qué tiene de malo? Nada técnicamente. Si tiene que implementar la interfaz para cualquiera de las funciones, como dispensar café, también debe implementar otros métodos destinados a té, refrescos y refrigerios. Esto es innecesario y estas funciones no están relacionadas entre sí. Cada una de esas funciones tiene muy poca cohesión entre ellas.
¿Qué es la cohesión? Es un factor que determina cuán fuertemente se relacionan entre sí los métodos en una interfaz.
Y en el caso de la máquina expendedora, los métodos apenas son interdependientes. Podemos segregar los métodos ya que tienen muy poca cohesión.
Ahora, cualquier interfaz que esté destinada a implementar una cosa, solo debe implementar takeMoney(), que es común para todas las funciones. Esto separa las funciones no relacionadas en una interfaz, evitando así la implementación forzada de funciones no relacionadas en una interfaz.
Los módulos de alto nivel no deben depender de los módulos de bajo nivel. Los detalles deben depender de abstracciones.
Considere el acondicionador de aire (refrigerador) en el café. Y si eres como yo, siempre hace mucho frío allí. Veamos el control remoto y las clases de CA.
Aquí, remoteControl es el módulo de alto nivel que depende de AC, el componente de bajo nivel. Si obtengo un voto, también me gustaría un calentador :P Entonces, para tener una regulación de temperatura genérica, en lugar de un enfriador, separemos el control remoto y el de temperatura. Pero la clase remoteControl está estrechamente relacionada con AC, que es una implementación concreta. Para desacoplar la dependencia, podemos crear una interfaz que tenga funciones de aumentar la temperatura () y disminuir la temperatura () dentro de un rango de, digamos, 45-65 F.
Como puede ver, tanto los módulos de alto nivel como los de bajo nivel dependen de una interfaz que abstrae la funcionalidad de aumentar o disminuir la temperatura.
La clase concreta, AC, implementa los métodos con el rango de temperatura aplicable.
Ahora probablemente pueda obtener el calentador que quiero implementando diferentes rangos de temperatura en una clase concreta diferente llamada calentador.
El módulo de alto nivel, remoteControl, solo tiene que preocuparse por llamar al método correcto durante el tiempo de ejecución.