14. Receiver and resource scopes
Receiver scopes organize access to an object. Resource scopes own explicitly declared resources for a bounded or lexical lifetime. Neither implies ownership of unrelated references.
14.1 Receiver scopes and conditional qualification
CS-USE-001. Use evaluates its receiver once and binds leading dot to the captured receiver. Receiver traversal is strict: a missing receiver or chain segment follows required-access failure, and use never silently skips the body. For a present receiver, its body executes against that object; evaluated getter/call/body exceptions propagate normally. Use does not create, copy, repair or dispose an object. Nested leading-dot access binds to the innermost receiver scope.
let loaded = LoadCustomer(id)
require loaded is not nothing otherwise "Customer was not found"
let customer = use loaded
.Name = "Ana"
.RefreshStatus()
end use
CS-USE-002. Without take, expression-form use over a present receiver returns that same receiver after successful completion. An as T refinement requires a present receiver runtime-compatible with T and binds the body/result to T. Absence follows strict required-access failure; runtime incompatibility follows invalid-cast behavior. Refinement does not invoke value conversion or construct another object.
CS-USE-003. Use_if evaluates its full receiver chain left-to-right once. An absent segment skips qualification and the whole body; later segments, arguments and predicates are not evaluated. Evaluated exceptions still propagate. use_if receiver as T also skips on runtime incompatibility. Additional and predicates execute after presence/type qualification with the narrowed receiver. A top-level or cannot bypass receiver qualification.
use_if customer.Account.Owner and .IsActive
.Notify()
end use_if
Successful body access is ordinary access. Use_if does not keep suppressing later exceptions or absences inside the body. Without take, expression-form use_if yields its receiver on success and nothing when qualification fails.
CS-USE-004. A use/use_if scope permits at most one take, as its final item. It evaluates after previous body statements complete normally and supplies the scope result. Failed use_if qualification supplies nothing without evaluating the projection. Take is not action return; return still exits the enclosing action.
let total = use order
.Recalculate()
take .Total
end use
The order fragment assumes Recalculate and Total members. Branch-dependent projections must compute a local and select it with one final take; take is not a path-local exit from these scopes.
14.2 Bounded resource ownership
CS-RESOURCE-001. Bounded using owns the resources explicitly declared in its header. Acquisition occurs left-to-right and later initializers may reference earlier resources. Successfully acquired resources are cleaned up in reverse order on normal completion, return or exceptional exit. Resource compatibility and cleanup obey the resolved disposable contract. Ordinary disposal exceptions propagate under .NET rules.
action ReadReport(path as String)
returns String
require path is not whitespace
let text = using stream = Global.System.IO.File.OpenRead(path),
reader = new Global.System.IO.StreamReader(stream)
take reader.ReadToEnd()
end using
return text
end action
If reader construction fails, stream is still cleaned up. If reading fails, both acquired resources are cleaned up. Successfully reading the text does not suppress a subsequent cleanup failure.
CS-RESOURCE-002. Value-form using requires one final take. It acquires resources, executes statements, evaluates/captures take, cleans up in reverse acquisition order, then produces the captured value. Statement-form using requires no take. It has no default receiver result: returning a just-disposed resource is not implied.
CS-RESOURCE-004. For bounded using and lexical let using, select cleanup from the resource's resolved static disposable contract and the enclosing execution context. In an asynchronous execution context, prefer IAsyncDisposable when that static contract exposes it; otherwise use IDisposable when available. Asynchronous contexts include explicit or inferred async ordinary actions, async dispose bodies, async iterators and asynchronous owned worker bodies. Determine context through CS-ASYNC-008 before selecting a dual-interface resource's route. In a fixed synchronous context, require IDisposable. A resource exposing only IAsyncDisposable is invalid there: diagnose it at compile time rather than blocking on asynchronous cleanup or inventing a synchronous bridge. Do not inspect a runtime object's additional interfaces to change the statically selected route.
| Resource's resolved static contract | Synchronous context | Asynchronous context |
|---|---|---|
| IDisposable only | Call Dispose | Call Dispose |
| IAsyncDisposable only | Compile-time error | Call and await DisposeAsync |
| Both IDisposable and IAsyncDisposable | Call Dispose | Call and await DisposeAsync |
| Neither disposable interface | Compile-time error | Compile-time error |
A resource viewed statically as IDisposable remains synchronous even if its runtime type also implements IAsyncDisposable. A static IAsyncDisposable view is async-only, even if the runtime type also implements IDisposable. For a mixed group, select each resource's route independently, retain reverse acquisition order and complete each asynchronous cleanup before proceeding to the preceding resource. Selection applies to imported .NET types as well as authored classes; it does not replace the selected type's native disposal behavior or the authored lifecycle rules below.
14.3 Lexical disposable locals
CS-RESOURCE-003. let using resource = expression keeps that owned resource until the enclosing lexical scope exits for any reason. Several declarations clean up in reverse successful acquisition order. An ordinary let has no inferred disposal merely because its type is disposable. Let using is a declaration, not a result-producing block, and has no take.
action ImportReport(path as String)
let using reader = new Global.System.IO.StreamReader(path)
let header = reader.ReadLine()
if header is nothing then
return
end if
Global.System.Console.WriteLine(header)
end action
An early return disposes reader. Resource ownership is explicit; no unrelated local, member or borrowed dependency is included.
14.4 Authored cleanup and finalization
CS-DISPOSE-001. dispose ... end dispose defines a type's deterministic cleanup. Ordinary statements are managed cleanup. The compiler provides normal IDisposable plumbing, a cleanup-lifecycle guard, repeat-success behavior and inheritance-aware cleanup where applicable. A managed-only dispose block does not generate a CLR finalizer.
CS-DISPOSE-002. An optional nested unmanaged ... end unmanaged section identifies cleanup that also belongs in CLR finalization fallback. Explicit disposal executes managed cleanup followed by unmanaged cleanup and suppresses finalization after successful disposal. The generated CLR finalizer runs the unmanaged section only. No separate authored finalizer or manual suppression call is required.
class ReportReader
private stream as Global.System.IO.Stream
constructor(source as Global.System.IO.Stream)
stream = arguments.source
end constructor
dispose
stream.Dispose()
end dispose
end class
This class explicitly treats its supplied stream as owned; callers must agree with that application contract. Merely storing a borrowed dependency would not authorize disposal.
CS-DISPOSE-003. async dispose ... end dispose declares asynchronous authored cleanup with the normal IAsyncDisposable contract. The compiler owns the disposal plumbing. Ordinary awaitable calls in this body require explicit await or task_of intent just as in an action; generated cleanup for nested owned using scopes retains its type-directed waits. Plain dispose remains synchronous IDisposable cleanup and must not silently change its public protocol from its implementation body. An async dispose declaration retains its asynchronous contract even when some/all paths complete synchronously. It does not gain an async-action cancellation parameter or Cancellation context.
class AsyncReportWriter
private writer as Global.System.IO.StreamWriter
constructor(destination as Global.System.IO.StreamWriter)
writer = arguments.destination
end constructor
async dispose
await writer.DisposeAsync()
end dispose
end class
The class explicitly owns the supplied writer. DisposeAsync is an ordinary imported ValueTask-returning .NET API; its completion is explicitly awaited within async dispose. Explicit or generated suspension in a plain dispose is a compile-time error; declare async dispose when cleanup must suspend. The compiler must not silently change the public disposal protocol or block on asynchronous cleanup.
CLR finalization remains synchronous and cannot await. An unmanaged section included in async disposal still supplies synchronous finalizer cleanup; suspension there is invalid. A type requiring asynchronous cleanup must not be silently treated as synchronously disposable. Type-directed using preserves the resource's real protocol and reverse cleanup order without requiring an await using keyword.
CS-DISPOSE-004. A class may declare at most one dispose block and at most one async dispose block. Declaring both exposes the corresponding IDisposable and IAsyncDisposable protocols with one shared cleanup lifecycle. The invoked route performs that route's complete cleanup, including applicable inherited obligations; the compiler does not automatically invoke both authored bodies. Ordinary helper actions may share cleanup work. Successful completion through either route makes later calls through either available route complete without executing another cleanup body. Declaring only async dispose does not synthesize a synchronous blocking bridge or an additional authored dispose body.
CS-DISPOSE-005. While authored cleanup is in progress, another disposal request for the same object throws InvalidOperationException. This includes overlapping calls, recursive calls and calls through the other protocol. The rejected request does not join, restart, cancel or otherwise disturb the active attempt, and does not replace its recorded outcome. If the active cleanup itself propagates that rejection, it fails under the ordinary cleanup-failure rule. Entry and lifecycle transitions must be coordinated so that competing requests cannot both begin cleanup.
CS-DISPOSE-006. A normally catchable failure from the active cleanup attempt propagates and is retained as the object's failed cleanup outcome. Later disposal calls rethrow that original failure, preserving its exception identity and propagation information. With both protocols declared, this failed outcome is shared across both. Do not rerun a partially executed body, retry automatically, switch to the other cleanup route or add a public reset operation. A failing asynchronous cleanup must not be recorded as successful merely because its DisposeAsync call initially returned an awaitable.
| Lifecycle when a disposal request arrives | Required outcome |
|---|---|
| Cleanup has not started | Start the invoked supported route and retain its completion or failure |
| Synchronous cleanup is running; another request uses either protocol | Reject the additional request with InvalidOperationException; the active attempt continues |
| Asynchronous cleanup is awaiting; another request uses either protocol | Reject the additional request with InvalidOperationException; do not join it or block a synchronous caller |
| Cleanup recursively requests disposal of the same object | Reject that nested request with InvalidOperationException |
| Either route completed successfully | Complete without rerunning either cleanup body |
| Either route failed with a normally catchable exception | Rethrow the retained original failure; no retry or cross-protocol fallback |
CS-DISPOSE-007. The shared lifecycle governs compiler-generated public disposal for authored DirectSharp classes. It does not replace the native disposal behavior of imported .NET types or the separately specified Gate disposal contract. In particular, Gate's refusal to dispose while acquisitions are pending/held leaves that Gate usable and permits a later disposal attempt. Applicable inherited cleanup obligations remain in force. Failed public cleanup does not suppress required unmanaged finalization fallback: retaining a failure for later public calls must not prevent independently required unmanaged finalizer cleanup. Successful disposal suppresses finalization as specified in CS-DISPOSE-002. Resource-scope protocol selection is separately defined by CS-RESOURCE-004.
CS-DISPOSE-008. A class may contain at most one unmanaged section, nested in either its dispose block or its async dispose block. That section defines a shared unmanaged cleanup fragment, regardless of which body contains its declaration. Explicit cleanup runs the selected route's managed body, then this shared fragment; it does not execute the other managed body to reach that body's nested section. The same fragment supplies one synchronous generated finalizer fallback for that class. It must be valid from either supported explicit route and from finalization: suspension is invalid, and locals or parameters private to a managed cleanup body cannot supply its required state. Use class state or ordinary synchronous helpers for shared unmanaged cleanup. Existing inherited cleanup obligations remain applicable.
| Cleanup path for a class declaring both protocols and one unmanaged section | Required execution |
|---|---|
| First Dispose call, managed cleanup succeeds | Synchronous managed body, then the shared unmanaged fragment |
| First DisposeAsync call, managed cleanup succeeds | Complete the asynchronous managed body, then run the shared fragment synchronously |
| Shared section is declared inside the other route's body | Run the shared fragment without invoking that other managed body |
| Selected managed body fails before the shared fragment | Retain the public cleanup failure; do not suppress required unmanaged finalization fallback |
| Shared fragment fails during explicit cleanup | Retain the public cleanup failure; do not suppress required unmanaged finalization fallback |
| Public cleanup previously succeeded | Repeat calls run neither a managed body nor the shared fragment |
| CLR finalization is required | Run unmanaged fallback without either managed body or suspension |
The shared fragment does not create a second public cleanup attempt. CS-DISPOSE-005–007 still govern overlapping calls, cached failures and finalizer independence. A failed public call does not retry the fragment through the other disposal protocol; required CLR finalization remains a separate fallback.
14.5 Reference lowering and diagnostics
The report-reading scope can use ordinary nested C# using:
public string ReadReport(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Requirement failed: path is not whitespace", nameof(path));
string text;
using (var stream = System.IO.File.OpenRead(path))
using (var reader = new System.IO.StreamReader(stream))
text = reader.ReadToEnd();
return text;
}
This captures text before cleanup but returns it afterward. Required diagnostics include incompatible resource protocols, multiple/nonfinal take items, value-form using without take, take on let using, unavailable contextual receivers, top-level or bypassing conditional qualification and async finalizer attempts. Cleanup idempotence after a successful disposal is a generated type contract; it does not turn resource-use-after-disposal into a valid operation.
Specification contents · Previous: 13. Exceptions · Next: 15. Collections, enumeration and consumption