En el diseño de lenguajes de programación llamamos polimorfismo a la capacidad del mismo código de significar diferentes caminos de ejecución según el contexto. Más concretamente, suele limitarse a que el mismo nombre de función pueda referirse a más de una función distinta y a que existan unas reglas para decidir a qué función concreta se llama en cada caso. El polimorfismo es importante porque permite la programación genérica, es decir, escribir funciones en las que el mismo código fuente funciona con distintos tipos. Esto es posible gracias a que las llamadas polimórficas terminarán llamando a una función concreta u otra al final en función del tipo de los parámetros.
La mayoría de lenguajes de programación tienen un cierto grado de polimorfismo. Incluso en C, lenguaje famoso por su simplicidad, el operador + termina compilándose a instrucciones distintas dependiendo de si se están sumando enteros o reales, de forma que el mismo código
puede significar instrucciones distintas en función de los tipos de
y
. C no es muy interesante más allá de ese ejemplo ya que su polimorfismo no es extensible. Todos los casos aceptados vienen dados con el compilador y el programador no es capaz de hacer sus propios tipos que se suman de una forma distinta o sus propias funciones polimórficas. Por eso a partir de ahora hablaremos de lenguajes en los que el polimorfismo es extensible.
A la hora de diseñar polimorfismo extensible hay dos escuelas: el polimorfismo nominal y el polimorfismo estructural.
El nominal es el más común. Consiste en permitir definir interfaces con colecciones de funciones. Los tipos concretos pueden luego implementar esas interfaces proveyendo una implementación para cada una de las funciones de la interfaz. La dependencia entre tipos e interfaces es explícita. Las interfaces tienen nombres únicos que las identifican. Un tipo tiene que explicitar que implementa una interfaz nombrándola. El código genérico acepta cualquier tipo que satisfaga una serie de interfaces. Por eso se llama nominal. Las cosas tienen nombres únicos que se usan para referirse a ellas. Dos interfaces con las mismas funciones pero distinto nombre son interfaces distintas. Un tipo con todas las funciones de una interfaz pero que no nombra explícitamente la dependencia con la interfaz no la satisface. Como decíamos antes, este es el sistema más común y lo encontramos tanto en el polimorfismo orientado a objetos de C++, Java y C# como en el polimorfismo basado en typeclasses, traits o protocolos (son lo mismo) de Haskell, Rust y Swift.
El polimorfismo estructural es menos frecuente. La idea es que no hay una dependencia directa entre interfaces y tipos. Cualquier tipo que implemente los requerimientos de una interfaz será considerado como que satisface esa interfaz, incluso si el tipo y la interfaz vienen de dos módulos distintos que no saben nada el uno del otro. En lenguajes compilados lo encontramos por ejemplo en C++ y en Go. También en los lenguajes débilmente tipados (o no tipados) como Python o JavaScript, que son estructurales por naturaleza. Una función que coge un parámetro sin nombrar el tipo en Python o JavaScript puede ser llamada con un argumento de cualquier tipo y funcionará con cualquier tipo que resulte implementar las operaciones que la función quiera hacer sobre él.
El polimorfismo estructural tiene mala fama. No está de moda. Por lo general los lenguajes de programación suelen optar por la vía nominal, que está considerada la preferida. En parte esto es por culpa de defectos de implementaciones concretas de polimorfismo estructural en lenguajes concretos y en parte se debe a defectos inherentes del modelo.
La principal ventaja del polimorfismo nominal es la expresión explícita de intención. Para que un tipo implemente una interfaz tiene que decir explícitamente que lo hace. Nunca va a pasar que un tipo implemente una interfaz por accidente, llevando a código que resulta que compila pero luego produce errores sorprendentes. Otra gran ventaja es que hace que sea trivial producir buenos mensajes de error. Un tipo implementa una interfaz o no lo hace. Comprobar si el parámetro de una función genérica implementa o no una interfaz es trivial y si no lo hace el mensaje de error es claro: no puedes llamar a función F con tipo T porque el tipo T no implementa la interfaz I. Que la intención sea explícita hace que no haya lugar para la ambigüedad.
La principal desventaja del polimorfismo nominal es la rigidez y la gestión de dependencias. Es común que haya reglas que restringen qué módulos pueden implementar qué funciones de interfaces. Por ejemplo, en Rust, se puede implementar una interfaz para un tipo solamente en el módulo que declara la interfaz o en el módulo que declara el tipo. Java y C# van más allá y permiten hacerlo sólo en el módulo que declara el tipo. Esto hace que sea difícil en ocasiones casar módulos que no saben el uno del otro. Si en nuestro proyecto tenemos dos dependencias que están fuera de nuestro control, una trae una interfaz, la otra trae un tipo y queremos que el tipo implemente la interfaz no podemos hacer eso. Lo mejor que podemos hacer es un tipo nuevo que incluya al otro y que implemente la interfaz. Es tedioso. Por otro lado, si permitimos que cualquier módulo implemente funciones de interfaces para cualquier tipo podemos terminar con dos implementaciones distintas de la misma interfaz para el mismo módulo, lo que es ambiguo.
El polimorfismo estructural tiene la mala fama de producir mensajes de error terroríficos. En parte es inmerecida y se debe al terrible sistema de programación genérica de C++, basado en funciones que aceptan parámetros de cualquier tipo y si eso fallan al instanciar, a veces a varias capas de profundidad. Lenguajes no tipados o dinámicamente tipados como Python o JavaScript lo hacen peor permitiendo ejecutar código incorrecto y fallando durante la ejecución. Esto se debe más a una implementación pobre que a un problema inherente, pero tiene algo de verdad. Go nos muestra un punto intermedio bastante razonable entre estructural y nominal. Las funciones genéricas tienen que decir qué interfaces requieren. El compilador es capaz de detectar fácilmente si un tipo satisface la interfaz necesaria o no y producir un error muy legible en el lugar correcto. Podríamos pensar en Go como estructural para los tipos pero nominal para las interfaces. C++ tiene un sistema opcional parecido que llama "conceptos". Lo que Go no tiene son funciones genéricas en las que no se explicita qué interfaz requieren, sino que esto es deducido por el lenguaje, como hace C++. Cualquier sistema de este tipo va a terminar necesariamente con funciones donde los requerimientos de los tipos son bastante complejos. Eso significa que llamar a una función así con un tipo incorrecto seguramente genere un mensaje de error más complicado y menos legible. Aquí no hay mucho que se pueda hacer, es una complejidad inherente. Esto es importante para el caso de Roc porque quiere usar el sistema de tipos de Hindley-Millner para deducir interfaces, lo que en ocasiones inevitablemente llevará a funciones con requerimientos muy complejos para sus parámetros polimórficos.
El otro problema inherente del polimorfismo estructural que mencionábamos antes y en el que es interesante ahondar es la imposiblidad de expresar intención. El compilador no es capaz de saber si un tipo implementa una interfaz de manera intencionada o por accidente. Tampoco un programador leyendo el código, a menos que haya un comentario que lo diga. Esto es capaz de crear situaciones en las que el código compila pero no debería, por accidente. También puede crear situaciones en las que el mismo tipo implementa una interfaz de forma distinta dependiendo de qué módulos estén visibles en un contexto dado, lo que puede crear bugs realmente sorprendentes. Esto sucede cuando tenemos cinco módulos: uno trae la interfaz, otro el tipo, otros dos traen dos implementaciones distintas de la interfaz para el tipo y el quinto llama a la función genérica.

