Spec-Zone.ru › Julia 1.3

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

Наиболее сильным наследием языка Lisp в Julia является поддержка метапрограммирования. Как и в Lisp, Julia представляет собственный код как структуру данных самого языка. Поскольку код представлен объектами, которые могут быть созданы и изменены внутри языка, программа может преобразовывать и генерировать собственный код. Это позволяет создавать сложный код без дополнительных шагов сборки, а также позволяет использовать макросы в стиле Lisp на уровне абстрактных синтаксических деревьев. В отличие от систем препроцессоров «макросов», таких как в C и C++, они выполняют текстовую обработку и подстановку до фактического разбора или интерпретации. Поскольку все типы данных и код в Julia представлены структурами данных Julia, доступны мощные возможности рефлексии для изучения внутренних компонентов программы и её типов, как и любого другого данных.

Представление программы

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

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

Что происходит дальше?

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

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

julia> typeof(ex1)
Expr

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

  • символ Symbol, определяющий тип выражения. Символ — это интернированный строковый идентификатор (более подробное обсуждение ниже).
julia> ex1.head
:call
  • аргументы выражения, которые могут быть символами, другими выражениями или литеральными значениями:
julia> ex1.args
3-element Array{Any,1}:
  :+
 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> :foo
:foo

julia> typeof(ans)
Symbol

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

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

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

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

Обратите внимание, что для использования синтаксиса : имя символа должно быть допустимым идентификатором. В противном случае должен использоваться конструктор Symbol(str).

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

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

julia> :(:)
:(:)

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

Выражения и оценка

Квотирование

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

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

julia> typeof(ex)
Expr

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

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

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

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

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

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

julia> typeof(ex)
Expr

Интерполяция

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

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

julia> a = 1;

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

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

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

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

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

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

Интерполяция разброса

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

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

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

Вложенное квотирование

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

julia> x = :(1 + 2);

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

Обратите внимание, что результат содержит Expr(:$, :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> :(1 + 2)
:(1 + 2)

julia> eval(ans)
3

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

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

julia> a = 1; b = 2;

julia> eval(ex)
3

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

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

julia> x
ERROR: UndefVarError: x not defined

julia> eval(ex)
1

julia> x
1

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

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

julia> a = 1;

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

julia> a = 0; b = 2;

julia> eval(ex)
3

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

Обратите внимание на важное различие в использовании a и b:

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

Функции над Exprссиями

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

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

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

julia> eval(ex)
21

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

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

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

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

julia> eval(ex)
42

Макросы

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

Основы

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

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

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

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

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

julia> @sayhello()
Hello, world!

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

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

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

Подождите: зачем макросы?

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

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

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

julia> ex = macroexpand(Main, :(@twostep :(1, 2, 3)) );
I execute at parse time. The argument is: $(Expr(:quote, :((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

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

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

julia> @showarg(a)
:a

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

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

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

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

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

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

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

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

Создание продвинутого макроса

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

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

Этот макрос можно использовать так:

julia> @assert 1 == 1.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Макрос @assert делает эффективное использование вставки в цитируемые выражения для упрощения обработки выражений внутри тела макроса.

Гигиена

Проблема, возникающая при более сложных макросах, — это гигиена. Короче говоря, макросы должны гарантировать, что переменные, которые они вводят в своих возвращаемых выражениях, не сталкиваются случайно с существующими переменными в окружающем коде, в который они расширяются. И наоборот, выражения, которые передаются макросу в качестве аргументов, часто ожидается будут вычисляться в контексте окружающего кода, взаимодействуя и изменяя существующие переменные. Возникает и другая проблема, связанная с тем, что макрос может вызываться в другом модуле, чем тот, в котором он определен. В этом случае нам необходимо убедиться, что все глобальные переменные разрешаются в правильном модуле. Julia уже имеет существенное преимущество перед языками с текстовым расширением макроса (такими как C) в том, что ей нужно только рассматривать возвращаемое выражение. Все остальные переменные (например, msg в @assert выше) следуют поведению обычного блока области видимости.

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

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

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

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

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

module MyModule
import Base.@time

time() = ... # compute something

@time time()
end

Здесь пользовательское выражение ex является вызовом функции time, но не той же функции time, что и используемый макросом. Ясно, что оно ссылается на MyModule.time. Поэтому мы должны обеспечить, чтобы код в 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()
    val = f()
    t1 = time()
    println("elapsed time: ", t1-t0, " seconds")
    return val
end

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

Макросы и диспетчеризация

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

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

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

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

julia> @m "asd"
1 arguments

julia> @m 1 2
Two arguments

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

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

julia> @m 2
An Integer

julia> x = 2
2

julia> @m x
1 arguments

Генерация кода

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

struct MyNumber
    x::Float64
end
# output

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

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

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

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

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

julia> cos(x)
MyNumber(-1.0)

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

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

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

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

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

@eval begin
    # multiple lines
end

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

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

  • r"^\s*(?:#|$)" производит объект регулярного выражения, а не строку
  • b"DATA\xff\u2200" является литералом массива байтов для [68,65,84,65,255,226,136,128].

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

macro r_str(p)
    Regex(p)
end

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

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

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

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

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

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

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

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

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

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

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

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

Сгенерированные функции

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

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

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

  1. Вы помечаете объявление функции макросом @generated. Это добавляет некоторую информацию в AST, которая позволяет компилятору узнать, что это сгенерированная функция.
  2. В теле сгенерированной функции у вас есть доступ только к типам аргументов — а не к их значениям.
  3. Вместо вычисления чего-либо или выполнения какого-либо действия, вы возвращаете цитируемое выражение, которое при оценке выполняет то, что вы хотите.
  4. Сгенерированные функции могут вызывать только функции, которые были определены до определения сгенерированной функции. (Несоблюдение этого может привести к получению MethodErrors со ссылкой на функции из будущего.)
  5. Сгенерированные функции не должны изменять или наблюдать за любым неконстантным глобальным состоянием (включая, например, IO, блокировки, нелокальные словари или использование hasmethod). Это означает, что они могут только читать глобальные константы и не могут иметь побочных эффектов. Другими словами, они должны быть полностью чистыми. Из-за ограничения реализации это также означает, что они в настоящее время не могут определять замыкание или генератор.

Проще всего проиллюстрировать это на примере. Мы можем объявить сгенерированную функцию foo как

julia> @generated function foo(x)
           Core.println(x)
           return :(x * x)
       end
foo (generic function with 1 method)

Обратите внимание, что тело возвращает цитируемое выражение, а именно :(x * x), а не просто значение x * x.

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

julia> x = foo(2); # note: output is from println() statement in the body
Int64

julia> x           # now we print x
4

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

julia> y
"barbar"

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

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

julia> foo(4)
16

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> gen1(1)
"original definition"

julia> gen2(1)
"definition for Int"

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

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

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

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

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

julia> bar(4)
16

julia> bar("baz")
"baz"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Пример со сложной логикой

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Легкий способ узнать это — извлечь тело в другую (обычную) функцию:

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

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

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

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

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

Необязательно сгенерированные функции

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

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

function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
    if N != length(I)
        throw(ArgumentError("Number of dimensions must match number of indices."))
    end
    if @generated
        ex = :(I[$N] - 1)
        for i = (N - 1):-1:1
            ex = :(I[$i] - 1 + dims[$i] * $ex)
        end
        return :($ex + 1)
    else
        ind = I[N] - 1
        for i = (N - 1):-1:1
            ind = I[i] - 1 + dims[i]*ind
        end
        return ind + 1
    end
end

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

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

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

© 2009–2020 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.3.1/manual/metaprogramming/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API