Функции в Julia
В этой главе описываются принципы работы функций, определений методов и таблиц методов.
Таблицы методов
Каждая функция в Julia является обобщенной функцией. Обобщенная функция концептуально представляет собой одну функцию, но состоит из множества определений, или методов. Методы обобщенной функции хранятся в таблице методов. Существует одна глобальная таблица методов (тип MethodTable) с именем Core.methodtable. Любая операция по умолчанию над методами (например, вызовы) использует эту таблицу.
Вызовы функций
При вызове f(x, y) выполняются следующие шаги: сначала формируется тип кортежа Tuple{typeof(f), typeof(x), typeof(y)}. Обратите внимание, что тип самой функции является первым элементом. Это связано с тем, что сама функция симметрично участвует в поиске метода с другими аргументами. Этот тип кортежа ищется в глобальной таблице методов. Однако система может затем кэшировать результаты, поэтому эти шаги можно пропустить позже для аналогичных поисков.
Процесс диспетчеризации осуществляется функцией jl_apply_generic, которая принимает два аргумента: указатель на массив значений f, x и y и количество значений (в данном случае 3).
В системе есть два вида API для работы с функциями и списками аргументов: одни из них принимают функцию и аргументы по отдельности, а другие — единую структуру аргументов. При использовании API первого вида часть с информацией об аргументах не содержит информации о функции, так как она передается отдельно. При использовании API второго вида функция является первым элементом структуры аргументов.
Например, следующая функция для выполнения вызова принимает только указатель на args, поэтому первым элементом массива args будет вызываемая функция.
jl_value_t *jl_apply(jl_value_t **args, uint32_t nargs)
Данная точка входа с тем же назначением принимает функцию отдельно, поэтому массив args не содержит функции.
jl_value_t *jl_call(jl_function_t *f, jl_value_t **args, int32_t nargs);
Добавление методов
При описанном выше процессе диспетчеризации все, что нужно для добавления нового метода, — это (1) тип кортежа и (2) код для тела метода. Эту операцию реализует функция jl_method_def.
Создание универсальных функций
Так как вызвать можно любой объект, для создания универсальной функций не требуется ничего особенного. Поэтому jl_new_generic_function просто создает одинарный подтип Function (нулевого размера) и возвращает его экземпляр. У функции может быть мнемоническое отображаемое имя, которое применяется в отладочных данных и при выводе информации об объектах. Например, таким именем для Base.sin является sin. По соглашению имя создаваемого типа совпадает с именем функции, перед которым добавляется символ #. Поэтому typeof(sin) возвращает Base.#sin.
Замыкания
Замыкание — это просто вызываемый объект, имена полей которого соответствуют захваченным переменным. Например, следующий код:
function adder(x)
return y->x+y
end
понижается примерно до следующего:
struct ##1{T}
x::T
end
(_::##1)(y) = _.x + y
function adder(x)
return ##1(x)
end
Встроенные функции
В модуле Core определены следующие встроенные функции.
@ast MarkdownAST.Document() do MarkdownAST.CodeBlock("", "<: === _abstracttype _apply_iterate _call_in_world_total _compute_sparams\n_defaultctors _equiv_typedef _expr _primitivetype _setsuper! _structtype\n_svec_len _svec_ref _typebody! _typevar applicable apply_type compilerbarrier\ncurrent_scope donotdelete fieldtype finalizer get_binding_type getfield getglobal\nifelse invoke invoke_in_world invokelatest isa isdefined isdefinedglobal\nmemorynew memoryref_isassigned memoryrefget memoryrefmodify! memoryrefnew\nmemoryrefoffset memoryrefreplace! memoryrefset! memoryrefsetonce! memoryrefswap!\nmodifyfield! modifyglobal! nfields replacefield! replaceglobal! setfield!\nsetfieldonce! setglobal! setglobalonce! sizeof svec swapfield! swapglobal! throw\nthrow_methoderror tuple typeassert typeof") end
В основном это одиночные объекты, все типы которых являются подтипами Builtin, который является подтипом Function. Их цель — раскрыть точки входа в среде выполнения, которые используют конвенцию вызова «jlcall»:
jl_value_t *(jl_value_t*, jl_value_t**, uint32_t)
Именованные аргументы
Именованные аргументы работают путем добавления методов в функцию kwcall. Эта функция обычно выступает в роли «сортировщика именованных аргументов» и затем вызывает внутреннее тело функции (определенное анонимно). Каждое определение в функции kwsorter содержит те же аргументы, что и некоторое определение в обычной таблице методов, но в начале добавляется аргумент NamedTuple, который содержит имена и значения переданных именованных аргументов. Задача функции kwsorter состоит в помещении именованных аргументов в нужные позиции в соответствии с именами, а также в вычислении и подстановке всех необходимых выражений значений по умолчанию. В результате получается обычный список позиционных аргументов, который передается в еще одну функцию, сгенерированную компилятором.
Самый простой способ разобраться в этом процессе — посмотреть, как понижается определение метода с именованными аргументами. Для следующего кода:
function circle(center, radius; color = black, fill::Bool = true, options...)
# draw
end
на самом деле создаются определения трех методов. Первый — это функция, которая принимает все аргументы (в том числе именованные) как позиционные и включает в себя код тела метода. Ее имя генерируется автоматически:
function #circle#1(color, fill::Bool, options, circle, center, radius)
# draw
end
Второй метод — это стандартное определение исходной функции circle для того случая, когда именованные аргументы не передаются:
function circle(center, radius)
#circle#1(black, true, pairs(NamedTuple()), circle, center, radius)
end
В этом случае просто производится диспетчеризация в первый метод с передачей значений по умолчанию. Функция pairs применяется к именованному кортежу с остальными аргументами для итерации по парам «ключ — значение». Обратите внимание: если метод не принимает остальные именованные аргументы, данный аргумент отсутствует.
Наконец, создается определение kwsorter:
function (::Core.kwcall)(kws, circle, center, radius)
if haskey(kws, :color)
color = kws.color
else
color = black
end
# и т. д.
# Остальные именованные аргументы помещаются в `options`
options = structdiff(kws, NamedTuple{(:color, :fill)})
# Если метод не принимает остальные именованные аргументы, происходит ошибка
# при непустом `options`
#circle#1(color, fill, pairs(options), circle, center, radius)
end
Проблемы, связанные с эффективностью компиляции
Создание нового типа для каждой функции может иметь серьезные последствия в плане потребления ресурсов компилятором в сочетании с принятым в Julia подходом «специализация по умолчанию по всем аргументам». И действительно, первоначальная реализация этого подхода страдала такими недостатками, как гораздо большая длительность сборки и тестирования, увеличенное потребление памяти и почти в два раза больший размер образа системы по сравнению с базовым. При примитивной реализации проблема усугубляется настолько, что системой становится практически невозможно пользоваться. Чтобы сделать такой подход практически применимым, требовался ряд существенных оптимизаций.
Первая проблема заключается в излишней специализации функций для разных значений аргументов функционального типа. Многие функции просто передают аргумент куда-то далее, например в другую функцию или в место хранения. Такие функции не нужно специализировать для каждого передаваемого замыкания. К счастью, такой случай распознать легко: достаточно проверить, вызывает ли функция один из своих аргументов (то есть встречается ли аргумент где-либо в начальной позиции). Функции более высокого порядка, требующие высокого быстродействия, такие как map, обязательно вызывают функцию-аргумент и поэтому специализируются как нужно. Эта оптимизация реализуется путем предварительной фиксации вызываемых аргументов во время прохода analyze-variables. Когда cache_method обнаруживает в иерархии типов Function аргумент, передаваемый в слот, объявленный как Any или Function, поведение будет таким же, как при наличии аннотации @nospecialize. На практике такой эвристический механизм оказывается крайне эффективным.
Следующий вопрос касается структуры таблиц методов. Эмпирические исследования показывают, что подавляющее большинство динамически распределяемых вызовов включают один или два аргумента. В свою очередь, многие из этих случаев могут быть решены, учитывая только первый аргумент. (Кстати: сторонники одиночной диспетчеризации не будут этим удивлены. Однако этот аргумент означает, что «множественная диспетчеризация легко оптимизируется на практике» и что мы должны ее использовать, а не «мы должны использовать одиночную диспетчеризацию»!). Таким образом, таблица методов и кэш разделяются по структуре на основе дерева решений слева направо, что позволяет эффективно выполнять поиск ближайшего соседа.
Интерфейсная часть генерирует объявления типов для всех замыканий. Изначально это было реализовано путем создания обычных объявлений типов. Однако в результате создавалось слишком много конструкторов, каждый из которых был крайне примитивен (все аргументы просто передавались в new). Так как методы упорядочены лишь частично, их добавление имеет алгоритмическую сложность O(n²) и, кроме того, для их хранения требуются лишние ресурсы. Оптимизация была достигнута путем создания выражений struct_type напрямую (в обход создания конструкторов по умолчанию) и прямого использования new для создания экземпляров замыканий. Может быть, не самое элегантное решение, но что-то нужно было делать.
Следующей проблемой был макрос @test, который создавал замыкание без аргументов для каждого тестового случая. На самом деле необходимости в этом нет, так как каждый тестовый случай просто выполняется один раз на месте. Поэтому макрос @test был развернут в блок try-catch, фиксирующий результат теста (true, false или исключение) и вызывающий обработчик набора тестов.