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,
falseforbool, and null for a struct type such asStringor 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 nonewkeyword at the call site. p.xreads a field andp.x = 3writes it.a === bis true whenaandbrefer to the same object, anda !== bwhen 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 = 1stores its argument directly into the fieldwidth, before the body runs. The= 1is 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.namewhen 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:
newis the older, deprecated spelling ofconstruct. 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] = valueincluded), a call through a function value, and areturnfrom 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 aPath), avarwhose type is inferred (var n = 12.0;stays adouble), 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,
floattodouble, or a struct to a trait it declares. Conversions never chain: ifTagconverts fromNameandNamefromString, aStringdoes not become aTag. - 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 throughthis(this.x). A parameter or local with the same name as a field hides the field; usethis.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(): Buffermethod can call the free functioncopy(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:
| Kind | Operators |
|---|---|
| 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)), anda + bpicks byb’s type, as for any overloaded method. - Each operator is its own method, with one exception: when a struct defines
==but not!=,a != bmeans!(a == b). A struct that defines==and is used as aHashMaporHashSetkey also needs a matchinghash<T>(see Hashing and equality).+=is not rewritten toa = 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 returnbooltoo. Arithmetic, bitwise and compound assignment operators usually return the struct’s own type; a compound assignment usually changesthisand returns it. - The left operand decides:
a + buses the method ona’s type. For a number on the left, such as2 * v, see A number on the left. - Operators that start with
<(<,<=,<<,<<=) can only be declared in thename: Typeform,<(other: V): bool: in the C-style form the parser readsbool <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:
2is anintand2.0adouble, so each needs its own extension (extend int,extend double). Without one,2 * visinvalid 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 != vmeans!(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.
| Operator | Declaration | op x calls |
|---|---|---|
| Negation | -(): V | x.-() |
| Bitwise complement | ~(): V | x.~() |
| Logical not | !(): bool | x.!() |
| Truth test | !!(): bool | x.!!() (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 ifandwhile; - the condition of
condition ? a : b; - each operand of
&&,||,andandor(unless the struct declares&&or||itself, which then takes the whole operation); - the operand of
!: without its own!method,!xis!(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;
}