Разбор последствий: почему вызов одной generic-функции для десяти конкретных типов может увеличить размер бинарника?
Для каждого конкретного типа, с которым используется обобщённая функция, Rust обычно выполняет мономорфизацию: создаёт специализированную версию функции. Поэтому десять типов могут привести к десяти экземплярам машинного кода, хотя исходная логика записана один раз.
Это даёт статическую диспетчеризацию, проверку типов на этапе компиляции и хорошие возможности для оптимизации, но может увеличить время компиляции и размер бинарника. Фактический итог зависит от оптимизаций, удаления неиспользуемого кода и возможностей линкера.
Обобщения создавались для повторного использования алгоритмов без отказа от статической типизации и производительности. Подход с мономорфизацией позволяет специализировать общий алгоритм под конкретные типы и не требовать обязательной поддержки универсального runtime-механизма для всех вызовов.
Такой подход часто называют реализацией zero-cost abstractions: абстракция generics обычно не добавляет плату за динамический поиск метода во время выполнения. Однако эта плата частично переносится на этап компиляции и потенциальный размер результирующего кода.
Предположим, библиотечная функция работает с любым типом, реализующим нужный трейт. Если её вызывают для большого числа типов, компилятору нужно проверить и сгенерировать код для каждого конкретного варианта.
Это создаёт два риска: увеличиваются время компиляции и размер бинарника. Для небольшого горячего алгоритма специализация обычно выгодна, но для крупной функции, редко используемой или вызываемой с десятками типов, дублирование машинного кода может стать существенным.
Мономорфизация происходит после того, как компилятор узнаёт конкретные типы параметров. Например, вызовы для u32 и u64 логически используют одну generic-функцию, но могут породить разные машинные реализации:
В run компилятор должен проверить два набора ограничений и сформировать варианты total для u32 и u64, если эти типы реализуют Measure. Вызов обычно разрешается статически: адрес нужной реализации известен при компиляции, поэтому возможны инлайнинг, удаление лишних абстракций и оптимизация под конкретный тип.
Важно, что мономорфизация не означает безусловное буквальное дублирование финального бинарника. Оптимизатор может удалить неиспользуемые экземпляры, объединить идентичные участки или встроить функцию в вызывающий код. Но полагаться на полное устранение дублирования нельзя: разные типы, bounds и параметры часто приводят к различающимся машинным реализациям.
Альтернатива — динамическая диспетчеризация через объект трейта, например dyn Measure. Тогда одна функция может работать с разными реализациями через таблицу виртуальных методов, уменьшая количество специализированного кода. Обратная сторона — косвенный вызов, ограничения на объектную безопасность трейта и обычно отсутствие специализации под конкретный тип.
Практический выбор зависит от профиля кода. Для коротких горячих функций generics часто предпочтительны; для крупных редко вызываемых обработчиков или большого количества реализаций может быть разумнее вынести общий код за динамическую границу.
В библиотеке сериализации общий обработчик принимает тип, реализующий трейт Encode. Клиенты используют его с несколькими десятками структур. Обработчик содержит не только несколько операций над байтами, но и крупную логику буферизации и диагностики.
Первый вариант — оставить весь обработчик generic. Он сохраняет статическую диспетчеризацию и позволяет оптимизировать код под каждый тип, но увеличивает время компиляции клиентских проектов и может раздувать бинарник.
Второй вариант — передавать dyn Encode. Это уменьшает число специализированных копий крупного обработчика, но добавляет косвенный вызов и требует, чтобы Encode был совместим с использованием через trait object. Кроме того, оптимизатор хуже видит конкретную реализацию.
Разумное решение — оставить маленький горячий участок generic, а крупную редко вызываемую часть сделать общей для разных типов. Такой компромисс сохраняет специализацию там, где она влияет на производительность, и ограничивает рост бинарника там, где он не окупается. Выбор подтверждают измерениями размера бинарника, времени компиляции и профиля выполнения, а не только формой исходного кода.
Всегда ли один вызов generic-функции означает отдельную копию в бинарнике?
Нет. Экземпляр создаётся для конкретной комбинации параметров типов, но затем оптимизатор может встроить его, удалить неиспользуемый код или объединить одинаковые машинные фрагменты. Поэтому мономорфизация создаёт потенциальные экземпляры, а не гарантирует пропорциональный рост финального файла.
Увеличивает ли мономорфизация только размер бинарника?
Нет. Она может увеличить время компиляции и объём работы оптимизатора, поскольку каждый экземпляр нужно анализировать и компилировать. Особенно заметен эффект у крупных generic-функций, большого числа типов и глубоких цепочек обобщённых вызовов.
Почему динамическая диспетчеризация не является безусловно лучшим способом избежать дублирования?
Она действительно может сократить число специализированных реализаций, но добавляет косвенный вызов и ограничивает оптимизации, зависящие от знания конкретного типа. Кроме того, не каждый трейт можно использовать как dyn Trait: его методы и связанные элементы должны удовлетворять требованиям объектной совместимости. Поэтому сравнивают не только размер кода, но и горячесть вызовов, требования API и измеренную производительность.