Spec-Zone.ru › Julia 0.7

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

Наиболее сильное наследие языка 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 объекты содержат две части:

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

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

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

Два созданных выше выражения — путем разбора и путем непосредственного создания — эквивалентны:

julia> ex1 == ex2
true

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

Функция dump предоставляет отформатированное и снабженное аннотациями представление объектов Expr:

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

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

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

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

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

Символы

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

julia> :foo
:foo

julia> typeof(ans)
Symbol

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

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

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

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

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

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

julia> :(:)
:(:)

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

Выражения и вычисление

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

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

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

julia> typeof(ex)
Expr

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

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

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

julia> a = 1;

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

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

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

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

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

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

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

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

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

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

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

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

julia> x = :(1 + 2);

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

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

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

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

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

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

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

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

QuoteNode

Обычно представление формы quote в AST — это Expr с заголовком :quote:

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

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

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

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

eval и эффекты

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

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

julia> eval(ans)
3

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

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

julia> a = 1; b = 2;

julia> eval(ex)
3

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

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

julia> x
ERROR: UndefVarError: x not defined

julia> eval(ex)
1

julia> x
1

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

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

julia> a = 1;

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

julia> a = 0; b = 2;

julia> eval(ex)
3

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

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

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

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

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

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

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

julia> eval(ex)
21

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

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

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

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

julia> eval(ex)
42

Макросы

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

Основы

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

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

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

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

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

julia> @sayhello()
Hello, world!

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

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

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

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

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

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

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

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

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

@name (expr1, expr2, ...)

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

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

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

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

julia> @showarg(a)
:a

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

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

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

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

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

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

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

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

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

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

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

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

julia> @assert 1 == 1.0

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

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

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

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

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

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(QuoteNode(expr)).

Для демонстрации, мы можем переписать пример @time выше следующим образом:

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

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

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

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

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

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

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

julia> @m "asd"
1 arguments

julia> @m 1 2
Two arguments

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

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

julia> @m 2
An Integer

julia> x = 2
2

julia> @m x
1 arguments

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

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

struct MyNumber
    x::Float64
end
# output

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

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

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

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

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

julia> cos(x)
MyNumber(-1.0)

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

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

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

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

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

@eval begin
    # multiple lines
end

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

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

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

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

macro r_str(p)
    Regex(p)
end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> x           # now we print x
4

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

julia> y
"barbar"

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

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

julia> foo(4)
16

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> gen1(1)
"original definition"

julia> gen2(1)
"definition for Int"

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

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

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

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

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

julia> bar(4)
16

julia> bar("baz")
"baz"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Продвинутый пример

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Spec-Zone.ru

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