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

Final methods and hidden hooks

Status: core design agreed; not implemented. This refines the feasibility sketch using @final and @protocol.Name method annotations. Short programs explore the resulting programming model. Future prioritization remains in B29.

Separate implementation policy from call visibility

The agreed design has three implementation policies:

DeclarationRequirement on a matching class
def operation(...): passSupply an inherent method matching the signature and visibility.
def operation(...): <body>Supply an override matching the signature and visibility, or use the protocol body.
@final above def operation(...): <body>Do not declare an inherent method of that name; use the protocol body.

Visibility is independent: pub permits application calls through the bound; a non-public hook is available to authorized protocol code. A final method requires a body; @final on a pass body is contradictory and is rejected. An explicit unit-returning body can represent an implemented no-op.

@final is written on the line above the method, like Python’s typing.final. final communicates that an implementation cannot be replaced; fixed would emphasize ownership of the algorithm, but resolves no additional semantic issue. Method annotations defines the @ syntax.

Enforce final at the structural boundary

Matching S: DocumentStorage requires:

  • Every required method exists with exactly matching signature and visibility, and any necessary access grant.
  • Every supplied override matches the protocol signature and visibility exactly.
  • No inherent method has the name of a final protocol method.

Check the entire contract even if the generic function never calls save. Report the failed bound at its use, with notes identifying both the final declaration and the conflicting concrete declaration.

The class remains valid on its own. It simply does not satisfy this protocol. No global scan of all classes and protocols is needed. Explicit DocumentStorage.save(&mut value, ...) enforces the same complete contract rather than offer a way around conformance.

Name-only conflict detection follows the agreed rule: a same-named inherent method fails the match, including an incompatible signature or private method. There is no visibility-based exception to the final restriction. An internal class edit can therefore break conformance. Field/method name collisions still need an explicit rule before implementation.

Exact signature matching uses Plenty’s existing structural signature rules, including receiver borrowing, type parameters and return type; parameter names need not be identical. Visibility is an additional equality requirement.

Once protocol-aware selection exists, this final-name check is a relatively small addition to conformance checking. It does not require the broader access grant machinery below. It also does not resolve how calls find protocol methods.

A coherent initial lookup rule

Select methods through an explicit bound and through protocol qualification, without automatically adding methods to concrete objects:

def persist[S: DocumentStorage](storage: &mut S, text: &str):
    storage.save(text)       # Final protocol implementation.

DocumentStorage.save(&mut files, text)  # Same contract and selection.
files.save(text)             # No injected inherent method.

For an ordinary default, calls through the bound choose the compatible inherent override if present. For a final method they always choose the protocol body. Calls from a final body to a replaceable method follow the same rule. A qualified call selects the same operation, including its override, to avoid two meanings of the public API. Explicitly invoking the fallback body would need a separate feature; it is outside the agreed design.

The earlier alternative where qualification always selects the original protocol body was not selected. Fixed selection is expressed with final; ordinary implemented methods provide overridable defaults.

A class author supplies hooks and optional overrides. Protocol code operates on that existing object, subject to normal ownership and borrowing. It adds no fields, ownership, allocation, or runtime wrapper. However, methods available through a bound can differ from those available on the concrete object. This needs teaching and completion support; the examples make the difference visible.

What non-public means

The desired interface is:

pub protocol DocumentStorage:
    def write(self: &mut DocumentStorage, document: &str) -> Result[(), IoError]:
        pass

    @final
    pub def save(self: &mut DocumentStorage, document: &str) -> Result[(), IoError]:
        validate(document)?
        self.write(document)?
        record_success()
        Ok(())

write is the hidden customization hook, and save is the public entry point. Three permissions must be distinguished:

  1. Calling write through a DocumentStorage bound.
  2. Calling the concrete object’s inherent write.
  3. Letting a protocol defined elsewhere call that concrete private method.

Visibility matching requires the concrete write to be non-public too; a public implementation fails conformance. Existing module privacy remains: code in the class’s defining module can still call its private methods. Class-only privacy and literal “only protocol bodies may call this method” were considered and not selected.

For the third, structural matching alone should not authorize access to unrelated private members. Otherwise application code could declare its own protocol with a private write requirement and a public forwarding method, then use matching to obtain the same access. This would defeat existing module privacy.

The agreed class-side declaration annotates the hook itself:

pub class FileStorage:
    @protocol.DocumentStorage
    def write(...):
        ...

This grants methods defined inside the named protocol access to this specific hook. It grants no access to other private methods or fields. A hook used by two protocols carries two annotations, one per line, in any order.

The compiler checks the annotation where the class is defined. The named protocol must declare a non-public requirement with the method’s name, and the method must match that requirement’s signature and visibility exactly; otherwise the annotation is an error at the method. This reports a mismatched hook at its declaration instead of at a later bound. The annotation grants access only: it does not declare conformance, and classes without private hooks still match structurally with no annotation. Any method defined inside DocumentStorage, including a later addition, may call write. Unrelated protocols with identical signatures receive no permission.

