Монография в ответ:
Основы метапрограммирования
Сначала будет проще начать с "нормальных" макросов. Я немного расслаблю определение, которое вы использовали:
function fib_expr(n::Integer, p)
if n <= 1
return :(1 * $p)
else
return :($n * $(fib_expr(n-1, p)))
end
end
Это позволяет передавать для p
не только символы, например целочисленные литералы или целые выражения. Учитывая это, мы можем определить макрос для той же функциональности:
macro fib_macro(n::Integer, p)
fib_expr(n, p)
end
Теперь, если @fib_macro 45 1
используется где-либо в коде, во время компиляции он сначала будет заменен длинным вложенным выражением
:(45 * (44 * ... * (1 * 1)) ... )
и затем компилируется нормально - в константу.
Вот и все, что нужно с макросами, правда. Замена синтаксиса во время компиляции; и с помощью рекурсии это может быть сколь угодно длинное изменение между компиляцией и вычислением функций в выражениях. И для вещей, которые по существу постоянны, но утомительны, если писать иначе, это очень полезно: пример хорошего примера: Base.Math. @ Evalpoly .
Оценка во время выполнения?
Но проблема в том, что вы не можете проверять значения, которые известны только во время выполнения: вы не можете реализовать fib(n) = @fib_macro n 1
, поскольку во время компиляции n
является символом, представляющим параметр, а не числом, которое вы можете отправить на.
Следующим лучшим решением будет использование
fib_eval(n::Integer) = eval(fib_expr(n, 1))
, который работает, но будет повторять процесс компиляции каждый раз, когда он вызывается - и это намного больше затрат, чем исходная функция, поскольку теперь во время выполнения , мы выполняем всю рекурсию для дерева выражений а затем вызвать компилятор на результат. Не хорошо.
Метод отправки и компиляции
Так что нам нужен способ смешивать время выполнения и время компиляции. Введите @generated
функции. Они будут выполняться во время выполнения для типа , а затем работать как макрос, определяющий тело функции.
Сначала о типе отправки. Если у нас есть
f(x) = x + 1
и вызов функции f(1)
, произойдет примерно следующее:
- Определен тип аргумента (
Int
)
- С таблицей методов функции обращаются, чтобы найти лучший метод соответствия
- Тело метода компилируется для определенного типа аргумента
Int
, если это не было сделано ранее
- Скомпилированный метод оценивается по конкретному аргументу
Если мы затем введем f(1.0)
, то же самое произойдет снова, с новым, другим специализированным методом, скомпилированным для Float64
, основанным на том же теле функции.
Типы значений и одиночные типы
Теперь у Юлии есть особенность: вы можете использовать числа в качестве типов. Это означает, что описанный выше процесс отправки будет также работать со следующей функцией:
g(::Type{Val{N}}) where N = N + 1
Это немного сложно. Помните, что типы сами по себе являются значениями в Юлии: Int isa Type
.
Здесь Val{N}
- это для каждого N
так называемого синглетонного типа , имеющего ровно один экземпляр, а именно Val{N}()
- точно так же, как Int
- это тип, имеющий много экземпляров 0
, -1
, 1
, -2
, ....
Type{T}
также является одноэлементным типом, имеющим в качестве единственного экземпляра тип T
. Int
- это Type{Int}
, а Val{3}
- это Type{Val{3}}
- фактически оба являются единственными значениями своего типа.
Итак, для каждого N
существует тип Val{N}
, являющийся единственным экземпляром Type{Val{N}}
. Таким образом, g
будет отправлено и скомпилировано для каждого N
. Вот как мы можем отправлять числа как типы. Это уже позволяет оптимизировать:
julia> @code_llvm g(Val{1})
define i64 @julia_g_61158(i8**) #0 !dbg !5 {
top:
ret i64 2
}
julia> @code_llvm f(1)
define i64 @julia_f_61076(i64) #0 !dbg !5 {
top:
%1 = shl i64 %0, 2
%2 = or i64 %1, 3
%3 = mul i64 %2, %0
%4 = add i64 %3, 2
ret i64 %4
}
Но помните, что он требует компиляции для каждого нового N
при первом вызове.
(И fkt(::T)
- это просто сокращение от fkt(x::T)
, если вы не используете x
в теле.)
Интеграция производящих функций и типов значений
Наконец к сгенерированным функциям. Они работают как небольшая модификация вышеуказанного шаблона отправки:
- Определен тип аргумента (
Int
) - С таблицей методов функции обращаются, чтобы найти наилучший метод сопоставления
- Тело метода обрабатывается как макрос, и вызывается с типом аргумента
Int
в качестве параметра ,если это не было сделано раньше.Полученное выражение компилируется в метод. - Скомпилированный метод оценивается по конкретному аргументу
Этот шаблон позволяет изменить реализацию для каждого типа, которому отправляется функция.
Для нашей конкретной настройки мы хотим отправить типы Val
, представляющие аргументы последовательности Фибоначчи:
@generated function fib_gen{n}(::Type{Val{n}}, p::Real)
return fib_expr(n, :p)
end
Теперь вы видите, что ваше объяснение было совершенно правильным:
при первом вызове fib_gen(Val{3}, 0.5)
параметрическая функция fib_gen{Val{3}}(...)
компилируется, и ее содержимым является полностью расширенное выражение, полученное с помощью fib_expr(3, :p)
, т.е. 3*2*1*p
с p
, замененным на входзначение.
Я надеюсь, что вся история также ответила на все три перечисленных вами вопроса:
- Реализация, использующая
eval
, повторяет рекурсию каждый раз, плюсиздержки компиляции Val
- это трюк для поднятия чисел к типам, а Type{T}
синглтон-тип, содержащий только T
-- но я надеюсь, что примеры были достаточно полезны - Время компиляции не перед выполнением, из-за JIT - это каждый раз, когда метод компилируется в первый раз, потому что он вызывается.