Destructors

A destructor is a struct's cleanup body. Xenon invokes it during delete and automatically for local struct values and stack-array elements when their scope ends. Its access modifier controls where that destruction is permitted.

Declaring cleanup

Write ~TypeName() inside the struct. The name must match its containing type. A struct may declare one destructor; it has no return type or parameters.

struct Buffer
{
    private byte[] Data;

    public Buffer(int length)
    {
        Data = new byte[length];
    }

    public ~Buffer()
    {
        delete(Data);
    }
}

The body can access instance fields and release resources the program treats as belonging to this instance. The compiler does not infer ownership from pointer or array fields.

The Buffer(int length) member is a constructor: it initializes the instance and allocates the data that the destructor later releases.

Every destructible temporary remains alive through its complete enclosing expression and is then destroyed exactly once. This also applies when the temporary is nested as a method receiver, an operand, or another subexpression: in GetShared()->Use(), the returned shared owner keeps the object alive until Use returns and is released afterward. Multiple live temporaries are cleaned up in reverse construction/evaluation order. A result transferred into an initializer, assignment, return, or consuming argument receives no additional temporary cleanup.

CreateResource().Use();
// ~Resource() runs after Use(), at the end of the full expression

Destructor access modifiers

Destructors accept public or private, just like other struct members. Without an access modifier, a destructor is private. Visibility controls who may initiate destruction; it does not change the destructor's body or transfer ownership.

DeclarationWhere destruction is permitted
public ~Resource()Both inside and outside Resource, subject to the normal lifetime and readonly rules.
private ~Resource()Only code belonging to Resource.
~Resource()The same as an explicit private destructor.

The compiler checks accessibility at delete, automatic destruction of a successfully initialized scalar local, stack-array element cleanup, and the generated call from a derived destructor to its base. Automatic cleanup does not bypass private access. An external scalar local or stack array requiring a private destructor is therefore rejected at compile time, even without an explicit delete.

A private destructor can keep release behind a type-owned API:

struct Resource
{
    private ~Resource() { }

    public static Resource* Create()
    {
        return new Resource();
    }

    public static void Destroy(Resource* resource)
    {
        delete(resource); // allowed inside Resource
    }

    public static void UseLocal()
    {
        Resource resource = Resource(); // automatic cleanup is allowed here
    }
}
Resource* resource = Resource.Create();
// Rejected outside Resource:
// delete(resource);
// Resource local = Resource();
// Resource[] localArray = Resource[1];

Resource.Destroy(resource); // allowed: the public API performs the private cleanup

Declaring a destructor private does not forbid heap allocation by itself. It restricts release, so provide an accessible release function if callers may own heap instances. A private base destructor is also inaccessible to derived code: virtual and override do not grant access to the generated base-destructor call.

What delete does

Buffer* buffer = new Buffer(64);
delete(buffer);

Here delete(buffer) calls ~Buffer(), which deletes Data. After that body finishes, the outer delete releases the Buffer allocation itself. Do not delete the same instance again from inside its destructor. In contrast, free(buffer) would release only the bytes and skip both destructors.

After release, the pointer and any aliases are invalid; delete does not reset variables to null. Copies of structs copy pointer and array fields without copying their allocations. Releasing the same resource from two copies can cause a double release.

Heap and local cleanup

Raw heap allocations require explicit release; overwriting a pointer or losing its last alias does not free them. Ownership handles are the explicit exception: unique<T> releases its one allocation, while the last shared<T> owner releases a reference-counted allocation. Both invoke the same destruction and deallocation sequence automatically. A weak<T> observer never destroys T. Scalar local structs and stack-array elements are also cleaned up when their scope exits.

Inheritance and virtual destructors

On every normal exit from a derived destructor, including an explicit return, the compiler first cleans up its locals, then calls the nearest inherited destructor exactly once, continuing toward the base. If a derived struct declares none, it inherits the nearest available destructor.

Declare the base destructor virtual when an allocation will be deleted through a base pointer. The runtime then selects the derived destructor before chaining to the base.

struct Entity
{
    public static int Trace = 0;
    public virtual ~Entity()
    {
        Entity.Trace = Entity.Trace * 10 + 1;
    }
}

