Metaprogramming

Сильнейшее наследие Lisp в языке Julia — это поддержка метапрограммирования. Как и Lisp, Julia представляет свой собственный код в виде структуры данных самого языка. Поскольку код представлен объектами, которые могут быть созданы и изменены изнутри языка, программа может трансформировать и генерировать свой собственный код. Это позволяет осуществлять сложную генерацию кода без дополнительных этапов сборки, а также позволяет использовать настоящие макросы в стиле Lisp, работающие на уровне abstract syntax trees. В отличие от этого, системы "макросов" препроцессора, такие как в C и C++, выполняют текстовую манипуляцию и замену до того, как произойдет фактический разбор или интерпретация. Поскольку все типы данных и код в Julia представлены структурами данных Julia, мощные reflection возможности доступны для изучения внутренностей программы и ее типов так же, как и для любых других данных.

Warning

Метапрограммирование — это мощный инструмент, но оно вводит сложность, которая может сделать код более трудным для понимания. Например, может быть удивительно сложно правильно установить правила области видимости. Метапрограммирование обычно следует использовать только тогда, когда другие подходы, такие как higher order functions и closures, не могут быть применены.

eval и определение новых макросов обычно следует использовать в качестве последнего средства. Почти никогда не является хорошей идеей использовать Meta.parse или преобразовывать произвольную строку в код Julia. Для манипуляции кодом Julia используйте структуру данных Expr напрямую, чтобы избежать сложности, связанной с тем, как синтаксис Julia разбирается.

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

Program representation

Каждая программа на Julia начинается с строки:

julia> prog = "1 + 1"
"1 + 1"

Что будет дальше?

Следующий шаг - это parse каждую строку в объект, называемый выражением, представленным типом Julia Expr:

julia> ex1 = Meta.parse(prog)
:(1 + 1)

julia> typeof(ex1)
Expr

Expr объекты содержат две части:

  • a Symbol идентифицирует вид выражения. Символ является interned string идентификатором (более подробное обсуждение ниже).
julia> ex1.head
:call
  • аргументы выражения, которые могут быть символами, другими выражениями или литеральными значениями:
julia> ex1.args
3-element Vector{Any}:
  :+
 1
 1

Выражения также могут быть построены непосредственно в prefix notation:

julia> ex2 = Expr(:call, :+, 1, 1)
:(1 + 1)

Два выражения, построенные выше – путем парсинга и прямого построения – эквивалентны:

julia> ex1 == ex2
true

Ключевой момент здесь в том, что код Julia внутренне представлен в виде структуры данных, доступной из самого языка.

Функция dump обеспечивает отступленный и аннотированный вывод объектов Expr:

julia> dump(ex2)
Expr
  head: Symbol call
  args: Array{Any}((3,))
    1: Symbol +
    2: Int64 1
    3: Int64 1

Expr объекты также могут быть вложенными:

julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)

Другой способ просмотра выражений - это использование Meta.show_sexpr, который отображает S-expression форму данного Expr, которая может быть очень знакома пользователям Lisp. Вот пример, иллюстрирующий отображение на вложенном Expr:

julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)

Symbols

Символ : имеет две синтаксические цели в Julia. Первая форма создает Symbol, interned string, используемый в качестве одного из строительных блоков выражений, из допустимых идентификаторов:

julia> s = :foo
:foo

julia> typeof(s)
Symbol

Конструктор Symbol принимает любое количество аргументов и создает новый символ, объединяя их строковые представления:

julia> :foo === Symbol("foo")
true

julia> Symbol("1foo") # `:1foo` would not work, as `1foo` is not a valid identifier
Symbol("1foo")

julia> Symbol("func",10)
:func10

julia> Symbol(:var,'_',"sym")
:var_sym

В контексте выражения символы используются для указания доступа к переменным; когда выражение оценивается, символ заменяется значением, связанным с этим символом в соответствующем scope.

Иногда дополнительные скобки вокруг аргумента : необходимы, чтобы избежать неоднозначности в разборе:

julia> :(:)
:(:)

julia> :(::)
:(::)

Expressions and evaluation

Quoting

Вторая синтаксическая цель символа : заключается в создании объектов выражений без использования явного конструктора Expr. Это называется цитированием. Символ :, за которым следуют парные скобки вокруг одного выражения кода Julia, создает объект Expr, основанный на заключенном коде. Вот пример короткой формы, используемой для цитирования арифметического выражения:

julia> ex = :(a+b*c+1)
:(a + b * c + 1)

julia> typeof(ex)
Expr