En el gráfico de arriba, si E depende de tanto C como D el código no compila. Si E depende de sólo uno de las dos el código compila pero hace cosas distintas dependiendo de cual. En un proyecto grande se pueden dar confusiones de este tipo que estén mucho más ofuscadas al estar dispersas entre distintos módulos grandes. Las confusiones resultantes de este tipo de situaciones no son agradables.
Me parece interesante hacer este tipo de análisis porque creo que es importante entender cuáles son los pros y contras de la mejor implementación posible. No me gustan los hombres de paja que critican un paradigma entero por fracasos de implementaciones concretas que son perfectamente evitables. Por ejemplo, la fragilidad de la programación genérica en C++ no debería desacreditar el polimorfismo estructural en su conjunto. Por otro lado, tampoco se puede sobrecompensar e ignorar los problemas inherentes que sí existen. Es decir, el mejor diseño posible basado en polimorfismo estructural seguirá teniendo peores mensajes de error y problemas de ambiguedad y falta de intención explícita. No hay forma de evitarlo.
Problemas de la implementación concreta de Roc
Si hasta ahora estábamos comentando los problemas que tendría una ejecución virtuosa de la idea, es hora de bajar al barro y ver qué está haciendo Roc en la práctica. Esta sección critica las ideas mostradas en la reciente charla
de Richard Feldman.
Roc ha introducido una notación de llamada a funciones con el punto parecida a a las funciones miembro de la programación orientada a objetos. Se puede llamar de esta manera a las funciones implementadas en el mismo módulo que el tipo del primer parámetro. Estas funciones, además de ser más cortas de llamar, tienen en Roc un privilegio especial. Son la base de la programación genérica al ser las funciones que pueden ser polimórficas. Esto es bueno y malo a la vez. Evita el problema de la doble implementación de una interfaz, ya que para cada tipo hay un solo módulo privilegiado que puede implementar interfaces para ese tipo. Esto nos trae más cerca de Java o C# que de C++ o Go, con sus limitaciones. Si inventamos una interfaz nueva o queremos hacer que varias dependencias interoperen no podemos implementar interfaces para tipos de otros.
La otra limitación que esto impone es que, como Roc no tiene overloading general a lo C++ por lo que sólo puede existir una función con el mismo nombre en cada módulo, esto significa que en la práctica todas las funciones polimórficas comparten un mismo espacio de nombres global. Dos interfaces no pueden tener una función con el mismo nombre o será imposible para un tipo implementar las dos a la vez. Esta es una limitación enorme y posiblemente lleve a los usuarios a crear manualmente espacios de nombres para las funciones polimórficas como parte del nombre, de forma que terminaremos viendo funciones que se llamen
en vez de
a secas para evitar colisiones de nombres con otras interfaces, parecido a lo que sucede en C.
Roc todavía está comenzando y tiene tiempo para cambiar y reevaluar. A veces parece que algunas de sus decisiones están sesgadas por el hecho de que no hay programas grandes ni bibliotecas escritos todavía en el lenguaje, lo que hace que a veces parezcan olvidarse o menospreciarse los problemas de los programas grandes. Hay cosas que lucen mucho en un programa de un solo archivo de mil líneas o en un proyectillo de 10 archivos pero que escalan muy mal. Tengo mucha curiosidad por ver cuál será el diseño final y por si encontrará soluciones a algunos de estos problemas.