Julia: tipos, métodos múltiples y aritmética sobre polinomios

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 #   1  3   1 ( ) 1 2 3 julia> for i in 1:2:3; print(i, " "); end #   1  3   2 1 3 

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) #       ; return   end function root(x, degree = 2) #  degree     return x^(1.0/degree) end function greeting(name; times = 42, greet = "hello") #       println(times, " times ", greet, " to ", name) end julia> greeting("John") 42 times hello to John julia> greeting("Mike", greet = "wassup", times = 100500) #           100500 times wassup to Mike 


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) #  @time -      0.006811 seconds (3.15 k allocations: 168.516 KiB) #   ,        2.8284271247461903 julia> @time sqrt(15) 0.000002 seconds (5 allocations: 176 bytes) # 5   -     @time 3.872983346207417 

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

 sumk=1N sqrt(1)k


 function mysqrt(num) #    -     #   -           if num >= 0 return sqrt(num) else return sqrt(complex(num)) end end function S(n) #    sum = 0 sgn = -1 for k = 1:n sum += mysqrt(sgn) sgn = -sgn end return sum end function S_typed(n::Integer) # ..     ,      #     sum::Complex = 0.0 sgn::Int = -1 for k = 1:n sum += mysqrt(sgn) sgn = -sgn end return sum end 

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 #     ,      ... julia> @code_warntype S_typed(3) Body::Complex{Float64} #      ... 

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 #   struct    name b::Int c::Char v::Vector end #       #       ,        julia> s = GenericStruct("Name", 1, 'z', [3., 0]) GenericStruct("Name", 1, 'z', [3.0, 0.0]) julia> s.name, sb, sc, sv ("Name", 1, 'z', [3.0, 0.0]) 

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 #   -     # ,    -     N  struct PointScalar<:NDimPoint x1::Real end struct Point2D<:NDimPoint x1::Real x2::Real end struct Point3D<:NDimPoint x1::Real x2::Real x3::Real end #     ;   Markdown """ mag(p::NDimPoint) Calculate the magnitude of the radius vector of an N-dimensional point `p` """ function mag(p::NDimPoint) sqrmag = 0.0 # ..   ,       #     T   fieldnames(T) for name in fieldnames(typeof(p)) sqrmag += getfield(p, name)^2 end return sqrt(sqrmag) end """ add(p1::T, p2::T) where T<:NDimPoint Calculate the sum of the radius vectors of two N-dimensional points `p1` and `p2` """ function add(p1::T, p2::T) where T<:NDimPoint #  -  , ..       #     list comprehension sumvector = [Float64(getfield(p1, name) + getfield(p1, name)) for name in fieldnames(T)] #     ,    #  ...      , .. # f([1, 2, 3]...) -   ,  f(1, 2, 3) return T(sumvector...) 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 # NTuple{N, Type} -    N    end """ evpoly(p::Polynomial, z::Real) Evaluate polynomial `p` at `z` using the Horner's rule """ function evpoly(p::Polynomial, z::Real) ans = p.coeff[end] for idx = p.degree:-1:1 ans = p.coeff[idx] + z * ans end return ans end 


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:

P(x)= sumk=0Ncknk(x),


donde n k ( x ) son polinomios básicos, n 0 ( x ) y para k > 0

nk(x)= prodi=0k1(xxi),


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}}) #     /     P(x) ≡ 0 coeff = isempty(v) ? (0.0,) : tuple([Float64(x) for x in v]...) #   -   new #  -    return new(length(coeff)-1, coeff) end end """ InterpPolynomial <: AbstractPolynomial Interpolation polynomials in Newton's form --- InterpPolynomial(xsample::Vector{<:Real}, fsample::Vector{<:Real}) Construct an `InterpPolynomial` from a vector of points `xsample` and corresponding function values `fsample`. All values in `xsample` must be distinct. """ struct InterpPolynomial<:AbstractPolynomial degree::Int xval::NTuple{N, Float64} where N coeff::NTuple{N, Float64} where N function InterpPolynomial(xsample::X, fsample::F) where {X<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}}, F<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}}} #   ,    ,   f  ,   if !allunique(xsample) throw(DomainError("Cannot interpolate with duplicate X points")) end N = length(xsample) if length(fsample) != N throw(DomainError("Lengths of X and F are not the same")) end coeff = [Float64(f) for f in fsample] #     (Stoer, Bulirsch, Introduction to Numerical Analysis, . 2.1.3) for i = 2:N for j = 1:(i-1) coeff[i] = (coeff[j] - coeff[i]) / (xsample[j] - xsample[i]) end end new(N-1, tuple([Float64(x) for x in xsample]...), tuple(coeff...)) end end 

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:
 # -   Base.+  , #    Base.:+,   " :+   Base" function Base.:+(p::Polynomial, x::Real) Polynomial(tuple(p.coeff[1] + x, p.coeff[2:end]...)) end function Base.:+(p::InterpPolynomial, x::Real) # ..           - #          . #       - #        fval::Vector{Float64} = [evpoly(p, xval) + x for xval in p.xval] InterpPolynomial(p.xval, fval) end #       function Base.:+(x::Real, p::AbstractPolynomial) return p + x end 

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) #    ,      deg = max(p1.degree, p2.degree) coeff = zeros(deg+1) coeff[1:p1.degree+1] .+= p1.coeff coeff[1:p2.degree+1] .+= p2.coeff Polynomial(coeff) end function Base.:+(p1::InterpPolynomial, p2::InterpPolynomial) xmax = max(p1.xval..., p2.xval...) xmin = min(p1.xval..., p2.xval...) deg = max(p1.degree, p2.degree) #         #       xmid = 0.5 * xmax + 0.5 * xmin dx = 0.5 * (xmax - xmin) / cos(0.5 * π / (deg + 1)) chebgrid = [xmid + dx * cos((k - 0.5) * π / (deg + 1)) for k = 1:deg+1] fsample = [evpoly(p1, x) + evpoly(p2, x) for x in chebgrid] InterpPolynomial(chebgrid, fsample) end function Base.:+(p1::InterpPolynomial, p2::Polynomial) xmax = max(p1.xval...) xmin = min(p1.xval...) deg = max(p1.degree, p2.degree) xmid = 0.5 * xmax + 0.5 * xmin dx = 0.5 * (xmax - xmin) / cos(0.5 * π / (deg + 1)) chebgrid = [xmid + dx * cos((k - 0.5) * π / (deg + 1)) for k = 1:deg+1] fsample = [evpoly(p1, x) + evpoly(p2, x) for x in chebgrid] InterpPolynomial(chebgrid, fsample) end function Base.:+(p1::Polynomial, p2::InterpPolynomial) p2 + p1 end 

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:
  1. Documentación del idioma Julia: docs.julialang.org
  2. Plataforma de discusión del lenguaje Julia: discurso.julialang.org
  3. J. Stoer, W. Bulirsch. Introducción al análisis numérico
  4. Julia Hub: habr.com/en/hub/julia
  5. Piensa en Julia: benlauwens.imtqy.com/ThinkJulia.jl/latest/book.html

Source: https://habr.com/ru/post/450628/


All Articles