Generics · Lesson 13.1

Generic type

Source
Declare a struct and a variant that take a type parameter — Pair<T>, Reading<T> — and use them at several types.

The Generic lesson gave a function a type parameter, so one body served int, float64 and char. A type can take a type parameter too. struct Pair<T> is not one struct but a pattern for many: Pair<int32> and Pair<char8[..]> are two different types stamped out of the same declaration. This is how a single definition can describe "two of something" or "a reading of something" without deciding in advance what that something is.

A struct with a type parameter

The parameter goes in angle brackets after the name, and the fields use it like any other type:

// Two values of the same type, whatever that type is.
struct Pair<T> {
    first: T;
    second: T;
}

Both fields are the same T, so a pair is always two values of one type. Each use of Pair picks its own T, and the compiler lays out a separate struct for each one — a pair of int32 holds two numbers, a pair of char8[..] holds two slices.

A type may take several parameters, and they need not agree:

// A type may take several parameters, and they need not agree.
struct Entry<K, V> {
    key: K;
    value: V;
}
WrittenWhat it is
Pair<T>the declaration — a pattern, not yet a type
Pair<int32>a type: two int32 fields
Pair<char8[..]>another type: two text fields
Entry<char8[..], int32>a type with two parameters, filled in order

Making a value: the literal names its type arguments

A struct literal spells out the type arguments after the name:

let point = Pair<int32> { first: 3, second: 4 };
let words = Pair<char8[..]> { first: "left", second: "right" };

The literal does not guess T from its fields. Pair { first: 3, second: 4 } is refused, even though both fields are plainly numbers — and with <int32> written down, 3 and 4 become int32 values, just as a literal beside a typed value does.

A generic function over a generic type

A generic function can take a generic type. Here T is never written at the call: it is read out of the Pair<int32> passed in, the same way Generic inferred it from plain arguments.

func Swapped<T>(pair: Pair<T>) -> Pair<T> {
    return Pair<T> { first: pair.second, second: pair.first };
}

Swapped(point) is Swapped<int32>, and it returns a Pair<int32> with the fields the other way round — 4 3.

A generic variant

Variants take type parameters the same way. A case's payload can be a T:

// A measurement that may be exact, a range, or missing altogether.
variant Reading<T> {
    Exact(T),
    Between(T, T),
    Missing
}

A case with a payload learns T from it, the way a function learns from its arguments. A case without one has nothing to learn from, so the type has to come from somewhere else — an annotation on the variable:

let temperature = Reading::Between(18.5, 21.0);
let floor: Reading<int32> = Reading::Exact(7);
let lost: Reading<int32> = Reading::Missing;
flowchart LR
    c["Reading::…"] --> q{"Does the case<br/>carry a payload?"}
    q -- "yes: Between(18.5, 21.0)" --> p["T comes from the payload:<br/>Reading&lt;float64&gt;"]
    q -- "no: Missing" --> a{"Is there an annotation<br/>or a parameter type?"}
    a -- "yes" --> ok["T comes from it:<br/>Reading&lt;int32&gt;"]
    a -- "no" --> err["error: requires 1 type argument"]

An annotation also wins over the payload: floor is a Reading<int32>, so its 7 is an int32 rather than an int.

Lowest then works for every reading at once. Its match is the one from Variant match, with T standing in for the payload type:

func Lowest<T>(reading: Reading<T>, fallback: T) -> T {
    return match reading {
        .Exact(value) => value,
        .Between(low, _) => low,
        .Missing => fallback
    };
}

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// The Generic lesson gave a function a type parameter. A type can have one too: `struct Pair<T>`
// is not one struct but a pattern for many, and `Pair<int32>` and `Pair<char8[..]>` are two
// different types stamped out of it. Each one is laid out for its own `T`, so a pair of bytes is
// small and a pair of strings is wide.
//
// Variants work the same way. A generic variant's cases can carry a `T`, and that is how one
// declaration describes "a reading of something" without saying what is being read.
import Io::PrintLine;

// Two values of the same type, whatever that type is.
struct Pair<T> {
    first: T;
    second: T;
}

// A type may take several parameters, and they need not agree.
struct Entry<K, V> {
    key: K;
    value: V;
}

// A measurement that may be exact, a range, or missing altogether.
variant Reading<T> {
    Exact(T),
    Between(T, T),
    Missing
}

// A generic function can take a generic type. `T` is inferred from the pair it is given.
func Swapped<T>(pair: Pair<T>) -> Pair<T> {
    return Pair<T> { first: pair.second, second: pair.first };
}

func Lowest<T>(reading: Reading<T>, fallback: T) -> T {
    return match reading {
        .Exact(value) => value,
        .Between(low, _) => low,
        .Missing => fallback
    };
}

func Main() -> int {
    // A struct literal names its type arguments. A literal does not guess `T` from its fields,
    // so `Pair { first: 3, second: 4 }` is rejected: "struct initializer for 'Pair' requires 1
    // type argument, but 0 were provided".
    let point = Pair<int32> { first: 3, second: 4 };
    let words = Pair<char8[..]> { first: "left", second: "right" };
    let flipped = Swapped(point);
    PrintLine("point    {} {}", flipped.first, flipped.second);
    PrintLine("words    {} {}", words.first, words.second);

    let age = Entry<char8[..], int32> { key: "age", value: 42 };
    PrintLine("entry    {} = {}", age.key, age.value);

    // A case with a payload learns `T` from it, the way a generic function learns from its
    // arguments, so this is a `Reading<float64>`. An annotation supplies `T` too: `floor` is a
    // `Reading<int32>`, so its 7 is an `int32`. A case without a payload has nothing to learn
    // from, so the annotation is what tells `Missing` which reading it is.
    let temperature = Reading::Between(18.5, 21.0);
    let floor: Reading<int32> = Reading::Exact(7);
    let lost: Reading<int32> = Reading::Missing;
    PrintLine("lowest   {}", Lowest(temperature, 0.0));
    PrintLine("lowest   {}", Lowest(floor, 0));
    PrintLine("lowest   {}", Lowest(lost, -1));
    return 0;
}

Run it

cd Examples/Generics/GenericType
rux run
point    4 3
words    left right
entry    age = 42
lowest   18.5
lowest   7
lowest   -1

Common mistakes

Leaving the type arguments off a struct literal.
Pair { first: 3, second: 4 } fails with error: struct initializer for 'Pair' requires 1 type argument, but 0 were provided. Write Pair<int32> { … }. The same goes for a type with two parameters: Entry<int32> { … } is refused with requires 2 type arguments, but 1 was provided.
A case with no payload and no annotation.
let lost = Reading::Missing; fails with error: variant case 'Reading::Missing' requires 1 type argument, but 0 were provided. Annotate the variable — let lost: Reading<int32> = Reading::Missing; — or name the type at the case: Reading::Missing<int32>().
Fields of a different type than T.
Once T is fixed, every field declared as T must be that type. Pair<int32> { first: 3, second: "four" } fails with error: field 'second' in initializer for 'Pair' has type 'char8[..]', but its declaration requires 'int32'.

Try it yourself

  1. Make a Pair<bool> and pass it to Swapped. You do not need to change Swapped at all.
  2. Write Highest<T>(reading: Reading<T>, fallback: T) -> T, which returns the upper end of a Between.
  3. Add a case Approximately(T) to Reading. Which function stops compiling, and why?

Learn more