Документация Engee

Метапрограммирование

Самое важное наследие 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 содержат две части:

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` не сработает, так как `1foo` не является допустимым именем
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: функцию 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 есть выделенный символ: @ (коммерческое at), за которым следует объявленное в блоке 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)
           # …оставшаяся часть макроса, возвращается выражение
       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. Затем во время выполнения, если при вычислении тестового выражения оно получает значение true, то возвращается nothing, тогда как если значение — false, выдается ошибка, указывающая на то, что утверждаемое выражение имело значение false. Заметьте, что невозможно записать это как функцию, так как доступно только значение условия, и невозможно отобразить выражение, которое вычислило его, в сообщении об ошибке.

Фактическое определение @assert в Julia Base более сложное. Оно позволяет пользователю указывать собственные варианты сообщений об ошибках, а не просто выводить на экран выражение со сбоем. Так же как и в функциях с переменным число аргументов (Функции с переменным числом аргументов (Vararg)), это обозначается многоточием после последнего аргумента:

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 ( 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() = ... # вычисляет что-то

@time time_ns()
end

Здесь пользовательское выражение ex является вызовом time_ns, но не той же функции time_ns, которую использует макрос. Оно явно ссылается на MyModule.time_ns. Поэтому мы должны сделать так, чтобы код в ex разрешался в среду вызова макросов. Это делается путем добавления к выражению escape-последовательностей esc:

macro time(ex)
    ...
    local val = $(esc(ex))
    ...
end

Выражение, обернутое таким образом, остается нетронутым макрорасширителем и просто дословно вставляется в выходные данные. Следовательно, оно будет разрешено в среду вызова макросов.

Этот механизм использования escape-последовательностей при необходимости можно использовать для «нарушения» правил гигиены, чтобы вводить пользовательские переменные или управлять ими. Например, следующий макрос задает для x значение 0 в среде вызова:

julia> macro zerox()
           return esc(:(x = 0))
       end
@zerox (macro with 1 method)

julia> function foo()
           x = 1
           @zerox
           return x # равно нулю
       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
# вывод

в который мы хотим добавить несколько методов. Мы можем сделать это программно в следующем цикле:

for op = (:sin, :cos, :tan, :log, :exp)
    eval(quote
        Base.$op(a::MyNumber) = MyNumber($op(a.x))
    end)
end
# вывод

и теперь мы можем использовать эти функции с нашим пользовательским типом:

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
    # несколько строк
end

Нестандартные строковые литералы

Вспомните, что в разделе Строки говорилось, что строковые литералы с идентификатором в качестве префикса называются нестандартными строковыми литералами, и их семантика будет отличаться от семантики строковых литералов без префикса. Пример:

Как это ни удивительно, эти варианты поведения не закодированы жестко в анализаторе или компиляторе 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
        # не комментарий
    else
        # комментарий
    end
end

Так как регулярное выражение r"^\s*(?:#|$)" компилируется и вставляется в синтаксическое дерево, когда этот код анализируется, выражение компилируется только один раз, а не каждый раз, когда выполняется цикл. Чтобы завершить это без макросов, пришлось бы записать этот цикл следующим образом:

re = Regex("^\\s*(?:#|\$)")
for line = lines
    m = match(re, line)
    if m === nothing
        # не комментарий
    else
        # комментарий
    end
end

Более того, если компилятор не смог определить, что объект регулярного выражения не изменялся во всех циклах, определенные оптимизации могут оказаться невозможными, что все равно делает эту версию менее эффективной, чем приведенная выше более удобная литеральная форма. Конечно, все равно встречаются ситуации, когда нелитеральная форма более удобна: если нужно интерполировать переменную в регулярное выражение, следует выбрать этот более многословный подход; в случаях, когда сам шаблон регулярного выражения динамический и может изменяться при каждой итерации цикла, необходимо конструировать новый объект регулярного выражения при каждой итерации. Однако в подавляющем большинстве вариантов использования регулярные выражения не конструируются на основе данных времени выполнения. В этом большинстве случаев возможность записывать регулярные выражения как значения времени компиляции бесценна.

Механизм для работы с определяемыми пользователем строковыми литералами чрезвычайно эффективен. С его помощью реализуются не только нестандартные литералы 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)
    # сделай что-нибудь
end

Затем этот макрос можно вызвать с помощью следующего синтаксиса:

foo"str"flag

Типом флага в упомянутом выше синтаксисе будет строка (String), содержащая все, что находится после строкового литерала.

Генерируемые функции

Весьма особенным макросом является @generated, который позволяет определять так называемые генерируемые функции. У них есть возможность генерировать специализированный код в зависимости от типов своих аргументов с большей гибкостью и (или) меньшим объемом кода, чем можно достичь с помощью множественной диспетчеризации. В то время как макросы работают с выражениями во время отладки и не могут получить доступ к типам их входных данных, генерируемая функция расширяется, когда типы аргументов известны, но функция еще не скомпилирована.

Вместо выполнения какого-то вычисления или действия объявление генерируемой функции возвращает цитируемое выражение, которое затем формирует тело метода, соответствующего типам аргументов. Когда вызывается генерируемая функция, выражение, которое она возвращает, компилируется, а затем выполняется. Чтобы сделать этот процесс эффективным, результат обычно кэшируется. А чтобы сделать его выводимым, для использования пригодно только ограниченное подмножество языка. Таким образом, генерируемые функции предоставляют гибкий способ перенести работу со времени выполнения на время компиляции, за счет больших ограничений на разрешенные конструкции.

Если говорить о генерируемых функциях, существует пять основных их отличий от обычных:

  1. Объявление функции отмечается макросом @generated. Это добавляет какую-то информацию в AST, что позволяет компилятору знать, что это генерируемая функция.

  2. В теле генерируемой функции у вас есть доступ только к типам аргументов, а не к их значениям.

  3. Вместо вычисления чего-либо или выполнения какого-то действия вы возвращаете цитируемое выражение, которое, когда будет вычислено, делает то, что вы хотите.

  4. Генерируемым функциям разрешается вызывать только функции, которые были определены до определения генерируемой функции. (Несоблюдение этого может привести к получению MethodErrors, ссылающихся на функции из будущей иерархии определения методов «возраст мира».)

  5. Генерируемые функции не должны изменять или наблюдать любые непостоянные глобальные состояния (в том числе, например, операции ввода-вывода, блокировки, нелокальные словари или использование 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); # примечание: выходные данные из выражения println() в теле
Int64

julia> x           # теперь мы выводим на экран 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 является «патологической»

Обратите внимание, что набор операций, которые не следует пытаться использовать в генерируемой функции, неограничен, а среда выполнения в настоящее время может только обнаруживать подмножество недопустимых операций. Есть множество других операций, которые без уведомления просто нарушат работу среды выполнения, обычно тонким образом, неочевидно связанным с неверным определением. Так как во время вывода запускается генератор функций, он должен соблюдать все ограничения этого кода.

Вот некоторые операции, которые не следует пытаться выполнить:

  1. Кэширование собственных указателей.

  2. Взаимодействие с содержимым или методами Core.Compiler любыми способами.

  3. Наблюдение любого изменяемого состояния.

    • Вывод для генерируемой функции можно выполнить в любое время, в том числе когда ваш код пытается наблюдать или изменить это состояние.

  4. Использование каких-либо блокировок: Код C, к которому вы отправляете вызов, может внутренне использовать блокировки (например, не является проблемой вызвать malloc, несмотря на то, что в большинстве реализаций блокировки требуются внутренне), но не пытайтесь удерживать или получать блокировки при выполнении кода Julia.

  5. Вызов любой функции, которая определена после тела генерируемой функции. Это условие является нестрогим для инкрементально загружаемых предкомпилированных модулей, чтобы разрешить вызов любой функции в модуле.

Хорошо. Теперь, когда вы лучше понимаете, как работают генерируемые функции, давайте используем их для создания несколько более продвинутой (и допустимой) функциональности…​

Продвинутый пример

Базовая библиотека 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.

Заметьте, что мы добавили проверку ошибок поверх функции. Этот код будет общим для обеих версий и является кодом времени выполнения в обеих версиях (он будет процитирован и возвращен как выражение из генерируемой версии). Это означает, что значения и типы локальных переменных недоступны во время генерации кода — код для генерации кода может видеть только типы аргументов.

В этом стиле определение функция генерации кода представляет собой главным образом необязательную оптимизацию. Компилятор будет использовать ее, если это удобно, но в противном случае может использовать вместо нее нормальную реализацию. Этот стиль является предпочтительным, так как он позволяет компилятору принимать дополнительные решения и компилировать программы большим числом способов, а нормальный код более читабельный, чем код, сгенерированный с помощью кода. Однако то, какая реализация будет использоваться, зависит от деталей реализации компилятора, поэтому важно, чтобы две реализации вели себя идентично.