Julia: funciones y estructuras como funciones

A pesar de que el concepto de Julia carece de programación orientada a objetos "clásica" con clases y métodos, el lenguaje proporciona herramientas de abstracción, un papel clave en el que juegan el sistema de tipos y los elementos de la programación funcional. Consideremos el segundo punto con más detalle.

El concepto de funciones en Julia es probablemente el más similar a los lenguajes de la familia Lisp (para ser más precisos, las ramas Lisp-1), y las funciones se pueden considerar en tres niveles: como subprogramas, como abstracciones a una determinada secuencia de acciones y como datos que representan esta abstracción .

Nivel 1. Funciones como rutinas


La asignación de subprogramas y la asignación de sus propios nombres ha estado ocurriendo desde tiempos prehistóricos, cuando Fortran se consideraba un lenguaje de alto nivel, y C todavía no estaba allí.

En este sentido, los productos de Julia son estándar. "Característica" puede llamarse el hecho de que sintácticamente no hay división en procedimientos y funciones. Independientemente de si la subrutina se llama para obtener algún valor o simplemente para realizar alguna acción en los datos, se llama una función.

La definición de una función comienza con la function palabra clave, seguida de una lista de argumentos, una secuencia de comandos entre paréntesis y el end la palabra finaliza la definición:

 """ sum_all(collection) Sum all elements of a collection and return the result """ function sum_all(collection) sum = 0 for item in collection sum += collection end sum end 

La sintaxis se distingue por el comportamiento heredado de Lisp: para un retorno "normal" de un valor de una función, la palabra return no return necesaria: el valor de la última expresión calculada antes de que end devuelva el end . En el ejemplo anterior, se devolverá el valor de la sum variable. Por lo tanto, return puede usarse como un marcador del comportamiento especial de una función:

 function safe_division(number, divisor) if divisor == 0 return 0 end number / divisor end #    function safe_division1(number, divisor) if divisor == 0 0 #             else number / divisor end end 

Para funciones con una definición corta, existe una sintaxis acortada similar a una notación matemática. Entonces, el cálculo de la longitud de la hipotenusa a lo largo de las piernas se puede definir de la siguiente manera:

 hypotenuse(a, b) = sqrt(a^2 + b^2) 

La división "segura" que utiliza el operador ternario se puede escribir de la siguiente manera:

 safe_division(number, divisor) = divisor == 0 ? 0 : number / divisor 

Como puede ver, no es necesario especificar tipos para argumentos de función. Dado cómo funciona el compilador Julia JIT, escribir pato no siempre resultará en un bajo rendimiento.

Como traté de demostrar en un artículo anterior , el compilador de Julia puede inferir el tipo del resultado devuelto por los tipos de argumentos de entrada. Por lo tanto, por ejemplo, la función safe_division requiere una modificación mínima para una operación rápida:

 function safe_division(number, divisor) if divisor == 0 return zero(number / divisor) end number / divisor end 

Ahora, si los tipos de ambos argumentos se conocen en la etapa de compilación, el tipo del resultado devuelto también se muestra sin ambigüedades, ya que la función zero(x) devuelve un valor cero del mismo tipo que su argumento (y dividir por cero, de acuerdo con IEEE 754 , tiene un valor perfectamente representable en el formato de números de punto flotante).

Las funciones pueden tener un número fijo de argumentos posicionales, argumentos posicionales con valores predeterminados, argumentos con nombre y un número variable de argumentos. Sintaxis:

 #    function hello(name) println("Hello, ", name) end #      #           function greeting_d(name, greeting = "Hello") println(greeting, ", ", name) end #       #          #       function greeting_kw(name; greeting = "Hello") println(greeting, ", ", name) end #  greeting   ,        function greeting_oblkw(name; greeting) println(greeting, ", ", name) end #      #  ,   ,      names function greeting_nary(greeting, names...) print(greeting) for name in names print(", ", name) end print('\n') end julia> hello("world") Hello, world julia> greeting_d("world") Hello, world julia> greeting_d("Mr. Smith", "How do you do") How do you do, Mr. Smith julia> greeting_kw("Mr. Smith") Hello, Mr. Smith julia> greeting_kw("mom", greeting = "Hi") Hi, mom julia> greeting_oblkw("world") ERROR: UndefKeywordError: keyword argument greeting not assigned Stacktrace: [1] greeting_oblkw(::String) at ./REPL[23]:3 [2] top-level scope at none:0 julia> greeting_oblkw("mom", greeting = "Hi") Hi, mom julia> greeting_nary("Hi", "mom", "dad", "everyone") Hi, mom, dad, everyone 