(чтобы просмотреть структуру этого выражения, попробуйте ex.head и ex.args, или используйте dump, как выше, или Meta.@dump)

Обратите внимание, что эквивалентные выражения могут быть построены с использованием Meta.parse или прямой формы Expr:

julia>      :(a + b*c + 1)       ==
       Meta.parse("a + b*c + 1") ==
       Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true

Выражения, предоставляемые парсером, обычно имеют только символы, другие выражения и литеральные значения в качестве своих аргументов, в то время как выражения, построенные с помощью кода Julia, могут иметь произвольные значения времени выполнения без литеральных форм в качестве аргументов. В этом конкретном примере + и a являются символами, *(b,c) является подвыражением, а 1 — это литеральное 64-битное знаковое целое число.

Существует вторая синтаксическая форма цитирования для нескольких выражений: блоки кода, заключенные в quote ... end.

julia> ex = quote
           x = 1
           y = 2
           x + y
       end
quote
    #= none:2 =#
    x = 1
    #= none:3 =#
    y = 2
    #= none:4 =#
    x + y
end

julia> typeof(ex)
Expr

Interpolation

Прямое создание объектов Expr с аргументами значений мощно, но конструкторы Expr могут быть утомительными по сравнению с "нормальным" синтаксисом Julia. В качестве альтернативы, Julia позволяет интерполяцию литералов или выражений в цитируемые выражения. Интерполяция обозначается префиксом $.

В этом примере значение переменной a интерполируется:

julia> a = 1;

julia> ex = :($a + b)
:(1 + b)

Интерполяция в нецитируемое выражение не поддерживается и приведет к ошибке на этапе компиляции:

julia> $a + b
ERROR: syntax: "$" expression outside quote

В этом примере кортеж (1,2,3) интерполируется как выражение в условное тестирование:

julia> ex = :(a in $:((1,2,3)) )
:(a in (1, 2, 3))

Использование $ для интерполяции выражений намеренно напоминает string interpolation и command interpolation. Интерполяция выражений позволяет удобно и читаемо программно строить сложные выражения на Julia.

Splatting interpolation

Обратите внимание, что синтаксис интерполяции $ позволяет вставлять только одно выражение в окружающее выражение. Иногда у вас есть массив выражений, и вам нужно, чтобы все они стали аргументами окружающего выражения. Это можно сделать с помощью синтаксиса $(xs...). Например, следующий код генерирует вызов функции, где количество аргументов определяется программно:

julia> args = [:x, :y, :z];

julia> :(f(1, $(args...)))
:(f(1, x, y, z))

Nested quote

Естественно, что выражения цитат могут содержать другие выражения цитат. Понимание того, как работает интерполяция в этих случаях, может быть немного сложным. Рассмотрим этот пример:

julia> x = :(1 + 2);

julia> e = quote quote $x end end
quote
    #= none:1 =#
    $(Expr(:quote, quote
    #= none:1 =#
    $(Expr(:$, :x))
end))
end

Обратите внимание, что результат содержит $x, что означает, что x еще не был оценен. Другими словами, выражение $ "принадлежит" внутреннему выражению кавычек, и поэтому его аргумент оценивается только тогда, когда внутреннее выражение кавычек:

julia> eval(e)
quote
    #= none:1 =#
    1 + 2
end

Однако внешнее выражение quote может интерполировать значения внутри $ во внутреннем выражении quote. Это делается с помощью нескольких $:

julia> e = quote quote $$x end end
quote
    #= none:1 =#
    $(Expr(:quote, quote
    #= none:1 =#
    $(Expr(:$, :(1 + 2)))
end))
end

Обратите внимание, что (1 + 2) теперь появляется в результате вместо символа x. Вычисление этого выражения дает интерполированное 3:

julia> eval(e)
quote
    #= none:1 =#
    3
end

Интуиция, стоящая за этим поведением, заключается в том, что x оценивается один раз для каждого $: один $ работает аналогично eval(:x), возвращая значение x, в то время как два $ эквивалентны eval(eval(:x)).

QuoteNode

The usual representation of a quote form in an AST is an Expr with head :quote:

julia> dump(Meta.parse(":(1+2)"))
Expr
  head: Symbol quote
  args: Array{Any}((1,))
    1: Expr
      head: Symbol call
      args: Array{Any}((3,))
        1: Symbol +
        2: Int64 1
        3: Int64 2

Как мы видели, такие выражения поддерживают интерполяцию с помощью $. Однако в некоторых ситуациях необходимо цитировать код без выполнения интерполяции. Этот вид цитирования пока не имеет синтаксиса, но внутренне представлен как объект типа QuoteNode:

julia> eval(Meta.quot(Expr(:$, :(1+2))))
3

julia> eval(QuoteNode(Expr(:$, :(1+2))))
:($(Expr(:$, :(1 + 2))))

Парсер выдает QuoteNode для простых цитируемых элементов, таких как символы:

julia> dump(Meta.parse(":x"))
QuoteNode
  value: Symbol x

QuoteNode также может быть использован для некоторых сложных задач метапрограммирования.

Evaluating expressions

Дано объект выражения, можно заставить Julia оценить (выполнить) его в глобальной области видимости, используя eval:

julia> ex1 = :(1 + 2)
:(1 + 2)

julia> eval(ex1)
3

julia> ex = :(a + b)
:(a + b)

julia> eval(ex)
ERROR: UndefVarError: `b` not defined in `Main`
[...]

julia> a = 1; b = 2;

julia> eval(ex)
3

Каждый module имеет свою собственную eval функцию, которая оценивает выражения в своей глобальной области видимости. Выражения, переданные в 4d61726b646f776e2e436f64652822222c20226576616c2229_40726566, не ограничиваются возвратом значений – они также могут иметь побочные эффекты, которые изменяют состояние окружения заключенного модуля:

julia> ex = :(x = 1)
:(x = 1)

julia> x
ERROR: UndefVarError: `x` not defined in `Main`

julia> eval(ex)
1

julia> x
1

Здесь оценка объекта выражения приводит к тому, что значение присваивается глобальной переменной x.

Поскольку выражения представляют собой просто объекты Expr, которые можно создавать программно, а затем оценивать, возможно динамически генерировать произвольный код, который затем можно выполнить с помощью eval. Вот простой пример:

julia> a = 1;

julia> ex = Expr(:call, :+, a, :b)
:(1 + b)

julia> a = 0; b = 2;

julia> eval(ex)
3

Значение a используется для построения выражения ex, которое применяет функцию + к значению 1 и переменной b. Обратите внимание на важное различие в том, как используются a и b:

  • Значение переменной a во время построения выражения используется как непосредственное значение в выражении. Таким образом, значение a при оценке выражения больше не имеет значения: значение в выражении уже 1, независимо от того, каково значение a.
  • С другой стороны, символ :b используется в конструкции выражения, поэтому значение переменной b в тот момент не имеет значения – :b просто символ, и переменная b даже не обязательно должна быть определена. Однако во время оценки выражения значение символа :b разрешается путем поиска значения переменной b.

Functions on Expressions

Как упоминалось выше, одной из чрезвычайно полезных функций Julia является возможность генерировать и манипулировать кодом Julia внутри самой Julia. Мы уже видели один пример функции, возвращающей Expr объекты: функция Meta.parse, которая принимает строку кода Julia и возвращает соответствующий Expr. Функция также может принимать один или несколько Expr объектов в качестве аргументов и возвращать другой Expr. Вот простой, мотивирующий пример:

julia> function math_expr(op, op1, op2)
           expr = Expr(:call, op, op1, op2)
           return expr
       end
math_expr (generic function with 1 method)

julia>  ex = math_expr(:+, 1, Expr(:call, :*, 4, 5))
:(1 + 4 * 5)

julia> eval(ex)
21

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

julia> function make_expr2(op, opr1, opr2)
           opr1f, opr2f = map(x -> isa(x, Number) ? 2*x : x, (opr1, opr2))
           retexpr = Expr(:call, op, opr1f, opr2f)
           return retexpr
       end
make_expr2 (generic function with 1 method)

julia> make_expr2(:+, 1, 2)
:(2 + 4)

julia> ex = make_expr2(:+, 1, Expr(:call, :*, 5, 8))
:(2 + 5 * 8)

julia> eval(ex)
42

Macros

Макросы предоставляют механизм для включения сгенерированного кода в окончательное тело программы. Макрос сопоставляет кортеж аргументов с возвращаемым выражением, и полученное выражение компилируется напрямую, а не требует вызова eval во время выполнения. Аргументы макроса могут включать выражения, литеральные значения и символы.

Basics

Вот чрезвычайно простой макрос:

julia> macro sayhello()
           return :( println("Hello, world!") )
       end
@sayhello (macro with 1 method)

Макросы имеют специальный символ в синтаксисе Julia: @ (символ "собака"), за которым следует уникальное имя, объявленное в блоке macro NAME ... end. В этом примере компилятор заменит все вхождения @sayhello на:

:( println("Hello, world!") )

Когда @sayhello вводится в REPL, выражение выполняется немедленно, и мы видим только результат вычисления:

julia> @sayhello()
Hello, world!

Теперь рассмотрим немного более сложный макрос:

julia> macro sayhello(name)
           return :( println("Hello, ", $name) )
       end
@sayhello (macro with 1 method)

Этот макрос принимает один аргумент: name. Когда встречается @sayhello, цитируемое выражение расширяется, чтобы интерполировать значение аргумента в финальное выражение:

julia> @sayhello("human")
Hello, human

Мы можем просмотреть выражение возвращаемого значения, используя функцию macroexpand (важное примечание: это чрезвычайно полезный инструмент для отладки макросов):

julia> ex = macroexpand(Main, :(@sayhello("human")) )
:(Main.println("Hello, ", "human"))

julia> typeof(ex)
Expr

Мы можем видеть, что литерал "human" был интерполирован в выражение.

Существует также макрос @macroexpand, который, возможно, немного удобнее, чем функция macroexpand:

julia> @macroexpand @sayhello "human"
:(println("Hello, ", "human"))

Hold up: why macros?

Мы уже видели функцию f(::Expr...) -> Expr в предыдущем разделе. На самом деле, macroexpand также является такой функцией. Так почему же существуют макросы?

Макросы необходимы, потому что они выполняются во время разбора кода, следовательно, макросы позволяют программисту генерировать и включать фрагменты пользовательского кода до выполнения всей программы. Чтобы проиллюстрировать разницу, рассмотрим следующий пример:

julia> macro twostep(arg)
           println("I execute at parse time. The argument is: ", arg)
           return :(println("I execute at runtime. The argument is: ", $arg))
       end
@twostep (macro with 1 method)

julia> ex = macroexpand(Main, :(@twostep :(1, 2, 3)) );
I execute at parse time. The argument is: :((1, 2, 3))

Первый вызов println выполняется, когда вызывается macroexpand. Результирующее выражение содержит только второй println:

julia> typeof(ex)
Expr

julia> ex
:(println("I execute at runtime. The argument is: ", $(Expr(:copyast, :($(QuoteNode(:((1, 2, 3)))))))))

julia> eval(ex)
I execute at runtime. The argument is: (1, 2, 3)

Macro invocation

Макросы вызываются с помощью следующего общего синтаксиса:

@name expr1 expr2 ...
@name(expr1, expr2, ...)

Обратите внимание на отличительный @ перед именем макроса и отсутствие запятых между выражениями аргументов в первой форме, а также отсутствие пробелов после @name во второй форме. Эти два стиля не следует смешивать. Например, следующий синтаксис отличается от приведенных выше примеров; он передает кортеж (expr1, expr2, ...) в качестве одного аргумента макросу:

@name (expr1, expr2, ...)

Альтернативный способ вызвать макрос над литералом массива (или композицией) — это сопоставить оба без использования скобок. В этом случае массив будет единственным выражением, переданным макросу. Следующий синтаксис эквивалентен (и отличается от @name [a b] * v):

@name[a b] * v
@name([a b]) * v

Важно подчеркнуть, что макросы получают свои аргументы в виде выражений, литералов или символов. Один из способов исследовать аргументы макроса — вызвать функцию show внутри тела макроса:

julia> macro showarg(x)
           show(x)
           # ... remainder of macro, returning an expression
       end
@showarg (macro with 1 method)

julia> @showarg(a)
:a

julia> @showarg(1+1)
:(1 + 1)

julia> @showarg(println("Yo!"))
:(println("Yo!"))

julia> @showarg(1)        # Numeric literal
1

julia> @showarg("Yo!")    # String literal
"Yo!"

julia> @showarg("Yo! $("hello")")    # String with interpolation is an Expr rather than a String
:("Yo! $("hello")")

В дополнение к данному списку аргументов, каждой макросу передаются дополнительные аргументы с именами __source__ и __module__.

Аргумент __source__ предоставляет информацию (в виде объекта LineNumberNode) о местоположении парсера знака @ из вызова макроса. Это позволяет макросам включать более качественную информацию об ошибках, и обычно используется в логировании, макросах для разбора строк и документации, например, а также для реализации макросов @__LINE__, @__FILE__ и @__DIR__.

Информацию о местоположении можно получить, ссылаясь на __source__.line и __source__.file:

julia> macro __LOCATION__(); return QuoteNode(__source__); end
@__LOCATION__ (macro with 1 method)

julia> dump(
            @__LOCATION__(
       ))
LineNumberNode
  line: Int64 2
  file: Symbol none

