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:
| Declaration | Requirement on a matching class |
|---|---|
def operation(...): pass | Supply 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:
- Calling
writethrough aDocumentStoragebound. - Calling the concrete object’s inherent
write. - 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.