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

Симптом заметен в двух местах: сборка внезапно занимает минуты и расходует память, либо программа каждый запуск повторяет вычисление, результат которого известен из исходников. В обоих случаях проблема одна — граница между компилятором и готовой программой выбрана без проверки. CTFE (Compile Time Function Execution, выполнение функции во время компиляции) помогает эту границу перенести, но не отменяет стоимость вычисления.

\n

Практический вопрос звучит так: какие входы известны при сборке, какой результат нужно получить и что будет стоить этот перенос. Если ответ сводится к «компилятор это умеет», решение ещё не доказано. Нужно показать контекст вызова, проверить код на целевой версии компилятора, сравнить сборку с runtime-вариантом и отдельно проверить отрицательный путь.

\n

Что именно делает CTFE

\n

В D функция может вычислить значение на этапе компиляции, когда это значение требуется соответствующему контексту. Спецификация перечисляет инициализацию статической переменной или manifest constant, размер статического массива, аргумент шаблона, static if, static foreach, static assert, mixin, а также аргументы pragma и __traits. Само имя функции не делает вызов compile-time.

\n

Та же функция может иметь два режима вызова. В enum или static assert компилятор вычисляет значение заранее. В обычном выражении внутри main вызов остаётся runtime-вызовом. Поэтому по одному исходнику функции нельзя заключить, где она выполнилась: смотреть нужно на место использования.

\n
\"Схема
Граница CTFE: компилятор вычисляет допустимое значение, а программа получает готовый результат.
\n

Минимальный пример с двумя границами

\n

Ниже функция не знает о файлах, сети и окружении. Её вход — число, результат — число, поэтому пример можно повторить на чистой машине. enum требует manifest constant, а static assert проверяет результат во время компиляции.

\n
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: это обычное выражение и оно может выполниться при запуске. Такой контраст полезнее комментария «функция работает во время компиляции», потому что показывает условие, от которого зависит поведение.

\n

Пример доказывает только семантическую границу и корректность условия 36. Он не доказывает ускорение приложения. Для этого нужен отдельный замер на проектном входе: один и тот же компилятор, одинаковые флаги, чистый кеш и сопоставимый результат.

\n

Почему появился NewCTFE

\n

Историческое описание NewCTFE в официальном D Blog относится к ноябрю 2016 года. В нём проблема старого интерпретатора объясняется способом представления изменений: интерпретатор проходил узлы AST (абстрактного синтаксического дерева) напрямую и мог копировать цепочку состояний при каждой мутации. На маленьких вычислениях накладные расходы не видны, но длинный цикл резко увеличивает промежуточную память.

\n

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

\n

Это историческое объяснение механизма, а не обещание, что любая современная сборка D использует ровно такую реализацию или даст конкретное ускорение. Источник также описывает состояние проекта и его планы на тот момент. Текущую применимость проверяют по версии DMD, LDC или другого компилятора и по собственной сборке.

\n

Ограничения языка важнее названия движка

\n

Актуальная спецификация D требует, чтобы функция, выполняемая через CTFE, оставалась исполнимой и в runtime. Compile-time вычисление должно быть эквивалентно запуску функции, а семантика не должна зависеть от значения, доступного только на этапе компиляции. Поэтому функция, которая существует только ради mixin и не может породить runtime-код, не является корректным способом обойти это правило.

\n

Спецификация запрещает в фактически исполняемом пути обращение к изменяемым статическим переменным, ассемблер, непереносимые приведения, некоторые операции с объединениями и неопределённое поведение. Безопасные указатели допускаются только в оговорённых случаях. Невосстановимые ошибки вроде срабатывания assert недопустимы. Условие в неисполненной ветке не следует оценивать так, будто оно уже нарушило ограничение.

\n

Спецификация отдельно предупреждает: CTFE может выполняться заметно дольше runtime-варианта, а бесконечный цикл способен повесить компилятор. Результат также может отличаться от runtime при implementation-defined поведении. Поэтому большой цикл, зависимость от платформы и неограниченный вход — причины остановить перенос и сначала поставить измерение.

\n

Окружение и псевдопеременная __ctfe

\n

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

\n

В D есть псевдопеременная __ctfe. Она истинна во время CTFE и ложна в runtime, поэтому через неё можно развести операцию, запрещённую в compile-time, и допустимую замену. В спецификации указано, что эта проверка вычисляется статически и не создаёт runtime-стоимости. Но ветвление удваивает пути сопровождения: для каждой ветки нужен свой тест, а скрывать таким образом внешний эффект не следует.

\n
int readValue()\n{\n    if (__ctfe)\n        return 7;              // детерминированная ветка для сборки\n\n    return loadFromRuntime();   // проектная runtime-операция\n}
\n

Этот фрагмент — схема границы, а не готовая библиотечная функция: loadFromRuntime должен существовать в проекте и иметь тот же тип результата. Если compile-time ветка не нужна, проще оставить одну функцию без __ctfe. Чем меньше режимов, тем легче доказать одинаковый контракт.

\n

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

\n
СимптомРабочая гипотезаПроверкаДействие
Сборка стала дольшеБольшой вход или цикл ушёл в CTFEСобрать минимальный пример и сравнить wall-clock с runtime-вариантомУменьшить вход, разбить расчёт или вернуть его в runtime
Компилятор расходует много памятиПромежуточное состояние растёт быстрее результатаЗафиксировать пик памяти и воспроизвести случай на отдельной функцииИзменить алгоритм или вынести подготовку в генератор
Сборки различаютсяВход зависит от платформы или окруженияПовторить сборку в чистом окружении и сравнить входы и артефактыПередавать данные явно и оставить внешнее состояние в runtime
Runtime всё ещё считает значениеВызов стоит в обычном выраженииПроверить контекст, добавить static assert и посмотреть код вызоваИспользовать manifest constant или другой поддерживаемый compile-time-контекст
Ошибка появляется только на одной версии DMD/LDCЗависимость от implementation-defined поведения или поддержки операцииЗаписать версию, флаги и минимальный воспроизводимый примерОграничить версию, изменить код или отказаться от переноса
\n

Порядок безопасного переноса

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

Критерий готовности

\n

Перенос готов, когда входы перечислены, результат детерминирован, compile-time-контекст виден из исходника, static assert ломает сборку при нарушении свойства, а сравнительный замер показывает приемлемую стоимость. Отдельно должны быть проверены runtime-вызов и отрицательный путь. Успешный маленький пример без этих условий — только гипотеза.

\n

NewCTFE полезно читать как инженерный урок: при росте CTFE-нагрузки узким местом становится не только алгоритм пользователя, но и представление промежуточного состояния в компиляторе. Поэтому переносите маленькие и предсказуемые вычисления, измеряйте границу и не выдавайте историческую архитектуру за гарантию современной версии.

\n

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

" }