Spec-Zone.ru › Julia 0.6

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

Наиболее сильное наследие языка Lisp в Julia — это поддержка метапрограммирования. Как и в Lisp, Julia представляет собственный код как структуру данных самого языка. Поскольку код представлен объектами, которые могут быть созданы и изменены внутри языка, программа может преобразовывать и генерировать свой собственный код. Это позволяет создавать сложный код без дополнительных этапов сборки, а также позволяет использовать макросы в стиле Lisp, работающие на уровне абстрактных синтаксических деревьев.

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

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

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

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

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

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

julia> ex1 = 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
  typ: Any

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

julia> ex3 = 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(), как показано выше)

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

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

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

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

julia> ex = quote
           x = 1
           y = 2
           x + y
       end
quote  # none, line 2:
    x = 1 # none, line 3:
    y = 2 # none, line 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: unsupported or misplaced expression $
 ...

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

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

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

julia> :( :a in $( :(:a + :b) ) )
                   ^^^^^^^^^^
                   quoted inner expression

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

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.

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

Как уже упоминалось выше, очень полезной функцией 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: знак @ (at-знак), после которого следует уникальное имя, объявленное в блоке 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( :(@sayhello("human")) )
:((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( :(@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, ...)

Важно подчеркнуть, что макросы получают свои аргументы в виде выражений, литералов или символов. Один из способов исследовать аргументы макроса — вызвать функцию 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!"))

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

Вот упрощённое определение макроса @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> 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 a == b
        nothing
    else
        (throw)((AssertionError)("a == b"))
    end)

julia> macroexpand(:(@assert a==b "a should equal b!"))
:(if a == b
        nothing
    else
        (throw)((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 ")!"
  typ: Any

Таким образом, вместо простой строки в 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() в стандартной библиотеке, а не на какую-либо переменную time, которую пользователь мог бы иметь (то же самое относится к println). Представьте проблемы, которые могут возникнуть, если выражение пользователя ex также содержало присваивания переменной с именем t0 или определяло свою собственную переменную time. Мы могли получить ошибки или загадочное некорректное поведение.

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

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

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

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

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

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

for op = (:+, :*, :&, :|, :$)
    eval(quote
        ($op)(a,b,c) = ($op)(($op)(a,b),c)
    end)
end

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

for op = (:+, :*, :&, :|, :$)
    eval(:(($op)(a,b,c) = ($op)(($op)(a,b),c)))
end

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

for op = (:+, :*, :&, :|, :$)
    @eval ($op)(a,b,c) = ($op)(($op)(a,b),c)
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. Сгенерированные функции не должны изменять или наблюдать за любым неконстантным глобальным состоянием (включая, например, ввод/вывод, блокировки, нелокальные словари или использование method_exists). Это означает, что они могут только считывать глобальные константы и не могут иметь никаких побочных эффектов. Другими словами, они должны быть полностью чистыми. Из-за ограничения реализации это также означает, что в настоящее время они не могут определять замыкание или нетипизированный генератор.

Проще всего проиллюстрировать это на примере. Мы можем объявить сгенерированную функцию 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(), поэтому он запрещён.

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

Изначально у 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.Inference любым способом.

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

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

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

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

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

Пример со сложной логикой

Базовая библиотека Julia содержит функцию sub2ind() для вычисления линейного индекса многомерного массива по набору многомерных индексов — другими словами, для вычисления индекса 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 (за исключением крайних случаев, когда функция генерируется более одного раза — см. отказ от ответственности выше).

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

Spec-Zone.ru

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