Cómo SOLID te puede dar la flexibilidad que tu código necesita

Seguro que a lo largo de tu carrera como programador te has encontrado con la problemática de cómo hacer que el comportamiento de tu lógica cambie basado en un tipo o una id, que por lo general lo solemos representar con enumeraciones.
La solución más común, y la menos escalable, suele ser crear un switch con la enum y en cada case aplicar la lógica que queremos.

En este post vamos a ver qué problemas tiene esto y cómo los podemos solucionar aplicando SOLID y Clean Code.

El problema de no aplicar SOLID

Supongamos que estamos haciendo un juego en el que tenemos distintos tipos de enemigos, con distintos comportamientos y modelos, y queremos que de forma aleatoria se decida qué enemigo es el que tiene que aparecer.

Una primera aproximación podría ser esta:

public enum EnemyTypes
    {
        Warrior,
        Archer
    }
public class EnemySpawner : MonoBehaviour
    {
        [SerializeField] private GameObject warrior;
        [SerializeField] private GameObject archer;

        private float timeToNextSpawn;

        private void Start()
        {
            CalculateTimeToNextSpawn();
        }

        private void CalculateTimeToNextSpawn()
        {
            timeToNextSpawn = Random.Range(10, 20);
        }

        private void Update()
        {
            timeToNextSpawn -= Time.deltaTime;
            if (timeToNextSpawn > 0)
            {
                return;
            }

            CalculateTimeToNextSpawn();
            SpawnRandomEnemy();
        }



        private void SpawnRandomEnemy()
        {
            var enemyTypes = (EnemyTypes[]) Enum.GetValues(typeof(EnemyTypes));
            var randomTypeIndex = Random.Range(0, enemyTypes.Length);
            var enemyTypeToSpawn = enemyTypes[randomTypeIndex];
            
            switch (enemyTypeToSpawn)
            {
                case EnemyTypes.Warrior:
                    Instantiate(warrior);
                    break;
                case EnemyTypes.Archer:
                    Instantiate(archer);
                    break;
                default:
                    throw new ArgumentOutOfRangeException();
            }
        } 
    }

(No nos dejemos llevar por detalles como que los tipos de enemigos podría estar cacheados para saber su length. También podríamos añadir un elemento al final de la enum que nos sirviese de length, etc.)

A primera vista puede parecer que esta solución no tiene ningún problema, pero esta clase tan pequeña está incumpliendo varios principios SOLID que vamos a ir detallando y explicando qué problemas tiene esto.

SOLID: Single Responsibility Principle (SRP)

El principio de una sola responsabilidad de SOLID nos dice que una clase solo debe de tener un único motivo de cambio. Esto significa que si podemos pensar más de un motivo distinto por el que esta clase podría cambiar querrá decir que esta clase tiene más de una responsabilidad. Podemos aplicar él mismo concepto un módulo.

Incumplir este principio suele desembocar en clases muy grandes que hacen muchas cosas, como los llamados “manager” que saben hacer de todo. Esto se traduce en problemas como:

Conflictos de git

Al mergear nuestros cambios tenemos más posibilidades de tener conflictos en el archivo ya que nosotros estábamos cambiando la funcionalidad X y nuestro compañero estaba cambiando la funcionalidad Y que también la gestiona esta clase.

Si cada funcionalidad tuviese su archivo, no tendríamos estos conflictos.

Daños colaterales

Cambiamos la funcionalidad X y rompemos la funcionalidad Y porque están en el mismo archivo y no nos hemos dado cuenta de que comparten variables que no deberían.

Si tenemos estas dos funcionalidades separadas es más difícil que los cambios de una rompan la otra ya que la comunicación entre ambas será mucho más clara y no compartirán variables.

Archivos enormes

Códigos enormes que son difíciles de leer y ver todo el alcance que van a tener nuestros cambios, ya que se mezcla con otras funcionalidades.

Otra vez lo mismo, si lo tenemos separado tendremos archivos más pequeños y manejables.

