Skip to content

Трейты и обобщённый код

This content is not available in your language yet.

Параметр типа [T: ...] мы уже писали, но всегда с готовым требованием из стандартной библиотеки. Пора научиться объявлять требования самим.

В Python обобщённый код держится на утиной типизации: раз у объекта есть метод price(), значит подойдёт. Ошибка вылезает при запуске, у клиента.

Mojo требует, чтобы обещание было записано в коде. Трейт — это и есть такая запись: список того, что тип обязан уметь.

🐍 Python
def average(sensors):
# надеемся, что у всех
# есть .read()
return sum(s.read()
for s in sensors) / len(sensors)
🔥 Mojo
def average[T: Sensor](
items: List[T]
) -> Float64:
# компилятор проверил,
# что .read() есть
...

В Python «есть ли метод» выясняется при вызове. В Mojo — при сборке, и не у вас, а у того, кто попытался подсунуть неподходящий тип. Но есть и цена: питоновский список может быть разнородным, а List[T] — нет. Об этом ниже.

Трейт объявляется словом trait, а ... отмечает метод как обязательный:

trait Sensor:
def read(self) -> Float64:
...
@fieldwise_init
struct Capacitive(Copyable, Sensor):
def read(self) -> Float64:
return 21.5
def average[T: Sensor](sensor: T, samples: Int) -> Float64:
var total = 0.0
for _ in range(samples):
total += sensor.read()
return total / Float64(samples)
def main():
print(average(Capacitive(), 4))
Результат
21.5

Соответствие объявляется в скобках после имени структуры — там же, где Copyable. Никакой разницы между «своими» и «библиотечными» трейтами нет.

Это главное отличие от Python, и на нём спотыкаются. Мало иметь нужный метод — нужно объявить трейт:

@fieldwise_init
struct Rogue(Copyable):
def read(self) -> Float64: # метод есть...
return 1.0 # ...но Sensor не объявлен
error: invalid call to 'average': value passed to 'sensor' cannot be converted from 'Rogue' to 'T', argument type 'Rogue' does not conform to trait 'Sensor'
Что это значит

Компилятор не угадывает намерения по именам методов. Совпадение сигнатуры для него ничего не значит — значение имеет только явно объявленное соответствие.

Как исправить

Допишите трейт в список: struct Rogue(Copyable, Sensor).

Строгость намеренная. Совпадение имён бывает случайным: read() у датчика и read() у файла — разные вещи, и хорошо, когда компилятор не считает их взаимозаменяемыми.

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

trait Sensor:
def read(self) -> Float64:
...
def label(self) -> String:
return String("датчик без имени")

Тип, реализовавший только read, всё равно соответствует Sensor и получает label даром. Это способ расширять трейт, не ломая тех, кто уже ему соответствует.

error: 'Broken' does not implement all requirements for 'Sensor'
Что это значит

Трейт — это всё или ничего: соответствующий тип обязан закрыть каждое требование. Какое именно не закрыто, компилятор говорит следующей строкой:

note: required function 'read' is not implemented
note: trait 'Sensor' declared here
Как исправить

Реализуйте названный метод — или уберите трейт из списка, если тип ему и не должен соответствовать.

Несколько трейтов сразу и трейт поверх трейта

Заголовок раздела «Несколько трейтов сразу и трейт поверх трейта»

Несколько требований сразу — через &:

def average[T: Sensor & Movable](items: List[T]) -> Float64:

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

Имя для набора требований. Если одна и та же связка нужна в разных местах, ей дают имя обычным comptime:

comptime SensorLike = Sensor & Movable
def average[T: SensorLike](items: List[T]) -> Float64:
...

Такое имя годится и в списке соответствия структуры: struct Precise(Copyable, SensorLike).

Уточнение — трейт, который включает другой:

trait Calibratable(Sensor):
def calibrate(mut self):
...

Всё, что соответствует Calibratable, автоматически годится и там, где просят Sensor. Обратное неверно.

Своё требование стоит объявлять, только когда в библиотеке нет готового. Вот те, что понадобятся в этой главе и рядом — каждая строка проверена на компиляторе 1.0.0:

ТрейтЧто даётЧто реализовать
Writableprint(x) и String(x)write_to[W: Writer](self, mut writer: W)
Equatable== и !=__eq__
Comparableвсе шесть операторов сравнениятолько __lt__
Hashableхеш значения__hash__
Sizedlen(x)__len__
Boolableif x:__bool__
Intable / FloatableInt(x) / Float64(x)__int__ / __float__
Defaultableсоздание без аргументов__init__(out self)

Трейты копирования — Copyable, ImplicitlyCopyable — подробно разобраны в главе Копирование и перемещение; здесь они ничем не отличаются от своих.

Полный список — в справочнике.

Трейт может потребовать не только методы, но и тип, свой у каждого реализующего:

trait Container:
comptime Element: AnyType
def first(self) -> Self.Element:
...
@fieldwise_init
struct IntBox(Copyable, Container):
comptime Element = Int
var value: Int
def first(self) -> Int:
return self.value
def peek[C: Container](c: C) -> C.Element:
return c.first()
def main():
print(peek(IntBox(7)))
Результат
7

Обратите внимание на C.Element в типе возврата: функция обещает вернуть то, что лежит в контейнере, не зная заранее, что это. Так устроены итераторы и коллекции в стандартной библиотеке.

Точно так же трейт может потребовать значение:

trait Unit:
comptime name: StaticString
comptime factor: Float64
@fieldwise_init
struct Foot(Copyable, Unit):
comptime name = "фут"
comptime factor = 0.3048
def describe[U: Unit]():
print(U.name, "->", 2.0 * U.factor, "м")
def main():
describe[Foot]()
Результат
фут -> 0.6096 м

Здесь Foot не создаётся вовсе — тип работает как носитель констант, и вся функция целиком считается при сборке.

Один тип на вызов, а не любой из подходящих

Заголовок раздела «Один тип на вызов, а не любой из подходящих»

Здесь питониста ждёт главное разочарование главы, и лучше встретить его сейчас, чем в своём коде.

Обобщённая функция average[T: Sensor] не принимает «любой датчик». Она собирается отдельно под каждый конкретный T. Поэтому список разнородных датчиков составить нельзя:

error: 'List' parameter 'T' has 'Movable' type, but value has type 'AnyTrait[Sensor]'
Что это значит

Вы написали List[Sensor], то есть попросили список «чего угодно, что соответствует трейту». Но List хранит значения одного типа известного размера, а трейт — это требование, а не тип.

Как исправить

Держите однородные списки и вызывайте обобщённую функцию для каждого отдельно — так и сделано в примере ниже. Механизма «список разных типов за общим интерфейсом» в Mojo нет.

Вот в чём разница с Python на самом деле: там [book, coffee] — обычное дело, потому что каждый элемент несёт свой тип с собой и метод ищется при вызове. В Mojo тип известен заранее и в списке он один.

Прайс-лист: у книг и кофе общее ровно одно — цена.