Nivel 2. Funciones como datos


El nombre de la función puede usarse no solo en llamadas directas, sino también como un identificador con el que está asociado el procedimiento para obtener el valor. Por ejemplo:

 function f_x_x(fn, x) fn(x, x) end julia> f_x_x(+, 3) 6 # +(3, 3) = 3+3 = 6 julia> f_x_x(*, 3) 9 # *(3, 3) = 9 julia> f_x_x(^, 3) 27 # ^(3, 3) = 3^3 = 27 julia> f_x_x(log, 3) 1.0 # log(3, 3) = 1 

Las funciones "clásicas" que toman un argumento funcional son map , reduce y filter .

map(f, x...) aplica la función f a los valores de todos los elementos de x (o tuplas de i-elements) y devuelve los resultados como una nueva colección:

 julia> map(cos, [0, π/3, π/2, 2*π/3, π]) 5-element Array{Float64,1}: 1.0 0.5000000000000001 6.123233995736766e-17 -0.4999999999999998 -1.0 julia> map(+, (2, 3), (1, 1)) (3, 4) 

reduce(f, x; init_val) "reduce" la colección a un valor, "expande" la cadena f(f(...f(f(init_val, x[1]), x[2])...), x[end]) :

 function myreduce(fn, values, init_val) accum = init_val for x in values accum = fn(accum, x) end accum end 

Como no se determina realmente en qué orden pasará la matriz durante la reducción, o si se fn(accum, x) o fn(x, accum) , la reducción dará un resultado predecible solo con operadores conmutativos o asociativos, como la suma o multiplicación

filter(predicate, x) devuelve una matriz de elementos x que satisfacen el predicado predicate :

 julia> filter(isodd, 1:10) 5-element Array{Int64,1}: 1 3 5 7 9 julia> filter(iszero, [[0], 1, 0.0, 1:-1, 0im]) 4-element Array{Any,1}: [0] 0.0 1:0 0 + 0im 

El uso de funciones de orden superior para operaciones en matrices en lugar de escribir un bucle tiene varias ventajas:

  1. el código se está acortando
  2. map() o reduce() muestran la semántica de la operación que se está realizando, entonces aún necesita comprender la semántica de lo que está sucediendo en el bucle
  3. map() permite al compilador comprender que las operaciones en los elementos de la matriz son independientes de los datos, lo que permite aplicar optimizaciones adicionales

Nivel 3. Funciones como abstracciones


A menudo, en map() o filter() necesita usar una función que no tiene asignado su propio nombre. Julia en este caso le permite expresar la abstracción de las operaciones sobre el argumento, sin ingresar su propio nombre para esta secuencia. Tal abstracción se llama función anónima o función lambda (ya que en la tradición matemática tales funciones se denotan con la letra lambda). La sintaxis para esta vista es:

 #   square(x) = x^2 #   x -> x^2 #   hypot(a, b) = sqrt(x^2 + y^2) #   -    ,    , #              (x, y) -> sqrt(x^2 + y^2) #   fortytwo() = 42 #   () -> 42 julia> map(i -> map(x -> x^i, 1:5), 1:5) 5-element Array{Array{Int64,1},1}: [1, 2, 3, 4, 5] [1, 4, 9, 16, 25] [1, 8, 27, 64, 125] [1, 16, 81, 256, 625] [1, 32, 243, 1024, 3125] 

Tanto las funciones con nombre como las anónimas se pueden asignar a variables y devolver como valores:

 julia> double_squared = x -> (2 * x)^2 #17 (generic function with 1 method) julia> double_squared(5) 100 

