Metaprogramming
L'héritage le plus fort de Lisp dans le langage Julia est son support de la métaprogrammation. Comme Lisp, Julia représente son propre code sous forme de structure de données du langage lui-même. Puisque le code est représenté par des objets qui peuvent être créés et manipulés depuis l'intérieur du langage, il est possible pour un programme de transformer et de générer son propre code. Cela permet une génération de code sophistiquée sans étapes de construction supplémentaires, et permet également de véritables macros de style Lisp opérant au niveau de abstract syntax trees. En revanche, les systèmes de "macro" de préprocesseur, comme ceux de C et C++, effectuent une manipulation et une substitution textuelles avant que toute analyse ou interprétation réelle n'ait lieu. Parce que tous les types de données et le code dans Julia sont représentés par des structures de données Julia, des capacités puissantes reflection sont disponibles pour explorer les internes d'un programme et ses types tout comme n'importe quelle autre donnée.
La métaprogrammation est un outil puissant, mais elle introduit une complexité qui peut rendre le code plus difficile à comprendre. Par exemple, il peut être étonnamment difficile d'obtenir les règles de portée correctes. La métaprogrammation ne devrait généralement être utilisée que lorsque d'autres approches telles que higher order functions et closures ne peuvent pas être appliquées.
eval
et la définition de nouvelles macros devraient généralement être utilisés en dernier recours. Il est presque jamais une bonne idée d'utiliser Meta.parse
ou de convertir une chaîne de caractères arbitraire en code Julia. Pour manipuler le code Julia, utilisez directement la structure de données Expr
pour éviter la complexité de la façon dont la syntaxe Julia est analysée.
Les meilleures utilisations de la métaprogrammation mettent souvent en œuvre la plupart de leur fonctionnalité dans des fonctions d'assistance à l'exécution, s'efforçant de minimiser la quantité de code qu'elles génèrent.
Program representation
Chaque programme Julia commence sa vie en tant que chaîne :
julia> prog = "1 + 1"
"1 + 1"
Que se passe-t-il ensuite ?
La prochaine étape consiste à parse chaque chaîne en un objet appelé une expression, représenté par le type Julia Expr
:
julia> ex1 = Meta.parse(prog)
:(1 + 1)
julia> typeof(ex1)
Expr
Les objets Expr
contiennent deux parties :
- un
Symbol
identifiant le type d'expression. Un symbole est un interned string identifiant (plus de discussion ci-dessous).
julia> ex1.head
:call
- les arguments d'expression, qui peuvent être des symboles, d'autres expressions ou des valeurs littérales :
julia> ex1.args
3-element Vector{Any}:
:+
1
1
Les expressions peuvent également être construites directement dans prefix notation :
julia> ex2 = Expr(:call, :+, 1, 1)
:(1 + 1)
Les deux expressions construites ci-dessus – par analyse et par construction directe – sont équivalentes :
julia> ex1 == ex2
true
Le point clé ici est que le code Julia est représenté en interne comme une structure de données qui est accessible depuis le langage lui-même.
La fonction dump
fournit un affichage indenté et annoté des objets Expr
:
julia> dump(ex2)
Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol +
2: Int64 1
3: Int64 1
Les objets Expr
peuvent également être imbriqués :
julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)
Une autre façon de voir les expressions est avec Meta.show_sexpr
, qui affiche la forme S-expression d'un Expr
donné, qui peut sembler très familière aux utilisateurs de Lisp. Voici un exemple illustrant l'affichage sur un Expr
imbriqué :
julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)
Symbols
Le caractère :
a deux usages syntaxiques en Julia. La première forme crée un Symbol
, un interned string utilisé comme un des éléments de base des expressions, à partir d'identifiants valides :
julia> s = :foo
:foo
julia> typeof(s)
Symbol
Le constructeur Symbol
prend un nombre quelconque d'arguments et crée un nouveau symbole en concaténant leurs représentations sous forme de chaîne ensemble :
julia> :foo === Symbol("foo")
true
julia> Symbol("1foo") # `:1foo` would not work, as `1foo` is not a valid identifier
Symbol("1foo")
julia> Symbol("func",10)
:func10
julia> Symbol(:var,'_',"sym")
:var_sym
Dans le contexte d'une expression, des symboles sont utilisés pour indiquer l'accès aux variables ; lorsqu'une expression est évaluée, un symbole est remplacé par la valeur liée à ce symbole dans le scope.
Parfois, des parenthèses supplémentaires autour de l'argument de :
sont nécessaires pour éviter toute ambiguïté dans l'analyse :
julia> :(:)
:(:)
julia> :(::)
:(::)
Expressions and evaluation
Quoting
Le deuxième but syntaxique du caractère :
est de créer des objets d'expression sans utiliser le constructeur explicite Expr
. Cela est appelé citation. Le caractère :
, suivi de parenthèses appariées autour d'une seule instruction de code Julia, produit un objet Expr
basé sur le code contenu. Voici un exemple de la forme courte utilisée pour citer une expression arithmétique :
julia> ex = :(a+b*c+1)
:(a + b * c + 1)
julia> typeof(ex)
Expr
(pour voir la structure de cette expression, essayez ex.head
et ex.args
, ou utilisez dump
comme ci-dessus ou Meta.@dump
)
Notez que des expressions équivalentes peuvent être construites en utilisant Meta.parse
ou la forme directe Expr
:
julia> :(a + b*c + 1) ==
Meta.parse("a + b*c + 1") ==
Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true
Les expressions fournies par le parseur ont généralement uniquement des symboles, d'autres expressions et des valeurs littérales comme arguments, tandis que les expressions construites par le code Julia peuvent avoir des valeurs d'exécution arbitraires sans formes littérales comme arguments. Dans cet exemple spécifique, +
et a
sont des symboles, *(b,c)
est une sous-expression, et 1
est un entier signé 64 bits littéral.
Il existe une deuxième forme syntaxique de citation pour plusieurs expressions : des blocs de code entourés de quote ... end
.
julia> ex = quote
x = 1
y = 2
x + y
end
quote
#= none:2 =#
x = 1
#= none:3 =#
y = 2
#= none:4 =#
x + y
end
julia> typeof(ex)
Expr
Interpolation
La construction directe d'objets Expr
avec des arguments de valeur est puissante, mais les constructeurs Expr
peuvent être fastidieux par rapport à la syntaxe "normale" de Julia. En alternative, Julia permet l'interpolation de littéraux ou d'expressions dans des expressions citées. L'interpolation est indiquée par un préfixe $
.
Dans cet exemple, la valeur de la variable a
est interpolée :
julia> a = 1;
julia> ex = :($a + b)
:(1 + b)
L'interpolation dans une expression non citée n'est pas supportée et entraînera une erreur de compilation :
julia> $a + b
ERROR: syntax: "$" expression outside quote
Dans cet exemple, le tuple (1,2,3)
est interpolé en tant qu'expression dans un test conditionnel :
julia> ex = :(a in $:((1,2,3)) )
:(a in (1, 2, 3))
L'utilisation de $
pour l'interpolation d'expressions rappelle intentionnellement string interpolation et command interpolation. L'interpolation d'expressions permet une construction programmatique pratique et lisible d'expressions Julia complexes.
Splatting interpolation
Remarquez que la syntaxe d'interpolation $
permet d'insérer uniquement une seule expression dans une expression englobante. Parfois, vous avez un tableau d'expressions et vous avez besoin qu'elles deviennent toutes des arguments de l'expression environnante. Cela peut être fait avec la syntaxe $(xs...)
. Par exemple, le code suivant génère un appel de fonction où le nombre d'arguments est déterminé de manière programmatique :
julia> args = [:x, :y, :z];
julia> :(f(1, $(args...)))
:(f(1, x, y, z))
Nested quote
Naturellement, il est possible que des expressions de citation contiennent d'autres expressions de citation. Comprendre comment l'interpolation fonctionne dans ces cas peut être un peu délicat. Considérons cet exemple :
julia> x = :(1 + 2);
julia> e = quote quote $x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :x))
end))
end
Remarquez que le résultat contient $x
, ce qui signifie que x
n'a pas encore été évalué. En d'autres termes, l'expression $
"appartient" à l'expression de citation intérieure, et donc son argument n'est évalué que lorsque l'expression de citation intérieure est :
julia> eval(e)
quote
#= none:1 =#
1 + 2
end
Cependant, l'expression quote
externe est capable d'interpoler des valeurs à l'intérieur du $
dans la citation interne. Cela se fait avec plusieurs $
:
julia> e = quote quote $$x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :(1 + 2)))
end))
end
Remarquez que (1 + 2)
apparaît maintenant dans le résultat au lieu du symbole x
. L'évaluation de cette expression donne un 3
interpolé :
julia> eval(e)
quote
#= none:1 =#
3
end
L'intuition derrière ce comportement est que x
est évalué une fois pour chaque $
: un $
fonctionne de manière similaire à eval(:x)
, donnant la valeur de x
, tandis que deux $
font l'équivalent de eval(eval(:x))
.
QuoteNode
The usual representation of a quote
form in an AST is an Expr
with head :quote
:
julia> dump(Meta.parse(":(1+2)"))
Expr
head: Symbol quote
args: Array{Any}((1,))
1: Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol +
2: Int64 1
3: Int64 2
Comme nous l'avons vu, de telles expressions prennent en charge l'interpolation avec $
. Cependant, dans certaines situations, il est nécessaire de citer du code sans effectuer d'interpolation. Ce type de citation n'a pas encore de syntaxe, mais est représenté en interne comme un objet de type QuoteNode
:
julia> eval(Meta.quot(Expr(:$, :(1+2))))
3
julia> eval(QuoteNode(Expr(:$, :(1+2))))
:($(Expr(:$, :(1 + 2))))
Le parseur produit des QuoteNode
s pour des éléments cités simples comme des symboles :
julia> dump(Meta.parse(":x"))
QuoteNode
value: Symbol x
QuoteNode
peut également être utilisé pour certaines tâches avancées de métaprogrammation.
Evaluating expressions
Étant donné un objet d'expression, on peut amener Julia à évaluer (exécuter) celui-ci au niveau global en utilisant eval
:
julia> ex1 = :(1 + 2)
:(1 + 2)
julia> eval(ex1)
3
julia> ex = :(a + b)
:(a + b)
julia> eval(ex)
ERROR: UndefVarError: `b` not defined in `Main`
[...]
julia> a = 1; b = 2;
julia> eval(ex)
3
Chaque module a sa propre fonction eval
qui évalue des expressions dans son espace global. Les expressions passées à 4d61726b646f776e2e436f64652822222c20226576616c2229_40726566
ne se limitent pas à retourner des valeurs – elles peuvent également avoir des effets secondaires qui modifient l'état de l'environnement du module englobant :
julia> ex = :(x = 1)
:(x = 1)
julia> x
ERROR: UndefVarError: `x` not defined in `Main`
julia> eval(ex)
1
julia> x
1
Ici, l'évaluation d'un objet d'expression entraîne l'attribution d'une valeur à la variable globale x
.
Puisque les expressions ne sont que des objets Expr
qui peuvent être construits de manière programmatique puis évalués, il est possible de générer dynamiquement du code arbitraire qui peut ensuite être exécuté en utilisant eval
. Voici un exemple simple :
julia> a = 1;
julia> ex = Expr(:call, :+, a, :b)
:(1 + b)
julia> a = 0; b = 2;
julia> eval(ex)
3
La valeur de a
est utilisée pour construire l'expression ex
qui applique la fonction +
à la valeur 1 et à la variable b
. Notez la distinction importante entre la façon dont a
et b
sont utilisés :
- La valeur de la variable
a
au moment de la construction de l'expression est utilisée comme une valeur immédiate dans l'expression. Ainsi, la valeur dea
lorsque l'expression est évaluée n'a plus d'importance : la valeur dans l'expression est déjà1
, indépendamment de la valeur que pourrait avoira
. - D'autre part, le symbole
:b
est utilisé dans la construction d'expressions, donc la valeur de la variableb
à ce moment-là est sans importance –:b
est juste un symbole et la variableb
n'a même pas besoin d'être définie. Cependant, au moment de l'évaluation de l'expression, la valeur du symbole:b
est résolue en recherchant la valeur de la variableb
.
Functions on Expr
essions
As hinted above, one extremely useful feature of Julia is the capability to generate and manipulate Julia code within Julia itself. We have already seen one example of a function returning Expr
objects: the Meta.parse
function, which takes a string of Julia code and returns the corresponding Expr
. A function can also take one or more Expr
objects as arguments, and return another Expr
. Here is a simple, motivating example:
julia> function math_expr(op, op1, op2)
expr = Expr(:call, op, op1, op2)
return expr
end
math_expr (generic function with 1 method)
julia> ex = math_expr(:+, 1, Expr(:call, :*, 4, 5))
:(1 + 4 * 5)
julia> eval(ex)
21
En voici un autre exemple, voici une fonction qui double tout argument numérique, mais laisse les expressions intactes :
julia> function make_expr2(op, opr1, opr2)
opr1f, opr2f = map(x -> isa(x, Number) ? 2*x : x, (opr1, opr2))
retexpr = Expr(:call, op, opr1f, opr2f)
return retexpr
end
make_expr2 (generic function with 1 method)
julia> make_expr2(:+, 1, 2)
:(2 + 4)
julia> ex = make_expr2(:+, 1, Expr(:call, :*, 5, 8))
:(2 + 5 * 8)
julia> eval(ex)
42
Macros
Les macros fournissent un mécanisme pour inclure du code généré dans le corps final d'un programme. Une macro associe un tuple d'arguments à une expression retournée, et l'expression résultante est compilée directement plutôt que de nécessiter un appel eval
à l'exécution. Les arguments de la macro peuvent inclure des expressions, des valeurs littérales et des symboles.
Basics
Voici un macro extraordinairement simple :
julia> macro sayhello()
return :( println("Hello, world!") )
end
@sayhello (macro with 1 method)
Les macros ont un caractère dédié dans la syntaxe de Julia : le @
(signe @), suivi du nom unique déclaré dans un bloc macro NAME ... end
. Dans cet exemple, le compilateur remplacera toutes les instances de @sayhello
par :
:( println("Hello, world!") )
Lorsque @sayhello
est saisi dans le REPL, l'expression s'exécute immédiatement, nous ne voyons donc que le résultat de l'évaluation :
julia> @sayhello()
Hello, world!
Maintenant, considérons un macro légèrement plus complexe :
julia> macro sayhello(name)
return :( println("Hello, ", $name) )
end
@sayhello (macro with 1 method)
Ce macro prend un argument : name
. Lorsque @sayhello
est rencontré, l'expression citée est développée pour interpoler la valeur de l'argument dans l'expression finale :
julia> @sayhello("human")
Hello, human
Nous pouvons voir l'expression de retour citée en utilisant la fonction macroexpand
(note importante : c'est un outil extrêmement utile pour le débogage des macros) :
julia> ex = macroexpand(Main, :(@sayhello("human")) )
:(Main.println("Hello, ", "human"))
julia> typeof(ex)
Expr
Nous pouvons voir que le littéral "human"
a été interpolé dans l'expression.
Il existe également un macro @macroexpand
qui est peut-être un peu plus pratique que la fonction macroexpand
:
julia> @macroexpand @sayhello "human"
:(println("Hello, ", "human"))
Hold up: why macros?
Nous avons déjà vu une fonction f(::Expr...) -> Expr
dans une section précédente. En fait, macroexpand
est également une telle fonction. Alors, pourquoi les macros existent-elles ?
Les macros sont nécessaires car elles s'exécutent lorsque le code est analysé, permettant ainsi au programmeur de générer et d'inclure des fragments de code personnalisé avant que le programme complet ne soit exécuté. Pour illustrer la différence, considérons l'exemple suivant :
julia> macro twostep(arg)
println("I execute at parse time. The argument is: ", arg)
return :(println("I execute at runtime. The argument is: ", $arg))
end
@twostep (macro with 1 method)
julia> ex = macroexpand(Main, :(@twostep :(1, 2, 3)) );
I execute at parse time. The argument is: :((1, 2, 3))
L'appel initial à println
est exécuté lorsque macroexpand
est appelé. L'expression résultante contient uniquement le second println
:
julia> typeof(ex)
Expr
julia> ex
:(println("I execute at runtime. The argument is: ", $(Expr(:copyast, :($(QuoteNode(:((1, 2, 3)))))))))
julia> eval(ex)
I execute at runtime. The argument is: (1, 2, 3)
Macro invocation
Les macros sont invoquées avec la syntaxe générale suivante :
@name expr1 expr2 ...
@name(expr1, expr2, ...)
Notez le @
distinct avant le nom de la macro et l'absence de virgules entre les expressions d'argument dans la première forme, ainsi que l'absence d'espace après @name
dans la deuxième forme. Les deux styles ne doivent pas être mélangés. Par exemple, la syntaxe suivante est différente des exemples ci-dessus ; elle passe le tuple (expr1, expr2, ...)
comme un seul argument à la macro :
@name (expr1, expr2, ...)
Une autre façon d'invoquer une macro sur un littéral de tableau (ou une compréhension) est de juxtaposer les deux sans utiliser de parenthèses. Dans ce cas, le tableau sera la seule expression transmise à la macro. La syntaxe suivante est équivalente (et différente de @name [a b] * v
) :
@name[a b] * v
@name([a b]) * v
Il est important de souligner que les macros reçoivent leurs arguments sous forme d'expressions, de littéraux ou de symboles. Une façon d'explorer les arguments des macros est d'appeler la fonction show
dans le corps de la macro :
julia> macro showarg(x)
show(x)
# ... remainder of macro, returning an expression
end
@showarg (macro with 1 method)
julia> @showarg(a)
:a
julia> @showarg(1+1)
:(1 + 1)
julia> @showarg(println("Yo!"))
:(println("Yo!"))
julia> @showarg(1) # Numeric literal
1
julia> @showarg("Yo!") # String literal
"Yo!"
julia> @showarg("Yo! $("hello")") # String with interpolation is an Expr rather than a String
:("Yo! $("hello")")
En plus de la liste d'arguments donnée, chaque macro reçoit des arguments supplémentaires nommés __source__
et __module__
.
L'argument __source__
fournit des informations (sous la forme d'un objet LineNumberNode
) sur l'emplacement du parseur du signe @
à partir de l'invocation de la macro. Cela permet aux macros d'inclure de meilleures informations de diagnostic d'erreur, et est couramment utilisé par les macros de journalisation, de parsing de chaînes, et de documentation, par exemple, ainsi que pour implémenter les macros @__LINE__
, @__FILE__
, et @__DIR__
.
Les informations de localisation peuvent être consultées en se référant à __source__.line
et __source__.file
:
julia> macro __LOCATION__(); return QuoteNode(__source__); end
@__LOCATION__ (macro with 1 method)
julia> dump(
@__LOCATION__(
))
LineNumberNode
line: Int64 2
file: Symbol none
L'argument __module__
fournit des informations (sous la forme d'un objet Module
) sur le contexte d'expansion de l'invocation de la macro. Cela permet aux macros de rechercher des informations contextuelles, telles que des liaisons existantes, ou d'insérer la valeur en tant qu'argument supplémentaire dans un appel de fonction à l'exécution effectuant une auto-réflexion dans le module actuel.
Building an advanced macro
Voici une définition simplifiée de la macro @assert
de Julia :
julia> macro assert(ex)
return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
end
@assert (macro with 1 method)
Ce macro peut être utilisé comme ceci :
julia> @assert 1 == 1.0
julia> @assert 1 == 0
ERROR: AssertionError: 1 == 0
À la place de la syntaxe écrite, l'appel de macro est développé au moment de l'analyse en son résultat retourné. Cela équivaut à écrire :
1 == 1.0 ? nothing : throw(AssertionError("1 == 1.0"))
1 == 0 ? nothing : throw(AssertionError("1 == 0"))
C'est-à-dire que, lors du premier appel, l'expression :(1 == 1.0)
est insérée dans l'emplacement de la condition de test, tandis que la valeur de string(:(1 == 1.0))
est insérée dans l'emplacement du message d'assertion. L'ensemble de l'expression, ainsi construite, est placé dans l'arbre syntaxique où se produit l'appel de la macro @assert
. Ensuite, au moment de l'exécution, si l'expression de test évalue à vrai, alors nothing
est retourné, tandis que si le test est faux, une erreur est levée indiquant l'expression affirmée qui était fausse. Remarquez qu'il ne serait pas possible d'écrire cela comme une fonction, car seule la valeur de la condition est disponible et il serait impossible d'afficher l'expression qui l'a calculée dans le message d'erreur.
La définition réelle de @assert
dans Julia Base est plus compliquée. Elle permet à l'utilisateur de spécifier optionnellement son propre message d'erreur, au lieu de simplement imprimer l'expression échouée. Tout comme dans les fonctions avec un nombre variable d'arguments (Varargs Functions), cela est spécifié avec des points de suspension suivant le dernier argument :
julia> macro assert(ex, msgs...)
msg_body = isempty(msgs) ? ex : msgs[1]
msg = string(msg_body)
return :($ex ? nothing : throw(AssertionError($msg)))
end
@assert (macro with 1 method)
Maintenant, @assert
a deux modes de fonctionnement, selon le nombre d'arguments qu'il reçoit ! S'il n'y a qu'un seul argument, le tuple d'expressions capturé par msgs
sera vide et il se comportera de la même manière que la définition plus simple ci-dessus. Mais maintenant, si l'utilisateur spécifie un deuxième argument, il est imprimé dans le corps du message au lieu de l'expression échouée. Vous pouvez inspecter le résultat d'une expansion de macro avec la macro judicieusement nommée @macroexpand
:
julia> @macroexpand @assert a == b
:(if Main.a == Main.b
Main.nothing
else
Main.throw(Main.AssertionError("a == b"))
end)
julia> @macroexpand @assert a==b "a should equal b!"
:(if Main.a == Main.b
Main.nothing
else
Main.throw(Main.AssertionError("a should equal b!"))
end)
Il y a encore un autre cas que le macro @assert
gère : que se passe-t-il, en plus d'imprimer "a devrait être égal à b", si nous voulions imprimer leurs valeurs ? On pourrait naïvement essayer d'utiliser l'interpolation de chaînes dans le message personnalisé, par exemple, @assert a==b "a ($a) devrait être égal à b ($b)!"
, mais cela ne fonctionnera pas comme prévu avec le macro ci-dessus. Pouvez-vous voir pourquoi ? Rappelez-vous de string interpolation que une chaîne interpolée est réécrite en un appel à string
. Comparez :
julia> typeof(:("a should equal b"))
String
julia> typeof(:("a ($a) should equal b ($b)!"))
Expr
julia> dump(:("a ($a) should equal b ($b)!"))
Expr
head: Symbol string
args: Array{Any}((5,))
1: String "a ("
2: Symbol a
3: String ") should equal b ("
4: Symbol b
5: String ")!"
Alors maintenant, au lieu d'obtenir une chaîne simple dans msg_body
, la macro reçoit une expression complète qui devra être évaluée pour s'afficher comme prévu. Cela peut être directement intégré dans l'expression retournée en tant qu'argument de l'appel string
; voir error.jl
pour l'implémentation complète.
Le macro @assert
utilise de manière efficace l'insertion dans des expressions citées pour simplifier la manipulation des expressions à l'intérieur du corps du macro.
Hygiene
Un problème qui se pose dans des macros plus complexes est celui de hygiene. En résumé, les macros doivent s'assurer que les variables qu'elles introduisent dans leurs expressions retournées ne se heurtent pas accidentellement aux variables existantes dans le code environnant dans lequel elles s'étendent. Inversement, les expressions qui sont passées à une macro en tant qu'arguments sont souvent attendues pour être évaluées dans le contexte du code environnant, interagissant avec et modifiant les variables existantes. Une autre préoccupation découle du fait qu'une macro peut être appelée dans un module différent de celui où elle a été définie. Dans ce cas, nous devons nous assurer que toutes les variables globales sont résolues au bon module. Julia a déjà un avantage majeur par rapport aux langages avec une expansion de macro textuelle (comme C) en ce sens qu'elle n'a besoin de considérer que l'expression retournée. Toutes les autres variables (comme msg
dans @assert
ci-dessus) suivent le normal scoping block behavior.
Pour démontrer ces problèmes, considérons l'écriture d'un macro @time
qui prend une expression comme argument, enregistre le temps, évalue l'expression, enregistre à nouveau le temps, imprime la différence entre les temps avant et après, puis a la valeur de l'expression comme valeur finale. Le macro pourrait ressembler à ceci :
macro time(ex)
return quote
local t0 = time_ns()
local val = $ex
local t1 = time_ns()
println("elapsed time: ", (t1-t0)/1e9, " seconds")
val
end
end
Ici, nous voulons que t0
, t1
et val
soient des variables temporaires privées, et nous voulons que time_ns
fasse référence à la fonction time_ns
dans Julia Base, et non à une variable time_ns
que l'utilisateur pourrait avoir (il en va de même pour println
). Imaginez les problèmes qui pourraient survenir si l'expression utilisateur ex
contenait également des affectations à une variable appelée t0
, ou définissait sa propre variable time_ns
. Nous pourrions obtenir des erreurs ou un comportement mystérieusement incorrect.
L'expandeur de macro de Julia résout ces problèmes de la manière suivante. Tout d'abord, les variables au sein d'un résultat de macro sont classées comme locales ou globales. Une variable est considérée comme locale si elle est assignée (et non déclarée globale), déclarée locale, ou utilisée comme nom d'argument de fonction. Sinon, elle est considérée comme globale. Les variables locales sont ensuite renommées pour être uniques (en utilisant la fonction gensym
, qui génère de nouveaux symboles), et les variables globales sont résolues dans l'environnement de définition de la macro. Par conséquent, les deux préoccupations ci-dessus sont traitées ; les variables locales de la macro ne seront pas en conflit avec les variables utilisateur, et time_ns
et println
se référeront aux définitions de Julia Base.
Un problème demeure cependant. Considérez l'utilisation suivante de cette macro :
module MyModule
import Base.@time
time_ns() = ... # compute something
@time time_ns()
end
Ici, l'expression utilisateur ex
est un appel à time_ns
, mais ce n'est pas la même fonction time_ns
que celle utilisée par la macro. Elle fait clairement référence à MyModule.time_ns
. Par conséquent, nous devons veiller à ce que le code dans ex
soit résolu dans l'environnement d'appel de la macro. Cela se fait en "échappant" l'expression avec esc
:
macro time(ex)
...
local val = $(esc(ex))
...
end
Une expression enveloppée de cette manière est laissée intacte par l'expanseur de macro et est simplement collée dans la sortie telle quelle. Par conséquent, elle sera résolue dans l'environnement d'appel de la macro.
Ce mécanisme d'échappement peut être utilisé pour "violer" l'hygiène lorsque cela est nécessaire, afin d'introduire ou de manipuler des variables utilisateur. Par exemple, le macro suivant définit x
à zéro dans l'environnement d'appel :
julia> macro zerox()
return esc(:(x = 0))
end
@zerox (macro with 1 method)
julia> function foo()
x = 1
@zerox
return x # is zero
end
foo (generic function with 1 method)
julia> foo()
0
Ce type de manipulation des variables doit être utilisé avec prudence, mais est parfois très pratique.
Obtenir les règles d'hygiène correctes peut être un défi redoutable. Avant d'utiliser un macro, vous voudrez peut-être considérer si une fermeture de fonction serait suffisante. Une autre stratégie utile consiste à différer autant de travail que possible à l'exécution. Par exemple, de nombreux macros enveloppent simplement leurs arguments dans un QuoteNode
ou un autre Expr
similaire. Quelques exemples incluent @task body
qui retourne simplement schedule(Task(() -> $body))
, et @eval expr
, qui retourne simplement eval(QuoteNode(expr))
.
Pour démontrer, nous pourrions réécrire l'exemple @time
ci-dessus comme suit :
macro time(expr)
return :(timeit(() -> $(esc(expr))))
end
function timeit(f)
t0 = time_ns()
val = f()
t1 = time_ns()
println("elapsed time: ", (t1-t0)/1e9, " seconds")
return val
end
Cependant, nous ne faisons pas cela pour une bonne raison : envelopper l'expr
dans un nouveau bloc de portée (la fonction anonyme) change également légèrement le sens de l'expression (la portée de toutes les variables qu'elle contient), alors que nous voulons que @time
soit utilisable avec un impact minimal sur le code enveloppé.
Macros and dispatch
Les macros, tout comme les fonctions Julia, sont génériques. Cela signifie qu'elles peuvent également avoir plusieurs définitions de méthode, grâce au dispatch multiple :
julia> macro m end
@m (macro with 0 methods)
julia> macro m(args...)
println("$(length(args)) arguments")
end
@m (macro with 1 method)
julia> macro m(x,y)
println("Two arguments")
end
@m (macro with 2 methods)
julia> @m "asd"
1 arguments
julia> @m 1 2
Two arguments
Cependant, il faut garder à l'esprit que le dispatching macro est basé sur les types d'AST qui sont remis à la macro, et non sur les types que l'AST évalue à l'exécution :
julia> macro m(::Int)
println("An Integer")
end
@m (macro with 3 methods)
julia> @m 2
An Integer
julia> x = 2
2
julia> @m x
1 arguments
Code Generation
Lorsqu'une quantité significative de code répétitif standard est requise, il est courant de le générer de manière programmatique pour éviter la redondance. Dans la plupart des langages, cela nécessite une étape de construction supplémentaire et un programme séparé pour générer le code répétitif. En Julia, l'interpolation d'expressions et eval
permettent à cette génération de code de se produire dans le cours normal de l'exécution du programme. Par exemple, considérons le type personnalisé suivant.
struct MyNumber
x::Float64
end
# output
pour lequel nous souhaitons ajouter un certain nombre de méthodes. Nous pouvons le faire de manière programmatique dans la boucle suivante :
for op = (:sin, :cos, :tan, :log, :exp)
eval(quote
Base.$op(a::MyNumber) = MyNumber($op(a.x))
end)
end
# output
et nous pouvons maintenant utiliser ces fonctions avec notre type personnalisé :
julia> x = MyNumber(π)
MyNumber(3.141592653589793)
julia> sin(x)
MyNumber(1.2246467991473532e-16)
julia> cos(x)
MyNumber(-1.0)
De cette manière, Julia agit comme son propre preprocessor, et permet la génération de code depuis l'intérieur du langage. Le code ci-dessus pourrait être écrit de manière légèrement plus concise en utilisant la forme de citation avec le préfixe :
:
for op = (:sin, :cos, :tan, :log, :exp)
eval(:(Base.$op(a::MyNumber) = MyNumber($op(a.x))))
end
Ce type de génération de code en langage, cependant, utilisant le modèle eval(quote(...))
, est suffisamment courant pour que Julia soit livrée avec un macro pour abréger ce modèle :
for op = (:sin, :cos, :tan, :log, :exp)
@eval Base.$op(a::MyNumber) = MyNumber($op(a.x))
end
Le macro @eval
réécrit cet appel pour être précisément équivalent aux versions plus longues ci-dessus. Pour des blocs plus longs de code généré, l'argument d'expression donné à 4d61726b646f776e2e436f64652822222c2022406576616c2229_40726566
peut être un bloc :
@eval begin
# multiple lines
end
Non-Standard String Literals
Rappelez-vous de Strings que les littéraux de chaîne préfixés par un identifiant sont appelés littéraux de chaîne non standard, et peuvent avoir des sémantiques différentes de celles des littéraux de chaîne non préfixés. Par exemple :
r"^\s*(?:#|$)"
produit un regular expression object plutôt qu'une chaîne.b"DATA\xff\u2200"
est un byte array literal pour[68,65,84,65,255,226,136,128]
.
Peut-être de manière surprenante, ces comportements ne sont pas codés en dur dans le parseur ou le compilateur Julia. Au lieu de cela, ce sont des comportements personnalisés fournis par un mécanisme général que tout le monde peut utiliser : les littéraux de chaîne préfixés sont analysés comme des appels à des macros nommées de manière spéciale. Par exemple, la macro d'expression régulière est simplement la suivante :
macro r_str(p)
Regex(p)
end
C'est tout. Cette macro indique que le contenu littéral de la chaîne littérale r"^\s*(?:#|$)"
doit être passé à la macro @r_str
et que le résultat de cette expansion doit être placé dans l'arbre syntaxique à l'endroit où la chaîne littérale se trouve. En d'autres termes, l'expression r"^\s*(?:#|$)"
est équivalente à placer directement l'objet suivant dans l'arbre syntaxique :
Regex("^\\s*(?:#|\$)")
Non seulement la forme littérale de chaîne est plus courte et beaucoup plus pratique, mais elle est également plus efficace : puisque l'expression régulière est compilée et que l'objet Regex
est en fait créé lorsque le code est compilé, la compilation n'a lieu qu'une seule fois, plutôt qu'à chaque fois que le code est exécuté. Considérez si l'expression régulière se trouve dans une boucle :
for line = lines
m = match(r"^\s*(?:#|$)", line)
if m === nothing
# non-comment
else
# comment
end
end
Puisque l'expression régulière r"^\s*(?:#|$)"
est compilée et insérée dans l'arbre de syntaxe lorsque ce code est analysé, l'expression n'est compilée qu'une seule fois au lieu de chaque fois que la boucle est exécutée. Pour accomplir cela sans macros, il faudrait écrire cette boucle comme ceci :
re = Regex("^\\s*(?:#|\$)")
for line = lines
m = match(re, line)
if m === nothing
# non-comment
else
# comment
end
end
De plus, si le compilateur ne pouvait pas déterminer que l'objet regex était constant sur toutes les boucles, certaines optimisations pourraient ne pas être possibles, rendant cette version encore moins efficace que la forme littérale plus pratique ci-dessus. Bien sûr, il existe encore des situations où la forme non littérale est plus pratique : si l'on doit interpoler une variable dans l'expression régulière, il faut adopter cette approche plus verbeuse ; dans les cas où le motif de l'expression régulière lui-même est dynamique, changeant potentiellement à chaque itération de boucle, un nouvel objet d'expression régulière doit être construit à chaque itération. Dans la grande majorité des cas d'utilisation, cependant, les expressions régulières ne sont pas construites sur la base de données d'exécution. Dans cette majorité de cas, la possibilité d'écrire des expressions régulières en tant que valeurs de temps de compilation est inestimable.
Le mécanisme des littéraux de chaîne définis par l'utilisateur est profondément, puissamment efficace. Non seulement les littéraux non standards de Julia sont implémentés en utilisant cela, mais la syntaxe des littéraux de commande (`echo "Hello, $person"`
) est également implémentée à l'aide de la macro apparemment inoffensive suivante :
macro cmd(str)
:(cmd_gen($(shell_parse(str)[1])))
end
Bien sûr, une grande partie de la complexité est cachée dans les fonctions utilisées dans cette définition de macro, mais ce ne sont que des fonctions, écrites entièrement en Julia. Vous pouvez lire leur source et voir précisément ce qu'elles font – et tout ce qu'elles font, c'est construire des objets d'expression à insérer dans l'arbre de syntaxe de votre programme.
Comme les littéraux de chaîne, les littéraux de commande peuvent également être préfixés par un identifiant pour former ce que l'on appelle des littéraux de commande non standard. Ces littéraux de commande sont analysés comme des appels à des macros spécialement nommées. Par exemple, la syntaxe custom`literal`
est analysée comme @custom_cmd "literal"
. Julia elle-même ne contient pas de littéraux de commande non standard, mais les packages peuvent utiliser cette syntaxe. Mis à part la syntaxe différente et le suffixe _cmd
au lieu du suffixe _str
, les littéraux de commande non standard se comportent exactement comme les littéraux de chaîne non standard.
Dans le cas où deux modules fournissent des littéraux de chaîne ou de commande non standard avec le même nom, il est possible de qualifier le littéral de chaîne ou de commande avec un nom de module. Par exemple, si à la fois Foo
et Bar
fournissent le littéral de chaîne non standard @x_str
, alors on peut écrire Foo.x"literal"
ou Bar.x"literal"
pour faire la distinction entre les deux.
Une autre façon de définir une macro serait comme ceci :
macro foo_str(str, flag)
# do stuff
end
Cette macro peut ensuite être appelée avec la syntaxe suivante :
foo"str"flag
Le type de drapeau dans la syntaxe mentionnée ci-dessus serait une String
contenant tout ce qui suit le littéral de chaîne.
Generated functions
Une macro très spéciale est @generated
, qui vous permet de définir ce qu'on appelle des fonctions générées. Celles-ci ont la capacité de générer du code spécialisé en fonction des types de leurs arguments avec plus de flexibilité et/ou moins de code que ce qui peut être réalisé avec le dispatch multiple. Alors que les macros fonctionnent avec des expressions au moment de l'analyse et ne peuvent pas accéder aux types de leurs entrées, une fonction générée est développée à un moment où les types des arguments sont connus, mais la fonction n'est pas encore compilée.
Au lieu d'effectuer un calcul ou une action, une déclaration de fonction générée renvoie une expression citée qui forme ensuite le corps de la méthode correspondant aux types des arguments. Lorsqu'une fonction générée est appelée, l'expression qu'elle renvoie est compilée puis exécutée. Pour rendre cela efficace, le résultat est généralement mis en cache. Et pour rendre cela inférable, seul un sous-ensemble limité du langage est utilisable. Ainsi, les fonctions générées offrent un moyen flexible de déplacer le travail du temps d'exécution au temps de compilation, au prix de restrictions plus importantes sur les constructions autorisées.
Lors de la définition des fonctions générées, il y a cinq différences principales par rapport aux fonctions ordinaires :
- Vous annotez la déclaration de fonction avec le macro
@generated
. Cela ajoute des informations à l'AST qui permettent au compilateur de savoir qu'il s'agit d'une fonction générée. - Dans le corps de la fonction générée, vous n'avez accès qu'aux types des arguments – pas à leurs valeurs.
- Au lieu de calculer quelque chose ou d'effectuer une action, vous renvoyez une expression citée qui, lorsqu'elle est évaluée, fait ce que vous voulez.
- Les fonctions générées ne sont autorisées à appeler que des fonctions qui ont été définies avant la définition de la fonction générée. (Le non-respect de cette règle peut entraîner des
MethodErrors
faisant référence à des fonctions d'un âge de monde futur.) - Les fonctions générées ne doivent pas muter ou observer un état global non constant (y compris, par exemple, l'E/S, les verrous, les dictionnaires non locaux, ou utiliser
hasmethod
). Cela signifie qu'elles ne peuvent lire que des constantes globales et ne peuvent avoir aucun effet secondaire. En d'autres termes, elles doivent être complètement pures. En raison d'une limitation d'implémentation, cela signifie également qu'elles ne peuvent actuellement pas définir une fermeture ou un générateur.
Il est plus facile d'illustrer cela avec un exemple. Nous pouvons déclarer une fonction générée foo
comme
julia> @generated function foo(x)
Core.println(x)
return :(x * x)
end
foo (generic function with 1 method)
Notez que le corps renvoie une expression citée, à savoir :(x * x)
, plutôt que simplement la valeur de x * x
.
Du point de vue de l'appelant, ceci est identique à une fonction régulière ; en fait, vous n'avez pas besoin de savoir si vous appelez une fonction régulière ou générée. Voyons comment foo
se comporte :
julia> x = foo(2); # note: output is from println() statement in the body
Int64
julia> x # now we print x
4
julia> y = foo("bar");
String
julia> y
"barbar"
Ainsi, nous voyons que dans le corps de la fonction générée, x
est le type de l'argument passé, et la valeur retournée par la fonction générée est le résultat de l'évaluation de l'expression citée que nous avons retournée de la définition, maintenant avec la valeur de x
.
Que se passe-t-il si nous évaluons foo
à nouveau avec un type que nous avons déjà utilisé ?
julia> foo(4)
16
Notez qu'il n'y a pas d'impression de Int64
. Nous pouvons voir que le corps de la fonction générée n'a été exécuté qu'une seule fois ici, pour l'ensemble spécifique des types d'arguments, et le résultat a été mis en cache. Après cela, pour cet exemple, l'expression renvoyée par la fonction générée lors de la première invocation a été réutilisée comme corps de méthode. Cependant, le comportement de mise en cache réel est une optimisation de performance définie par l'implémentation, il est donc invalide de dépendre trop étroitement de ce comportement.
Le nombre de fois qu'une fonction générée est générée peut n'être qu'une seule fois, mais il peut aussi être plus fréquent, ou sembler ne pas se produire du tout. En conséquence, vous ne devez jamais écrire une fonction générée avec des effets de bord - quand et à quelle fréquence les effets de bord se produisent est indéfini. (C'est vrai pour les macros aussi - et tout comme pour les macros, l'utilisation de eval
dans une fonction générée est un signe que vous faites quelque chose de mal.) Cependant, contrairement aux macros, le système d'exécution ne peut pas gérer correctement un appel à 4d61726b646f776e2e436f64652822222c20226576616c2229_40726566
, donc il est interdit.
Il est également important de voir comment les fonctions @generated
interagissent avec la redéfinition de méthodes. En suivant le principe qu'une fonction @generated
correcte ne doit pas observer d'état mutable ni provoquer de mutation de l'état global, nous constatons le comportement suivant. Observez que la fonction générée ne peut pas appeler une méthode qui n'a pas été définie avant la définition de la fonction générée elle-même.
Initialement, f(x)
a une seule définition.
julia> f(x) = "original definition";
Définir d'autres opérations qui utilisent f(x)
:
julia> g(x) = f(x);
julia> @generated gen1(x) = f(x);
julia> @generated gen2(x) = :(f(x));
Nous ajoutons maintenant quelques nouvelles définitions pour f(x)
:
julia> f(x::Int) = "definition for Int";
julia> f(x::Type{Int}) = "definition for Type{Int}";
et comparez comment ces résultats diffèrent :
julia> f(1)
"definition for Int"
julia> g(1)
"definition for Int"
julia> gen1(1)
"original definition"
julia> gen2(1)
"definition for Int"
Chaque méthode d'une fonction générée a sa propre vue des fonctions définies :
julia> @generated gen1(x::Real) = f(x);
julia> gen1(1)
"definition for Type{Int}"
La fonction générée foo
ci-dessus ne faisait rien qu'une fonction normale foo(x) = x * x
ne pourrait pas faire (à part imprimer le type lors de la première invocation et entraîner un surcoût plus élevé). Cependant, la puissance d'une fonction générée réside dans sa capacité à calculer différentes expressions citées en fonction des types qui lui sont passés :
julia> @generated function bar(x)
if x <: Integer
return :(x ^ 2)
else
return :(x)
end
end
bar (generic function with 1 method)
julia> bar(4)
16
julia> bar("baz")
"baz"
(bien que bien sûr cet exemple artificiel serait plus facilement mis en œuvre en utilisant le dispatch multiple...)
Abuser cela corrompra le système d'exécution et provoquera un comportement indéfini :
julia> @generated function baz(x)
if rand() < .9
return :(x^2)
else
return :("boo!")
end
end
baz (generic function with 1 method)
Étant donné que le corps de la fonction générée est non déterministe, son comportement, et le comportement de tout le code suivant est indéfini.
Ne copiez pas ces exemples !
Ces exemples sont, espérons-le, utiles pour illustrer comment fonctionnent les fonctions générées, à la fois dans la définition et au site d'appel ; cependant, ne les copiez pas, pour les raisons suivantes :
- la fonction
foo
a des effets de bord (l'appel àCore.println
), et il est indéfini exactement quand, à quelle fréquence ou combien de fois ces effets de bord se produiront - la fonction
bar
résout un problème qui est mieux résolu avec un dispatch multiple - définirbar(x) = x
etbar(x::Integer) = x ^ 2
fera la même chose, mais c'est à la fois plus simple et plus rapide. - la fonction
baz
est pathologique
Notez que l'ensemble des opérations qui ne doivent pas être tentées dans une fonction générée est illimité, et le système d'exécution ne peut actuellement détecter qu'un sous-ensemble des opérations invalides. Il existe de nombreuses autres opérations qui corrompront simplement le système d'exécution sans notification, généralement de manière subtile, sans lien évident avec la mauvaise définition. Comme le générateur de fonctions est exécuté pendant l'inférence, il doit respecter toutes les limitations de ce code.
Certain opérations qui ne devraient pas être tentées incluent :
Mise en cache des pointeurs natifs.
Interagir avec le contenu ou les méthodes de
Core.Compiler
de quelque manière que ce soit.Observer tout état mutable.
- L'inférence sur la fonction générée peut être exécutée à tout moment, y compris pendant que votre code tente d'observer ou de modifier cet état.
Prendre des verrous : Le code C que vous appelez peut utiliser des verrous en interne (par exemple, il n'est pas problématique d'appeler
malloc
, même si la plupart des implémentations nécessitent des verrous en interne), mais n'essayez pas de détenir ou d'acquérir des verrous pendant l'exécution du code Julia.Appeler une fonction qui est définie après le corps de la fonction générée. Cette condition est assouplie pour les modules précompilés chargés de manière incrémentielle afin de permettre l'appel de n'importe quelle fonction dans le module.
D'accord, maintenant que nous avons une meilleure compréhension de la façon dont les fonctions générées fonctionnent, utilisons-les pour construire des fonctionnalités plus avancées (et valides)...
An advanced example
La bibliothèque de base de Julia a une fonction interne sub2ind
pour calculer un index linéaire dans un tableau n-dimensionnel, basé sur un ensemble d'indices multilinéaires n - en d'autres termes, pour calculer l'index i
qui peut être utilisé pour indexer un tableau A
en utilisant A[i]
, au lieu de A[x,y,z,...]
. Une implémentation possible est la suivante :
julia> function sub2ind_loop(dims::NTuple{N}, I::Integer...) where N
ind = I[N] - 1
for i = N-1:-1:1
ind = I[i]-1 + dims[i]*ind
end
return ind + 1
end;
julia> sub2ind_loop((3, 5), 1, 2)
4
La même chose peut être faite en utilisant la récursion :
julia> sub2ind_rec(dims::Tuple{}) = 1;
julia> sub2ind_rec(dims::Tuple{}, i1::Integer, I::Integer...) =
i1 == 1 ? sub2ind_rec(dims, I...) : throw(BoundsError());
julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer) = i1;
julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer, I::Integer...) =
i1 + dims[1] * (sub2ind_rec(Base.tail(dims), I...) - 1);
julia> sub2ind_rec((3, 5), 1, 2)
4
Les deux implémentations, bien que différentes, font essentiellement la même chose : une boucle d'exécution sur les dimensions du tableau, collectant le décalage dans chaque dimension dans l'index final.
Cependant, toutes les informations dont nous avons besoin pour la boucle sont intégrées dans les informations de type des arguments. Cela permet au compilateur de déplacer l'itération au moment de la compilation et d'éliminer complètement les boucles d'exécution. Nous pouvons utiliser des fonctions générées pour obtenir un effet similaire ; dans le jargon des compilateurs, nous utilisons des fonctions générées pour dérouler manuellement la boucle. Le corps devient presque identique, mais au lieu de calculer l'index linéaire, nous construisons une expression qui calcule l'index :
julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
ex = :(I[$N] - 1)
for i = (N - 1):-1:1
ex = :(I[$i] - 1 + dims[$i] * $ex)
end
return :($ex + 1)
end;
julia> sub2ind_gen((3, 5), 1, 2)
4
Quel code cela va-t-il générer ?
Une façon simple de le découvrir est d'extraire le corps dans une autre fonction (régulière) :
julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
length(I) == N || return :(error("partial indexing is unsupported"))
ex = :(I[$N] - 1)
for i = (N - 1):-1:1
ex = :(I[$i] - 1 + dims[$i] * $ex)
end
return :($ex + 1)
end;
julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
return sub2ind_gen_impl(dims, I...)
end;
julia> sub2ind_gen((3, 5), 1, 2)
4
Nous pouvons maintenant exécuter sub2ind_gen_impl
et examiner l'expression qu'il renvoie :
julia> sub2ind_gen_impl(Tuple{Int,Int}, Int, Int)
:(((I[1] - 1) + dims[1] * (I[2] - 1)) + 1)
Donc, le corps de la méthode qui sera utilisé ici n'inclut pas de boucle du tout - juste l'indexation dans les deux tuples, la multiplication et l'addition/soustraction. Toute la boucle est effectuée à la compilation, et nous évitons complètement les boucles pendant l'exécution. Ainsi, nous ne bouclons qu'une seule fois par type, dans ce cas une fois par N
(sauf dans les cas particuliers où la fonction est générée plus d'une fois - voir la clause de non-responsabilité ci-dessus).
Optionally-generated functions
Les fonctions générées peuvent atteindre une grande efficacité à l'exécution, mais cela entraîne un coût en temps de compilation : un nouveau corps de fonction doit être généré pour chaque combinaison de types d'arguments concrets. En général, Julia est capable de compiler des versions "génériques" de fonctions qui fonctionneront pour n'importe quels arguments, mais avec des fonctions générées, cela est impossible. Cela signifie que les programmes utilisant intensivement des fonctions générées pourraient être impossibles à compiler statiquement.
Pour résoudre ce problème, le langage fournit une syntaxe pour écrire des implémentations alternatives normales, non générées, de fonctions générées. Appliqué à l'exemple sub2ind
ci-dessus, cela ressemblerait à ceci :
julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
ex = :(I[$N] - 1)
for i = (N - 1):-1:1
ex = :(I[$i] - 1 + dims[$i] * $ex)
end
return :($ex + 1)
end;
julia> function sub2ind_gen_fallback(dims::NTuple{N}, I) where N
ind = I[N] - 1
for i = (N - 1):-1:1
ind = I[i] - 1 + dims[i]*ind
end
return ind + 1
end;
julia> function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
length(I) == N || error("partial indexing is unsupported")
if @generated
return sub2ind_gen_impl(dims, I...)
else
return sub2ind_gen_fallback(dims, I)
end
end;
julia> sub2ind_gen((3, 5), 1, 2)
4
En interne, ce code crée deux implémentations de la fonction : une générée où le premier bloc dans if @generated
est utilisé, et une normale où le bloc else
est utilisé. À l'intérieur de la partie then
du bloc if @generated
, le code a la même sémantique que d'autres fonctions générées : les noms des arguments se réfèrent à des types, et le code doit retourner une expression. Plusieurs blocs if @generated
peuvent apparaître, auquel cas l'implémentation générée utilise tous les blocs then
et l'implémentation alternative utilise tous les blocs else
.
Remarquez que nous avons ajouté une vérification d'erreur au début de la fonction. Ce code sera commun aux deux versions et est du code d'exécution dans les deux versions (il sera cité et renvoyé comme une expression de la version générée). Cela signifie que les valeurs et les types des variables locales ne sont pas disponibles au moment de la génération du code – le code de génération de code ne peut voir que les types des arguments.
Dans ce style de définition, la fonctionnalité de génération de code est essentiellement une optimisation optionnelle. Le compilateur l'utilisera si cela est pratique, mais sinon, il peut choisir d'utiliser l'implémentation normale à la place. Ce style est préféré, car il permet au compilateur de prendre plus de décisions et de compiler les programmes de plusieurs manières, et parce que le code normal est plus lisible que le code générant du code. Cependant, l'implémentation utilisée dépend des détails d'implémentation du compilateur, il est donc essentiel que les deux implémentations se comportent de manière identique.