[descargar_libro_solid]

Analicemos las responsabilidades de EnemySpawner

Si mañana decidimos que en lugar de instanciar los enemigos con un random, queremos utilizar una lista que recibimos desde el servidor con el orden de los enemigos, o lo leemos desde una configuración. ¿Tendremos que cambiar esta clase?

Sí, tendremos que cambiar el método SpawnRandomEnemy donde hacemos el random. Ya tenemos un motivo de cambio, una responsabilidad.

Si mañana tenemos un nuevo tipo de enemigo, como un mago por ejemplo, ¿tendremos que cambiar esta clase?

Sí, tendríamos que añadir una nueva variable y modificar el switch. Otro motivo de cambio.

Si ahora decidimos que en lugar de hacer spawn a intervalos queremos utilizar el estado del mundo, saber cuántos enemigos hay vivos, tener en cuenta la vida del héroe y la dificultad del juego… ¿tendríamos que cambiar esta clase?

Nuevamente la respuesta es sí. Tendríamos que cambiar el Update y lo referente a cómo calculamos el tiempo, por lo que tenemos otro motivo de cambio.

Cómo hemos podido ver, esta clase tiene al menos 3 motivos de cambio, lo cual ahora mismo no supone un problema porque es un código muy sencillo.

A la larga ten por seguro que acabará siendo un problema. Será un problema porque crecerá, añadiremos lógica, querremos soportar distintas opciones y lo llenaremos de condicionales que nos dificultarán entender el flujo, etc.

En previsión a estos problemas nos tendríamos que plantear refactorizar esta clase. No queremos que llegue el día en el que añadir un nuevo caso suponga pasar horas entendiendo ese código y preguntándonos si habremos roto alguna otra cosa.

¿Deberíamos de refactorizar esta clase para cumplir el principio SOLID de SRP?

La respuesta fácil es sí. Si queremos tener un código lo más flexible posible y fácil de mantener, deberíamos de extraer cada responsabilidad a una clase.

La respuesta larga es que depende:

¿Esta parte del juego es propensa a que reciba cambios?

Si la respuesta es sí, entonces nos deberíamos de plantear refactorizarlo. En nuestro caso sabemos que mañana vamos a querer añadir más tipos de personajes, con lo que deberíamos de extraer esta responsabilidad.

¿Cuando nos van a pedir que hagamos alguna de estas modificaciones? ¿Será mañana, en una semana, o puede que pasen varios meses?

Si sabemos que el cambio llegará pronto, deberíamos de prevenir esto y tener nuestra clase preparada para soportar el cambio sin mucho esfuerzo. Pero si el cambio llegará dentro de varias semanas o meses, tal vez podamos retrasar este refactor para cuando tengamos que añadir otro tipo de enemigo.

Si decidimos no refactorizarlo ahora deberíamos de tener el compromiso de hacerlo en el momento que vayamos a cambiar esta clase, antes de añadir nada, de otra forma cada vez que añadamos código a la clase estaremos añadiendo complejidad y dificultando el cambio.

En Clean Code tenemos la regla del boy scout, esta regla nos dice que siempre que visitemos una clase intentemos dejarla mejor de lo que la hemos encontrado, de esta forma evitaremos hacer grandes refactors. Hablaremos de esto en futuros posts.

Vamos con el siguiente principio que incumplimos.

SOLID: Open-Close Principle (OCP)

El principio de abierto-cerrado de SOLID nos dice que una clase debe de estar abierta a extensión pero cerrada a modificación. Esto significa que para añadir nueva funcionalidad a nuestro sistema no deberíamos de necesitar modificar lo que ya existe.

