Null Objects

Publicado 12-04-2019

Todo comienza cuando invocamos un método que devuelve un objeto si lo encuentra, o null si no lo hace. Acto seguido, debemos chequear si ese valor devuelto es efectivamente el objeto esperado para poder operar. Y si nos ponemos audaces y no chequeamos la posible nulidad, nos arriesgamos a obtener la excepción más conocida por todos los programadores: las referencias a null.

El código fuente del video (separado en diferentes commits según el incremento) está disponible en Github para su consulta.

Para tener un contexto, veamos cómo se realiza el chequeo por nulidad en diferentes lenguajes:

if (nullProneReference != null) {
    nullProneReference.doSomething();
}

En Java, como vemos, debemos comprobar explícitamente. Si le agregamos el componente dinámico al lenguaje, y una sintaxis más amigable, obtenemos la versión en Groovy:

nullProneReference?.doSomething()

Groovy presenta un atajo simple, que verifica la nulidad de la variable antes de invocar el método. Y ahora, en Ruby:

null_prone_reference.try(:do_something)
# equivalente a...
null_prone_reference && null_prone_reference.do_something
# y también a...
null_prone_reference ? null_prone_reference.do_something : nil

Más allá de los atajos sintácticos, las soluciones que presentan estos lenguajes consiguen salvarnos del problema en primera instancia. Sin embargo, no bien comenzamos a encadenar llamadas a métodos, debemos propagar el uso de dichos atajos. A modo ilustrativo, en Ruby sería así:

object.try(:method1).try(:method2).try(:method3)

Esta sucesión de try dificulta la lectura del código, y por lo tanto su comprensión. Adicionalmente, es complicado para los IDE comprobar la correctitud de las invocaciones, ni que decir de la ausencia de errores de tipeo en el nombre (ya que pasan a ser símbolos en lugar de mensajes).

Es por esto que surge el patrón Null Object, mediante el cual se crea una clase que representa la ausencia de un objeto de otra clase a la que acompaña.

Características

Se define al Null Object como a la representación de los objetos de una clase en particular, cuyo comportamiento es neutro (o nulo, por eso el nombre). Las implementaciones difieren conforme se utilice un lenguaje con tipado estático o dinámico, por lo que abordaré ambos ejemplos.

Con tipado estático: Java

El siguiente ejemplo podría beneficiarse de un Null Object:

ServicesProvider provider = DB.getServicesProvider(id);
if (provider != null && provider.needsPayment()) {
    // recién aquí es seguro invocar métodos de provider
    provider.pay(provider.getDebt());
}

La forma de implementar este patrón, involucra al método getServicesProvider(), y la creación de una nueva clase en la jerarquía:

class NullServicesProvider extends ServicesProvider {
    //...
    public boolean needsPayment() {
        return false;
    }
}

class DB {
    //...
    public static ServicesProvider getServicesProvider(int id) {
        ServicesProvider result = someMagic();
        if (result == null) {
            result = new NullServicesProvider();
        }
        return result;
    }
}

Lo que permitirá reescribir el primer extracto de código de la siguiente manera:

ServicesProvider provider = DB.getServicesProvider(id);
// siempre es seguro invocar métodos de provider
if (provider.needsPayment()) {
    provider.pay(provider.getDebt());
}

Dicha salvedad se extiende a todos los sitios, dentro del código fuente de nuestra aplicación, donde se utilice este método para obtener un ServiceProvider: ya no deberemos verificar que el valor no sea nulo.

Con tipado dinámico: Ruby

En un lenguaje con tipado dinámico, la implementación puede prescindir de la herencia: tenemos el Duck Typing a nuestro servicio. El mismo ejemplo que para Java, en Ruby sería así:

provider = Storage.find_service_provider id
if provider.try(:needs_payment?)
    # estas llamadas son seguras
    provider.pay(provider.debt)
end

Lo cual se puede resolver con los siguientes cambios:

class NullServiceProvider
    # ...
    def needs_payment?
        false
    end
end

class Storage
    # ...
    def self.find_service_provider(id)
        result = some_magic()
        result || NullServiceProvider.new
    end
end

El código luego de la introducción del patrón Null Object quedaría de la siguiente manera:

provider = Storage.find_service_provider id
# siempre es seguro invocar métodos al provider
if provider.needs_payment?
    provider.pay(provider.debt)
end

El código es mucho más breve, no hay acoplamiento entre clases y sus clases nulas, y aumenta sensiblemente la legibilidad.

Consideraciones

