Spec-Zone.ru › Julia 1.4

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

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

Обратите внимание, что результат содержит $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.

Функции над выражениями

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

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

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

julia> eval(ex)
21

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

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

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

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

julia> eval(ex)
42

Макросы

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

Основы

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

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

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

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

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

julia> @sayhello()
Hello, world!

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

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

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

Постойте, зачем макросы?

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

Вызов макроса

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

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

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

@name (expr1, expr2, ...)

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

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

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

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

julia> @showarg(a)
:a

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

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

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

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

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

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

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

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

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

Вот упрощенное определение макроса 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. Затем во время выполнения, если выражение проверки вычисляется как истинное, возвращается 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. Генерируемые функции не должны изменять или наблюдать любое состояние глобального состояния, которое не является константой (включая, например, ввод-вывод, блокировки, словари нелокальных областей или использование 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 многомерных индексов — другими словами, для расчёта индекса 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.4.2/manual/metaprogramming/

Spec-Zone.ru

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