Аргумент __module__ предоставляет информацию (в виде объекта Module) о контексте расширения вызова макроса. Это позволяет макросам искать контекстную информацию, такую как существующие привязки, или вставлять значение в качестве дополнительного аргумента в вызов функции времени выполнения, выполняя саморефлексию в текущем модуле.

Building an advanced macro

Вот упрощенное определение макроса @assert языка Julia:

julia> macro assert(ex)
           return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
       end
@assert (macro with 1 method)

Этот макрос можно использовать следующим образом:

julia> @assert 1 == 1.0

julia> @assert 1 == 0
ERROR: AssertionError: 1 == 0

Вместо написанного синтаксиса вызов макроса расширяется во время разбора до его возвращаемого результата. Это эквивалентно записи:

1 == 1.0 ? nothing : throw(AssertionError("1 == 1.0"))
1 == 0 ? nothing : throw(AssertionError("1 == 0"))

То есть, в первом вызове выражение :(1 == 1.0) вставляется в слот условия теста, в то время как значение string(:(1 == 1.0)) вставляется в слот сообщения утверждения. Все выражение, таким образом, помещается в синтаксическое дерево, где происходит вызов макроса @assert. Затем, во время выполнения, если тестовое выражение оценивается как истинное, то возвращается nothing, в то время как если тест ложный, возникает ошибка, указывающая на утвержденное выражение, которое было ложным. Обратите внимание, что было бы невозможно написать это как функцию, поскольку доступно только значение условия, и было бы невозможно отобразить выражение, которое его вычислило, в сообщении об ошибке.

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

julia> macro assert(ex, msgs...)
           msg_body = isempty(msgs) ? ex : msgs[1]
           msg = string(msg_body)
           return :($ex ? nothing : throw(AssertionError($msg)))
       end
@assert (macro with 1 method)

Теперь @assert имеет два режима работы, в зависимости от количества аргументов, которые он получает! Если есть только один аргумент, кортеж выражений, захваченных msgs, будет пустым, и он будет вести себя так же, как и более простое определение выше. Но теперь, если пользователь указывает второй аргумент, он будет напечатан в теле сообщения вместо неудавшегося выражения. Вы можете проверить результат расширения макроса с помощью aptly названного @macroexpand макроса:

julia> @macroexpand @assert a == b
:(if Main.a == Main.b
        Main.nothing
    else
        Main.throw(Main.AssertionError("a == b"))
    end)

julia> @macroexpand @assert a==b "a should equal b!"
:(if Main.a == Main.b
        Main.nothing
    else
        Main.throw(Main.AssertionError("a should equal b!"))
    end)

Существует еще один случай, который обрабатывает фактический макрос @assert: что если, помимо вывода "a должно быть равно b", мы хотим вывести их значения? Можно наивно попытаться использовать интерполяцию строк в пользовательском сообщении, например, @assert a==b "a ($a) должно быть равно b ($b)!", но это не сработает, как ожидалось, с вышеупомянутым макросом. Вы можете понять, почему? Вспомните из string interpolation, что интерполированная строка переписывается в вызов string. Сравните:

julia> typeof(:("a should equal b"))
String

julia> typeof(:("a ($a) should equal b ($b)!"))
Expr

julia> dump(:("a ($a) should equal b ($b)!"))
Expr
  head: Symbol string
  args: Array{Any}((5,))
    1: String "a ("
    2: Symbol a
    3: String ") should equal b ("
    4: Symbol b
    5: String ")!"

Теперь вместо получения простой строки в msg_body макрос получает полное выражение, которое необходимо будет оценить, чтобы отобразить его как ожидается. Это можно напрямую вставить в возвращаемое выражение в качестве аргумента для вызова string; см. error.jl для полного реализации.

Макрос @assert активно использует вставку в цитируемые выражения, чтобы упростить манипуляцию выражениями внутри тела макроса.

Hygiene

Проблема, которая возникает в более сложных макросах, заключается в hygiene. Короче говоря, макросы должны гарантировать, что переменные, которые они вводят в свои возвращаемые выражения, случайно не конфликтуют с существующими переменными в окружающем коде, в который они расширяются. Напротив, выражения, которые передаются в макрос в качестве аргументов, часто ожидаются для оценки в контексте окружающего кода, взаимодействуя с существующими переменными и изменяя их. Еще одна проблема возникает из-за того, что макрос может быть вызван в другом модуле, чем тот, в котором он был определен. В этом случае нам нужно убедиться, что все глобальные переменные разрешены в правильный модуль. У Julia уже есть значительное преимущество перед языками с текстовым расширением макросов (такими как C) в том, что ей нужно учитывать только возвращаемое выражение. Все остальные переменные (такие как msg в @assert выше) следуют normal scoping block behavior.

