Spec-Zone.ru › Julia 1.2

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

Самым сильным наследием 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

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

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

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 Парсер возвращает QuoteNode для простых цитируемых элементов, таких как символы:

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

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

eval и эффекты

Учитывая объект выражения, можно заставить 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
@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 выше) следуют поведению обычного блока области видимости.

END_OF_DOCUMENT_MARKER

Для демонстрации этих проблем рассмотрим создание макроса @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. Сгенерированные функции не должны изменять или наблюдать за любым неконстантным глобальным состоянием (включая, например, ввод/вывод, блокировки, нелокальные словари или использование 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 для вычисления линейного индекса в n-мерном массиве на основе набора n многолинейных индексов — другими словами, для вычисления индекса i, который может использоваться для индексирования массива A с использованием A[i], а не A[x,y,z,...].

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Дополнительные сгенерированные функции

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

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

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

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

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

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

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

Spec-Zone.ru

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