Feature request: `defer` statement
Summary
I would like to write defer <expr>; instead of try {} finally { <expr>; } .
Motivation
A common pattern is to have a try-finally statement with a single line in the finally block to cleanup a single resource, specially with FFI code. The try-finally takes 3 lines of code, increases nesting and moves related code far away.
Examples
- For example the following code from the Dart SDK:
Click to hide
Future<Null> writeComponentToFile(
Component component, String path, Library wantedLibrary) async {
File output = new File(path);
IOSink sink = output.openWrite();
try {
BinaryPrinter printer =
new BinaryPrinter(sink, libraryFilter: (lib) => lib == wantedLibrary);
printer.writeComponentFile(component);
} finally {
await sink.close();
}
}
Can be reduced by 3 lines with defer:
Click to hide
Future<Null> writeComponentToFile(
Component component, String path, Library wantedLibrary) async {
IOSink sink = File(path).openWrite();
defer await sink.close();
BinaryPrinter printer =
new BinaryPrinter(sink, libraryFilter: (lib) => lib == wantedLibrary);
printer.writeComponentFile(component);
}
- Another example code, from the
win32package:
Click to show
/// Creates a new GUID.
factory Guid.generate() {
final pGuid = calloc<GUID>();
try {
CoCreateGuid(pGuid);
return pGuid.toDartGuid();
} finally {
free(pGuid);
}
}
Click to show
/// Creates a new GUID.
factory Guid.generate() {
final pGuid = calloc<GUID>();
defer free(pGuid);
CoCreateGuid(pGuid);
return pGuid.toDartGuid();
}
- Sometimes the cleanup code is too far away from the variable declaration site:
Click to show
@visibleForTesting
static Future<Uri> maybeStartDDS({
required Uri uri,
required String ddsHost,
required String ddsPort,
bool machineMode = false,
}) async {
final pathSegments = uri.pathSegments.toList();
if (pathSegments.isNotEmpty && pathSegments.last.isEmpty) {
// There's a trailing '/' at the end of the parsed URI, so there's an
// empty string at the end of the path segments list that needs to be
// removed.
pathSegments.removeLast();
}
final authCodesEnabled = pathSegments.isNotEmpty;
final wsUri = uri.replace(
scheme: 'ws',
pathSegments: [
...pathSegments,
'ws',
],
);
final vmService = await vmServiceConnectUri(wsUri.toString());
try {
// If this request throws a RPC error, DDS isn't running and we should
// try and start it.
await vmService.getDartDevelopmentServiceVersion();
} on RPCError {
// If the user wants to start a debugging session we need to do some extra
// work and spawn a Dart Development Service (DDS) instance. DDS is a VM
// service intermediary which implements the VM service protocol and
// provides non-VM specific extensions (e.g., log caching, client
// synchronization).
final debugSession = DDSRunner();
if (await debugSession.start(
vmServiceUri: uri,
ddsHost: ddsHost,
ddsPort: ddsPort,
debugDds: false,
disableServiceAuthCodes: !authCodesEnabled,
// TODO(bkonyi): should we just have DDS serve its own duplicate
// DevTools instance? It shouldn't add much, if any, overhead but will
// allow for developers to access DevTools directly through the VM
// service URI at a later point. This would probably be a fairly niche
// workflow.
enableDevTools: false,
enableServicePortFallback: true,
)) {
uri = debugSession.ddsUri!;
if (!machineMode) {
print(
'Started the Dart Development Service (DDS) at $uri',
);
}
} else if (!machineMode) {
print(
'WARNING: Failed to start the Dart Development Service (DDS). '
'Some development features may be disabled or degraded.',
);
}
} finally {
await vmService.dispose();
}
return uri;
}
Click to show
@visibleForTesting
static Future<Uri> maybeStartDDS({
required Uri uri,
required String ddsHost,
required String ddsPort,
bool machineMode = false,
}) async {
final pathSegments = uri.pathSegments.toList();
if (pathSegments.isNotEmpty && pathSegments.last.isEmpty) {
// There's a trailing '/' at the end of the parsed URI, so there's an
// empty string at the end of the path segments list that needs to be
// removed.
pathSegments.removeLast();
}
final authCodesEnabled = pathSegments.isNotEmpty;
final wsUri = uri.replace(
scheme: 'ws',
pathSegments: [
...pathSegments,
'ws',
],
);
final vmService = await vmServiceConnectUri(wsUri.toString());
defer await vmService.dispose();
try {
// If this request throws a RPC error, DDS isn't running and we should
// try and start it.
await vmService.getDartDevelopmentServiceVersion();
} on RPCError {
// If the user wants to start a debugging session we need to do some extra
// work and spawn a Dart Development Service (DDS) instance. DDS is a VM
// service intermediary which implements the VM service protocol and
// provides non-VM specific extensions (e.g., log caching, client
// synchronization).
final debugSession = DDSRunner();
if (await debugSession.start(
vmServiceUri: uri,
ddsHost: ddsHost,
ddsPort: ddsPort,
debugDds: false,
disableServiceAuthCodes: !authCodesEnabled,
// TODO(bkonyi): should we just have DDS serve its own duplicate
// DevTools instance? It shouldn't add much, if any, overhead but will
// allow for developers to access DevTools directly through the VM
// service URI at a later point. This would probably be a fairly niche
// workflow.
enableDevTools: false,
enableServicePortFallback: true,
)) {
uri = debugSession.ddsUri!;
if (!machineMode) {
print(
'Started the Dart Development Service (DDS) at $uri',
);
}
} else if (!machineMode) {
print(
'WARNING: Failed to start the Dart Development Service (DDS). '
'Some development features may be disabled or degraded.',
);
}
}
return uri;
}
- A reason to keep related code together in the example above or when calling
free(ptr)is to avoid declaring the variable, writing the next lines of code and (the most important step) forgetting about cleaning resources in the end.
Implementation details
Deferred code declared last should run before deferred code declared first. The overhead of finally is minimal, because it doesn't collect the stacktrace.
Extras
An optional desirable feature is to have an IDE option to convert a finally block with a single statement to a defer statement.
This is useful but a bit too specific.
We can already do something with callbacks:
Future<Null> writeComponentToFile(
Component component,
String path,
Library wantedLibrary)
=> defer((defer) {
IOSink sink = File(path).openWrite();
defer(() => sink.close());
BinaryPrinter printer =
new BinaryPrinter(sink, libraryFilter: (lib) => lib == wantedLibrary);
printer.writeComponentFile(component);
});
This is a pattern that Riverpod uses:
final provider = StreamProvider<int>((ref) {
final controller = StreamController();
ref.onDispose(() => controller.close();
// TODO add values in the controller
return controller.stream;
});
IMO the ideal solution would be to empower function composition. Think middlewares or currying
So instead of:
Result example(...) => someUtil((value) {
});
We could write:
@someUtil
Result example(Value value, ...) {
}
So the effect is that:
stmts1;
defer stmt;
stmts2;
} // end of block
is equivalent to
stmts1;
try {
stmt;
} finally {
stmts2;
}
} // end of block.
Whatever result the defer's stmt has, it is remembered, and them stmts2 is executed.
If that completes normally, the result of stmt is used, otherwise the result of stmts2 is used.
It's possible. I don't think it's particularly readable, though.
@lrhn the line with defer will run after the current function. Would be equivalent to:
stmts1;
try {
stmts2;
} finally {
stmt;
}
} // end of block.
This is useful but a bit too specific.
I gave an example with a simple finally block, but it works with 2 or more lines too.
Click to show
try {
...
} finally {
free(c);
free(b);
free(a);
}
Could be written as:
...
defer free(a);
defer free(b);
defer free(c);
Or even:
defer () {
assert(identical(toStringVisiting.last, m));
toStringVisiting.removeLast();
}();
I kept the request short, but defer {} would look nicer.
OK, so it's the defer that goes later, That makes more sense too, with the name - it's deferring the execution, not deferring the result until after the rest of the block.
It's deferred until the end of the function. Could also be until the end of the current block.
There are still things that worry me here. If defer is executed inside a block, it goes at the end of the function. If it's executed inside a loop, you are scheduling an unbounded number of "at end" executions. Likely to be run in reverse order. Not a problem as such.
Also, if the code refers to local variables, they must be kept alive until the end of the function, which means creating a closure.
That makes it more expensive than a plain try/catch (but maybe not if the logic of that would need a closure anyway).
It's still inside the same function, which is good because then it's still async if it needs to be.
(Can you defer a yield in an async* function? Probably.)
Something like this can be implemented manually as:
import 'dart:async';
R deferScope<R>(
R Function(void Function(FutureOr<void> Function()) defer) body,
) {
List<FutureOr<void> Function()> deferred = [];
FutureOr<void> runDeferred() {
while (deferred.isNotEmpty) {
var last = deferred.removeLast();
try {
var result = last();
if (result is Future<void>) {
return result.whenComplete(runDeferred);
}
} catch (e, s) {
var result = runDeferred();
if (result is Future<void>) {
return result.then((_) => Future.error(e, s));
}
rethrow;
}
}
}
try {
var result = body(deferred.add);
if (result is Future) {
// Always succeeds, `result.whenComplete(...)` has same type as `result`.
return result.whenComplete(runDeferred) as R;
}
return result;
} catch (e, s) {
var result = runDeferred();
if (result is Future<void>) {
// You shouldn't have async deferred if `R` does not allow `Future`.
// Maybe have two function, one `deferScope` and one `asyncDeferScope`.
return result.then<Never>((_) => Future<Never>.error(e, s)) as R;
}
rethrow;
}
}
used as:
Future<int> foo(int arg) => deferScope((defer) async {
// function body with
defer(() async => print("All done!"));
// and
defer(() async => print(arg));
return arg++;
});
void main() async {
await foo(42);
print("Done");
}
I don't think making this a language feature will make it must more efficient. It'll be a little less verbose, and it can specialize for whether the function is async or not, but then this one probably should too.
That's what I mentioned before
IMO the main issue here is that the syntax for doing it manually sucks. I wish we had better function composition to avoid the "function in function"
Thanks @lrhn! The approach looks interesting. I will give it a try.
I wish we had better function composition to avoid the "function in function"
I agree a language feature can make it more readable. Lombok has a similar feature, the @Cleanup annotation.
this would be very helpful for awaiting ,at the end ,to prevent loosing the asynchrony ,because dart lacks ownership semantics
For reference: how defer works in
C: https://thephd.dev/c2y-the-defer-technical-specification-its-time-go-go-go
Go: https://go.dev/ref/spec#Defer_statements
Zig: https://ziglang.org/documentation/master/#defer
I like how C and Zig boobs the deferred code to the scope exit, not the function exit. That ensures that variables are still in scope.
The C rules Short control flow are complicated, but the language has goto, so it might be warranted.
If you are inside a scope block and can execute statements, then it's probably a <statements> sequence. Then the statement sequence
s1
defer s2
s3
would be completely equivalent to
s1
try {
s3
} finally {
s2
}
if the original was valid. (The rewrite introduces new scopes, but if the variable declarations were valid, then they would still be.)
What I prefer about the explicit try/finally is that the code of work on the order it executes.
But nobody loves the extra indentation.
That said, the defer writers the next code that is guaranteed to execute next, and anything after that might never run ... It can throw as the first thing and the defer will then run next.
What if we allowed a stand-alone finally statement in a <statements>:
<statements> ::= <statement>*
(<statement> <statementsFinally>)?
<statementsFinally> ::=
'finally' ':' <statements>
Maybe we can drop the colon, and handle ambiguity with try/finally by making finally boobs to a preceding try/catch of there is one.
(Or make it 'finally' <statement>, so you need a block of its now than one statement. Or something similar.)
Point is that we use finally to introduce a finally block that is the tail of a <statements> without indenting the head.
Part of the utility of defer is that it's next to 'resource acquisition'. This makes it very easy to check when reading the code that something will be properly cleaned up, and when writing the code you're less likely to forget it too.
I like the idea of reusing finally, but it'd have to be in a way where the behavior between the finally in try-catch is not much different to the defer finally. The way you described it, as trailing a statements would be that, but you'd be leaving some of the ergonomics and appeal of defer on the table, as I described above.
I also kinda share the concerns of Remi, that something more flexible than defer may be needed. C, Go and Zig are all 'simple' languages; none of them are OOP and the programming style and kind of programs written in them are vastly different. Something like RAII would fit Dart better, but isn't really feasible bc of the GC.
The with keyword / context managers in Python goes halfway there and lets objects describe the cleanup code necessary when the with block they are bound to goes out of scope https://docs.python.org/3/reference/compound_stmts.html#with
I personally only really write Flutter apps and libraries for Flutter apps in Dart and I struggle to see places where defer would've been a big benefit (not saying it wouldn't have been an improvement, just not something I'd care much about in comparison to other items on this tracker).
Lots of Flutter code ends up having .dispose methods you have to manually call to close resources and prevent leaking callbacks, subscribers etc.
And I don't see how defer would help with that.
Maybe it's more useful when writing servers or CLIs with Dart though 🤷♀️.
The with keyword / context managers in Python goes halfway there and lets objects describe the cleanup code necessary when the with block they are bound to goes out of scope https://docs.python.org/3/reference/compound_stmts.html#with
Python's with is effectively equivalent to Java's try-with-resources and C#'s using as far as I know (the latter doesn't require nesting). Flutter code most of the time requires creating a disposable object in initState and performing cleanup in another method, dispose. I guess these features won't help more than defer.
Something like RAII would fit Dart better, but isn't really feasible bc of the GC.
You can create an issue to have limited RAII support if it would solve the issue, but I'm unsure if it would work well.