Generics
Let's ask ChatGPT how it would explain generic types to a beginner:
Q: How would you explain generic typing to a beginner?
ChatGPT: Generics are a way of creating flexible and reusable code in programming. They allow you to write functions, classes, and data structures that can work with different types of data without specifying the type ahead of time.
In our own words, generic types allow us to write code that generalizes over many possible types. In fact, that is where they get their name from.
Type parameters and type arguments
In Motoko, custom types, functions and classes can specify generic type parameters. Type parameters have names and are declared by adding them in between angle brackets < >
. The angle brackets are declared directly after the name of the type, function or class (before any other parameters).
Type parameters
Here's a custom type alias for a tuple, that has the conventional generic type parameter T
:
type CustomType<T> = (Nat, Int, T);
The type parameter T
is supplied after the custom type name in angle brackets: CustomType<T>
. Then the generic type parameter is used inside the tuple. This indicates that a value of CustomType
will have some type T
in the tuple (alongside known types Nat
and Int
) without knowing ahead of time what type that would be.
The names of generic type parameters are by convention single capital letters like T
and U
or words that start with a capital letter like Ok
and Err
.
Generic parameter type names must start with a letter, may contain lowercase and uppercase letters, and may also contain numbers 0-9
and underscores _
.
Type arguments
Lets use our CustomType
:
let x : CustomType<Bool> = (0, -1, true);
The CustomType
is used to annotate our variable x
. When we actually construct a value of a generic type, we have to specify a specific type for T
as a type argument. In the example, we used Bool
as a type argument. Then we wrote a tuple of values, and used a true
value where a value of type T
was expected.
The same CustomType
could be used again and again with different type arguments for T
:
let y : CustomType<Float> = (100, -100, 0.5);
let z : CustomType<[Nat]> = (100, -100, [7, 6, 5]);
In the last example we used [Nat]
as a type argument. This means we have to supply an immutable array of type [Nat]
for the T
in the tuple.
Generics in type declarations
Generics may be used in type declarations of compound types like objects and variants.
Generic variant
A commonly used generic type is the Result
variant type that is available as a public type in the Base Library in the Result module.
public type Result<Ok, Err> = {
#ok : Ok;
#err : Err;
};
A type alias Result
is declared, which has two type parameters Ok
and Err
. The type alias is used to refer to a variant type with two variants #ok
and #err
. The variants have associated types attached to them. The associated types are of generic type Ok
and Err
.
This means that the variants can take on many different associated types, depending on how we want to use our Result
.
Results are usually used as a return value for functions to provide information about a possible success or failure of the function:
func checkUsername(name : Text) : Result<(), Text> {
let size = name.size();
if (size < 4) #err("Too short!") else if (size > 20) #err("To long!") else #ok();
};
Our function checkUsername
takes a Text
argument and returns a value of type Result<(), Text>
. The unit type ()
and Text
are type arguments.
The function checks the size of the its argument name
. In case name
is shorter than 4 characters, it returns an 'error' by constructing a value for the #err
variant and adding an associated value of type Text
. The return value would in this case be #err("Too short!")
which is a valid value for our Result<(), Text>
variant.
Another failure scenario is when the argument is longer than 20 characters. The function returns #err("To long!")
.
Finally, if the size of the argument is allowed, the function indicates success by constructing a value for the #ok
variant. The associated value is of type ()
giving us #ok()
.
If we use this function, we could switch
on its return value like this:
let result = checkUsername("SamerWeb3");
switch (result) {
case (#ok()) {};
case (#err(error)) {};
};
We pattern match on the possible variants of Result<(), Text>
. In case of the #err
variant, we also bind the associated Text
value to a new name error
so we could use it inside the scope of the #err
case.
Generic object
Here's a type declaration of an object type Obj
that takes three type parameters T
, U
and V
. These type parameters are used as types of some variables of the object.
type Obj<T, U, V> = object {
a : T;
b : T -> U;
var c : V;
d : Bool;
};
The first variable a
is of generic type T
. The second variable b
is a public function in the object with generic arguments and return type T -> U
. The third variable is mutable and is of type V
. And the last variable d
does not use a generic type.
We would use it like this:
let obj : Obj<Nat, Int, Text> = {
a = 0;
b = func(n : Nat) { -1 * n };
var c = "Motoko";
d = false;
};
We declare a variable obj
of type Obj
and use Nat
, Int
and Text
as type arguments. Note that b
is a function that takes a Nat
and returns an Int
.
Generics in functions
Generic types are also found in functions. Functions that allow type parameters are:
- Private and public functions in modules and nested modules
- Private and public functions in objects
- Private and public functions in classes
- Only private functions in actors
NOTE
Public shared functions in actors are not allowed to have generic type arguments.
Some public functions in modules of the Base Library are written with generic type parameters. Lets look the useful init
public function found in the Array
module of the Base Library:
public func init<X>(size : Nat, initValue : X) : [var X]
// Function body is omitted
This function is used to construct a mutable array of a certain size
filled with copies of some initValue
. The function takes one generic type parameter X
. This parameter is used to specify the type of the initial value initValue
.
It may be used like this:
import Array "mo:base/Array";
let t = Array.init<Bool>(3, true);
We import the Array.mo
module and name it Array
. We access the function with Array.init
. We supply type arguments into the angle brackets, in our case <Bool>
. The second argument in the function (initValue
) is now of type Bool
.
A mutable array will be constructed with 3
mutable elements of value true
. The value of t
would therefore be
[var true, true, true]
Generics in classes
Generics may be used in classes to construct objects with generic types.
Lets use the Array.init
function again, this time in a class:
import Array "mo:base/Array";
class MyClass<T>(n : Nat, initVal : T) {
public let array : [var T] = Array.init<T>(n, initVal);
};
The class MyClass
takes one generic type parameter T
, which is used three times in the class declaration.
-
The class takes two arguments:
n
of typeNat
andinitVal
of generic typeT
. -
The public variable
array
is annotated with[var T]
, which is the type returned from theArray.init
function. -
The function
Array.init
is used inside the class and takesT
as a type parameter.
Recall that a class is just a constructor for objects, so lets make an object with our class.
let myObject = MyClass<Bool>(2, true);
// myClass.array now has value [true, true]
myObject.array[0] := false;
We construct an object myObject
by calling our class with a type argument Bool
and two argument values. The second argument must be of type Bool
, because that's what we specified as the type argument for T
and initVal
is of type T
.
We now have an object with a public variable array
of type [var Bool]
. So we can reference an element of that array with the angle bracket notation myObject.array[]
and mutate it.