Julia encourage à mettre autant de code que possible dans des fonctions qui peuvent être compilées à la volée (JIT), et créer de nombreuses petites fonctions est, par conception, performant.
Cela a tendance à laisser beaucoup de petites fonctions simples, qu'il faut ensuite combiner pour accomplir des tâches non triviales.
Une approche évidente consiste à imbriquer les appels de fonctions. L'exemple suivant est très artificiel, mais il illustre le propos.
julia> first.(titlecase.(reverse.(["my", "test", "strings"])))
3-element Vector{Char}:
'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)
L'inconvénient de cette approche est que la lisibilité chute rapidement à mesure que l'imbrication s'approfondit.
Il nous faut une approche plus simple et plus flexible.
C'est la technique chère aux mathématiciens, et Julia en reprend la syntaxe mathématique.
Un nombre arbitraire de fonctions peut être composed à l'aide d'opérateurs ∘ (que l'on saisit avec \circ puis tabulation).
Le résultat peut être utilisé comme une seule fonction.
julia> compfunc = first ∘ titlecase ∘ reverse
first ∘ titlecase ∘ reverse
julia> compfunc.(["my", "test", "strings"])
3-element Vector{Char}:
'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)
# alternative syntax, giving the same result
julia> (first ∘ titlecase ∘ reverse).(["my", "test", "strings"])
Quelques points à noter :
On peut aussi y voir l'approche des programmeurs, plutôt que celle des mathématiciens.
Les Pipelines sont utilisés depuis longtemps dans les scripts shell Unix, et sont devenus plus récemment populaires dans les langages de programmation grand public (on attribue parfois à F# leur adoption pionnière).
Le principe de base est de partir de quelques données, puis de les faire passer au travers d'une séquence de fonctions pour obtenir le résultat.
L'opérateur pipe est |> (comme en F# et dans les versions récentes de R), mais Julia dispose aussi d'une version broadcasting .|>.
julia> ["my", "test", "strings"] .|> reverse .|> titlecase .|> first
3-element Vector{Char}:
'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)
L'exécution se fait désormais strictement de gauche à droite, la sortie de chaque fonction circulant dans le sens de la flèche pour devenir l'entrée de la fonction suivante.
Ce n'est pas un hasard si les fonctions utilisées pour illustrer la composition et l'enchaînement avec des pipes prennent toutes un seul argument.
Certains langages purement fonctionnels font passer le premier argument dans une fonction, tout en permettant d'y inclure d'autres.
En revanche, Julia n'attend que des noms de fonctions (ou leur équivalent) dans un pipeline, sans aucun argument supplémentaire.
Cela répond à d'importantes raisons techniques (liées au fait que le currying n'est pas un élément standard de la conception du langage).
Les nombreuses personnes qui ne connaissent rien au currying doivent simplement accepter que cette limitation n'est pas un oubli négligent, et qu'elle ne devrait pas changer dans les futures versions de Julia.
On a besoin de fonctions à un seul argument qui font tout ce qu'il faut. Heureusement, définir de nouvelles fonctions en Julia est facile.
Le plus simple est d'utiliser une anonymous function.
Par exemple, si l'on a une seule string en entrée et qu'on veut la découper sur les tirets bas :
julia> "my_test_strings" |> (s -> split(s, '_'))
3-element Vector{SubString{String}}:
"my"
"test"
"strings"
Ce tableau peut ensuite être passé à d'autres fonctions via un pipe, comme précédemment.
Mettre la fonction anonyme entre parenthèses est facultatif dans ce cas, mais c'est de manière plus générale un bon moyen de réduire l'ambiguïté.
De même, on peut créer une fonction nommée, plus tôt dans le programme, et la réutiliser au besoin.
Les Closures dépassent le cadre de ce concept, mais quiconque les connaît d'autres langages reconnaîtra qu'elles offrent un moyen plus flexible de créer des fonctions à un seul argument.
julia> function makesplit(sep)
fs(str) = split(str, sep)
fs
end
makesplit (generic function with 1 method)
julia> f_us = makesplit('_')
(::var"#fs#32"{Char}) (generic function with 1 method)
julia> "my_test_strings" |> f_us
3-element Vector{SubString{String}}:
"my"
"test"
"strings"
# alternatively:
julia> "my_test_strings" |> makesplit('_')
3-element Vector{SubString{String}}:
"my"
"test"
"strings"
Une fois makesplit() définie, elle peut servir avec n'importe quel séparateur.
Remarque que makesplit('_') est un appel de fonction qui donne une autre fonction, laquelle reçoit à son tour l'entrée du pipe.
Si cela te semble confus, c'est normal au début (mais ça devient plus clair avec la pratique).
Une longue discussion a eu lieu pour rendre les pipes plus polyvalents dans Julia de base, mais les diverses suggestions sont mutuellement incompatibles et aucun accord n'a été trouvé.
En attendant, les utilisateurs et utilisatrices ont opté pour la solution habituelle : créer divers paquets installables qui répondent à des besoins précis. Aucun ne fonctionnera dans Exercism, mais jette un œil à ceux-ci si ça t'intéresse :