Skip to content

Ограничения

This content is not available in your language yet.

Трейт отвечает на вопрос «что тип умеет». Остаётся вторая половина: «какими должны быть значения параметров». Размер буфера положителен, конец отрезка правее начала, ширина вектора — степень двойки. Ни один трейт этого не выразит.

Условие пишется в конце сигнатуры, после типа возврата:

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 == 55доказывает
n + 1 > 0, n * 2 > 0, n - 1 > 05доказывает
(n & 1) == 010доказывает
(n & (n - 1)) == 08доказывает
(n << 1) == 105доказывает
n >= 0 and n < 105доказывает
(n % 2) == 010не доказывает
(n // 2) == 510не доказывает
(n ** 2) == 255не доказывает
is_even(n) — своя функция10не доказывает

Сравнения, + - *, унарный минус и все битовые операции встроены в компилятор — их он сворачивает. %, //, **, любой вызов вашей функции, сравнение строк реализованы обычным кодом Mojo, и на них решатель останавливается с той самой строкой cannot evaluate call to non-builtin function.

Второе, что удивляет. Решатель не выводит следствий — он ищет дословное совпадение, с точностью до пары нормализаций. Проверено:

ИзвестноТребуетсяИтог
n > 0n >= 1выводит (строгое ↔ нестрогое ±1)
n > 00 < nвыводит (переворот сторон)
n >= 0 and n < 10n >= 0выводит (and разбирается на факты)
n > 0n >= 0не выводит
n > 0n != 0не выводит
n == 5n >= 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 correctness
note: cannot prove constraint for candidate
note: 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 ifcomptime if n >= 0: return inner[n]()
код после comptime assertcomptime assert n >= 0 — и дальше n >= 0 считается известным

Последние два особенно полезны: они позволяют «сузить» знание локально, не втаскивая условие в сигнатуру всей функции.

where умеет и такое: тип соответствует трейту не всегда, а только при условии на параметр.

@fieldwise_init
struct 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 constraint
note: constraint declared here evaluated to False, expected 'conforms_to(T, Writable)'

Это способ дать типу дополнительные возможности там, где они осмысленны, не заводя двух разных типов.

Ограничение — не только проверка. Это ещё и место, где вы можете объяснить, чего от вас хотят.

Без него ошибка вылезет глубоко внутри вашей реализации, и читать её будет тот, кто вашего кода не видел:

error: function instantiation failed
note: 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: .... Разница только в том, чей текст читает пользователь — стандартной библиотеки или ваш. Это и есть разница между хорошей и плохой библиотекой.

Кольцевой буфер: ёмкость должна быть положительной и степенью двойки.

ring.mojo
# Кольцевой буфер фиксированной ёмкости.
# Два требования к ёмкости записаны по-разному:
# - «больше нуля» — 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 = 0b0111
11 & 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 из главы Параметризация на своём коде — результат может удивить.
Примеры проверены на Mojo 1.1.0

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