Spec-Zone.ru › Julia 1.5

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

Наиболее сильное наследие языка 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_ns()
        local val = $ex
        local t1 = time_ns()
        println("elapsed time: ", (t1-t0)/1e9, " 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_ns()
    val = f()
    t1 = time_ns()
    println("elapsed time: ", (t1-t0)/1e9, " seconds")
    return val
end

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

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

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

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

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

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

julia> @m "asd"
1 arguments

julia> @m 1 2
Two arguments

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

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

julia> @m 2
An Integer

julia> x = 2
2

julia> @m x
1 arguments

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

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

struct MyNumber
    x::Float64
end
# 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 не должна наблюдать за каким-либо изменяемым состоянием или вызывать какое-либо изменение глобального состояния, мы видим следующее поведение. Обратите внимание, что генерируемая функция не может вызвать какой-либо метод, который не был определён до определения самой генерируемой функции.

Изначально 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.5.3/manual/metaprogramming/

Spec-Zone.ru

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