Enums
An enum is a type with a fixed list of values, written out where everyone can see them:
enum DistanceUnit
{
case meter;
case kilometer;
case mile;
}
DistanceUnit $unit = DistanceUnit::kilometer;
if ($unit == DistanceUnit::kilometer) {
echo "kilometres it is"; // kilometres it is
}Three names, three values, and the compiler can check you handled all of them. A case can also carry data, and match is how you read it back.
Why an enum
Because that's what you end up writing otherwise, and it holds until the day somebody passes 7. Or until you add a fourth unit and forget one of the four places that switch on it, which is worse, because nothing tells you.
An enum closes both holes. DistanceUnit has exactly three values, they have names, and the compiler can check that you handled all of them.
Construction
A case is a static function on the enum, so DistanceUnit::meter is a call. It is spelled without parentheses when it takes nothing:
enum DistanceUnit { case meter; case kilometer; case mile; }
$a = DistanceUnit::meter;
$b = DistanceUnit::mile;
echo $a != $b; // 1Where the destination already says which enum it is, the leading dot says the rest:
enum DistanceUnit { case meter; case kilometer; case mile; }
function describe(DistanceUnit $u) : bool
{
return $u == DistanceUnit::meter;
}
DistanceUnit $unit = .kilometer; // the declaration named the type
echo describe(.meter); // 1, the parameter named itThat's the same shorthand static functions use, and the same machinery underneath. A case is not a special kind of thing, which is a theme here.
== and != compare which case a value is holding, because that's all an enum's identity ever is. Nobody declares that operator and nobody can write one that disagrees.
Backing values
Two different things get called "an enum with values", so let's be precise about which is which.
The first is a backing value: a constant attached to each case, usually because something outside your program already decided what the number is.
enum HttpStatus : int32
{
case ok = 200;
case not_found = 404;
case teapot = 418;
}
echo HttpStatus::not_found->value(); // 404The type after the : is the backing type. Integers and string are allowed:
enum DistanceUnit : string
{
case meter = "m";
case kilometer = "km";
case mile = "mi";
}
echo DistanceUnit::kilometer->value(); // kmvalue() is a function rather than a field, and that's deliberate. For an integer backing the value genuinely is the discriminant, so reading it costs nothing. A string can't be a discriminant, and storing one in every value would mean DistanceUnit::meter allocated. So the value lives with the declaration, value() fetches it, and both backings are spelled the same way.
A case's value is a literal written at the backing type, with the same rules as any other literal assigned to that type. Hex and binary work, and a value the backing can't hold is an error rather than a wrap. case big = 300; on an int8 is refused, the same way int8 $x = 300; is.
enum Register : uint8
{
case status = 0x10;
case control = 0b00100000;
}from is the other direction. On a closed integer enum it answers T?, because the integer might not be one of the cases:
enum HttpStatus : int32
{
case ok = 200;
case not_found = 404;
}
echo HttpStatus::from(200) != null; // 1
echo HttpStatus::from(999) == null; // 1That's the honest answer for HTTP. 999 is not a status, and null says so.
Named maps
Backing is one encoding, and it is the enum's own. Sometimes you need another: the DHD's glyph indices, two C libraries that disagree on the numbers. None of those is what the enum is. Write the relation on the enum, once:
enum Glyph
{
case unknown;
case earth;
case abydos;
map dhd : int32
{
.unknown = -1;
.earth = 1;
.abydos = 27;
}
}
echo Glyph::abydos->dhd(); // 27
echo Glyph::from(dhd: 27) != null; // 1
echo Glyph::from(dhd: 999) == null; // 1
Glyph $g = .from(dhd: 999) ?? .unknown;
echo $g == Glyph::unknown; // 1$glyph->dhd() is total: every listed case has a code. Glyph::from(dhd: $raw) is partial, because 999 is not in the relation. The DHD's own unknown sentinel is -1, which is .unknown. Those two inputs are different facts. ?? .unknown is the caller's policy, not something the map invents.
map is not a reserved word. map<K, V> is still the stdlib type. A second map is a second named view: map ancient : int32 { ... } mints $glyph->ancient() and Glyph::from(ancient: $raw). The unnamed integer backing, if there is one, still synthesizes unlabelled from($raw) and value(). They do not clash.
A listed case appears once. Every right-hand side is a compile-time integer, or a payload-free enum case. Two cases cannot share a code: the reverse would not be a function. There is no else to write. Cases you leave out are the holes. A payload case has nowhere to sit in a table of tags, so a map is a payload-free enum's.
Leave a case out and the forward becomes optional. Status::from(color: $c) stays partial either way: a colour no status maps to is null.
enum Color
{
case red;
case green;
case blue;
}
enum Status
{
case ok;
case warn;
case error;
map color : Color
{
.ok = .green;
.warn = .blue;
}
}
echo Status::ok->color() != null; // 1
echo Status::error->color() == null; // 1
Color $c = Status::ok->color() ?? .red;
echo $c == Color::green; // 1The colon already names the destination, so an enum range uses the leading dot, the same shorthand a Color $c = .green uses. List every case and $s->color() is total: a Color, not a Color?.
The enum does not have to own the map. A DHD binding cannot put codes on a Glyph it did not declare, so the same relation sits next to the type with for:
enum Glyph
{
case unknown;
case earth;
case abydos;
}
public map dhd : int32 for Glyph
{
.unknown = -1;
.earth = 1;
.abydos = 27;
}
echo Glyph::abydos->dhd(); // 27
echo Glyph::from(dhd: 27) != null; // 1Call sites are unchanged: $glyph->dhd() and Glyph::from(dhd: $raw). A file-scope map is a declaration of this module, so public is what a library writes to export it. Leave it off and only this module can name the methods. An in-body map stays as reachable as the enum. There is still no modifier to write there.
Open enums
Sometimes the set is not actually closed. C error codes are a known handful of integers, plus whatever a newer library version invented. You want names and match for the ones you know, and you want -999 to still be a value.
Write the known codes with =, and one case without:
enum Error : int32
{
case invalidArgs = -2;
case invalidOperation = -3;
case outOfMemory = -4;
case other;
}
Error $known = .from(-2); // Error::invalidArgs
Error $new = .from(-999); // Error::other
echo $known->value(); // -2
echo $new->value(); // -999
echo $known == Error::invalidArgs; // 1other is just a name. Nothing is reserved. What makes the enum open is the shape: exactly one case has no =, and at least one sibling does. This still auto-numbers and stays closed:
enum E : int32 { case a; case b; } // 0 and 1, closedHere is the catch. from always succeeds, value() always hands the integer back, and the round-trip is identity: from($e->value()) is $e. You still cannot write Error(-999), and you cannot write Error::other as a value either. The leftover is whatever integer is not a named case, so constructing it by name would not know which integer to store. from is how a leftover arrives.
match stays exhaustive if you handle other. Or else. A library bump that invents -999 cannot break existing arms. Adding a named case later still can, because that is adding a case, and that is supposed to be loud.
enum Error : int32
{
case invalidArgs = -2;
case other;
}
function describe(Error $e) : string
{
return match ($e) {
.invalidArgs => "bad args",
.other => "miniaudio said something else",
};
}
echo describe(.from(-2)); // bad args
echo describe(.from(-999)); // miniaudio said something elseother is leftover integers. else is named cases you skipped. Write both if you want both.
enum Error : int32
{
case invalidArgs = -2;
case invalidOperation = -3;
case other;
}
function describe(Error $e) : string
{
return match ($e) {
.invalidArgs => "bad args",
.other => "something else",
else => "skipped named",
};
}
echo describe(.from(-999)); // something else
echo describe(.invalidOperation); // skipped namedThe leftover is the integer. Read it with value(). It is not a payload, so this will not compile:
enum Error : int32
{
case invalidArgs = -2;
case other(int32 $code); // error: the leftover cannot carry a payload
}This is the type every C binding actually has. Wrap the raw int32 on the way in, unwrap it on the way out. An open Error is still { int32 } in memory, which is not C's int, so do not declare the C function as returning Error. Pass $e->value() back. C interop is the wrapping in context.
Payloads
The other thing people mean by "an enum with values" is a payload: data that differs per instance rather than per case.
enum CurlError
{
case cannot_resolve_host;
case cannot_connect;
case timeout(int32 $after_seconds);
case http(int32 $code, string $body);
}
CurlError $err = .timeout(30);CurlError is now a closed set of shapes rather than a closed set of names. A timeout knows how long it waited. An HTTP failure carries its status and its body. And cannot_connect carries nothing, because there is nothing to say about it.
A payload and a backing value are mutually exclusive. A case is one or the other, and an enum picks one for all of its cases.
Reading a payload back needs match. That's the next section, and it's what holds the rest of this together.
indirect
An enum's size is the tag plus the widest case. That is the right default until a case contains the enum itself, or until a fat case is making every slot in an array as wide as the rare payload.
Write indirect on that case. The payload is one heap cell, no class header. Sibling cases stay in the parent. Call sites and match do not change.
enum Node
{
case leaf(int32 $n);
indirect case binary(Node $left, Node $right);
}
function eval(const Node& $n) : int32
{
return match ($n) {
Node::leaf($v) => $v,
Node::binary($l, $r) => eval($l) + eval($r),
};
}
echo eval(.binary(.leaf(1), .leaf(2))); // 3indirect is recognised only in front of case. It is an ordinary name everywhere else.
A copy of a Node that holds binary allocates a new cell and copies the fields. mv hands the pointer over. Two values do not share a cell, so a write through a match binding of the copy leaves the original alone.
mem::size<Node>() is the tag plus the max of the inline payloads and a pointer. Mark the fat cases indirect and an array of them stays that wide. Leave them inline and the array is still widest-case times length; mem::size shows it.
A cycle through an inline case will not compile. indirect is the spelling that breaks the cycle.
match
match is an expression, so its arms produce a value:
enum DistanceUnit
{
case meter(int32 $v);
case kilometer(int32 $v);
case mile(int32 $v);
}
function to_meters(DistanceUnit $u) : int32
{
return match ($u) {
.meter($v) => $v,
.kilometer($v) => $v * 1000,
.mile($v) => $v * 1609,
};
}
echo to_meters(.mile(2)); // 3218The names in parentheses bind the payload for that arm. They do not take part in choosing it: DistanceUnit::meter and DistanceUnit::meter($v) select exactly the same case, and the second one additionally says what to call what is inside.
Which means there is no arm ordering to keep straight, and no way for one pattern to quietly shadow another. The case name picks the arm and nothing else does.
Every case, or an else
A match has to cover the enum. Leave one out and you get told which one:
enum DistanceUnit { case meter; case kilometer; case mile; }
DistanceUnit $unit = .meter;
$n = match ($unit) {
.meter => 1,
.kilometer => 2,
};
// error: this 'match' does not cover every case of 'DistanceUnit' - one is left out. Name mile, or
// add an 'else' arm.This is the whole reason enums beat integer constants. Add a fourth case a year from now and the compiler walks you round every match in the program.
When you genuinely only care about one case, else covers the rest:
enum CurlError { case cannot_connect; case timeout(int32 $after); }
CurlError $err = .timeout(30);
$waited = match ($err) {
.timeout($after) => $after,
else => 0,
};
echo $waited; // 30An else that can never run is an error rather than dead code, because in practice it is a case that got renamed out from under it:
enum Side { case left; case right; }
Side $s = .left;
$n = match ($s) {
.left => 1,
.right => 2,
else => 3,
};
// error: this 'match' already covers every case of 'Side', so its 'else' arm can never run.Block arms
Write an arm as a block and it produces nothing, which makes the whole match a statement:
enum CurlError
{
case cannot_connect;
case timeout(int32 $after);
}
function report(CurlError $e) : void
{
match ($e) {
.cannot_connect => { echo "refused"; },
.timeout($after) => { echo "timed out after"; echo $after; },
}
}
report(.timeout(30)); // timed out after
// 30That form takes no trailing semicolon, the same as if and foreach.
It is all or nothing: every arm produces a value, or every arm is a block. Mixing them is an error, because the result of the match would be two different things depending on which arm ran.
A match whose arms all return is a way out of the function, so this needs no trailing return:
enum Side { case left; case right; }
function to_int(Side $s) : int32
{
match ($s) {
.left => { return -1; },
.right => { return 1; },
}
}
echo to_int(.right); // 1An arm may also simply never come back. die returns void, which would normally clash with an arm handing back an int32, and it doesn't: an arm that never returns contributes no type to the unification at all. That's what makes unwrap() writable, where one arm has a T and the other only has an E:
enum slot
{
case filled(int32 $value);
case blank();
}
function must(const slot& $s) : int32
{
return match ($s) {
.filled($v) => $v,
.blank() => die('nothing in the slot'),
};
}
echo must(.blank());Bindings
A payload binding points into the value being matched. Nothing is copied, so a match over an enum holding a string allocates nothing:
enum Message
{
case empty;
case text(string $body);
}
function is_blank(Message $m) : bool
{
return match ($m) {
.empty => true,
.text($body) => $body->size() == 0,
};
}
echo is_blank(.text("chevron seven locked")); // 0
echo is_blank(.empty()); // 1Methods work on the binding, because a borrow is what a receiver wants anyway. Assign it somewhere that outlives the arm and it copies, exactly as reading any other borrow does.
Here is the catch. If an arm binds a payload and then writes the subject (reassign it, mv it, or pass it as &), that would tear the payload out from under its own binding. So the compiler copies the subject first, and the bindings borrow the copy. They come out const: a write through one would land in a copy nobody else can see.
enum Message
{
case empty;
case text(string $body);
}
Message $m = .text("still here");
match ($m) {
.text($body) => {
$m = .empty();
echo $body; // still here
}
.empty => {}
}One thing to watch: an arm's value is typed on its own, with nothing telling a bare literal what to be. So .empty => 0 sitting beside an arm that produces a usize is two different types, and the compiler says so rather than guessing. Write the literal at the type you meant.
That also means a match yields a place when every one of its arms does. An arm handing back a payload hands back a borrow of the subject, and a method can return it:
enum slot
{
case filled(int32 $value);
case blank();
}
function read(const slot& $s) : int32
{
return match ($s) {
.filled($v) => $v,
.blank() => 0,
};
}
echo read(.filled(7)); // 7
echo read(.blank()); // 0The subject has to be storage the program already holds, because the borrow points into it. match ($this) inside a method is the shape that matters and it qualifies; a match over a call result does not, and says so. See What is missing.
Ownership
A case with an owning payload is torn down properly, and only the case actually being held is touched:
enum Message
{
case empty;
case text(string $body);
}
function consume(Message $m) : void {}
Message $a = .text("hello");
Message $b = $a; // copies the string, because that is the live case
consume($b);
Message $c = .empty();
Message $d = $c; // copies nothing at all
echo "done"; // doneYou write none of that. The compiler synthesizes one teardown and one copy per enum, each branching on the case, the same way it does for a struct that owns something.
The body
Cases, methods, static functions, operators and static properties:
enum Side
{
case left;
case right;
static int32 $flips = 0;
const function is_left() : bool
{
return $this == Side::left;
}
}
echo Side::left->is_left(); // 1Not properties, constructors, constants or nested types:
enum Side
{
case left;
public int32 $extra;
}
// error: 'Side' is an enum, so it cannot declare a property. An enum holds `case` declarations, and
// the functions, operators and statics it answers with.The layout of an enum belongs to the compiler: a discriminant, and a slot per payload field. A property of your own would be seated beside a discriminant it knows nothing about, and a constructor could build a value that is none of the cases. init is a struct and class word for the same reason: an enum has no fields of yours to derive. A constant is refused for a duller reason: Side::left already names a case, so Side::LIMIT would make one spelling mean two things.
Generics
enum Slot<T>
{
case empty;
case filled(T $value);
}
function unwrap_or(Slot<int32> $s, int32 $fallback) : int32
{
return match ($s) {
.empty => $fallback,
.filled($v) => $v,
};
}
echo unwrap_or(.filled(9), 0); // 9
echo unwrap_or(.empty(), -1); // -1Errors
This is what payload cases are for. The enum states what can fail as a closed set, and match makes the compiler check that your handling covers it:
enum ParseError
{
case empty_input;
case bad_digit(uint8 $byte);
case too_long(usize $length);
}
function explain(ParseError $e) : string
{
return match ($e) {
.empty_input => "nothing to parse",
.bad_digit($b) => "unexpected byte",
.too_long($len) => "too long",
};
}
echo explain(.bad_digit(122)); // unexpected byteThe library ships the type that carries one of these back to a caller alongside the value they did not get: result<T, E>. It is an enum, written in ordinary Echo, and guard reads it through two interfaces that any enum of yours can declare.
Layout
A discriminant, plus the payload of whichever case you are holding:
enum CurlError {
case cannot_connect; // 0
case timeout(int32 $after); // 1
case http(int32 $code, string $body); // 2
}The size is the tag plus the widest case, plus whatever padding their alignment wants. A cannot_connect is not as wide as http. A plain enum is one byte. An integer-backed one, open or closed, is the backing integer in a struct: { int32 } for : int32. That is why you still cannot hand an Error to C as if it were int. Copy and teardown still only touch the live case, so an http being copied does not retain a timeout that is not there.
The payload slots are still ordinary properties as far as the rest of the compiler is concerned. That's what keeps copy, drop, match and debug info from growing an arm per case. The overlay is the lowering.
The layout is otherwise unspecified. Don't mem::bit_cast an enum and expect a stable shape.
Next
- Structs for the static functions and the leading-dot shorthand that an enum's cases reuse.
- Control flow for
guard, which reads a value that may not be there. - Errors and panics for where an enum fits among Echo's failure answers.