Трейты(Traits)¶
Трейт - это набор требований, которые должен выполнять тип. Вы можете рассматривать его как контракт: тип, соответствующий трейту, гарантирует, что он реализует все функции этого трейта.
Трейты аналогичны интерфейсам Java, концепциям C++, протоколам Swift и трейтам Rust. Если вы знакомы с какой-либо из этих функций, Mojo traits решает ту же основную проблему.
Вы, вероятно, уже видели некоторые трейты, такие как Copyable и Movable, используемые в примерах кода. В этом разделе описывается, как работают трейты, как использовать существующие трейты и как определить свои собственные трейты.
Background¶
В языках с динамической типизацией, таких как Python, вам не нужно явно объявлять, что два класса похожи. Это проще всего показать на примере:
🐍 Python
class Duck:
def quack(self):
print("Quack.")
class StealthCow:
def quack(self):
print("Moo!")
def make_it_quack(maybe_a_duck):
try:
maybe_a_duck.quack()
except:
print("Not a duck.")
make_it_quack(Duck())
make_it_quack(StealthCow())
Quack
Moo!
Классы Duck и StealthCow никак не связаны, но они оба определяют метод quack(), поэтому в функции make_it_quack() они работают одинаково. Это работает, потому что Python использует динамическую диспетчеризацию — он определяет методы для вызова во время выполнения. Таким образом, функции make_it_quack() безразлично, какие типы вы передаете, важен только тот факт, что они реализуют метод quack().
В среде со статической типизацией этот подход не работает: функции Mojo требуют, чтобы вы указывали тип каждого аргумента. Если бы вы хотели написать этот пример в Mojo без трейтов, вам нужно было бы написать перегрузку функции для каждого типа ввода.
🔥 Mojo:
@fieldwise_init
struct Duck(Copyable):
fn quack(self):
print("Quack")
@fieldwise_init
struct StealthCow(Copyable):
fn quack(self):
print("Moo!")
fn make_it_quack(definitely_a_duck: Duck):
definitely_a_duck.quack()
fn make_it_quack(not_a_duck: StealthCow):
not_a_duck.quack()
make_it_quack(Duck())
make_it_quack(StealthCow())
Quack
Moo!
Это не так уж и плохо, учитывая наличие всего двух типов. Но чем больше типов вы хотите поддерживать, тем менее практичным становится такой подход.
Вы можете заметить, что в версиях make_it_quack() для Mojo не используется оператор try/except. Нам это не нужно, потому что статическая проверка типов Mojo гарантирует, что вы можете передавать только экземпляры Duck или StealthCow в функцию make_it_quack().
Использование трейтов¶
Трейты решают эту проблему, позволяя вам определить общий набор поведений, которые могут реализовывать типы. Затем вы можете написать функцию, которая зависит от трейта, а не от отдельных типов. В качестве примера давайте обновим make_it_quack(), используя трейты. Это будет включать в себя три шага:
- Определение нового трейта, который может использовать
Quackable. - Добавление трейта в структуры
DuckиStealthCow. - Обновление функции
make_it_quack(), чтобы она зависела от трейта.
Определение трейта¶
Первым шагом является определение трейта, для которого требуется метод quack():
trait Quackable:
fn quack(self):
...
Трейт очень похож на структуру, за исключением того, что он вводится ключевым словом trait. Обратите внимание, что за сигнатурой метода `quack() следуют три точки (...). Это означает, что он не реализован в рамках трейта. В этом примере quack является обязательным методом и должен быть реализован любой соответствующей структурой.
Трейт может предоставлять реализацию по умолчанию, поэтому соответствующим структурам не нужно реализовывать метод самостоятельно. Вы можете предоставить полную реализацию или использовать ключевое слово pass. Использование pass создает неоперативный метод, который ничего не делает. В вашей соответствующей структуре вы можете переопределить эту реализацию по умолчанию:
trait Quackable:
fn quack(self):
pass
Трейт также может включать в себя элементы comptime — значения констант времени компиляции, которые должны определяться соответствующими структурами. Элементы comptime полезны для написания трейтов, описывающих универсальные типы. Дополнительные сведения см. в разделе Общие элементы comptime.
Добавление трейтов в структуры¶
Далее, нам нужны некоторые структуры, которые соответствуют трейту Quackable. Поскольку приведенные выше структуры Duck и StealthCow уже реализуют метод quack(), все, что нам нужно сделать, это добавить трейт Quackable к трейтам, которым оно соответствует(в круглых скобках после имени структуры).
(Если вы знакомы с Python, это выглядит точно так же, как синтаксис наследования в Python.)
@fieldwise_init
struct Duck(Copyable, Quackable):
fn quack(self):
print("Quack")
@fieldwise_init
struct StealthCow(Copyable, Quackable):
fn quack(self):
print("Moo!")
Структура должна реализовывать все методы, которые объявлены в трейте. Компилятор обеспечивает соответствие: если структура заявляет, что она соответствует трейту, она должна реализовать все, что требуется для этого трейта, иначе код не будет скомпилирован.
Использование трейтов в качестве привязки к типу¶
Наконец, вы можете определить функцию, которая принимает Quackable следующим образом:
fn make_it_quack[DuckType: Quackable](maybe_a_duck: DuckType):
maybe_a_duck.quack()
Или используя сокращенную форму:
fn make_it_quack2(maybe_a_duck: Some[Quackable]):
maybe_a_duck.quack()
Этот синтаксис может показаться немного незнакомым, если вы раньше не имели дела с параметрами Mojo. Первая сигнатура означает, что maybe_a_duck является аргументом типа DuckType, где DuckType - это тип, который должен соответствовать трейту Quackable. Quackable называется типом, привязанным к DuckType.
Форма Some[Quackable] выражает ту же идею: тип maybe_a_duck - это некоторый конкретный тип, который соответствует трейту Quackable.
Обе формы работают одинаково, за исключением того, что в первой форме явно указывается тип значения. Это может быть полезно, например, если вы хотите получить два значения одного типа:
fn take_two_quackers[DuckType: Quackable](quacker1: DuckType, quacker2: DuckType):
pass
Соединяя все это воедино¶
Пользоваться этой функцией достаточно просто:
make_it_quack(Duck())
make_it_quack(StealthCow())
Quack
Moo!
Обратите внимание, что вам не нужны квадратные скобки при вызове функции make_it_quack(): компилятор определяет тип аргумента и гарантирует, что тип имеет требуемый трейт.
Одно из ограничений, связанных с трепйтами, заключается в том, что вы не можете добавлять трейты к существующим типам. Например, если вы определили новый числовой трейт, вы не сможете добавить его в стандартную библиотеку типов Float64 и Int. Однако стандартная библиотека уже содержит довольно много трейтов, и со временем мы добавим еще больше.
Трейты могут потребовать статические методы¶
В дополнение к обычным методам экземпляра, трейты могут указывать необходимые статические методы.
trait HasStaticMethod:
@staticmethod
fn do_stuff(): ...
fn fun_with_traits[type: HasStaticMethod]():
type.do_stuff()
Реализации методов по умолчанию¶
Часто некоторые или все структуры, соответствующие данному трейту, могут использовать одну и ту же реализацию для данного требуемого метода. В этом случае трейт может включать реализацию по умолчанию. Соответствующая структура все равно может определять свою собственную версию метода, переопределяя реализацию по умолчанию. Но если структура не определяет свою собственную версию, она автоматически наследует реализацию по умолчанию.
Определение реализации по умолчанию для трейта выглядит так же, как написание метода для структуры:
trait DefaultQuackable:
fn quack(self):
print("Quack")
@fieldwise_init
struct DefaultDuck(Copyable, DefaultQuackable):
pass
При просмотре документации по API в поисках стандартного библиотечного трейта вы увидите методы, которые необходимо реализовать, перечисленные в качестве обязательных методов, и методы, которые имеют реализации по умолчанию, перечисленные в качестве предоставляемых методов.
Трейт Equatable является хорошим примером использования реализаций по умолчанию. Этот трейт включает в себя два метода: __eq__() (соответствующий оператору ==) и __ne__() (соответствующий оператору !=). Каждый тип, соответствующий стандарту Equatable, должен определить метод __eq__() для себя, но этот трейт предоставляет реализацию по умолчанию для __ne__(). Учитывая метод __eq__(), определение __ne__() является тривиальным для большинства типов:
fn __ne__(self, other: Self) -> Bool:
return not self.__eq__(other)
Trait композиции¶
Вы можете составлять трейты, используя символ &. Это позволяет вам определять новые трейты, которые являются простыми комбинациями других трейтов. Вы можете использовать композицию трейтов везде, где вы использовали бы один трейт:
trait Flyable:
fn fly(self): ...
fn quack_and_go[type: Quackable & Flyable](quacker: type):
quacker.quack()
quacker.fly()
@fieldwise_init
struct FlyingDuck(Copyable, Quackable, Flyable):
fn quack(self):
print("Quack")
fn fly(self):
print("Whoosh!")
Вы также можете использовать ключевое слово comptime для создания сокращенного названия для композиции трейтов:
comptime DuckLike = Quackable & Flyable
struct ToyDuck(DuckLike):
# ... implementation omitted
Вы также можете создавать трейты с помощью наследования, определив новый пустой трейт следующим образом:
trait DuckTrait(Quackable, Flyable):
pass
Однако это менее гибко, чем использование композиции трейтов, и не рекомендуется. Разница в том, что при использовании ключевого слова trait определяется новый именованный трейт. Чтобы структура соответствовала этому трейту, вам необходимо явно включить его в сигнатуру структуры. С другой стороны, значение DuckLike comptime представляет собой комбинацию двух отдельных трейтов, Quackable и Flyable, и все, что соответствует этим двум трейтам, соответствует DuckLike. Например, рассмотрим тип FlyingDuck, показанный выше:
struct FlyingDuck(Copyable, Quackable, Flyable):
# ... etc
Поскольку FlyingDuck соответствует как Quackable, так и Flyable, он также соответствует составу трейтов, присущих DuckLike. Но он не соответствует DuckTrait, поскольку не включает DuckTrait в свой список трейтов.
Trait наследование¶
Трейты могут наследоваться от других трейтов. Трейт, который наследуется от другого трейта, включает в себя все требования, заявленные родительским трейтом. Например:
trait Animal:
fn make_sound(self):
...
# Bird наследуется от Animal
trait Bird(Animal):
fn fly(self):
...
Поскольку Bird наследуется от Animal, структура, соответствующая трейту Bird, должна реализовывать как make_sound(), так и fly(). И поскольку каждая Bird соответствует Animal, структура, соответствующая Bird, может быть передана в любую функцию, для которой требуется Animal.
Чтобы наследовать от нескольких трейтов, добавьте в круглые скобки список трейтов, разделенных запятыми, или их комбинации. Например, вы можете определить трейт с именем Animal, который сочетает в себе требования, предъявляемые к трейту животного, и новый именованный трейт:
trait Named:
fn get_name(self) -> String:
...
trait NamedAnimal(Animal, Named):
fn emit_name_and_sound(self):
...
Наследование полезно, когда вы создаете новый трейт, который добавляет свои собственные требования. Если вы просто хотите выразить объединение двух или более трейтов, вам следует использовать простую композицию трейтов:
comptime NamedAnimal = Animal & Named
Трейты и методы жизненного цикла¶
Трейты могут требовать необходимые методы жизненного цикла, включая конструкторы, конструкторы копирования и конструкторы перемещения.
Например, следующий код создает трейт MassProducible. Тип MassProducible имеет конструктор по умолчанию (без аргументов) и может быть перемещен. В нем используются два встроенных трейта: Defaultable, для которого требуется конструктор по умолчанию (без аргументов), и Movable, для которого требуется, чтобы у типа был конструктор перемещения.
Функция factory[]() возвращает только что созданный экземпляр типа MassProducible. В следующем примере для справки показаны определения параметров Defaultable и Movable в комментариях:
# trait Defaultable
# fn __init__(out self): ...
# trait Movable
# fn __moveinit__(out self, deinit existing: Self): ...
comptime MassProducible = Defaultable & Movable
fn factory[type: MassProducible]() -> type:
return type()
struct Thing(MassProducible):
var id: Int
fn __init__(out self):
self.id = 0
fn __moveinit__(out self, deinit existing: Self):
self.id = existing.id
var thing = factory[Thing]()
Register-passable трейты¶
Трейт может быть объявлен либо с помощью декоратора @register_passable, либо с помощью декоратора @register_passable("trivial"). Эти декораторы добавляют требования к соответствующим структурам:
-
Если трейт объявлен как
@register_passable, каждая структура, соответствующая этому трейту, должна быть либо@register_passable, либо@register_passable("trivial"). -
Если трейт объявлен как
@register_passable("trivial"), то каждая структура, соответствующая этому трейту, также должна бытьstruct @register_passable("trivial").
Для обеспечения соответствия трейт или структура, определенные с помощью @register_passable, должны автоматически соответствовать перемещаемому трейту, а трейт или структура, определенные с помощью @register_passable("trivial"), должны автоматически соответствовать копируемому трейту.
В некоторых случаях компилятор может неправильно отслеживать эти автоматические соответствия. Если вы столкнетесь с проблемой, добавьте атрибуты в свою структуру явно.
Встроенные трейты¶
Стандартная библиотека Mojo включает в себя множество функций. Они реализованы в нескольких типах стандартных библиотек, и вы также можете реализовать их в своих собственных типах. Эти функции стандартной библиотеки включают:
- Absable
- AnyType
- Boolable
- Comparable
- Copyable
- Defaultable
- Hashable
- ImplicitlyDestructible
- Indexer
- Intable
- IntableRaising
- KeyElement
- Movable
- PathLike
- Powable
- Representable
- Sized
- Stringable
- StringableRaising
- Roundable
- Writable
- Writer
В приведенных выше справочных документах по API приведены примеры использования каждой функции. В следующих разделах рассматриваются некоторые из этих функций.
Трейт Sized¶
Трейт Sized определяет типы, которые имеют измеримую длину, например строки и массивы.
В частности, для определения размера требуется тип для реализации метода __len__(). Этот трейт используется встроенной функцией len(). Например, если вы пишете пользовательский тип списка, вы могли бы реализовать эту функцию, чтобы ваш тип работал с len():
struct MyList(Copyable, Sized):
var size: Int
# ...
fn __init__(out self):
self.size = 0
fn __len__(self) -> Int:
return self.size
print(len(MyList()))
0
Трейты Intable и IntableRaising¶
Трейт Intable определяет тип, который может быть преобразован в Int. трейт IntableRaising описывает, что тип может быть преобразован в Int, но при преобразовании может возникнуть ошибка.
Оба этих трейта требуют, чтобы тип реализовывал метод __int__(). Например:
@fieldwise_init
struct IntLike(Intable):
var i: Int
fn __int__(self) -> Int:
return self.i
value = IntLike(42)
print(Int(value) == 42)
True
Трейты Stringable, Representable и Writable¶
Трейт Stringable определяет тип, который может быть явно преобразован в String. Трейт StringableRaising описывает тип, который может быть преобразован в String, но при преобразовании может возникнуть ошибка. Эти особенности также означают, что тип может поддерживать спецификаторы формата {!s} и {} для метода format() класса String и StringSlice. Эти особенности требуют, чтобы тип определял метод __str__().
Напротив, трейт Representable определяет тип, который можно использовать со встроенной функцией repr(), а также спецификатором формата {!r} метода format(). Этот трейт требует, чтобы тип определял метод __repr__(), который должен вычислять "официальное" строковое представление типа. Если это вообще возможно, это должно выглядеть как допустимое выражение Mojo, которое можно было бы использовать для воссоздания экземпляра struct с тем же значением.
Свойство Writable описывает тип, который может создавать текстовое представление, понятное пользователю, путем записи в объект Writer. Функция print() требует, чтобы ее аргументы соответствовали свойству Writable. По умолчанию это позволяет выполнять эффективную потоковую запись, избегая ненужных промежуточных распределений в куче строк.
Трейт Writable требует наличия типа для реализации метода write_to(), который предоставляется с объектом, соответствующим трейту Writer в качестве аргумента. Затем вы вызываете метод write() экземпляра Writer для записи последовательности доступных для записи аргументов, составляющих строковое представление вашего типа.
Важной особенностью функции Writer является то, что она принимает только допустимый текст в формате UTF-8. Это означает, что при записи данных в Writer вы можете использовать только значения StringSlice (с помощью метода write_string()) или другие типы, доступные для записи; вы не можете записывать произвольные байты. Эта гарантия гарантирует, что такие типы, как String, могут безопасно реализовывать функцию записи без риска повреждения их внутренних данных в формате UTF-8.
Хотя на первый взгляд это может показаться сложным, на практике вы можете свести к минимуму шаблонный и дублирующийся код, используя статическую функцию String.write() для реализации Stringable типа в терминах его доступной для записи реализации. Вот простой пример типа, который реализует все строковые, представимые и доступные для записи свойства:
@fieldwise_init
struct Dog(Copyable, Stringable, Representable, Writable):
var name: String
var age: Int
# Позволяет записывать тип в любой `Writer`
fn write_to(self, mut writer: Some[Writer]):
writer.write("Dog(", self.name, ", ", self.age, ")")
# Создайте и верните `String`, используя предыдущий метод
fn __str__(self) -> String:
return String.write(self)
# Альтернативное полное представление при вызове `repr`
fn __repr__(self) -> String:
return String(
"Dog(name=", repr(self.name), ", age=", repr(self.age), ")"
)
dog = Dog("Rex", 5)
print(repr(dog))
print(dog)
var dog_info = "String: {!s}\nRepresentation: {!r}".format(dog, dog)
print(dog_info)
Dog(name='Rex', age=5)
Dog(Rex, 5)
String: Dog(Rex, 5)
Representation: Dog(name='Rex', age=5)
Трейт ImplicitlyDestructible¶
При создании универсального типа контейнера одной из проблем является знание того, как избавиться от содержащихся в нем элементов при уничтожении контейнера. Любой тип, который динамически выделяет память, должен содержать деструктор (метод __del__()), который должен быть вызван для освобождения выделенной памяти. Но не все типы имеют деструктор.
Трейт ImplicitlyDestructible представляет тип с деструктором. Почти все трейты наследуются от ImplicitlyDestructible, и все структуры по умолчанию соответствуют ImplicitlyDestructible. Для любого типа, который соответствует ImplicitlyDestructible и не определяет деструктор, Mojo генерирует деструктор без операций. Это означает, что вы можете вызвать деструктор для любого типа, который наследуется от ImplicitlyDestructible.
TODO В документации по стандартной библиотеке Mojo вы также увидите трейт, называемый
AnyType, который представляет тип, который может иметь или не иметь деструктор. Все структуры неявно соответствуют этому трету. Этот трейт существует для поддержки функции, называемой линейными типами или явно уничтожаемыми типами. Линейный тип не может быть уничтожен компилятором неявно, а вместо этого может быть уничтожен только тогда, когда программист явно вызывает метод именованного деструктора. Компилятор выдаст ошибку, если линейный тип становится неиспользуемым без явного уничтожения.
Шаблонные структуры с третами structs with traits¶
Вы также можете использовать атрибуты при определении универсального контейнера. Универсальный контейнер - это контейнер (например, массив или хэш-карта), который может содержать различные типы данных. В динамическом языке, таком как Python, в контейнер легко добавлять различные типы элементов. Но в среде со статической типизацией компилятор должен иметь возможность идентифицировать типы во время компиляции. Например, если контейнеру необходимо скопировать значение, компилятору необходимо убедиться, что тип может быть скопирован.
Тип списка является примером универсального контейнера. Один список может содержать только один тип данных. Элементы списка должны соответствовать трейту копирования:
struct List[T: Copyable]:
Например, вы можете создать список целочисленных значений следующим образом:
var list: List[Int]
list = [1, 2, 3, 4]
for i in range(len(list)):
print(list[i], end=" ")
1 2 3 4
Вы можете использовать трейты для определения требований к элементам, хранящимся в контейнере. Например, для списка требуются элементы, которые можно перемещать и копировать. Чтобы сохранить структуру в списке, структура должна соответствовать трейту Copyable, для чего требуется конструктор копирования и конструктор перемещения.
comptime члены для генериков¶
В дополнение к методам, трейт может включать элементы comptime, которые должны быть определены любой соответствующей структурой. Например:
trait Repeater:
comptime count: Int
Реализующая структура должна определить конкретное постоянное значение для элемента comptime, используя любое значение параметра времени компиляции. Например, она может использовать литеральную константу или выражение времени компиляции, включая то, которое использует параметры структуры.
struct Doublespeak(Repeater):
comptime count: Int = 2
struct Multispeak[verbosity: Int](Repeater):
comptime count: Int = Self.verbosity * 2 + 1
Структура Doublespeak имеет постоянное значение для параметра count, но структура Multispeak позволяет пользователю задать это значение с помощью параметра:
repeater = Multispeak[12]()
Обратите внимание, что поле называется count, а параметр Multispeak - verbosity. Параметры и элементы comptime находятся в одном пространстве имен, поэтому имя параметра не может совпадать с именем элемента comptime.
Элементы comptime наиболее полезны для написания трейтов для универсальных типов. Например, представьте, что вы хотите написать трейт, описывающий общую структуру данных стека, в которой хранятся элементы, соответствующие копируемому трейту.
Добавив тип элемента в качестве элемента comptime для трейта, вы можете указать общие методы для трейта:
trait Stacklike:
comptime EltType: Copyable
fn push(mut self, var item: Self.EltType):
...
fn pop(mut self) -> Self.EltType:
...
Следующая структура реализует свойство Stacklike, используя список в качестве базового хранилища:
struct MyStack[type: Copyable & ImplicitlyDestructible](Stacklike):
"""A simple Stack built using a List."""
comptime EltType = Self.type
comptime list_type = List[Self.EltType]
var list: Self.list_type
fn __init__(out self):
self.list = Self.list_type()
fn push(mut self, var item: Self.EltType):
self.list.append(item^)
fn pop(mut self) -> Self.EltType:
return self.list.pop()
fn dump[
WritableEltType: Writable & Copyable
](self: MyStack[WritableEltType]):
print("[", end="")
for item in self.list:
print(item, end=", ")
print("]")
Тип MyStack добавляет метод dump(), который выводит содержимое стека. Поскольку структура, соответствующая параметру Copyable, не обязательно доступна для печати, MyStack использует условное соответствие для определения метода dump(), который работает до тех пор, пока тип элемента доступен для записи.
Следующий код реализует эту новый трейт, определяя универсальный метод add_to_stack(), который добавляет элемент к любому стекоподобному типу.
def add_to_stack[S: Stacklike](mut stack: S, var item: S.EltType):
stack.push(item^)
def main():
s = MyStack[Int]()
add_to_stack(s, 12)
add_to_stack(s, 33)
s.dump() # [12, 33, ]
print(s.pop()) # 33