struct Enemy : Entity
{
    public override ~Enemy()
    {
        Entity.Trace = Entity.Trace * 10 + 2;
    }
}
Enemy* enemy = new Enemy { };
Entity* entity = enemy;
delete(entity);
// Entity.Trace == 21: Enemy first, then Entity.

A user-written derived destructor must explicitly say override to replace an inherited virtual destructor. It uses its own type name and occupies the inherited slot only after validation. A nonvirtual destructor is selected from the pointer's static type, so deleting through a base pointer can skip derived cleanup.

An override cannot reduce the inherited destructor's accessibility. A public virtual destructor requires a public override at every level; both an explicit private override and an override with omitted access are rejected. The same visibility comparison is used for methods, properties and indexers.

struct InvalidEnemy : Entity
{
    private override ~InvalidEnemy() { }
    // Error: an override cannot reduce the accessibility of its inherited member.
}

A derived declaration such as struct PassiveEnemy : Entity { } remains valid: without a user-written destructor, no explicit access modifier or override is required. Inherited cleanup still runs. This exemption does not make a private base destructor accessible.

Visibility and base-call access are separate checks. A private-to-private or private-to-public override does not narrow visibility, but Xenon still rejects the derived destruction chain because it would call a private base destructor. There is no protected destructor access modifier. Abstract destructors and abstract override destructors are not supported.

Arrays of structs

delete(array) calls the element type's destructor once per element in reverse linear order, then releases the array header and element storage once. A rectangular array uses reverse row-major order; an empty array has no element destructor calls. free(array) skips element destruction and releases storage only.

struct Item
{
    public int Id;
    public static int Trace = 0;
    public ~Item()
    {
        Item.Trace = Item.Trace * 10 + Id;
    }
}
Item[] items = new Item[3];
items[0].Id = 1;
items[1].Id = 2;
items[2].Id = 3;
delete(items);
// Item.Trace == 321.

Array allocation zero-initializes elements, applies struct field initializers and sets up virtual dispatch. It does not invoke user constructors. Design element cleanup to handle that initial state.

Destruction is recursive for inline struct fields that themselves require destruction. It does not invent ownership for handles: an array of pointers does not destroy pointed-to objects, and an outer jagged array does not free child arrays. Release raw pointer and array allocations explicitly according to their existing ownership rules. See nested array cleanup.

Compiler-generated destructors

A struct requires destruction when it declares a destructor, inherits destructor behavior, or contains an inline struct field that requires destruction. If it has no user destructor, Xenon synthesizes a complete destructor automatically.

struct Leaf
{
    public int Id;
    public ~Leaf() { Log(Id); }
}

struct Container
{
    public Leaf First;
    public Leaf Second;
}

{
    Container value = Container();
} // destroys Second, then First

Fields are destroyed in reverse declaration order. Nested compiler-generated destructors compose recursively, and concrete generic specializations derive destruction requirements from their substituted field types.

When a type has a user destructor, the deterministic order is: the user body, the type's own fields in reverse declaration order, then the complete base destructor. A derived type without its own destructor still receives a generated destructor when needed, including a virtual-destructor forwarding entry for base-pointer release.

Handles remain explicit

Raw pointers and ordinary array handles are not owning fields merely because they occur inside a destroyed struct. Automatic field destruction applies only to value types that require destruction, including unique, shared and weak fields. This cleanup composes through nested structs and concrete generic specializations.

Scalar local cleanup

A successfully initialized local struct value that requires destruction is destroyed when its declaring scope ends. Uninitialized locals are not destroyed. An explicitly declared destructor must be accessible from the function that creates the local.

Item.Trace = 0;
{
    Item first = Item { 1 };
    Item second = Item { 2 };
} // second, then first: Item.Trace == 21.

Scalar locals, by-value parameters and stack arrays use deterministic cleanup. Locals and arrays share reverse construction order within a scope; parameters are live on function entry. Cleanup runs on ordinary exit and when return, break or continue leaves the scope. A return expression is evaluated before cleanup. Reassigning initialized struct storage first destroys its live old value and then reuses the existing cleanup registration; pointer and reference locals do not own their targets.

An explicit move transfers this cleanup responsibility. A moved local is skipped during scope cleanup, while the destination becomes responsible for one later destructor call. Move-assignment into an already-live scalar first destroys the destination's old value. See Move semantics.

Argument evaluation may defer a direct move until every argument succeeds. A later argument cannot reinitialize the same place, or an overlapping parent or child place, because that would make the deferred commit target a different lifetime. Split the move and reinitialization into separate statements.

