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)
}
#[executable]
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)
}
#[executable]
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)
}
#[executable]
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)
}
#[executable]
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)
}
#[executable]
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)
}
#[executable]
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());
}
#[executable]
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());
}
#[executable]
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());
}
#[executable]
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 }
}
}
- The
IntoIteratortrait is designed to convert a collection into an iterator. - The
IntoIterassociated 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 thisIntoItertype must implement theIteratortrait. - 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_itermethod 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>:
#[executable]
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
while let Some(item) = iter.next() {
println!("Item: {}", item);
}
}