En esta publicación, nos centraremos en la característica principal, en mi opinión, distintiva del lenguaje Julia: la representación de funciones en forma de métodos con despacho múltiple. Esto le permite aumentar el rendimiento de los cálculos sin reducir la legibilidad del código y sin estropear la abstracción, por un lado, y le permite trabajar con conceptos matemáticos en una notación más familiar, por el otro. Como ejemplo, se considera la cuestión del trabajo uniforme (en términos de operaciones lineales) con polinomios en la representación de la lista de coeficientes y con polinomios de interpolación.
Sintaxis básica
Una breve introducción para aquellos que no están al tanto. Julia es un lenguaje similar a un script, tiene REPL (bucle de lectura-evaluación-impresión, es decir, un shell interactivo). A primera vista, se parece bastante, por ejemplo, a Python o MATLAB.
Operaciones aritméticas
La aritmética es casi lo mismo que en todas partes: +, -, *, /, ^ para exponenciación, etc.
Comparación:>, <,> =, <=, == ,! = Etc.
Asignación: =.
Características: la división a través de
/
siempre devuelve un número fraccionario; si necesita la parte entera de la división de dos enteros, debe usar la operación
div(m, n)
o el infijo equivalente
m ÷ n
.
Tipos
Tipos numéricos:
- Enteros (
Int
) - 2
, 3
, -42
- Enteros sin
UInt
( UInt
) - 0x12345
- Punto flotante (
Float32
, Float64
) - 1.0
, -Inf
, -Inf
, NaN
- Racional (
Rational
) - 3//3
, 7//2
- Real (
Real
): todo lo anterior - Complejo (
Complex
) - 3+4*im
, 2//3+2//3*im
, 3.0+0.0*im
( im
es una unidad imaginaria, solo un número con una parte imaginaria escrita explícitamente se considera complejo) Number
: todo lo anterior
Cadenas y caracteres:
'a'
- personaje ( Char
)"a"
es una cadena ( String
)
NB: las cadenas, como ahora en muchos idiomas, son inmutables.
NB: las cadenas (así como los nombres de variables) son compatibles con Unicode, incluidos los emoji.
Matrices:
x = [1, 2, 3]
- especificando una matriz por enumeración directa de elementos- constructores especiales:
zeros(length)
para una matriz de ceros, ones(length)
para una matriz de unos, rand(length)
para una matriz de números aleatorios, etc. - soporte de matriz multidimensional
- soporte para operaciones de álgebra lineal (adición de matrices, multiplicación escalar, multiplicación de vectores de matriz y mucho más) en la biblioteca estándar
NB: todas las colecciones se indexan a partir de una.
NB: porque el lenguaje está destinado a tareas computacionales, las matrices son uno de los tipos más importantes, tendrá que volver a los principios de su trabajo más de una vez.
Tuplas (conjunto ordenado de elementos, inmutable):
(2, 5.3, "k")
es una tupla regular(a = 3, b = 4)
- tupla nombrada
NB: se puede acceder a los campos de una tupla nombrada tanto por nombre a través de un punto como por índice a través de []
julia> x = (a = 5, b = 12) (a = 5, b = 12) julia> x[1] 5 julia> sqrt(xa^2 + x[2]^2) 13.0
Diccionarios
julia> x = Dict('a' => 5, 'b' => 12) Dict{Char,Int64} with 2 entries: 'a' => 5 'b' => 12 julia> x['c'] = 13 13 julia> x Dict{Char,Int64} with 3 entries: 'a' => 5 'c' => 13 'b' => 12
Construcciones de lenguaje de control básico
1. Las variables se crean automáticamente tras la asignación. El tipo es opcional.
julia> x = 7; x + 2 9 julia> x = 42.0; x * 4 168.0
2. El bloque de salto condicional comienza con la expresión
if <condition>
y termina con la palabra
end
. También puede tener una luz
else
o luces si no:
if x > y println("X is more than Y") elseif x == y println("X and Y are equal") else println("X is less than Y") end
3. Hay dos construcciones de bucle:
while
y
for
. El segundo funciona como en Python, es decir. Itera sobre la colección. Un uso común es iterar sobre un rango de valores cuya sintaxis es
start[:increment]:end
. A diferencia de Python, un rango
incluye valores iniciales y finales, es decir el rango vacío no será
1:1
(este es un rango de 1), sino
1:0
. El final del cuerpo del bucle está marcado con la palabra
end
.
julia> for i in 1:3; print(i, " "); end
4. Las funciones están definidas por la
function
palabra clave, la definición de la función también termina con la palabra
end
. Se admiten argumentos con valores predeterminados y argumentos con nombre.
function square(x) return x * x end function cube(x) x * square(x)
En general, todo esto es bastante similar a Python, excepto por pequeñas diferencias en la sintaxis y el hecho de que los bloques de código no se asignan con espacios, sino con palabras clave. En casos simples, los programas de Python incluso se traducen en Julia casi uno a uno.
Pero hay una diferencia significativa en el hecho de que en Julia puede especificar explícitamente los tipos de variables, lo que le permite compilar programas y obtener un código rápido.
La segunda diferencia significativa es que Python implementa un modelo OOP "clásico" con clases y métodos, mientras que Julia implementa un modelo de despacho múltiple.
Anotaciones de tipo y envío múltiple
Veamos qué es una función incorporada:
julia> sqrt sqrt (generic function with 19 methods)
Como REPL nos muestra,
sqrt
es una función genérica con 19 métodos. ¿Qué tipo de función generalizada y qué tipo de métodos?
Y esto significa que hay
varias funciones
sqrt
que se aplican a diferentes tipos de argumentos y, en consecuencia, calculan la raíz cuadrada utilizando varios algoritmos. Puede ver qué opciones están disponibles escribiendo
julia> methods(sqrt)
Se puede ver que la función se define para diferentes tipos de números, así como para matrices.
A diferencia de la OOP "clásica", donde la implementación concreta del método está determinada solo por la clase de llamada (despachando por el primer argumento), en Julia la elección de una función está determinada por los tipos (y número) de
todos sus argumentos.
Cuando se llama a una función con argumentos específicos de todos sus métodos, se selecciona uno que describe con mayor precisión el conjunto específico de tipos con los que se llama a la función, y es la que se usa.
Una característica distintiva es que se aplica un enfoque llamado compilación “justo antes de tiempo” por los autores del lenguaje. Es decir Las funciones se compilan para los tipos de datos dados en la primera llamada, después de lo cual las siguientes llamadas se realizan mucho más rápido. La diferencia entre la primera y las siguientes llamadas puede ser muy significativa:
julia> @time sqrt(8)
En el mal caso, cada llamada de función es una verificación del tipo de argumentos recibidos y una búsqueda del método deseado en la lista. Sin embargo, si le da pistas al compilador, puede eliminar las comprobaciones, lo que conducirá a un código más rápido.
Por ejemplo, considere calcular la suma
function mysqrt(num)
El punto de referencia muestra que la función
S_typed()
no solo se ejecuta más rápido, sino que tampoco requiere asignación de memoria para cada llamada, a diferencia de
S()
. El problema aquí es que el tipo del
mysqrt()
devuelto por
mysqrt()
no está definido, al igual que el tipo del lado derecho de la expresión
sum = sum + mysqrt(sgn)
Como resultado, el compilador ni siquiera puede determinar qué tipo de
sum
será en cada iteración. Entonces, el boxeo (enganche de etiqueta de tipo) es una variable y se asigna memoria.
Para la función
S_typed()
, el compilador sabe de antemano que
sum
es un valor complejo, por lo que el código está más optimizado (en particular, llamar a
mysqrt()
puede estar efectivamente en línea, siempre devolviendo el valor de retorno a
Complex
).
Más importante aún, para
S_typed()
compilador sabe que el valor de retorno es de tipo
Complex
, pero para
S()
tipo del valor de salida no se define nuevamente, lo que ralentizará todas las funciones donde se llamará a
S()
.
Puede verificar que el compilador piense en los tipos devueltos por la expresión utilizando la macro
@code_warntype
:
julia> @code_warntype S(3) Body::Any
Si se llama a una función en algún lugar del bucle para el cual
@code_warntype
no puede imprimir el tipo de retorno, o para el cual en algún lugar del cuerpo muestra la recepción de un valor de tipo
Any
, entonces la optimización de estas llamadas probablemente dará un aumento de rendimiento muy tangible.
Tipos compuestos
Un programador puede definir tipos de datos compuestos para sus necesidades utilizando la construcción de
struct
:
julia> struct GenericStruct
Las estructuras en Julia son inmutables, es decir, al crear una instancia de la estructura, ya no es posible cambiar los valores de los campos (más precisamente, no puede cambiar la dirección de los campos en la memoria; los elementos de los campos mutables, como
sv
en el ejemplo anterior, se pueden cambiar). Las estructuras
mutable struct
se crean mediante la construcción de
mutable struct
, cuya sintaxis es la misma que para las estructuras regulares.
La herencia de estructuras en el sentido "clásico" no es compatible, sin embargo, existe la posibilidad de "heredar" el comportamiento combinando tipos compuestos en supertipos o, como se les llama en Julia, tipos abstractos. Las relaciones de tipo se expresan como
A<:B
(A es un subtipo de B) y
A>:B
(A es un subtipo de B). Se parece a esto:
abstract type NDimPoint end
Estudio de caso: polinomios
Un sistema de tipos junto con el despacho múltiple es conveniente para expresar conceptos matemáticos. Veamos un ejemplo de una biblioteca simple para trabajar con polinomios.
Introducimos dos tipos de polinomios: "canónico", definido a través de coeficientes en potencias, y "interpolación", definido por un conjunto de pares (x, f (x)). Por simplicidad, consideraremos solo argumentos válidos.
Para almacenar un polinomio en una notación habitual, es adecuada una estructura que tenga una matriz o una tupla de coeficientes como campo. Para ser completamente inmutable, que haya una caravana. Por lo tanto, el código para definir el tipo abstracto, la estructura del polinomio y calcular el valor del polinomio en un punto dado es bastante simple:
abstract type AbstractPolynomial end """ Polynomial <: AbstractPolynomial Polynomials written in the canonical form """ struct Polynomial<:AbstractPolynomial degree::Int coeff::NTuple{N, Float64} where N
Los polinomios de interpolación necesitan una estructura de representación y un método de cálculo diferentes. En particular, si el conjunto de puntos de interpolación se conoce de antemano y se planea calcular el mismo polinomio en diferentes puntos,
la fórmula de interpolación de Newton es conveniente:
donde
n k (
x ) son polinomios básicos,
n 0 (
x ) y para
k > 0
donde
x i son los nodos de interpolación.
De las fórmulas anteriores se puede ver que el almacenamiento está convenientemente organizado en forma de un conjunto de nodos de interpolación
x i y coeficientes
c i , y el cálculo se puede hacer de manera similar al esquema de Horner.
""" InterpPolynomial <: AbstractPolynomial Interpolation polynomials in Newton's form """ struct InterpPolynomial<:AbstractPolynomial degree::Int xval::NTuple{N, Float64} where N coeff::NTuple{N, Float64} where N end """ evpoly(p::Polynomial, z::Real) Evaluate polynomial `p` at `z` using the Horner's rule """ function evpoly(p::InterpPolynomial, z::Real) ans = p.coeff[p.degree+1] for idx = p.degree:-1:1 ans = ans * (z - p.xval[idx]) + p.coeff[idx] end return ans end
La función para calcular el valor del polinomio en ambos casos se llama igual -
evpoly()
- pero acepta diferentes tipos de argumentos.
Además de la función de cálculo, sería bueno escribir una función que cree un polinomio a partir de datos conocidos.
Hay dos técnicas para esto en Julia: constructores externos y constructores internos. Un constructor externo es simplemente una función que devuelve un objeto del tipo apropiado. Un constructor interno es una función que se introduce dentro de la descripción de la estructura y reemplaza al constructor estándar. Es recomendable utilizar el constructor interno para construir polinomios de interpolación, ya que
- es más conveniente obtener un polinomio no a través de los nodos y coeficientes de interpolación, sino a través de los nodos y valores de la función interpolada
- los nodos de interpolación deben ser distintos
- el número de nodos y coeficientes debe coincidir
Escribir un constructor interno en el que se garantice el cumplimiento de estas reglas garantiza que todas las variables creadas de tipo
InterpPolynomial
, al menos, puedan ser procesadas correctamente por la función
evpoly()
.
Escribimos un constructor de polinomios ordinarios que toma una matriz unidimensional o una tupla de coeficientes como entrada. El constructor del polinomio de interpolación recibe los nodos de interpolación y los valores deseados en ellos y utiliza el
método de diferencias divididas para calcular los coeficientes.
""" Polynomial <: AbstractPolynomial Polynomials written in the canonical form --- Polynomial(v::T) where T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}}) Construct a `Polynomial` from the list of the coefficients. The coefficients are assumed to go from power 0 in the ascending order. If an empty collection is provided, the constructor returns a zero polynomial. """ struct Polynomial<:AbstractPolynomial degree::Int coeff::NTuple{N, Float64} where N function Polynomial(v::T where T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}})
Además de la generación real de polinomios, sería bueno poder realizar operaciones aritméticas con ellos.
Dado que los operadores aritméticos en Julia son funciones ordinarias, a las que se agrega una notación infija como azúcar sintáctica (las expresiones
a + b
+(a, b)
son válidas y absolutamente idénticas), su sobrecarga se realiza de la misma manera que la escritura métodos adicionales a sus funciones.
El único punto sutil es que el código de usuario se inicia desde el módulo
Main
(espacio de nombres) y las funciones de la biblioteca estándar están en el módulo
Base
, por lo que al sobrecargar, debe importar el módulo
Base
o escribir el nombre completo de la función.
Entonces, agregamos la suma de un polinomio con un número:
Para agregar dos polinomios ordinarios, es suficiente agregar los coeficientes, y al agregar el polinomio de interpolación al otro, puede encontrar los valores de suma en varios puntos y construir una nueva interpolación a partir de ellos.
function Base.:+(p1::Polynomial, p2::Polynomial)
Del mismo modo, puede agregar otras operaciones aritméticas en polinomios, lo que resulta en su representación en el código en una notación matemática natural.
Eso es todo por ahora. Intentaré escribir más sobre la implementación de otros métodos numéricos.
En la preparación, se utilizaron los siguientes materiales:
- Documentación del idioma Julia: docs.julialang.org
- Plataforma de discusión del lenguaje Julia: discurso.julialang.org
- J. Stoer, W. Bulirsch. Introducción al análisis numérico
- Julia Hub: habr.com/en/hub/julia
- Piensa en Julia: benlauwens.imtqy.com/ThinkJulia.jl/latest/book.html