Spec-Zone.ru › Julia 1.1

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

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

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

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

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

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

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

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

julia> typeof(ex1)
Expr

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

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

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

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

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

julia> ex1 == ex2
true

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

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

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

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

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

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

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

Символы

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

julia> :foo
:foo

julia> typeof(ans)
Symbol

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

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

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

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

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

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

julia> :(:)
:(:)

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

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

Котирование

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

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

julia> typeof(ex)
Expr

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

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

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

julia> a = 1;

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

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

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

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

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

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

Интерполяция со спрэдом

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

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

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

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

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

julia> x = :(1 + 2);

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

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

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

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

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

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

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

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

QuoteNode

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

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

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

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

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

eval и эффекты

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

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

julia> eval(ans)
3

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

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

julia> a = 1; b = 2;

julia> eval(ex)
3

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

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

julia> x
ERROR: UndefVarError: x not defined

julia> eval(ex)
1

julia> x
1

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

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

julia> a = 1;

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

julia> a = 0; b = 2;

julia> eval(ex)
3

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

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

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

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

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

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

julia> eval(ex)
21

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

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

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

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

julia> eval(ex)
42

Макросы

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

Основы

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

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

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

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

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

julia> @sayhello()
Hello, world!

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

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

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

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

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

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

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

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

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

julia> typeof(ex)
Expr

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

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

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

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

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

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

@name (expr1, expr2, ...)

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

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

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

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

julia> @showarg(a)
:a

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

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

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

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

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

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

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

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

Создание расширенного макроса

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

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

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

julia> @assert 1 == 1.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Гигиена

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

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

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

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

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

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

module MyModule
import Base.@time

time() = ... # compute something

@time time()
end

Здесь пользовательское выражение ex является вызовом time, но не той же самой time функции, которую использует макрос. Он явно ссылается на MyModule.time. Поэтому мы должны обеспечить, чтобы код в ex был разрешен в среде вызова макроса. Это делается путём «экранирования» выражения с помощью esc:

macro time(ex)
    ...
    local val = $(esc(ex))
    ...
end

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

Этот механизм экранирования может быть использован для «нарушения» гигиены при необходимости, для введения или манипулирования переменными пользователя. Например, следующий макрос устанавливает x в ноль в среде вызова:

julia> macro zerox()
           return esc(:(x = 0))
       end
@zerox (macro with 1 method)

julia> function foo()
           x = 1
           @zerox
           return x # is zero
       end
foo (generic function with 1 method)

julia> foo()
0

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

Обеспечить корректность правил гигиены может быть непростой задачей. Перед использованием макроса вы можете подумать, будет ли достаточно функции с замыканием. Другая полезная стратегия — отложить как можно больше работы до выполнения. Например, многие макросы просто заключают свои аргументы в QuoteNode или другое подобное Expr. Некоторые примеры этого включают @task body, который просто возвращает schedule(Task(() -> $body)), и @eval expr, который просто возвращает eval(QuoteNode(expr)).

Чтобы продемонстрировать, мы можем переписать пример @time выше, как:

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

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

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

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

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

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

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

julia> @m "asd"
1 arguments

julia> @m 1 2
Two arguments

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

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

julia> @m 2
An Integer

julia> x = 2
2

julia> @m x
1 arguments

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

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

struct MyNumber
    x::Float64
end
# output

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

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

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

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

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

julia> cos(x)
MyNumber(-1.0)

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

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

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

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

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

@eval begin
    # multiple lines
end

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

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

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

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

macro r_str(p)
    Regex(p)
end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> x           # now we print x
4

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

julia> y
"barbar"

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

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

julia> foo(4)
16

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> gen1(1)
"original definition"

julia> gen2(1)
"definition for Int"

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

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

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

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

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

julia> bar(4)
16

julia> bar("baz")
"baz"

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

Использование этого приведёт к повреждению системы выполнения и вызовет неопределённое поведение:

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

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

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

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

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

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

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

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

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

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

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

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

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

Расширенный пример

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Spec-Zone.ru

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