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
thisand 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,
thisused 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 asextend Square with Labeled {}. Only an extension with awithclause can leave out its body;pep-fmtwrites 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
Squareas 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(), orT.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
Describevariable, passed as one, or added to anArray<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 { ... } }makes2.0 * vwork; 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 intis alsoextend 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
Squarebecomes aShapewherever aShapeis expected: an initializer, an assignment, an argument, a return value. -
Conformance is declared. Only a type that declares the trait with
extend Type with Traitconverts. 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
Shapeor aSquare, the struct’s own destructor runs. -
Only instance methods are callable through the trait. Static methods, generic methods, and methods whose signature uses
thisas 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. -
copycopies 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 Squaretests whether the value holds aSquare. As the whole condition of aniforwhile,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 amatchexpression on a trait value needs anelsearm. -
value as? Squareconverts the value, givingSquare?: Empty when the value holds something else, or is null.aswithout the?is an error for a trait value, since the conversion can fail. -
Another trait works the same way:
value as? Namedandvalue is Namedsucceed when the struct behind the value declaresNamedtoo. -
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.