Метапрограммирование
Наиболее сильным наследием языка Lisp в Julia является поддержка метапрограммирования. Как и в Lisp, Julia представляет собственный код как структуру данных языка. Поскольку код представлен объектами, которые могут быть созданы и изменены внутри языка, программа может преобразовывать и генерировать собственный код. Это позволяет выполнять сложную генерацию кода без дополнительных шагов сборки, а также позволяет использовать макросы в стиле Lisp на уровне абстрактных синтаксических деревьев. В отличие от систем макросов препроцессоров, таких как в C и C++, которые выполняют текстовые манипуляции и подстановки до любого фактического разбора или интерпретации. Поскольку все типы данных и код в Julia представлены структурами данных 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("func",10)
:func10
julia> Symbol(:var,'_',"sym")
:var_sym
Обратите внимание, что для использования синтаксиса : имя символа должно быть допустимым идентификатором. В противном случае должен использоваться конструктор Symbol(str).
В контексте выражения символы используются для обозначения доступа к переменным; при вычислении выражения символ заменяется значением, связанным с этим символом в соответствующей области видимости.
Иногда для избежания неоднозначности при разборе необходимы дополнительные скобки вокруг аргумента для ::
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: функция 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 ) о контексте расширения вызова макроса. Это позволяет макросам находить контекстную информацию, например, существующие привязки, или вставлять значение в качестве дополнительного аргумента в вызов функции во время выполнения, осуществляющей самооценку в текущем модуле.
Разработка расширенного макроса
Вот упрощенное определение макроса @assert Julia:
julia> macro assert(ex)
return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
end
@assert (macro with 1 method)
Этот макрос можно использовать следующим образом:
julia> @assert 1 == 1.0 julia> @assert 1 == 0 ERROR: AssertionError: 1 == 0
Вместо написанного синтаксиса вызов макроса расширяется в момент разбора до его возвращаемого результата. Это эквивалентно записи:
1 == 1.0 ? nothing : throw(AssertionError("1 == 1.0"))
1 == 0 ? nothing : throw(AssertionError("1 == 0"))
То есть в первом вызове выражение :(1 == 1.0) вставлено в позицию условия теста, а значение string(:(1 == 1.0)) вставлено в позицию сообщения об утверждении. Всё построенное выражение помещается в дерево синтаксиса, где происходит вызов макроса @assert. Затем при выполнении, если выражение теста истинно, возвращается nothing, а если тест ложный, возникает ошибка, указывающая на утверждаемое выражение, которое было ложным. Заметьте, что написать это как функцию не представляется возможным, поскольку доступно только значение условия, и невозможно отобразить выражение, вычисляющее его, в сообщении об ошибке.
Фактическое определение макроса @assert в Julia Base более сложное. Оно позволяет пользователю необязательно указывать собственное сообщение об ошибке вместо простого вывода невыполнимого выражения. Так же как и в функциях с переменным числом аргументов (Функции с переменным числом аргументов), это указывается с помощью многоточия после последнего аргумента:
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. Это добавляет некоторую информацию в АСТ, позволяя компилятору узнать, что это сгенерированная функция. - В теле сгенерированной функции вы имеете доступ только к типам аргументов, а не к их значениям.
- Вместо вычисления чего-либо или выполнения какого-либо действия вы возвращаете выражение в кавычках, которое при вычислении выполняет то, что вы хотите.
- Сгенерированные функции допускается вызывать только функции, которые были определены до определения сгенерированной функции. (Несоблюдение этого может привести к получению
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–2021 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.7.0/manual/metaprogramming/