Чтобы продемонстрировать эти проблемы, давайте рассмотрим написание макроса @time, который принимает выражение в качестве аргумента, записывает время, оценивает выражение, снова записывает время, выводит разницу между временем до и после, а затем имеет значение выражения в качестве своего конечного значения. Макрос может выглядеть так:

macro time(ex)
    return quote
        local t0 = time_ns()
        local val = $ex
        local t1 = time_ns()
        println("elapsed time: ", (t1-t0)/1e9, " seconds")
        val
    end
end

Здесь мы хотим, чтобы t0, t1 и val были приватными временными переменными, а time_ns ссылался на функцию time_ns в Julia Base, а не на какую-либо переменную time_ns, которую может иметь пользователь (то же самое касается println). Представьте себе проблемы, которые могут возникнуть, если выражение пользователя ex также содержало бы присваивания переменной с именем t0 или определяло бы свою собственную переменную time_ns. Мы можем получить ошибки или загадочное неправильное поведение.

Макрорасширитель Julia решает эти проблемы следующим образом. Сначала переменные внутри результата макроса классифицируются как локальные или глобальные. Переменная считается локальной, если ей присваивается значение (и она не объявлена глобальной), объявлена локальной или используется в качестве имени аргумента функции. В противном случае она считается глобальной. Локальные переменные затем переименовываются, чтобы быть уникальными (с помощью функции gensym, которая генерирует новые символы), а глобальные переменные разрешаются в среде определения макроса. Таким образом, обе вышеупомянутые проблемы решаются; локальные переменные макроса не будут конфликтовать с любыми переменными пользователя, а time_ns и println будут ссылаться на определения Julia Base.

Одна проблема все же остается. Рассмотрим следующее использование этого макроса:

module MyModule
import Base.@time

time_ns() = ... # compute something

@time time_ns()
end

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

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

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

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

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

julia> function foo()
           x = 1
           @zerox
           return x # is zero
       end
foo (generic function with 1 method)

julia> foo()
0

Этот вид манипуляции с переменными следует использовать с осторожностью, но иногда он бывает весьма полезен.

Получение правильных правил гигиены может быть серьезной задачей. Прежде чем использовать макрос, вы можете рассмотреть, будет ли достаточно замыкания функции. Еще одна полезная стратегия — отложить как можно больше работы на время выполнения. Например, многие макросы просто оборачивают свои аргументы в QuoteNode или другой подобный Expr. Некоторые примеры этого включают @task body, который просто возвращает schedule(Task(() -> $body)), и @eval expr, который просто возвращает eval(QuoteNode(expr)).

Чтобы продемонстрировать, мы можем переписать пример @time выше как:

macro time(expr)
    return :(timeit(() -> $(esc(expr))))
end
function timeit(f)
    t0 = time_ns()
    val = f()
    t1 = time_ns()
    println("elapsed time: ", (t1-t0)/1e9, " seconds")
    return val
end

Однако мы не делаем этого по уважительной причине: оборачивание expr в новый блок области видимости (анонимная функция) также немного изменяет значение выражения (область видимости любых переменных в нем), в то время как мы хотим, чтобы @time можно было использовать с минимальным воздействием на обернутый код.

Macros and dispatch

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

julia> macro m end
@m (macro with 0 methods)

julia> macro m(args...)
           println("$(length(args)) arguments")
       end
@m (macro with 1 method)

julia> macro m(x,y)
           println("Two arguments")
       end
@m (macro with 2 methods)

julia> @m "asd"
1 arguments

julia> @m 1 2
Two arguments

Однако следует помнить, что макрораспределение основано на типах AST, которые передаются макросу, а не на типах, к которым AST приводит во время выполнения:

julia> macro m(::Int)
           println("An Integer")
       end
@m (macro with 3 methods)

julia> @m 2
An Integer

julia> x = 2
2

julia> @m x
1 arguments

Code Generation

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

struct MyNumber
    x::Float64
end
# output

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

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

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

julia> x = MyNumber(π)
MyNumber(3.141592653589793)

julia> sin(x)
MyNumber(1.2246467991473532e-16)

julia> cos(x)
MyNumber(-1.0)

Таким образом, Julia действует как свой собственный preprocessor и позволяет генерировать код изнутри языка. Указанный выше код можно было бы написать немного короче, используя форму цитирования с префиксом ::

for op = (:sin, :cos, :tan, :log, :exp)
    eval(:(Base.$op(a::MyNumber) = MyNumber($op(a.x))))
end

