I'd like to summarize the arguments here to help us come closer to a decision.
Linear types (~Deinitable) would make a forgotten close() a compile error, and many of us prefer compile-time enforcement (#12, #17, #21, #25, #39). However, linear types aren't guaranteed to happen, or to happen any time soon (#14, #15). Agreeing that System's new types are best expressed as linear types could help prioritize the feature (#14).
Narrowing the question
Let's assume that ~Deinitable isn't available yet. A noncopyable resource type's deinit can then trap or close. (A deinit that does neither silently leaks resources, so we'll ignore that case.) Either option can still trap on an ownership violation, such as a close that fails with EBADF (pitch #31).
How do we decide whether Trap or Close is better?
Let's assume our type has a consuming, throwing close() that reports any error from the underlying close function (e.g. close or CloseHandle). Using this close(), the two options differ only when a handle reaches its deinit, where Trap crashes and Close closes the handle. Any code that works under Trap, including its error handling, would behave and perform identically under Close.
So the better option depends on what developers will write, not what they can write. Most benefits of Trap come from the fact that a developer or library author must write an explicit close() on every path, or else the app/server will crash.
Each argument below is followed by a counterargument. Expand the summaries to see more details and sources.
Arguments for Trap
Trap: Explicit close() calls leave a syntactic trace that makes lifetimes easier to reason about.
- Swift requires
tryandawaitso that readers can see the effects. A close can block, fail, and change kernel state, so a close deserves the same visibility (pitch #22). - Handles are limited, so programs need to control exactly when they're released (#19, pitch #14).
- An innocuous-looking change can move an implicit close. The change's diff might not show where the handle was opened (#17, pitch #21).
For example, suppose only one branch closes the handle. On the other path, deinit closes the handle where the branches rejoin (#38, #43):
let handle = try Win32.FileHandle.open(...)
if shouldClose {
try? handle.close()
}
// If `shouldClose` is false, `deinit` closes `handle` here, before `finish()`
finish()
Close: Code that needs a precise close point can still call close(), and the compiler then keeps that point from moving.
- An explicit
close()at the end of a scope makes any earlier consume a compile error (pitch #16). - A handle that's never consumed closes where a
defer { try? handle.close() }written right after itsopenwould run, ifdefercould consume the handle (pitch #16). - Today, a handle consumed on only one path closes where the paths rejoin. John says that's intended (#42), and Alejandro calls it a bug (#41). Kavon offered to move that close to the end of the scope in a new move checker (#46).
- Unlike a missing
try, a missingclose()isn't a compile error with either option. Trap only catches a missingclose()on paths that run.
Trap: Requiring an explicit close() encourages developers to perform error handling.
- The developer must write
try handle.close()or acknowledge the discarded error withtry? handle.close(). - Swift doesn't let you omit
tryjust because you plan to ignore the error (#22). - A closing
deinitalways loses the error (pitch #21). - On some platforms, close can report a deferred write error (#6). A program could log that error or show it to the user (#34).
Close: A program can rarely act on a close error, and developers who wish to perform error handling will do so anyway.
- Linux, the BSDs, and Darwin release the descriptor even when close fails, so a failed close shouldn't be retried (#13, #37).
- Many closes can't fail in any meaningful or actionable way; Trap adds complexity and noise for these cases (#23, #31).
- To catch lost writes, a program has to call
fsyncorFlushFileBuffersbefore closing. Neither option enforces those calls (#13). - A developer who can't act on the error will write
try? handle.close()at the end of the scope. Thattry?is equivalent to a closingdeinit, except that thedeinitalso avoids accidental crashes on error paths (pitch #7).
Trap: The ecosystem should follow one pattern for non-memory resources, and some resources require trapping in deinit.
- The pattern should also cover clients, requests, and queries (#17).
- Many resources have
asyncor throwing cleanup thatdeinitcan't express (#17, #38). - Some resources must be released from a specific isolation. Isolated
deinits likely won't come to noncopyable types (#12). - RAII should only be used for resources whose release can't fail except by programmer error (pitch #24). The release also shouldn't block for long and should either be opt-in or have no observable timing.
Close: One rule fits every resource: trap only where deinit can't release the resource correctly.
- Under that rule,
Continuationand NIO'sasyncfile handle keep their traps. Windows handles and Mach ports close indeinit(#7). - The choice should depend on the resource. Many resources can be released without reporting real effects to the caller (#20).
- SE-0390's motivating example is a noncopyable
FileDescriptorwhosedeinitcloses the descriptor (#36). Franz and others repeatedly objected to such resource examples in the~Copyableproposals (#38).
Trap: Automatic disposal should be an opt-in wrapper, not the default.
- The default should be that the code holding a handle is responsible for closing it. Delegating that responsibility, for example with a wrapper like
RAII<FileDescriptor>, should be an explicit choice (pitch #21).
Close: A wrapper can't opt a developer out of crashes in someone else's code.
- An app's wrapper doesn't cover handles that a library opens or accepts. Whether those handles trap or close is the library author's choice, not the app developer's. If Trap is the default, a missed
close()in a library can crash the app, and only the library's author can fix the crash. - With Close as the default, the opt-in goes the other way. SYS-0011 suggests a trapping wrapper for those who want it, and only code that adopts the wrapper is affected.
Trap: Trapping keeps our options open, since relaxing a trap later is not source-breaking.
- A closing
deinitcan't start trapping later without crashing clients that rely on the implicit close. - Code written for Trap already closes on every path like a future
~Deinitabletype would require.
Close: Adopting ~Deinitable later would be source-breaking with either option.
- A
~Deinitabletype would no longer fit APIs that take~Copyabletypes, unless they're generalized (#14). Those APIs includeOptional, which Trap's workarounds use. - Aviva suggested staging in
~Deinitablewhile keeping the existingdeinitfor code that hasn't migrated (#15). That approach works whether thedeinittraps or closes. - Even once
~Deinitableexists, a read-only file stream may not need it, since its close can't fail in any meaningful way (#23).
Arguments for Close
Close: Crashing the app/server on a forgotten close() is a poor user experience that can be avoided.
Trap: Most forgotten closes are caught during development.
- A forgotten
close()on a common path crashes as soon as that code path runs (#17). - Crash reports help the developer or library author fix the rest.
Close: Closing in deinit fits Swift's goal of progressive disclosure: simple uses stay simple, and developers can add explicit closes and error handling as they need them.
- The swift.org home page says Swift's "progressive disclosure allows you to pick up the language quickly, taking advantage of power-user features as you need them."
- With Close, a simple use can open a handle and let
deinitclose it, even on error paths. - Code that needs the close error can call
close(). Code that needs durability can callfsyncorFlushFileBuffersfirst. Code that wants every close to be explicit can adopt a trapping wrapper. - With Trap, even the simplest use must close the handle explicitly on every path, including error paths. John notes that explicitly destroying a value on every path is "really laborious," even with compiler checking (#20).
Trap: Progressive disclosure shouldn't hide effects. Swift requires try and await even in simple code.
- The compiler knows which calls throw or suspend, but Swift still requires the markers so that readers can follow the control flow (pitch #22).
Close: With Trap, seemingly innocuous changes can crash the process if errors aren't handled within scope. Handling those errors locally takes cumbersome do/catch blocks that close the handle and rethrow.
In a function that already throws, adding a try between an open and a close looks harmless (#24):
let handle = try Win32.FileHandle.open(...)
+try someMethod()
try handle.close()
But if someMethod() throws, the process now traps instead of propagating the error. The diff doesn't show the new crash.
To propagate the error, Trap requires a local do/catch that closes the handle and rethrows. (Wrapping the handle in an Optional so defer can take() it is another workaround.)
let handle = try Win32.FileHandle.open(...)
do {
try someMethod()
} catch {
try? handle.close() // Ignores the error anyway
throw error
}
try handle.close()
Trap: Scoped helpers and future language features could close the handle on error paths.
- Franz agrees the added-
tryproblem should be solved, but not with implicit closing (#30). - Scoped helpers would eliminate the
do/catchin most cases (#2, #21). - David suggests the clutter is "tertiary to correctness and clarity" (#21).
- The pitched at-most-once functions feature should make a consuming
deferpossible (#3). - A
withsyntax, like Python's context managers, could cover remaining use cases (#30, #38).
Close: Scoped helpers and with blocks can't cover a handle that a function returns, and they must turn errors from the body and the close into one thrown error.
- A function can't return a handle that a helper or
withblock manages (#35). - A consuming
defercan't be combined withreturn handleor an explicittry handle.close(). The handle would be consumed twice, which doesn't compile (pitch #16). - Only a
deferthat skips an already-consumed handle would work. A closingdeinitalready does that (pitch #7). - Swift doesn't let a
deferthrow, so a consumingdefercouldn't propagate the close error (#23). - A helper's closure must also carry every effect, including the thrown error type (#2, #17). David suggests untyped throws for the error type (#4).
- When the body and the close both throw, a helper or
withblock can only throw one error. System'sFileDescriptor.closeAfter(_:)and Franz'swithsketch both drop the close error (#30), just as a closingdeinitdoes.
Trap: In the future, ~Deinitable could cover the handles that helpers can't.
- Franz suggests eventually adopting both context managers and
~Deinitable(#38). Context managers would handle most code.~Deinitablewould give compile-time checks to code that wants manual control.
Close: Language limitations prevent some owners from closing a handle explicitly.
- A class or
InlineArraycan't consume a stored handle (pitch #1). - A handle captured by a task can't be consumed afterward (pitch #1).
- A
lethandle that lends a nonescapable view can't be consumed afterward either (#16). - With Trap, these owners need a workaround, like an
Optionalor aMutexbox. Otherwise, the process crashes.
Trap: Swift should lift those limitations instead of designing APIs around them.
I've tried to include the main arguments from this discussion and the pitch thread, plus a few that hadn't come up yet. Please feel free to pick them apart or add your own!