Apesar do conceito de Julia carecer de programação orientada a objetos “clássica” com classes e métodos, a linguagem fornece ferramentas de abstração, um papel fundamental desempenhado pelo sistema de tipos e pelos elementos da programação funcional. Vamos considerar o segundo ponto em mais detalhes.
O conceito de funções em Julia é provavelmente o mais semelhante aos idiomas da família Lisp (para ser mais preciso, os ramos Lisp-1), e as funções podem ser consideradas em três níveis: como subprogramas, como abstrações para uma determinada sequência de ações e como dados que representam essa abstração. .
Nível 1. Funções como rotinas
A alocação de subprogramas e a atribuição de nomes próprios vem ocorrendo desde os tempos pré-históricos, quando o Fortran era considerado um idioma de alto nível e C ainda não existia.
Nesse sentido, os produtos Julia são padrão. Pode-se chamar “recurso” o fato de que sintaticamente não há divisão em procedimentos e funções. Independentemente de a sub-rotina ser chamada para obter algum valor ou apenas para executar alguma ação nos dados, ela é chamada de função.
A definição de uma função começa com a palavra-chave
function
, seguida por uma lista de argumentos, uma sequência de comandos entre colchetes e a palavra
end
termina a definição:
""" 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
A sintaxe é diferenciada pelo comportamento herdado do Lisp: para um retorno "normal" de um valor de uma função, a palavra
return
não
return
necessária: o valor da última expressão calculada antes do
end
retorno. No exemplo acima, o valor da variável
sum
será retornado. Assim, o
return
pode ser usado como um marcador do comportamento especial de uma função:
function safe_division(number, divisor) if divisor == 0 return 0 end number / divisor end
Para funções com uma definição curta, há uma sintaxe abreviada semelhante a uma notação matemática. Portanto, o cálculo do comprimento da hipotenusa ao longo do comprimento das pernas pode ser definido da seguinte forma:
hypotenuse(a, b) = sqrt(a^2 + b^2)
A divisão "segura" usando o operador ternário pode ser escrita da seguinte maneira:
safe_division(number, divisor) = divisor == 0 ? 0 : number / divisor
Como você pode ver, não é necessário especificar tipos para argumentos de função. Dado como o compilador Julia JIT funciona, a digitação com patos nem sempre resulta em baixo desempenho.
Como tentei demonstrar em um
artigo anterior , o compilador Julia pode inferir o tipo do resultado do retorno pelos tipos de argumentos de entrada. Portanto, por exemplo, a função
safe_division
requer modificações mínimas para operação rápida:
function safe_division(number, divisor) if divisor == 0 return zero(number / divisor) end number / divisor end
Agora, se os tipos de ambos os argumentos forem conhecidos no estágio de compilação, o tipo do resultado retornado também será exibido sem ambiguidade, pois a função
zero(x)
retorna um valor zero do mesmo tipo que seu argumento (e a divisão por zero, de acordo com a
IEEE 754 , tem um valor perfeitamente representável no formato de números de ponto flutuante).
As funções podem ter um número fixo de argumentos posicionais, argumentos posicionais com valores padrão, argumentos nomeados e um número variável de argumentos. Sintaxe:
Nível 2. Funções como Dados
O nome da função pode ser usado não apenas em chamadas diretas, mas também como um identificador ao qual o procedimento para obter o valor está associado. Por exemplo:
function f_x_x(fn, x) fn(x, x) end julia> f_x_x(+, 3) 6
As funções "clássicas" que usam um argumento funcional são
map
,
reduce
e
filter
.
map(f, x...)
aplica a função
f
aos valores de todos os elementos de
x
(ou tuplas de elementos i) e retorna os resultados como uma nova coleção:
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)
"reduz" a coleção para um único valor, "expandindo" a cadeia
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 não é realmente determinado em que ordem a matriz passará durante a redução ou se
fn(accum, x)
ou
fn(x, accum)
serão
fn(x, accum)
, a redução fornecerá um resultado previsível apenas com operadores comutativos ou associativos, como adição ou multiplicação.
filter(predicate, x)
retorna uma matriz de
x
elementos que satisfazem o 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
O uso de funções de ordem superior para operações em matrizes em vez de gravar um loop tem várias vantagens:
- o código está ficando mais curto
map()
ou reduce()
mostram a semântica da operação que está sendo executada, então você ainda precisa entender a semântica do que está acontecendo no loopmap()
permite que o compilador entenda que as operações nos elementos da matriz são independentes dos dados, o que permite que otimizações adicionais sejam aplicadas
Nível 3. Funções como Abstrações
Frequentemente, no
map()
ou no
filter()
você precisa usar uma função que não recebeu seu próprio nome. Julia, nesse caso, permite expressar a
abstração das operações no argumento, sem inserir seu próprio nome para esta sequência. Essa abstração é chamada de
função anônima ou
função lambda (uma vez que, na tradição matemática, essas funções são denotadas pela letra lambda). A sintaxe para esta visualização é:
As funções nomeadas e anônimas podem ser atribuídas a variáveis e retornadas como valores:
julia> double_squared = x -> (2 * x)^2
Escopo variável e fechamentos lexicais
Normalmente, eles tentam escrever funções de forma que todos os dados necessários para o cálculo sejam obtidos através de argumentos formais, ou seja, quaisquer nomes de variáveis que ocorrem no corpo são os nomes dos argumentos formais ou os nomes das variáveis introduzidas no corpo da função.
function normal(x, y) z = x + y x + y * z end function strange(x, y) x + y * z end
Sobre a função
normal()
, podemos dizer que em seu corpo todos os nomes de variáveis estão
relacionados , ou seja, se em qualquer lugar (incluindo a lista de argumentos) substituirmos "x" por "m" (ou qualquer outro identificador), "y" por "n" e "z" por "sum_of_m_and_n", o significado da expressão não será alterado. Na função
strange()
, o nome z
não é
relacionado , ou seja, a) o significado pode mudar se esse nome for substituído por outro eb) a correção da função depende se uma variável com o nome “z” foi definida no momento em que a função foi chamada.
De um modo geral, a função
normal()
também não é tão limpa:
- O que acontece se uma variável chamada z for definida fora da função?
- Os caracteres + e *, de fato, também são identificadores não relacionados.
Com o ponto 2, nada pode ser feito senão concordar - é lógico que definições de todas as funções usadas no sistema devam existir, e esperamos que seu significado real corresponda às nossas expectativas.
O ponto 1 é menos óbvio do que parece. O fato é que a resposta depende de onde a função está definida. Se for definido globalmente,
z
dentro de
normal()
será uma variável local, ou seja, mesmo se houver uma variável global
z
seu valor não será substituído. Se a definição da função estiver dentro do bloco de código, se houver uma definição anterior de
z
nesse bloco, será o valor da variável externa que será alterada.
Se o corpo da função contiver o nome de uma variável externa, esse nome será associado ao valor que existia no ambiente em que a função foi criada. Se a função em si for exportada desse ambiente (por exemplo, se ela for retornada de outra função como um valor), ela "captura" a variável do ambiente interno, que não está mais acessível no novo ambiente. Isso é chamado de fechamento lexical.
Os fechamentos são úteis principalmente em duas situações: quando você precisa criar uma função de acordo com os parâmetros fornecidos e quando você precisa de uma função que tenha algum estado interno.
Considere a situação com uma função que encapsula um estado interno:
function f_with_counter(fn) call_count = 0 ncalls() = call_count
Estudo de caso: todos os mesmos polinômios
Em um
artigo anterior, a apresentação de polinômios como estruturas é considerada. Em particular, uma das estruturas de armazenamento é uma lista de coeficientes, começando pelos mais jovens. Para calcular o polinômio
p
no ponto
x
proposta a função
evpoly(p, x)
, que calcula o polinômio de acordo com o esquema de Horner.
Código de definição 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)
A representação de um polinômio na forma de uma estrutura não corresponde totalmente à sua compreensão intuitiva como uma função matemática. Porém, retornando o valor funcional, os polinômios também podem ser especificados diretamente como funções. Então foi:
struct Polynomial degree::Int coeff::NTuple{N, Float64} where N function Polynomial(v::T where T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}})
Transformamos essa definição em uma função que pega uma matriz / tupla de coeficientes e retorna a função real que calcula o polinômio:
function Polynomial_as_closure(v::T where T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}})
Da mesma forma, você pode escrever uma função para o polinômio de interpolação.
Uma pergunta importante: havia algo que se perdeu na nova definição da definição anterior? Infelizmente, sim - definir o polinômio como uma estrutura deu dicas para o compilador e, para nós, a capacidade de sobrecarregar operadores aritméticos para essa estrutura. Infelizmente, Julia não fornece funções para um sistema de tipos tão poderoso.
Felizmente, nesse caso, podemos tirar o melhor dos dois mundos, pois Julia permite que você crie as chamadas estruturas que podem ser chamadas. I.e. Você pode especificar um polinômio como uma estrutura, mas pode chamá-lo como uma função! Para as definições de estruturas do artigo anterior, você só precisa adicionar:
function (p::Polynomial)(z::Real) evpoly(p, z) end function (p::InterpPolynomial)(z::Real) evpoly(p, z) end
Usando argumentos funcionais, você também pode adicionar um construtor externo de um polinômio de interpolação para uma determinada função construída a partir de um conjunto de pontos:
function InterpPolynomial(fn, xsample::T) where {T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}}} InterpPolynomial(xsample, map(fn, xsample)) end
Verificamos a definição julia> psin = InterpPolynomial(sin, [0, π/6, π/2, 5*π/6, π])
Conclusão
As possibilidades emprestadas da programação funcional em Julia fornecem uma linguagem mais expressiva em comparação com um estilo puramente imperativo. A representação de estruturas na forma de funções é uma forma de registro mais conveniente e natural de conceitos matemáticos.