Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Traits and extensions

Pepper has no inheritance. Instead, you add behavior to a type with an extension, and describe a set of behavior that several types share with a trait. Extensions can be written anywhere, so you can add methods to a library type, such as String, from your own code.

A trait is also a type: a value of type Shape holds any struct, or enum whose cases carry values, that has the Shape trait, and calls its methods (see Traits as types).

Extensions

An extend block adds methods to a struct declared elsewhere, whether in the same file, another file, or the standard library:

struct Temperature {
    var celsius: float = 0.0;
    construct(this.celsius = 0.0) {}
}

# `extend` adds methods to a struct declared elsewhere.
extend Temperature {
    fahrenheit(): float {
        return celsius * 9.0 / 5.0 + 32.0;
    }

    static freezing(): Temperature {
        return Temperature(0.0);
    }

    bool ==(Temperature other) {
        return celsius == other.celsius;
    }
}

# Library types can be extended too.
extend String {
    exclaim(): String {
        var s = String();
        s.append(this);
        s.append("!");
        return s;
    }
}

# One specialization of a generic library type can be extended, and its
# methods can loop over `this`.
extend Array<int> {
    total(): int {
        var sum = 0;
        for (var x in this) {
            sum += x;
        }
        return sum;
    }
}

struct Box<T> {
    var value: T;
}

# Extending the generic struct adds the method to every Box<...>.
extend Box<T> {
    get(): T {
        return value;
    }
}

main(): int {
    var t = Temperature(100.0);
    assert(t.fahrenheit() == 212.0, "extension method");
    assert(Temperature.freezing() == Temperature(0.0), "static method and operator from an extension");

    assert("hello".exclaim() == "hello!", "String extension");

    var numbers = Array<int>();
    numbers.append(2);
    numbers.append(3);
    assert(numbers.total() == 5, "Array<int> extension loops over this");

    var b = Box<int>();
    b.value = 5;
    assert(b.get() == 5, "Box<int>");
    var s = Box<String>();
    s.value = "five";
    assert(s.get() == "five", "Box<String>");
    return 0;
}
  • An extension can hold anything a struct body can hold except fields, a constructor, and a destructor: methods, static methods, operators, and indexers.
  • Methods in an extension behave exactly like methods declared in the struct. They can use this and name the struct’s fields directly, and they are called the same way.
  • A type can have any number of extensions.
  • An extension method may share its name with the struct’s methods if its parameter types differ: it joins their overload set.
  • An extension in another file is usable only where that file (or its module) is imported, and only if it is exported; the standard library’s extensions are usable everywhere. See Visibility.

Extending generic structs

extend Box<T>, using the struct’s own type parameter name, adds the methods to every specialization: Box<int>, Box<String>, and so on. The example above does this.

When Box constrains its parameter (struct Box<T: Shape>), the extension keeps the constraint without restating it; extend Box<T: Shape> may restate it as documentation (see constraints). A constraint the struct does not have limits the extension to the specializations that meet it: extend Box<T: Named> { ... } adds its methods to Box<Square> but not to Box<String>, and extend Box<T: Shape> with Shape makes a Box of shapes a shape (see constrained extensions).

extend Box<int> targets one specialization: its methods exist only on Box<int>, and calling one on a Box<String> is an error (no member named 'doubled' in 'Box<String>').

A library type’s specialization can be extended the same way. The example’s extend Array<int> adds total(), which loops over this with for.

An extension cannot add fields; declare every field in the struct itself.

Traits

A trait names a set of methods. Declare one with trait and method signatures ending in ;. Then declare that a type has the trait with extend Type with Trait, and provide the methods in that extension:

# A trait names a set of methods that a type provides.
trait Shape {
    area(): int;
    name(): String;