Los inconvenientes de no respetar este principio son los siguientes:

  • Para extender nuestro sistema tenemos que cambiar cosas que ya funcionan, con el riesgo de introducir algún bug o dejarlo inservible en el proceso.
  • Hace que los costes de desarrollo sean mayores ya que antes de extender la funcionalidad debemos de conocer muy bien lo que hace nuestro sistema y cómo lo hace.
  • Siempre requeriremos de un programador para extender la funcionalidad.
  • En casos extremos podría suponer romper la compatibilidad con el código existente, debido a que no habíamos pensado cómo extenderlo. Esto sobretodo ocurre con juegos cliente-servidor.

En nuestro ejemplo, si queremos ampliar el sistema con otro tipo de enemigo debemos de modificar la clase EnemySpawner, con lo que estamos incumpliendo este principio.

El caso ideal sería que pudiésemos hacer esto con solo modificar algo de configuración, y que no requiera programar nada nuevo.

Lo mismo ocurre si queremos añadir distintas formas de decidir cómo queremos elegir el enemigo a instanciar. Deberíamos de poder hacerlo sin que esta clase se diese cuenta de que hemos cambiado la estrategia (Strategy pattern).

Vamos a ver ahora cómo podemos solucionar todos estos problemas y verás lo fácil que resulta hacerlo y luego mantenerlo.

Solución aplicando Single Responsibility Principle

Primero vamos a hacer que nuestra clase cumpla el principio SRP de SOLID, para esto extraeremos estas 3 responsabilidades:

  • Cómo elegimos qué enemigo instanciar (el random).
  • Instanciación de los distintos enemigos (switch).
  • Tiempos de instanciación (intervalos de tiempo aleatorio)
solid single responsibility principle

Responsabilidad de seleccionar qué enemigo instanciar

Tan fácil como añadir una clase y llevarnos la lógica donde hacemos el random y elegimos el tipo de enemigo:

public class EnemyTypeSelector
    {
        public EnemyTypes Select()
        {
            var enemyTypes = (EnemyTypes[]) Enum.GetValues(typeof(EnemyTypes));
            var randomTypeIndex = Random.Range(0, enemyTypes.Length);
            return enemyTypes[randomTypeIndex];
        }
    }

Responsabilidad de instanciar enemigo

Para extraer esta responsabilidad crearemos una factoría utilizando el patrón Factory Method y derivando la responsabilidad a esta clase:

public class EnemyFactory : MonoBehaviour
    {
        [SerializeField] private GameObject warrior;
        [SerializeField] private GameObject archer;
        
        public GameObject Create(EnemyTypes enemyTypeToSpawn)
        {
            switch (enemyTypeToSpawn)
            {
                case EnemyTypes.Warrior:
                    return Instantiate(warrior);
                case EnemyTypes.Archer:
                    return Instantiate(archer);
                default:
                    throw new ArgumentOutOfRangeException();
            }
        }
    }

Responsabilidad de la gestión de tiempos de spawn

Como con EnemyTypeSelector, sólo tenemos que extraer la lógica referente a la gestión del tiempo y llamarla desde el update de nuestra clase. También podríamos hacerla MonoBehaviour y que tenga su propio update, pero para facilitar el testing automático lo haremos con una clase plana de C#.

 public class IntervalSpawner
    {
        private readonly Action spawnCallback;
        private          float  timeToNextSpawn;

        public IntervalSpawner(Action spawnCallback)
        {
            this.spawnCallback = spawnCallback;
            CalculateTimeToNextSpawn();
        }

        private void CalculateTimeToNextSpawn()
        {
            timeToNextSpawn = Random.Range(10, 20);
        }

        public void Update(float elapsedTime)
        {
            timeToNextSpawn -= elapsedTime;
            if (timeToNextSpawn > 0)
            {
                return;
            }

            CalculateTimeToNextSpawn();
            spawnCallback?.Invoke();
        }
    }

Responsabilidad de gestionar el spawn de enemigos en su conjunto

Ahora tenemos que cambiar EnemySpawner para que consuma las clases que hemos creado y siga teniendo la misma funcionalidad:

public class EnemySpawner : MonoBehaviour
    {
        [SerializeField] private EnemyFactory enemyFactory;

        private EnemyTypeSelector enemyTypeSelector;
        private IntervalSpawner intervalSpawner;

        private void Start()
        {
            enemyTypeSelector = new EnemyTypeSelector(); 
            intervalSpawner = new IntervalSpawner(SpawnRandomEnemy);
        }

        private void Update()
        {
            intervalSpawner.Update(Time.deltaTime);
        }

        private void SpawnRandomEnemy()
        {
            var enemyTypeToSpawn = enemyTypeSelector.Select();
            enemyFactory.Create(enemyTypeToSpawn);
        }
    }
Unity EnemySpawner
EnemySpawner

¿Qué hemos ganado con estos cambios?

Una vez extraídas las responsabilidades vemos que nos han quedado 4 clases muy pequeñas y manejables en lugar de una única clase con TODA la lógica.

Esto hará que nuestro código mantenga una complejidad baja y sea más fácil de leer.

Además nos abre las puertas a que en un futuro podamos aplicar otras técnicas como la inversión de dependencias (DIP) e inversión de control (IoC) y podamos reciclar algunos componentes.

Hay quienes dicen que prefieren tener toda la lógica en una única clase y no tener que andar buscando por donde está repartida la lógica. En el peor de los casos estaremos extrayendo estas clases en la misma carpeta, con lo que localizarlo debería de ser sencillo. Este pensamiento suele ser derivado de otros problemas de código que podrían ser los siguientes:

  • Pobreza de nombres.
  • Los nombres de las clases no dicen qué responsabilidad tiene esa clase, se utilizan nombres vacíos como manager.
  • Los nombres de las funciones no indican cual es la intención de esa función.
  • La jerarquía de carpetas no deja claro que arquitectura seguimos y como están distribuidas las responsabilidades.

Si utilizamos unos nombres ricos que nos cuenten a la perfección la responsabilidad de las clases, la intención de las funciones, tenemos una jerarquía de carpetas fácil de seguir y siguiendo una arquitectura coherente, si tenemos todo esto no deberíamos de tener ningún problema a la hora de encontrar lo que estemos buscando.

Esto es algo de lo que hablaremos en futuros posts relacionados con Clean Code y Clean Architecture.

Solución aplicando Open-Close Principle

Para cumplir el principio OCP de SOLID vamos a eliminar el switch y lo reemplazaremos por algo más dinámico como podría ser un diccionario. Este diccionario lo alimentaremos desde una lista de enemigos serializada en EnemyFactory, con lo que no estaremos atados a la compilación.

Dado que hemos extraído esta funcionalidad a EnemyFactory respetando así SRP, solo tendremos que cambiar una única clase, y dado que respetaremos el contrato (las funciones públicas que exponemos), ninguna clase que consuma esta factoría se verá afectada por el cambio. Todo ventajas.

Enemy

Hasta ahora no habíamos hablado de la clase Enemy pero vamos a hacerlo ahora. Esta clase simplemente representa lo que es un enemigo y se comunica con otros componentes como el movimiento o la gestión del estado del enemigo:

public class Enemy : MonoBehaviour
    {
        [SerializeField] private EnemyTypes type;
        public EnemyTypes Type => type;

      // Logic
    }

Tener el tipo serializado nos permitirá asignarlo desde el editor de Unity lo que hará que cualquier diseñador, artista, o cualquiera que colabore en el proyecto, pueda crear enemigos sin necesidad de programar nada.

EnemyFactory

Vamos a hacer el cambio de la factoría:

public class EnemyFactory : MonoBehaviour
    {
        [SerializeField] private Enemy[] enemyPrefabs;

        private Dictionary<EnemyTypes, Enemy> typeToEnemyPrefab;

        private void Start()
        {
            typeToEnemyPrefab = new Dictionary<EnemyTypes, Enemy>(enemyPrefabs.Length);
            foreach (var enemyPrefab in enemyPrefabs)
            {
                typeToEnemyPrefab.Add(enemyPrefab.Type, enemyPrefab);
            }
        }

        public Enemy Create(EnemyTypes enemyTypeToSpawn)
        {
            if (!typeToEnemyPrefab.TryGetValue(enemyTypeToSpawn, out var enemy))
            {
throw new Exception($"{enemyTypeToSpawn} is not on enemy prefab");
            }

            return Instantiate(enemy);
        }
    }

Como podéis ver, estamos serializando una colección de enemigos, esto permitirá que cualquier GameObject que arrastremos a esta lista (y tenga el script de Enemy) será un enemigo y estará listo para ser instanciado.

Unity  EnemyFactory
EnemyFactory

El diccionario lo utilizaremos como atajo para buscar el prefab correspondiente al tipo que necesitemos instanciar. Obviamente si intentamos añadir dos enemigos del mismo tipo nos dará un error de ejecución al tener la key duplicada.

¿Qué nos ha aportado este cambio?

Este cambio nos ha aportado que cualquiera sin conocimientos de programación puede extender los enemigos. Lo único que tendrá que hacer esta persona es añadir un nuevo tipo a la enumeración y asignar el prefab a la factoría.

Esto aún podría ser mejorable si eliminasemos por completo la enumeración y en su lugar utilizaremos IDs basadas en strings, hash codes, o uuids. Estos ids podrían estar en el servidor, o en un archivo de configuración que no necesite modificar código para ampliarlo.

Conclusión

En este post hemos visto cómo partiendo del típico código que nos encontraríamos en cualquier sitio para decidir qué elemento instanciar basado en un tipo o id, un código que a medida que vamos añadiendo elementos vamos aumentando su complejidad y dificultando su mantenimiento, y que para extenderlo necesitamos la ayuda de un programador.

De este código, hemos pasado a tener un código mucho más flexible, con sus responsabilidades separadas y que nos permite que cualquiera pueda extender nuestra lógica sin necesidad de programar nada. Además de ser menos propenso a bugs.

Si mañana queremos cambiar cómo decidimos el tipo de enemigo a instanciar solo tendremos que cambiar la clase EnemyTypeSelector sin afectar al resto de las clases. Y lo que es más importante aún, sin modificar código que no está directamente relacionado con esto y así evitarnos bugs derivados.

En un futuro podríamos aplicar el principio DIP de SOLID e inyectar esta clase. De esta forma ninguna de las clases que colaboran con esta se enteraría del cambio y nuestro código sería aún más flexible y fácil de testear.

Del mismo modo, ahora podemos añadir distintos tipos de enemigos casi sin esfuerzo, solo tenemos que configurar el prefab y añadirlo a la factoría.

Todo esto lo hemos conseguido gracias a aplicar dos principios de SOLID: Single Responsibility Principle (SRP) y Open-Close Principle (OCP). Imagínate el beneficio y el potencial de aplicar SOLID y Clean Code a todo nuestro código, incluso a la arquitectura que utilizamos.

En definitiva, hemos visto la cantidad de beneficios que tiene seguir los principios SOLID y el poco esfuerzo que cuesta hacer esto desde el principio.

Si no estás acostumbrado a trabajar con SOLID es posible que al principio te resulte difícil aplicarlo, pero solo es cuestión de práctica que se salga de forma automática, como cuando empezaste en el mundo de los videojuegos y fuiste mejorando con la práctica.

[descargar_libro_solid]

Fuentes

Otros posts

Resumen
➤ Cómo SOLID te puede dar la flexibilidad que tu código necesita
Nombre del artículo
➤ Cómo SOLID te puede dar la flexibilidad que tu código necesita
Descripción
En este post vamos a ver como los 🤖 principios SOLID pueden POTENCIAR 🏋🏻‍♂️ tu CARRERA 💶 y aportar a tu código la flexibilidad que necesitas.
Autor
Publisher Name
The power ups
Publisher Logo