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
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:
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
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:
- el código se está acortando
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 buclemap()
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:
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
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:
- ¿Qué sucede si una variable llamada z se define fuera de la función?
- 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
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)
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}})
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}})
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, π])
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.