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

Structs

A struct groups related values into one named type, and gives that type behavior through methods. Pepper has no classes and no inheritance: a struct holds data, methods and extensions add behavior, and traits describe what a type can do.

Every struct value is a reference to an object, so assigning a struct or passing it to a function shares the object rather than copying it. The memory chapter explains exactly when objects are created and destroyed.

Declaring a struct

Declare a struct with struct, a name, and its fields in braces. Struct names are PascalCase by convention.

# A struct groups related values into one type.
struct Point {
    var x: int = 0;
    var y: int = 0;
}

# Fields can also be declared C-style, with the type first.
struct Counter {
    int count;          # no initializer: starts at zero
    String label = "clicks";
}

main(): int {
    # Without a constructor, `Point()` makes a Point with every field
    # at its initial value.
    var p = Point();
    assert(p.x == 0 && p.y == 0, "fields start at their initializers");

    p.x = 3;
    p.y = 4;
    assert(p.x * p.x + p.y * p.y == 25, "fields can be read and written");

    var c = Counter();
    assert(c.count == 0, "a numeric field without an initializer is zero");
    assert(c.label == "clicks", "a String field can have an initializer");

    # A struct value is a reference: `q` names the same Point as `p`.
    var q = p;
    q.x = 10;
    assert(p.x == 10, "p and q are the same object");
    assert(p === q, "=== tests whether two references name the same object");
    assert(!(p === Point()), "a new Point is a different object");
    return 0;
}

Things to notice:

  • A field is declared like a variable: var x: int = 0; or, C-style, int x = 0;. The initializer is optional.
  • A field without an initializer starts at its type’s default: zero for numbers, false for bool, and null for a struct type such as String or another struct. Reading a field through a null reference crashes the program, so give struct-typed fields a value before you use them.
  • A struct with no constructor can be created with its name and empty parentheses, Point(). There is no new keyword at the call site.
  • p.x reads a field and p.x = 3 writes it.
  • a === b is true when a and b refer to the same object, and a !== b when they do not. See Comparing structs for ==.

A struct can have a field whose type is a struct declared later in the file, a field of its own type, or fields that lead back to it through other structs (a Team holding a Player that holds the Team). A struct-typed field holds a reference, so a linked list or a tree is just a struct that names itself:

# A field can hold the struct's own type: each Link points at the next one.
struct Link {
    var value: int;
    var next: Link;

    construct(this.value = 0) {}
}

# Two structs can refer to each other, in either declaration order.
struct Team {
    var name: String;
    var lead: Player;

    construct(this.name = "") {}
}

struct Player {
    var name: String;
    # A weak back pointer: the team owns its lead, so the player's link
    # back to the team must not keep the team alive (see the memory chapter).
    var team: Weak<Team>;

    construct(this.name = "") {}
}

sum(first: Link): int {
    var total = 0;
    var link: Link = first;
    while (link !== __null) {
        total = total + link.value;
        link = link.next;
    }
    return total;
}

main(): int {
    var list = Link(1);
    list.next = Link(2);
    list.next.next = Link(3);
    assert(sum(list) == 6, "the list holds 1, 2 and 3");
    assert(list.next.next.next === __null, "the last link has no next");

    var team = Team("Owls");
    team.lead = Player("Ada");
    team.lead.team = Weak<Team>(team);
    with (var t = team.lead.team) {
        assert(t.name == "Owls", "the player reaches its team");
    }
    return 0;
}

Objects that refer to each other in a loop keep each other alive, so make back pointers such as a child’s parent a Weak<T> (see Cycles leak).

Constructors

A constructor sets up a new object. Declare it with construct, followed by its parameters and a body. Callers create the object by calling the struct’s name like a function.

struct Rect {
    var width: int;
    var height: int;
    var area: int;

    # `this.width = 1` declares a parameter that is stored straight into the
    # field `width`; `1` is its default when the caller leaves it out.
    construct(this.width = 1, this.height = 1) {
        area = width * height;
    }
}

struct Temperature {
    var celsius: float;

    # Ordinary parameters work too, written like a function's: `name: Type`.
    construct(fahrenheit: float = 32.0) {
        celsius = (fahrenheit - 32.0) * 5.0 / 9.0;
    }
}

