Метапрограммирование
Самым сильным наследием языка 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 [...] julia> a = 1; b = 2; julia> eval(ex) 3
Каждый модуль имеет собственную функцию eval, которая вычисляет выражения в его глобальной области видимости. Выражения, передаваемые функции eval, не ограничены возвращением значений — они также могут иметь побочные эффекты, изменяющие состояние среды модуля:
julia> ex = :(x = 1) :(x = 1) julia> x ERROR: UndefVarError: `x` not defined 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!"))
Помимо указанного списка аргументов, каждый макрос получает дополнительные аргументы, названные __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, относящегося к функциям из будущего.) - Сгенерированные функции не должны изменять или наблюдать за каким-либо неконстантным глобальным состоянием (включая, например, ввод/вывод, блокировки, нелокальные словари или использование
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
sub2ind_loop (generic function with 1 method)
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
sub2ind_gen (generic function with 1 method)
julia> sub2ind_gen((3, 5), 1, 2)
4
Какой код будет сгенерирован?
Легкий способ узнать - извлечь тело в другую (обычную) функцию:
julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
return sub2ind_gen_impl(dims, I...)
end
sub2ind_gen (generic function with 1 method)
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
sub2ind_gen_impl (generic function with 1 method)
Теперь мы можем выполнить sub2ind_gen_impl и изучить выражение, которое она возвращает:
julia> sub2ind_gen_impl(Tuple{Int,Int}, Int, Int)
:(((I[1] - 1) + dims[1] * (I[2] - 1)) + 1)
Итак, тело метода, которое будет использовано здесь, вообще не содержит цикла — только индексирование в двух кортежах, умножение и сложение/вычитание. Все циклы выполняются на этапе компиляции, и мы полностью избегаем циклов во время выполнения. Таким образом, мы выполняем цикл только один раз на тип, в этом случае один раз на N (за исключением особых случаев, когда функция генерируется более одного раза — см. оговорку выше).
Дополнительные генерируемые функции
Сгенерированные функции могут обеспечить высокую эффективность во время выполнения, но имеют свою стоимость на этапе компиляции: для каждой комбинации конкретных типов аргументов должна генерироваться новая функция. Обычно Julia способна компилировать «общие» версии функций, которые будут работать с любыми аргументами, но сгенерированные функции это сделать не позволяют. Это означает, что программы, активно использующие сгенерированные функции, могут быть нестатически компилируемыми.
Для решения этой проблемы язык предоставляет синтаксис для написания обычных, не сгенерированных альтернативных реализаций сгенерированных функций. Применительно к примеру sub2ind выше, это будет выглядеть так:
function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
if N != length(I)
throw(ArgumentError("Number of dimensions must match number of indices."))
end
if @generated
ex = :(I[$N] - 1)
for i = (N - 1):-1:1
ex = :(I[$i] - 1 + dims[$i] * $ex)
end
return :($ex + 1)
else
ind = I[N] - 1
for i = (N - 1):-1:1
ind = I[i] - 1 + dims[i]*ind
end
return ind + 1
end
end
Внутренне этот код создает две реализации функции: сгенерированную, где используется первый блок в 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.10/manual/metaprogramming/