Tipado nominal y tipado estructural

Cuando empiezas a conocer varios lenguajes de programación es fácil darse cuenta de las diferencias entre ellos. No me refiero sólamente a las más evidentes (paradigma, sintaxis, librerías, etc.), sino también a cosas que puede resultar algo más sutiles, como las implicaciones que tienen en el diseño de las aplicaciones el sistema de tipos del lenguaje o las capacidades de inferencia que ofrezca.

Es muy habitual ver discusiones (eternas) sobre si los lenguajes con tipado estático son mejores o peores que los dinámicos, pero esa es sólo una de las posibles formas de clasificar los sistemas de tipos.

Últimamente estoy programando bastante con TypeScript y aprovechando su tipado estructural para minimizar el uso de clases. Eso me ha llevado a conocer mejor las posibilidades que este tipo de tipado (valga la redundancia) puede ofrecer, pero también a ver algunas limitaciones, especialmente por la forma en que está implementado en TypeScript.

De momento en este post quiero intentar (no soy un experto en la materia) explicar las diferencias entre el tipado nominal y el tipado estructural, y algunas de las implicaciones que tienen uno y otro. Seguramente los expertos de verdad en sistemas de tipos puedan ver un montón de inexactitudes en la forma en que voy a contarlo. Estaré encantado de recibir correcciones y aprender más sobre todo esto.

Sistemas de tipos

Antes de nada, es importante tener una intuición de qué es un sistema de tipos, y para ello, primero necesitamos definir qué es un tipo. Sin entrar en definiciones complicadas, podemos ver los tipos como una forma de clasificar valores, entendiendo valores en el sentido más amplio de la palabra (una variable, una constante, o incluso una función). En base al sistema de tipos, podemos establecer que un valor determinado es un int o es una función de int -> string.

Partiendo de esa idea, una forma fácil de pensar en un tipo es verlo como un conjunto (potencialmente infinito) formado por los valores que forman parte de él. Por ejemplo, el tipo int estaría formado por los números enteros (probablemente con las limitaciones del hardware usado para representarlos) y el tipo class Person por todas las posibles instancias que se puedan construir de la clase Person.

Esta información de tipos permite comprobar que nuestro programa se comporta como debe. En el caso de un lenguaje con tipado estático, el compilador podrá chequear de forma estática las asignaciones para verificar que los tipos son compatibles entre sí. En el caso de un lenguaje dinámico, estas comprobaciones se realizán en tiempo de ejecución.

Todo esto se puede complicar con mil matices cuando empezamos a pensar en conversiones entre tipos, o en la posibilidad de que un valor cambie de tipo a lo largo de su ciclo de vida, o en tipos de tipos, pero de momento nos sirve para hacernos una idea de la utilidad que tienen los sistemas de tipos.

Tipado nominal

En un sistema de tipos nominal, dos tipos son compatibles si tienen el mismo nombre. Si volvemos un momento a los tipos como conjuntos, lo que estamos diciendo es que dos conjuntos son compatibles si les hemos dado el mismo nombre, independientemente de que tengan los mismos elementos o no.

Esto, que parece una tontería, tiene implicaciones más profundas. En un sistema de tipos nominal, podemos tener dos tipos con «idénticos» valores posibles, pero que no son compatibles entre sí. Un ejemplo de esto son las clases de C#:

class Person {
  public int Id { get; set; }
  public string Name { get; set; }
}
class Product {
  public int Id { get; set; }
  public string Name { get; set; }
}

Person paco = new Person { Id = 2, Name = "Paco" };
Product morcilla = paco; // error!

Person y Product son estructuralmente idénticos y pueden tomar exactamente los mismos valores, concretamente, todos los posibles pares de int y string. Sin embargo, para el sistema de tipos de C#, los elementos de un conjunto no son compatibles con los del otro. Y esto es Bueno™. O al menos lo parece, porque nos evita cometer fallos como intentar vender personas o casarnos con productos.

Un problema directo de los sistemas de tipos nominales es que limitan el polimorfismo. Si al final el tipo de algo viene marcado por el nombre, y no por las características, ¿cómo podemos tener operaciones que sean capaces de trabajar con varios tipos distintos? Aquí entran en juego distintas técnicas de subtipado nominal.

Una opción, como en el caso de C# o Java, es poder indicar que un tipo es un subtipo de otro a través de una relación de herencia explícita. Siguiendo con nuestra analogía de conjuntos podemos ver ese caso como que el conjunto de la clase derivada es un subconjunto de la clase padre. Otra opción es utilizar interfaces o algún otro tipo de herencia múltiple. La situación es la misma que con la herencia simple, es decir, estamos indicando explícitamente durante la definición de un tipo que ese tipo es compatible con otro.

En otros lenguajes, por ejemplo Haskell o Scala, existen conceptos como type classes que, simplificando mucho y mintiendo un poco, nos permiten indicar que un tipo implementa un interfaz, pero no nos obliga a hacerlo en el momento de definir el tipo, sino que podemos hacerlo a posteriori.

Además de todo esto, hay lenguajes que permiten definir alias sobre tipos, de forma que podamos referirnos a un tipo con varios nombres, pero sin considerarlos tipos diferentes. Por ejemplo, en C podemos usar typedef para ello:

typedef char CHARACTER;

char c1 = 'a';
CHARACTER c2 = c2; // OK, es sólo un alias, no un nuevo tipo

Tipado estructural

Si el tipado nominal se basaba en nombres, no hace falta ser un genio para intuir que el tipado estructural se basa en la estructura de los tipos. En un sistema de tipos estructural dos tipos son compatibles si la estructura de sus elementos es compatible.