pricing.mojo
# Прайс-лист. У книг и кофе общее ровно одно — у каждого есть цена.
# Этого хватает, чтобы написать подсчёт суммы один раз.
@fieldwise_init
struct Money(ImplicitlyCopyable, Comparable, Writable):
"""Деньги в копейках: во Float64 их хранить нельзя из-за округлений."""
var kopeks: Int
# Достаточно одного __lt__ — остальные пять операторов сравнения
# трейт Comparable добавляет сам.
def __lt__(self, other: Self) -> Bool:
return self.kopeks < other.kopeks
# write_to даёт и print(), и String(): отдельного трейта
# для строкового представления в Mojo 1.0 нет.
def write_to[W: Writer](self, mut writer: W):
writer.write(self.kopeks // 100, " ₽")
trait Priced:
"""Цена обязательна, наличие — нет: у него есть значение по умолчанию."""
def price(self) -> Money:
...
def in_stock(self) -> String:
return String("под заказ")
@fieldwise_init
struct Book(Copyable, Priced):
var title: String
var kopeks: Int
def price(self) -> Money:
return Money(self.kopeks)
def in_stock(self) -> String:
return String("на складе")
@fieldwise_init
struct Coffee(Copyable, Priced):
var grams: Int
def price(self) -> Money:
return Money(self.grams * 350)
# in_stock не реализован — возьмётся значение по умолчанию.
def total[T: Priced & Movable](items: List[T]) -> Money:
"""Работает с любым списком товаров, лишь бы у них была цена.
Второе требование, Movable, идёт от List, а не от задачи.
"""
var subtotal = 0
for item in items:
subtotal += item.price().kopeks
return Money(subtotal)
def show[T: Priced](item: T, name: String):
print(" ", name, "—", item.price(), "|", item.in_stock())
def main():
# Тип указан явно: голый литерал [...] дал бы Array, а не List.
var books: List[Book] = [Book("Мойо", 89000), Book("Расты", 125050)]
var coffee: List[Coffee] = [Coffee(250), Coffee(1000)]
print("книги:")
for b in books:
show(b, b.title)
print("итого:", total(books))
print("кофе:")
for c in coffee:
show(c, String(c.grams) + String(" г"))
print("итого:", total(coffee))
# Списки приходится держать раздельно: List[Priced] не существует.
# Сравнение при этом работает, хотя написан только __lt__.
print("дороже ли кофе книг:", total(coffee) > total(books))
Результат
книги:
   Мойо — 890 ₽ | на складе
   Расты — 1250 ₽ | на складе
итого: 2140 ₽
кофе:
   250 г — 875 ₽ | под заказ
   1000 г — 3500 ₽ | под заказ
итого: 4375 ₽
дороже ли кофе книг: True

Три вещи, ради которых стоит перечитать пример:

  • total написан один раз, а работает и с книгами, и с кофе;
  • Coffee не реализует in_stock — печатается «под заказ» из трейта;
  • списки держатся раздельно, и это не лень автора, а следствие устройства языка.

🎯 Проверь себя

Функция `total[T: Priced]` не собирается: компилятор просит «evidence for conforms_to(T, Movable)». Откуда взялось требование, которого нет в задаче?

От List. Чтобы хранить элементы, он должен уметь их перемещать, и это требование передаётся вашему параметру. Задача про цены здесь ни при чём — надо дописать & Movable. Та же ситуация разбиралась в главе Параметризация.

У вас есть `List[Book]` и `List[Coffee]`, у обоих типов реализован `price()`. Можно ли сложить их в один список и вызвать `total` один раз?

Нет. List[Priced] не существует: трейт — это требование к типу, а не тип. Компилятор ответит 'List' parameter 'T' has 'Movable' type, but value has type 'AnyTrait[Priced]'. Обобщённая функция собирается отдельно под каждый конкретный тип, поэтому списки приходится держать раздельно.

Тип реализует метод `read()`, но объявлен как `struct Rogue(Copyable)`. Подойдёт ли он функции `average[T: Sensor]`?

Нет. В Mojo нет утиной типизации: соответствие объявляется явно. Компилятор скажет argument type 'Rogue' does not conform to trait 'Sensor', и лечится это дописыванием трейта в скобки.

Трейт `Sensor` даёт реализацию `label` по умолчанию, и в вашей структуре тоже написан `label`. Какой вызовется? А если второй трейт тоже даёт `label`?

Вызовется ваш: реализация в структуре всегда важнее той, что в трейте. А вот два трейта с одинаковым методом — уже конфликт, и компилятор откажется выбирать: conflicting default implementations in 'A' and 'B'; you must implement it manually. Значит, придётся написать метод в структуре явно.

Вы делаете свой тип ключом словаря и объявили `Hashable`. Хватит ли этого?

Нет. Dict требует KeyElement, а это, по словам самого компилятора, Equatable & Hashable & Movable. Без сравнения на равенство словарь не отличит совпадение хешей от совпадения ключей.

Ограничения: как потребовать от типа не наличие метода, а выполнение условия — и как сделать, чтобы сообщение об ошибке читал человек, а не компилятор.

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

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