Ограничения
Трейт отвечает на вопрос «что тип умеет». Остаётся вторая половина: «какими должны быть значения параметров». Размер буфера положителен, конец отрезка правее начала, ширина вектора — степень двойки. Ни один трейт этого не выразит.
where: условие на параметр
Заголовок раздела «where: условие на параметр»Условие пишется в конце сигнатуры, после типа возврата:
def pow2[n: Int]() -> Int where n >= 0: var result = 1 comptime for _ in range(n): result *= 2 return result
def main(): print(pow2[5]())32
Попробуйте pow2[-1]() — программа не соберётся:
error: invalid call to 'pow2': violated constraint
Значение параметра не удовлетворяет условию. Какому именно, говорит следующая строка — и она же показывает подставленное значение:
note: constraint declared here evaluated to False, expected '(n >= Int(0))'Ошибка на стороне вызывающего: он передал негодное значение. Исправлять нужно вызов, а не функцию, — если, конечно, условие не оказалось слишком строгим.
Одна клауза может ограничивать сразу несколько параметров:
def window[start: Int, end: Int](self) where end > start and end <= Self.capacity:То же самое для структур
Заголовок раздела «То же самое для структур»Условие ставится после списка трейтов:
struct Ring[capacity: Int](Copyable, Movable) where capacity > 0: ...Теперь Ring[0] — ошибка сборки, а не пустой буфер, который где-то
потом поделит на ноль.
Две разные ошибки, которые легко перепутать
Заголовок раздела «Две разные ошибки, которые легко перепутать»Эту ошибку вы уже видели дважды — когда List требовал Movable
в главах Параметризация и
Трейты. Пора объяснить, что там на самом деле
происходило.
Сообщений про ограничения бывает два, и лечатся они по-разному:
| Сообщение | Что случилось | Что делать |
|---|---|---|
violated constraint | условие проверено и оказалось ложным | чинить вызов: значение негодное |
lacking evidence to prove correctness | условие не удалось проверить | дать компилятору знание — или перенести проверку в comptime assert |
Первое мы уже разобрали. Второе — сложнее и интереснее.
Решатель ничего не вычисляет
Заголовок раздела «Решатель ничего не вычисляет»Решатель ограничений (constraint solver) работает символически. Он не вычисляет ваши выражения: он держит список уже известных утверждений и ищет среди них требуемое. Поэтому вот это не соберётся:
def half[n: Int]() -> Int where n % 2 == 0: return n // 2
# half[10]() — десять чётное. И всё равно ошибка сборки.error: invalid call to 'half': lacking evidence to prove correctness
Обратите внимание: это не violated constraint. Компилятор
не говорит «условие ложно» — он говорит «не могу доказать, что
оно истинно»:
note: constraint declared here needs evidence for '((Int(10) % Int(2)) == Int(0))'note: cannot evaluate call to non-builtin function declared hereВторая строка и есть диагноз, и она же — ключ ко всей теме.
Перенесите проверку в тело функции: comptime assert действительно
вычисляет выражение и с остатком справляется без труда.
Где проходит граница
Заголовок раздела «Где проходит граница»Не там, где кажется. Дело не в «сложности» выражения, а в том, встроена операция в компилятор или реализована обычной функцией Mojo. Проверено прогоном:
| Условие | Значение | Итог |
|---|---|---|
n >= 0, n > 0, n < 10, n != 0, n == 5 | 5 | доказывает |
n + 1 > 0, n * 2 > 0, n - 1 > 0 | 5 | доказывает |
(n & 1) == 0 | 10 | доказывает |
(n & (n - 1)) == 0 | 8 | доказывает |
(n << 1) == 10 | 5 | доказывает |
n >= 0 and n < 10 | 5 | доказывает |
(n % 2) == 0 | 10 | не доказывает |
(n // 2) == 5 | 10 | не доказывает |
(n ** 2) == 25 | 5 | не доказывает |
is_even(n) — своя функция | 10 | не доказывает |
Сравнения, + - *, унарный минус и все битовые операции встроены
в компилятор — их он сворачивает. %, //, **, любой вызов вашей
функции, сравнение строк реализованы обычным кодом Mojo, и на них
решатель останавливается с той самой строкой
cannot evaluate call to non-builtin function.
Логического вывода нет вовсе
Заголовок раздела «Логического вывода нет вовсе»Второе, что удивляет. Решатель не выводит следствий — он ищет дословное совпадение, с точностью до пары нормализаций. Проверено:
| Известно | Требуется | Итог |
|---|---|---|
n > 0 | n >= 1 | выводит (строгое ↔ нестрогое ±1) |
n > 0 | 0 < n | выводит (переворот сторон) |
n >= 0 and n < 10 | n >= 0 | выводит (and разбирается на факты) |
n > 0 | n >= 0 | не выводит |
n > 0 | n != 0 | не выводит |
n == 5 | n >= 5 | не выводит |
Из «n больше нуля» компилятор не заключит, что n неотрицательно.
Звучит абсурдно, но у такого устройства есть плюс: оно предсказуемо.
Правило практическое — повторяйте условие дословно.
Ограничение нужно передавать дальше
Заголовок раздела «Ограничение нужно передавать дальше»Второй сюрприз, из того же символического устройства. Функция с ограничением, вызванная из функции без ограничения, не соберётся:
def inner[n: Int]() -> Int where n >= 0: return n
def outer[n: Int]() -> Int: # ограничения нет return inner[n]() # ошибка: нечем доказатьerror: invalid call to 'inner': lacking evidence to prove correctnessnote: cannot prove constraint for candidatenote: constraint declared here needs evidence for '(n >= Int(0))'note: provide evidence for the constraint here to aid in candidate selectionКомпилятор рассуждает честно: про n внутри outer ему не известно
ничего, а значит, и n >= 0 он утверждать не вправе. Знание нужно
откуда-то взять.
Взять его можно четырьмя способами — все проверены:
| Откуда берётся знание | Как выглядит |
|---|---|
| ограничение самой функции | def outer[n: Int]() -> Int where n >= 0: |
| ограничение структуры | struct Ring[n: Int](...) where n > 0: |
ветка comptime if | comptime if n >= 0: return inner[n]() |
код после comptime assert | comptime assert n >= 0 — и дальше n >= 0 считается известным |
Последние два особенно полезны: они позволяют «сузить» знание локально, не втаскивая условие в сигнатуру всей функции.
Условное соответствие трейту
Заголовок раздела «Условное соответствие трейту»where умеет и такое: тип соответствует трейту не всегда, а только
при условии на параметр.
@fieldwise_initstruct Box[T: Movable & Deinitable](Copyable where conforms_to(T, Copyable)): var value: Self.TЧитается так: «Box копируем — при условии, что копируемо его
содержимое». Box[Int] подойдёт функции, требующей Copyable,
а Box[Connection] с move-only соединением внутри — нет, и выяснится
это при сборке.
Так устроена вся стандартная библиотека: List[Int] копируется,
List[Connection] — нет, и никакого дублирования кода для этого
не понадобилось.
Отдельный метод тоже может быть условным. Условие ставится там же, в конце сигнатуры:
def show(self) where conforms_to(Self.T, Writable): print("внутри:", self.value)Box[Int]().show() работает, а у Box с непечатаемым содержимым метода
попросту нет:
error: invalid call to 'show': violated constraintnote: constraint declared here evaluated to False, expected 'conforms_to(T, Writable)'Это способ дать типу дополнительные возможности там, где они осмысленны, не заводя двух разных типов.
Ради чего всё это: сообщение для человека
Заголовок раздела «Ради чего всё это: сообщение для человека»Ограничение — не только проверка. Это ещё и место, где вы можете объяснить, чего от вас хотят.
Без него ошибка вылезет глубоко внутри вашей реализации, и читать её будет тот, кто вашего кода не видел:
error: function instantiation failednote: call expansion failed with parameter value(s): ("start": 2, "end": 20)note: constraint failed: index is out of boundsС comptime assert он прочитает написанное вами:
note: constraint failed: ёмкость кольца должна быть степенью двойкиОбратите внимание: у обоих сообщений одинаковая форма
constraint failed: .... Разница только в том, чей текст читает
пользователь — стандартной библиотеки или ваш. Это и есть разница между
хорошей и плохой библиотекой.
Всё вместе
Заголовок раздела «Всё вместе»Кольцевой буфер: ёмкость должна быть положительной и степенью двойки.
# Кольцевой буфер фиксированной ёмкости.# Два требования к ёмкости записаны по-разному:# - «больше нуля» — where: условие попадает в сигнатуру типа;# - «степень двойки» — comptime assert ради понятного сообщения.# Технически второе тоже выразилось бы через where (битовые операции# решателю доступны), но тогда пользователь читал бы машинный текст.
struct Ring[capacity: Int](Copyable, Movable) where capacity > 0: var data: Array[Int, Self.capacity] var count: Int
def __init__(out self): # Проверка при сборке: маска вместо деления работает только # для степеней двойки. Сообщение своё — его и прочитает тот, # кто ошибётся с ёмкостью. comptime assert ( Self.capacity & (Self.capacity - 1) ) == 0, "ёмкость кольца должна быть степенью двойки" self.data = Array[Int, Self.capacity](fill=0) self.count = 0
def copy(self) -> Self: var result = Self() comptime for i in range(Self.capacity): result.data[i] = self.data[i] result.count = self.count return result^
def push(mut self, value: Int): # Быстрый остаток: работает благодаря той самой степени двойки. self.data[self.count & (Self.capacity - 1)] = value self.count += 1
def get(self, index: Int) -> Int: return self.data[index & (Self.capacity - 1)]
def title(self) -> String: # describe требует n > 0. Доказательство берётся из ограничения # самой структуры — своего where здесь писать не нужно. return describe[Self.capacity]()
def window[start: Int, end: Int]( self, ) -> Array[Int, end - start] where end > start and end <= Self.capacity: """Кусок буфера. Оба условия проверяются при сборке.""" var result = Array[Int, end - start](fill=0) comptime for i in range(end - start): result[i] = self.data[start + i] return result^
def describe[n: Int]() -> String where n > 0: """Требует положительного n — и не умеет это доказывать само.""" return String("кольцо на ") + String(n)
def main(): var ring = Ring[8]() for value in range(12): ring.push(value)
print(ring.title()) print("последнее записанное:", ring.get(11)) print("всего записей:", ring.count)
var part = ring.window[2, 5]() print("окно [2, 5):", part[0], part[1], part[2])
# Ни одна из этих строк не соберётся: # # var zero = Ring[0]() # violated constraint: (capacity > Int(0)) # var odd = Ring[6]() # ёмкость кольца должна быть степенью двойки # var bad = ring.window[5, 2]() # violated constraint (составное, см. ниже) # # Компилятор печатает составное условие как вложенное: # expected '(end <= capacity) if (end > start) else (end > start)' print("три ошибки поймались бы при сборке, а не при запуске")кольцо на 8 последнее записанное: 11 всего записей: 12 окно [2, 5): 10 11 4 три ошибки поймались бы при сборке, а не при запуске
Откуда взялась степень двойки
Заголовок раздела «Откуда взялась степень двойки»Приём, который в Python встречается редко, поэтому разберём на числах.
При ёмкости 8 маска равна 7, то есть 0b111. Записываем двенадцатый
элемент — индекс 11:
11 = 0b1011 7 = 0b011111 & 7 = 0b0011 = 3Ровно то же даёт 11 % 8. Одна операция & вместо деления — и это
работает только для степеней двойки. Возьмите ёмкость 6: маска
станет 5 = 0b101, и 6 & 5 = 4, а 6 % 6 = 0. Буфер молча начнёт
писать не туда.
Вот это допущение и охраняет comptime assert. Без него ошибка была бы
не ошибкой сборки, а тихо испорченными данными.
Что дальше
Заголовок раздела «Что дальше»Раздел о метапрограммировании закончен. Дальше — производительность, и начнём с того, как честно её измерять.
🎯 Проверь себя
`pow2[-1]()` даёт `violated constraint`, а `half[10]()` — `lacking evidence`. Чем эти случаи отличаются и что чинить в каждом?
В первом условие проверено и оказалось ложным: виноват вызов,
минус единица и правда меньше нуля. Во втором компилятор не смог
условие проверить — он сворачивает только встроенные операции
(сравнения, + - *, битовые), а остаток реализован обычной функцией
Mojo: cannot evaluate call to non-builtin function. Первое чинят
исправлением значения, второе — переносом проверки в comptime assert, который выражение действительно вычисляет.
Функция `outer[n]` вызывает `inner[n]`, у которого `where n >= 0`. Что нужно сделать, чтобы собралось?
Дать компилятору знание о n. Способов четыре: повторить
ограничение в сигнатуре outer, взять его из ограничения структуры,
обернуть вызов в comptime if n >= 0, либо поставить перед вызовом
comptime assert n >= 0. Последние два не требуют менять сигнатуру.
Вы пишете библиотеку. Почему стоит потратить время на `comptime assert` с текстом, а не оставить как есть?
Потому что без него пользователь получит ошибку из глубины вашей
реализации — про выход за границы или неудачную инстанциацию — и не
поймёт, чего от него хотят. С сообщением он прочитает
constraint failed: ваш текст и увидит объяснение
по-человечески, ещё до того как программа собралась.
Что означает условное соответствие `struct Box[...](Copyable where conforms_to(T, Copyable))`?
Условное соответствие: Box копируем не всегда, а только когда
копируемо его содержимое. Box[Int] подойдёт функции, требующей
Copyable; Box с move-only типом внутри — нет. Так же устроен
List в стандартной библиотеке.
Раздел пройден
Заголовок раздела «Раздел пройден»Теперь понятно, откуда в Mojo берётся скорость, которой нет у Python: значительная часть работы просто не доживает до запуска. Компилятор считает, что может; параметры дают ему то, из чего считать; трейты и ограничения описывают, что при этом допустимо.
Четыре слоя, и у каждого свой вопрос:
| Слой | Вопрос, на который отвечает |
|---|---|
comptime | что посчитать заранее |
| параметры | что сделать входом для сборки |
| трейты | что тип обязан уметь |
| ограничения | каким должно быть значение |
Раздел Производительность — о том, как всё это превращается в скорость; он ещё пишется. А пока лучший способ закрепить прочитанное — сделать это руками:
- соберите
ring.mojoсRing[6]иRing[0]и сравните сообщения; - замените в нём
comptime assertнаwhereи посмотрите, что увидит пользователь; - повторите замер
.textиз главы Параметризация на своём коде — результат может удивить.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0