Tipos fantasmas en C#

Hace poco decía que lo mejor de javascript era que había obligado a mucha gente a conocer otras formas de hacer las cosas y que este nuevo conocimiento podía ser útil al volver a usar las herramientas y lenguajes con los que uno está más familiarizado.

Esto obviamente no es algo específico de javascript y pasa cada vez que aprendes un nuevo lenguaje. Mis últimas aventuras con lenguajes funcionales me han llevado a conocer una técnica relativamente frecuente en lenguajes como Haskell o Scala que me ha parecido interesante aplicar a C#: los tipos fantasmas.

Un tipo fantasma es un tipo parámetrico en el que el parámetro sólo se utiliza en la definición del tipo, no se utiliza en ninguna de sus operaciones. Esto puede parece un poco lioso, pero si lo traducimos a C# y ponemos un poco de código es bastante fácil de entender:

public class PhantomType<T>
{
    void DoSomething(int a, intb) {...}
    int GetSomeValue() {...}
}

La clase PhantomType es una clase genérica con un parámetro T, pero en sus metodos no se referencia para nada ese parámetro. Aparentemente, el parámetro sobra y no sirve para nada.

¿Por qué querría alguien escribir una perversión semejante? Para aprovechar la comprobación estática de tipos del compilador, que para esto estamos trabajando con un lenguaje estático.

Un ejemplo práctico: Mejorando el tipo de los IDs de entidades

Imaginemos un método para calcular el precio de un producto con la siguiente signatura:

decimal GetPrice(int productId, int customerId, int priceListId);

Este método utiliza los Ids del producto, cliente y tarifa para calcular el precio final de venta de un producto. El «problema» de este método es que todos sus parámetros son de tipo int, por lo que es posible pasarlos en orden incorrecto y el compilador no se enterará de nada:

// Esto es correcto
var price = GetPrice(product.Id, customer.Id, priceList.Id);

// Esto es incorrecto, pero no se puede detectar en tiempo de compilación
var price = GetPrice(customer.Id, priceList.Id, product.Id);

Hay varias formas de resolver esto. Por ejemplo, podríamos cambiar el método para que en lugar de recibir los ids recibiese las entidades enteras, pero eso nos obligaría a disponer siempre de las entidades para invocar el método, y podríamos tener escenarios de uso en los cuales partimos de datos existentes en un ViewModel o en un DTO y no nos interese cargar la entidad desde la base de datos.

También podemos pensar que estamos antes un problema de primitive obsession (que, de hecho, lo estamos) y decidir crear una nueva clase para representar cada id, pero pronto acabaríamos con un montón de clases prácticamente idénticas que son aburridas de escribir y mantener.

Otra opción que seguro que ya habíais intuido por el título de este post es usar tipos fantasmas. Podemos crear un tipo para representar ids como éste:

public struct Id<T>
{
    public readonly int Value;

    public Id(int value)
    {
        Value = value;
    }
}

Id<T> es un tipo fantasma porque no usa para nada el parámetro de tipo T, lo único que hace es encapsular en un struct es valor del id. El código de las entidades cambiaría para usar este tipo, por ejemplo:

public class Product
{
    public Id<Product> Id { get; private set; }

    // ...  el resto de la clase
}

Por cierto, si te estás preguntando cómo vas a mapear eso con tu ORM, a lo mejor deberías plantearte cambiar de ORM y buscar algo más flexible.

Gracias al tipo Id<T> podemos hacer más restrictiva la signatura del método GetPrice que teníamos antes:

decimal GetPrice(Id<Product> productId, Id<Customer> customerId, Id<PriceList> priceListId)

Ahora el compilador podrá detectar errores al invocar el método GetPrice. Para que el tipo Id<T> esté completo deberíammos redefinir los métodos Equals y GetHashCode, e incluso podemos añadir una conversión implícita de int a Id<T> que nos permita escribir código como this.Id = 14 y nos facilite un poco la vida.

En este gist podéis encontrar el código completo de Id<T>.

Conclusiones

El debate sobre lenguajes estáticos y dinámicos está más vivo que nunca y siempre habrá detractores en cada uno de los bandos. Al final no se trata de decidir sin son mejores unos u otros porque eso siempre dependerá de cada caso de uso, sino de aprovechar al máximo las características de cada tipo de lenguaje.

Con los tipos fantasmas podemos sacarle más partido a la comprobación estática de tipos que realiza el compilador y detectar errores en tiempo de compilación, lo que no deja de ser una ventaja interesante.

Quiero recalcar nuevamente lo importante que resulta tener la mente la abierta y experimentar con otros lenguajes, porque aunque nunca llegues a utilizarlos «en la vida real», puedes tomar ideas que sí sean aplicables en tu día a día.