The grant is lexical, not inherited from the caller. A standalone helper in the protocol’s module does not receive it merely because a protocol method calls the helper. Keep a hook-calling helper as a private implemented protocol method when it needs the grant. Ordinary functions in the concrete class’s module still have their existing module access. If the class and protocol share a module, that ordinary access remains available independently of the grant.

Broad grants to any protocol, module-wide switches, and a grant to all of one protocol’s requirements were considered and not selected. Member-specific permission makes expansion deliberate. An earlier class-level allow DocumentStorage.write declaration was replaced by the method annotation, which keeps the grant beside the hook and needs no repeated method name.

The grant is the one place a class must import a protocol: its module imports the protocol’s module. Import cycles are compile errors that name the dependency chain, and imports run no code, so Plenty has no partially initialized modules. A cycle would need the protocol’s module to import the class’s module. Protocol bodies are checked against the protocol’s own contract, so they never name a concrete class. Keep the contract in a module independent of concrete backends; anything that names a backend, such as a default factory, belongs in a third module. Public hooks still need no import.

No access scheme prevents an implementation from exposing a separate operation or performing its own I/O; the guarantee concerns the declared interface.

Method annotations

@ introduces a compiler annotation on the line above a def. The set is closed: @final on protocol methods and @protocol.Name on class methods, where Name resolves as a protocol in the class’s module (for example @protocol.DocumentStorage or @protocol.library.DocumentStorage). Any other annotation is an error. Annotations change no signature, body or call; they state facts the compiler checks, so their order does not matter.

final and protocol are reserved words, so annotation names cannot collide with user declarations. User-defined decorators are deferred and may never be added: generic wrappers need variadic generics (B36), Plenty runs no code at import, and a wrapper closure could allocate and change the signature. A later decorator design must keep the wrapped signature unchanged and must not alter the meaning of these annotations.

The annotation mechanism now exists, with one member: @copy on class declarations (copy classes, issue #535). The parser reads each @name alone on its line against the closed set and rejects unknown names, arguments, repeats, and annotations where no declaration accepts them. Names after @ are matched apart from identifiers, so @copy reserves nothing and the builtin copy(value) keeps its name; @final and @protocol.Name likewise do not need their names reserved. Adding them means adding members to that set and accepting annotations before def in protocol and class bodies, which the parser rejects today.

Check provided bodies against the contract

Agreed requirement: check protocol bodies against their declared contract, without depending on extra members of any particular implementing class. Every method or operation used on the implementing type must be justified by that contract and the ordinary operations available for that type parameter. A call to an undeclared self.bonus() is a definition-time error even if every currently used class happens to have a compatible bonus.

This applies to default and final bodies alike, including aliases, nested closures, and calls through helpers. Helpers must have sufficient declared constraints for the call to be checked; unconstrained per-instance checking must not become an escape from the protocol contract. Member access grants authorize access to declared hooks; they do not add undeclared members.

Report contract violations at the protocol definition. Concrete specialization can still perform lowering and concrete checks, but cannot make an invalid protocol body valid by discovering additional methods or fields on the object. This requires abstract contract checking beyond today’s per-instance generic body checking. A syntactic scan for self.method is insufficient.

Whether to extend the same discipline to all ordinary generic functions is outside this decision. The implementation must nevertheless enforce it across helper calls originating in a protocol body.

Remaining specification work

The core behavior above is settled. This is not yet a complete implementation specification or approval to implement the feature.

Compatibility: today’s protocol requirements use bare def and remain callable from bounded generic functions. Reinterpreting every bare def as a hidden hook requires a deliberate stdlib, example, and tutorial migration. Adding a final member can also invalidate existing structural matches.

Abstract checking machinery: specify how required, default and final methods, method-local bounds, helper contracts, and definition-site access are represented during checking before a concrete implementing type is known. Contract checking is required, not an optional later enhancement.

Inference and overlap: identify the protocol from the bound, not from the concrete receiver alone. Two bounds on different parameters can refer to the same concrete type and select different provided bodies. Combined bounds remain outside this design; qualification selects a protocol without global discovery. Rules for underdetermined type arguments and receiver aliases need precise specification and regression cases.

Access and collision edge cases: specify access in closures lexically nested inside protocol methods, and fields sharing a provided method name. These should be resolved explicitly rather than left to accidental behavior of generated functions.

State and validity: final freezes an algorithm, not its results. Hook contracts still define partial failure, side effects, and reentrancy. Persistent accounting/state must live in the object or a wrapper; a protocol supplies no new storage.

The @final and @protocol.Name spellings are agreed. Diagnostic wording can be decided without reopening dispatch.