En este artículo vamos a ver uno de los code smell que más me he encontrado a lo largo de mi carrera. Ese código maloliente es el de trabajar con nulos, if este objeto es nulo hago esto, si no hago otra cosa.
Y sí, esto tan común es un code smell. Veremos también un par de patrones que nos ayudarán a evitar este código y la técnica de las pre-condiciones.
Code Smell de preguntar por los nulos
Supongamos que tenemos un héroe que empieza sin arma pero en algún momento del juego puede obtener una arma. Para que la ejecución no falle, antes de atacar con el arma tendremos que preguntar si tiene arma o no:
public class Hero : MonoBehaviour
{
private Weapon currentWeapon;
public void PickWeapon(Weapon weapon)
{
currentWeapon = weapon;
}
public void Attack()
{
if (currentWeapon != null)
{
currentWeapon.DoAttack(null);
}
}
}
Ese if
tan inocente es un Code Smell. No deberíamos de estar preguntando si una cosa es nula o no para ejecutar una lógica u otra. En este caso es muy simple, solo es un if, pero estoy seguro de que habéis visto casos más complejos con una serie de if/else.
Veamos otro caso, supongamos que ese arma recibe un componente para quitarle vida, por seguridad vamos a comprobar si no es nulo para llamar a su función, y si es nulo lo intentaremos recuperar con un GetComponent
:
public class Weapon : MonoBehaviour
{
public void DoAttack(HealthController healthController)
{
if (healthController == null)
{
healthController = GetComponentInChildren<HealthController>();
}
healthController.ApplyDamage(-10);
}
}
Este código es el mismo código maloliente que antes y además presenta varios problemas.
Primero estamos asumiendo que el componente está dentro del mismo GameObject del arma, lo cuál es mucho asumir, nunca debemos asumir estas cosas cuando programemos o tendremos daños colaterales.
En este caso, si es null
y lo conseguimos obtener con el GetComponentInChildren
habremos evitado una excepción pero a cambio estaremos ocultando un error ya que el comportamiento no será el esperado, le estaremos quitando vida a otro componente.
En este caso es mil veces mejor utilizar un assert o un throw.
Assert como pre-condición
Un Assert
no es otra cosa que una comprobación que solo se ejecutará durante el desarrollo y cuando hagamos nuestra build se eliminará.
Lo que estamos pretendiendo con un Assert
es validar todo lo que se deba validar durante el desarrollo, y asumimos que en la build final todo irá bien. De no ir bien lo que tendremos es una excepción cuando utilicemos ese componente nulo.
Es preferible tener esa excepción, la cual nos va a dar mucha información de lo que está pasando y tendremos información para solucionar, antes que esconderlo en un if
y fingir que no ha pasado nada.
El código anterior utilizando un Assert
queda de la siguiente forma:
public override void DoAttack(HealthController healthController)
{
Assert.IsNotNull(healthController, "HealthController can not be null");
healthController.ApplyDamage(-10);
}
Como veis, el código se simplifica.
Throw Exception para evitar comportamientos inesperados
Otra técnica para evitar comportamientos inesperados y esconder errores, es utilizar excepciones.
La diferencia entre las excepciones y los Asserts
es que las excepciones sí que estarán en la build final.
public override void DoAttack(HealthController healthController)
{
if (healthController == null)
{
throw new Exception("HealthController can not be null");
}
healthController.ApplyDamage(-10);
}
Según el caso nos convendría más utilizar un Assert
o un Exception
, yo prefiero utilizar los Asserts
para comportamiento que no se puede dar en la build final, si llegamos con un null
a esta función es porque algo hemos hecho mal durante el desarrollo.
Además si es nulo, cuando llamemos a ApplyDamage
tendremos una excepción sin necesidad de hacer este if
.
Las excepciones las deberíamos de reservar para comprobar excepciones reales y no si los componentes son nulos.
El patrón Null Object
Volviendo al ejemplo del héroe, y si pudiéramos hacer que el arma nunca fuera nula, pero cuando no tengamos arma, utilicemos un arma que no haga nada. Eso a grosso modo sería un NullObject
, un objeto que hereda de una clase base pero que sus funciones no hacen nada o devuelven valores por defecto.
public class NullWeapon : Weapon
{
public NullWeapon() : base(default)
{
}
public override void DoAttack(HealthController healthController)
{
// Do nothing
}
public override int GetDamage()
{
return 0;
}
}
En este caso ya no necesitamos comprobar por el nulo en el Hero
porque si es un Weapon
«normal» hará su lógica, y si es un NullWeapon
simplemente no hará nada.
public class Hero : MonoBehaviour
{
private Weapon currentWeapon;
private void Awake()
{
currentWeapon = new NullWeapon();
}
public void PickWeapon(Weapon weapon)
{
currentWeapon = weapon;
}
public void Attack()
{
currentWeapon.DoAttack(null);
}
}
Esta es una solución de lo más elegante para no tener que lidiar con nulos, simplemente no permitimos que existan nulos pero si objetos que representan un nulo, o un objeto que no hace nada.
La clase Optional, nuestra mejor solución
En Java tienen una clase templatizada llamada Optional
, esta clase es una versión del NullObject
pero mucho más potente. Aquí os voy a enseñar una pequeña implementación que he hecho, bastante más reducida que la original de Java:
Este objeto lo que hará es almacenar la instancia del objeto que puede ser opcional, si existe tendremos su referencia y si no existe tendremos un null
, pero el consumidor no necesitará preguntar por esto.
El método IfPresent
lo que hará es ejecutar la acción que le pasemos si la instancia esta presente, si no es nula.
public class Optional<T>
{
private readonly T value;
public Optional(T value)
{
this.value = value;
}
public Optional()
{
}
public void IfPresent(Action<T> consumer)
{
if (value != null)
{
consumer(value);
}
}
}
Si no es nulo llamamos a la acción y le pasamos como argumento la instancia, se consumiría así:
public class Hero : MonoBehaviour
{
private Optional<Weapon> currentWeapon;
private void Awake()
{
currentWeapon = new Optional<Weapon>();
}
public void PickWeapon(Weapon weapon)
{
currentWeapon = new Optional<Weapon>(weapon);
}
public void Attack()
{
currentWeapon
.IfPresent((weapon) => weapon.DoAttack(null));
}
}
Ahora nuestro método Attack
no necesita hacer un if
preguntando por el nulo, simplemente llamará al IfPresent
y si el arma es null
no hará nada, pero si no es nula se llamará a la función que le pasemos, en este caso es una lambda
que llama al DoAttack
.
El siguiente método de la clase Optional
es el OrElse
, es como un Get
pero si la instancia es nula nos devolverá el valor que le pasemos.
public T OrElse(T elseValue)
{
if (value == null)
{
return elseValue;
}
return value;
}
El método OrElseThrow
es muy similar al OrElse
pero si la instancia es nula devolverá la excepción que le indiquemos.
public T OrElseThrow(Exception exeption)
{
if (value == null)
{
throw exeption;
}
return value;
}
Imaginemos que queremos devolver el arma en el Hero
, pero si es nula queremos devolver un NullWeapon
, lo podríamos hacer así:
public class Hero : MonoBehaviour
{
private Optional<Weapon> currentWeapon;
...
public Weapon GetWeapon()
{
return currentWeapon.OrElse(new NullWeapon());
}
}
Al hacer currentWeapon.OrElse(new NullWeapon())
le estamos diciendo: si existe devuelve la instancia, pero si no utiliza esta otra instancia que te estoy pasando.
El OrElseThrow
se consume de forma muy similar, pero para esto voy a plantear otro ejemplo. Supongamos que tenemos un Repository
de héroes que nos ayuda a hacer búsquedas sobre estos héroes.
public interface IHeroRepository
{
Optional<Hero> GetHeroById(int id);
}
public class HeroRepository : IHeroRepository
{
public Optional<Hero> GetHeroById(int id)
{
// Aquí haría la búsqueda
return new Optional<Hero>();
}
}
Si el héroe existe crearemos un Optional
con esa instancia, pero si no existe el Optional
estará vacío. Cuando vayamos a consumirlo queremos que si no encuentra el héroe salte una excepción porque algo ha ido mal.
public class Consumer
{
public void Method(IHeroRepository heroRepository, int id)
{
var hero = heroRepository.GetHeroById(id)
.OrElseThrow(new Exception($"Hero with id {id} not found"));
}
}
Con el OrElseThrow
simplemente le decimos que no existe salte la excepción que tenemos preparada, pero si existe seguiremos la ejecución.
Eso nos evita tener que ir preguntando si la instancia es nula o no para ejecutar un código u otro.
Conclusión
Hemos visto varias técnicas para evitarnos tener que trabajar con nulos y preguntar por ellos. Con esto hemos eliminado uno de los Code Smell más comunes y aumentando la legibilidad de nuestro código, lo que se traducirá en un código más mantenible.
Si quieres descargarte la clase Optional que he implementado, la encontrarás aquí.
Otras entradas
- ¿Cómo empezar en el desarrollo de videojuegos?
- Patrones de diseño – Template Method
- Patrones de diseño – Service Locator
- Cómo aumentar el rendimiento de tu equipo en Unity
- Patrones de diseño – Composite
- Devlog #00 – Empezamos proyecto nuevo