main(): int {
    var r = Rect(3, 4);
    assert(r.width == 3 && r.height == 4, "arguments fill the fields");
    assert(r.area == 12, "the constructor body runs after the fields are set");

    var tall = Rect(2);
    assert(tall.width == 2 && tall.height == 1, "omitted arguments use their defaults");

    var unit = Rect();
    assert(unit.area == 1, "every parameter has a default, so Rect() works");

    var boiling = Temperature(212.0);
    assert(boiling.celsius == 100.0, "212 F is 100 C");
    assert(Temperature().celsius == 0.0, "the default is 32 F");
    return 0;
}
  • A parameter written this.width = 1 stores its argument directly into the field width, before the body runs. The = 1 is required: it is the value used when the caller omits that argument.
  • Ordinary parameters are written as in a function, name: Type, with an optional default: fahrenheit: float = 32.0. The C-style form, float fahrenheit = 32.0, is accepted too.
  • Inside the body, fields are in scope by name. Use this.name when a parameter has the same name as a field.
  • A struct may declare several constructors with different parameter lists; a call picks one by its arguments, as for any overloaded function. At most one of them may be [Implicit]. A struct has at most one destructor.

A this.field parameter must have a default. Leaving it out is an error:

struct Point {
    var x: int;
    construct(this.x) {}
}
main.pep:3:21: error: expected '='
    3 |     construct(this.x) {}
      |                     ^

A struct without a constructor accepts no arguments:

main.pep:7:13: error: too many arguments to 'Point': expected 0, have 2
    7 |     var p = Point(1, 2);
      |             ^~~~~

Note: new is the older, deprecated spelling of construct. It still compiles, with a warning that offers the fix (see Deprecations).

Implicit conversions

A constructor marked [Implicit] also converts. Wherever a value flows into a place whose type is the struct, and the value has the constructor’s parameter type, the compiler calls the constructor for you:

struct Celsius {
    var degrees: double;

    # `[Implicit]` lets a `double` become a `Celsius` wherever one is expected.
    [Implicit]
    construct(double degrees) {
        this.degrees = degrees;
    }
}

struct Label {
    var text: String;

    # Without `[Implicit]`, callers write `Label("...")` themselves.
    construct(String text) {
        this.text = text;
    }
}

describe(t: Celsius): String {
    return (t.degrees > 30.0) ? "hot" : "mild";
}

main(): int {
    assert(describe(35.0) == "hot", "an argument converts");
    var today: Celsius = 21.5;            # a typed variable converts
    assert(describe(today) == "mild", "already a Celsius");

    var label = Label("kitchen");         # no conversion: call the constructor
    assert(label.text == "kitchen", "explicit constructor call");

    var n = 12.0;                         # no slot type: stays a double
    assert(n == 12.0, "untyped local");
    return 0;
}

The standard library uses this for io.Path, which converts from a String, so dir.join("logs") and io.File.open("notes.txt") work without writing io.Path(...), and for Error, so Result<int>.err("bad input") works.

  • Where it applies: a variable’s initializer when the variable’s type is written, an assignment, a function, method or constructor argument (operators and a[i] = value included), a call through a function value, and a return from a function with a declared return type.
  • Where it does not: the receiver of a method call ("docs".join("x") does not turn "docs" into a Path), a var whose type is inferred (var n = 12.0; stays a double), and __null, which stays a null reference.
  • One conversion per value. The value must already have the constructor’s parameter type, or reach it by a widening that loses nothing: a smaller integer to a larger one, an integer literal that fits, float to double, or a struct to a trait it declares. Conversions never chain: if Tag converts from Name and Name from String, a String does not become a Tag.
  • Overloads prefer anything else. Among overloads, a member that takes the value as it is, or by any other conversion, beats one that needs an [Implicit] constructor.
  • Each conversion is a constructor call, so it allocates a new object. The VS Code extension shows the call as an inlay hint around the converted value, and hovering the value names the constructor. The converted value is a temporary like any constructor call’s result: it is released after the call it was passed to (see Memory and reference counting).
  • Only a constructor that can be called with one argument may be [Implicit]: one parameter, or a first parameter followed by parameters that all have defaults. It may not convert from the struct itself or, in a generic struct, from a bare type parameter (construct(T value)):