¿Qué quiere decir que dos tipos tienen estructura compatible? Bueno, esto depende un poco de cada sistema de tipos, pero básicamente, que el tipo desde el que estamos asignando contiene, al menos, la misma información que el tipo al que estamos asignando.

Por ejemplo, en TypeScript podemos jugar con los siguientes tipos:

type Shape { width: number, height: number }
type Box { color: string, width: number, height: number }

let s: Shape = { width: 10, height: 5 };
let b: Box = s; // Error, Shape no tiene color

let b2: Box = { color: 'blue', width: 8, height: 4 };
let s2: Shape = b2; // OK, Box tiene todo lo que necesita Shape

En realidad, es similar a lo que ocurriría si hubiésemos establecido una relación de herencia entre los tipos o de implementación de interfaces al estilo C#/Java, pero de forma implícita. En ningún sitio hemos indicado que Box es un subtipo de Shape, pero el compilador es capaz de darse cuenta de que todos los objetos Box son compatibles con los objetos Shape, ya que contienen las mismas propiedades con el mismo tipo (y alguna adicional, que nos da igual).

La ventaja de este estilo de tipado es que no hace falta estar definiendo a priori prácticamente nada. No necesitamos irle poniendo nombre a las cosas y creando jerarquías de clases o interfaces para poder aprovechar el polimorfismo, todas nuestras funciones serán capaces de trabajar con cualquier tipo de datos siempre y cuando se ajusten a la estructura apropiada. Vamos, la versión type safe del Duck Typing.

Dependiendo del lenguaje, se permite más o menos flexibilidad a la hora de utilizar el tipado estructural. Scala, por ejemplo, soporta tipado estructural, pero sólo cuando explícitamente se quiere usar:

class SomeClass { def say() = { "hi!" }}

// Esta función vale para cualquier cosa con say()
def duck(obj: {def say(): String}) { println(obj.say()) }

duck(new SomeClass) // OK
duck(new AnyRef { def say() = { "Any!" } }) // OK

// Ésta sólo para objetos SomeClass
def no_duck(obj: SomeClass) { println(obj.say()) }


no_duck(new SomeClass) // OK
no_duck(new AnyRef { def say() = { "Any!" } }) // Error

Al definir la función duck indicamos que lo único que nos importa del parámetro que recibe es que tenga un método say() : String. Eso hace que podamos usarla tanto con un objeto de tipo SomeClass, como con un objeto cualquiera que tenga ese método. En el caso de no_duck es al contrario; indicamos que queremos un objeto de tipo SomeClass, por lo que no basta con que el objeto que recibe como parámetro tenga un método say(): String, y se aplica un tipado más nominal.

Esta forma de trabajar tiene la ventaja de que permite decidir cuánta flexibilidad queremos tenemos (o cuanto riesgo queremos asumir, según se mire), pero a cambio perdemos parte del atractivo del tipado estructural, ya que nos obliga a definir a priori cuándo queremos que sea posible usarlo y cuándo no.

Resumen

Cada sistema de tipos tiene distintas características que pueden potenciar determinadas formas de trabajar.

En un sistema de tipos nominal podemos aprovechar su mayor rigidez para ganar en seguridad, limitando las operaciones disponibles para cada tipo de forma que evitemos cometer errores y mezclar operaciones con tipos que no tienen sentido (como el ejemplo de vender una persona o casarse con un producto).

A cambio, cuando queremos tener operaciones polimórficas capaces de trabajar con distintos tipos nos obligan a ser más explícitos. Esto hace que sea frecuente que acabemos con más artefactos en el código, como interfaces, clases base o type classes.

En cambio, los sistemas de tipos estructurales nos permiten definir operaciones basándonos sólamente en la estructura de sus argumentos, lo que facilita concretar a posteriori el tipo concreto de los mismos. Mientras la estructura sea compatible, podremos utilizar esas operaciones sobre los nuevos tipos que creemos, sin necesidad de declarar nada explícitamente.

Como contrapartida, podemos acabar realizando operaciones que, si bien estructuralmente son compatibles, semánticamente no tienen sentido (como el caso del producto y la persona que veíamos antes).

3 comentarios en “Tipado nominal y tipado estructural

  1. Preciso post que casi no necesita el disclaimer ;-)
    Tal vez solo puntualizar que las type classes estan de alguna manera a caballo entre los dos tipos (je) de tipado : es cierto que hay que definirlas y que al final tienen un nombre, pero se pueden extender (puedes decir que un par (String,a) es el Duck de tu ejemplo diciendo que say = first), incluso automaticamente en muchos casos y ganas algo de seguridad al obligarte a poner en las funciones (Duck a => a) que debe haber una «instancia» de esa clase en el ambito actual para el tipo en cuestion

  2. Gracias!

    Una duda que me surgió sobre haskell y que seguramente sepas resolverme. Si quiero definir un tipo que sea igual que Integer pero no quiero que sean asignables entre si, ¿la única forma es con newtype/data y usando un constructor, o se puede hacer sin eso?

  3. Mmm segun lo que sea «igual que Integer» pero en principio seria la forma mas directa (aunque con el boilerplate del constructor claro)
    Con la relativamente nueva derivacion automatica, se puede usar como numero:

    {-# LANGUAGE GeneralizedNewtypeDeriving #-}

    newtype MyInteger = MyInt Integer
    deriving (Eq,Show,Num)

    main = print $ (MyInt 2) + 1
    -- MyInt 3

Comentarios cerrados.