Метапрограммирование
Самым сильным наследием языка Lisp в Julia является поддержка метапрограммирования. Как и Lisp, Julia представляет свой собственный код как структуру данных языка. Поскольку код представлен объектами, которые можно создавать и изменять внутри языка, программа может преобразовывать и генерировать свой собственный код. Это позволяет создавать сложный код без дополнительных этапов сборки, а также позволяет использовать макросы в стиле Lisp на уровне абстрактных синтаксических деревьев. В отличие от систем макросов препроцессора, таких как в C и C++, они выполняют текстовую обработку и подстановку перед любым фактическим разбором или интерпретацией. Поскольку все типы данных и код в Julia представлены структурами данных Julia, доступны мощные возможности рефлексии для изучения внутренней структуры программы и её типов, как и любого другого данных.
Метапрограммирование — мощный инструмент, но оно добавляет сложность, которая может затруднить понимание кода. Например, удивительно трудно соблюдать правила области видимости. Метапрограммирование обычно следует применять только тогда, когда другие подходы, такие как функции высшего порядка и замыкания, неприменимы.
eval и определение новых макросов следует использовать в качестве крайней меры. Почти никогда не рекомендуется использовать Meta.parse или преобразовывать произвольную строку в код Julia. Для работы с кодом Julia используйте структуру данных Expr напрямую, чтобы избежать сложности синтаксического разбора Julia.
Лучшие применения метапрограммирования часто реализуют большую часть своей функциональности в вспомогательных функциях во время выполнения, стремясь минимизировать объём генерируемого кода.
Представление программы
Каждая программа Julia начинается как строка:
julia> prog = "1 + 1" "1 + 1"
Что происходит дальше?
Следующим шагом является разбор каждой строки в объект, называемый выражением, представленный типом Julia Expr:
julia> ex1 = Meta.parse(prog) :(1 + 1) julia> typeof(ex1) Expr
Expr объекты содержат две части:
- символ
Symbol, определяющий тип выражения. Символ — это интернированный строковый идентификатор (подробнее об этом ниже).
julia> ex1.head :call
- аргументы выражения, которые могут быть символами, другими выражениями или литеральными значениями:
julia> ex1.args
3-element Vector{Any}:
:+
1
1
Выражения также могут быть построены непосредственно в префиксной нотации:
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-выражения данного Expr, что может показаться очень знакомым пользователям Lisp. Вот пример, иллюстрирующий вывод для вложенного Expr:
julia> Meta.show_sexpr(ex3) (:call, :/, (:call, :+, 4, 4), 2)
Символы
Символ : имеет два синтаксических назначения в Julia. Первая форма создаёт Symbol, интернированную строку, используемую в качестве одного из строительных блоков выражений, из допустимых идентификаторов:
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
В контексте выражения символы используются для указания доступа к переменным; при оценке выражения символ заменяется значением, связанным с этим символом в соответствующей области видимости.
Иногда для избежания неоднозначности в синтаксическом разборе необходимы дополнительные скобки вокруг аргумента функции ::
julia> :(:) :(:) julia> :(::) :(::)
Выражения и оценка
Квотирование
Второе синтаксическое назначение символа : — создание объектов выражений без явного использования конструктора 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
Интерполяция
Прямое создание объектов 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))
Использование $ для интерполяции выражений намеренно напоминает интерполяцию строк и интерполяцию команд. Интерполяция выражений позволяет удобно и наглядно программно строить сложные выражения Julia.
Интерполяция разбросанными элементами
Обратите внимание, что синтаксис интерполяции $ позволяет вставлять только одно выражение в окружающее выражение. Иногда у вас есть массив выражений, и вам нужно, чтобы все они стали аргументами окружающего выражения. Это можно сделать с помощью синтаксиса $(xs...). Например, следующий код генерирует вызов функции, где число аргументов определяется программно:
julia> args = [:x, :y, :z]; julia> :(f(1, $(args...))) :(f(1, x, y, z))
Вложенное квотирование
Естественно, квотированные выражения могут содержать другие квотированные выражения. Понимание того, как работает интерполяция в этих случаях, может быть немного сложным. Рассмотрим этот пример:
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 может интерполировать значения внутри $ во внутреннем квотированном выражении. Это делается с помощью нескольких $:
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
Обычное представление формы quote в AST — это Expr с заголовком :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 также можно использовать для некоторых сложных задач метапрограммирования.
Оценка выражений
Учитывая объект выражения, можно заставить 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
Каждый модуль имеет собственную функцию eval, которая вычисляет выражения в его глобальной области видимости. Выражения, передаваемые функции eval, не ограничены возвращением значений; они также могут иметь побочные эффекты, изменяющие состояние среды окружения модуля:
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.
Функции над Exprжениями
Как уже упоминалось выше, одной из чрезвычайно полезных функций 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
Макросы
Макросы обеспечивают механизм включения сгенерированного кода в окончательный текст программы. Макрос отображает кортеж аргументов в возвращаемое выражение, и полученное выражение компилируется непосредственно, а не требует вызова eval во время выполнения. Аргументы макроса могут включать выражения, литералы и символы.
Основы
Вот чрезвычайно простой макрос:
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"))
Подождите: зачем макросы?
Мы уже видели функцию 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)
Вызов макроса
Макросы вызываются с помощью следующего общего синтаксиса:
@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 ) о контексте расширения вызова макроса. Это позволяет макросам получать контекстную информацию, например, существующие привязки, или вставлять значение в качестве дополнительного аргумента в вызов функции во время выполнения, осуществляющей самооценку в текущем модуле.
Создание расширенного макроса
Вот упрощённое определение макроса Julia @assert:
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 более сложное. Оно позволяет пользователю по желанию указывать собственное сообщение об ошибке вместо простого вывода невыполненного выражения. Как и в функциях с переменным числом аргументов (Функции с переменным числом аргументов), это указывается с помощью многоточия после последнего аргумента:
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 , будет пустым, и он будет вести себя так же, как более простое определение выше. Но теперь, если пользователь укажет второй аргумент, он выводится в теле сообщения вместо невыполненного выражения. Результат расширения макроса можно проверить с помощью макроса @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) should equal b ($b)!", но это не сработает так, как ожидается, с вышеуказанным макросом. Вы видите, почему? Вспомните из раздела интерполяции строк, что интерполированная строка переписывается в вызов 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 эффективно использует вставку в цитируемые выражения для упрощения манипулирования выражениями внутри тела макроса.
Гигиена
Проблема, возникающая в более сложных макросах, — это проблема гигиены. Короче говоря, макросы должны гарантировать, что переменные, которые они вводят в своих возвращаемых выражениях, не конфликтуют случайно с существующими переменными в окружающем коде, в который они развертываются. Напротив, выражения, которые передаются в макрос в качестве аргументов, часто ожидаются вычисляться в контексте окружающего кода, взаимодействуя и изменяя существующие переменные. Ещё одна проблема возникает из того, что макрос может быть вызван в другом модуле, нежели тот, где он был определён. В этом случае нам нужно убедиться, что все глобальные переменные разрешаются к нужному модулю. Julia уже обладает существенным преимуществом перед языками с текстовой макрорасширкой (такими как C), так как ей необходимо учитывать только возвращаемое выражение. Все остальные переменные (например, msg в @assert выше) следуют поведению обычной области видимости.
Чтобы продемонстрировать эти проблемы, давайте рассмотрим написание макроса @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 был пригоден для использования с минимальным влиянием на заключённый код.
Макросы и диспетчеризация
Макросы, как и функции 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
Генерация кода
Когда требуется значительное количество повторяющегося кода-шаблона, обычно его генерируют программно, чтобы избежать избыточности. В большинстве языков это требует дополнительного этапа сборки и отдельной программы для генерации повторяющегося кода. В 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 действует как собственный препроцессор и позволяет генерировать код изнутри языка. Код выше можно записать несколько более кратко, используя префиксное цитирование :.
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 переписывает этот вызов точно так же, как вышеупомянутые более длинные версии. Для больших блоков генерируемого кода аргументом выражения, переданным @eval, может быть блок:
@eval begin
# multiple lines
end
Нестандартные строковые литералы
Напомним из Строки, что строковые литералы с префиксом из идентификатора называются нестандартными строковыми литералами и могут иметь другое семантическое значение, чем строковые литералы без префикса. Например:
-
r"^\s*(?:#|$)"создаёт объект регулярного выражения, а не строку -
b"DATA\xff\u2200"— это литерал массива байтов для[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 был константным во всех циклах, некоторые оптимизации могли бы быть невозможны, делая эту версию по-прежнему менее эффективной, чем более удобная форма литерала выше. Конечно, есть ситуации, когда нелитеральная форма удобнее: если нужно интерполировать переменную в регулярное выражение, нужно использовать более объёмный подход; в случаях, когда шаблон регулярного выражения сам по себе динамичен, потенциально изменяясь на каждой итерации цикла, новый объект регулярного выражения должен быть создан на каждой итерации. Однако в подавляющем большинстве случаев регулярные выражения не строятся на основе данных во время выполнения. В этом большинстве случаев возможность написания регулярных выражений как значений во время компиляции — неоценимо.
Механизм для пользовательских строковых литералов глубоко, сильно мощный. С его помощью реализованы не только нестандартные литералы 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, который позволяет определять так называемые сгенерированные функции. Они обладают возможностью генерировать специализированный код в зависимости от типов их аргументов с большей гибкостью и/или меньшим объемом кода, чем то, что может быть достигнуто с помощью множественной диспетчеризации. В то время как макросы работают с выражениями на этапе парсинга и не могут получить доступ к типам своих входных данных, сгенерированная функция расширяется в момент, когда типы аргументов известны, но функция еще не скомпилирована.
Вместо выполнения какого-либо вычисления или действия, объявление сгенерированной функции возвращает цитированное выражение, которое затем формирует тело метода, соответствующего типам аргументов. Когда вызывается сгенерированная функция, выражение, которое она возвращает, компилируется и затем выполняется. Чтобы это было эффективным, результат обычно кэшируется. И для того, чтобы это было выводимо, используется только ограниченный подмножество языка. Таким образом, сгенерированные функции предоставляют гибкий способ перемещения работы из времени выполнения в время компиляции, ценой больших ограничений на разрешенные конструкции.
При определении сгенерированных функций существует пять основных отличий от обычных функций:
- Вы помечаете объявление функции макросом
@generated. Это добавляет некоторую информацию в AST, которая позволяет компилятору узнать, что это сгенерированная функция. - В теле сгенерированной функции вы имеете доступ только к типам аргументов, а не к их значениям.
- Вместо вычисления чего-либо или выполнения какого-либо действия, вы возвращаете цитируемое выражение, которое, при оценке, делает то, что вы хотите.
- Сгенерированные функции разрешается вызывать только функции, которые были определены до определения сгенерированной функции. (Несоблюдение этого может привести к получению
MethodErrors, относящегося к функциям из будущего.) - Сгенерированные функции не должны изменять или наблюдать за любым неконстантным глобальным состоянием (включая, например, IO, блокировки, нелокальные словари или использование
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 в сгенерированной функции является признаком того, что вы делаете что-то неправильно.) Однако, в отличие от макросов, система выполнения не может правильно обработать вызов eval, поэтому он запрещён.
Важно также увидеть, как функции @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.Вызов любой функции, которая определяется после тела сгенерированной функции. Это условие ослаблено для модулей, загружаемых по частям, чтобы разрешить вызов любой функции в модуле.
Хорошо, теперь, когда мы лучше понимаем, как работают сгенерированные функции, давайте используем их для построения более сложной (и корректной) функциональности...
Расширенный пример
Базовая библиотека 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 (за исключением особых случаев, когда функция генерируется более одного раза — см. оговорку выше).
Дополнительные генерируемые функции
Сгенерированные функции могут обеспечить высокую эффективность во время выполнения, но имеют свою стоимость на этапе компиляции: для каждой комбинации конкретных типов аргументов должна генерироваться новая функция. Обычно 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.
Обратите внимание, что мы добавили проверку ошибок в начало функции. Этот код будет общим для обеих версий и представляет собой код времени выполнения в обеих версиях (он будет процитирован и возвращен как выражение из сгенерированной версии). Это означает, что значения и типы локальных переменных недоступны на этапе генерации кода — код генерации кода может видеть только типы аргументов.
В этом стиле определения функция генерации кода по сути является необязательной оптимизацией. Компилятор будет использовать её, если это удобно, но в противном случае может выбрать использование обычной реализации вместо неё. Этот стиль предпочтительнее, так как он позволяет компилятору принимать больше решений и компилировать программы более разнообразными способами, а обычный код более удобочитаем, чем код, генерирующий код. Тем не менее, какая реализация используется, зависит от деталей реализации компилятора, поэтому крайне важно, чтобы две реализации вели себя идентично.
© 2009–2024 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.11/manual/metaprogramming/