Generics
Generics are a way to define a type or function that can work with any type. This is useful when you want to write a function which can be used with different types, or when you want to define a type that can hold any other type. Generics are the foundation of many advanced features in Move, such as collections, abstract implementations, and more.
In the Standard Library
An example of a generic type is the vector type, which is a container type that can hold any other type. Another example of a generic type in the standard library is the Option type, which is used to represent a value that may or may not be present.
Generic Syntax
To define a generic type or function, a type signature needs to have a list of generic parameters
enclosed in angle brackets (<
and >
). The generic parameters are separated by commas.
/// Container for any type `T`.
public struct Container<T> has drop {
value: T,
}
/// Function that creates a new `Container` with a generic value `T`.
public fun new<T>(value: T): Container<T> {
Container { value }
}
In the example above, Container
is a generic type with a single type parameter T
, the value
field of the container stores the T
. The new
function is a generic function with a single type
parameter T
, and it returns a Container
with the given value. Generic types must be initialed
with a concrete type, and generic functions must be called with a concrete type.
#[test]
fun test_container() {
// these three lines are equivalent
let container: Container<u8> = new(10); // type inference
let container = new<u8>(10); // create a new `Container` with a `u8` value
let container = new(10u8);
assert!(container.value == 10, 0x0);
// Value can be ignored only if it has the `drop` ability.
let Container { value: _ } = container;
}
In the test function test_generic
we demonstrate three equivalent ways to create a new Container
with a u8
value. Because numeric types need to be inferred, we specify the type of the number
literal.
Multiple Type Parameters
You can define a type or function with multiple type parameters. The type parameters are then separated by commas.
/// A pair of values of any type `T` and `U`.
public struct Pair<T, U> {
first: T,
second: U,
}
/// Function that creates a new `Pair` with two generic values `T` and `U`.
public fun new_pair<T, U>(first: T, second: U): Pair<T, U> {
Pair { first, second }
}
In the example above, Pair
is a generic type with two type parameters T
and U
, and the
new_pair
function is a generic function with two type parameters T
and U
. The function returns
a Pair
with the given values. The order of the type parameters is important, and it should match
the order of the type parameters in the type signature.
#[test]
fun test_generic() {
// these three lines are equivalent
let pair_1: Pair<u8, bool> = new_pair(10, true); // type inference
let pair_2 = new_pair<u8, bool>(10, true); // create a new `Pair` with a `u8` and `bool` values
let pair_3 = new_pair(10u8, true);
assert!(pair_1.first == 10, 0x0);
assert!(pair_1.second, 0x0);
// Unpacking is identical.
let Pair { first: _, second: _ } = pair_1;
let Pair { first: _, second: _ } = pair_2;
let Pair { first: _, second: _ } = pair_3;
}
If we added another instance where we swapped type parameters in the new_pair
function, and tried
to compare two types, we'd see that the type signatures are different, and cannot be compared.
#[test]
fun test_swap_type_params() {
let pair1: Pair<u8, bool> = new_pair(10u8, true);
let pair2: Pair<bool, u8> = new_pair(true, 10u8);
// this line will not compile
// assert!(pair1 == pair2, 0x0);
let Pair { first: pf1, second: ps1 } = pair1; // first1: u8, second1: bool
let Pair { first: pf2, second: ps2 } = pair2; // first2: bool, second2: u8
assert!(pf1 == ps2); // 10 == 10
assert!(ps1 == pf2); // true == true
}
Types for variables pair1
and pair2
are different, and the comparison will not compile.
Why Generics?
In the examples above we focused on instantiating generic types and calling generic functions to create instances of these types. However, the real power of generics is the ability to define shared behavior for the base, generic type, and then use it independently of the concrete types. This is especially useful when working with collections, abstract implementations, and other advanced features in Move.
/// A user record with name, age, and some generic metadata
public struct User<T> {
name: String,
age: u8,
/// Varies depending on application.
metadata: T,
}
In the example above, User
is a generic type with a single type parameter T
, with shared fields
name
and age
, and the generic metadata
field which can store any type. No matter what the
metadata
is, all of the instances of User
will have the same fields and methods.
/// Updates the name of the user.
public fun update_name<T>(user: &mut User<T>, name: String) {
user.name = name;
}
/// Updates the age of the user.
public fun update_age<T>(user: &mut User<T>, age: u8) {
user.age = age;
}
Phantom Type Parameters
In some cases, you may want to define a generic type with a type parameter that is not used in the fields or methods of the type. This is called a phantom type parameter. Phantom type parameters are useful when you want to define a type that can hold any other type, but you want to enforce some constraints on the type parameter.
/// A generic type with a phantom type parameter.
public struct Coin<phantom T> {
value: u64
}
The Coin
type here does not contain any fields or methods that use the type parameter T
. It is
used to differentiate between different types of coins, and to enforce some constraints on the type
parameter T
.
public struct USD {}
public struct EUR {}
#[test]
fun test_phantom_type() {
let coin1: Coin<USD> = Coin { value: 10 };
let coin2: Coin<EUR> = Coin { value: 20 };
// Unpacking is identical because the phantom type parameter is not used.
let Coin { value: _ } = coin1;
let Coin { value: _ } = coin2;
}
In the example above, we demonstrate how to create two different instances of Coin
with different
phantom type parameters USD
and EUR
. The type parameter T
is not used in the fields or methods
of the Coin
type, but it is used to differentiate between different types of coins. It will make
sure that the USD
and EUR
coins are not mixed up.
Constraints on Type Parameters
Type parameters can be constrained to have certain abilities. This is useful when you need the inner
type to allow certain behavior, such as copy or drop. The syntax for constraining a type
parameter is T: <ability> + <ability>
.
/// A generic type with a type parameter that has the `drop` ability.
public struct Droppable<T: drop> {
value: T,
}
/// A generic struct with a type parameter that has the `copy` and `drop` abilities.
public struct CopyableDroppable<T: copy + drop> {
value: T, // T must have the `copy` and `drop` abilities
}
Move Compiler will enforce that the type parameter T
has the specified abilities. If the type
parameter does not have the specified abilities, the code will not compile.
/// Type without any abilities.
public struct NoAbilities {}
#[test]
fun test_constraints() {
// Fails - `NoAbilities` does not have the `drop` ability
// let droppable = Droppable<NoAbilities> { value: 10 };
// Fails - `NoAbilities` does not have the `copy` and `drop` abilities
// let copyable_droppable = CopyableDroppable<NoAbilities> { value: 10 };
}
Further Reading
- Generics in the Move Reference.