    # A method with a body is a default: every type with the trait gets it.
    describe(): String {
        return "a " + name();
    }
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

# `extend ... with` declares that Square has the Shape trait, and adds the
# trait's methods to it.
extend Square with Shape {
    area(): int {
        return side * side;
    }

    name(): String {
        return "square";
    }
}

# A trait can be generic, and can declare static methods; `this` as a type
# stands for the type that has the trait.
trait From<T> {
    static this from(T value);
}

extend Square with From<int> {
    static Square from(int side) {
        return Square(side);
    }
}

# A trait whose methods all have defaults needs no body: end the extension
# with `;`, the same as writing an empty `{}`.
trait Labeled {
    label(): String {
        return "shape";
    }
}

extend Square with Labeled;

main(): int {
    var sq = Square(3);
    assert(sq.area() == 9, "trait method called on the struct");
    assert(sq.name() == "square", "second trait method");
    assert(sq.describe() == "a square", "default method from the trait");
    assert(Square.from(4).area() == 16, "static method from a generic trait");
    assert(sq.label() == "shape", "default method from a bodiless extension");
    return 0;
}
  • A trait can have type parameters, like From<T>.
  • A trait can declare static methods. In a trait, this used as a type stands for the type that has the trait.
  • extend Square with Shape, Printable { ... } attaches several traits at once.
  • When the type already has every method the traits need, from its own methods or the traits’ defaults, the extension can end with ; instead of a body: extend Square with Labeled; is the same as extend Square with Labeled {}. Only an extension with a with clause can leave out its body; pep-fmt writes an empty one as ;.
  • A trait can declare several methods of one name with different parameter types; the type implements each with the method whose parameter types match (see Overloading).
  • The methods in the extension are ordinary methods of the struct, so you call them on a Square as usual.
  • A generic parameter can require a trait, total_area<T: Shape>, so that only types declared to have it are accepted (see constraints).
  • The runtime declares traits too. ToString (to_string(): String) makes a type usable in an interpolated string.

The compiler checks that the type has every method the trait declares, either in the extension or in the struct itself. A missing method is an error at the extension, with a note at the trait’s method:

main.pep:9:8: error: 'Square' does not implement 'area', required by trait 'Shape'
    9 | extend Square with Shape {
      |        ^~~~~~
main.pep:2:5: note: 'area' declared here
    2 |     area(): int;
      |     ^~~~

A method that implements a trait should name its parameters as the trait does, since a call through a value of the trait type names its arguments with the trait’s names. A different name is a warning, whose fix renames the parameter and its uses:

main.pep:8:10: warning: parameter 'factor' of 'area' is named 'scale' in trait 'Shape'
    8 |     area(factor: int): int { return side * side * factor; }
      |          ^~~~~~
main.pep:2:5: note: 'area' declared here
    2 |     area(scale: int): int;
      |     ^~~~

Default method bodies

A trait method can have a body. A type that has the trait and does not define that method gets the trait’s body as its own method; a type that defines the method keeps its own. Inside a default body, the trait’s type parameters and this stand for the types named in the with clause:

trait Shape {
    area(): int;
    sides(): int { return 4; }
}

extend Square with Shape {
    area(): int { return side * side; }
}

Here Square(3).sides() returns 4.

A default method can have type parameters of its own (see Generic methods). The runtime’s Iterator<T> uses this for its map<U> and filter adapters, which every type that declares Iterator<T> gets.

A default body sees only what its trait declares: a bare call to one of the trait’s methods calls it on this, and any other bare name means what it means where the trait is declared (a top-level function or variable), even when a conforming type happens to have a method or field of that name. Call through this explicitly only for the trait’s own methods.

A closure in a default body can use this and the trait’s methods too. Each type that gets the default gets its own copy of the closure, which captures this as that type, as a closure in one of its own methods would (see this in methods):

# A closure in a trait's default method can use `this` and the trait's
# methods.
trait Shape {
    area(): int;

    # Every type with Shape gets this method, and its own copy of the
    # closure, which captures `this`.
    scaled_area(): (int) => int {
        return (factor: int) => area() * factor;
    }
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int {
        return side * side;
    }
}

main(): int {
    var square = Square(3);
    var scale = square.scaled_area();
    assert(scale(2) == 18, "closure in a default method");

    # The closure keeps the square alive and sees its current side.
    square.side = 4;
    assert(scale(2) == 32, "sees the current value");
    return 0;
}

Numbers, bool and enums

A number type (int, long, byte, char, float, double and the rest), bool, or an enum whose cases carry no values can declare a trait too, with an extension written the same way. Inside its methods, this is the value:

trait Describe {
    describe(): String;
    # A default method works for value types too.
    shout(): String { return describe() + "!"; }
}

trait Zero {
    static zero(): this;
}

# A number type conforms like a struct; `this` is the value.
extend int with Describe {
    describe(): String { return this < 0 ? "negative" : "non-negative"; }
}

extend int with Zero {
    static zero(): int { return 0; }
}

enum Light {
    Red,
    Green,
}

# An enum whose cases carry no values can conform too.
extend Light with Describe {
    describe(): String { return this == Light.Red ? "stop" : "go"; }
}

# Each call is resolved for the type argument: no trait value is made.
describe_all<T: Describe>(items: Array<T>): String {
    var text = "";
    for (var item in items) {
        text = text + item.describe() + " ";
    }
    return text;
}

zero_of<T: Zero>(): T { return T.zero(); }

main(): int {
    var numbers = Array<int>();
    numbers.append(-1);
    numbers.append(2);
    assert(describe_all(numbers) == "negative non-negative ", "numbers");
    assert(Light.Green.shout() == "go!", "enum");
    assert((5).describe() == "non-negative", "a call on a value");
    assert(zero_of<int>() == 0, "a static member through T");
    return 0;
}
  • The type then meets the constraint T: Describe (see constraints), and its methods can be called on a value (x.describe()) or, for a static one, on the type (int.zero(), or T.zero() in a generic). Each call goes straight to the method: there is no object and no vtable.

  • It is never a trait value. A number or such an enum cannot be stored in a Describe variable, passed as one, or added to an Array<Describe>:

    main.pep:7:15: error: 'int' cannot be converted to trait 'Doubled': only structs and enums whose cases carry values can be trait values
        7 | main(): int { Doubled d = 3; return 0; }
          |               ^~~~~~~
    main.pep:4:8: note: 'int' conforms to 'Doubled' here, which makes it meet the constraint, not a trait value
        4 | extend int with Doubled {
          |        ^~~
    
  • An extension of a number type may also add methods without a trait (extend int { ... }), and operators for a number on the left of another type (extend double { *(v: Vector): Vector { ... } } makes 2.0 * v work; see A number on the left). An enum declares its methods inside the enum, so an extension of one must name a trait.

  • The spellings of one type are one type: extend int is also extend i32.

Traits as types

A trait’s name is also a type. A variable, parameter, field, return value, or collection element of type Shape holds any struct, or enum whose cases carry values, that declares Shape, and calling a method on it runs that type’s own method:

# A trait used as a type holds any struct that declares the trait.
trait Shape {
    area(): int;
    name(): String;
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int { return side * side; }
    name(): String { return "square"; }
}

struct Rect {
    var width: int = 1;
    var height: int = 1;
    construct(this.width = 1, this.height = 1) {}
}

extend Rect with Shape {
    area(): int { return width * height; }
    name(): String { return "rect"; }
}

# One function accepts every Shape; each call runs the value's own method.
measure(shape: Shape): int {
    return shape.area();
}

main(): int {
    # A Square converts to a Shape wherever a Shape is expected.
    var s: Shape = Square(3);
    assert(measure(s) == 9, "square through a trait parameter");
    assert(s.name() == "square", "method of the concrete type");

    # Collections can hold different types that share a trait.
    var shapes = Array<Shape>();
    shapes.append(Square(2));
    shapes.append(Rect(2, 5));
    var total = 0;
    for (var shape in shapes) {
        total = total + shape.area();
    }
    assert(total == 14, "sum of areas");

    # Two trait values are equal when they refer to the same object.
    var same: Shape = s;
    assert(same == s, "same object");
    return 0;
}
  • Conversion is implicit. A Square becomes a Shape wherever a Shape is expected: an initializer, an assignment, an argument, a return value.

  • Conformance is declared. Only a type that declares the trait with extend Type with Trait converts. Having the right methods is not enough:

    main.pep:11:20: error: 'Circle' does not conform to trait 'Shape'
       11 |     var s: Shape = Circle();
          |                    ^~~~~~
    main.pep:6:8: note: declare it with 'extend Circle with Shape'
        6 | struct Circle {
          |        ^~~~~~
    
  • A trait value is a shared reference to the struct, like any other struct value (see Memory). When the last reference goes away, whether it was a Shape or a Square, the struct’s own destructor runs.

  • Only instance methods are callable through the trait. Static methods, generic methods, and methods whose signature uses this as a type, need the concrete type; calling one through a trait value is an error.

  • Methods must match the trait’s signature. An implementation with different parameter or return types is an error. The one exception is a trait method that returns a trait type: the implementation may return a struct that declares that trait, as iterate() does in Iteration.

  • == and != on trait values compare identity: they are equal when they refer to the same object.

  • copy copies the struct behind a trait value and returns it as the same trait type (see Copying trait values). It panics when that struct cannot be copied.

Converting a trait value back

A trait value remembers the struct it holds, so it converts back to that struct, or to another trait the struct declares. A type pattern names the type, like an enum case, and binds the value as that type:

# A trait value converts back to the struct it holds, or to another trait
# that struct declares, with `is`, `match` and `as?`.
trait Shape {
    area(): int;
}

trait Named {
    name(): String;
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int { return side * side; }
}

extend Square with Named {
    name(): String { return "square"; }
}

struct Rect {
    var width: int = 1;
    var height: int = 1;
    construct(this.width = 1, this.height = 1) {}
}

extend Rect with Shape {
    area(): int { return width * height; }
}

# A type pattern tests for a struct and binds the value as that struct.
describe(shape: Shape): String {
    return match (shape) {
        Square(square) => $"a square of side {square.side}",
        Rect(rect) => $"a {rect.width} by {rect.height} rectangle",
        else => "another shape",
    };
}

main(): int {
    var shape: Shape = Square(3);
    assert(describe(shape) == "a square of side 3", "match on the struct");
    assert(describe(Rect(2, 5)) == "a 2 by 5 rectangle", "another struct");

    # `is` tests without converting; as an `if` condition it can bind.
    assert(shape is Square, "a square");
    assert(!(shape is Rect), "not a rectangle");
    if (shape is Square(square)) {
        assert(square.side == 3, "bound as a Square");
    }

    # `as?` gives an optional: Empty when the value is something else.
    var square = shape as? Square;
    assert(square.value().side == 3, "converted to Square");
    var rect = shape as? Rect;
    assert(rect.is_empty(), "not a Rect");

    # Converting to another trait works when the struct declares both.
    var named = shape as? Named;
    assert(named.value().name() == "square", "a Square is Named");
    var other: Shape = Rect(1, 1);
    assert((other as? Named).is_empty(), "a Rect is not Named");
    return 0;
}
  • value is Square tests whether the value holds a Square. As the whole condition of an if or while, value is Square(square) also binds it, as for enums.

  • match (value) { Square(square) => ..., Rect(rect) => ..., else => ... } picks the arm of the value’s struct. Any struct may declare a trait, so a match expression on a trait value needs an else arm.

  • value as? Square converts the value, giving Square?: Empty when the value holds something else, or is null. as without the ? is an error for a trait value, since the conversion can fail.

  • Another trait works the same way: value as? Named and value is Named succeed when the struct behind the value declares Named too.

  • A generic struct is named with its type arguments: Cell<int>(cell).

  • A value struct’s trait value holds a copy of it (see boxing), and converting back copies it out again.

  • The bound value is another reference to the same object, like any copy (see Memory); a test holds no reference after it.

  • Naming a struct that does not declare the trait is an error, since the value can never hold one:

    main.pep:9:18: error: 'Point' does not implement 'Shape', so a 'Shape' value never holds one
        9 |     if (shape is Point) {
          |                  ^~~~~
    

Weak references and atomics

Weak<Shape> is a weak reference to a trait value: it holds the struct’s weak reference and its vtable, and a with block upgrades it to a Shape while a strong reference is left:

# A weak reference can point at a trait value: it keeps the struct behind
# it without keeping it alive.
trait Shape {
    area(): int;
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int { return side * side; }
}

main(): int {
    var shape: Shape = Square(3);
    var weak = Weak<Shape>(shape);

    # While a strong reference is left, `with` reaches the value as a Shape.
    with (var live = weak) {
        assert(live.area() == 9, "upgraded to a Shape");
    } else {
        assert(false, "still alive");
    }

    # Once the last strong reference goes, the Square is destroyed.
    shape = __null;
    assert(weak.expired(), "destroyed");
    with (var live = weak) {
        assert(false, "expired: the body is skipped");
    }
    return 0;
}

Atomic<Shape> is ruled out. Atomic<T> needs a thread-safe T, and a trait value may hold any struct, thread-safe or not; it is also two pointers wide, more than one atomic instruction swaps on every platform. The error is the one for any type that is not thread-safe:

main.pep:8:32: error: 'Shape' does not conform to 'Shared', required by 'T' in 'Atomic'
    8 |     var current = async.Atomic<Shape>();
      |                                ^~~~~

To share a trait value between threads, keep it in a Mutex<Shape> and swap it with replace, or use Atomic<Square> for one [Shared] struct type.