Multiple Dispatch
Ya hablamos del mecanismo de Just in time compilation, que es una de las razones que dan lugar a la gran performance de Julia. El otro elemento fundamental del mix es la técnica de multiple dispatch. Veamos en qué consiste.
Ya vimos que las siguientes variantes son todas admisibles:
julia> using Plots
julia> f(x) = 2x^2+3cos(x)
julia> plot(f)
julia> x = -1:0.1:1
julia> y = f.(x)
julia> plot(x,f)
julia> plot(x,y)
julia> plot(collect(x),y)
El primer plot recibe una función. El segundo un rango y una función. El tercero, un rango y un vector. El cuarto, dos vectores. Esto no es habitual. En lenguajes como C, cuando uno declara una función debe indicar los datos que recibe, junto con su tipo y la función sólo podrá aplicarse a datos de ese tipo. En Python no hace falta aclarar los tipos, y para ciertas cosas hay tipos que pueden ser intercambiables (por ejemplo: si uno itera a lo largo de un objeto es indistinto si el objeto es un array, una lista o un rango). Sin embargo, una función y un vector no se parecen de modo que en Python difícilmente podrían ocupar el mismo lugar en el llamado a una función. Es decir: si se usa el nombre plot para indicar el gráfico de un vector vs otro vector, habrá que usar otro nombre para la función que reciba un vector y una función. En Python (y todos los lenguajes orientados a objetos, como C++), se pueden definir funciones dentro de un objeto, que le pertenecen. Esto permite repetir nombres. Por ejemplo, tanto una lista como un array pueden tener una función append. La sintaxis para aplicar la función es lista.append(elemento) o array.append(elemento). Python sabe qué función debe ejecutar porque la sintaxis objeto. le indica dentro de qué objeto está la definición de la función. A esto se lo llama single dispatch. Es decir: el intérprete o el compilador sabe qué función append ejecutar mirando un único tipo: el del objeto que llamó a la función.
Julia no es un lenguaje orientado a objetos. Las funciones son externas y no le pertenecen a ningún objeto. Pero implementa un sistema de multiple dispatch que permite definir muchas funciones con el mismo nombre. Para decidir cuál función correr, Julia mira los tipos de datos que se le dieron a la función y elige el método más adecuado. Para poner en marcha este mecanismo es necesario anotar los tipos de datos al definir una función.
Veamos un ejemplo:
julia> g(x::Int64) = println("recibí un entero")
julia> g(x::Float64) = println("recibí un flotante")
julia> g(2)
julia> g(2.5)
Esta es la principal aplicación de las anotaciones de tipo en Julia.
plot seguramente tendrá que usar el comando plot, independientemente de qué es lo que quiera graficar.
Pero la principal ventaja del multiple dispatch es que le permite al compilador decidir qué método quiere aplicar y usar el más adecuado al tipo de dato ingresado. Esto, a su vez, hace que el compilador pueda aplicar una serie de optimizaciones específicas para ese tipo de dato, lo que redunda en una mejor performance. Lo que hace que Julia pueda competir con Fortran o `C` es la combinación de just in time compilation con un muy buen sistema de multiple dispatch.
Veamos un ejemplo apenas más sofisticado:
julia> h(x) = println("Esta es la función por defecto")
julia> h(x::Number) = println("Recibí un número")
julia> h(x::String) = println("Recibí una cadena de caracteres")
julia> h(2)
julia> h(2.5)
julia> h(2//3)
julia> h("palabra")
julia> h([1,2,3])
julia> h((1,2))
julia> h(1,2)
julia> h(x::Rational) = println("Recibí un número racional")
julia> h(3//4)
julia> h(2.5)
Aquí se observan varias cosas:
- Existe un tipo de dato
Numberque de alguna manera alberga a enteros, flotantes, racionales, etc. Presumiblemente, a todo lo que de verdad sea un número. Juliaelige qué método ejecutar en función del tipo de dato que recibe. En particular, elige el método con la definición que mejor se ajusta al dato recibido. En el primer tramo de código, cualquier número cae bajo el método definido paraNumber. Como no hay método específico para vector o tupla, se aplica el método más general (el que no tiene ninguna anotación). Sin embargo, luego de definir un método específico paraRational, al recibir números racionales aplica éste, que es más preciso que el genérico paraNumber, mientras que para el dato2.5sigue aplicando el método deNumber, que es el más ajustado para un flotante.
Veamos un ejemplo concreto de uso del multiple dispatch. Probar lo siguiente:
julia> using BenchmarkTools
julia> x = rand(10_000_000);
julia> @benchmarck sort($x)
julia> @benchmark sort(1:10_000_000)
julia> @benchmark sort(10_000_000:-1:1)
julia> y = sort(x);
julia> @benchmark sort($y)
julia> sort(10_000_000:-1:1)
BenchmarkTools es un paquete que contiene algunas herramientas para testear performance, más sofisticadas que @time. En particular, @benchmark corre muchas veces la función que le pasamos, y nos devuelve información diversa acerca de todas las corridas, incluido un histograma de tiempos. Conviene poner $ antes del nombre de la variable que le pasamos porque estamos usando variables globales que son más difíciles de gestionar para el compilador. Al usar $ pasamos el valor de la variable como si fuera una variable local, lo que da mediciones más confiables (y más parecidas a lo que se obtiene en un uso realista de la función).
Observamos que ordenar rangos, sean crecientes o decrecientes, es mucho más rápido que ordenar vectores, incluso cuando los vectores ya están ordenados. ¿Por qué será así? Investiguemos. Si corremos:
julia> sort
julia> methods(sort)
Vemos que sort es una función con 4 métodos (5, si tenemos BenchmarkTools cargada, y quizá más si ya cargamos otros paquetes, porque los paquetes pueden definir nuevos métodos para la misma función). En particular, la función methods nos indica cuáles son estos métodos:
julia> methods(sort)
[1] sort(r::AbstractUnitRange)
@ range.jl:1410
[2] sort(r::AbstractRange)
@ range.jl:1413
[3] sort(v::AbstractVector; kws...)
@ Base.Sort sort.jl:1489
[4] sort(A::AbstractArray{T}; dims, alg, lt, by, rev, order, scratch) where T
@ Base.Sort sort.jl:1783
Sin adentrarnos demasiado en la notación, es fácil interpretar que tenemos:
- Un método que funciona simultáneamente para orderar
AbstractUnitRange: aún no está claro qué significaAbstract, peroUnitRangeson rangos que saltan de a1, es decir, los que se crean por defecto cuando uno no aclara el paso. - Un método para
AbstractRange(suponemos que serán rangos, en general) - Un método para
AbstractVector(suponemos que serán vectores, en general) - Un método para
AbstractArray{T}. De nuevo, no está claro que significa la notación{T}, pero parece referir aArraysen general (por ejemplo: matrices).
Además, methods nos informa dónde están definidos los métodos. Por ejemplo, el método para AbstractUnitRange está en el archivo range.jl en la línea 1410 y el de AbstractRange en el mismo lugar, en la línea 1413. Al no haber aclaraciones sabemos que range.jl está en el núcleo de Julia. Los métodos para AbstractVector y AbstractArray pertenecen al módulo interno Base.Sort (o sea: están en la instalación básica, pero dentro de un submódulo destinado a Sort), en el archivo sort.jl y en las líneas 1489 y 1783, respectivamente. Estos archivos están en la carpeta de instalación de Julia. En sistemas Unix, esta es carpeta oculta de nombre: .julia/ ubicada en el home del usuario.
En nuestro caso no es difícil inferir qué método está aplicando Julia en cada caso (AbstractVector para x, AbstractUnitRange para 1:10_000_000 y AbstractRange para 10_000_000:-1:1). Sin embargo, si estamos en duda, podemos usar @which:
julia> @which sort(1:10_000_000)
Esto nos devuelve exactamente qué método está usando para la aplicación concreta de la función.
Queremos ver qué hace exactamente el sort cuando lo aplicamos a un rango. Podemos ir a buscar estos archivos explorando a mano la carpeta de instalación de Julia (en Unix típicamente es /home/nombre_de_usuario/.julia/). Pero no hace falta. Julia nos provee de herramientas para inspeccionar su código directamente desde la consola. Tenemos dos opciones:
@edit: aplicado del mismo modo que@whichnos abre el archivo en el que se encuentra definido el método, posicionado sobre el método, en nuestro editor de código por defecto. Importante: esto requiere que haya un editor por defecto definido en el sistema, o más específicamente, que esté definida la variable de entornoJULIA_EDITOR. Si esto no es así, seguramente@editdará un error indicando este problema. En sistemasUnix, dentro delhomedel usuario (/home/nombre_de_usuario/o simplemente~/) hay un archivo de configuración de la shell por defecto: típicamente.bashrco.zshrc). Podemos definir el editor por defecto abriendo ese archivo y tipeando (al final):export EDITOR = vscode(dondevscodepuede reemplazarse por el comando que abre el editor de preferencia). Podemos también definir:export JULIA_EDITOR = vscode. Guardamos el archivo y en una nueva sesión deJulia@editdebería funcionar correctamente.@less: Hace lo mismo que@edit, pero en lugar de abrir nuestro editor por defecto abre un paginador cuyo nombre esless. Este paginador nos permite navegar el archivo, pero no editarlo. Podemos subir y bajar con las flechas o conkyj. Para salir del paginador basta presionarq.- Además, si estamos en
VSCodey tenemos un archivo que contiene una llamada a una función, comosort(1:10_000_000), podemos posicionarnos sobre esta línea y click derecho y luego seleccioarGo to Definition, y nos despliega la definición del método.
Al hacer esto, vemos que la definición de sort para AbstractUnitRange es simplemente
julia> sort(r::AbstractUnitRange) = r
Es decir, como Julia sabe que, por definición, un AbstractUnitRange está ordenado, cuando alguien intenta ordenarlo, Julia simplemente devuelve lo mismo que recibió y no hace ninguna operación. Por eso es tan rápido. Además, tenemos que la definición para AbstractRange también es muy sencilla:
sort(r::AbstractRange) = issorted(r) ? r : reverse(r)
Aquí Julia verifica si el rango está ordenado, en cuyo caso devuelve r. En caso contrario, devuelve reverse(r), es decir, el rango dado vuelta. Unas líneas podemos ver que issorted también tiene un método especialmente definido para AbstractRange que lo único que hace es fijarse si el step que define el rango es positivo o negativo.
La arquitectura del método de sort para AbstractVector es bastante más compleja (previsiblemente), pero podríamos explorarla usando el mismo mecanismo. Observamos que todas estas funciones están escritas in plain Julia y podemos acceder a ellas sin problema.
using e import mencionamos que en Julia suele usarse using, porque es poco probable que un paquete pise el nombre de una función definido en otro paquete. La razón es que en realidad pisar un nombre no es un problema, si se lo hace declarando adecuadamente el tipo de dato: esto sólo crea un nuevo método para la misma función. De modo que muchos paquetes pueden volver a definir la misma función, incluso aunque esa función esté definida en el núcleo de Julia. Más adelante veremos que la posibilidad de agregar métodos a funciones definidas en otras librerías o en el núcleo de Julia es un instrumento muy poderoso.