Этот тип генерации кода на языке, однако, с использованием шаблона eval(quote(...)), достаточно распространен, что в Julia есть макрос для сокращения этого шаблона:

for op = (:sin, :cos, :tan, :log, :exp)
    @eval Base.$op(a::MyNumber) = MyNumber($op(a.x))
end

Макрос @eval переписывает этот вызов так, чтобы он был точно эквивалентен вышеуказанным более длинным версиям. Для более длинных блоков сгенерированного кода аргумент выражения, переданный в 4d61726b646f776e2e436f64652822222c2022406576616c2229_40726566, может быть блоком:

@eval begin
    # multiple lines
end

Non-Standard String Literals

Recall from Strings that string literals prefixed by an identifier are called non-standard string literals, and can have different semantics than un-prefixed string literals. For example:

Возможно, это удивительно, но эти поведения не закодированы в парсере или компиляторе Julia. Вместо этого они являются пользовательскими поведениями, предоставляемыми общим механизмом, который может использовать любой: строковые литералы с префиксом разбираются как вызовы макросов с особыми именами. Например, макрос регулярного выражения — это просто следующее:

macro r_str(p)
    Regex(p)
end

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

Regex("^\\s*(?:#|\$)")

Не только строковый литерал короче и гораздо удобнее, но он также более эффективен: поскольку регулярное выражение компилируется, а объект Regex фактически создается когда код компилируется, компиляция происходит только один раз, а не каждый раз, когда код выполняется. Подумайте, если регулярное выражение встречается в цикле:

for line = lines
    m = match(r"^\s*(?:#|$)", line)
    if m === nothing
        # non-comment
    else
        # comment
    end
end

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

re = Regex("^\\s*(?:#|\$)")
for line = lines
    m = match(re, line)
    if m === nothing
        # non-comment
    else
        # comment
    end
end

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

Механизм пользовательских строковых литералов глубоко, profoundly мощен. Не только нестандартные литералы Julia реализованы с его помощью, но и синтаксис литералов команд (`echo "Hello, $person"`) также реализован с использованием следующей на вид безобидной макроса:

macro cmd(str)
    :(cmd_gen($(shell_parse(str)[1])))
end

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

Как и строковые литералы, литералы команд также могут быть предварены идентификатором, чтобы образовать то, что называется нестандартными литералами команд. Эти литералы команд разбираются как вызовы макросов со специальными именами. Например, синтаксис custom`literal` разбирается как @custom_cmd "literal". Сам язык Julia не содержит никаких нестандартных литералов команд, но пакеты могут использовать этот синтаксис. Помимо различного синтаксиса и суффикса _cmd вместо суффикса _str, нестандартные литералы команд ведут себя точно так же, как нестандартные строковые литералы.

В случае, если два модуля предоставляют нестандартные строковые или командные литералы с одинаковым именем, возможно квалифицировать строковый или командный литерал с помощью имени модуля. Например, если и Foo, и Bar предоставляют нестандартный строковый литерал @x_str, то можно написать Foo.x"literal" или Bar.x"literal", чтобы устранить неоднозначность между ними.

Другой способ определить макрос будет выглядеть так:

macro foo_str(str, flag)
    # do stuff
end

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

foo"str"flag

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

Generated functions

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

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

При определении сгенерированных функций есть пять основных отличий от обычных функций:

  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); # note: output is from println() statement in the body
Int64

julia> x           # now we print x
4

julia> y = foo("bar");
String

julia> y
"barbar"

Итак, мы видим, что в теле сгенерированной функции x является типом переданного аргумента, а значение, возвращаемое сгенерированной функцией, является результатом вычисления цитируемого выражения, которое мы вернули из определения, теперь с значением x.

Что произойдет, если мы снова оценим foo с типом, который мы уже использовали?

julia> foo(4)
16

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

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

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

Изначально f(x) имеет одно определение

julia> f(x) = "original definition";

Определите другие операции, которые используют f(x):

julia> g(x) = f(x);

julia> @generated gen1(x) = f(x);

julia> @generated gen2(x) = :(f(x));

Теперь мы добавляем некоторые новые определения для f(x):

julia> f(x::Int) = "definition for Int";

julia> f(x::Type{Int}) = "definition for Type{Int}";

и сравните, как эти результаты различаются:

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> gen1(1)
"original definition"

julia> gen2(1)
"definition for Int"

Каждый метод сгенерированной функции имеет свой собственный взгляд на определенные функции:

julia> @generated gen1(x::Real) = f(x);

julia> gen1(1)
"definition for Type{Int}"