13 comentarios en “Tipos fantasmas en C#

  1. Mmm en tu gist tambien hay una interesante aplicacion del tipo fantasma (en el que es utilizado en tiempo de ejecucion): en la implementacion de equals. Un ejemplo de como los tipos parametrizados «reificados» en tiempo de ejecucion pueden ser utiles y como el borrado de los parametros en java en tiempo de ejecucion, en este caso, limita su utilidad.

  2. Sabía que en Java los tipos genéricos funcionaban distinto que en .NET, pero después de ver las diferencias creo que me quedo con .NET.

    Si no he entendido mal, en Java al final la implementación es la misma para todas las instancias del tipo y sólo se valida en tiempo de compilación, por lo que por ejemplo tendría siempre boxing/unboxing si usas enteros, ¿no?

    En .NET es frecuente acceder al parámetro de tipo (con un typeof(T)) y, aunque menos frecuente, tampoco es tan raro cerrar un tipo genérico en tiempo de ejecución usando reflection con un typeof(List<>).MakeGenericType(someTypeLoadedDynamically).

  3. Lo del boxing/unboxing no termino de pillarlo pero sí, en java no esta disponible directamente la informacion de los parametros en tiempo de ejecucion (p.ej. instanceof) aunque si que esta en el bytecode compilado y se puede conseguir con algun hack via reflexion. De hecho lo de «borrado» que he usado en mi anterior comentario no es tecnicamente cierto (aunque se suele usar).
    http://nerds-central.blogspot.com.es/2011/04/java-type-erasure-what-pile-of-bollocks.html

  4. En .NET existen tipos por valor (piensa en un int, double o, general, cualquier struct) y tipos por referencia (cualquier clase). Los tipos por valor se almacenan en el stack y los tipos por referencia en el heap (esto es un detalle de implementación, pero a día de hoy funciona así). Cuando quieres «guardar» un tipo por valor en una referencia a Object, hace falta reservar memoria en el heap y copiar allí el valor del stack. Eso se conoce como boxing. La operación opuesta es el unboxing.

    Antes de introducir tipos genéricos, si tenías un ArrayList (parecido a un Vector de Java, si no recuerdo mal) y metías dentro ints, como internamente el ArrayList manejaba un array de object, además del precio de los cast al meterlos y sacarlos tenías el precio del boxing/unboxing, que podía no ser despreciable.

    Al introducir generics en C#2.0, las clases con colecciones no mantienen dentro un array de object, sino un array del tipo concreto, por lo que te ahorras los casts y los boxings/unboxings.

    Lo que no me queda claro después de leer el artículo de nerds-central es si Java usa realmente para algo las anotaciones que añade al código o no. Realmente la impresión que me da es que están «a título informativo», pero internamente la clase sigue siendo la misma versión sin parametrizar que existía antes.

  5. Sí, lo del autoboxing es similar en java a partir de la version 1.5. Mi duda es que en java no puedes tener, al menos explicitamente, una Collection y el autoboxing es necesario tengas o no la informacion de los parametros de tipo disponible en runtime. Quiero decir que ya tengas una List o List cuando haces un list.add(1) el autoboxing hay que realizarlo igual creo. Los objetos de la coleccion son Integer en ambos casos. Igual me estoy perdiendo algo evidente…
    En cuanto a lo de la «type erasure», java no utiliza la informacion de los tipos genericos aunque realmente no la borre del bytecode generado, asi que efectivamente (menos en cuanto a la reflexion toca) es como si no existieran.

  6. Hablo muy de memoria sobre Java, así que si me equivoco corrígeme. En Java los tipos primitivos no heredan de Object y viven por su cuenta. Para poder interoperar con ellos existe la conversión a los Integer, Character y compañía, que sí heredan de Object y los meten dentro de la ontología de tipos.

    En .NET ese concepto no existe porque todos los tipos están unificados en una única jerarquía que parte de Object. Los tipos «primitivos» se implementan como tipos por valor (structs), pero derivan de Object. De hecho pueden implementar interfaces, puedes escribir cosas como 17.CompareTo(20) (aunque en ese caso se puede producir un boxing al invocar el método, pero esa es otra historia :-)).

    Cuando en .NET haces var list = new List<int>(); list.Add(1), no se produce ningún boxing. No se crea ningún objeto en ninguna parte (suponiendo que la lista tenga espacio en su array interno y ese tipo de cosas). Simplemente se copia el valor 1 en el array que usa internamente List para almacenar los datos.

    Sin embargo, cuando haces var list = new List<object>(); list.Add(1), que sería el equivalente a usar una lista no genérica, sí que se produce un boxing para encapsular el int dentro de un object porque el array que usa List para almacenar datos es un array de referencias, no de valores, por lo que hace falta convertir el valor (un entero) en una referencia (el object) y para ello se instancia un nuevo Object en el heap.

    De todas formas, aquí lo explican mejor que yo: http://msdn.microsoft.com/en-us/library/yz2be5wk.aspx

  7. Ams, no recordaba esa diferencia en la organizacion de los tipos entre java y .net. Perfectamente explicada, por otra parte ;-)

  8. La propuesta es curiosa pero, en mi opinión, parece más un intento por arreglar un problema de diseño que uno real. Si se me permite la comparación, es como si un electricista dijera que la forma de reconocer cables innecesariamente largos de entre una maraña laberíntica es pintarlos de colores, con el consecuente esfuerzo adicional. Si el electricista, simplemente, hubiera puesto esos cables más cortos muy probablemente no necesitaría pintarlos de ningún color para distinguirlos. En nuestro caso, creo que no sería necesario utilizar Id si, simplemente, pasásemos «customer» y no «customerId» al método. El tipado que persigue Id ya existe, y lo dan Customer y las demás clases que naturalmente se han creado como parte del dominio de la aplicación. En orientación a objetos, entiendo que lo lógico es que sean los objetos en sí mismos los que representen su propia identidad, y no su DNI por separado. Si se hace así, no veo la necesidad de un Id.

  9. Gracias por el comentario, Abel. En realidad lo de los IDs es sólo un ejemplo para demostrar lo que son los tipos fantasmas, pero puede que no sea el mejor.

    De todas formas, con respecto a lo que comentas de pasar el Customer entero, no siempre es una buena idea. Es posible que sólo tengas el Id, por ejemplo porque lo has cargado en un ViewModel o lo has recibido en un DTO que no contiene el Customer completo, y en ese caso necesitarías construir el objecto Customer lo que, seguramente, implique un acceso a la base de datos potencialmente costoso.

    Por otra parte, lo de representar la identidad de un objeto con el propio objeto y no con su DNI es un punto interesante.

    Hay casos en que una única entidad del mundo real, por seguir con el ejemplo, un cliente, se ve modelada como distintos objetos dependiento de la parte del sistema que lo trata, y en ese caso hace falta tener una identidad que permita hacer el seguimiento. Es muy típico cuando tienes aplicaciones DDD con varios Bounded Context, donde puede existir un CRM.Customer y un ERP.Customer, cada uno con datos y métodos distintos, pero que representan al mismo cliente en la vida real. Cuando quieres enlazar información de ambos Bounded Context, es razonable que lo que fluya entre ellos sea «el DNI por separado» para no acoplarlos entre si.

  10. Entiendo lo que comentas sobre DDD y el acoplamiento, Juanmna pero lo del Id{T} me sigue pareciendo un artificio difícil de justificar. Es como cuando en ciertos lenguages la gente se queja de que no hay parámetros output y se «tiene» que crear una clase en la que envolver un tipo primitivo para poderlo simular, cuando en verdad el mal ahí (en la mayoría de los casos) es estar usando un parámetro output. Creo que unos buenos tests sobre la clase que llama al método GetPrice deberían ser suficientes para detectar que se le está llamando con los parámetros en un orden incorrecto. Para mi, algo que ha de justificarse, digamos, en exeso es, normalmente, algo que debe revisarse. Creo que es el caso en el que Id{T} está incurriendo en tanto en cuanto la no utilización de T es bastante oscura. Un test que verifica que GetPrice es llamado como debe creo que no requerirá explicación adicional a la necesaria en cualquier otro test. Por otro lado, creo que el uso de Id{T} es intrusivo. Obliga a modificar tu diseño para tratar de suplir una, digamos, carencia del lenguaje con el que se programa que, de otro modo, no sería necesaria. Es más, incluso usando ese mismo lenguaje, sigue siendo innecesaria si, como digo, hay un test que se ocupa de la correspondiente verificación. Test, por cierto, nada intrusivo. Tanto si implementas el test como medida de protección ante el fallo que quieres cubrir como si no, tu diseño no se ve afectado.

  11. Completamente de acuerdo en que todo el ejemplo de Ids es un intento de «salvar» una limitación del lenguaje y resulta intrusivo. Si C# tuviese tipos derivados al estilo ADA o Pascal, no sería necesario y todo quedaría más natural (http://en.wikibooks.org/wiki/Ada_Programming/Subtypes#Derived_types).

    En cuanto a lo del test, no estoy tan de acuerdo. El problema es que en este caso concreto no tienes que verificar si GetPrice funciona, sino si todos aquellos que llaman a GetPrice funcionan. Además serían (presumiblemente) test de interacción, que no me acaban de convencer mucho (creo que cuestan más de lo que aportan).

    Si trabajas con un lenguaje dinámico no te quedaría más remedio que hacerlo así, pero ya que tengo un compilador que va hacer un análisis estático, prefiero intentar aprovecharlo.

  12. Lo de que habría que verificar si todos los que llaman a GetPrice es un buen punto. También es cierto que hacer un test de este tipo cuesta más de lo que aporta. Pero, sinceramente, también creo que utilizar Id{T} cuesta más de lo que aporta. Quizá con otro ejemplo más práctico se vería mejor la aportación de esta solución.

  13. Me parece muy interesante el artículo. Y como siempre, habrá gente que dirá que le parece muy mal o muy bien.
    Creo que hay que tener este tipo de «Helpers» a mano porque hay momentos en que te puede salvar la vida y sobretodo me quedo con la frase «experimentar con otros lenguajes» para tomar ideas. No hay que ser tan cerrado ni purista, a la larga es contraproducente.

Comentarios cerrados.