Перейти к содержимому

Введение в метапрограммирование

У программы на Python одна фаза жизни: запустили — работает. У программы на Mojo их две, и это меняет способ думать о коде.

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

Фаза выполнения. Программа запускается и делает то, что осталось.

Метапрограммирование — это умение переносить работу из второй фазы в первую. Всё, что посчитано при сборке, во время работы уже бесплатно.

Проверить это можно за пять секунд — код ниже нужно положить внутрь функции, на верхнем уровне переменных в Mojo не бывает:

var size = 4
var 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 объявляет значение, которое компилятор вычисляет сам:

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() и разбор строки в число умеют бросать исключения, вместе с ними отпадает и весь ввод-вывод. Запретов на самом деле больше; полный список и разные тексты ошибок — в главе Вычисления на этапе компиляции.

Обычный if проверяет условие каждый раз при выполнении. comptime if проверяет его один раз при сборке — и в готовую программу попадает только выбранная ветка:

def label[verbose: Bool]() -> String:
comptime if verbose:
return String("подробный режим")
else:
return String("кратко")
def main():
print(label[True]())
print(label[False]())
Результат
подробный режим
кратко

Компилятор собрал две разные функции: в одной осталась только первая строка, в другой — только вторая. Никакой проверки условия во время работы не происходит — это видно и в собранном бинарнике, куда попадает текст только выбранной ветки.

def banner[times: Int](text: String):
comptime for index in range(times):
print(index, text)
def main():
banner[3]("повтор")
Результат
0 повтор
1 повтор
2 повтор

В собранной программе здесь не цикл, а три подряд идущих вызова print. Счётчика нет, проверки условия нет, перехода назад нет.

🐍 Python
def area(shape, size):
if shape == "круг":
return 3.14159 * size * size
return size * size
# проверка shape происходит
# при каждом вызове
🔥 Mojo
def area[circle: Bool](size: Float64) -> Float64:
comptime if circle:
return 3.14159 * size * size
return size * size
# проверки нет вовсе:
# собраны две отдельные функции

В Python сравнение строк выполняется миллион раз, если функцию вызвали миллион раз. В Mojo оно выполнится один раз — при сборке.

Выигрыш складывается из трёх вещей:

  • нет проверок, которые всё равно всегда дают один и тот же ответ;
  • нет циклов с известным заранее числом шагов;
  • компилятор видит больше: зная длину числом, он вправе развернуть обработку или векторизовать её. В отличие от первых двух пунктов это не гарантия, а возможность — как повезёт с оптимизатором.

Метапрограммирование — не бесплатный обед.

Что растётПочему
Время сборкикомпилятор выполняет ваш код, а не только переводит его
Сложность сообщений об ошибкахошибка возникает в сгенерированном коде, а не в написанном

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

Часто добавляют третий пункт — размер программы: под каждый набор параметров генерируется своя копия функции. Механизм именно такой, но результат неочевиден: копия, в которой всё свернулось в константы, бывает меньше общей версии. Мы это измерили — цифры и оба исхода в главе Параметризация.

two_phases.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, — а таких большинство.

Вычисления на этапе компиляции: что именно компилятор готов посчитать сам, как это проверить и что делать, когда он отказывается.

Примеры проверены на Mojo 1.1.0

Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0