Replacing a live destructible receiver field provides a strong lifetime guarantee: Xenon installs the new field value first, then destroys a detached copy of the old value. If that destructor throws, callers and enclosing destructor cleanup still observe the new field as live. Local scalar replacement retains its ordinary destroy-then-reuse behavior because its cleanup registration can directly represent an empty destination during unwinding.

A user-defined destructor makes its complete value an indivisible lifetime boundary. Moving or explicitly destroying only one ordinary field is rejected, including through a local reference, a returned reference, or a nested field path beneath any ancestor with a user-defined destructor. The destructor may access every field and rely on whole-object invariants; move or destroy the complete aggregate instead. Compiler-generated field cleanup alone does not create this restriction. A complete storage<T> field remains its own whole-lifetime object: destroying that wrapper updates its independent Empty/Initialized state and generated cleanup observes the change.

Stack array cleanup

Stack arrays use the same element destruction order as heap arrays. Cleanup runs automatically when the allocation scope exits, before its stack storage is reclaimed. Using the Item type above:

Item.Trace = 0;
{
    Item[,] items = Item[1, 3];
    items[0, 0].Id = 1;
    items[0, 1].Id = 2;
    items[0, 2].Id = 3;
} // Calls ~Item for [0,2], [0,1], [0,0].
// Item.Trace == 321; no free call is needed or allowed.

Cleanup also runs when return, break or continue exits the allocation scope. Inner scopes are cleaned up first, and allocations within each scope are destroyed in reverse allocation order. The return value is computed before these destructors run.

Copying an array handle into empty storage does not create another allocation to destroy. Reassigning a live array local destroys the old allocation's elements immediately in reverse order and retires its cleanup registration before the new handle becomes responsible for cleanup; aliases to the replaced elements no longer designate live objects. Stack arrays cannot contain other arrays, but can have any rectangular rank.

As with heap arrays, field initializers run when elements are created, but user constructors do not. A trap terminates execution without running scope cleanup.

Cleanup inside readonly functions

Allocation, destruction and deallocation of locally created state are permitted inside readonly code. Resources explicitly provided through mutable pointer/reference parameters may also be changed or freed. Constructors and destructors need no readonly annotation: the compiler checks their bodies with the actual object and argument origins.

struct Resource
{
    public int* Counter;

    public Resource(int* counter)
    {
        Counter = counter;
    }

    public ~Resource()
    {
        *Counter += 1;
    }
}

void readonly Destroy(Resource* resource)
{
    delete(resource); // explicit mutable resource
}

void readonly Use(int* counter)
{
    Resource* resource = new Resource(counter);
    Destroy(resource);
    {
        Resource[] items = Resource[1];
        items[0].Counter = counter;
    } // allowed: automatic stack-array element destructor
}

Here cleanup changes only the explicitly supplied counter. Replacing that pointer with one obtained from static state would make the write in the destructor a forbidden hidden effect. A destructor that writes a static counter, like the tracing examples earlier on this page, cannot be used for cleanup inside a readonly function.

delete on a hidden pointer is rejected even if that pointer was copied through local variables or structs. A readonly pointee does not grant lifetime authority, so delete(readonlyPointer) is rejected. Allowed effects do not prove that a pointer owns a live heap allocation; the normal rules against double release and releasing stack memory still apply.

An ordinary non-readonly Dispose() method can also be called on a local or explicitly mutable resource when its body passes contextual instance-method checks. A destructor may call such a helper under the same rules. Hidden pointer fields cannot be freed, and hidden/static effects remain forbidden throughout nested calls. Dispose has no special lifetime meaning and is invoked only when the program calls it.

Readonly functions follow the same lifetime and accessibility rules: scalar locals and stack-array elements are destroyed at scope exit, and typed heap objects are released through delete. A permitted effect does not bypass a private destructor.

Null and valid allocations

Deleting a null pointer or null array is a no-op. The null check runs before destructor invocation, including virtual lookup. This does not bypass compile-time pointer capability or destructor accessibility checks. You may still clear a variable explicitly after release:

if (buffer != null)
{
    delete(buffer);
    buffer = null;
}

This pattern assumes buffer still denotes a live allocation or null. Never pass an already released pointer, an interior element address, or stack storage to delete.