main.pep:2:5: error: 'Implicit' constructor of 'Span' must be callable with one argument
    2 |     [Implicit]
      |     ^~~~~~~~~~

Conversions are opt-in: a constructor without [Implicit] never runs on its own. When a value would fit such a constructor, the type error says so:

main.pep:6:27: error: cannot pass a value of type 'String' as argument 1 of 'show', which has type 'Label'
    6 | main(): int { return show("x"); }
      |                           ^~~
main.pep:3:5: note: 'Label' has a constructor from 'String': call 'Label(...)', or mark the constructor [Implicit]
    3 |     construct(String text) { this.text = text; }
      |     ^~~~~~~~~

Destructors

A destructor runs when an object is destroyed, which happens when the last reference to it goes away. Declare it with destruct and a body, with no parentheses. Use it to release something the object holds that Pepper does not manage for you, such as a file handle from C.

import async;

# A module-scope counter: const, and changed through an Atomic.
const closed = async.Atomic<int>(0);

struct Connection {
    var name: String;

    construct(this.name = "") {}

    # The destructor runs once, when the last reference to the object goes
    # away. Use it to release whatever the object holds.
    destruct {
        closed.fetch_add(1);
    }
}

main(): int {
    {
        var c = Connection("db");
        assert(closed.load() == 0, "still in use");
    }
    # `c` went out of scope at the closing brace.
    assert(closed.load() == 1, "the destructor ran at the end of the block");
    return 0;
}

You never call a destructor yourself. After it runs, Pepper releases the object’s fields, so a destructor can still read them. The memory chapter lists every point where an object can be destroyed.

Methods

A method is a function declared inside a struct. You call it on an object with a dot, v.length_squared(), and inside it this refers to that object.

struct Vector {
    var x: int = 0;
    var y: int = 0;

    construct(this.x = 0, this.y = 0) {}

    # A method: name, parameters, then the return type after `:`.
    length_squared(): int {
        # Fields can be named directly inside a method...
        return x * x + y * y;
    }

    # ...or through `this`, the object the method was called on.
    dot(other: Vector): int {
        return this.x * other.x + this.y * other.y;
    }

    # Methods can change fields, and can call other methods.
    scale(factor: int) {
        x = x * factor;
        y = y * factor;
    }

    # The return type can also come first, C-style.
    bool is_zero() {
        return length_squared() == 0;
    }

    # A static method belongs to the type, not to one object: there is
    # no `this`. Call it on the type name.
    static unit_x(): Vector {
        return Vector(1, 0);
    }
}

main(): int {
    var v = Vector(3, 4);
    assert(v.length_squared() == 25, "method call");
    assert(v.dot(Vector(1, 1)) == 7, "method with an argument");

    v.scale(2);
    assert(v.x == 6 && v.y == 8, "a method can modify its object");

    assert(Vector().is_zero(), "methods can be called on a temporary");
    assert(Vector.unit_x().x == 1, "static method called on the type");
    return 0;
}
  • Methods are written like functions: name(parameters): ReturnType { ... }. Leave out the return type for a method that returns nothing. The C-style form, bool is_zero() { ... }, works too.
  • Inside a method, the object’s fields and other methods can be named directly (x, length_squared()), or through this (this.x). A parameter or local with the same name as a field hides the field; use this. to reach the field.
  • A bare call names the struct’s own method before a top-level function of the same name, unless no method by that name takes those arguments: then it calls the function. So a copy(): Buffer method can call the free function copy(label).
  • A method can change the fields of its object. Because struct values are references, the caller sees the change.
  • Calling a method does not copy the object or add a reference to it. The result of a method call is a new reference, like any function result.
  • Several methods can share a name when their parameters differ; each call picks the best fit (see Overloading).

Static methods

A method marked static belongs to the type rather than to one object. It has no this, and you call it on the type name: Vector.unit_x(). Static methods are the usual way to offer named constructors, such as the standard library’s Result<int>.ok(42).

Inside the struct, any method, static or not, can call a static method by its bare name: unit_x() in a method of Vector means Vector.unit_x().