Alcance variable y cierres léxicos


Normalmente, intentan escribir funciones de tal manera que todos los datos necesarios para el cálculo se obtengan a través de argumentos formales, es decir. los nombres de variables que aparecen en el cuerpo son los nombres de argumentos formales o los nombres de variables introducidas dentro del cuerpo de la función.

 function normal(x, y) z = x + y x + y * z end function strange(x, y) x + y * z end 

Sobre la función normal() , podemos decir que en su cuerpo todos los nombres de variables están relacionados , es decir si en todas partes (incluida la lista de argumentos) reemplazamos "x" con "m" (o cualquier otro identificador), "y" con "n" y "z" con "sum_of_m_and_n", el significado de la expresión no cambiará. En la función strange() , el nombre z no está relacionado , es decir a) el significado puede cambiar si este nombre se reemplaza por otro, y b) la exactitud de la función depende de si una variable con el nombre "z" se definió en el momento en que se llamó a la función.

En términos generales, la función normal() tampoco es tan limpia:

  1. ¿Qué sucede si una variable llamada z se define fuera de la función?
  2. Los caracteres + y *, de hecho, también son identificadores no relacionados.

Con el punto 2, no se puede hacer nada más que estar de acuerdo: es lógico que existan definiciones de todas las funciones utilizadas en el sistema, y ​​esperamos que su significado real se corresponda con nuestras expectativas.

El punto 1 es menos obvio de lo que parece. El hecho es que la respuesta depende de dónde se define la función. Si se define globalmente, entonces z dentro de normal() será una variable local, es decir incluso si hay una variable global z su valor no se sobrescribirá. Si la definición de la función está dentro del bloque de código, entonces si hay una definición anterior de z en este bloque, se cambiará el valor de la variable externa.

Si el cuerpo de la función contiene el nombre de una variable externa, este nombre está asociado con el valor que existía en el entorno donde se creó la función. Si la función misma se exporta desde este entorno (por ejemplo, si se devuelve desde otra función como un valor), entonces "captura" la variable del entorno interno, que ya no es accesible en el nuevo entorno. Esto se llama cierre léxico.

Los cierres son principalmente útiles en dos situaciones: cuando necesita crear una función de acuerdo con los parámetros dados y cuando necesita una función que tenga algún estado interno.

Considere la situación con una función que encapsula un estado interno:

 function f_with_counter(fn) call_count = 0 ncalls() = call_count # invoke()  ,     #    ,  ncalls() function invoke(args...) call_count += 1 fn(args...) end #         # call_count     , #   invoke()  call_count()        (call = invoke, call_count = ncalls) end julia> abscount = f_with_counter(abs) (call = getfield(Main, Symbol("#invoke#22")){typeof(abs)}(abs, Core.Box(0)), call_count = getfield(Main, Symbol("#ncalls#21"))(Core.Box(0))) julia> abscount.call_count() 0 julia> abscount.call(-20) 20 julia> abscount.call_count() 1 julia> abscount.call(im) 1.0 julia> abscount.call_count() 2 

Estudio de caso: todos los mismos polinomios


En un artículo anterior, se considera la presentación de polinomios como estructuras. En particular, una de las estructuras de almacenamiento es una lista de coeficientes, comenzando por el más joven. Para calcular el polinomio p en el punto x propuso llamar a la función evpoly(p, x) , que calcula el polinomio según el esquema de Horner.

Código de definición completa
 abstract type AbstractPolynomial end """ 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}}) coeff = isempty(v) ? (0.0,) : tuple([Float64(x) for x in v]...) 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}}} 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] for i = 2:N for j = 1:(i-1) coeff[i] = (coeff[j] - coeff[i]) / (xsample[j] - xsample[i]) end end new(N-1, ntuple(i -> Float64(xsample[i]), N), tuple(coeff...)) end end function InterpPolynomial(fn, xsample::T) where {T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}}} InterpPolynomial(xsample, map(fn, xsample)) end 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 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 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 


La representación de un polinomio en forma de estructura no corresponde totalmente a su comprensión intuitiva como función matemática. Pero al devolver el valor funcional, los polinomios también se pueden especificar directamente como funciones. Entonces fue:

 struct Polynomial 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 """ 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 

