From 9c5b0ff877d4dfff1d38abfe855e6cca3395b4a1 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 01:43:30 +0300 Subject: [PATCH] Editorial: refine CTFE article 368 --- editorial/agent-rewrites/368.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editorial/agent-rewrites/368.json b/editorial/agent-rewrites/368.json index 77dfa76..14d3181 100644 --- a/editorial/agent-rewrites/368.json +++ b/editorial/agent-rewrites/368.json @@ -2,6 +2,6 @@ "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" + "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

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

" }