{ "index": 368, "slug": "новый-движок-ctfe", "title": "CTFE в D: как перенести вычисление в сборку и не сломать её", "excerpt": "CTFE выполняет функцию там, где компилятору нужно значение на этапе сборки. Разбираем границу между compile-time и runtime, историческую проблему NewCTFE и проверку стоимости такого переноса.", "contentHtml": "
Симптом заметен в двух местах: сборка внезапно занимает минуты и расходует память, либо программа каждый запуск повторяет вычисление, результат которого известен из исходников. В обоих случаях проблема одна — граница между компилятором и готовой программой выбрана без проверки. CTFE (Compile Time Function Execution, выполнение функции во время компиляции) помогает эту границу перенести, но не отменяет стоимость вычисления.
\nПрактический вопрос звучит так: какие входы известны при сборке, какой результат нужно получить и что будет стоить этот перенос. Если ответ сводится к «компилятор это умеет», решение ещё не доказано. Нужно показать контекст вызова, проверить код на целевой версии компилятора, сравнить сборку с runtime-вариантом и отдельно проверить отрицательный путь.
\nВ D функция может вычислить значение на этапе компиляции, когда это значение требуется соответствующему контексту. Спецификация перечисляет инициализацию статической переменной или manifest constant, размер статического массива, аргумент шаблона, static if, static foreach, static assert, mixin, а также аргументы pragma и __traits. Само имя функции не делает вызов compile-time.
Та же функция может иметь два режима вызова. В enum или static assert компилятор вычисляет значение заранее. В обычном выражении внутри main вызов остаётся runtime-вызовом. Поэтому по одному исходнику функции нельзя заключить, где она выполнилась: смотреть нужно на место использования.
Ниже функция не знает о файлах, сети и окружении. Её вход — число, результат — число, поэтому пример можно повторить на чистой машине. enum требует manifest constant, а static assert проверяет результат во время компиляции.
int square(int value)\n{\n return value * value;\n}\n\nenum squared = square(6);\nstatic assert(squared == 36);\n\nvoid main()\n{\n import std.stdio : writeln;\n\n writeln(squared); // готовое compile-time значение\n writeln(square(6)); // обычный runtime-вызов\n}\nВызов справа от enum попадает в compile-time-контекст, поэтому squared становится результатом вычисления. Вторая строка в main не обязана использовать CTFE: это обычное выражение и оно может выполниться при запуске. Такой контраст полезнее комментария «функция работает во время компиляции», потому что показывает условие, от которого зависит поведение.
Пример доказывает только семантическую границу и корректность условия 36. Он не доказывает ускорение приложения. Для этого нужен отдельный замер на проектном входе: один и тот же компилятор, одинаковые флаги, чистый кеш и сопоставимый результат.
Историческое описание NewCTFE в официальном D Blog относится к ноябрю 2016 года. В нём проблема старого интерпретатора объясняется способом представления изменений: интерпретатор проходил узлы AST (абстрактного синтаксического дерева) напрямую и мог копировать цепочку состояний при каждой мутации. На маленьких вычислениях накладные расходы не видны, но длинный цикл резко увеличивает промежуточную память.
\nПредложенное направление меняло не саму идею CTFE, а внутренний способ её реализации. Фронтенд проходил AST и передавал операции бэкенду, тот строил промежуточное представление и байткод, который затем интерпретировался. Такое разделение оставляло место для анализа потока данных и уменьшало зависимость от копирования каждого промежуточного узла.
\nЭто историческое объяснение механизма, а не обещание, что любая современная сборка D использует ровно такую реализацию или даст конкретное ускорение. Источник также описывает состояние проекта и его планы на тот момент. Текущую применимость проверяют по версии DMD, LDC или другого компилятора и по собственной сборке.
\nАктуальная спецификация D требует, чтобы функция, выполняемая через CTFE, оставалась исполнимой и в runtime. Compile-time вычисление должно быть эквивалентно запуску функции, а семантика не должна зависеть от значения, доступного только на этапе компиляции. Поэтому функция, которая существует только ради mixin и не может породить runtime-код, не является корректным способом обойти это правило.
Спецификация запрещает в фактически исполняемом пути обращение к изменяемым статическим переменным, ассемблер, непереносимые приведения, некоторые операции с объединениями и неопределённое поведение. Безопасные указатели допускаются только в оговорённых случаях. Невосстановимые ошибки вроде срабатывания assert недопустимы. Условие в неисполненной ветке не следует оценивать так, будто оно уже нарушило ограничение.
Спецификация отдельно предупреждает: CTFE может выполняться заметно дольше runtime-варианта, а бесконечный цикл способен повесить компилятор. Результат также может отличаться от runtime при implementation-defined поведении. Поэтому большой цикл, зависимость от платформы и неограниченный вход — причины остановить перенос и сначала поставить измерение.
\n__ctfeCompile-time результат должен быть повторяемым при одинаковом исходном коде и входах. Сеть, текущее время, рабочая директория и случайный файл принадлежат окружению запуска; прятать их чтение внутри вычисления опасно даже тогда, когда конкретная версия компилятора допускает нужный вызов. Если вход внешний или большой, явный генератор до сборки обычно даёт лучшее место для кеша, ошибки и артефакта.
\nВ D есть псевдопеременная __ctfe. Она истинна во время CTFE и ложна в runtime, поэтому через неё можно развести операцию, запрещённую в compile-time, и допустимую замену. В спецификации указано, что эта проверка вычисляется статически и не создаёт runtime-стоимости. Но ветвление удваивает пути сопровождения: для каждой ветки нужен свой тест, а скрывать таким образом внешний эффект не следует.
int readValue()\n{\n if (__ctfe)\n return 7; // детерминированная ветка для сборки\n\n return loadFromRuntime(); // проектная runtime-операция\n}\nЭтот фрагмент — схема границы, а не готовая библиотечная функция: loadFromRuntime должен существовать в проекте и иметь тот же тип результата. Если compile-time ветка не нужна, проще оставить одну функцию без __ctfe. Чем меньше режимов, тем легче доказать одинаковый контракт.
| Симптом | Рабочая гипотеза | Проверка | Действие |
|---|---|---|---|
| Сборка стала дольше | Большой вход или цикл ушёл в CTFE | Собрать минимальный пример и сравнить wall-clock с runtime-вариантом | Уменьшить вход, разбить расчёт или вернуть его в runtime |
| Компилятор расходует много памяти | Промежуточное состояние растёт быстрее результата | Зафиксировать пик памяти и воспроизвести случай на отдельной функции | Изменить алгоритм или вынести подготовку в генератор |
| Сборки различаются | Вход зависит от платформы или окружения | Повторить сборку в чистом окружении и сравнить входы и артефакты | Передавать данные явно и оставить внешнее состояние в runtime |
| Runtime всё ещё считает значение | Вызов стоит в обычном выражении | Проверить контекст, добавить static assert и посмотреть код вызова | Использовать manifest constant или другой поддерживаемый compile-time-контекст |
| Ошибка появляется только на одной версии DMD/LDC | Зависимость от implementation-defined поведения или поддержки операции | Записать версию, флаги и минимальный воспроизводимый пример | Ограничить версию, изменить код или отказаться от переноса |
static assert для свойства, которое должно быть неизменным.Перенос готов, когда входы перечислены, результат детерминирован, compile-time-контекст виден из исходника, static assert ломает сборку при нарушении свойства, а сравнительный замер показывает приемлемую стоимость. Отдельно должны быть проверены runtime-вызов и отрицательный путь. Успешный маленький пример без этих условий — только гипотеза.
NewCTFE полезно читать как инженерный урок: при росте CTFE-нагрузки узким местом становится не только алгоритм пользователя, но и представление промежуточного состояния в компиляторе. Поэтому переносите маленькие и предсказуемые вычисления, измеряйте границу и не выдавайте историческую архитектуру за гарантию современной версии.
\n__ctfe, время выполнения и требование runtime-исполняемости функции.