Tipos y funciones paramétricos en Julia (10ª parte – ¡Hola Julia!)

Publicado el 13 agosto 2020 por Daniel Rodríguez @analyticslane

Los vectores pueden ser de tipo entero Array{Int64,1}, real Array{Float64,1} o cadenas de texto Array{String,1} entre otros. Esto es así porque los Array son tipos paramétricos. Los tipos y funciones paramétricos en Julia son una herramienta con la que se puede simplificar el código que tenemos que escribir, ya que no es necesaria una versión para cada uno de los tipos de datos.

Definición de un tipo de dato paramétrico

La definición de tipos de datos paramétricos en Julia es una tarea relativamente sencilla. Simplemente se tiene que indicar después del nombre de la estructura entre llaves una clave para que representa el tipo de dato, para la cual generalmente se utiliza T. Así podemos crear un tipo de dato Punto genérico.

struct Punto{T}
    x::T
    y::T
end

Punto(1, 2) # Punto{Int64}(1, 2)
Punto(1.2, 2.2) # Punto{Float64}(1.2, 2.2)
Punto("Madrid", "París") # Punto{String}("Madrid", "París")

Algo que con una única definición nos permite crear el tipo punto tanto para enteros como reales. Aunque también para cadenas de texto. Un efecto secundario que posiblemente no deseemos en la mayoría de los casos. Afortunadamente es posible limitar los tipos de datos indicando que T solo pertenecer a un subconjunto de los tipos. En nuestro caso, se puede limitar para que los datos sean únicamente de tipo numérico.

struct Punto{T <: Number}
    x::T
    y::T
end

Punto(1, 2) # Punto{Int64}(1, 2)
Punto(1.2, 2.2) # Punto{Float64}(1.2, 2.2)
Punto("Madrid", "París") # MethodError: no method matching Punto(::String, ::String)

Ahora cuando el dato no es numérico se produce un error que nos indica la incoherencia de los tipos de datos utilizados.

Definición de funciones paramétricas en Julia

Las funciones también pueden ser paramétricas en Julia, simplemente en lugar del tipo se le indica una clave, al igual que en el caso anterior se suele usar T. Así podemos definir la función multiplicación.

function producto(p::T, q::T) where T
    return p * q
end

producto(1, 2) # 3
producto(2, π) # MethodError: no method matching suma(::Int64, ::Irrational{:π})
producto("Madrid", "París") # "MadridParís"

En este ejemplo se puede ver que se puede multiplicar los valores cuando las dos variables son del mismo tipo. Enteros con enteros o cadenas con cadenas, pero no enteros con irracionales. Al igual que en el caso anterior puede ser que este comportamiento no nos interese, ya que solamente queremos multiplicar números. Lo que se puede evitar limitando el tipo de datos que puede admitir la función. Además, también es posible simplificar la forma de escribir la función, eliminado la palabra clave function y asignado el resultado a la definición de la función. Lo que permite definir las funciones en una única línea.

producto(p::T, q::T) where T <: Number = p * q

producto(1, 2) # 3
producto(2, π) # MethodError: no method matching suma(::Int64, ::Irrational{:π})
producto("Madrid", "París") # MethodError: no method matching producto(::String, ::String)

En este caso vemos que no se puede multiplicar un tipo entero con uno real, pero si uno cadena de texto con otra. Al igual que el caso anterior puede ser que no deseemos este efecto secundario. La solución al problema es la misma, limitar el tipo de dato como se muestra a continuación.

Uso de varios tipos parámetros en las funciones

Lo que vemos en este caso es que solamente se pueden multiplicar valores numéricos cuando ambos datos son del mismo tipo. Para solucionar este problema y permitir que se pueda operar sobre tipos diferentes tenemos que indicar que los datos pueden ser de diferente tipo. Lo que se consigue usando dos parámetros en lugar de uno.

producto(p::T, q::I) where {T <: Number, I <: Number} = p * q

producto(1, 2) # 3
producto(2, π) # 6.283185307179586
producto("Madrid", "París") # MethodError: no method matching producto(::String, ::String)

En este ejemplo es necesario notar varias cosas. Lo primero que ya no tenemos un único tipo paramétrico, sino dos {T, I}. Para denotar que usamos más de un tipo es necesario cerrar estos entre llaves después de la cláusula where. Es posible fijar una limitación diferente para cada uno de los tipos, pero en este caso es la misma, los valores han de ser numéricos.

Ahora tenemos un comportamiento más parecido al que podríamos desear para una función multiplicación. Algo que se ha conseguido solamente con una función paramétrica en lugar de tener que definir varias funciones.

Validar los tipos de datos en arrays

Lo que hemos visto hasta ahora también se puede utilizar para los tipos de datos en vectores. Permitieron que una función solo trabaje con tipos que solamente están basados en otros determinados. Un ejemplo puede ser una función que nos obtiene la suma de un vector, el cual se puede indicar que solamente sea de tipo numérico con un código como el siguiente.

function sumaArray(vec::Array{T})::T where T <: Number 
    resultado::T = 0
    
    for v in vec
        resultado += v
    end
    
    return resultado 
end

sumaArray([1, 2]) # 3
sumaArray(['a', 'b']) # MethodError: no method matching sumaArray(::Array{Char,1})

En este caso en la función admite objetos de tipo Array{T} donde T únicamente puede ser uno de los tipos numéricos. Además, el parámetro sirve para indicar el tipo de dato que devolverá la función, el mismo tipo con el que está construido el vector, y el tipo usado internamente en la función para los cálculos. Ya que la variable results se indica que es del tipo paramétrico.

Conclusiones

En esta entrada hemos visto cómo usar tipos y funciones paramétricos en Julia. Algo que permite reducir las líneas de código al mismo tiempo que se validan los tipos de datos. Evitando tener que duplicar el mismo código por ejemplo para tipos reales y enteros, al mismo tiempo que comprobamos que los datos no son cadenas de texto.