Введение в метапрограммирование
This content is not available in your language yet.
У программы на Python одна фаза жизни: запустили — работает. У программы на Mojo их две, и это меняет способ думать о коде.
Фаза сборки. Компилятор читает исходник, вычисляет всё, что можно вычислить заранее, разворачивает циклы, выбрасывает недостижимые ветки и генерирует машинный код.
Фаза выполнения. Программа запускается и делает то, что осталось.
Метапрограммирование — это умение переносить работу из второй фазы в первую. Всё, что посчитано при сборке, во время работы уже бесплатно.
Компилятор различает эти фазы строго
Заголовок раздела «Компилятор различает эти фазы строго»Проверить это можно за пять секунд — код ниже нужно положить внутрь функции, на верхнем уровне переменных в Mojo не бывает:
var size = 4var buffer = Array[Int, size](fill=0) # ошибкаerror: cannot use a dynamic value in a parameter list
Array хранит данные прямо в себе, поэтому его длина должна быть
известна компилятору — иначе он не знает, сколько места отвести.
А size здесь обычная переменная: её значение появится только
при запуске.
Объявите значение как вычисляемое при сборке:
def main(): comptime size = 4 var buffer = Array[Int, size](fill=0)Это и есть граница двух фаз. Всё в квадратных скобках живёт в первой, всё в круглых — во второй.
comptime: значение, посчитанное при сборке
Заголовок раздела «comptime: значение, посчитанное при сборке»Слово comptime объявляет значение, которое компилятор вычисляет сам:
def triangular(n: Int) -> Int: var total = 0 for value in range(1, n + 1): total += value return total
comptime SIZE = triangular(4)
def main(): print(SIZE)10
Обратите внимание: triangular — совершенно обычная функция, никак
не помеченная. Компилятор просто выполнил её сам и подставил в программу
готовое число. В собранном коде цикла нет — там 10.
Обычная — но не любая. У вычислений на сборке есть жёсткая граница:
на этапе компиляции не существует ни файлов, ни сети, ни внешних библиотек.
Функция, помеченная raises, отвергается сразу:
error: cannot call raising function in comptime initializerА поскольку open() и разбор строки в число умеют бросать исключения,
вместе с ними отпадает и весь ввод-вывод. Запретов на самом деле больше;
полный список и разные тексты ошибок — в главе
Вычисления на этапе компиляции.
comptime if: ветка выбирается при сборке
Заголовок раздела «comptime if: ветка выбирается при сборке»Обычный if проверяет условие каждый раз при выполнении. comptime if
проверяет его один раз при сборке — и в готовую программу попадает только
выбранная ветка:
def label[verbose: Bool]() -> String: comptime if verbose: return String("подробный режим") else: return String("кратко")
def main(): print(label[True]()) print(label[False]())подробный режим кратко
Компилятор собрал две разные функции: в одной осталась только первая строка, в другой — только вторая. Никакой проверки условия во время работы не происходит — это видно и в собранном бинарнике, куда попадает текст только выбранной ветки.
comptime for: цикл разворачивается при сборке
Заголовок раздела «comptime for: цикл разворачивается при сборке»def banner[times: Int](text: String): comptime for index in range(times): print(index, text)
def main(): banner[3]("повтор")0 повтор 1 повтор 2 повтор
В собранной программе здесь не цикл, а три подряд идущих вызова print.
Счётчика нет, проверки условия нет, перехода назад нет.
Что это даёт
Заголовок раздела «Что это даёт»def area(shape, size): if shape == "круг": return 3.14159 * size * size return size * size
# проверка shape происходит# при каждом вызовеdef area[circle: Bool](size: Float64) -> Float64: comptime if circle: return 3.14159 * size * size return size * size
# проверки нет вовсе:# собраны две отдельные функцииВ Python сравнение строк выполняется миллион раз, если функцию вызвали миллион раз. В Mojo оно выполнится один раз — при сборке.
Выигрыш складывается из трёх вещей:
- нет проверок, которые всё равно всегда дают один и тот же ответ;
- нет циклов с известным заранее числом шагов;
- компилятор видит больше: зная длину числом, он вправе развернуть обработку или векторизовать её. В отличие от первых двух пунктов это не гарантия, а возможность — как повезёт с оптимизатором.
Чем за это платят
Заголовок раздела «Чем за это платят»Метапрограммирование — не бесплатный обед.
| Что растёт | Почему |
|---|---|
| Время сборки | компилятор выполняет ваш код, а не только переводит его |
| Сложность сообщений об ошибках | ошибка возникает в сгенерированном коде, а не в написанном |
Второй пункт — главная причина не увлекаться. Параметр там, где хватило бы обычного аргумента, усложняет чтение кода и ухудшает диагностику, а выигрыша не даёт.
Часто добавляют третий пункт — размер программы: под каждый набор параметров генерируется своя копия функции. Механизм именно такой, но результат неочевиден: копия, в которой всё свернулось в константы, бывает меньше общей версии. Мы это измерили — цифры и оба исхода в главе Параметризация.
Всё вместе
Заголовок раздела «Всё вместе»# Что происходит на сборке, а что — при запуске.
def triangular(n: Int) -> Int: """Сумма чисел от 1 до n. Обычная функция, ничего особенного.""" var total = 0 for value in range(1, n + 1): total += value return total
# Считается один раз при сборке — в готовой программе тут уже число.comptime SIZE = triangular(4)
def label[verbose: Bool]() -> String: """Ветка выбирается на этапе компиляции, в программу попадёт только одна.""" comptime if verbose: return String("подробный режим") else: return String("кратко")
def banner[times: Int](text: String): """Цикл разворачивается при сборке: цикла в готовой программе нет.""" comptime for index in range(times): print(" ", index, text)
def main(): print("1. значение посчитано при сборке:", SIZE)
print("2. его можно использовать там, где нужна константа") var buffer = Array[Int, SIZE](fill=0) print(" длина буфера:", len(buffer))
print("3. ветка выбрана при сборке") print(" ", label[True]()) print(" ", label[False]())
print("4. цикл развёрнут при сборке") banner[3]("повтор")1. значение посчитано при сборке: 10 2. его можно использовать там, где нужна константа длина буфера: 10 3. ветка выбрана при сборке подробный режим кратко 4. цикл развёрнут при сборке 0 повтор 1 повтор 2 повтор
Что дальше в разделе
Заголовок раздела «Что дальше в разделе»| Глава | О чём |
|---|---|
| Вычисления на этапе компиляции | что именно компилятор умеет считать сам и где границы |
| Параметризация | параметры функций и структур, автоматическая специализация |
| Трейты и обобщённый код | как записать требования к типу — и почему разнородного списка не выйдет |
| Ограничения | where-клаузы и понятные сообщения об ошибках |
Материализация — перенос вычисленного при сборке значения в обычную переменную — разобрана в следующей главе, вместе с ценой этой операции.
За кадром раздела остаётся одна тема, о которой стоит знать по названию:
рефлексия (reflection) — возможность спросить у компилятора, какие
у типа поля и как они называются. Она живёт в модуле std.reflection:
например, reflect[Point].field_count() при сборке вернёт число полей
структуры Point. Подробности — в
официальном руководстве.
🎯 Проверь себя
Функция `loud(n)` печатает «считаю n» и возвращает `n * 2`. Вы пишете `comptime VALUE = loud(21)`, собираете программу через `mojo build` и запускаете её. Что появится в какой момент?
считаю 21 напечатается при сборке, в выводе самой команды
mojo build. При запуске собранного файла этой строки уже не будет
— останется только результат. Функция отработала один раз, внутри
компилятора, и в программу попало готовое число: ни вызова,
ни умножения там нет. Это и есть две фазы, увиденные своими глазами.
Чем `comptime if` отличается от обычного `if`?
Обычный if проверяет условие при каждом выполнении. comptime if
проверяется один раз при сборке, и в программу попадает только
выбранная ветка — проверки во время работы не остаётся вовсе.
Что делать, если в чужом коде встретился `@parameter if` или `alias`?
Заменить на comptime if и comptime. alias ещё работает
с предупреждением, а @parameter if и @parameter for в 1.1 стали
ошибкой: 'for' statement does not support decorators. Это стоит
держать в голове при чтении любых материалов о Mojo, написанных
до 1.0, — а таких большинство.
Что дальше
Заголовок раздела «Что дальше»Вычисления на этапе компиляции: что именно компилятор готов посчитать сам, как это проверить и что делать, когда он отказывается.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0