Mercury: cuando Prolog y Haskell tuvieron un bebé
Mercury es un lenguaje de programación muy poco conocido pero muy interesante. De forma corta podríamos describirlo como el hijo que tendrían Prolog y Haskell. Se trata de un lenguaje que combina los paradigmas lógico y funcional en uno solo. Fue desarrollado por la Universidad de Melbourne en Australia aunque ahora sobrevive como proyecto opensource. Su objetivo era la creación de software grande, robusto y eficiente. La sintaxis es tremendamente parecida a Prolog, por lo que aunque haya algunas modificaciones, es relativamente fácil escribir en él si ya conocemos Prolog. Mercury tiene tipado fuerte y estático y genera ejecutables nativos. Es algo más puro que Prolog ya que gestiona mejor los side effects y es probablemente el lenguaje lógico más rápido que existe a día de hoy.
Para empezar con Mercury, una opción es descargar la última versión estable (formato DEB) desde la página oficial aunque muchas distros Linux ya han empaquetado Mercury. También soporta Windows y otros sistemas operativos pero hay que compilar desde cero, lo cuál puede ser un poco complicado de primeras.
Hola mundo en Mercury
La primera diferencia con Prolog la vamos a tener nada más ponernos a programar. No existe un shell donde introducir queries, sino que vamos a necesitar un fichero y compilarlo.
En un fichero llamado hola.m podemos escribir lo siguiente:
:- module hola.
:- interface.
:- import_module io.
:- pred main(io::di, io::uo) is det.
:- implementation.
main(!IO) :-
write_string("¡Hola Mercury!\n", !IO).
Si conoces Prolog, verás que la sintaxis es muy similar pero hay bastantes cosas nuevas. No voy a explicar las cosas que son iguales que Prolog, para eso mejor ve al tutorial de Prolog. Lo primero es indicar el módulo en el que estamos. En Mercury todos los programas están organizados en módulos, normalmente con el mismo nombre que el fichero. Cada módulo se compone de dos secciones, una interfaz y una implementación. La interfaz es lo que el módulo deja ver al resto del programa, y la implementación es completamente privada.
En Mercury es necesario declarar los predicados que luego vamos a definir, indicando los tipos y modos. En este caso, para definir main, necesitamos acceder a tipos que están el módulo io, así que lo importamos antes. Los tipos del predicado main (que se ejecuta por defecto) son io::di e io::uo. Sin entrar mucho en detalles, el primero significa destructive input y el segundo, unique output. Con is det declaramos el predicado como determinista (siempre tiene solución y es única).
En la implementación vemos que aceptamos un único argumento (no eran dos?) y llamamos a write_string, que imprime un string y le tenemos que pasar el argumento de IO también. IO es una variable ya que, al igual que en Prolog, en Mercury las variables se distinguen porque empiezan por mayúsculas.
main(IO, IO1) :-
write_string("¡Hola Mercury!\n", IO, IO1)
Y ahí ya se ve claramente como los argumentos coinciden con lo que declaramos en la interfaz. Estas variables de estado se pueden encadenar y el azúcar sintáctico también nos ayuda a hacer refactorizaciones más sencillas en código con variables de estado encadenadas.
Para compilar, ejecutamos mmc
mmc hola.m
Y ya tenemos un ejecutable nativo llamado "hola" que podemos ejecutar.
Funciones
Aparte de predicados, Mercury también tiene funciones, algo de lo que carece Prolog. Veamos este ejemplo para calcular la sucesión de fibonacci:
:- module fib.
:- interface.
:- import_module io.
:- pred main(io::di, io::uo) is det.
:- implementation.
:- import_module int.
:- func fibonacci(int) = int.
fibonacci(N) =
( if (N = 0;N = 1) then
1
else
fibonacci(N-1) + fibonacci(N-2)
).
main(!IO) :-
F = fibonacci(5),
io.write_int(F, !IO),
io.nl(!IO).
En este ejemplo, declaramos dentro de la implementación la función fibonacci, que toma un int y devuelve un int. Para usar int tenemos que importar el módulo, como solo lo necesitamos en la implementación, no lo importamos en la interfaz. Después la función se define con otra sintaxis. Aquí podemos ver los if-then-else (aunque la sintaxis de Prolog ( -> ; ) también es admitida). Más diferencias que podemos ver respecto a Prolog es que las expresiones aritméticas se evalúan directamente.
Por último, en main, llamamos a la función fibonacci e imprimimos el resultado. Aquí usamos el nombre cualificado de los predicados, con io delante. Es opcional en muchos casos pero altamente recomendable ponerlo.
Tipos de datos
Vamos a entrar más en detalle en los tipos de datos de Mercury, que toman una fuerte inspiración de Haskell.
Lo primero que hay que tener en cuenta es que en Mercury, todos las variables van a ser inmutables. Sin embargo, a diferencia de Prolog, el compilador puede inferir lugares donde aplicar mutabilidad internamente para mejorar el rendimiento.
Los tipos más básicos son int, float, string y char. Además contamos con tuplas y listas.
Las tuplas son un número de elementos prefijados de tipo diferente, por ejemplo, {5, "hola"} es una tupla de tipo {int, string}
Las listas en Mercury son colecciones de N elementos, todos ellos del mismo tipo.[3,4,5,6] es de tipo list(int). Las listas se pueden deconstruir como en Prolog usando la barra vertical. Si L = [1,2,3,4] y L = [X|Xs], entonces X es igual a 1 y Xs a [2,3,4].
Tipos suma
Mercury soporta tipos suma. Los más básicos son simples enumeraciones.
:- type color ---> red ; green ; yellow ; blue ; black ; white ; purple.
En este caso crearíamos el tipo color con esos posibles valores. Pero cada enumeración puede llevar asociado algún dato extra.
:- type font ---> serif(color) ; sans_serif ; mono.
En este caso los tipos de fuente pueden ser Serif, Sans-Serif y Mono, pero en el caso de la Serif, lleva asociado un color.
Estos campos extra pueden tener un nombre, para acceder a ellos más fácilmente mediante ^. De este modo tendríamos lo que en otros lenguajes se llama records o estructuras.
:- type mail ---> mail( from :: string, to :: string, msg :: string).
% mas adelante
...
ToAddress = Mail^to,
...
Con := podemos crear un nuevo record igual al anterior pero cambiando el valor de un campo.
:- type mail ---> mail( from :: string, to :: string, msg :: string).
...
Mail = mail("a@example.org", "b@example.org", "Test message"),
Mail1 = (Mail^to := "c@example.org"),
(if Mail1^to = "b@example.org" then
io.write_string("Mail to B\n", !IO)
else
io.write_string("Mail NOT to B\n", !IO)
),
...
En este caso se ejecutaría la parte de "Mail NOT to B".
Tipos polimórficos
Mercury soporta polimorfismo a nivel de tipo. Es posible crear estructuras de datos genéricas para un tipo de dato T. El ejemplo más archiconocido es la lista, que en Mercury podríamos definirla así.
:- type list(T) ---> [] ; [T | list(T)].
Tipos equivalentes
En Mercury podemos crear tipos idénticos a otros, pero con distinto nombre para mejorar la legibilidad.
:- type money == int.
:- type radius == float.
Modos en Mercury
Un elemento vital para Mercury es la declaración de modos de los predicados. En parte ya lo hemos visto, ya que al declarar los predicados estábamos indicando los modos a la vez, pero en realidad puede separarse en dos directivas diferentes. Los modos indican a Mercury la "dirección" del código así como las posibles soluciones.
Partamos de un ejemplo sencillo, un predicado (no función) de suma, donde pasamos los dos sumandos mediante una tupla.
:- pred suma_tuple({int, int}::in, int::out) is det.
suma_tuple({X, Y}, Z) :-
Z = X + Y.
En la línea de declaración hemos combinado los tipos y los modos. Esto se puede separar. Será obligatorio separar si queremos que un predicado se compile con más de un modo.
:- pred suma_tuple({int, int}, int).
:- mode suma_tuple(in, out) is det.
suma_tuple({X, Y}, Z) :-
Z = X + Y.
En la declaración de modo indicamos que variables son de entrada y cuáles de salida. Además definimos el número de soluciones. Los posibles valores son: det (=1), semidet (<=1), multi(>= 1), nondet (>=0) y failure (=0).
:- pred phone(string, string).
:- mode phone(in, out) is semidet.
phone("123456789", "Benito").
phone("245678901", "Mar").
En este ejemplo phone va a tener un modo, donde proveemos un número y nos devuelve el nombre. Es semidet ya que puede que no encuentre el teléfono (no hay solución) o que sí. Pero no podemos devolver más de una solución (podríamos si lo declaramos como nondet).
Con este modo podemos hacer estas llamadas:
(if phone("123456789", _Name) then
io.write_string("Phone found\n", !IO)
else
true
),
Sin embargo, no podemos hacer lo inverso.
(if phone(_Phone, "Benito") then
io.write_string("Phone found\n", !IO)
else
true
),
Para que compile deberemos agregar este otro modo:
:- mode phone(out, in) is semidet.
Existen más tipos de modo pero no serán tan habituales.
Ejemplo más grande
He aquí un ejemplo de como se resolvería el problema 1 del Advent of Code de 2021 en Mercury (ambas partes).
:- module aoc2021day1.
:- interface.
:- import_module io.
:- pred main(io::di, io::uo) is det.
:- implementation.
:- import_module int.
:- import_module list.
:- import_module string.
:- pred load_data(string::in, list(string)::out, io::di, io::uo) is det.
load_data(Filename, ReadData, !IO) :-
io.read_named_file_as_lines(Filename, OpenResult, !IO),
(if OpenResult = ok(Data) then
ReadData = Data
else
ReadData = []
).
:- pred solve(list(int)::in, int::out) is det.
solve([], 0).
solve([_], 0).
solve([X,Y|Xs], N) :-
solve([Y|Xs], N0),
(if X < Y then
N = N0 + 1
else
N = N0
).
:- pred slide_window(list(int)::in, list(int)::out) is det.
slide_window([], []).
slide_window([_], []).
slide_window([_,_], []).
slide_window([X,Y,Z|Xs], Ys) :-
slide_window([Y,Z|Xs], Ys0),
N = X + Y + Z,
Ys = [N|Ys0].
:- pred solve2(list(int)::in, int::out) is det.
solve2(Data, N) :-
slide_window(Data, Slides),
solve(Slides, N).
main(!IO) :-
load_data("input", ReadData, !IO),
(if list.map(string.to_int, ReadData, Data) then
solve(Data, N),
io.format("Solution 1: %d\n", [i(N)], !IO),
solve2(Data, M),
io.format("Solution 2: %d\n", [i(M)], !IO)
else
io.write_string("Invalid file\n", !IO)
).
Typeclasses
En Mercury podemos crear typeclasses de forma muy similar a Haskell. También son muy parecidas a las traits de Rust. Con typeclasses podemos restringir los argumentos de un predicado/función a tipos que implementen la typeclass que nos interesa.
:- typeclass shape(T) where [].
:- type point ---> point(float, float).
:- type rectangle ---> rectangle(point, float, float).
:- type circle ---> circle(point, float).
:- instance shape(rectangle) where [].
:- instance shape(circle) where [].
En este ejemplo, creamos una typeclass llamada shape, sin más requisitos y creamos 3 tipos: point, rectangle y circle. De estos, dos van a ser instancias de la typeclass shape: rectangle y circle.
Posteriormente podremos definir predicados donde solo admitamos tipos que implementen shape, pero nos da igual si son rectangle o circle, de la siguiente forma.
:- pred shapes_only(T::in) is det <= shape(T).
Sin embargo, la verdadera utilidad es que las typeclasses impongan ciertos predicados que deben implementarse para poder ser instancia de algo. En nuestro ejemplo de shape, podemos poner calcular el área. Es algo que en todas las figuras puede hacerse, pero el método es diferente si es un rectángulo o si es un círculo.
Para ello, declaramos predicados y modos en la typeclass:
:- typeclass shape(T) where [
pred get_area(T, float),
mode get_area(in, out) is det
].
Al instanciar, podemos añadir el código directamente o hacer referencia a un predicado externo.
:- instance shape(rectangle) where [
(get_area(Rect, Area) :-
Rect = rectangle(_Centre, Width, Height),
Area = Width * Height
)
].
:- instance shape(circle) where [
pred(get_area/2) is circle_area
].
:- pred circle_area(circle::in, float::out) is det.
circle_area(circle(_Centre, Radius), Area) :-
Area = math.pi * Radius * Radius.
Un ejemplo completo de uso de typeclasses a continuación:
:- module shape.
:- interface.
:- import_module io.
:- pred main(io::di, io::uo) is det.
:- implementation.
:- import_module float.
:- import_module list.
:- import_module math.
:- import_module string.
:- typeclass shape(T) where [
pred get_area(T, float),
mode get_area(in, out) is det
].
:- type point ---> point(float, float).
:- type rectangle ---> rectangle(point, float, float).
:- type circle ---> circle(point, float).
:- instance shape(rectangle) where [
(get_area(Rect, Area) :-
Rect = rectangle(_Centre, Width, Height),
Area = Width * Height
)
].
:- instance shape(circle) where [
pred(get_area/2) is circle_area
].
:- pred circle_area(circle::in, float::out) is det.
circle_area(circle(_Centre, Radius), Area) :-
Area = math.pi * Radius * Radius.
:- pred print_area(T::in, io::di, io::uo) is det <= shape(T).
print_area(Shape, !IO) :-
get_area(Shape, Area),
io.format("Area of shape: %f\n", [f(Area)], !IO).
main(!IO) :-
print_area(rectangle(point(1.0, 2.0), 30.0, 10.0), !IO),
print_area(circle(point(1.0, 2.0), 10.0), !IO).
Con esto ya conoceríamos las principales mejoras de Mercury respecto a Prolog y damos por acabado el tutorial. ¿Qué te parece Mercury? ¿Prefieres la brevedad de Prolog o la explicitud de Mercury?