Сгенерированная функция foo, приведенная выше, не делала ничего такого, что не могла бы сделать обычная функция foo(x) = x * x (за исключением вывода типа при первом вызове и увеличения накладных расходов). Однако сила сгенерированной функции заключается в ее способности вычислять разные цитируемые выражения в зависимости от типов, переданных ей:

julia> @generated function bar(x)
           if x <: Integer
               return :(x ^ 2)
           else
               return :(x)
           end
       end
bar (generic function with 1 method)

julia> bar(4)
16

julia> bar("baz")
"baz"

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

Злоупотребление этим приведет к повреждению системы выполнения и вызовет неопределенное поведение:

julia> @generated function baz(x)
           if rand() < .9
               return :(x^2)
           else
               return :("boo!")
           end
       end
baz (generic function with 1 method)

Поскольку тело сгенерированной функции является недетерминированным, ее поведение, а также поведение всего последующего кода неопределено.

Не копируйте эти примеры!

Эти примеры, надеюсь, помогут проиллюстрировать, как работают сгенерированные функции, как в определении, так и на месте вызова; однако, не копируйте их, по следующим причинам:

  • функция foo имеет побочные эффекты (вызов Core.println), и не определено, когда, как часто или сколько раз эти побочные эффекты будут происходить
  • функция bar решает задачу, которая лучше решается с помощью множественной диспетчеризации - определение bar(x) = x и bar(x::Integer) = x ^ 2 сделает то же самое, но это и проще, и быстрее.
  • функция baz является патологической

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

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

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

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

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

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

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

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

An advanced example

Библиотека Julia имеет внутреннюю функцию sub2ind, которая вычисляет линейный индекс в n-мерном массиве на основе набора из n многомерных индексов - другими словами, для вычисления индекса i, который можно использовать для индексации массива A с помощью A[i], вместо A[x,y,z,...]. Одной из возможных реализаций является следующая:

julia> function sub2ind_loop(dims::NTuple{N}, I::Integer...) where N
           ind = I[N] - 1
           for i = N-1:-1:1
               ind = I[i]-1 + dims[i]*ind
           end
           return ind + 1
       end;

julia> sub2ind_loop((3, 5), 1, 2)
4

То же самое можно сделать с помощью рекурсии:

julia> sub2ind_rec(dims::Tuple{}) = 1;

julia> sub2ind_rec(dims::Tuple{}, i1::Integer, I::Integer...) =
           i1 == 1 ? sub2ind_rec(dims, I...) : throw(BoundsError());

julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer) = i1;

julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer, I::Integer...) =
           i1 + dims[1] * (sub2ind_rec(Base.tail(dims), I...) - 1);

julia> sub2ind_rec((3, 5), 1, 2)
4

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

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

julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end;

julia> sub2ind_gen((3, 5), 1, 2)
4

Какой код это сгенерирует?

Простой способ выяснить это - вынести тело в другую (обычную) функцию:

julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
           length(I) == N || return :(error("partial indexing is unsupported"))
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end;

julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           return sub2ind_gen_impl(dims, I...)
       end;

julia> sub2ind_gen((3, 5), 1, 2)
4

Теперь мы можем выполнить sub2ind_gen_impl и изучить выражение, которое оно возвращает:

julia> sub2ind_gen_impl(Tuple{Int,Int}, Int, Int)
:(((I[1] - 1) + dims[1] * (I[2] - 1)) + 1)

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

Optionally-generated functions

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

Чтобы решить эту проблему, язык предоставляет синтаксис для написания обычных, негенерируемых альтернативных реализаций сгенерированных функций. Применительно к примеру sub2ind выше, это будет выглядеть так:

julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end;

julia> function sub2ind_gen_fallback(dims::NTuple{N}, I) where N
           ind = I[N] - 1
           for i = (N - 1):-1:1
               ind = I[i] - 1 + dims[i]*ind
           end
           return ind + 1
       end;

julia> function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           length(I) == N || error("partial indexing is unsupported")
           if @generated
               return sub2ind_gen_impl(dims, I...)
           else
               return sub2ind_gen_fallback(dims, I)
           end
       end;

julia> sub2ind_gen((3, 5), 1, 2)
4

Внутри этот код создает две реализации функции: сгенерированную, где используется первый блок в if @generated, и обычную, где используется блок else. Внутри части then блока if @generated код имеет ту же семантику, что и другие сгенерированные функции: имена аргументов ссылаются на типы, и код должен возвращать выражение. Может быть несколько блоков if @generated, в этом случае сгенерированная реализация использует все блоки then, а альтернативная реализация использует все блоки else.

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

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