Cómo implementar la S de SOLID, usando ejemplos basados en Unity.
Introducción
SOLID es… Muchas cosas. En principio podemos tomarlo como un fundamento (uno de tantos), para el desarrollo y arquitectura de software. Es un conjunto de buenas prácticas que forma parte de los principios de la programación orientada a objetos. Al hacer proyectos grandes, es una muy buena práctica implementar estos principios, ya que hacen que nuestro proyecto sea fácilmente escalable.
No es necesario saber de memoria cada uno de los significados de las letras, sino sólo entender “por qué” es necesario en cada caso aplicar cada uno de los principios. Luego de entender estos principios y empezar a aplicarlos, se van a dar cuenta incluso que es muy difícil aplicar uno, sin aplicar todos en simultáneo.
Preparación
Hice un repositorio en GitLab con los archivos. El mismo cuenta con un branch para cada letra de SOLID donde pueden ir siguiendo cada uno de los cambios para cada una de las letras. El primer commit de cada branch es el código “sin optimizar” de cada letra. Y de ahí cada commit va mostrando diferentes cambios.
CLICK ACÁ PARA IR AL REPOSITORIO.
S – Single Responsibility Principle (SRP)
“A class should have one, and only one, reason to change”
Este principio dice que “una clase debería tener una, y sólo una, única razón para cambiar”. Es uno de los más fundamentales, y con los que quizás la mayoría se va familiarizando al ir aprendiendo nuevos métodos para mejorar la forma de trabajo. Robert C. Martin, conocido más como “El tío Bob”, propone lo siguiente:
“Gather together the things that change for the same reasons. Separate those things that change for different reasons”
Básicamente significa: “Agrupa las cosas que cambian por las mismas razones, y separa las que cambien por razones diferentes”. Nuestra tarea con este principio es la de mantener objetos con responsabilidades únicas, y que cuando necesitemos agregar más tipos de objetos que dependan de nuestras clases, no tener que editar nuestra implementación para que soporte más (o al menos tener que editarlo lo menos posible).
Si podemos pensar todo esto teniendo en cuenta este principio, cada vez que tengamos la necesidad de agregar funcionalidad nueva nos será más fácil, y contemplará una menor cantidad de problemas, ya que por lo general nuestros sistemas van a tener distintas implementaciones que serán de gran importancia, como el núcleo de alguna feature, y es vital que ese núcleo no deba ser modificado al agregar más y más funcionalidad a nuestra aplicación, ya que de otra manera, más servicios podrían verse comprometidos. Y esto podría ser peor mientras más servicios tengamos que editar para adaptar nueva funcionalidad.
Un ejemplo de esto podría ser un DatabaseService, que a juzgar por el nombre podríamos interpretar que se trata de un servicio que nos brinda acceso a una base de datos. Posiblemente muchos módulos de nuestra aplicación utilicen este recurso, así que si nos vemos en la necesidad de editarlo para agregar más funcionalidades, posiblemente muchas partes del sistema se vean comprometidos a cambios, por lo tanto, nuestro código es menos escalable o más difícil de escalar.
Ejemplo de NECESIDAD
Un ejemplo de algo que podría necesitar aplicar esto sería una clase “All in One“. Este tipo de implementaciones que muchos implementamos alguna vez se refiere a aquellos objetos que hacen de todo. Por ejemplo, una nave que chequea las pulsaciones de las teclas, crea y posiciona sus propias bullets y además las mueve y elimina según sea necesario.
Si abren el proyecto del repositorio en el primer commit, van a encontrar algo como esto al darle play.
El código fuente para hacer todo esto es el siguiente:
Noten en los 3 bloques que remarqué, que esta clase tiene demasiadas responsabilidades. No sólo eso… Quisiéramos agregar más tipos de bullets, tendríamos que programar ahí dentro los diferentes funcionamientos y llenar de if ese bloque. Y esto lo tendríamos que hacer CADA VEZ que se nos ocurra un nuevo tipo de bullet.
Para evitarlo, deberíamos sacarle las responsabilidades que no le correspondan, y llevarlas a otro lugar. Deberíamos crear otras clases, que sean responsables de las acciones que a esta clase no le correspondan. Para ello podríamos crear un script llamado “Bullet” que se encargue de lo relacionado con, al menos, la bullet que querémos disparar. De esta le sacaríamos una responsabilidad a nuestra clase “Ship“.
1º CAMBIO: Bullet
Podríamos crear la clase “Bullet” y darle un poco de responsabilidad. La misma debería hacer todo lo que corresponda a una bullet cualquiera sea. En este caso, simplemente se moverá hacia adelante.
Bullet.cs
Ship.cs
Con esto logramos aplicar un poco de la S de SOLID y dejar algo más limpia la clase “Ship“. Pero todavía podemos limpiarla un poco más. Todavía nos quedan responsabilidades que podemos pasar hacia otro lado. Por ejemplo, la creación en sí de la bullet, podría ser dada por un “Spawner“. De esta forma, sólo tendríamos que decirle al spawner que cree una bullet, y el mismo se encargaría de crearla, posicionarla y setearle los parámetros que hagan falta. Así que sigamos por ese lado.
2º CAMBIO: Spawner
Vamos a crear la clase “Spawner” y a delegarle la funcionalidad.
Spawner.cs
Ahora podemos simplemente crear un prefab completamente vacío que represente a nuestro spawner y tirarle este script. De esta manera, en cualquier lugar que tiremos a nuestro spawner podría servir como “creador” de objetos.
Con esto ya tenemos un prefab que puede perfectamente encargarse de lo relacionado con “spawnear” cosas nuevas. Sólo hay que pedírselo y despreocuparnos del resto. Lo que resta ahora es escribir el código necesario para que nuestra nave sepa cómo pedirle al spawner que cree algo nuevo, como por ejemplo, una bullet.
Para ello, vamos a modificar la clase “Ship” creándole un método “Shoot()” para además abstraerlo de la detección de teclas, que es otra de las cosas que esta clase NO DEBERÍA hacer.
¿Qué hicimos?
- Creamos un array de “Spawner“.
- Obtuvimos todos los spawners dentro de la nave.
- Al disparar, llamamos al método “Spawn()” de TODOS los spawners.
Antes de probar cómo funcionan estos cambios, lo que tenémos que hacer es poner algunos spawners dentro de la nave. Vamos a poner 2, uno en cada ala.
Luego nos restaría arrastrar a cada uno de los spawners el prefab de nuestra bullet. Si no lo hacemos, nuestro spawner no podrá crear ninguna bullet nueva.
Para probar si esto funciona, prueben llamar al método “Shoot()” desde el Update. Esto va a hacer que cree las bullets frame a frame. Si lo hace, sabrán que funciona correctamente. Debería quedar algo así:
Una vez que comprobamos que funciona, ya tenemos una parte más a la cual le aplicamos la S de SOLID. Así que ahora resta darla la última limpieza: La detección de teclas.
3º CAMBIO: PlayerController
Por supuesto, este paso también debería quitarse de la clase “Ship”, ya que no le corresponde comprobar qué tecla presionó el usuario. Para abstraer esto, vamos a crear una nueva clase, llamada “PlayerController”, la cual se va a encargar de comprobar las teclas y decirle a nuestra nave que dispare cuando le corresponda. Además, también tenemos que sacar la parte de movimiento, y pasarla a algún método para que también pueda ser llamado en el momento que corresponda.
El método “Move()” debería quedar de la siguiente manera:
Con este cambio, nuestra clase “Ship” debería quedar lista. Ahora, vamos a enforcarnos directamente en la clase “PlayerController“, la cual tendrá la siguiente forma:
¿Qué hicimos?
- Obtenemos la referencia a nuestra nave (este script va a estar dentro).
- Comprobamos cada una de las teclas correspondientes.
- Llamamos a los métodos de la nave según corresponda.
Lo único que quedaría para completar esta parte es arrastrar este script dentro de nuestra nave.
Y con esto queda finalizada la aplicación de la letra S.
Antes de continuar...
Antes de continuar con SOLID es importante saber algunas cuestiones, como diferenciar un poco mejor conceptos como “tipo de dato” e “implementación“. Más que nada para entender un poco mejor el uso de clases e interfaces. Si ya estás canchero con este concepto, podes saltearlo. Sino, a continuación voy a dar un pequeño concepto de lo que significa.
¿Qué es un tipo de dato ?
Comúnmente cuando decímos tipo de dato hablamos de una Clase. Y no es que ese concepto esté mal, pero quizás que abarque a toda la clase junto con su implementación (lo métodos y propiedades), hace que englobe demasiadas cosas. Si vamos un poco más profundo, podemos ver que la clase es sólo la implementación de un tipo de dato, el cual está dado por su… INTERFAZ.
En cada uno de los distintos lenguajes el significado de “interfaz” o el rol que ocupa puede variar, pero en un concepto más amplio es relativamente el mismo:
Entonces... ¿Qué es una interfaz?
En simples palabras, es una estructura que garantiza que un objeto implemente determinados métodos y propiedades.
Con este concepto en mente, básicamente podríamos decir que la interfaz define las “firmas” que un objeto contiene. Si yo creo una variable, cuyo tipo de dato es una interfaz, estoy diciendo al compilador que puedo meter ahí cualquier objeto que implemente esa interfaz. En este sentido, podríamos decir que la interfaz es el TIPO DE DATO (donde se registran los métodos, o sea, el contrato que indica las cosas que una clase debe implementar), y por otro lado, podríamos decir que la clase es LA IMPLEMENTACIÓN de ese tipo de dato.
La interfaz nos dice qué firmas debemos cumplir, y una clase cualquiera podría implementarlas. De esta forma, los tipos de datos de nuestras variables podrían ser interfaces, y si de alguna manera necesitamos cambiar la forma en la que las cosas se hacen, simplemente almacenamos otra clase que implemente la misma interfaz en la misma variable. Esto es útil en muchos casos, como cuando tenemos que realizar acciones diferentes, dependiendo de la plataforma sobre la que nuestra aplicación o juego está ejecutandose.
Veamos un ejemplo simple:
En este caso tenemos 2 clases y 1 interfaz. Podríamos crear una variable del tipo de la interfaz, en la cual podríamos almacenar una instancia de cualquiera de las dos clases, incluso si una no hereda de la otra, incluso si su cadena de herencia no se relacionan para nada. Por ejemplo:
En este ejemplo, fijense que puedo asignar en la variable damageable
cualquiera de los dos objetos de arriba, incluso aunque ninguno herede del otro. De esta manera, podemos utilizar las variables asumiendo que tienen un determinado grupo de métodos, y sin que nos importe QUÉ HACEN esos métodos.
Si lo piensan… Al momento de programar y acceder a los distintos métodos, la clase que invoca un método dentro de un objeto, no tiene por qué conocer CÓMO ese objeto va a ejecutar la acción. De esta manera, podríamos en cualquier momento crear otra clase, que implemente la misma interfaz, pero que haga las cosas de manera diferente, sin tener que reescribir la misma clase.
Ooooooooooobviamente con esto no quiero decir que de ahora en más siempre deberían programar orientado a interfaces. Pero cuando programan distintos servicios o clases cuyo propósito es bastante core, por ejemplo, una clase que gestiona diferentes tipos de objetos, puede ser muy útil poner todo lo que ustedes necesitan en una interfaz, y después evaluar cómo es que van a implementar cada uno de esos métodos.
¿Interfaces con métodos privados ?
Bueno… Ese título es un poco… Amarillista. Realmente no se puede crear un método privado, pero… PERO… Sí se puede crear implementar el método de una interfaz de una manera “NO PÚBLICA”. Con esto me refiero a que una clase podría implementar las firmas de una interfaz, sin exponerlas.
¿De qué sirve esto? Es más que nada para tener un poco de orden en nuestro código y no exponer cosas que no quisiéramos que sean modificadas directamente desde esa clase. En el último ejemplo que mostré, lo que pasa es que yo podría acceder e invocar al método TakeDamage() desde otro lugar, sólo teniendo acceso variable del tipo Character o EnvironmentObject.
Cuando trabajamos con otras personas (la mayor parte del tiempo), o incluso al trabajar con nuestro mismo código después de mucho tiempo, es muy útil ocultar el scope de los métodos como una manera de decir “Hey! No accedas desde acá porque la podes cagar!“.
Pero volviendo un poco al tema… ¿Cuál es la diferencia entre PRIVADO y NO PÚBLICO? Es algo similar a cuando ustedes suben un video a YouTube y tienen las opciones “privado” y “no listado“. Uno significa que definitivamente nadie lo va a poder ver más que ustedes, y el otro significa que sólo lo pueden ver las personas que TENGAN EL LINK. Acá está la clave. La idea de la “implementación no pública” es la de sólo garantizar acceso mediante la vía correcta. Veamos un ejemplo de implementación:
De esta manera, si yo creo una variable de tipo Character o de tipo EnvironmentObject, en ningún caso voy a poder acceder al método “TakeDamage()“. Para poder accederlo, necesito poner la instancia de ese objeto dentro de una variable de tipo IDamageable.
Pero el objetivo de la implementación “NO PÚBLICA” no es sólo garantizar el acceso mediante el tipo correcto, sino también permitirnos implementar varias interfaces que contengan las mismas firmas. Pero… ¿Por qué habría varias interfaces con las mismas firmas? Vamos a ver un ejemplo un poco más práctico. Supongamos que tenemos 3 interfaces para guardar el juego:
A continuación, las 3 interfaces podrían implementarse de la siguiente manera:
Cada una de estas 3 interfaces sirven para que podamos guardar el estado de nuestra aplicación o juego en diferentes lugares. Por ejemplo, en algunos casos puede ser útil guardar el estado de una partida en la memoria a modo de checkpoint temporal, o sea, si el usuario muere, que el juego arranque desde un determinado punto, pero si saca el juego, arranca desde un checkpoint más lejano o desde el inicio del nivel. Pero si el usuario accede a “Guardar Partida”, lo más probable es que queramos que esa partida se escriba en el disco rígido del usuario. De la misma manera, también podríamos tener la opción de sincronizarlo de manera online para guardarlo en alguna base de datos.
Para realizar esto, podríamos tener 3 clases diferentes que implementen 3 modos de guardado diferentes, o la misma clase podría encargarse de los 3, ya que todos están relacionado (o sea, la clase no tendría demasiadas responsabilidades, ya que todas referirían a la misma acción: guardar). En tal caso, podemos hacer lo siguiente:
Ahora… La pregunta del millón… ¿Cómo hacemos para acceder a cada uno de esos métodos? FÁCIL!
Noten que la instancia que guardamos en cada una de las variables es EXACTAMENTE la misma. Pero… El medio por el cual accedemos es diferente. Al guardar la instancia en variables de interfaces diferentes, cada una de esas variables tiene acceso SOLAMENTE a las funciones que la interfaz le indica. De esta forma, ustedes pueden acceder a los métodos que correspondan, según el tipo de dato, o sea el VERDADERO tipo de dato: La interfaz.
¿Interfaz de INTERFACES ?
Podría ser tentador tener una herencia de interfaces. Obvio… Esto no existe. Pero… Podemos hacer una interfaz llamada IStorage que IMPLEMENTE las 3 interfaces. La clave está en la palabra “implementar“, ya que en realidad no es una forma de heredar propiedades, sino de implementarlas, sin ligar el tipo de dato directamente. Nos quedaría de la siguiente forma:
Y nuestra clase quedaría de la siguiente forma:
PREGUNTA
¿Qué pasaría si llamo al método “Save()” o “Load()” desde una variable del tipo IStorage?
Bueno… Hagamos la prueba:
Si hacemos esto… El compilador nos va a tirar el siguiente error:
Noten que ni siquiera le importa el tercer método faltante, puesto que ni siquiera puede resolver el acceso entre los dos primeros. Pero esto no es algo malo, ya que seguimos teniendo un acceso restringido a cada uno de los métodos. Para accederlos, deberíamos hacer lo mismo que ya veníamos haciendo:
El uso de interfaces es muy importante dentro de SOLID y nos sirve mucho para estructurar mejor el proyecto y hacerlo más escalable. Hay otro tema interesante dentro de las interfaces que son las Covariantes y Contravariantes.
Covariantes
Imaginemos el caso en el que tenemos una interfaz, con valores generics, y sabemos que jamás vamos a utilizar ese generic como valor de retorno (o sea, jamás lo vamos a usar hacia afuera). En ese caso, podemos usar dicho valor como covariante. Veamos el siguiente ejemplo:
Si sabemos que con esta interface sólo vamos a utilizar TOutput
como valor de retorno (o sea, como return en un método), podemos hacerle un ligero ajuste:
De esta manera, hacemos que nadie pueda declarar un método en nuestra interfaz que utilice TOutput
como valor de entrada. En el caso de que alguien lo haga, el compilador dará un error:
Así que si alguien intenta hacer algo que no debe (incluso ustedes mismos), el compilador nos va a recordar que no se puede.
Contravarianza
En simples palabras… Es… Lo inverso. O sea, prohibe que podemos usar un parámetro como salida. Por ejemplo, aplicado a nuestro caso:
La covarianza y contravarianza tienen otra finalidad, aparte de “bloquear” la entrada y/o salida de un dato genérico, y es permitirnos asignar valores cuando ese valor genérico es derivado o incluso una base. Qué significa esto? Bueno… Veamos el siguiente ejemplo:
Dada esta estructura, imaginemos que creamos las siguientes variables:
Ese código hará que el compilador tire un error, diciéndo que no se puede convertir IZoo<Cat>
en IZoo<Animal>
. Pero… Por qué? Si Cat
hereda de Animal
… Por qué no podría hacer esa asignación? Veamos un ejemplo donde el compilador SÍ nos dejaría:
Pero… Por qué en este caso nos permite hacerlo y en el otro no? Bueno… Esto es porque la interfaz IEnumerable<T>
es covariante. O sea que en su declaración, tiene un <out T>
. Si quisiéramos que el código anterior no nos tire error, deberíamos hacer ese pequeño ajuste:
Entonces… Qué función tiene acá la COVARIANZA? La misma hace que yo pueda guardar en mi variable de tipo IZoo<Animal>
cualquier implementación de IZoo<TAnimal>
mientras que ese TAnimal
herede de Animal
.
ACLARACIÓN
Esto NO ES lo mismo que usar "where TAnimal : Animal"
al final de la interfaz. Este “where
” lo que haría es aclarar que TAnimal
SOLAMENTE puede heredar de Animal
.
De la misma forma, veamos el siguiente ejemplo:
Este código generaría un error similar al anterior. Pero para solucionarlo, deberíamos cambiar el “out
” anterior por un “in
“. O sea:
Entonces… Qué función tiene acá la CONTRAVARIANZA? La misma hace que yo pueda guardar en mi variable de tipo IZoo<Cat>
cualquier implementación de IZoo<TAnimal>
mientras que ese TAnimal sea, en algún punto, la base de Cat
.