# Static methods belong to the type; inside it, they are called by name.
struct Vector {
    var x: double = 0.0;
    var y: double = 0.0;
    construct(this.x = 0.0, this.y = 0.0) {}

    static unit_x(): Vector {
        return Vector(1.0, 0.0);
    }

    # A static method calls another by its bare name.
    static scaled_unit_x(factor: double): Vector {
        var unit = unit_x();
        return Vector(unit.x * factor, unit.y * factor);
    }
}

main(): int {
    var v = Vector.scaled_unit_x(3.0);
    assert(v.x == 3.0 && v.y == 0.0, "called on the type name outside it");
    return 0;
}

Operators

A struct can define what the operators do for it. Declare a method whose name is the operator, written like any other method: ==(other: Money): bool. The C-style form with the return type first, bool ==(Money other), is accepted too.

struct Money {
    var cents: int = 0;

    construct(this.cents = 0) {}

    # `a == b` calls `a.==(b)`. Without this method, `==` on two structs
    # compares identity, like `===`.
    ==(other: Money): bool {
        return cents == other.cents;
    }

    # `!=` is derived from `==` when the struct does not define it.

    # `a < b` calls `a.<(b)`.
    <(other: Money): bool {
        return cents < other.cents;
    }

    # `a + b` calls `a.+(b)` and returns a new Money.
    +(other: Money): Money {
        return Money(cents + other.cents);
    }

    # `-a` calls `a.-()`: a unary operator takes no parameters.
    -(): Money {
        return Money(0 - cents);
    }

    # `a - b` calls `a.-(b)`. The unary and binary `-` overload each other,
    # told apart by their number of parameters.
    -(other: Money): Money {
        return Money(cents - other.cents);
    }

    # `a += b` needs its own method; it is not rewritten to `a = a + b`.
    +=(other: Money): Money {
        cents += other.cents;
        return this;
    }
}

struct Shelf {
    var slots: Array<String>;

    construct() {
        slots = Array<String>();
        slots.append("empty");
        slots.append("empty");
    }

    # An indexer with one parameter handles reads: `shelf[i]`.
    [](index: int): String {
        return slots[index];
    }

    # An indexer with two parameters handles writes: `shelf[i] = value`.
    [](index: int, value: String): String {
        slots[index] = value;
        return value;
    }
}

main(): int {
    var price = Money(250);
    assert(price == Money(250), "== compares cents");
    assert(price != Money(100), "!= compares cents");
    # `===` and `!==` always compare identity: equal cents, different objects.
    assert(price !== Money(250), "!== compares identity");
    var same = price;
    assert(same === price, "=== is true for the same object");

    var total = price + Money(50);
    assert(total.cents == 300, "+ makes a new value");
    assert(price.cents == 250, "+ left its operands alone");

    assert(price < total, "< compares cents");

    var refund = -price;
    assert(refund.cents == -250, "unary - calls -()");
    assert((total - price).cents == 50, "binary - calls -(other)");

    total += Money(25);
    assert(total.cents == 325, "+= updated total in place");

    var shelf = Shelf();
    shelf[1] = "books";
    assert(shelf[1] == "books", "indexer write, then read");
    assert(shelf[0] == "empty", "the other slot is unchanged");
    return 0;
}

When the left operand is a struct that declares the operator, a + b becomes the call a.+(b). The right operand is the method’s single parameter, and it can be any type. These binary operators can be defined:

KindOperators
Arithmetic+ - * / % **
Bitwise& | ^
Bitwise shifts<< >>
Comparison and logical== != < <= > >= && ||
Compound assignment+= -= *= /= %= <<= >>= &= |= ^=

Keep these rules in mind:

  • An operator can be declared once per right-operand type (+(Money) and +(int)), and a + b picks by b’s type, as for any overloaded method.
  • Each operator is its own method, with one exception: when a struct defines == but not !=, a != b means !(a == b). A struct that defines == and is used as a HashMap or HashSet key also needs a matching hash<T> (see Hashing and equality). += is not rewritten to a = a + b; define each one you want to use.
  • Comparison operators must return bool; any other return type is an error (comparison operator '==' must return 'bool', not 'String'). Logical operators should return bool too. Arithmetic, bitwise and compound assignment operators usually return the struct’s own type; a compound assignment usually changes this and returns it.
  • The left operand decides: a + b uses the method on a’s type. For a number on the left, such as 2 * v, see A number on the left.
  • Operators that start with < (<, <=, <<, <<=) can only be declared in the name: Type form, <(other: V): bool: in the C-style form the parser reads bool < as the start of generic arguments.

