Fundamentos

¿Por qué Julia?

El problema

El proceso de producir resultados en ciencia e ingeniería depende de múltiples etapas para las cuales, históricamente, se han requerido especialistas dedicados y apenas comunicándose sus resultados parciales para hacer funcionar un sistema.

Esto, en un mundo de creciente multidisciplina e interdisciplina, se vuelve menos conveniente, pues comunicar conceptos y justificiaciones complejas entre diversos especialistas se ha hecho crucial para progresar con eficiencia una investigación y desarrollo de tecnología.

Una solución elegante

Julia es un lenguaje de programación que es capaz de minimizar la brecha entre el concepto y el código, teniendo de ejemplo:

A = [∫ϕ₁² ∫ϕ₁₂;
     ∫ϕ₁₂ ∫ϕ₂²]

para crear una matriz cuyas entradas son integrales de algunas funciones, muy común en métodos de elemento finito o métodos numéricos generales para mecánica cuántica. Este código corre perfectamente al definir los símbolos anteriores:

ϕ₁(x) = 1-x; ϕ₂(x) = x; 
ϕ₁²(x) = ϕ₁(x)^2; ϕ₂²(x) = ϕ₂(x)^2; ϕ₁₂(x) = ϕ₁(x)ϕ₂(x)
(f) = quadgk(f,0,1)

Dejando muy en claro lo que hace el código para cualquiera que conozca los símbolos, incluso con poca experiencia con el lenguaje.

Esta énfasis en legibilidad y eficiencia de escritura de código es algo que ya existe en lenguajes como Python, pero en menor grado de especialización para las ciencias y definitivamente con un costo de eficiencia de cómputo…

Es por eso que rubros de la academia y tecnología persisten en utilizar lenguajes altamente eficientes como Fortran, C/C++, o incluso Octave. Julia puede ser ambos: Legible y eficiente. Emparejando muy de cerca velocidades de cómputo de C y Fortran (como visto aquí), y a veces superando, sin comprometer la legibilidad o la interactividad

¿Cómo lo logra Julia?

Julia, a diferencia de Python, R y Octave, no es un lenguaje interpretado, si no compilado. No obstante, sigue siendo interactivo como estos anteriores, lo que nos permite recibir los resultados de nuestro código a tiempo real ¿Cómo lo hace?

Julia utiliza un compilador JIT (Just-In-Time) implementado en LLVM, que quiere decir que compila el código hasta el momento en que es necesario compilarlo y así proveer velocidades de lenguajes compilador como C/C++ pero interactividad y dinamismo de Python.

Además, Julia es un lenguaje opcionalmente tipado, lo que quiere decir que, al igual que Python, es posible escribir código sin restringir el tipo de una variable o salida de función; incluso cambiándo su valor de tipo dinámicamente. Pero, tenemos también la opción de restringir los tipos como en C/C++ para ayudarle al compilador JIT a optimizar mejor nuestro código.

Ésta y más técnicas son las utilizadas para escribir código máximalmente eficiente, pero aún tan fácil de leer y aprender como lo anteriormente mostrado.

Paradigmas y diseño del lenguaje Julia

El diseño de Julia se puede intentar resumir en: ser de código abierto, multiparadigma y de propósito general con énfasis en cómputo científico y paralelo, conteniendo principalmente aspectos de programación funcional, imperativa y orientada a objetos; sin ser purista en ninguno.

Pero no se puede hacer justicia diciendo solamente eso, pues contiene muchos aspectos únicos (si vienen de otros lenguajes de programación, ver esta lista). Éstos los iremos visitando a lo largo del curso… comencemos con lo básico primero

Operaciones fundamentales y tipos primitivos

Aritmética básica

Tenemos aritmética usual (+, *, -, /, ^). A continuación ilustramo la sintaxis básica:

5+2
7

El resultado de sumar un float con un entero es un float. Veremos luego más sobre este proceso de promoción de tipos.

5.0+2
7.0

La multiplicación se realiza con el operador *, como es usual con otros lenguajes de programación

5*3
15

Pero notemos que, a diferencia de en otros lenguajes como Python, la exponenciación se utiliza con ^.

2^3
8

Tenemos división con el operador /, que retorna un flotante si el resultado de la división lo amerita.

3/2
1.5

Podemos realizar también la división «al revés». Muy común en Octave/Matlab y es una sintaxis que se trasladará para el caso de matrices.

2\3
1.5

Infinitos, complejos y racionales

Además de la aritmética usual, tenemos por defecto infinitos, números complejos y números racionales. Estos pueden ser resultado de las operaciones anteriores en una forma esperada por nuestros conceptos matemáticos usuales.

Por ejemplo, tenemos infinitos de ambos signos:

1/0
Inf
-5/0
-Inf

Números complejos utilizando la palabra clave im (que puede escribirse con concatenación simple al número o con el operador explícito de multiplicación *).

(5+2im)*(2-4im)
18 - 16im

Notemos que el infinito complejo se expresa como un infinito en su parte real y otro infinito en la parte imaginaria.

