Метапрограммирование
Наиболее сильное наследие языка 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 Vector{Any}:
:+
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> s = :foo :foo julia> typeof(s) Symbol
Конструктор Symbol принимает любое количество аргументов и создает новый символ, конкатенируя их строковые представления:
julia> :foo == Symbol("foo")
true
julia> Symbol("func",10)
:func10
julia> Symbol(:var,'_',"sym")
:var_sym
Обратите внимание, что для использования синтаксиса : имя символа должно быть допустимым идентификатором. В противном случае необходимо использовать конструктор Symbol(str).
В контексте выражения символы используются для обозначения доступа к переменным; при оценке выражения символ заменяется значением, связанным с этим символом в соответствующей области видимости.
Иногда дополнительные скобки вокруг аргумента к : необходимы для избежания неоднозначности в разборе:
julia> :(:) :(:) julia> :(::) :(::)
Выражения и оценка
Квотирование
Второе синтаксическое назначение символа : заключается в создании объектов выражений без явного использования конструктора Expr. Это называется квотированием. Символ :, после которого идут парные скобки вокруг единственного оператора кода Julia, создает объект Expr на основе заключенного кода. Вот пример использования сокращенной формы для квотирования арифметического выражения:
julia> ex = :(a+b*c+1) :(a + b * c + 1) julia> typeof(ex) Expr
(для просмотра структуры этого выражения, попробуйте ex.head и ex.args, или используйте dump, как выше, или Meta.@dump)
Обратите внимание, что эквивалентные выражения можно создать с использованием Meta.parse или прямой формы Expr:
julia> :(a + b*c + 1) ==
Meta.parse("a + b*c + 1") ==
Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true
Выражения, предоставленные анализатором, обычно имеют в качестве аргументов только символы, другие выражения и литеральные значения, в то время как выражения, построенные кодом Julia, могут иметь произвольные значения во время выполнения без литеральных форм в качестве аргументов. В данном конкретном примере + и a — это символы, *(b,c) — это подвыражение, а 1 — это литеральное 64-битное знаковое целое число.
Существует вторая синтаксическая форма квотирования для нескольких выражений: блоки кода в quote ... end.
julia> ex = quote
x = 1
y = 2
x + y
end
quote
#= none:2 =#
x = 1
#= none:3 =#
y = 2
#= none:4 =#
x + y
end
julia> typeof(ex)
Expr
Интерполяция
Непосредственное создание объектов Expr с аргументами-значениями мощно, но конструкторы Expr могут быть громоздкими по сравнению с обычным синтаксисом Julia. В качестве альтернативы Julia позволяет интерполировать литералы или выражения в квотируемые выражения. Интерполяция обозначается префиксом $.
В этом примере интерполируется значение переменной a:
julia> a = 1; julia> ex = :($a + b) :(1 + b)
Интерполяция в неквотируемое выражение не поддерживается и вызовет ошибку компиляции:
julia> $a + b ERROR: syntax: "$" expression outside quote
В этом примере кортеж (1,2,3) интерполируется как выражение в условный тест:
julia> ex = :(a in $:((1,2,3)) ) :(a in (1, 2, 3))
Использование $ для интерполяции выражений намеренно напоминает интерполяцию строк и интерполяцию команд. Интерполяция выражений позволяет удобно и наглядно программно создавать сложные выражения Julia.
Интерполяция распылением
Обратите внимание, что синтаксис интерполяции $ позволяет вставлять только одно выражение в окружающее выражение. Иногда у вас есть массив выражений, и вам нужно, чтобы все они стали аргументами окружающего выражения. Это можно сделать с помощью синтаксиса $(xs...). Например, следующий код генерирует вызов функции, где количество аргументов определяется программно:
julia> args = [:x, :y, :z]; julia> :(f(1, $(args...))) :(f(1, x, y, z))
Вложенное квотирование
Естественно, квотируемые выражения могут содержать и другие квотируемые выражения. Понимание того, как работает интерполяция в этих случаях, может быть немного сложным. Рассмотрим этот пример:
julia> x = :(1 + 2);
julia> e = quote quote $x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :x))
end))
end
Обратите внимание, что результат содержит $x, что означает, что x еще не было оценено. Другими словами, выражение $ «принадлежит» внутреннему квотируемому выражению, и поэтому его аргумент оценивается только тогда, когда оценивается внутреннее квотируемое выражение:
julia> eval(e)
quote
#= none:1 =#
1 + 2
end
Однако внешнее выражение quote способно интерполировать значения внутри $ вложенного квотирования. Это выполняется с помощью нескольких $:
julia> e = quote quote $$x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :(1 + 2)))
end))
end
Обратите внимание, что теперь в результате отображается (1 + 2) вместо символа x. Оценка этого выражения дает интерполированное 3:
julia> eval(e)
quote
#= none:1 =#
3
end
Интуиция, стоящая за этим поведением, заключается в том, что x вычисляется один раз для каждого $: одно $ работает аналогично eval(:x), давая значение x, в то время как два $ выполняют эквивалент eval(eval(:x)).
QuoteNode
Обычное представление формы quote в AST — это Expr с заголовком :quote:
julia> dump(Meta.parse(":(1+2)"))
Expr
head: Symbol quote
args: Array{Any}((1,))
1: Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol +
2: Int64 1
3: Int64 2
Как мы видели, такие выражения поддерживают интерполяцию с $.
Однако в некоторых ситуациях необходимо квотировать код без выполнения интерполяции. Такое квотирование пока не имеет синтаксиса, но внутренне представлено объектом типа QuoteNode:
julia> eval(Meta.quot(Expr(:$, :(1+2)))) 3 julia> eval(QuoteNode(Expr(:$, :(1+2)))) :($(Expr(:$, :(1 + 2))))
Анализатор возвращает QuoteNode для простых квотируемых элементов, таких как символы:
julia> dump(Meta.parse(":x"))
QuoteNode
value: Symbol x
QuoteNode также может использоваться для определенных сложных задач метапрограммирования.
Оценивание выражений
Учитывая объект выражения, можно заставить Julia оценить (выполнить) его в глобальной области с помощью eval:
julia> ex1 = :(1 + 2) :(1 + 2) julia> eval(ex1) 3 julia> ex = :(a + b) :(a + b) julia> eval(ex) ERROR: UndefVarError: b not defined [...] julia> a = 1; b = 2; julia> eval(ex) 3
Каждый модуль имеет свою собственную функцию eval, которая оценивает выражения в его глобальной области. Выражения, передаваемые функции eval, не ограничены возвратом значений — они также могут иметь побочные эффекты, изменяющие состояние среды модуля:
julia> ex = :(x = 1) :(x = 1) julia> x ERROR: UndefVarError: x not defined julia> eval(ex) 1 julia> x 1
Здесь оценка объекта выражения приводит к присвоению значения глобальной переменной x.
Поскольку выражения — это просто объекты Expr, которые можно программно создавать и затем оценивать, можно динамически генерировать произвольный код, который можно затем выполнить с помощью eval. Вот простой пример:
julia> a = 1; julia> ex = Expr(:call, :+, a, :b) :(1 + b) julia> a = 0; b = 2; julia> eval(ex) 3
Значение a используется для построения выражения ex, которое применяет функцию + к значению 1 и переменной b. Обратите внимание на важное различие в использовании a и b:
- Значение переменной
aво время построения выражения используется как непосредственное значение в выражении. Таким образом, значениеaпри вычислении выражения больше не имеет значения: значение в выражении уже1, независимо от значенияa. - С другой стороны, символ
:bиспользуется при построении выражения, поэтому значение переменнойbв этот момент не имеет значения –:b— это просто символ, а переменнаяbдаже не обязательно должна быть определена. Однако при вычислении выражения значение символа:bразрешается путем поиска значения переменнойb.
Функции над выражениями
Как было отмечено выше, одной из чрезвычайно полезных функций Julia является возможность генерировать и манипулировать кодом Julia внутри самого Julia. Мы уже видели один пример функции, возвращающей объекты Expr: функцию parse, которая принимает строку кода Julia и возвращает соответствующее выражение Expr. Функция также может принимать один или несколько объектов Expr в качестве аргументов и возвращать другой объект Expr. Вот простой, мотивирующий пример:
julia> function math_expr(op, op1, op2)
expr = Expr(:call, op, op1, op2)
return expr
end
math_expr (generic function with 1 method)
julia> ex = math_expr(:+, 1, Expr(:call, :*, 4, 5))
:(1 + 4 * 5)
julia> eval(ex)
21
В качестве другого примера, вот функция, которая удваивает любой числовой аргумент, но оставляет выражения без изменений:
julia> function make_expr2(op, opr1, opr2)
opr1f, opr2f = map(x -> isa(x, Number) ? 2*x : x, (opr1, opr2))
retexpr = Expr(:call, op, opr1f, opr2f)
return retexpr
end
make_expr2 (generic function with 1 method)
julia> make_expr2(:+, 1, 2)
:(2 + 4)
julia> ex = make_expr2(:+, 1, Expr(:call, :*, 5, 8))
:(2 + 5 * 8)
julia> eval(ex)
42
Макросы
Макросы предоставляют способ включения сгенерированного кода в окончательный код программы. Макрос отображает кортеж аргументов на возвращаемое выражение, и полученное выражение компилируется непосредственно, а не требует вызова eval во время выполнения. Аргументы макроса могут включать выражения, литералы и символы.
Основы
Вот чрезвычайно простой макрос:
julia> macro sayhello()
return :( println("Hello, world!") )
end
@sayhello (macro with 1 method)
Макросы имеют специальный символ в синтаксисе Julia: символ @ (знак @), за которым следует уникальное имя, объявленное в блоке macro NAME ... end. В этом примере компилятор заменит все экземпляры @sayhello на:
:( println("Hello, world!") )
Когда @sayhello вводится в REPL, выражение выполняется немедленно, поэтому мы видим только результат вычисления:
julia> @sayhello() Hello, world!
Теперь рассмотрим несколько более сложный макрос:
julia> macro sayhello(name)
return :( println("Hello, ", $name) )
end
@sayhello (macro with 1 method)
Этот макрос принимает один аргумент: name. Когда встречается @sayhello, цитируемое выражение расширяется, чтобы интерполировать значение аргумента в конечное выражение:
julia> @sayhello("human")
Hello, human
Мы можем просмотреть цитируемое возвращаемое выражение с помощью функции macroexpand (важное примечание: это чрезвычайно полезный инструмент для отладки макросов):
julia> ex = macroexpand(Main, :(@sayhello("human")) )
:(Main.println("Hello, ", "human"))
julia> typeof(ex)
Expr
Мы видим, что литерал "human" был интерполирован в выражение.
Также существует макрос @macroexpand, который, возможно, немного удобнее, чем функция macroexpand:
julia> @macroexpand @sayhello "human"
:(println("Hello, ", "human"))
Постойте, зачем макросы?
Мы уже видели функцию f(::Expr...) -> Expr в предыдущем разделе. Фактически, macroexpand также является такой функцией. Так зачем нужны макросы?
Макросы необходимы, потому что они выполняются при разборе кода, поэтому макросы позволяют программисту генерировать и включать фрагменты настраиваемого кода до полного выполнения программы. Чтобы проиллюстрировать разницу, рассмотрим следующий пример:
julia> macro twostep(arg)
println("I execute at parse time. The argument is: ", arg)
return :(println("I execute at runtime. The argument is: ", $arg))
end
@twostep (macro with 1 method)
julia> ex = macroexpand(Main, :(@twostep :(1, 2, 3)) );
I execute at parse time. The argument is: :((1, 2, 3))
Первый вызов println выполняется при вызове macroexpand. Полученное выражение содержит только второй println:
julia> typeof(ex)
Expr
julia> ex
:(println("I execute at runtime. The argument is: ", $(Expr(:copyast, :($(QuoteNode(:((1, 2, 3)))))))))
julia> eval(ex)
I execute at runtime. The argument is: (1, 2, 3)
Вызов макроса
Макросы вызываются с помощью следующего общего синтаксиса:
@name expr1 expr2 ... @name(expr1, expr2, ...)
Обратите внимание на различающийся символ @ перед именем макроса и отсутствие запятых между выражениями аргументов в первой форме, и отсутствие пробелов после @name во второй форме. Эти два стиля не следует смешивать. Например, следующий синтаксис отличается от приведённых выше примеров; он передает кортеж (expr1, expr2, ...) как один аргумент макросу:
@name (expr1, expr2, ...)
Альтернативный способ вызова макроса над литералом массива (или выражением с пониманием) — сопоставить их без использования скобок. В этом случае макрос получит массив как единственное выражение. Следующий синтаксис эквивалентен (и отличается от @name [a b] * v):
@name[a b] * v @name([a b]) * v
Важно подчеркнуть, что макросы получают свои аргументы в виде выражений, литералов или символов. Один из способов изучить аргументы макроса — вызвать функцию show внутри тела макроса:
julia> macro showarg(x)
show(x)
# ... remainder of macro, returning an expression
end
@showarg (macro with 1 method)
julia> @showarg(a)
:a
julia> @showarg(1+1)
:(1 + 1)
julia> @showarg(println("Yo!"))
:(println("Yo!"))
Помимо заданного списка аргументов, каждый макрос получает дополнительные аргументы с именами __source__ и __module__.
Аргумент __source__ предоставляет информацию (в виде объекта LineNumberNode ) о расположении в парсере символа @ из вызова макроса. Это позволяет макросам включать лучшую диагностику ошибок и обычно используется в журналах, макросах для парсинга строк и документации, а также для реализации макросов @__LINE__, @__FILE__ и @__DIR__.
Информацию о местоположении можно получить, ссылаясь на __source__.line и __source__.file:
julia> macro __LOCATION__(); return QuoteNode(__source__); end
@__LOCATION__ (macro with 1 method)
julia> dump(
@__LOCATION__(
))
LineNumberNode
line: Int64 2
file: Symbol none
Аргумент __module__ предоставляет информацию (в виде объекта Module ) о контексте расширения вызова макроса. Это позволяет макросам искать контекстную информацию, например, существующие связи, или вставлять значение в качестве дополнительного аргумента к вызову функции выполнения, выполняющей саморефлексию в текущем модуле.
Создание расширенного макроса
Вот упрощённое определение макроса Julia @assert:
julia> macro assert(ex)
return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
end
@assert (macro with 1 method)
Этот макрос можно использовать следующим образом:
julia> @assert 1 == 1.0 julia> @assert 1 == 0 ERROR: AssertionError: 1 == 0
Вместо написанного синтаксиса вызов макроса расширяется во время разбора до его результата. Это эквивалентно написанию:
1 == 1.0 ? nothing : throw(AssertionError("1 == 1.0"))
1 == 0 ? nothing : throw(AssertionError("1 == 0"))
То есть, в первом вызове выражение :(1 == 1.0) вставляется в слот условия проверки, а значение string(:(1 == 1.0)) вставляется в слот сообщения об утверждении. Всё сконструированное выражение размещается в синтаксическом дереве, где происходит вызов макроса @assert. Затем во время выполнения, если выражение проверки оценивается как истинное, то возвращается nothing, в противном случае генерируется ошибка, указывающая на ошибочное утверждаемое выражение. Обратите внимание, что это нельзя было бы записать как функцию, так как доступно только значение условия, и невозможно было бы отобразить выражение, которое его вычислило, в сообщении об ошибке.
Фактическое определение макроса @assert в Julia Base более сложно. Оно позволяет пользователю необязательно указывать собственное сообщение об ошибке вместо простого вывода невыполненного выражения. Так же, как и в функциях с переменным числом аргументов (Функции с переменным числом аргументов), это указывается с помощью многоточия после последнего аргумента:
julia> macro assert(ex, msgs...)
msg_body = isempty(msgs) ? ex : msgs[1]
msg = string(msg_body)
return :($ex ? nothing : throw(AssertionError($msg)))
end
@assert (macro with 1 method)
Теперь у @assert есть два режима работы в зависимости от количества получаемых им аргументов! Если аргументов только один, кортеж выражений, захваченных msgs, будет пустым, и он будет работать так же, как и более простое определение выше. Но теперь, если пользователь указывает второй аргумент, он выводится в теле сообщения вместо выражения, приведшего к ошибке. Результат расширения макроса можно проверить с помощью макроса @macroexpand:
julia> @macroexpand @assert a == b
:(if Main.a == Main.b
Main.nothing
else
Main.throw(Main.AssertionError("a == b"))
end)
julia> @macroexpand @assert a==b "a should equal b!"
:(if Main.a == Main.b
Main.nothing
else
Main.throw(Main.AssertionError("a should equal b!"))
end)
Ещё один случай, который обрабатывает фактический макрос @assert, заключается в том, что помимо вывода «a должно быть равно b», мы хотели бы вывести их значения. Можно было бы наивно попробовать использовать интерполяцию строк в пользовательском сообщении, например, @assert a==b "a ($a) should equal b ($b)!", но это не сработает так, как ожидается, с вышеуказанным макросом. Почему? Вспомните из раздела интерполяция строк, что интерполированная строка преобразуется в вызов string. Сравните:
julia> typeof(:("a should equal b"))
String
julia> typeof(:("a ($a) should equal b ($b)!"))
Expr
julia> dump(:("a ($a) should equal b ($b)!"))
Expr
head: Symbol string
args: Array{Any}((5,))
1: String "a ("
2: Symbol a
3: String ") should equal b ("
4: Symbol b
5: String ")!"
Таким образом, вместо получения простой строки в msg_body, макрос получает полное выражение, которое необходимо будет вычислить, чтобы отобразить его должным образом. Это можно непосредственно вставить в возвращаемое выражение как аргумент к вызову string; см. error.jl для полной реализации.
Макрос @assert активно использует вставку в цитируемые выражения для упрощения манипулирования выражениями внутри тела макроса.
Гигиена
Проблема, возникающая в более сложных макросах, — это проблема гигиены. Короче говоря, макросы должны гарантировать, что переменные, которые они вводят в своих возвращаемых выражениях, случайно не сталкиваются с существующими переменными в окружающем коде, в который они расширяются. И наоборот, выражения, передаваемые макросу в качестве аргументов, часто ожидаются для оценки в контексте окружающего кода, взаимодействуя с существующими переменными и изменяя их. Другая проблема возникает из того факта, что макрос может вызываться в другом модуле, отличном от того, где он был определен. В этом случае нам необходимо убедиться, что все глобальные переменные разрешены в правильном модуле. Julia уже имеет значительное преимущество перед языками с текстовой макроподстановкой (такими как C) в том, что ей нужно только учитывать возвращаемое выражение. Все остальные переменные (например, msg в @assert выше) следуют обычному поведению блоков области видимости.
Чтобы продемонстрировать эти проблемы, давайте рассмотрим написание макроса @time, который принимает выражение в качестве аргумента, записывает время, вычисляет выражение, снова записывает время, выводит разницу между временем до и после, а затем имеет значение выражения в качестве своего конечного значения. Макрос может выглядеть так:
macro time(ex)
return quote
local t0 = time_ns()
local val = $ex
local t1 = time_ns()
println("elapsed time: ", (t1-t0)/1e9, " seconds")
val
end
end
Здесь мы хотим, чтобы t0, t1, и val были частными временными переменными, и мы хотим, чтобы time_ns ссылалось на функцию time_ns в Julia Base, а не на какую-либо time_ns переменную, которая может быть у пользователя (то же самое относится к println). Представьте себе проблемы, которые могут возникнуть, если пользовательское выражение ex также содержит присваивания переменной с именем t0, или определяет свою собственную переменную time_ns. Мы можем получить ошибки или загадочное некорректное поведение.
Макрос-расширитель Julia решает эти проблемы следующим образом. Во-первых, переменные внутри результата макроса классифицируются как локальные или глобальные. Переменная считается локальной, если она присваивается (и не объявлена глобальной), объявлена локальной или используется как имя аргумента функции. В противном случае она считается глобальной. Локальные переменные затем переименовываются для уникальности (с использованием функции gensym, которая генерирует новые символы), а глобальные переменные разрешаются в среде определения макроса. Таким образом, обе вышеупомянутые проблемы решаются; локальные переменные макроса не будут конфликтовать ни с какими пользовательскими переменными, и time_ns и println будут ссылаться на определения Julia Base.
Однако одна проблема остается. Рассмотрим следующее использование этого макроса:
module MyModule import Base.@time time_ns() = ... # compute something @time time_ns() end
Здесь пользовательское выражение ex является вызовом time_ns, но не той же самой time_ns функцией, что и макрос. Оно явно ссылается на MyModule.time_ns. Поэтому мы должны организовать, чтобы код в ex был разрешен в среде вызова макроса. Это делается путем "экранирования" выражения с помощью esc:
macro time(ex)
...
local val = $(esc(ex))
...
end
Выражение, заключенное таким образом, оставляется макросом-расширителем в покое и просто вставляется в вывод дословно. Поэтому оно будет разрешено в среде вызова макроса.
Этот механизм экранирования может использоваться для «нарушения» гигиены при необходимости, чтобы ввести или манипулировать пользовательскими переменными. Например, следующий макрос устанавливает x в ноль в среде вызова:
julia> macro zerox()
return esc(:(x = 0))
end
@zerox (macro with 1 method)
julia> function foo()
x = 1
@zerox
return x # is zero
end
foo (generic function with 1 method)
julia> foo()
0
Этот вид манипулирования переменными следует использовать осмотрительно, но иногда он очень полезен.
Обеспечение корректности правил гигиены может быть сложной задачей. Прежде чем использовать макрос, вы можете подумать, достаточно ли будет функции замыкания. Еще одна полезная стратегия — отложить как можно больше работы на время выполнения. Например, многие макросы просто заключают свои аргументы в QuoteNode или другие подобные Expr. Некоторые примеры этого включают @task body, который просто возвращает schedule(Task(() -> $body)), и @eval expr, который просто возвращает eval(QuoteNode(expr)).
Чтобы продемонстрировать, мы можем переписать пример @time выше как:
macro time(expr)
return :(timeit(() -> $(esc(expr))))
end
function timeit(f)
t0 = time_ns()
val = f()
t1 = time_ns()
println("elapsed time: ", (t1-t0)/1e9, " seconds")
return val
end
Однако мы не делаем этого по уважительной причине: заключение expr в новый блок области видимости (анонимная функция) также немного изменяет значение выражения (область видимости любых переменных в нем), тогда как мы хотим, чтобы @time было пригодно к использованию с минимальным влиянием на заключенный код.
Макросы и распределение
Макросы, так же как и функции Julia, являются универсальными. Это означает, что они также могут иметь несколько определений методов благодаря многократному распределению:
julia> macro m end
@m (macro with 0 methods)
julia> macro m(args...)
println("$(length(args)) arguments")
end
@m (macro with 1 method)
julia> macro m(x,y)
println("Two arguments")
end
@m (macro with 2 methods)
julia> @m "asd"
1 arguments
julia> @m 1 2
Two arguments
Однако следует помнить, что распределение макросов основано на типах AST, которые передаются макросу, а не на типах, к которым AST приводит во время выполнения:
julia> macro m(::Int)
println("An Integer")
end
@m (macro with 3 methods)
julia> @m 2
An Integer
julia> x = 2
2
julia> @m x
1 arguments
Генерация кода
Когда требуется значительное количество повторяющегося кода-шаблона, обычно его генерируют программно, чтобы избежать избыточности. В большинстве языков это требует дополнительного этапа сборки и отдельной программы для генерации повторяющегося кода. В Julia интерполяция выражений и eval позволяют такой генерации кода происходить в рамках обычного выполнения программы. Например, рассмотрим следующий пользовательский тип
struct MyNumber
x::Float64
end
# output
для которого мы хотим добавить ряд методов. Мы можем сделать это программно в следующем цикле:
for op = (:sin, :cos, :tan, :log, :exp)
eval(quote
Base.$op(a::MyNumber) = MyNumber($op(a.x))
end)
end
# output
и теперь мы можем использовать эти функции с нашим пользовательским типом:
julia> x = MyNumber(π) MyNumber(3.141592653589793) julia> sin(x) MyNumber(1.2246467991473532e-16) julia> cos(x) MyNumber(-1.0)
Таким образом, Julia действует как собственный препроцессор и позволяет генерировать код изнутри языка. Вышеприведенный код можно написать немного более кратко, используя префиксную форму цитирования ::
for op = (:sin, :cos, :tan, :log, :exp)
eval(:(Base.$op(a::MyNumber) = MyNumber($op(a.x))))
end
Этот вид генерации кода внутри языка, однако, используя шаблон eval(quote(...)), достаточно распространен, поэтому Julia поставляется с макросом для сокращения этого шаблона:
for op = (:sin, :cos, :tan, :log, :exp)
@eval Base.$op(a::MyNumber) = MyNumber($op(a.x))
end
Макрос @eval переписывает этот вызов, что точно эквивалентно приведенным выше более длинным версиям. Для более длинных блоков генерируемого кода аргумент выражения, переданный @eval, может быть блоком:
@eval begin
# multiple lines
end
Нестандартные строковые литералы
Вспомните из Строки, что строковые литералы с префиксом идентификатора называются нестандартными строковыми литералами и могут иметь семантику, отличную от не-префиксных строковых литералов. Например:
-
r"^\s*(?:#|$)"создает объект регулярного выражения, а не строку -
b"DATA\xff\u2200"— это литерал массива байтов для[68,65,84,65,255,226,136,128].
Возможно, это удивительно, но это поведение не закодировано в парсер или компилятор Julia. Вместо этого это пользовательское поведение, предоставляемое общим механизмом, которым может воспользоваться любой пользователь: префиксные строковые литералы анализируются как вызовы специально названных макросов. Например, макрос регулярных выражений — это просто следующее:
macro r_str(p)
Regex(p)
end
Вот и всё. Этот макрос говорит, что содержимое строкового литерала r"^\s*(?:#|$)" должно быть передано в макрос @r_str, а результат этого расширения должен быть помещен в дерево синтаксиса, где находится строковый литерал. Другими словами, выражение r"^\s*(?:#|$)" эквивалентно прямому размещению следующего объекта в дерево синтаксиса:
Regex("^\\s*(?:#|\$)")
Строковая форма не только короче и удобнее, но и эффективнее: так как регулярное выражение компилируется, а объект Regex фактически создается при компиляции кода, компиляция происходит только один раз, а не каждый раз при выполнении кода. Подумайте, если регулярное выражение встречается в цикле:
for line = lines
m = match(r"^\s*(?:#|$)", line)
if m === nothing
# non-comment
else
# comment
end
end
Поскольку регулярное выражение r"^\s*(?:#|$)" компилируется и вставляется в дерево синтаксиса при разборе этого кода, выражение компилируется только один раз вместо каждого выполнения цикла. Для достижения этого без макросов пришлось бы написать этот цикл следующим образом:
re = Regex("^\\s*(?:#|\$)")
for line = lines
m = match(re, line)
if m === nothing
# non-comment
else
# comment
end
end
Более того, если компилятор не может определить, что объект regex является постоянным во всех циклах, некоторые оптимизации могут быть невозможны, что делает эту версию по-прежнему менее эффективной, чем более удобная форма литерала выше. Конечно, есть ситуации, когда нелитеральная форма более удобна: если требуется интерполяция переменной в регулярное выражение, необходимо использовать более громоздкий подход; в случаях, когда шаблон регулярного выражения сам по себе динамичен, потенциально изменяясь при каждом проходе цикла, новый объект регулярного выражения должен создаваться на каждой итерации. Однако в подавляющем большинстве случаев регулярные выражения не строятся на основе данных времени выполнения. В большинстве случаев возможность записи регулярных выражений как значений времени компиляции очень ценна.
Подобно нестандартным строковым литералам, существуют нестандартные литералы команд с использованием префиксной формы синтаксиса литералов команд. Литерал команды custom`literal` анализируется как @custom_cmd "literal". Сам Julia не содержит нестандартных литералов команд, но пакеты могут использовать этот синтаксис. Помимо разного синтаксиса и суффикса _cmd вместо суффикса _str, нестандартные литералы команд ведут себя точно так же, как нестандартные строковые литералы.
В случае, если два модуля предоставляют нестандартные строковые или командные литералы с одинаковым именем, можно квалифицировать строковый или командный литерал с именем модуля. Например, если и Foo, и Bar предоставляют нестандартный строковый литерал @x_str, то можно написать Foo.x"literal" или Bar.x"literal" для устранения неоднозначности между двумя.
Механизм для пользовательских строковых литералов очень мощный. Он используется не только для реализации нестандартных литералов Julia, но и для синтаксиса литералов команд (`echo "Hello, $person"`) с помощью следующего, на первый взгляд простого макроса:
macro cmd(str)
:(cmd_gen($(shell_parse(str)[1])))
end
Конечно, в определениях функций, используемых в этом макросе, скрывается значительная сложность, но они просто функции, написанные полностью на языке Julia. Вы можете прочитать их исходный код и увидеть, что они делают — и все, что они делают, — это создавать объекты выражений для вставки в дерево синтаксиса вашей программы.
Другой способ определения макроса выглядит так:
macro foo_str(str, flag)
# do stuff
end
END_OF_DOCUMENT_MARKER Этот макрос затем можно вызвать с помощью следующего синтаксиса:
foo"str"flag
Тип флага в вышеупомянутом синтаксисе будет String, содержащим содержимое всего, что следует за строковой литеральной.
Сгенерированные функции
Очень специальный макрос @generated, который позволяет определять так называемые сгенерированные функции. Они обладают способностью генерировать специализированный код в зависимости от типов их аргументов с большей гибкостью и/или меньшим количеством кода, чем можно добиться с помощью множественного диспетчера. В то время как макросы работают со выражениями на стадии разбора и не могут получить доступ к типам своих входных данных, сгенерированная функция расширяется в момент, когда типы аргументов известны, но функция еще не скомпилирована.
Вместо выполнения какого-либо вычисления или действия, объявление сгенерированной функции возвращает выражение в кавычках, которое затем образует тело для метода, соответствующего типам аргументов. Когда вызывается сгенерированная функция, выражение, которое она возвращает, компилируется, а затем выполняется. Для повышения эффективности результат обычно кэшируется. А для возможности вывода результата, используется только ограниченный подмножество языка. Таким образом, сгенерированные функции предоставляют гибкий способ перемещения работы из времени выполнения в время компиляции, ценой более строгих ограничений на допустимые конструкции.
При определении сгенерированных функций существует пять основных отличий от обычных функций:
- Вы аннотируете объявление функции с помощью макроса
@generated. Это добавляет некоторую информацию в AST, которая позволяет компилятору узнать, что это сгенерированная функция. - В теле сгенерированной функции вы имеете доступ только к типам аргументов, а не к их значениям.
- Вместо вычисления чего-либо или выполнения какого-либо действия, вы возвращаете выражение в кавычках, которое, при оценке, делает то, что вам нужно.
- Сгенерированные функции могут вызывать только функции, которые были определены до определения сгенерированной функции. (Несоблюдение этого может привести к получению
MethodErrors, относящегося к функциям из будущего.) - Сгенерированные функции не должны изменять или наблюдать за любым неконстантным глобальным состоянием (включая, например, ввод/вывод, блокировки, нелокальные словари или использование
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является патологической
Обратите внимание, что множество операций, которые не следует пытаться использовать в сгенерированной функции, не ограничено, и система выполнения в настоящее время может обнаруживать только подмножество недопустимых операций. Существует множество других операций, которые просто испортят систему выполнения без уведомления, обычно в тонких способами, не очевидно связанных с плохим определением. Поскольку генератор функций выполняется во время вывода, он должен соблюдать все ограничения этого кода.
Некоторые операции, которые не следует пытаться выполнять, включают:
Кэширование указателей нативные.
Взаимодействие с содержимым или методами
Core.Compilerкаким-либо образом.-
Наблюдение за любым изменяемым состоянием.
- Вывод сгенерированной функции может выполняться в любое время, включая время, когда ваш код пытается наблюдать или изменять это состояние.
Принятие каких-либо блокировок: код C, который вы вызываете, может использовать блокировки внутри, (например, вызывать
mallocне является проблемой, даже если большинство реализаций требуют блокировки внутри), но не пытайтесь удерживать или приобретать их во время выполнения кода Julia.Вызов любой функции, которая определяется после тела сгенерированной функции. Это условие ослаблено для модулей с предварительной компиляцией, загружаемых по частям, для разрешения вызова любой функции в модуле.
Хорошо, теперь, когда мы лучше понимаем, как работают сгенерированные функции, давайте используем их для создания некоторых более сложных (и допустимых) функциональных возможностей...
Пример расширенного использования
Внутренняя библиотека 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–2021 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.6.0/manual/metaprogramming/