Transformamos esta definición en una función que toma una matriz / tupla de coeficientes y devuelve la función real que calcula el polinomio:
 function Polynomial_as_closure(v::T where T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}}) #     /     P(x) ≡ 0 if isempty(v) return x::Real -> 0.0 end coeff = tuple(map(float, v)...) degree = length(coeff) - 1 function evpoly(z::Real) ans = coeff[end] for idx = degree:-1:1 ans = coeff[idx] + z * ans end return ans end evpoly end julia> p = Polynomial_as_closure((0, 1, 1)) # x² + x (::getfield(Main, Symbol("#evpoly#28")){Tuple{Float64,Float64,Float64},Int64}) (generic function with 1 method) julia> p(1) # ,    evpoly()! 2.0 julia> p(11) 132.0 

Del mismo modo, puede escribir una función para el polinomio de interpolación.

Una pregunta importante: ¿hubo algo que se perdió en la nueva definición de la definición anterior? Desafortunadamente, sí, establecer el polinomio como una estructura dio pistas para el compilador, y para nosotros, la capacidad de sobrecargar operadores aritméticos para esta estructura. Por desgracia, Julia no proporciona funciones de un sistema de tipos tan poderoso.

Afortunadamente, en este caso podemos aprovechar lo mejor de ambos mundos, ya que Julia le permite crear las llamadas estructuras invocables. Es decir Puede especificar un polinomio como estructura, ¡pero puede llamarlo como una función! A las definiciones de estructuras del artículo anterior solo necesita agregar:

 function (p::Polynomial)(z::Real) evpoly(p, z) end function (p::InterpPolynomial)(z::Real) evpoly(p, z) end 

Usando argumentos funcionales, también puede agregar un constructor externo de un polinomio de interpolación para una determinada función construida a partir de un conjunto de puntos:

 function InterpPolynomial(fn, xsample::T) where {T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}}} InterpPolynomial(xsample, map(fn, xsample)) end 

Verificamos la definición
 julia> psin = InterpPolynomial(sin, [0, π/6, π/2, 5*π/6, π]) #   InterpPolynomial(4, (0.0, 0.5235987755982988, 1.5707963267948966, 2.6179938779914944, 3.141592653589793), (0.0, 0.954929658551372, -0.30396355092701327, -0.05805276197975913, 0.036957536116863636)) julia> pcos = InterpPolynomial(cos, [0, π/6, π/2, 5*π/6, π]) #   InterpPolynomial(4, (0.0, 0.5235987755982988, 1.5707963267948966, 2.6179938779914944, 3.141592653589793), (1.0, -0.2558726308373678, -0.36358673785585766, 0.1388799037738005, 5.300924469105863e-17)) julia> psum = pcos + psin InterpPolynomial(4, (3.141592653589793, 2.5416018461576297, 1.5707963267948966, 0.5999908074321635, 0.0), (-1.0, -1.2354929267138448, 0.03888175053443867, 0.1969326657535598, 0.03695753611686364)) julia> for x = range(0, π, length = 20) println("Error at x = ", x, ": ", abs(psum(x) - (sin(x) + cos(x)))) end Error at x = 0.0: 0.0 Error at x = 0.3490658503988659: 0.002748366490382681 Error at x = 0.6981317007977318: 0.0031870524474437723 Error at x = 1.0471975511965976: 0.006538414090220712 Error at x = 1.3962634015954636: 0.0033647273630357244 Error at x = 1.7453292519943295: 0.003570894863996865 Error at x = 2.0943951023931953: 0.007820939854677023 Error at x = 2.443460952792061: 0.004305934583281101 Error at x = 2.792526803190927: 0.00420977797025246 Error at x = 3.141592653589793: 1.1102230246251565e-16 


Conclusión


Las posibilidades tomadas de la programación funcional en Julia dan un lenguaje más expresivo en comparación con un estilo puramente imperativo. La representación de estructuras en forma de funciones es una forma de registro más conveniente y natural de conceptos matemáticos.

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


All Articles