The result of an operator has the type its method returns, so with -(other: V): int, var r = a - b; is an int.

A number on the left

2.0 * v uses the operator of the left operand’s type, double, which a struct cannot change. Declare it in an extension of the number type instead, where this is the number:

# A number on the left of an operator: `2 * v` with a struct `v`.
struct Vector {
    var x: double;
    var y: double;

    construct(this.x = 0.0, this.y = 0.0) {}

    # `v * 2.0` calls `v.*(2.0)`.
    *(scale: double): Vector {
        return Vector(x * scale, y * scale);
    }
}

# `2.0 * v` uses the operator of the left operand's type, so `double`
# declares it in an extension; `this` is the number.
extend double {
    *(v: Vector): Vector {
        return v * this;
    }
}

# `2` is an `int`, which needs its own.
extend int {
    *(v: Vector): Vector {
        return v * (this as double);
    }
}

main(): int {
    var v = Vector(1.0, 2.0);
    var a = v * 2.0;
    var b = 2.0 * v;
    var c = 2 * v;
    assert(a.x == 2.0 && b.x == 2.0 && c.y == 4.0, "both orders");
    # Numbers on both sides still use the built-in operator.
    assert(2 * 3 == 6, "built-in");
    return 0;
}
  • The number type is the left operand’s own: 2 is an int and 2.0 a double, so each needs its own extension (extend int, extend double). Without one, 2 * v is invalid operands to binary expression ('int' and 'Vector').

  • The operator takes one operand, which may be any type except a number, bool, enum or pointer: numbers on both sides always use the built-in operator.

    main.pep:2:14: error: operator '+' of 'int' cannot take an operand of type 'int': the built-in operator applies
        2 |     +(other: int): int { return 0; }
          |              ^~~
    
  • Comparisons work the same way, and 2.0 != v means !(2.0 == v) when the extension declares only ==. Compound assignments (n *= v) do not apply, since the number cannot hold the result.

  • The extension is visible like any other: where its file is imported, if it is exported.

Unary operators

A unary operator is a method with no parameters; its operand is this.

OperatorDeclarationop x calls
Negation-(): Vx.-()
Bitwise complement~(): Vx.~()
Logical not!(): boolx.!()
Truth test!!(): boolx.!!() (see below)

The C-style form works here too: V -(), bool !!(). ! and !! must return bool (truth-test operator '!!' must return 'bool', not 'int'), and !, ~ and !! take no parameters. -a on a number is unchanged. A struct may declare both a unary -() and a binary -(other: V): they overload each other, told apart by their number of parameters.

Truth tests and conditions

A condition asks a value “true or false?”. For bool the answer is the value, for a number it is “not zero”, and for a raw pointer (__ptr<T>) or a function value it is “not null”. A struct answers through its truth-test operator, !!(): bool, like C++’s operator bool:

# A struct that declares `!!(): bool` can be used as a condition.
struct Budget {
    var cents: int = 0;

    construct(this.cents = 0) {}

    # The truth test: `if (budget)` calls `budget.!!()`.
    !!(): bool {
        return cents > 0;
    }
}

struct Label {
    var text: String;

    construct(this.text = "") {}
}

spend(budget: Budget, amount: int): int {
    var spent = 0;
    # A condition, an operand of `&&`, and the operand of `!` all call `!!`.
    while (budget && amount > 0) {
        budget.cents -= 1;
        amount -= 1;
        spent += 1;
    }
    if (!budget) {
        print("budget used up");
    }
    return spent;
}

main(): int {
    var budget = Budget(3);
    assert(spend(budget, 2) == 2, "spent 2 of 3");
    assert(budget ? true : false, "a ternary condition calls !! too");
    assert(spend(budget, 5) == 1, "only 1 left");
    assert(!!budget == false, "!!x calls the truth test");

    # A struct without `!!` is not a condition: `if (label)` is an error.
    # Compare with `__null` to ask whether a reference is set.
    var label: Label;
    assert(label === __null, "no Label yet");
    label = Label("sale");
    assert(label !== __null, "now there is one");
    return 0;
}

