Malgré le fait que le concept de Julia manque de programmation orientée objet «classique» avec des classes et des méthodes, le langage fournit des outils d'abstraction, un rôle clé dans lequel sont joués le système de types et les éléments de la programmation fonctionnelle. Examinons le deuxième point plus en détail.
Le concept de fonctions dans Julia est probablement le plus similaire aux langages de la famille Lisp (pour être plus précis, les branches Lisp-1), et les fonctions peuvent être considérées à trois niveaux: en tant que sous-programmes, en tant qu'abstractions à une certaine séquence d'actions, et en tant que données représentant cette abstraction .
Niveau 1. Fonctionne comme des routines
L'attribution de sous-programmes et l'attribution de leurs propres noms se poursuivent depuis la préhistoire, lorsque Fortran était considéré comme un langage de haut niveau et que C n'était pas encore là.
En ce sens, les produits Julia sont standard. La «caractéristique» peut être appelée le fait que syntaxiquement il n'y a pas de division en procédures et fonctions. Que la sous-routine soit appelée pour obtenir une valeur ou simplement pour effectuer une action sur les données, elle est appelée fonction.
La définition d'une fonction commence par le mot clé
function
, suivie d'une liste d'arguments, d'une séquence de commandes entre crochets, et le mot
end
termine la définition:
""" 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 syntaxe se distingue par le comportement hérité de Lisp: pour un retour "normal" d'une valeur d'une fonction, le mot
return
pas nécessaire: la valeur de la dernière expression calculée avant
end
retournée. Dans l'exemple ci-dessus, la valeur de la
sum
variable sera retournée. Ainsi,
return
peut être utilisé comme marqueur du comportement spécial d'une fonction:
function safe_division(number, divisor) if divisor == 0 return 0 end number / divisor end
Pour les fonctions avec une définition courte, il existe une syntaxe raccourcie similaire à une notation mathématique. Ainsi, le calcul de la longueur de l'hypoténuse le long de la longueur des jambes peut être défini comme suit:
hypotenuse(a, b) = sqrt(a^2 + b^2)
La division «sûre» utilisant l'opérateur ternaire peut s'écrire comme suit:
safe_division(number, divisor) = divisor == 0 ? 0 : number / divisor
Comme vous pouvez le voir, il n'est pas nécessaire de spécifier des types pour les arguments de fonction. Étant donné le fonctionnement du compilateur Julia JIT, le typage canard n'entraînera pas toujours de mauvaises performances.
Comme j'ai essayé de le démontrer dans un
article précédent , le compilateur Julia peut déduire le type du résultat de retour par les types d'arguments d'entrée. Par conséquent, par exemple, la fonction
safe_division
nécessite une modification minimale pour un fonctionnement rapide:
function safe_division(number, divisor) if divisor == 0 return zero(number / divisor) end number / divisor end
Maintenant, si les types des deux arguments sont connus au stade de la compilation, le type du résultat renvoyé est également affiché sans ambiguïté, car la fonction
zero(x)
renvoie une valeur nulle du même type que son argument (et divisée par zéro, selon
IEEE 754 , a une valeur parfaitement représentable au format de nombres à virgule flottante).
Les fonctions peuvent avoir un nombre fixe d'arguments positionnels, des arguments positionnels avec des valeurs par défaut, des arguments nommés et un nombre variable d'arguments. Syntaxe:
Niveau 2. Fonctionne comme données
Le nom de la fonction peut être utilisé non seulement dans les appels directs, mais aussi comme identifiant auquel est associée la procédure d'obtention de la valeur. Par exemple:
function f_x_x(fn, x) fn(x, x) end julia> f_x_x(+, 3) 6
Les fonctions "classiques" qui prennent un argument fonctionnel sont
map
,
reduce
et
filter
.
map(f, x...)
applique la fonction
f
aux valeurs de tous les éléments de
x
(ou des tuples d'i-éléments) et retourne les résultats comme une nouvelle collection:
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)
"réduit" la collection à une seule valeur, "étendant" la chaîne
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
Puisqu'il n'est pas vraiment déterminé dans quel ordre le tableau passera pendant la réduction, ou si
fn(accum, x)
ou
fn(x, accum)
sera
fn(x, accum)
, la réduction ne donnera un résultat prévisible qu'avec des opérateurs commutatifs ou associatifs, tels que l'addition ou la multiplication.
filter(predicate, x)
renvoie un tableau d'éléments
x
qui satisfont le prédicat de
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
L'utilisation de fonctions d'ordre supérieur pour les opérations sur les tableaux au lieu d'écrire une boucle présente plusieurs avantages:
- le code se raccourcit
map()
ou reduce()
montre la sémantique de l'opération en cours, alors vous devez toujours comprendre la sémantique de ce qui se passe dans la bouclemap()
permet au compilateur de comprendre que les opérations sur les éléments du tableau sont indépendantes des données, ce qui permet d'appliquer des optimisations supplémentaires
Niveau 3. Fonctionne comme abstractions
Souvent dans
map()
ou
filter()
vous devez utiliser une fonction à laquelle aucun nom n'a été attribué. Dans ce cas, Julia vous permet d'exprimer l'
abstraction des opérations sur l'argument, sans entrer votre propre nom pour cette séquence. Une telle abstraction est appelée
une fonction anonyme , ou une
fonction lambda (car dans la tradition mathématique, ces fonctions sont désignées par la lettre lambda). La syntaxe de cette vue est:
Les fonctions nommées et anonymes peuvent être affectées à des variables et renvoyées sous forme de valeurs:
julia> double_squared = x -> (2 * x)^2
Portée variable et fermetures lexicales
Normalement, ils essaient d'écrire des fonctions de telle manière que toutes les données nécessaires au calcul soient obtenues par des arguments formels, c'est-à-dire tout nom de variable apparaissant dans le corps est soit le nom des arguments formels, soit le nom des variables introduites dans le corps de la fonction.
function normal(x, y) z = x + y x + y * z end function strange(x, y) x + y * z end
À propos de la fonction
normal()
, nous pouvons dire que dans son corps tous les noms de variables sont
liés , c'est-à-dire si nous partout (y compris la liste d'arguments) remplaçons «x» par «m» (ou tout autre identifiant), «y» par «n» et «z» par «sum_of_m_and_n», la signification de l'expression ne changera pas. Dans la fonction
strange()
, le nom z n'est
pas lié , c'est-à-dire a) la signification peut changer si ce nom est remplacé par un autre, et b) l'exactitude de la fonction dépend du fait qu'une variable portant le nom «z» a été définie au moment de l'appel de la fonction.
De manière générale, la fonction
normal()
n'est pas aussi propre:
- Que se passe-t-il si une variable nommée z est définie en dehors de la fonction?
- En fait, les caractères + et * sont également des identificateurs indépendants.
Avec le point 2, rien ne peut être fait que d'être d'accord - il est logique que les définitions de toutes les fonctions utilisées dans le système existent, et nous espérons que leur véritable sens correspond à nos attentes.
Le point 1 est moins évident qu'il n'y paraît. Le fait est que la réponse dépend de l'endroit où la fonction est définie. S'il est défini globalement, alors
z
à
normal()
intérieur de
normal()
sera une variable locale, c'est-à-dire même s'il existe une variable globale
z
sa valeur ne sera pas écrasée. Si la définition de la fonction est à l'intérieur du bloc de code, alors s'il existe une définition antérieure de
z
dans ce bloc, ce sera la valeur de la variable externe qui sera modifiée.
Si le corps de la fonction contient le nom d'une variable externe, ce nom est associé à la valeur qui existait dans l'environnement dans lequel la fonction a été créée. Si la fonction elle-même est exportée de cet environnement (par exemple, si elle est renvoyée d'une autre fonction en tant que valeur), alors elle «capture» la variable de l'environnement interne, qui n'est plus accessible dans le nouvel environnement. C'est ce qu'on appelle la fermeture lexicale.
Les fermetures sont principalement utiles dans deux situations: lorsque vous devez créer une fonction en fonction des paramètres donnés et lorsque vous avez besoin d'une fonction ayant un état interne.
Considérez la situation avec une fonction qui encapsule un état interne:
function f_with_counter(fn) call_count = 0 ncalls() = call_count
Etude de cas: tous les mêmes polynômes
Dans un
article précédent, la présentation des polynômes en tant que structures est considérée. En particulier, l'une des structures de stockage est une liste de coefficients, en commençant par les plus jeunes. Pour calculer le polynôme
p
au point
x
proposé d'appeler la fonction
evpoly(p, x)
, qui calcule le polynôme selon le schéma de Horner.
Code pleine définition 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 représentation d'un polynôme sous la forme d'une structure ne correspond pas entièrement à sa compréhension intuitive en tant que fonction mathématique. Mais en renvoyant la valeur fonctionnelle, les polynômes peuvent également être spécifiés directement en tant que fonctions. C'était donc:
struct Polynomial degree::Int coeff::NTuple{N, Float64} where N function Polynomial(v::T where T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}})
Nous transformons cette définition en une fonction qui prend un tableau / tuple de coefficients et renvoie la fonction réelle qui calcule le polynôme:
function Polynomial_as_closure(v::T where T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}})
De même, vous pouvez écrire une fonction pour le polynôme d'interpolation.
Une question importante: y avait-il quelque chose qui était perdu dans la nouvelle définition de la définition précédente? Malheureusement, oui - définir le polynôme comme une structure a donné des indications pour le compilateur, et pour nous, la capacité de surcharger les opérateurs arithmétiques pour cette structure. Hélas, Julia ne prévoit pas les fonctions d'un système de type aussi puissant.
Heureusement, dans ce cas, nous pouvons prendre le meilleur des deux mondes, car Julia vous permet de créer des structures appelables. C'est-à-dire Vous pouvez spécifier un polynôme comme structure, mais pouvoir l'appeler comme fonction! Aux définitions des structures de l'article précédent, il suffit d'ajouter:
function (p::Polynomial)(z::Real) evpoly(p, z) end function (p::InterpPolynomial)(z::Real) evpoly(p, z) end
À l'aide d'arguments fonctionnels, vous pouvez également ajouter un constructeur externe d'un polynôme d'interpolation pour une certaine fonction construite à partir d'un ensemble de points:
function InterpPolynomial(fn, xsample::T) where {T<:Union{Vector{<:Real}, NTuple{<:Any, <:Real}}} InterpPolynomial(xsample, map(fn, xsample)) end
Nous vérifions la définition julia> psin = InterpPolynomial(sin, [0, π/6, π/2, 5*π/6, π])
Conclusion
Les opportunités empruntées à la programmation fonctionnelle dans Julia donnent un langage plus expressif par rapport à un style purement impératif. La représentation des structures sous forme de fonctions est un moyen d'enregistrement plus pratique et naturel des concepts mathématiques.