Metaprogramming
Сильнейшее наследие Lisp в языке Julia — это поддержка метапрограммирования. Как и Lisp, Julia представляет свой собственный код в виде структуры данных самого языка. Поскольку код представлен объектами, которые могут быть созданы и изменены изнутри языка, программа может трансформировать и генерировать свой собственный код. Это позволяет осуществлять сложную генерацию кода без дополнительных этапов сборки, а также позволяет использовать настоящие макросы в стиле Lisp, работающие на уровне abstract syntax trees. В отличие от этого, системы "макросов" препроцессора, такие как в C и C++, выполняют текстовую манипуляцию и замену до того, как произойдет фактический разбор или интерпретация. Поскольку все типы данных и код в Julia представлены структурами данных Julia, мощные reflection возможности доступны для изучения внутренностей программы и ее типов так же, как и для любых других данных.
Метапрограммирование — это мощный инструмент, но оно вводит сложность, которая может сделать код более трудным для понимания. Например, может быть удивительно сложно правильно установить правила области видимости. Метапрограммирование обычно следует использовать только тогда, когда другие подходы, такие как higher order functions и closures, не могут быть применены.
eval
и определение новых макросов обычно следует использовать в качестве последнего средства. Почти никогда не является хорошей идеей использовать Meta.parse
или преобразовывать произвольную строку в код Julia. Для манипуляции кодом Julia используйте структуру данных Expr
напрямую, чтобы избежать сложности, связанной с тем, как синтаксис Julia разбирается.
Лучшие применения метапрограммирования часто реализуют большую часть своей функциональности в вспомогательных функциях времени выполнения, стремясь минимизировать объем генерируемого кода.
Program representation
Каждая программа на Julia начинается с строки:
julia> prog = "1 + 1"
"1 + 1"
Что будет дальше?
Следующий шаг - это parse каждую строку в объект, называемый выражением, представленным типом Julia Expr
:
julia> ex1 = Meta.parse(prog)
:(1 + 1)
julia> typeof(ex1)
Expr
Expr
объекты содержат две части:
- a
Symbol
идентифицирует вид выражения. Символ является interned string идентификатором (более подробное обсуждение ниже).
julia> ex1.head
:call
- аргументы выражения, которые могут быть символами, другими выражениями или литеральными значениями:
julia> ex1.args
3-element Vector{Any}:
:+
1
1
Выражения также могут быть построены непосредственно в prefix notation:
julia> ex2 = Expr(:call, :+, 1, 1)
:(1 + 1)
Два выражения, построенные выше – путем парсинга и прямого построения – эквивалентны:
julia> ex1 == ex2
true
Ключевой момент здесь в том, что код Julia внутренне представлен в виде структуры данных, доступной из самого языка.
Функция dump
обеспечивает отступленный и аннотированный вывод объектов Expr
:
julia> dump(ex2)
Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol +
2: Int64 1
3: Int64 1
Expr
объекты также могут быть вложенными:
julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)
Другой способ просмотра выражений - это использование Meta.show_sexpr
, который отображает S-expression форму данного Expr
, которая может быть очень знакома пользователям Lisp. Вот пример, иллюстрирующий отображение на вложенном Expr
:
julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)
Symbols
Символ :
имеет две синтаксические цели в Julia. Первая форма создает Symbol
, interned string, используемый в качестве одного из строительных блоков выражений, из допустимых идентификаторов:
julia> s = :foo
:foo
julia> typeof(s)
Symbol
Конструктор Symbol
принимает любое количество аргументов и создает новый символ, объединяя их строковые представления:
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
В контексте выражения символы используются для указания доступа к переменным; когда выражение оценивается, символ заменяется значением, связанным с этим символом в соответствующем scope.
Иногда дополнительные скобки вокруг аргумента :
необходимы, чтобы избежать неоднозначности в разборе:
julia> :(:)
:(:)
julia> :(::)
:(::)
Expressions and evaluation
Quoting
Вторая синтаксическая цель символа :
заключается в создании объектов выражений без использования явного конструктора Expr
. Это называется цитированием. Символ :
, за которым следуют парные скобки вокруг одного выражения кода Julia, создает объект Expr
, основанный на заключенном коде. Вот пример короткой формы, используемой для цитирования арифметического выражения:
julia> ex = :(a+b*c+1)
:(a + b * c + 1)
julia> typeof(ex)
Expr
(чтобы просмотреть структуру этого выражения, попробуйте ex.head
и ex.args
, или используйте dump
, как выше, или Meta.@dump
)
Обратите внимание, что эквивалентные выражения могут быть построены с использованием Meta.parse
или прямой формы Expr
:
julia> :(a + b*c + 1) ==
Meta.parse("a + b*c + 1") ==
Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true
Выражения, предоставляемые парсером, обычно имеют только символы, другие выражения и литеральные значения в качестве своих аргументов, в то время как выражения, построенные с помощью кода Julia, могут иметь произвольные значения времени выполнения без литеральных форм в качестве аргументов. В этом конкретном примере +
и a
являются символами, *(b,c)
является подвыражением, а 1
— это литеральное 64-битное знаковое целое число.
Существует вторая синтаксическая форма цитирования для нескольких выражений: блоки кода, заключенные в 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
Прямое создание объектов Expr
с аргументами значений мощно, но конструкторы Expr
могут быть утомительными по сравнению с "нормальным" синтаксисом Julia. В качестве альтернативы, Julia позволяет интерполяцию литералов или выражений в цитируемые выражения. Интерполяция обозначается префиксом $
.
В этом примере значение переменной a
интерполируется:
julia> a = 1;
julia> ex = :($a + b)
:(1 + b)
Интерполяция в нецитируемое выражение не поддерживается и приведет к ошибке на этапе компиляции:
julia> $a + b
ERROR: syntax: "$" expression outside quote
В этом примере кортеж (1,2,3)
интерполируется как выражение в условное тестирование:
julia> ex = :(a in $:((1,2,3)) )
:(a in (1, 2, 3))
Использование $
для интерполяции выражений намеренно напоминает string interpolation и command interpolation. Интерполяция выражений позволяет удобно и читаемо программно строить сложные выражения на Julia.
Splatting interpolation
Обратите внимание, что синтаксис интерполяции $
позволяет вставлять только одно выражение в окружающее выражение. Иногда у вас есть массив выражений, и вам нужно, чтобы все они стали аргументами окружающего выражения. Это можно сделать с помощью синтаксиса $(xs...)
. Например, следующий код генерирует вызов функции, где количество аргументов определяется программно:
julia> args = [:x, :y, :z];
julia> :(f(1, $(args...)))
:(f(1, x, y, z))
Nested quote
Естественно, что выражения цитат могут содержать другие выражения цитат. Понимание того, как работает интерполяция в этих случаях, может быть немного сложным. Рассмотрим этот пример:
julia> x = :(1 + 2);
julia> e = quote quote $x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :x))
end))
end
Обратите внимание, что результат содержит $x
, что означает, что x
еще не был оценен. Другими словами, выражение $
"принадлежит" внутреннему выражению кавычек, и поэтому его аргумент оценивается только тогда, когда внутреннее выражение кавычек:
julia> eval(e)
quote
#= none:1 =#
1 + 2
end
Однако внешнее выражение quote
может интерполировать значения внутри $
во внутреннем выражении quote. Это делается с помощью нескольких $
:
julia> e = quote quote $$x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :(1 + 2)))
end))
end
Обратите внимание, что (1 + 2)
теперь появляется в результате вместо символа x
. Вычисление этого выражения дает интерполированное 3
:
julia> eval(e)
quote
#= none:1 =#
3
end
Интуиция, стоящая за этим поведением, заключается в том, что x
оценивается один раз для каждого $
: один $
работает аналогично eval(:x)
, возвращая значение x
, в то время как два $
эквивалентны 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
Как мы видели, такие выражения поддерживают интерполяцию с помощью $
. Однако в некоторых ситуациях необходимо цитировать код без выполнения интерполяции. Этот вид цитирования пока не имеет синтаксиса, но внутренне представлен как объект типа QuoteNode
:
julia> eval(Meta.quot(Expr(:$, :(1+2))))
3
julia> eval(QuoteNode(Expr(:$, :(1+2))))
:($(Expr(:$, :(1 + 2))))
Парсер выдает QuoteNode
для простых цитируемых элементов, таких как символы:
julia> dump(Meta.parse(":x"))
QuoteNode
value: Symbol x
QuoteNode
также может быть использован для некоторых сложных задач метапрограммирования.
Evaluating expressions
Дано объект выражения, можно заставить Julia оценить (выполнить) его в глобальной области видимости, используя 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
Каждый module имеет свою собственную eval
функцию, которая оценивает выражения в своей глобальной области видимости. Выражения, переданные в 4d61726b646f776e2e436f64652822222c20226576616c2229_40726566
, не ограничиваются возвратом значений – они также могут иметь побочные эффекты, которые изменяют состояние окружения заключенного модуля:
julia> ex = :(x = 1)
:(x = 1)
julia> x
ERROR: UndefVarError: `x` not defined in `Main`
julia> eval(ex)
1
julia> x
1
Здесь оценка объекта выражения приводит к тому, что значение присваивается глобальной переменной x
.
Поскольку выражения представляют собой просто объекты Expr
, которые можно создавать программно, а затем оценивать, возможно динамически генерировать произвольный код, который затем можно выполнить с помощью eval
. Вот простой пример:
julia> a = 1;
julia> ex = Expr(:call, :+, a, :b)
:(1 + b)
julia> a = 0; b = 2;
julia> eval(ex)
3
Значение a
используется для построения выражения ex
, которое применяет функцию +
к значению 1 и переменной b
. Обратите внимание на важное различие в том, как используются a
и b
:
- Значение переменной
a
во время построения выражения используется как непосредственное значение в выражении. Таким образом, значениеa
при оценке выражения больше не имеет значения: значение в выражении уже1
, независимо от того, каково значениеa
. - С другой стороны, символ
:b
используется в конструкции выражения, поэтому значение переменнойb
в тот момент не имеет значения –:b
просто символ, и переменнаяb
даже не обязательно должна быть определена. Однако во время оценки выражения значение символа:b
разрешается путем поиска значения переменнойb
.
Functions on Expr
essions
Как упоминалось выше, одной из чрезвычайно полезных функций Julia является возможность генерировать и манипулировать кодом Julia внутри самой Julia. Мы уже видели один пример функции, возвращающей Expr
объекты: функция Meta.parse
, которая принимает строку кода Julia и возвращает соответствующий Expr
. Функция также может принимать один или несколько Expr
объектов в качестве аргументов и возвращать другой Expr
. Вот простой, мотивирующий пример:
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
В качестве другого примера, вот функция, которая удваивает любой числовой аргумент, но оставляет выражения без изменений:
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
Макросы предоставляют механизм для включения сгенерированного кода в окончательное тело программы. Макрос сопоставляет кортеж аргументов с возвращаемым выражением, и полученное выражение компилируется напрямую, а не требует вызова eval
во время выполнения. Аргументы макроса могут включать выражения, литеральные значения и символы.
Basics
Вот чрезвычайно простой макрос:
julia> macro sayhello()
return :( println("Hello, world!") )
end
@sayhello (macro with 1 method)
Макросы имеют специальный символ в синтаксисе Julia: @
(символ "собака"), за которым следует уникальное имя, объявленное в блоке macro NAME ... end
. В этом примере компилятор заменит все вхождения @sayhello
на:
:( println("Hello, world!") )
Когда @sayhello
вводится в REPL, выражение выполняется немедленно, и мы видим только результат вычисления:
julia> @sayhello()
Hello, world!
Теперь рассмотрим немного более сложный макрос:
julia> macro sayhello(name)
return :( println("Hello, ", $name) )
end
@sayhello (macro with 1 method)
Этот макрос принимает один аргумент: name
. Когда встречается @sayhello
, цитируемое выражение расширяется, чтобы интерполировать значение аргумента в финальное выражение:
julia> @sayhello("human")
Hello, human
Мы можем просмотреть выражение возвращаемого значения, используя функцию macroexpand
(важное примечание: это чрезвычайно полезный инструмент для отладки макросов):
julia> ex = macroexpand(Main, :(@sayhello("human")) )
:(Main.println("Hello, ", "human"))
julia> typeof(ex)
Expr
Мы можем видеть, что литерал "human"
был интерполирован в выражение.
Существует также макрос @macroexpand
, который, возможно, немного удобнее, чем функция macroexpand
:
julia> @macroexpand @sayhello "human"
:(println("Hello, ", "human"))
Hold up: why macros?
Мы уже видели функцию f(::Expr...) -> Expr
в предыдущем разделе. На самом деле, macroexpand
также является такой функцией. Так почему же существуют макросы?
Макросы необходимы, потому что они выполняются во время разбора кода, следовательно, макросы позволяют программисту генерировать и включать фрагменты пользовательского кода до выполнения всей программы. Чтобы проиллюстрировать разницу, рассмотрим следующий пример:
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))
Первый вызов println
выполняется, когда вызывается macroexpand
. Результирующее выражение содержит только второй 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
Макросы вызываются с помощью следующего общего синтаксиса:
@name expr1 expr2 ...
@name(expr1, expr2, ...)
Обратите внимание на отличительный @
перед именем макроса и отсутствие запятых между выражениями аргументов в первой форме, а также отсутствие пробелов после @name
во второй форме. Эти два стиля не следует смешивать. Например, следующий синтаксис отличается от приведенных выше примеров; он передает кортеж (expr1, expr2, ...)
в качестве одного аргумента макросу:
@name (expr1, expr2, ...)
Альтернативный способ вызвать макрос над литералом массива (или композицией) — это сопоставить оба без использования скобок. В этом случае массив будет единственным выражением, переданным макросу. Следующий синтаксис эквивалентен (и отличается от @name [a b] * v
):
@name[a b] * v
@name([a b]) * v
Важно подчеркнуть, что макросы получают свои аргументы в виде выражений, литералов или символов. Один из способов исследовать аргументы макроса — вызвать функцию show
внутри тела макроса:
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")")
В дополнение к данному списку аргументов, каждой макросу передаются дополнительные аргументы с именами __source__
и __module__
.
Аргумент __source__
предоставляет информацию (в виде объекта LineNumberNode
) о местоположении парсера знака @
из вызова макроса. Это позволяет макросам включать более качественную информацию об ошибках, и обычно используется в логировании, макросах для разбора строк и документации, например, а также для реализации макросов @__LINE__
, @__FILE__
и @__DIR__
.
Информацию о местоположении можно получить, ссылаясь на __source__.line
и __source__.file
:
julia> macro __LOCATION__(); return QuoteNode(__source__); end
@__LOCATION__ (macro with 1 method)
julia> dump(
@__LOCATION__(
))
LineNumberNode
line: Int64 2
file: Symbol none
Аргумент __module__
предоставляет информацию (в виде объекта Module
) о контексте расширения вызова макроса. Это позволяет макросам искать контекстную информацию, такую как существующие привязки, или вставлять значение в качестве дополнительного аргумента в вызов функции времени выполнения, выполняя саморефлексию в текущем модуле.
Building an advanced macro
Вот упрощенное определение макроса @assert
языка Julia:
julia> macro assert(ex)
return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
end
@assert (macro with 1 method)
Этот макрос можно использовать следующим образом:
julia> @assert 1 == 1.0
julia> @assert 1 == 0
ERROR: AssertionError: 1 == 0
Вместо написанного синтаксиса вызов макроса расширяется во время разбора до его возвращаемого результата. Это эквивалентно записи:
1 == 1.0 ? nothing : throw(AssertionError("1 == 1.0"))
1 == 0 ? nothing : throw(AssertionError("1 == 0"))
То есть, в первом вызове выражение :(1 == 1.0)
вставляется в слот условия теста, в то время как значение string(:(1 == 1.0))
вставляется в слот сообщения утверждения. Все выражение, таким образом, помещается в синтаксическое дерево, где происходит вызов макроса @assert
. Затем, во время выполнения, если тестовое выражение оценивается как истинное, то возвращается nothing
, в то время как если тест ложный, возникает ошибка, указывающая на утвержденное выражение, которое было ложным. Обратите внимание, что было бы невозможно написать это как функцию, поскольку доступно только значение условия, и было бы невозможно отобразить выражение, которое его вычислило, в сообщении об ошибке.
Фактическое определение @assert
в Julia Base более сложное. Оно позволяет пользователю опционально указывать собственное сообщение об ошибке, вместо того чтобы просто выводить неудачное выражение. Точно так же, как в функциях с переменным числом аргументов (Varargs Functions), это указывается с помощью многоточия после последнего аргумента:
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)
Теперь @assert
имеет два режима работы, в зависимости от количества аргументов, которые он получает! Если есть только один аргумент, кортеж выражений, захваченных msgs
, будет пустым, и он будет вести себя так же, как и более простое определение выше. Но теперь, если пользователь указывает второй аргумент, он будет напечатан в теле сообщения вместо неудавшегося выражения. Вы можете проверить результат расширения макроса с помощью aptly названного @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)
Существует еще один случай, который обрабатывает фактический макрос @assert
: что если, помимо вывода "a должно быть равно b", мы хотим вывести их значения? Можно наивно попытаться использовать интерполяцию строк в пользовательском сообщении, например, @assert a==b "a ($a) должно быть равно b ($b)!"
, но это не сработает, как ожидалось, с вышеупомянутым макросом. Вы можете понять, почему? Вспомните из string interpolation, что интерполированная строка переписывается в вызов string
. Сравните:
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 ")!"
Теперь вместо получения простой строки в msg_body
макрос получает полное выражение, которое необходимо будет оценить, чтобы отобразить его как ожидается. Это можно напрямую вставить в возвращаемое выражение в качестве аргумента для вызова string
; см. error.jl
для полного реализации.
Макрос @assert
активно использует вставку в цитируемые выражения, чтобы упростить манипуляцию выражениями внутри тела макроса.
Hygiene
Проблема, которая возникает в более сложных макросах, заключается в hygiene. Короче говоря, макросы должны гарантировать, что переменные, которые они вводят в свои возвращаемые выражения, случайно не конфликтуют с существующими переменными в окружающем коде, в который они расширяются. Напротив, выражения, которые передаются в макрос в качестве аргументов, часто ожидаются для оценки в контексте окружающего кода, взаимодействуя с существующими переменными и изменяя их. Еще одна проблема возникает из-за того, что макрос может быть вызван в другом модуле, чем тот, в котором он был определен. В этом случае нам нужно убедиться, что все глобальные переменные разрешены в правильный модуль. У Julia уже есть значительное преимущество перед языками с текстовым расширением макросов (такими как C) в том, что ей нужно учитывать только возвращаемое выражение. Все остальные переменные (такие как msg
в @assert
выше) следуют normal scoping block behavior.
Чтобы продемонстрировать эти проблемы, давайте рассмотрим написание макроса @time
, который принимает выражение в качестве аргумента, записывает время, оценивает выражение, снова записывает время, выводит разницу между временем до и после, а затем имеет значение выражения в качестве своего конечного значения. Макрос может выглядеть так:
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
Здесь мы хотим, чтобы t0
, t1
и val
были приватными временными переменными, а time_ns
ссылался на функцию time_ns
в Julia Base, а не на какую-либо переменную time_ns
, которую может иметь пользователь (то же самое касается println
). Представьте себе проблемы, которые могут возникнуть, если выражение пользователя ex
также содержало бы присваивания переменной с именем t0
или определяло бы свою собственную переменную time_ns
. Мы можем получить ошибки или загадочное неправильное поведение.
Макрорасширитель Julia решает эти проблемы следующим образом. Сначала переменные внутри результата макроса классифицируются как локальные или глобальные. Переменная считается локальной, если ей присваивается значение (и она не объявлена глобальной), объявлена локальной или используется в качестве имени аргумента функции. В противном случае она считается глобальной. Локальные переменные затем переименовываются, чтобы быть уникальными (с помощью функции gensym
, которая генерирует новые символы), а глобальные переменные разрешаются в среде определения макроса. Таким образом, обе вышеупомянутые проблемы решаются; локальные переменные макроса не будут конфликтовать с любыми переменными пользователя, а time_ns
и println
будут ссылаться на определения Julia Base.
Одна проблема все же остается. Рассмотрим следующее использование этого макроса:
module MyModule
import Base.@time
time_ns() = ... # compute something
@time time_ns()
end
Здесь выражение пользователя ex
является вызовом time_ns
, но не тем же самым time_ns
функцией, которую использует макрос. Оно явно ссылается на MyModule.time_ns
. Поэтому мы должны обеспечить разрешение кода в ex
в среде вызова макроса. Это делается путем "экранирования" выражения с помощью esc
:
macro time(ex)
...
local val = $(esc(ex))
...
end
Выражение, обернутое таким образом, остается нетронутым макрорасширителем и просто вставляется в вывод без изменений. Поэтому оно будет разрешено в среде вызова макроса.
Этот механизм экранирования может быть использован для "нарушения" гигиены, когда это необходимо, чтобы ввести или изменить пользовательские переменные. Например, следующий макрос устанавливает x
в ноль в среде вызова:
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
Этот вид манипуляции с переменными следует использовать с осторожностью, но иногда он бывает весьма полезен.
Получение правильных правил гигиены может быть серьезной задачей. Прежде чем использовать макрос, вы можете рассмотреть, будет ли достаточно замыкания функции. Еще одна полезная стратегия — отложить как можно больше работы на время выполнения. Например, многие макросы просто оборачивают свои аргументы в QuoteNode
или другой подобный Expr
. Некоторые примеры этого включают @task body
, который просто возвращает schedule(Task(() -> $body))
, и @eval expr
, который просто возвращает eval(QuoteNode(expr))
.
Чтобы продемонстрировать, мы можем переписать пример @time
выше как:
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
Однако мы не делаем этого по уважительной причине: оборачивание expr
в новый блок области видимости (анонимная функция) также немного изменяет значение выражения (область видимости любых переменных в нем), в то время как мы хотим, чтобы @time
можно было использовать с минимальным воздействием на обернутый код.
Macros and dispatch
Макросы, как и функции Julia, являются универсальными. Это означает, что они также могут иметь несколько определений методов благодаря множественному диспетчеризации:
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
Однако следует помнить, что макрораспределение основано на типах AST, которые передаются макросу, а не на типах, к которым AST приводит во время выполнения:
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
Когда требуется значительное количество повторяющегося шаблонного кода, обычно его генерируют программно, чтобы избежать избыточности. В большинстве языков это требует дополнительного этапа сборки и отдельной программы для генерации повторяющегося кода. В Julia интерполяция выражений и eval
позволяют такой генерации кода происходить в обычном ходе выполнения программы. Например, рассмотрим следующий пользовательский тип
struct MyNumber
x::Float64
end
# output
для которого мы хотим добавить ряд методов. Мы можем сделать это программно в следующем цикле:
for op = (:sin, :cos, :tan, :log, :exp)
eval(quote
Base.$op(a::MyNumber) = MyNumber($op(a.x))
end)
end
# output
и теперь мы можем использовать эти функции с нашим пользовательским типом:
julia> x = MyNumber(π)
MyNumber(3.141592653589793)
julia> sin(x)
MyNumber(1.2246467991473532e-16)
julia> cos(x)
MyNumber(-1.0)
Таким образом, Julia действует как свой собственный preprocessor и позволяет генерировать код изнутри языка. Указанный выше код можно было бы написать немного короче, используя форму цитирования с префиксом :
:
for op = (:sin, :cos, :tan, :log, :exp)
eval(:(Base.$op(a::MyNumber) = MyNumber($op(a.x))))
end
Этот тип генерации кода на языке, однако, с использованием шаблона eval(quote(...))
, достаточно распространен, что в Julia есть макрос для сокращения этого шаблона:
for op = (:sin, :cos, :tan, :log, :exp)
@eval Base.$op(a::MyNumber) = MyNumber($op(a.x))
end
Макрос @eval
переписывает этот вызов так, чтобы он был точно эквивалентен вышеуказанным более длинным версиям. Для более длинных блоков сгенерированного кода аргумент выражения, переданный в 4d61726b646f776e2e436f64652822222c2022406576616c2229_40726566
, может быть блоком:
@eval begin
# multiple lines
end
Non-Standard String Literals
Recall from Strings that string literals prefixed by an identifier are called non-standard string literals, and can have different semantics than un-prefixed string literals. For example:
r"^\s*(?:#|$)"
производит regular expression object, а не строку.b"DATA\xff\u2200"
является byte array literal для[68,65,84,65,255,226,136,128]
.
Возможно, это удивительно, но эти поведения не закодированы в парсере или компиляторе Julia. Вместо этого они являются пользовательскими поведениями, предоставляемыми общим механизмом, который может использовать любой: строковые литералы с префиксом разбираются как вызовы макросов с особыми именами. Например, макрос регулярного выражения — это просто следующее:
macro r_str(p)
Regex(p)
end
Это всё. Этот макрос говорит о том, что буквальное содержимое строкового литерала r"^\s*(?:#|$)"
должно быть передано макросу @r_str
, и результат этого расширения должен быть помещён в синтаксическое дерево, где происходит строковый литерал. Другими словами, выражение r"^\s*(?:#|$)"
эквивалентно прямому помещению следующего объекта в синтаксическое дерево:
Regex("^\\s*(?:#|\$)")
Не только строковый литерал короче и гораздо удобнее, но он также более эффективен: поскольку регулярное выражение компилируется, а объект Regex
фактически создается когда код компилируется, компиляция происходит только один раз, а не каждый раз, когда код выполняется. Подумайте, если регулярное выражение встречается в цикле:
for line = lines
m = match(r"^\s*(?:#|$)", line)
if m === nothing
# non-comment
else
# comment
end
end
Поскольку регулярное выражение r"^\s*(?:#|$)"
компилируется и вставляется в синтаксическое дерево при разборе этого кода, выражение компилируется только один раз, а не каждый раз при выполнении цикла. Чтобы достичь этого без макросов, нужно было бы написать этот цикл так:
re = Regex("^\\s*(?:#|\$)")
for line = lines
m = match(re, line)
if m === nothing
# non-comment
else
# comment
end
end
Более того, если компилятор не может определить, что объект regex является постоянным во всех циклах, определенные оптимизации могут быть невозможны, что делает эту версию все еще менее эффективной, чем более удобная литеральная форма выше. Конечно, все еще существуют ситуации, когда нелитеральная форма более удобна: если необходимо интерполировать переменную в регулярное выражение, необходимо использовать этот более многословный подход; в случаях, когда шаблон регулярного выражения сам по себе динамичен, потенциально изменяясь при каждой итерации цикла, новый объект регулярного выражения должен быть создан на каждой итерации. Однако в подавляющем большинстве случаев регулярные выражения не создаются на основе данных времени выполнения. В большинстве этих случаев возможность записывать регулярные выражения как значения времени компиляции является бесценной.
Механизм пользовательских строковых литералов глубоко, profoundly мощен. Не только нестандартные литералы Julia реализованы с его помощью, но и синтаксис литералов команд (`echo "Hello, $person"`
) также реализован с использованием следующей на вид безобидной макроса:
macro cmd(str)
:(cmd_gen($(shell_parse(str)[1])))
end
Конечно, большое количество сложности скрыто в функциях, используемых в этом определении макроса, но это всего лишь функции, написанные полностью на Julia. Вы можете прочитать их исходный код и точно увидеть, что они делают – и все, что они делают, это конструируют объекты выражений, которые будут вставлены в синтаксическое дерево вашей программы.
Как и строковые литералы, литералы команд также могут быть предварены идентификатором, чтобы образовать то, что называется нестандартными литералами команд. Эти литералы команд разбираются как вызовы макросов со специальными именами. Например, синтаксис custom`literal`
разбирается как @custom_cmd "literal"
. Сам язык Julia не содержит никаких нестандартных литералов команд, но пакеты могут использовать этот синтаксис. Помимо различного синтаксиса и суффикса _cmd
вместо суффикса _str
, нестандартные литералы команд ведут себя точно так же, как нестандартные строковые литералы.
В случае, если два модуля предоставляют нестандартные строковые или командные литералы с одинаковым именем, возможно квалифицировать строковый или командный литерал с помощью имени модуля. Например, если и Foo
, и Bar
предоставляют нестандартный строковый литерал @x_str
, то можно написать Foo.x"literal"
или Bar.x"literal"
, чтобы устранить неоднозначность между ними.
Другой способ определить макрос будет выглядеть так:
macro foo_str(str, flag)
# do stuff
end
Этот макрос можно вызвать с помощью следующего синтаксиса:
foo"str"flag
Тип флага в вышеупомянутом синтаксисе будет String
с содержимым, которое следует за строковым литералом.
Generated functions
Очень специальный макрос — это @generated
, который позволяет вам определять так называемые сгенерированные функции. Эти функции имеют возможность генерировать специализированный код в зависимости от типов своих аргументов с большей гибкостью и/или меньшим количеством кода, чем то, что можно достичь с помощью множественной диспетчеризации. В то время как макросы работают с выражениями во время разбора и не могут получить доступ к типам своих входных данных, сгенерированная функция расширяется в то время, когда известны типы аргументов, но функция еще не скомпилирована.
Вместо выполнения каких-либо вычислений или действий, сгенерированное объявление функции возвращает цитируемое выражение, которое затем формирует тело для метода, соответствующего типам аргументов. Когда сгенерированная функция вызывается, выражение, которое она возвращает, компилируется и затем выполняется. Чтобы сделать это эффективно, результат обычно кэшируется. А чтобы сделать это выводимым, используется только ограниченный подмножество языка. Таким образом, сгенерированные функции предоставляют гибкий способ переноса работы с времени выполнения на время компиляции, за счет больших ограничений на допустимые конструкции.
При определении сгенерированных функций есть пять основных отличий от обычных функций:
- Вы аннотируете объявление функции с помощью макроса
@generated
. Это добавляет некоторую информацию в AST, которая позволяет компилятору знать, что это сгенерированная функция. - В теле сгенерированной функции у вас есть доступ только к типам аргументов – не к их значениям.
- Вместо того чтобы что-то вычислять или выполнять какое-то действие, вы возвращаете цитируемое выражение, которое, когда его оценят, сделает то, что вам нужно.
- Сгенерированные функции могут вызывать только те функции, которые были определены до определения сгенерированной функции. (Несоблюдение этого правила может привести к получению
MethodErrors
, ссылающихся на функции из будущего временного контекста.) - Сгенерированные функции не должны изменять или наблюдать за каким-либо неконстантным глобальным состоянием (включая, например, ввод-вывод, блокировки, нелокальные словари или использование
hasmethod
). Это означает, что они могут только читать глобальные константы и не могут иметь побочных эффектов. Другими словами, они должны быть полностью чистыми. Из-за ограничения реализации это также означает, что в настоящее время они не могут определять замыкание или генератор.
Проще всего проиллюстрировать это на примере. Мы можем объявить сгенерированную функцию foo
как
julia> @generated function foo(x)
Core.println(x)
return :(x * x)
end
foo (generic function with 1 method)
Обратите внимание, что тело возвращает цитируемое выражение, а именно :(x * x)
, а не просто значение x * x
.
С точки зрения вызывающего, это идентично обычной функции; на самом деле, вам не нужно знать, вызываете ли вы обычную или сгенерированную функцию. Давайте посмотрим, как ведет себя foo
:
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"
Итак, мы видим, что в теле сгенерированной функции x
является типом переданного аргумента, а значение, возвращаемое сгенерированной функцией, является результатом вычисления цитируемого выражения, которое мы вернули из определения, теперь с значением x
.
Что произойдет, если мы снова оценим foo
с типом, который мы уже использовали?
julia> foo(4)
16
Обратите внимание, что нет распечатки Int64
. Мы видим, что тело сгенерированной функции было выполнено только один раз здесь, для конкретного набора типов аргументов, и результат был кэширован. После этого, для этого примера, выражение, возвращенное из сгенерированной функции при первом вызове, было повторно использовано в качестве тела метода. Однако фактическое поведение кэширования является оптимизацией производительности, определяемой реализацией, поэтому недопустимо слишком сильно полагаться на это поведение.
Количество раз, когда генерируемая функция вызывается, может быть только один раз, но она может также вызываться чаще или, кажется, не вызываться вовсе. В результате, вы никогда не должны писать генерируемую функцию с побочными эффектами - когда и как часто происходят побочные эффекты, не определено. (Это также верно для макросов - и, как и в случае с макросами, использование eval
в генерируемой функции является признаком того, что вы делаете что-то неправильно.) Однако, в отличие от макросов, система выполнения не может корректно обработать вызов 4d61726b646f776e2e436f64652822222c20226576616c2229_40726566
, поэтому это запрещено.
Важно также увидеть, как функции @generated
взаимодействуют с переопределением методов. Следуя принципу, что корректная функция @generated
не должна наблюдать за каким-либо изменяемым состоянием или вызывать мутацию глобального состояния, мы наблюдаем следующее поведение. Обратите внимание, что сгенерированная функция не может вызывать никакой метод, который не был определен до определения самой сгенерированной функции.
Изначально f(x)
имеет одно определение
julia> f(x) = "original definition";
Определите другие операции, которые используют f(x)
:
julia> g(x) = f(x);
julia> @generated gen1(x) = f(x);
julia> @generated gen2(x) = :(f(x));
Теперь мы добавляем некоторые новые определения для f(x)
:
julia> f(x::Int) = "definition for Int";
julia> f(x::Type{Int}) = "definition for Type{Int}";
и сравните, как эти результаты различаются:
julia> f(1)
"definition for Int"
julia> g(1)
"definition for Int"
julia> gen1(1)
"original definition"
julia> gen2(1)
"definition for Int"
Каждый метод сгенерированной функции имеет свой собственный взгляд на определенные функции:
julia> @generated gen1(x::Real) = f(x);
julia> gen1(1)
"definition for Type{Int}"
Сгенерированная функция foo
, приведенная выше, не делала ничего такого, что не могла бы сделать обычная функция foo(x) = x * x
(за исключением вывода типа при первом вызове и увеличения накладных расходов). Однако сила сгенерированной функции заключается в ее способности вычислять разные цитируемые выражения в зависимости от типов, переданных ей:
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"
(хотя, конечно, этот искусственный пример было бы легче реализовать с использованием множественной диспетчеризации...)
Злоупотребление этим приведет к повреждению системы выполнения и вызовет неопределенное поведение:
julia> @generated function baz(x)
if rand() < .9
return :(x^2)
else
return :("boo!")
end
end
baz (generic function with 1 method)
Поскольку тело сгенерированной функции является недетерминированным, ее поведение, а также поведение всего последующего кода неопределено.
Не копируйте эти примеры!
Эти примеры, надеюсь, помогут проиллюстрировать, как работают сгенерированные функции, как в определении, так и на месте вызова; однако, не копируйте их, по следующим причинам:
- функция
foo
имеет побочные эффекты (вызовCore.println
), и не определено, когда, как часто или сколько раз эти побочные эффекты будут происходить - функция
bar
решает задачу, которая лучше решается с помощью множественной диспетчеризации - определениеbar(x) = x
иbar(x::Integer) = x ^ 2
сделает то же самое, но это и проще, и быстрее. - функция
baz
является патологической
Обратите внимание, что набор операций, которые не следует пытаться выполнять в сгенерированной функции, неограничен, и в настоящее время система выполнения может обнаружить только подмножество недопустимых операций. Существует множество других операций, которые просто испортят систему выполнения без уведомления, обычно тонкими способами, не очевидно связанными с плохим определением. Поскольку генератор функций запускается во время вывода, он должен учитывать все ограничения этого кода.
Некоторые операции, которые не следует пытаться выполнять, включают:
Кэширование нативных указателей.
Взаимодействие с содержимым или методами
Core.Compiler
любым способом.Наблюдение за любым изменяемым состоянием.
- Вывод по сгенерированной функции может выполняться в любое время, включая тот момент, когда ваш код пытается наблюдать или изменять это состояние.
При использовании любых блокировок: код на C, к которому вы обращаетесь, может использовать блокировки внутри, (например, не является проблемой вызывать
malloc
, хотя большинство реализаций требуют блокировок внутри), но не пытайтесь удерживать или захватывать какие-либо блокировки во время выполнения кода Julia.Вызов любой функции, которая определена после тела сгенерированной функции. Это условие ослаблено для инкрементально загружаемых предварительно скомпилированных модулей, чтобы разрешить вызов любой функции в модуле.
Хорошо, теперь, когда мы лучше понимаем, как работают сгенерированные функции, давайте используем их для создания более сложной (и действительной) функциональности...
An advanced example
Библиотека Julia имеет внутреннюю функцию sub2ind
, которая вычисляет линейный индекс в n-мерном массиве на основе набора из n многомерных индексов - другими словами, для вычисления индекса i
, который можно использовать для индексации массива A
с помощью A[i]
, вместо A[x,y,z,...]
. Одной из возможных реализаций является следующая:
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
То же самое можно сделать с помощью рекурсии:
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
Обе эти реализации, хотя и разные, по сути выполняют одну и ту же задачу: цикл выполнения по измерениям массива, собирая смещение в каждом измерении в конечный индекс.
Однако вся информация, необходимая для цикла, встроена в типовую информацию аргументов. Это позволяет компилятору перенести итерацию на время компиляции и полностью устранить циклы во время выполнения. Мы можем использовать сгенерированные функции для достижения аналогичного эффекта; в терминологии компилятора мы используем сгенерированные функции для ручного развертывания цикла. Тело становится почти идентичным, но вместо вычисления линейного индекса мы создаем выражение, которое вычисляет индекс:
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
Какой код это сгенерирует?
Простой способ выяснить это - вынести тело в другую (обычную) функцию:
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
Теперь мы можем выполнить sub2ind_gen_impl
и изучить выражение, которое оно возвращает:
julia> sub2ind_gen_impl(Tuple{Int,Int}, Int, Int)
:(((I[1] - 1) + dims[1] * (I[2] - 1)) + 1)
Итак, тело метода, которое будет использоваться здесь, не включает в себя цикл вообще - только индексация в двух кортежах, умножение и сложение/вычитание. Все циклы выполняются на этапе компиляции, и мы полностью избегаем циклов во время выполнения. Таким образом, мы выполняем цикл один раз для каждого типа, в данном случае один раз для N
(за исключением крайних случаев, когда функция генерируется более одного раза - см. отказ от ответственности выше).
Optionally-generated functions
Сгенерированные функции могут достигать высокой эффективности во время выполнения, но имеют стоимость компиляции: для каждой комбинации конкретных типов аргументов должно быть сгенерировано новое тело функции. Обычно Julia может компилировать "универсальные" версии функций, которые будут работать с любыми аргументами, но с сгенерированными функциями это невозможно. Это означает, что программы, активно использующие сгенерированные функции, могут быть невозможны для статической компиляции.
Чтобы решить эту проблему, язык предоставляет синтаксис для написания обычных, негенерируемых альтернативных реализаций сгенерированных функций. Применительно к примеру sub2ind
выше, это будет выглядеть так:
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
Внутри этот код создает две реализации функции: сгенерированную, где используется первый блок в if @generated
, и обычную, где используется блок else
. Внутри части then
блока if @generated
код имеет ту же семантику, что и другие сгенерированные функции: имена аргументов ссылаются на типы, и код должен возвращать выражение. Может быть несколько блоков if @generated
, в этом случае сгенерированная реализация использует все блоки then
, а альтернативная реализация использует все блоки else
.
Обратите внимание, что мы добавили проверку на ошибки в начало функции. Этот код будет общим для обеих версий и является кодом времени выполнения в обеих версиях (он будет процитирован и возвращен как выражение из сгенерированной версии). Это означает, что значения и типы локальных переменных недоступны во время генерации кода – код генерации кода может видеть только типы аргументов.
В этом стиле определения функция генерации кода по сути является необязательной оптимизацией. Компилятор будет использовать её, если это удобно, но в противном случае может выбрать использование обычной реализации. Этот стиль предпочтителен, так как он позволяет компилятору принимать больше решений и компилировать программы различными способами, а также потому, что обычный код более читаем, чем код, генерирующий код. Однако то, какая реализация используется, зависит от деталей реализации компилятора, поэтому важно, чтобы две реализации вели себя идентично.