Esta decisión de diseño provee un buen ejemplo de la complejidad de crear un lenguaje de programación consistente con nuestros conceptos matemáticos, como se puede ver a mayor profundidad en esta y esta discusión.

(5+2im)/0
Inf + Inf*im

Los números racionales son un tipo en sí mismo construidos con su numerador y denominador separados por //

3//4
3//4

Las fracciones siempre se reducen a su forma más simple

6//8
3//4
3//4 == 6//8
true

Los siguientes son equivalentes respectivamente al «infinito racional» y el «cero racional»

1//0, 0//2
(1//0, 0//1)

Esto se ilustra mejor a continuación:

0//2 == 0//3 == 0, 3//0 == Inf
(true, true)

Aritmética en tipos especiales

Estos tienen aritméticas propias que reflejan lo que entendemos conceptualmente de ellos. Por ejemplo, la suma y productos escalares de infinitos retornan infinitos, mientras que la división de infinitos y resta de ellos, como sabemos del cálculo básico, no son operaciones bien definidas. Para entender un poco más sobre los infinits y su aritmética puedn visitar aquí

3*Inf+2, Inf/Inf, Inf - Inf
(Inf, NaN, NaN)

Los racionales pueden sumarse y restarse, obteniendo un resultado ya en forma simplificada, aunque en caso de que el resultado sea un número entero a, se expresa en la forma a//1 para preservar su tipo racional hasta que se necesite cambiar a entero de nuevo (si aun caso).

1//2 + 3//2
2//1
3*1//3
1//1

Los números complejos tienen también su aritmética usual como mostrado arriba. Aquí tenemos un ejemplo con la exponenciación con base y potencia compleja:

(im)^(im)
0.20787957635076193 + 0.0im

Tipos primitivos de datos

Como se vio anteriormente, los resultados de las operaciones están ligados al tipo de dato que utilizamos.

Con tipo de dato nos referimos a los distintos objetos que Julia nos permite guardar en memoria y manipular (mediante funciones, las cuales estudiaremos luego). Algunos tipos de datos primitivos son los siguientes:

typeof(5), typeof(100_000_000_000_000_000_000_000), typeof(5.0), typeof('c'), typeof("Hola"), typeof(true)
(Int64, Int128, Float64, Char, String, Bool)

donde se a utilizado la función typeof para imprimir en pantalla el tipo de dato de cada objeto. Las funciones serán discutidas luego.

Una explicación breve de éstos tipos es:

  • Int64: Número entero representado en la computadora utilizando \(64\) bits, es decir, una cadena de \(0\)s y \(1\)s de tamaño \(64\).

    De ésta, el primero de ellos se utiliza para guardar el signo del entero, por lo que quedan \(63\) libres e implicando que el rango de valores de un Int64 es entre -\(2^{63}\) y \(2^{63}-1\) (donde el \(-1\) aparece por que los positivos incluyen al cero). Se puede leer más aquí.

  • Int128: Similar al Int64, ahora teniendo un rango posible entre -\(2^{127}\) y \(2^{127}-1\)

  • Float64: Una representación decimal finita que intenta aproximar un número real utilizando 64 bits de información.

    Esto es logrado por lo que se conoce como sistema numérico de punto flotante. La forma en que ésta es representada en memoria es más complicada que los Int, pero se puede leer más aquí

  • Char: Un caracter de texto, representado mediante codificación Unicode. Ésto quiere decir que Julia permite desde caractéres de nuestro alfabeto usual, caractéres con tildes y diéresis, así como japoneses, coreanos y chinos, subíndices, simbología matemática, alfabeto griego, emoticones y más.

  • String: Una cadena de más de un caracter.

  • Bool: Un Booleano. Solamente puede tener dos valores: Verdadero o falso. Se utiliza para representar condiciones y controlar flujos del código.

Números enteros

Como comentado anteriormentre, Julia tiene diferentes tipos de enteros dependiendo del espacio en memoria que se utiliza para guardarlos/representarlos. Estos pueden ser generados utilizando el nombre del tipo como una función:

typeof(Int32(5)), typeof(Int16(5)), typeof(Int8(5)) 
(Int32, Int16, Int8)

La representación binaria o de bits de cualquier tipo primitivo en Julia (booleano, flotante, entero, caracter) se puede obtener mediante la función bitstring). Esto nos permite hacernos una mejor idea de cómo se guardan en memoria estos objetos.

bitstring(Int8(5)), bitstring(Int16(5))
("00000101", "0000000000000101")

Podemos utilizar la función methods para enlistar todos los tipos para los cuales la función bitstring está definida:

methods(bitstring)
# 6 methods for generic function bitstring:

El hecho que cada tipo de dato sea representado por una cadena finita de \(0\)s y \(1\)s implica que tendremos límites en los valores de nuestros datos. En particular, podemos ver los máximos de cada tipo de entero:

## Estos valores son 2^(n-1) - 1, donde n es el número de bits.
typemax(Int8), typemax(Int16), typemax(Int32), typemax(Int64)
(127, 32767, 2147483647, 9223372036854775807)

