Associated Items
Associated Items are the items declared in traits or defined in implementations. Specifically, there are associated functions (including methods, that we already covered in Chapter 5), associated types, associated constants, and associated implementations.
Associated items are useful when they are logically related to the implementation. For example, the is_some
method on Option
is intrinsically related to Options, so should be associated.
Every associated item kind comes in two varieties: definitions that contain the actual implementation and declarations that declare signatures for definitions.
Associated Types
Associated types are type aliases allowing you to define abstract type placeholders within traits. Instead of specifying concrete types in the trait definition, associated types let trait implementers choose the actual types to use.
Let's consider the following Pack
trait:
trait Pack<T> {
type Result;
fn pack(self: T, other: T) -> Self::Result;
}
impl PackU32Impl of Pack<u32> {
type Result = u64;
fn pack(self: u32, other: u32) -> Self::Result {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn bar<T, impl PackImpl: Pack<T>>(self: T, b: T) -> PackImpl::Result {
PackImpl::pack(self, b)
}
trait PackGeneric<T, U> {
fn pack_generic(self: T, other: T) -> U;
}
impl PackGenericU32 of PackGeneric<u32, u64> {
fn pack_generic(self: u32, other: u32) -> u64 {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn foo<T, U, +PackGeneric<T, U>>(self: T, other: T) -> U {
self.pack_generic(other)
}
fn main() {
let a: u32 = 1;
let b: u32 = 1;
let x = foo(a, b);
let y = bar(a, b);
// result is 2^32 + 1
println!("x: {}", x);
println!("y: {}", y);
}
The Result
type in our Pack
trait acts as placeholder for a type that will be filled in later. Think of associated types as leaving a blank space in your trait for each implementation to write in the specific type it needs. This approach keeps your trait definition clean and flexible. When you use the trait, you don't need to worry about specifying these types - they're already chosen for you by the implementation. In our Pack
trait, the type Result
is such a placeholder. The method's definition shows that it will return values of type Self::Result
, but it doesn't specify what Result
actually is. This is left to the implementers of the Pack
trait, who will specify the concrete type for Result
. When the pack
method is called, it will return a value of that chosen concrete type, whatever it may be.
Let's see how associated types compare to a more traditional generic approach. Suppose we need a function foo
that can pack two variables of type T
. Without associated types, we might define a PackGeneric
trait and an implementation to pack two u32
like this:
trait Pack<T> {
type Result;
fn pack(self: T, other: T) -> Self::Result;
}
impl PackU32Impl of Pack<u32> {
type Result = u64;
fn pack(self: u32, other: u32) -> Self::Result {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn bar<T, impl PackImpl: Pack<T>>(self: T, b: T) -> PackImpl::Result {
PackImpl::pack(self, b)
}
trait PackGeneric<T, U> {
fn pack_generic(self: T, other: T) -> U;
}
impl PackGenericU32 of PackGeneric<u32, u64> {
fn pack_generic(self: u32, other: u32) -> u64 {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn foo<T, U, +PackGeneric<T, U>>(self: T, other: T) -> U {
self.pack_generic(other)
}
fn main() {
let a: u32 = 1;
let b: u32 = 1;
let x = foo(a, b);
let y = bar(a, b);
// result is 2^32 + 1
println!("x: {}", x);
println!("y: {}", y);
}
With this approach, foo
would be implemented as:
trait Pack<T> {
type Result;
fn pack(self: T, other: T) -> Self::Result;
}
impl PackU32Impl of Pack<u32> {
type Result = u64;
fn pack(self: u32, other: u32) -> Self::Result {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn bar<T, impl PackImpl: Pack<T>>(self: T, b: T) -> PackImpl::Result {
PackImpl::pack(self, b)
}
trait PackGeneric<T, U> {
fn pack_generic(self: T, other: T) -> U;
}
impl PackGenericU32 of PackGeneric<u32, u64> {
fn pack_generic(self: u32, other: u32) -> u64 {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn foo<T, U, +PackGeneric<T, U>>(self: T, other: T) -> U {
self.pack_generic(other)
}
fn main() {
let a: u32 = 1;
let b: u32 = 1;
let x = foo(a, b);
let y = bar(a, b);
// result is 2^32 + 1
println!("x: {}", x);
println!("y: {}", y);
}
Notice how foo
needs to specify both T
and U
as generic parameters. Now, let's compare this to our Pack
trait with an associated type:
trait Pack<T> {
type Result;
fn pack(self: T, other: T) -> Self::Result;
}
impl PackU32Impl of Pack<u32> {
type Result = u64;
fn pack(self: u32, other: u32) -> Self::Result {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn bar<T, impl PackImpl: Pack<T>>(self: T, b: T) -> PackImpl::Result {
PackImpl::pack(self, b)
}
trait PackGeneric<T, U> {
fn pack_generic(self: T, other: T) -> U;
}
impl PackGenericU32 of PackGeneric<u32, u64> {
fn pack_generic(self: u32, other: u32) -> u64 {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn foo<T, U, +PackGeneric<T, U>>(self: T, other: T) -> U {
self.pack_generic(other)
}
fn main() {
let a: u32 = 1;
let b: u32 = 1;
let x = foo(a, b);
let y = bar(a, b);
// result is 2^32 + 1
println!("x: {}", x);
println!("y: {}", y);
}
With associated types, we can define bar
more concisely:
trait Pack<T> {
type Result;
fn pack(self: T, other: T) -> Self::Result;
}
impl PackU32Impl of Pack<u32> {
type Result = u64;
fn pack(self: u32, other: u32) -> Self::Result {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn bar<T, impl PackImpl: Pack<T>>(self: T, b: T) -> PackImpl::Result {
PackImpl::pack(self, b)
}
trait PackGeneric<T, U> {
fn pack_generic(self: T, other: T) -> U;
}
impl PackGenericU32 of PackGeneric<u32, u64> {
fn pack_generic(self: u32, other: u32) -> u64 {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn foo<T, U, +PackGeneric<T, U>>(self: T, other: T) -> U {
self.pack_generic(other)
}
fn main() {
let a: u32 = 1;
let b: u32 = 1;
let x = foo(a, b);
let y = bar(a, b);
// result is 2^32 + 1
println!("x: {}", x);
println!("y: {}", y);
}
Finally, let's see both approaches in action, demonstrating that the end result is the same:
trait Pack<T> {
type Result;
fn pack(self: T, other: T) -> Self::Result;
}
impl PackU32Impl of Pack<u32> {
type Result = u64;
fn pack(self: u32, other: u32) -> Self::Result {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn bar<T, impl PackImpl: Pack<T>>(self: T, b: T) -> PackImpl::Result {
PackImpl::pack(self, b)
}
trait PackGeneric<T, U> {
fn pack_generic(self: T, other: T) -> U;
}
impl PackGenericU32 of PackGeneric<u32, u64> {
fn pack_generic(self: u32, other: u32) -> u64 {
let shift: u64 = 0x100000000; // 2^32
self.into() * shift + other.into()
}
}
fn foo<T, U, +PackGeneric<T, U>>(self: T, other: T) -> U {
self.pack_generic(other)
}
fn main() {
let a: u32 = 1;
let b: u32 = 1;
let x = foo(a, b);
let y = bar(a, b);
// result is 2^32 + 1
println!("x: {}", x);
println!("y: {}", y);
}
As you can see, bar
doesn't need to specify a second generic type for the packing result. This information is hidden in the implementation of the Pack
trait, making the function signature cleaner and more flexible. Associated types allow us to express the same functionality with less verbosity, while still maintaining the flexibility of generic programming.
Associated Constants
Associated constants are constants associated with a type. They are declared using the const
keyword in a trait and defined in its implementation.
In our next example, we define a generic Shape
trait that we implement for a Triangle
and a Square
. This trait includes an associated constant, defining the number of sides of the type that implements the trait.
trait Shape<T> {
const SIDES: u32;
fn describe() -> ByteArray;
}
struct Triangle {}
impl TriangleShape of Shape<Triangle> {
const SIDES: u32 = 3;
fn describe() -> ByteArray {
"I am a triangle."
}
}
struct Square {}
impl SquareShape of Shape<Square> {
const SIDES: u32 = 4;
fn describe() -> ByteArray {
"I am a square."
}
}
fn print_shape_info<T, impl ShapeImpl: Shape<T>>() {
println!("I have {} sides. {}", ShapeImpl::SIDES, ShapeImpl::describe());
}
fn main() {
print_shape_info::<Triangle>();
print_shape_info::<Square>();
}
After that, we create a print_shape_info
generic function, which requires that the generic argument implements the Shape
trait. This function will use the associated constant to retrieve the number of sides of the geometric figure, and print it along with its description.
trait Shape<T> {
const SIDES: u32;
fn describe() -> ByteArray;
}
struct Triangle {}
impl TriangleShape of Shape<Triangle> {
const SIDES: u32 = 3;
fn describe() -> ByteArray {
"I am a triangle."
}
}
struct Square {}
impl SquareShape of Shape<Square> {
const SIDES: u32 = 4;
fn describe() -> ByteArray {
"I am a square."
}
}
fn print_shape_info<T, impl ShapeImpl: Shape<T>>() {
println!("I have {} sides. {}", ShapeImpl::SIDES, ShapeImpl::describe());
}
fn main() {
print_shape_info::<Triangle>();
print_shape_info::<Square>();
}
Associated constants allow us to bind a constant number to the Shape
trait rather than adding it to the struct or just hardcoding the value in the implementation. This approach provides several benefits:
- It keeps the constant closely tied to the trait, improving code organization.
- It allows for compile-time checks to ensure all implementors define the required constant.
- It ensures two instances of the same type have the same number of sides.
Associated constants can also be used for type-specific behavior or configuration, making them a versatile tool in trait design.
We can ultimately run the print_shape_info
and see the output for both Triangle
and Square
:
trait Shape<T> {
const SIDES: u32;
fn describe() -> ByteArray;
}
struct Triangle {}
impl TriangleShape of Shape<Triangle> {
const SIDES: u32 = 3;
fn describe() -> ByteArray {
"I am a triangle."
}
}
struct Square {}
impl SquareShape of Shape<Square> {
const SIDES: u32 = 4;
fn describe() -> ByteArray {
"I am a square."
}
}
fn print_shape_info<T, impl ShapeImpl: Shape<T>>() {
println!("I have {} sides. {}", ShapeImpl::SIDES, ShapeImpl::describe());
}
fn main() {
print_shape_info::<Triangle>();
print_shape_info::<Square>();
}
Associated Implementations
Associated implementations allow you to declare that a trait implementation must exist for an associated type. This feature is particularly useful when you want to enforce relationships between types and implementations at the trait level. It ensures type safety and consistency across different implementations of a trait, which is important in generic programming contexts.
To understand the utility of associated implementations, let's examine the Iterator
and IntoIterator
traits from the Cairo core library, with their respective implementations using ArrayIter<T>
as the collection type:
// Collection type that contains a simple array
#[derive(Drop)]
pub struct ArrayIter<T> {
array: Array<T>,
}
// T is the collection type
pub trait Iterator<T> {
type Item;
fn next(ref self: T) -> Option<Self::Item>;
}
impl ArrayIterator<T> of Iterator<ArrayIter<T>> {
type Item = T;
fn next(ref self: ArrayIter<T>) -> Option<T> {
self.array.pop_front()
}
}
/// Turns a collection of values into an iterator
pub trait IntoIterator<T> {
/// The iterator type that will be created
type IntoIter;
impl Iterator: Iterator<Self::IntoIter>;
fn into_iter(self: T) -> Self::IntoIter;
}
impl ArrayIntoIterator<T> of IntoIterator<Array<T>> {
type IntoIter = ArrayIter<T>;
fn into_iter(self: Array<T>) -> ArrayIter<T> {
ArrayIter { array: self }
}
}
fn main() {
let mut arr: Array<felt252> = array![1, 2, 3];
// Converts the array into an iterator
let mut iter = arr.into_iter();
// Uses the iterator to print each element
loop {
match iter.next() {
Option::Some(item) => println!("Item: {}", item),
Option::None => { break; },
};
}
}
- The
IntoIterator
trait is designed to convert a collection into an iterator. - The
IntoIter
associated type represents the specific iterator type that will be created. This allows different collections to define their own efficient iterator types. - The associated implementation
Iterator: Iterator<Self::IntoIter>
(the key feature we're discussing) declares that thisIntoIter
type must implement theIterator
trait. - This design allows for type-safe iteration without needing to specify the iterator type explicitly every time, improving code ergonomics.
The associated implementation creates a binding at the trait level, guaranteeing that:
- The
into_iter
method will always return a type that implementsIterator
. - This relationship is enforced for all implementations of
IntoIterator
, not just on a case-by-case basis.
The following main
function demonstrates how this works in practice for an Array<felt252>
:
// Collection type that contains a simple array
#[derive(Drop)]
pub struct ArrayIter<T> {
array: Array<T>,
}
// T is the collection type
pub trait Iterator<T> {
type Item;
fn next(ref self: T) -> Option<Self::Item>;
}
impl ArrayIterator<T> of Iterator<ArrayIter<T>> {
type Item = T;
fn next(ref self: ArrayIter<T>) -> Option<T> {
self.array.pop_front()
}
}
/// Turns a collection of values into an iterator
pub trait IntoIterator<T> {
/// The iterator type that will be created
type IntoIter;
impl Iterator: Iterator<Self::IntoIter>;
fn into_iter(self: T) -> Self::IntoIter;
}
impl ArrayIntoIterator<T> of IntoIterator<Array<T>> {
type IntoIter = ArrayIter<T>;
fn into_iter(self: Array<T>) -> ArrayIter<T> {
ArrayIter { array: self }
}
}
fn main() {
let mut arr: Array<felt252> = array![1, 2, 3];
// Converts the array into an iterator
let mut iter = arr.into_iter();
// Uses the iterator to print each element
loop {
match iter.next() {
Option::Some(item) => println!("Item: {}", item),
Option::None => { break; },
};
}
}