Hello, Swift community!
I would like to solicit discussion about the possibility of allowing deinit
inside ~Copyable
types to be marked as @available(*, unavailable)
for the purpose of enforcing the use of a consuming method.
To provide some context, here's the primary use case I had that drove me to wish for such a feature:
struct File: ~Copyable {
init(_ path: FilePath, _ mode: FileDescriptor.AccessMode) throws {
fileDescriptor = try .open(path, mode)
}
deinit {
do {
try fileDescriptor.close()
} catch {
/*
* Uh-oh!
* A pending write operation had failed.
* There's nothing we can do here, but accept the silent data loss.
*/
assertionFailure("error while closing file: \(error)")
}
}
consuming func close() throws {
discard self
try fileDescriptor.close()
}
private let fileDescriptor: FileDescriptor
/* Some `borrowing` methods for reading and writing... */
}
func doSomething() throws {
let file = try File("/dev/stdout", .writeOnly)
try file.write(/*...*/)
/* We forgot to manually close the file, so we're at risk of silent data loss. */
}
With the ability to mark deinit
to be marked as @available(*, unavailable)
, this use case becomes a lot safer:
struct File: ~Copyable {
init(_ path: FilePath, _ mode: FileDescriptor.AccessMode) throws {
fileDescriptor = try .open(path, mode)
}
@available(*, unavailable, message: "use close() explicitly")
deinit {
assertionFailure("not reachable")
}
consuming func becomeNoLongerOpen() throws {
try fileDescriptor.close()
// ERROR: `deinit` is unavailable: use close() explicitly
// FIX-IT: consider using `discard self`
}
consuming func close() throws {
// okay: `discard self` prevents an implicit call to `deinit`
discard self
try fileDescriptor.close()
}
private let fileDescriptor: FileDescriptor
/* Some `borrowing` methods for reading and writing... */
}
func doSomethingBad() throws {
let file = try File("/dev/stdout", .writeOnly) // ERROR: `deinit` is unavailable: use close() explicitly
try file.write(/*...*/)
// NOTE: `deinit` implicitly called from here
}
func doSomethingGood() throws {
let file = try File("/dev/stdout", .writeOnly)
try file.write(/*...*/)
try file.close()
// okay: consuming method call prevents an implicit call to `deinit`
}
With this feature, a successfully initialized instance of File
is guaranteed to never go without an explicit call to close()
, because the compiler will not accept code that leaves the instance to deinitialize implicitly.
Fun fact: discard self
is now the only way to end the lifetime of the instance. If a consuming method does not discard self
, the instance will have to be moved out to another scope, but that other scope will also not have any way of getting rid of the instance, so eventually the instance will have to be moved back into a method of the type itself for the purpose of triggering discard self
.
Another fun fact: If a type has no consuming methods and also has an unavailable deinit
, then all instances of that type will be immortal (e.g. globals).
Has anyone else wished this was a thing? Are there any major issues about this that I'm forgetting?