y los mínimos…

## Estos valores son -2^(n-1), donde n es el número de bits.
typemin(Int8), typemin(Int16), typemin(Int32), typemin(Int64)
(-128, -32768, -2147483648, -9223372036854775808)

Estos valores extremos son importantes puesto a que pueden causar lo que conocemos como integer overflow o integer underflow (“desbordamiento” de enteros, aunque el término en español no es comúnmente utilizado).

Ésto es: Que el valor resultante de operaciones de enteros excede el máximo o mínimo de un tipo de entero y retornamos valores en el extremo opuesto:

typemax(Int32) + Int32(1) ## <- Integer overflow
-2147483648
typemin(Int32) - Int32(1) ## <- Integer underflow
2147483647

Notemos que esto no pasará en el siguiente ejemplo. ¿Por qué?:

## el número 1 aquí, escrito sin especificar su tipo, tendrá tantos bits 
## como el "tamaño de palabra" utilizado en el procesador. Más de esto luego.
typemax(Int32) + 1  
2147483648
typeof(typemax(Int32) + 1 )
Int64

Esto sucede debido al sistema de promoción de tipos en julia. Para los enteros, siempre consideramos a los de mayor número de bits como “superiores” en el sentido siguiente:

Sean

  • x un entero de n bits

  • y un entero de m bits

  • n > m

entonces: x + y será un entero de n bits tras promover y hacia su versión de n bits (lo cual siempre es posible).

La razón por la que typemax(Int32) + 1 es de tipo Int64 en computadoras de 64 bits es porque los enteros sin especificar su tipo (1 en este caso) tienen por defecto tamaño 64. En general, la longitud de bits de enteros y de floats será del tamaño de lo que se conoce como tamaño de palabra en el procesador.

De no estar seguro del tamaño de palabra que utiliza su procesador, Julia tiene una variable interna a la cual consultar para verificarlo rápidamente. En el caso donde se ejecutó por primera vez este notebook, tenemos un word size de 64 bits:

Sys.WORD_SIZE
64

Números de punto flotante

Los números de punto flotante son un sistema numérico que pretende aproximar lo mejor posible a los números reales utilizando una representación decimal, particularmente densa para números pequeños, los cuales suelen ser más útiles en garantizar la precisión de cálculos numéricos. De nuevo, más información aquí.

A diferencia de los enteros, los tipo flotantes no tienen límite superior ni inferior… si no que son representados infinitos especiales para cada cantidad de bits, que abarcan esa cantidad de memoria pero todos respetan propiedades esperadas como “ser mayor que cualquier otro flotante” o la aritmética de infinitos vista anteriormente.

typemax(Float32)
Inf32
typemin(Float32)
-Inf32

Además como visto anteriormennte, tenemos un número flotante especial (una versión para cada número de bits), NaN (“Not a Number”), que sirve para representar datos que no podríann ser representados de otra manera o que señalizan un error sin necesitar levantar un error que paralice el flujo de un programa.

typeof(NaN), typeof(Float32(NaN))
(Float64, Float32)

Con aritmética que “absorbe” cualquier otro número a ser NaN y además representar expresiones matemáticas no definidas.

NaN + NaN, NaN*NaN, NaN/NaN, 5 + NaN, 0/0, Inf/Inf,  Inf - Inf
(NaN, NaN, NaN, NaN, NaN, NaN, NaN)

Podemos ver la representación de bits de dos flotantes:

bitstring(Float16(5.0)), bitstring(Float32(5.0)), bitstring(Int16(5))
("0100010100000000", "01000000101000000000000000000000", "0000000000000101")

Notemos que la cadena binaria del número 5 en su representación flotante cambia de la representación de entero en la adición de una cadena larga de \(0\)s a la derecha, la cual se utiliza para presentar su parte decimal. Ejemplo:

bitstring(Float16(5.1))
"0100010100011010"

No obstante, como la cadena binaria sigue siendo finita, esperamos que exista algún tipo de límite. Si este límite no es un máximo ni un mínimo ¿dónde está?

Resulta que la precisión de los números flotantes está determinada por el número de cifras significativas que el número de bits nos permite guardar. Por ejemplo, el decimal \(5.1\) puede guardarse sin mucha memoria, pero una representación de 100 cifras de \(\pi\) no es posible con un Float16, pero su precisión incrementa entre más bits tengamos.

Float16(pi)
Float16(3.14)
Float64(pi)
3.141592653589793

Esto, para números especiales como \(\pi\) no es un problema en Julia, ya que son representados mediante otro tipo de datos llamado Irrational, especial para representar de manera arbitrariamente precisa los números irracionales:

pi
π = 3.1415926535897...
typeof(pi)
Irrational{:π}

Si queremos extraer una precisión particular de un número como \(\pi\) o garantizar que cierto número de bits serán utilizados en nuestros cálculos, basta con utilizarlo en una operación o extraer el valor directamente utilizando el tipo flotante más general BigFloat

3pi
9.42477796076938
pi^2
9.869604401089358
BigFloat(pi, precision = 256)
3.141592653589793238462643383279502884197169399375105820974944592307816406286198