These are the boolean contexts. In each, a struct value x becomes x.!!():

  • the condition of if, else if and while;
  • the condition of condition ? a : b;
  • each operand of &&, ||, and and or (unless the struct declares && or || itself, which then takes the whole operation);
  • the operand of !: without its own ! method, !x is !(x.!!()).

!!x is the truth test itself: on a struct it calls x.!!(); on a bool or a number it is still two negations. Optional declares !!, so if (found) asks whether an optional holds a value.

A struct that does not declare !! cannot be used as a condition at all:

main.pep:6:9: error: 'Node' cannot be used as a condition: declare '!!(): bool' on it, or compare with '__null'
    6 |     if (node) {
      |         ^~~~

A struct value is a reference, but a condition never tests it for null. To ask whether a reference is set, compare it with __null (node !== __null), or better, use an Optional for a value that may be missing. A trait value is treated the same way: it is a condition only when its trait declares !!.

Comparing structs

Without an == method, a == b on two structs compares identity: it is true only when both refer to the same object, exactly like a === b. The same goes for !=. Define == when two distinct objects with the same contents should count as equal, as String and Array do; != then follows from it. To compare field by field, mark the struct [Equatable], which derives == and hash together. === and !== always compare identity, whatever methods the struct declares, so "a" + "b" == "ab" is true while "a" + "b" === "ab" is false.

A struct never equals a number: unless the struct declares an == (or !=) taking one, comparing a struct value such as an Optional with an integer is an error (cannot compare 'Optional<ulong>' with an integer), since it could only ever compare the object itself. Test an optional with is_empty() (or if (found)) and read its value with value().

Indexers

An indexer lets you write object[index]. Declare it as a method named []:

  • With one parameter, [](index: int): String, it handles reads: shelf[i].
  • With two parameters, [](index: int, value: String): String, it handles writes: shelf[i] = value. The second parameter is the value being stored.

The C-style form, String [](int index), is accepted too. Only parameters without a default count, so either kind can end with a defaulted parameter, such as the location: __ptr<char> = __caller_location() that Array uses to report an out-of-range index at the caller’s line (see Assertions and panics).

A struct can have both. With both, compound assignment through an index (counts[i] += 1) reads with the first and writes with the second. Array and String are indexed this way.

Assignment through a chain of indexes, library["fiction"][0] = "Dune", reads with every index but the last and writes with the last, as if the value read were held in a variable first (var fiction = library["fiction"]; fiction[0] = "Dune";). Since a struct is held by reference, the write changes the object the outer struct holds. Assigning through an index whose type has only the reading indexer is an error (cannot assign through '[]': 'Shelf' declares a '[]' getter but no setter).

# A struct declaring `[]` can be indexed: one parameter reads, two write.
struct Shelf {
    var titles: Map<String, String>;
    var sections: Map<String, Shelf>;

    construct() {
        titles = Map<String, String>();
        sections = Map<String, Shelf>();
    }

    # `shelf["name"]` reads a section.
    [](name: String): Shelf {
        return sections[name];
    }

    # `shelf["name"] = section` writes one.
    [](name: String, section: Shelf): Shelf {
        sections[name] = section;
        return section;
    }

    # `shelf[slot]` and `shelf[slot] = title` overload `[]` by type.
    [](slot: ulong): String {
        return titles[$"{slot}"];
    }

    [](slot: ulong, title: String): String {
        titles[$"{slot}"] = title;
        return title;
    }
}

main(): int {
    var library = Shelf();
    library["fiction"] = Shelf();
    # A chained assignment writes into the shelf the first index reads,
    # like `var fiction = library["fiction"]; fiction[0] = ...;`.
    library["fiction"][0] = "Dune";
    library["fiction"]["classics"] = Shelf();
    library["fiction"]["classics"][0] = "Emma";
    assert(library["fiction"][0] == "Dune", "set through two indexers");
    assert(library["fiction"]["classics"][0] == "Emma", "and through three");
    return 0;
}