Метапрограммирование
Наиболее ярким наследием языка 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 объекты содержат три части:
- символ
Symbol, определяющий тип выражения. Символ — это идентификатор интернированной строки (более подробное обсуждение ниже).
julia> ex1.head :call
- аргументы выражения, которые могут быть символами, другими выражениями или литеральными значениями:
julia> ex1.args
3-element Array{Any,1}:
:+
1
1
- наконец, тип результата выражения, который может быть аннотирован пользователем или выведен компилятором (и может быть полностью проигнорирован в целях данной главы):
julia> ex1.typ Any
Выражения также могут быть построены напрямую в префиксной записи:
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.
Функции над 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
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() во время выполнения. Аргументы макроса могут включать выражения, литеральные значения и символы.
Основы
Вот чрезвычайно простой макрос:
macro sayhello()
return :( println("Hello, world!") )
end
Макросы имеют выделенный символ в синтаксисе Julia: @ (знак «@»), за которым следует уникальное имя, объявленное в блоке macro NAME ... end. В этом примере компилятор заменит все экземпляры @sayhello на:
:( println("Hello, world!") )
Когда @sayhello выполняется в REPL, выражение выполняется немедленно, поэтому мы видим только результат вычисления:
julia> @sayhello() "Hello, world!"
Теперь рассмотрим немного более сложный макрос:
julia> macro sayhello(name)
return :( println("Hello, ", $name) )
end
Этот макрос принимает один аргумент: name. Когда встречается @sayhello, цитируемое выражение расширяется, чтобы интерполировать значение аргумента в конечное выражение:
julia> @sayhello("human")
Hello, human
Мы можем просмотреть цитируемое возвращаемое выражение с помощью функции macroexpand() (важное замечание: это чрезвычайно полезный инструмент для отладки макросов):
julia> ex = macroexpand( :(@sayhello("human")) )
:(println("Hello, ","human"))
^^^^^^^
interpolated: now a literal string
julia> typeof(ex)
Expr
Постойте: зачем нужны макросы?
Мы уже видели функцию 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
julia> ex = macroexpand( :(@twostep :(1, 2, 3)) );
I execute at parse time. The argument is: :((1,2,3))
Первый вызов println() выполняется при вызове macroexpand(). Результирующее выражение содержит только второй println:
julia> typeof(ex)
Expr
julia> ex
:(println("I execute at runtime. The argument is: ",$(Expr(:copyast, :(:((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
julia> @showarg(a)
(:a,)
julia> @showarg(1+1)
:(1 + 1)
julia> @showarg(println("Yo!"))
:(println("Yo!"))
Создание расширенного макроса
Вот упрощенное определение макроса @assert в Julia:
macro assert(ex)
return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
end
Этот макрос можно использовать так:
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 в стандартной библиотеке более сложное. Оно позволяет пользователю необязательно указывать собственное сообщение об ошибке вместо простого вывода невыполнившегося выражения. Так же, как и в функциях с переменным количеством аргументов, это указывается с помощью многоточия после последнего аргумента:
macro assert(ex, msgs...)
msg_body = isempty(msgs) ? ex : msgs[1]
msg = string(msg_body)
return :($ex ? nothing : throw(AssertionError($msg)))
end
Теперь @assert имеет два режима работы, в зависимости от количества полученных аргументов! Если аргументов только один, кортеж выражений, захваченных msgs, будет пустым, и он будет вести себя так же, как и более простое определение выше. Но теперь, если пользователь указывает второй аргумент, он будет выведен в теле сообщения вместо выражения, которое привело к ошибке. Результат расширения макроса можно проверить с помощью подходящей функции macroexpand():
julia> macroexpand(:(@assert a==b))
:(if a == b
nothing
else
(Base.throw)(Base.Main.Base.AssertionError("a == b"))
end)
julia> macroexpand(:(@assert a==b "a should equal b!"))
:(if a == b
nothing
else
(Base.throw)(Base.Main.Base.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 в ноль в среде вызова:
macro zerox() return esc(:(x = 0)) end function foo() x = 1 @zerox x # is zero end
Этот вид манипулирования переменными следует использовать с осторожностью, но иногда он очень удобен.
Генерация кода
Когда требуется значительное количество повторяющегося шаблонного кода, его обычно генерируют программно, чтобы избежать избыточности. В большинстве языков это требует дополнительного этапа сборки и отдельной программы для генерации повторяющегося кода. В Julia интерполяция выражений и eval() позволяют такой генерации кода происходить в обычном процессе выполнения программы. Например, следующий код определяет ряд операторов с тремя аргументами в терминах их 2-аргументных форм:
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 постоянен во всех циклах, некоторые оптимизации могут быть невозможны, что делает эту версию все еще менее эффективной, чем более удобная форма литерала выше. Конечно, есть ситуации, когда нелитеральная форма более удобна: если нужно интерполировать переменную в регулярное выражение, необходимо применить более подробный подход; в тех случаях, когда шаблон регулярного выражения сам по себе динамичен, потенциально изменяющийся на каждой итерации цикла, новый объект регулярного выражения должен быть создан на каждой итерации. Однако в подавляющем большинстве случаев регулярные выражения не строятся на основе данных во время выполнения. В большинстве этих случаев возможность записи регулярных выражений как значений времени компиляции является бесценной.
Механизм для пользовательских строковых литералов глубоко и чрезвычайно мощный. Он используется не только для нестандартных литералов Julia, но и для синтаксиса литералов команд (`echo "Hello, $person"`) с помощью следующего, на первый взгляд безобидного макроса:
macro cmd(str) :(cmd_gen($shell_parse(str))) end
Конечно, большая сложность скрыта в функциях, используемых в этом определении макроса, но они просто функции, написанные полностью на Julia. Вы можете прочитать их исходный код и увидеть, что они делают — и все, что они делают, это конструируют объекты выражений, которые будут вставлены в дерево синтаксического анализа вашей программы.
Сгенерированные функции
Очень специальный макрос @generated, который позволяет определять так называемые сгенерированные функции. Они обладают способностью генерировать специализированный код в зависимости от типов своих аргументов с большей гибкостью и/или меньшим кодом, чем можно получить с помощью множественного диспетчера. В то время как макросы работают с выражениями на этапе синтаксического анализа и не могут получить доступ к типам своих входных данных, сгенерированная функция расширяется в момент, когда типы аргументов известны, но функция еще не скомпилирована.
Вместо выполнения какого-либо вычисления или действия, объявление сгенерированной функции возвращает цитируемое выражение, которое затем формирует тело для метода, соответствующего типам аргументов. При вызове тело выражения компилируется (или извлекается из кэша при последующих вызовах) и оценивается только возвращаемое выражение — а не код, который его сгенерировал. Таким образом, сгенерированные функции обеспечивают гибкую структуру для переноса работы из времени выполнения в время компиляции.
При определении сгенерированных функций есть три основных отличия от обычных функций:
- Вы помечаете объявление функции макросом
@generated. Это добавляет некоторую информацию в AST, позволяющую компилятору узнать, что это сгенерированная функция. - В теле сгенерированной функции вы можете получить доступ только к типам аргументов, а не к их значениям.
- Вместо вычисления чего-либо или выполнения какого-либо действия вы возвращаете цитируемое выражение, которое, при оценке, делает то, что вы хотите.
Проще всего проиллюстрировать это на примере. Мы можем объявить сгенерированную функцию foo как
julia> @generated function foo(x)
println(x)
return :(x*x)
end
foo (generic function with 1 method)
Обратите внимание, что тело возвращает цитируемое выражение, а именно :(x*x), а не только значение x*x.
С точки зрения вызывающей стороны, они очень похожи на обычные функции; на самом деле, вам не нужно знать, вызываете ли вы обычную или сгенерированную функцию — синтаксис и результат вызова просто одинаковы. Давайте посмотрим, как foo ведет себя:
# note: output is from println() statement in the body
julia> x = foo(2);
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() в сгенерированной функции является признаком того, что вы делаете что-то неправильно.)
Пример сгенерированной функции 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)
Поскольку поведение тела сгенерированной функции не является детерминированным, его поведение не определено; выражение, возвращенное при первом вызове, будет использоваться для всех последующих вызовов с тем же типом (снова, с исключением, охваченным вышеупомянутым замечанием). Когда мы вызываем сгенерированную функцию с x нового типа, rand() будет вызываться снова, чтобы увидеть, какой метод использовать для нового типа. В этом случае для одного типа из десяти baz(x) вернёт строку "boo!".
Не копируйте эти примеры!
Эти примеры, надеюсь, помогут проиллюстрировать, как работают сгенерированные функции, как при определении, так и в месте вызова; однако не копируйте их по следующим причинам:
- функция
fooимеет побочные эффекты, и не определено, когда, как часто или сколько раз эти побочные эффекты будут происходить - функция
barрешает задачу, которая лучше решается с помощью множественного диспетчера — определениеbar(x) = xиbar(x::Integer) = x^2сделает то же самое, но это и проще, и быстрее. - функция
bazпатологически безумна
Вместо этого, теперь, когда мы лучше понимаем, как работают сгенерированные функции, давайте используем их для построения более сложной функциональности...
Расширенный пример
В базовой библиотеке Julia есть функция sub2ind() для вычисления линейного индекса n-мерного массива на основе набора n мультилинейных индексов — другими словами, для вычисления индекса i, который можно использовать для индексации в массив A с помощью A[i], а не A[x,y,z,...]. Одно из возможных реализаций выглядит так:
function sub2ind_loop{N}(dims::NTuple{N}, I::Integer...)
ind = I[N] - 1
for i = N-1:-1:1
ind = I[i]-1 + dims[i]*ind
end
return ind + 1
end
То же самое можно сделать с помощью рекурсии:
sub2ind_rec(dims::Tuple{}) = 1
sub2ind_rec(dims::Tuple{},i1::Integer, I::Integer...) =
i1==1 ? sub2ind_rec(dims,I...) : throw(BoundsError())
sub2ind_rec(dims::Tuple{Integer,Vararg{Integer}}, i1::Integer) = i1
sub2ind_rec(dims::Tuple{Integer,Vararg{Integer}}, i1::Integer, I::Integer...) =
i1 + dims[1]*(sub2ind_rec(tail(dims),I...)-1)
Обе эти реализации, хотя и разные, делают по существу одно и то же: цикл во время выполнения по размерностям массива, собирая смещение в каждом измерении в окончательный индекс.
Однако вся необходимая информация для цикла встроена в информацию о типе аргументов. Таким образом, мы можем использовать сгенерированные функции, чтобы перенести итерацию во время компиляции; по терминологии компиляторов, мы используем сгенерированные функции для ручного разворачивания цикла. Тело становится почти идентичным, но вместо вычисления линейного индекса мы создаём выражение, которое вычисляет индекс:
@generated function sub2ind_gen{N}(dims::NTuple{N}, I::Integer...)
ex = :(I[$N] - 1)
for i = N-1:-1:1
ex = :(I[$i] - 1 + dims[$i]*$ex)
end
return :($ex + 1)
end
Какой код будет сгенерирован?
Простой способ узнать это — извлечь тело в другую (обычную) функцию:
julia> @generated function sub2ind_gen{N}(dims::NTuple{N}, I::Integer...)
sub2ind_gen_impl(dims, I...)
end
sub2ind_gen (generic function with 1 method)
julia> function sub2ind_gen_impl{N}(dims::Type{NTuple{N}}, I...)
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.5/manual/metaprogramming/