{ "index": 368, "slug": "новый-движок-ctfe", "title": "CTFE в D: как перенести вычисление в сборку и не сломать её", "excerpt": "CTFE вычисляет часть кода во время компиляции. Разбираем, когда это сокращает работу runtime, почему тяжёлый CTFE раздувает сборку и как проверить границу применения.", "contentHtml": "

Симптом обычно выглядит безобидно: функция вызывается как обычная, но сборка внезапно занимает минуты, потребляет много памяти или падает на большом входе. Другой вариант — программа запускается дольше, потому что вычисление, которое можно было выполнить один раз, осталось в runtime. Цена ошибки двойная. Разработчик теряет время на сборке и получает неясную границу между кодом программы и кодом компилятора.

\n

CTFE в D решает только одну задачу: выполняет функцию во время компиляции, когда контекст требует значения уже на этом этапе. Результат затем используется как готовая константа, размер массива, аргумент шаблона или строка для mixin. Это не отдельный язык и не гарантия, что любой вызов станет compile-time. Функция должна оставаться допустимой и для обычного выполнения, а входы должны быть известны компилятору.

\n

Тезис: переносите границу, а не весь алгоритм

\n

Хороший кандидат для CTFE принимает небольшие фиксированные данные и возвращает компактный результат. Так можно заранее построить таблицу, разобрать короткую схему, вычислить маску или проверить инвариант. Плохой кандидат читает сеть, текущее время, состояние операционной системы или большой внешний файл. Такие значения принадлежат окружению запуска. Их нужно получать явно в runtime либо отдельным шагом подготовки до компиляции.

\n

Новый движок CTFE исторически решал проблему стоимости старого интерпретатора. Старый подход создавал объекты AST для большого числа промежуточных выражений. Длинный цикл или сложное регулярное выражение могли породить огромное количество узлов и занять гигабайты памяти. Идея нового подхода — сначала скомпилировать функцию в промежуточное представление, а затем исполнять его специализированным интерпретатором. Это уменьшает накладные расходы представления, но не отменяет стоимость самого вычисления и не делает любой код подходящим для CTFE.

\n

Поэтому вопрос звучит не так: «умеет ли новый движок выполнить эту функцию?». Практический вопрос другой: «какие входы известны, насколько велик результат, сколько стоит вычисление в сборке и что остаётся runtime?». Ответ должен быть виден из кода и проверяться обычной сборкой.

\n
\"Иллюстрация
CTFE переносит предсказуемое вычисление в сборку, но не заменяет runtime.
\n

Механизм на маленьком примере

\n

В D CTFE срабатывает в контексте, где требуется compile-time значение. Вызов той же функции в обычном выражении может выполниться при запуске. Разница определяется местом использования, а не специальным именем функции.

\n
string[] buildRoutes()\n{\n    return [\"/\", \"/catalog\", \"/checkout\"];\n}\n\nenum string[] routes = buildRoutes();\n\nstatic assert(routes.length == 3);\n\nvoid main()\n{\n    import std.stdio : writeln;\n    writeln(routes);\n}
\n

Здесь и входы, и результат известны из исходника. Инициализатор enum требует значения на этапе компиляции, поэтому компилятор пытается выполнить buildRoutes через CTFE. static assert проверяет свойство результата тоже на этапе компиляции. В main программа читает уже сформированный массив; она не строит маршруты заново.

\n

Учебный пример не доказывает ускорение конкретного приложения. Он показывает только границу выполнения. Реальный выигрыш нужно измерять на своём проекте: сравнить время сборки, размер бинарника и работу runtime на одинаковом входе.

\n

Что происходит на границе окружения

\n

Компилятор не может считать сетевой ответ или текущее время стабильным значением исходного кода. Даже чтение файла окружения меняет результат между машинами и сборками. Если функция зависит от такого состояния, она перестаёт быть хорошим compile-time контрактом. Скрывать зависимость нельзя: получится сборка, которая зависит от рабочей директории, переменной окружения или случайного файла.

\n

У CTFE есть и более тонкая граница. D требует, чтобы функция, выполняемая на этапе компиляции, могла быть скомпилирована для runtime. Поэтому нельзя строить функцию только вокруг compile-time поведения, которое не имеет корректного runtime-представления. Когда нужна генерация кода из строкового шаблона, отделите шаблонный интерфейс от функции, которую можно вызвать обычным способом.

\n

Псевдопеременная __ctfe позволяет выбрать отдельную ветку для compile-time, если операция запрещена в CTFE и для runtime есть другой путь. Используйте её редко. Она делает два режима выполнения явными, но также удваивает число путей, которые нужно поддерживать и проверять.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Сборка резко замедлиласьБольшой цикл выполняется через CTFEСобрать минимальный пример и сравнить время с runtime-вызовомУменьшить вход, разбить вычисление или вернуть его в runtime
Компилятор расходует много памятиСложное вычисление создаёт большое промежуточное состояниеПроверить размер входа и воспроизвести сбой на изолированном вызовеСократить таблицу, изменить алгоритм или вынести подготовку в отдельный генератор
Результат различается на машинахCTFE читает окружение, время или файлПовторить сборку с чистым окружением и сравнить входыПередавать данные явно и оставить зависимое от среды действие в runtime
В runtime вычисление всё ещё выполняетсяВызов стоит в обычном выражении, а не в compile-time-контекстеДобавить static assert или использовать значение в enumПеренести вызов в контекст, где требуется compile-time значение
Секрет попал в бинарникЗначение встроилось в результат сборкиПроверить артефакт и строковые константыНе передавать секрет в CTFE; читать его при запуске из защищённого источника
\n

Как отличить пользу от переноса проблемы

\n

Сравнивайте три стоимости. Первая — время компиляции. Вторая — память компилятора и размер получившегося бинарника. Третья — время запуска и частота повторения вычисления. CTFE оправдан, если одно вычисление на сборку заменяет много одинаковых вычислений при запуске и сборка остаётся предсказуемой. Если сборка выполняется для каждого небольшого изменения, даже умеренная таблица может сделать цикл разработки хуже.

\n

Размер результата тоже важен. CTFE может ускорить запуск, но встроить в бинарник большой массив. Это влияет на загрузку, кэш и поставку артефакта. Нельзя объявлять оптимизацию успешной только потому, что runtime больше не вызывает функцию. Нужен баланс между стоимостью сборки и стоимостью использования.

\n

Отдельно проверьте повторяемость. Одинаковый исходный код и одинаковые входы должны давать одинаковый результат. Если результат зависит от локали, платформенного размера типа или implementation-defined поведения, зафиксируйте это ограничение или не переносите вычисление в CTFE.

\n

Порядок внедрения

\n
  1. Назовите результат, который должен быть готов до запуска: таблица, размер, строка, типовой параметр или проверяемый инвариант.
  2. Выпишите все входы функции. Отметьте литералы и фиксированную конфигурацию отдельно от файлов, сети, времени и окружения.
  3. Сделайте функцию небольшой и детерминированной. Уберите скрытые обращения к внешнему состоянию.
  4. Перенесите вызов в compile-time-контекст: инициализатор enum, размер статического массива, static if, static foreach, static assert или другой поддерживаемый контекст.
  5. Добавьте static assert для главного свойства результата. Проверка должна ломать сборку при неверном значении.
  6. Соберите минимальный пример и основной проект. Зафиксируйте время сборки, пик памяти и размер бинарника до изменения и после него.
  7. Проверьте runtime отдельно. Убедитесь, что программа использует готовый результат и не читает запрещённое окружение через обходной путь.
  8. Если вычисление стало тяжёлым, остановите перенос. Упростите вход, смените алгоритм или вынесите подготовку в отдельный явный этап.
\n

Ограничения

\n

Поддерживаемые операции и эффективность CTFE зависят от версии компилятора и конкретного кода. Историческое описание NewCTFE объясняет архитектурное направление, но не является обещанием одинакового поведения в любой современной сборке. Проверяйте фактическую версию DMD, LDC или другого компилятора в своём проекте.

\n

CTFE не изолирует секреты. Если пароль или ключ известен на этапе компиляции, его можно извлечь из исходников, промежуточных данных или бинарника. Не используйте compile-time вычисления как механизм защиты.

\n

CTFE также не заменяет генератор, если входом служит большой внешний документ. Генератор может иметь собственный кэш, отчёт об ошибках и понятный артефакт. Это другая граница ответственности. Не прячьте её внутри компилятора ради короткого вызова функции.

\n

Проверяемый критерий готовности

\n

Перенос готов, если выполнены все четыре условия: входы перечислены и не зависят от скрытого окружения; static assert проверяет ключевое свойство; сборка проходит на целевой версии компилятора с зафиксированным ресурсным профилем; runtime использует готовое значение и не повторяет вычисление. Для отрицательного пути должен существовать пример, который остаётся runtime-кодом или явно останавливается на границе CTFE.

\n

Если хотя бы одно условие не выполнено, статус должен быть «не готово», даже когда отдельный пример компилируется. CTFE полезен не потому, что компилятор способен выполнить функцию, а потому, что команда понимает, где это выполнение происходит, сколько оно стоит и почему результат можно безопасно использовать.

\n

Проверяемые источники

\n" }