Este patrón recibe también el nombre de Default Object, pero no es tan conocido de ese modo. Es entendible y a veces hasta más acertado, ya que un Null Object modela el comportamiento predeterminado de un objeto de cierta clase, de manera de no perturbar el código de las clases clientes.

Cuando se implementa un Null Object, solemos utilizar el prefijo Null. Sin embargo, un apartado de Ron Jeffries en el libro Refactoring, de Martin Fowler, nos cuenta la alternativa del nombre Missing Object. En otros artículos más, encontré implementaciones con el nombre de UnknownObject o de NoObject. Todas las nomenclaturas me parecen apropiadas para ciertos contextos. No obstante, debo recomendar firmemente lo siguiente:

Sin importar la nomenclatura que adoptemos para este patrón, debemos mantenerla constante a lo largo de todo el proyecto, para reducir la carga cognitiva al momento de leer el código fuente.

Finalmente, y dado que los Null Objects presentan un comportamiento que no varía según el estado, y que pueden comenzar a propagarse por muchos sitios a lo largo de nuestro código, se recomienda crearlos como Singleton: siempre que pidamos un NullObject, obtendremos el mismo.

Ventajas

Tony Hoare confesó que considera la introducción de las referencias nulas como la equivocación de los mil millones de dólares. La cita de su conferencia en la QCon de Londres en 2009, es muy interesante:

Lo llamo mi equivocación de los mil millones de dólares. Fue la invención de la referencia nula, en el año 1965. En esa época estaba diseñando el primer sistema de tipado comprensivo para las referencias de un lenguaje orientado a objetos (Algol W). Mi objetivo era asegurarme de que todos los usos de las referencias sean absolutamente seguros, con verificaciones realizadas automáticamente por el compilador. Pero no pude resistir la tentación de agregar una referencia nula, simplemente porque era muy fácil de implementar. Esto llevó a innumerables errores, vulnerabilidades, caídas de sistemas, que probablemente han causado mil millones de dólares de dolor y daños en los últimos cuarenta años.

El uso de este patrón nos permite reducir la incidencia de las referencias a nulo a lo largo del código fuente de nuestros programas, con las ventajas sintácticas y de robustez que eso conlleva.

Adicionalmente, la utilización de este patrón permite que cada vez que se utilice un objeto de nuestras clases se pueda hacer uniformemente, sin necesidad de considerar casos especiales para los valores nulos. Esto evita que se rompa el polimorfismo en nuestro código, por los casos especiales.

Por último, conforme extendamos el uso de Null Objects, suprimimos de los clientes la carga de la verificación por nulidad de los objetos devueltos por nuestras API. Los usuarios de nuestro código lo agradecerán, y las complejidad del código bajará.

Desventajas

No puedo dejar de notar que la introducción de Null Objects involucra la adición de una nueva clase a nuestro programa por cada clase concreta de la cual pudieran devolverse objetos nulos. Quedará a criterio de los desarrolladores si dicha adición es justificada, conforme reduzca la complejidad del resto del código. Si tenemos pocos chequeos, no valdrá la pena. Sin embargo, su kilometraje puede variar.

Dado que no se requieren jerarquías para implementar un Null Object en los lenguajes dinámicos, es probable que algunos métodos terminen no siendo implementados. Sin embargo, eso se subsana con buenas pruebas y una introducción metódica y cuidada del patrón.

Si no se comunica adecuadamente al resto del equipo de desarrollo, se pueden hacer comprobaciones inútiles a lo largo del código, simplemente por no saber que se evita el retorno de nulos, en favor de Null Objects.

Lectura profunda


Comentarios

  1. Cristian R.

    Hola Lucas. Ayer miraba el último Sip of Code y encontré una sutileza al principio que pensé por ahí te interesaría escuchar/investigar. Al pasar comparás dynamic typing con duck typing, como si fueran sinónimos y eso no es exactamente lo mismo. Go por ejemplo usa duck typing (if it quacks like a duck and it walks like a duck..) a la hora de ver si una struct implementa una interface, como lo hacen Ruby y Python ero Go es estáticamente tipado. O sea, en la mayoría de los casos es cierto, but not always. Es interesante el caso de Go. Rust es igual que Java pero más extensible, si mal no recuerdo es muy similar a Groovy que alguna vez me comentaste respecto a las restricciones de implementación de interfaces en tipos que vos no creaste.

  2. Lucas V. (yo)

    @Cristian R.: ¡Voy a mirar eso! Gracias por el dato, aún no utilicé Go pero está en mi lista de “lenguajes interesantes”, y eso es una buena excusa para investigar un poco.