Classes
A class is declared exactly like a struct, with one keyword changed:
class Stargate
{
int32 $lockedChevrons;
function lock() : void {
$this->lockedChevrons = $this->lockedChevrons + 1;
}
}
Stargate $sgc = Stargate(0);
$sgc->lock();
echo $sgc->lockedChevrons; // 1Same syntax, same constructors, same methods, same const function. Field defaults, $name:, labels, and init work the same. Structs is that chapter. The semantics are the opposite. A struct is a value that gets copied. A class lives on the heap, is reference counted, and assigning it hands out another name for the same object:
class Counter
{
int32 $value;
}
Counter $a = Counter(0);
Counter $b = $a;
$b->value = 99;
echo $a->value; // 99, because there is only one objectThat's the whole decision: one owner and a copy, or many owners and a shared object. Everything below follows from it.
Putting a struct on the heap
A class is a struct on the heap. If the payload is already a struct, wrap it:
struct Point
{
int32 $x;
int32 $y;
}
rc<Point> $p = rc<Point>(1, 2);
echo $p->value->x; // 1rc<T> is a class in the standard library. rc<Point> is a class, so it satisfies T : class, it has identity, and erased::from will take it. Point itself will not. Copying the handle is a retain; the Point inside is copied once, at construction.
rc<int32>(7) seats a value that is already an int32. rc<Point>(1, 2) forwards into Point(1, 2). Generics is the factory that uses this.
Which one should you reach for
Reach for a struct by default. It's cheaper, it has no allocation, and a value you can't accidentally share is a value you can't accidentally corrupt.
Reach for a class when:
- two parts of your program genuinely need to see the same mutations
- the thing has identity rather than just contents (a connection, a window, a node in a graph)
- you need to store it behind an interface, which a struct cannot do
What it costs
One heap allocation per object, and a header alongside your properties holding a strong count, a weak count and a type pointer. Every assignment is a counter increment, every scope exit a decrement.
You can watch it happen:
class Handle
{
int32 $x;
}
Handle $a = Handle(1);
echo mem::refs($a); // 1
Handle $b = $a;
echo mem::refs($a); // 2mem::refs and mem::weaks exist for exactly this: understanding what your program is actually doing. They are not something to build logic on.
The count is cheap until you share it
The default retain is a load, an add and a store. That is correct for every program that never shares a handle across threads, and cheaper. Mark the class when a handle will be copied onto another thread with no lock held:
#[atomic]
class Counter
{
int32 $hits;
}
Counter $c = Counter(0);
echo $c->hits; // 0The count becomes an atomic RMW. The fields do not. Two threads writing $hits still race: put the word in an atomic<T>, or put the object behind a mutex. It is refused on a struct and on an interface, where there is no count.
Atomics is the chapter. Threads is why you would mark one.
When the destructor runs
Not at the closing brace. When the last reference goes away:
class Resource
{
int32 $tag;
destructor()
{
echo $this->tag;
}
}
function scope() : void
{
Resource $a = Resource(7);
Resource $b = $a;
echo "inside";
}
scope();
echo "after";That prints inside, then 7, then after. $b going out of scope dropped the count to one, and $a going out of scope dropped it to zero, which is the moment the destructor ran.
This is the practical difference from a struct, where the destructor runs at a brace you can point at. With a class, teardown happens wherever the last holder happens to be.
A class is not nullable by default
Declaring a Stargate promises there is a gate there:
class Stargate
{
int32 $lockedChevrons;
}
Stargate $sgc = Stargate(0);
echo $sgc->lockedChevrons; // 0If absence is a real state, say so with ?:
class Wormhole
{
int32 $id;
}
class Stargate
{
int32 $lockedChevrons;
Wormhole? $active;
}
Stargate $idle = Stargate(0, null);
echo $idle->lockedChevrons; // 0Wormhole? $active is the honest type. A gate is idle most of the time, and a plain Wormhole $active would demand a connection that never closes.
Everything in Nullability applies: guard to narrow, ?? for a fallback, ?-> to short-circuit a chain.
Note: Stargate $sgc; with no initializer currently compiles and hands you a null handle, which slips past this rule. That's a hole, not a feature, and it's on the list.
Statics work here on exactly the struct's terms
A static function called on the type, a static property the type owns one of, the leading-dot shorthand, all of it. The rules don't change for a class, so they're written up once, on the struct page.
The one extra thing a class adds: a static property holding a class handle owns a reference like any other and gives it back when the program ends, so a class parked in a static stays alive for the whole run. Usually that's the point. Occasionally it's the bug.
instanceof
Because a class object carries a type pointer, you can ask what it is:
class Hatak
{
int32 $gliderBays;
}
class Daedalus
{
int32 $railguns;
}
Hatak $enemy = Hatak(4);
echo $enemy instanceof Hatak; // 1
echo $enemy instanceof Daedalus; // 0
if ($enemy instanceof Hatak) {
echo "raise shields";
}A null handle is not an instance of anything, which is the answer you want:
class Hatak
{
int32 $gliderBays;
}
Hatak? $sensorContact = null;
echo $sensorContact instanceof Hatak; // 0instanceof works against an interface too, and that's where it earns its keep. See Interfaces.
It does not work on a struct. A struct has no header and no type pointer, so there is nothing to ask.
Interfaces, and the vtable
A class is the half of the language that can be stored as an interface:
interface Vessel
{
function hyperspaceSpeed() : int32;
}
class Hatak : Vessel
{
int32 $gliderBays;
function hyperspaceSpeed() : int32
{
return 32;
}
}
class Daedalus : Vessel
{
int32 $railguns;
function hyperspaceSpeed() : int32
{
return 90;
}
}
Vessel $contact = Hatak(4);
echo $contact->hyperspaceSpeed(); // 32
$contact = Daedalus(16);
echo $contact->hyperspaceSpeed(); // 90One variable, two different concrete types over its lifetime, dispatch decided at runtime. It costs a vtable pointer and an indirect call.
That's the job a struct can't do, and Interfaces explains why the split is deliberate rather than a gap waiting to be filled.
Reference cycles leak
Reference counting has one well-known failure and Echo doesn't paper over it. If two objects hold strong references to each other, neither count ever reaches zero.
The gate and its wormhole are exactly that shape. The gate owns the connection it opened:
class Wormhole
{
int32 $id;
destructor()
{
echo $this->id;
}
}
class Stargate
{
int32 $lockedChevrons;
Wormhole? $active;
destructor()
{
echo 100;
}
}
function dial() : void
{
Stargate $sgc = Stargate(7, null);
Wormhole $outbound = Wormhole(1);
$sgc->active = $outbound;
// if Wormhole also held a strong Stargate, neither destructor would ever run
}
dial();
echo "done";There is no cycle collector and there is not going to be one. The fix is to decide which direction of the relationship owns, and make the other one weak.
weak references
weak<T> refers to an object without keeping it alive. Here the direction is obvious: the gate owns the wormhole it opened, and the wormhole only needs to know where it came from.
class Wormhole
{
int32 $id;
weak<Stargate> $origin;
destructor()
{
echo $this->id;
}
}
class Stargate
{
int32 $lockedChevrons;
Wormhole? $active;
destructor()
{
echo 100;
}
}
function dial() : void
{
Stargate $sgc = Stargate(7, null);
Wormhole $outbound = Wormhole(1, null);
$sgc->active = $outbound; // strong, forward
$outbound->origin = &$sgc; // weak, backward
echo mem::refs($sgc); // 1
echo mem::refs($outbound); // 2
}
dial();
echo "done";Both destructors run. The forward edge owns, the backward edge watches, and the counts reach zero in order.
Note the & on $outbound->origin = &$sgc. The destination decides that this is a weak reference, and the & is how you write taking a reference to something you don't intend to own.
Using a weak reference
A weak reference can't be read directly, because the object may be gone. Upgrade it with strong(), which gives you a T?:
class Stargate
{
int32 $lockedChevrons;
}
Stargate $sgc = Stargate(7);
weak<Stargate> $watcher = &$sgc;
Stargate $stillThere = guard strong($watcher) else { die("gate is gone"); }
echo $stillThere->lockedChevrons; // 7strong is a free function, not a method: strong($watcher), not $watcher->strong().
For convenience the three optional forms accept a weak<T> directly and do the upgrade for you:
class Stargate
{
int32 $lockedChevrons;
}
Stargate $sgc = Stargate(7);
weak<Stargate> $watcher = &$sgc;
echo $watcher?->lockedChevrons ?? -1; // 7
Stargate $stillThere = guard $watcher else { die("gate is gone"); }
echo $stillThere->lockedChevrons; // 7Once the object is gone, all of those answer absent rather than reading freed memory. The weak count is what keeps the block readable long enough to answer the question, which is why teardown is two moments: the payload dies when the strong count hits zero, and the block is freed when the weak count follows.
Next
- Structs for the value half, and the constructor rules both share.
- Interfaces for storing a class behind a contract.
- Nullability for
T?,guard,??and?->. - Atomics for
#[atomic]andatomic<T>. - Threads for starting a second thread.