Трейты и обобщённый код
This content is not available in your language yet.
Параметр типа [T: ...] мы уже писали, но всегда с готовым требованием
из стандартной библиотеки. Пора научиться объявлять требования самим.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В Python обобщённый код держится на утиной типизации: раз у объекта есть
метод price(), значит подойдёт. Ошибка вылезает при запуске, у клиента.
Mojo требует, чтобы обещание было записано в коде. Трейт — это и есть такая запись: список того, что тип обязан уметь.
def average(sensors): # надеемся, что у всех # есть .read() return sum(s.read() for s in sensors) / len(sensors)def average[T: Sensor]( items: List[T]) -> Float64: # компилятор проверил, # что .read() есть ...В Python «есть ли метод» выясняется при вызове. В Mojo — при сборке,
и не у вас, а у того, кто попытался подсунуть неподходящий тип.
Но есть и цена: питоновский список может быть разнородным,
а List[T] — нет. Об этом ниже.
Объявить и реализовать
Заголовок раздела «Объявить и реализовать»Трейт объявляется словом trait, а ... отмечает метод как
обязательный:
trait Sensor: def read(self) -> Float64: ...
@fieldwise_initstruct 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_initstruct 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 implementednote: 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:
| Трейт | Что даёт | Что реализовать |
|---|---|---|
Writable | print(x) и String(x) | write_to[W: Writer](self, mut writer: W) |
Equatable | == и != | __eq__ |
Comparable | все шесть операторов сравнения | только __lt__ |
Hashable | хеш значения | __hash__ |
Sized | len(x) | __len__ |
Boolable | if x: | __bool__ |
Intable / Floatable | Int(x) / Float64(x) | __int__ / __float__ |
Defaultable | создание без аргументов | __init__(out self) |
Трейты копирования — Copyable, ImplicitlyCopyable — подробно разобраны
в главе Копирование и перемещение; здесь они
ничем не отличаются от своих.
Полный список — в справочнике.
Ассоциированные типы
Заголовок раздела «Ассоциированные типы»Трейт может потребовать не только методы, но и тип, свой у каждого реализующего:
trait Container: comptime Element: AnyType
def first(self) -> Self.Element: ...
@fieldwise_initstruct 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_initstruct 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 тип известен заранее и в списке он один.
Всё вместе
Заголовок раздела «Всё вместе»Прайс-лист: у книг и кофе общее ровно одно — цена.
# Прайс-лист. У книг и кофе общее ровно одно — у каждого есть цена.# Этого хватает, чтобы написать подсчёт суммы один раз.
@fieldwise_initstruct 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_initstruct 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_initstruct 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. Без сравнения
на равенство словарь не отличит совпадение хешей от совпадения
ключей.
Что дальше
Заголовок раздела «Что дальше»Ограничения: как потребовать от типа не наличие метода, а выполнение условия — и как сделать, чтобы сообщение об ошибке читал человек, а не компилятор.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0