Aquí el argumento precision es el número de bits que utilizaremos en la representación de dicho número. Entre más sean, más cifras significativas podremos representar; esto puede ser crucial en caso de necesitar realizar cálculos sumamente precisos, aunque viene con el costo de mayor memoria por número.

Para tener una mejor medida de cuántos bits necesitamos para obtener “x” precisión, presentamos brevemente el concepto de “épsilon de máquina”: El máximo error relativo que puede ocurrir por medio del procedimiendo de redondeo. En cierto sentido, ésto es una cota de la precisión para una cantidad selecta de bits.

eps(Float64), eps(Float32)
(2.220446049250313e-16, 1.1920929f-7)

El epsilon de máquina depende de en que rango esté el número utilizado, no solo su tipo:

eps(1.0), eps(10000.0) 
(2.220446049250313e-16, 1.8189894035458565e-12)

Esto es porque los números cercanos a cero yacen más densos mientras que los más grandes están más esparcidos. Por ello, normalizar cantidades a números pequeños y adimensionales en cálculos numéricos es importante para minimizar el error de precisión de máquina.

Caracteres

Los Char son un tipo de dato primitivo que representa cualquier caracter definido en cualquier codificación bajo el estándar Unicode (ej. UTF-8, UTF-16, GB18030). Esto incluyen caracteres alfanuméricos a los que estamos acostumbrados:

typeof('r'), typeof('5')
(Char, Char)

O también characteres especiales de otros alfabetos o incluso emojis:

typeof('尊') ## <- Respeto/honor
Char
 typeof('🦈')
Char

Muy cercanamente asociados a los Char tenemos a los Strings (cadenas de caracteres), un tipo compuesto que se genera a partir de Chars. La diferencia de sintaxis crucial es el uso de doble comillas » en lugar de comillas simples, “ .

typeof("¡Hola!"), typeof("안녕하세요"), typeof("Z")
(String, String, String)

Booleanos

Los booleanos (Bool) son el último tipo de dato primitivo del que platicaremos. Éstos se estudiarán más a fondo cuando se estudien las estructuras de control y los condicionales. Presentamos aquí algunas propiedades:

  • Tenemos dos valores de tipo booleano: true y false

typeof(true), typeof(false)
(Bool, Bool)
  • Numericamente, son equivalentes 1 y a 0 respectivamente.

true == 1, false == 0, true + true, true * false
(true, true, 2, false)
  • false puede ser utilizado como un 0 “más fuerte” que, por ejemplo, puede volver 0 incluso a NaN.

false * NaN
0.0

Esto es para tener consistencia en operaciones booleanas a las cuales les interesa más que los resultados de ellas sean valores “convertibles” a booleanos de nuevo, por lo que NaN no tendría mucha utilidad aquí.

Tipos compuestos

Observemos los siguientes tipos de datos:

typeof(5+2im), typeof(5.0+2im), typeof(Inf), typeof(Inf + Inf*im), typeof(5//2)
(Complex{Int64}, Complex{Float64}, Float64, Complex{Float64}, Rational{Int64})
typeof(Int32(5)//Int32(2))
Rational{Int32}

Los números complejos y los números racionales son tipos compuestos: Su estructura es construida a partir de otros más simples.

Esta es una idea poderosa en Julia que nos permite construir estructuras de datos arreglos de números (vectores, matrices, etc.)

typeof([2, 3, 5])  ## Esto es un "vector"/arreglo unidimensional
Array{Int64,1}

El anterior es un tipo compuesto llamado arreglo, en general, o en particular por su forma: vector. Su tipo se identifica con Int64, ya que todos sus sub-unidades (sus entradas) son de tipo Int64.

Este tipo global que se identifica para los tipos compuestos ayuda al compilador a asignar memoria eficientemente al objeto completo. Por ejemplo, en este caso, cada entrada (con un total de \(3\)), al ser Int64, solamente ocupa 64 bits para almacenarse, y no tenemos el riesgo de necesitar más que eso. Por ello, el compilador puede buscar una sección de memoria con \(3*64\) bits contiguos que posteriormente facilite y acelere el acceso entre elementos para operarlos, por ejemplo, al querer sumarlos, sacar su promedio, hacer una búsqueda, etc.

Esta es una desventaja general de los objetos por defecto de lenguajes como la lista de Python, la cual equivale a un arreglo pero de cualquier tipo, por lo que su almacenamento en memoria no puede ser optimizado.

No obstante, de necesitarlo, Julia igualmente puede tener un arreglo que pueda contener cualquier tipo, al tener como tipo global Any.

typeof([3, "Hola", 5.0])
Array{Any,1}

Los anteriores son un análogo de lista para Python, pero igualmente tenemos objetos diseñados para comportarse como matrices, al estilo de lenguajes como Octave/Matlab:

typeof([2 3 5])  ## Esto es una "matriz"/arreglo bidimensional
Array{Int64,2}
typeof([2.0 3.0 5.0])
Array{Float64,2}
typeof([2 3 5;
        6 8 2])
Array{Int64,2}

El siguiente es un arreglo de tipo Float64, pues los Int64 presentes fueron promovidos a Float64.

typeof([2.0 3 5]) 
Array{Float64,2}

¿Qué decide cuál tipo de número se promueve a cuál otro? Este es una pregunta importante al considerar tipos compuestos, especialmente cuando luego construyamos nuestros propios tipos compuestos.

La respuesta corta e intuitiva en nuestro caso particular es: Un entero siempre puede convertirse a un flotante (agregando un .0, ej. 5 -> 5.0) pero un flotante puede fallar fácilmente en poder convertirse en un entero si tiene parte decimal (5.5 no podría convertirse en 5 ni en 6 sin perder información).

Una respuesta más elaborada toma en cuenta dos cosas. Primero, la siguiente imagen:

Jerarquía

Esta es una jerarquía de tipos en donde podemos observar que tipos que yacen en los extremos de sus “ramas” (como Int32, Float64, Bool, etc.) son los que hemos llamado tipos primitivos, pero estos son particularizaciones de tipos conocidos como tipos abstracto.

En resumen, tenemos una jerarquía de supertipos abstractos (ej. Integer es un supertipo de todos los Int64, Int16, …) y subtipos (ej. Int8 es subtipo de Integer pero a su vez Integer es a su vez subtipo de Real) donde solo los subtipos que no tienen más subtipos son llamados primitivos.

Para que un tipo, x, pueda ser convertido en otro tipo y, necesitamos que x pueda ser llevado hacia un supertipo en común, digamos z, y luego re-presentarlo en un elemento de tipo y de manera única y sin perder información que le distinga su valor.

Esto lleva al segundo punto: La función promote, la cual está encargada de realizar este procedimiento para cada tipo compuesto que lo requiera por uniformidad.

promote(5, 1.4, 6)
(5.0, 1.4, 6.0)

Es posible en cualquier momento obtener la jerarquía de supertipos de un tipo en particular:

supertypes(Int64) 
(Int64, Signed, Integer, Real, Number, Any)
supertypes(Float64)
(Float64, AbstractFloat, Real, Number, Any)
supertypes(Char)
(Char, AbstractChar, Any)

Podemos observar que, para todos los tipos, el tipo Any será un supertipo maximal. La motivación y beneficio de definir esta jerarquía de tipos será evidente una vez que estudiemos funciones y construyamos nuestros propios tipos.

Vectores, matrices y arreglos

Regresando a la plática de arreglos, exploremos su aritmética, la cual se comporta como esperaríamos del álgebra lineal.

[1 2 4] + [3 2 1]
1×3 Array{Int64,2}:
 4  4  5

Podemos pensar informalmente en los arreglos “sin comas” como “vectores fila” mientras que los vectores “con comas” como “vectores columna”. Esto es útil recordar cuando queremos hacer productos de matriz con vector:

[2 3; 6 8] * [1, 1] 
2-element Array{Int64,1}:
  5
 14

También podemos exponenciar matrices:

[3 4; 6 8]^2 ## ¿Pueden encontrar más matrices con esta propiedad? :) 
			 ## pista: Cayley-Hamilton  
2×2 Array{Int64,2}:
 33  44
 66  88

Pero ¿Cómo es que el mismo operador (+, *, /,etc.) sabe qué hacer dependiendo del tipo de dato?

Operaciones por tipo

En julia todos los operadores son realmente funciones. Podemos encontrar sus definiciones en diversos archivos de lo que llamamos la librería estándar o base de Julia anteponiendo @which (conocido como un Macros, más de ellos luego) a la operación realizada.

@which 3.0*4.0
*(x::Float64, y::Float64) in Base at float.jl:405
@which (3+0im)*(4+0im)
*(z::Complex, w::Complex) in Base at complex.jl:277
@which Float32(4.0)*Float32(2.0)
*(x::Float32, y::Float32) in Base at float.jl:404
@which 3*4
*(x::T, y::T) where T<:Union{Int128, Int16, Int32, Int64, Int8, UInt128, UInt16, UInt32, UInt64, UInt8} in Base at int.jl:87
@which [2 3; 6 8] * [1, 1]
*(A::AbstractArray{T,2}, x::AbstractArray{S,1}) where {T, S} in LinearAlgebra at /opt/hostedtoolcache/julia/1.5.1/x64/share/julia/stdlib/v1.5/LinearAlgebra/src/matmul.jl:49

Incluso, tenemos operaciones con cadenas de caracteres:

@which "Hola" * " " * "mundo"
*(s1::Union{AbstractChar, AbstractString}, ss::Union{AbstractChar, AbstractString}...) in Base at strings/basic.jl:251

La operación de multiplicación de cadenas de texto funciona igual a la suma + en python. Julia eligió utilizar multiplicación debido a que no es una operación conmutativa, y tiene más sentido tener una notación consistente con la de un monoide:

"Hola" * " " * "mundo", "Hola"^3
("Hola mundo", "HolaHolaHola")

cuyo elemento identidad es la cadena vacía: \"\"

"hola" * ""
"hola"

Para dejar más en claro que los operadores son funciones, podemos notar que podemos operar de la siguiente manera, muy similar a los lenguajes basado en Lisp

Tengan muy en mente esta noción, pues resultará claro luego que es uno de los pilares de diseño más importantes de Julia.

*(5,3,2)
30
+(3,5,2,1,5)
16
^(3, 2)
9

Para poder indagar mejor en cómo están definidos estos operadores, y cualquier parte de código de Julia, se pueden utilizar macros como @edit o @doc:

@doc 4*5
*(x, y...)

Multiplication operator. x*y*z*... calls this function with all arguments, i.e. *(x, y, z, ...).

Variables y funciones

Sintaxis básica

Como mencionado anteriormente, Julia prioritiza y enfoca mucho la legibilidad del código. Esto es en gran parte posible gracias a lo expresivos que pueden ser los nombres y definiciones de variables y funciones.

Por ejemplo, nuestras variables podrían ser muy explícitas de contar el número de conejitos y lobos en una zona en particular.

🐰 = 4; 🐺 = 2;

Esta además es una buena oportunidad para ver cómo Pluto puede utilizarse como un editor reactivo…

🐰 + 1 
5
2*🐺
4

Por supuesto, también podemos tener variables con nombres más usuales

x = 5
5

Aquí en pluto, debido a la reactividad del cuaderno, no podemos definir más de una sola variable por celda a menos que, como anteriormente, utilicemos punto y coma (;) o esto que llamamos un bloque begin\(-\)end, que compone varias expresiones en una sola expresión compuesta. Hablaremos más de ello luego.

begin
	x₀ = 5 
	x₁ = 2 
	x₂ = 1
	x₀ + x₁ 
end
7

La notación para crear funciones es bastante flexible. La siguiente es una forma estándar en muchos lenguajes de programación.

function sumaUno(x)
	return(x+1)
end
sumaUno (generic function with 1 method)
sumaUno(10)
11

Esta misma función puede, de manera más sucinta, expresarse como lo haríamos en papel

f(x) = x+1
f (generic function with 1 method)
f(10)
11

También podemos colocar múltiples argumentos:

w(x,y,z) = x+y+z
w (generic function with 1 method)
w(1,3,-2)
2

Una tercera forma de hacerlo es:

OtraForma(x) = begin
	x+1
end
OtraForma (generic function with 1 method)

Esta es una combinación entre la claridad en el nombre de la variable del segundo método y la capacidad de tener un bloque grande de instrucciones entre el begin y end.

x->x+1
#1 (generic function with 1 method)

Ésta última forma de definir funciones se engloba como un tipo de función llamado funciones anónimas. Éste nombre debido a que estas funciones no necesitan un nombre para ser evaluadas:

(x->x+1)(5)
6

Aunque pueden igual guardarse dentro de una variable para darles nombre si uno lo desea…

función_desanonimizada = x->x+1
#5 (generic function with 1 method)

En el caso de funciones anónimas de múltiples argumentos, se escribe así:

(x,y,z) -> x+y+z
#7 (generic function with 1 method)

Mejorando la legibilidad de las funciones

A diferencia de en otros lenguajes, para definir funciones dependientes de una variable x, Julia permite anteponer objetos numéricos y implicar multiplicación, similar a cómo escribiríamos en un papel:

g(x) = 2x^2 + 3x + 1
g (generic function with 1 method)
g(4)
45

Esto es solo un ejemplo de las interconexión natural que obtenemos entre las notaciones estándar y sintaxis de Julia, veamos más a continuación:

f₁(x) = x + 2; f₂(x) = x + 1;
(f₁∘f₂)(2)
5

Lo anterior evalúa primero \(f₂(2) = 2 + 1 = 3\), y ese resultado lo evalúa en la función \(f₁\). Es decir, el resultado será \(f₁(3) = 3 + 2 = 5\). Un ejemplo de composición de funciones.

3 ÷ 2, 123551 ÷ 19723
(1, 6)

El símbolo de división literal, ÷ (ingresado mediante la escritura de \div, como en \(\LaTeX\), seguida por <TAB>), realiza una división entera. Es decir, devuelve la parte entera del resultado de dividir dos números.

Todos los símbolos permitidos por Julia se pueden encontrar en esta lista

5  10, 5  5.1, 5  5 - eps(Float64)
(false, false, true)

Tenemos un símbolo de aproximación (\approx + <TAB>) para comparar cantidades flotantes y considerarlas equivalentes si difieren en alguna pequeña cantidad respecto a lo que la precisión de punto flotante considera pequeño (dependiendo de qué tipo de flotante se utiliza, qué tan cercano estamos de 0, etc.)

16, (5^2 - 3^2), 4  5
(4.0, 4.0, true)

Y por supuesto podemos utilizar el símbolo de raíz cuadrada (\sqrt + <TAB>) para ejecutar dicha operación y el símbolo de no igualdad (\ne + <TAB>) para verificar objetos diferentes.

Las posibilidades son ilimitadas, pues podemos siempre definir cualquier operación como algún símbolo de la lista unicode mostrada anteriormente.

(a,b) = (3a+b^2) ## ¡Los paréntesis son importantes para que sea `infix`! 
⨳ (generic function with 1 method)
4  5 ## 3(4) + 5^2 = 12 + 25 = 37 
37
10  -1
31

Noten que por defecto los operadores definidos de esta manera serán (la mayor parte del tiempo) asociativos hacia la izquierda. El cómo generar asociatividad derecha y entender mejor ésta decisión de diseño se puede lograr visitando esta discusión.

4  (5  1) == 4  5  1, (4  5)  1 == 4  5  1
(false, true)

Retorno de las funciones

Una función, como visto anteriormente, tiene un valor de retorno tras evaluación mediante la palabra clave return. No obstante, esta no es necesaria, pues las funciones por defecto van a retornar la ultima expresión antes de end.

function test_retorno(x)
	x-1
end
test_retorno (generic function with 1 method)
test_retorno(4)
3

Si deseamos que la función retorne nada, necesitamos explíticamente solicitarlo mediante return(nothing)

function test_retorno₂(x)
	x-1
	return(nothing)
end
test_retorno₂ (generic function with 1 method)
test_retorno₂(4)

Esta vez la función fue evaluada, se operó x-1 pero su resultado no se guardó en ninguna localidad apuntada por alguna variable (y eventualmente se perderá en el recolector de basura de julia). Luego, la función no retorna nada, tal cual como especificado.

Por otro lado, podemos retornar múltiples valores por una función

function test(a,b)
	2b, 3a
end
test (generic function with 1 method)
test(4,7), typeof(test(4,7))
((14, 12), Tuple{Int64,Int64})

Notemos que el tipo de retorno de una función con múltiples retornos es lo que conocemos como una tupla. Más información de ellas en la sección de estructuras de datos.

Tuplas como argumentos de funciones

En general, cualquier agrupación ordenada de números es representada en Julia por una tupla, eso incluye los argumentos de una función. Considere el siguiente ejemplo:

begin
	test₂(x,y,z) = x*y - z
	tupla_test = (4,3,1)
	test₂(tupla_test...)  
end
11

Notemos que la función test₂ requiere tres argumentos para ser evaluada, no obstante, le hemos pasado solamente uno: tupla_test. Esto funciona debido a dos razones:

  • La variable tupla_test es una agrupación de tres elementos que podrían ser pasados como argumentos a test₂, aunque pasarlo como test₂(tupla_test) generaría un error.

  • Para evitar el error, debemos realizar el procedimiento llamado como desempacamiento (unpacking). Esto es, extrae los elementos de una tupla como si fueran elementos individuales; de esa forma evaluándolos. Esto se hace con los ellipsis ... contiguo a la tupla.

Esto puede ser utilizado en la definición de las funciones también, para lograr tener un número variable de parámetros.

function argumentosVariables(a, b, x...)
	sum(x)/length(x) + a*b 
end
argumentosVariables (generic function with 1 method)

La función anterior toma dos números, a y b, que son parámetros obligatorios y los multiplica, pero a ello, le sumará la media de todos los números que le siguen.

argumentosVariables(4, 3, 6) ## 4*3 + 6 = 12 + 6 = 18
18.0
argumentosVariables(4, 3, 6, 1, 5, 2, 9, 2)
16.166666666666668

Observemos que x no es realmente opcional, pues necesita tener al menos un número para que tenga sentido efectuar length(x) y sum(x).

## Genera error debido a que la función no está bien definida para cuando x es vacío
## argumentosVariables(4,3) 

Los argumentos realmente opcionales se mirarán luego de discutir los tipos dentro d las funciones:

Determinación de tipos en las funciones

Observemos las siguientes dos funciones:

function duplicadorDeTexto(texto) 
	return(texto*texto)
end
duplicadorDeTexto (generic function with 1 method)
function alPoderDeDos(n)
	return(n*n)
end
alPoderDeDos (generic function with 1 method)

Su nombre, así como el de sus argumentos, documenta bien qué es lo que las funciones pretenden hacer. No obstante, ¿Realmente necesitamos dos funciones?

duplicadorDeTexto("Hola"), duplicadorDeTexto(3)
("HolaHola", 9)

Por supuesto, aunque 3 no es texto, fue procesado por la función ya que la operación * está bien definida para enteros. De hecho, la función alPoderDeDos es idéntica a duplicadorDeTexto en cuanto a lógica.

No obstante, ambas funciones son guardadas en memoria y necesitan ser compiladas por el compilador JIT por cada tipo de dato con el vayamos a utilizarlo. Para ahorrarle el paso al compilador de esperar a saber el tipo de dato a utilizar, podemos especificar el tipo de dato a utilizar de la siguiente forma:

function duplicador(texto::String)
	return(texto*texto)
end
duplicador (generic function with 1 method)

Uno puede explorar los pasos que sigue la evaluación de una función al ser evaluada por instrucciones en lenguaje máguina utilizando el macros @code_native

## @code_native duplicador("Hola")

Si nosotros quisieramos proveer la funcionalidad también para los números, hacemos uso de lo llamado Multiple dispatch, o bien puesto en concepto: la producción de múltiples definiciones para una sola función, dependientes del contexto de sus argumentos.

Cada una de estas múltiples definiciones son llamadas métodos, aunque no deben confundirse con lo que se conoce como método en programación orientada a objetos.

function duplicador(num::Integer)
	return(2*num)
end
duplicador (generic function with 2 methods)
duplicador(3)
6
## @code_native duplicador(3)

Notemos que ahora la definición de nuestra función indica generic function with 2 methods, haciendo referencia a que es una sola función pero que ahora posee dos métodos.

Podemos también especificar el tipo de la salida de la función, lo cual es además útil para aumentar la legibilidad de nuestro flujo de código, evitar errores no deseados tras ser suficientemente específicos y forzar el tipo de ciertos resultados.

function media(x::AbstractFloat, y::AbstractFloat) :: Float32
	resultado = (x+y)/2
	return(resultado)
end
media (generic function with 1 method)

Notemos que como el tipo de salida se ha especificado a ser Float32, los números x y y, que pueden ser Floats de cualquier número de bits (al pertenecer a su clase abstracta) van a ser siempre convertidos a su representación de 32 bits tras operarse con la función.

media(5.0, 3.5), typeof(media(5.0, 3.5))
(4.25f0, Float32)

Para los más inclinados a una notación matemática más rigurosa, es posible hacer definiciones de funciones anónimas, especificando sus tipos, de forma que imite la notación usual de definición de funciones en matemática.

 = Integer;  = Rational;
h = x:: -> (1//x)::
#9 (generic function with 1 method)
h(5)
1//5

Argumentos opcionales en funciones

Para agregar un valor por defecto en un argumento de una función basta con seleccionarlo en su definición. Posterior a ello, éste será totalmente opcional a colocar, y en caso de no colocarlo, se utilizará el valor por defecto que se eligió.

function múltiples_opcionales(x, y = 1, z = 3)
	return(x,y,z)
end
múltiples_opcionales (generic function with 3 methods)
múltiples_opcionales(5)
(5, 1, 3)
múltiples_opcionales(5, 5, 9)
(5, 5, 9)
múltiples_opcionales(5, 6) 
(5, 6, 3)

Broadcasting

Una función definida aparentemente para un número individual puede ser evaluada en objetos como vectores y matrices utilizando el operador de broadcasting (o difusión), “.”, de la siguiente manera:

m(x::Integer) = x^2 + 1
m (generic function with 1 method)
m.([1,2,4]) 
3-element Array{Int64,1}:
  2
  5
 17
g.([1 2 4; 3 5 6])
2×3 Array{Int64,2}:
  6  15  45
 28  66  91

El broadcast también puede ser aplicado operación por operación desde la definición de la función:

g₂(x) = 2x.^2 .+ 3x .+ 1
g₂ (generic function with 1 method)
g₂([1 2 4; 3 5 6])
2×3 Array{Int64,2}:
  6  15  45
 28  66  91

Veamos un ejemplo un poco más complejo:

function extracción_dominio(correo::String)
	## La siguiente función extrae una subcadena acorde a alguna expresión regular
	## encontrada.
	result = match(r"@(.*)", correo).captures[1] 
	return(result)
end
extracción_dominio (generic function with 1 method)
extracción_dominio("correo_ejemplo@julia.com")
"julia.com"
begin
	lista_correos = ["lffm.fismat@tutanota.com",
					 "correo_ejemplo@julia.com",
	   				 "otro_correo@yahoo.com",
					 "luke@lukesmith.xyz"]
	
	extracción_dominio.(lista_correos)
end
4-element Array{SubString{String},1}:
 "tutanota.com"
 "julia.com"
 "yahoo.com"
 "lukesmith.xyz"

Structs

Los structs en julia son la forma de producir tipos propios que funcionen tal cual como los tipos primitivos y compuestos de los que hemos hablado hasta ahora.

struct Empleado
	nombre
	edad
end
typeof(Empleado), typeof(Float64)
(DataType, DataType)
empleado₁ = Empleado("Felipe", 24)
Empleado("Felipe", 24)
typeof(empleado₁)
Empleado
supertypes(Empleado)
(Empleado, Any)

Más información aquí

dump(Float64)
Float64 <: AbstractFloat

Funciones como tipos

Las funciones en julia son lo que se conoce como first-class citizen. Esto quiere decir que, al igual a todos los demás tipos que hemos visto, las funciones pueden operarse dentro de otras funciones, muy similar a como se puede hacer con las funciones en R.

@doc Function
Function

Abstract type of all functions.

function evalua_funciones(f::Function, argumento)
	f(argumento)
end
evalua_funciones (generic function with 1 method)
evalua_funciones(x -> x+1, 4)
5
evalua_funciones(extracción_dominio, "correo_falso@wikipedia.org")
"wikipedia.org"