-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Description
Proposal
This pre-RFC proposes a new syntactic construct, tentatively named errdefer, inspired by Zig’s errdefer, to associate error-only cleanup logic with a try block.
Since we have a try block expression (#3721) without an "Ok" wrapped result (rust-lang/rust#70941) to distinguish the "errors" to spawn, we can have a block to handle the duplicated cleanup logics during error handling.
The syntax introduces a trailing block:
let result = try {
/* main logic */
} errdefer {
/* cleanup logic */
};The errdefer block is executed if and only if the try block does not produce a successful value; basically, the value is the last expression of the try block.
Motivation
The recently introduced try { ... } block expression (#3721) allows structured error propagation without immediately wrapping values in Ok. However, it currently provides no mechanism to express error-path-specific cleanup logic in a structured and borrow-checker-friendly manner.
In real-world code, especially when interacting with external resources or undergoing multi-step state transitions, cleanup logic often needs to run only when a try block does not complete successfully, i.e., when it exits due to error propagation or early control flow (return, break, or continue). Today, this typically requires:
- check after returned,
- ad-hoc RAII guards,
- or duplicated cleanup code across error branches.
These approaches either reduce readability or interact poorly with borrowing and move semantics.
For example:
// Check after returned
{
let result = self.foo();
if result.is_err() {
/* Break the relation of `foo` */
self.foo_cleanup();
}
result
}
// RAII guards
{
let guard = ErrDefer::new(|| {
// Register a drop function that runs when an error returns
});
foo()?; // execute drop function
guard.assume_ok(); // consume and cancel the drop function
Ok(ok_value)
}Semantics
This syntax can be roughly desugared to inlining the errdefer blocks at the break points.
try {
foo()?
} errdefer {
bar();
baz();
}will be the same semantics to
try {
match Try::branch(foo()) {
ControlFlow::Continue(v) => v,
ControlFlow::Break(r) => {
{ // past or call the `errdefer` block here
bar();
baz();
}
return /* try block desugar with `r` */;
}
}
}- The
errdeferblock is executed when control flow exits the associatedtryblock without producing its success value, including:- error propagation via
?operators - early exits via
return,break, orcontinue.
- error propagation via
- The
errdeferblock is executed before local variables of thetryblock are dropped. - The
errdeferblock is not executed if the try block completes successfully at the last expression of thetryblock. - The
errdeferblock does not handle the unwind logic. It won't execute when thetryblock panics. - The
errdeferblock is lexically associated with thetryblock and has access to bindings that are guaranteed to be live at all error exit points. Soerrdeferwon't borrow anything before errors, unlike RAII guards. - The
errdeferblock should have a unit value()at the last expression, or no expression at the end, since it only deals with the cleanup logic, not changing return values. - Different from the
tryblocks,?operators in theerrdeferblock belong to the super scope, just as thereturn/break/continue. - Both homogeneous and heterogeneous
tryblocks can have anerrdeferblock. - If a
tryblock has no error or early return branches, itserrdeferblock will be considered as dead code and unreachable.
Example:
let x;
/* ... */
let result = try {
let y;
/* ... */
let f_moved = foo(&y)?;
if cond {
let cond_local;
/* ... */
return;
}
bar(f_moved)?
} errdefer {
// `return` & `?` branches are going to the `errdefer` block
// we can use `x`, `y`, and the variables came from the outer scope
self.unregist(&y);
self.channel_a.off().unwrap();
self.channel_b.off()?; // not related to the `result`, which is the super scope things
};Early return statements
The return, break, and continue break the try block into its super scope. We currently can not distinguish the false-positive cases, such as return Ok(()); of the super scope. Therefore, this RFC conservatively assumes that this usually represents a mishandling behavior.
// loop {
try {
let Some(x) = x_wrapped else {
// May not be an error,
// false positive here, still enter `errdefer` block.
// Needs to be moved outside
continue;
};
} errdefer { /* ... */ }Nested try and errdefer
errdefer blocks may contain try expressions with their own errdefer blocks. Each errdefer is isolated from others; the semantics are the same.
try { /* ... */ } errdefer {
// `try` expression cannot be placed at the last expression of the `errdefer` block
let result = try {
cleanup1()?;
cleanup2()?;
} errdefer {
eprintln!("error during cleanup");
};
}Can errdefer be added to other expressions or functions?
No, because only the try blocks can distinguish between the success value and the error values. This is thanks to the no-Ok/no-Some wrapping.
Future possibilities
Multiple success branches
If there are break try $expr statements, the errdefer block can also handle multiple success branches just like the single one.
try {
if cond {
break try foo()?; // Ok-wrapped
}
/* ... */
bar()?
}Multiple errdefer blocks
For more precise error handling, the errdefer block may specify a local variable in the try block, and only execute when this variable is accessible. The execution is ordered as they are defined.
try {
let x = foo1()?;
foo2(&x)?;
let y;
foo3()?;
foo4(x)?
} errdefer x {
/* error on foo2 & foo3 will enter this branch */
} errdefer (x, y) {
/* error on foo3 will enter this branch */
} errdefer {
/* For all cases */
}But the shadowed variables need to define a syntax to "match" them, and the types cannot be wildcard _:
try {
let x: i32;
let x: u32;
let y: ();
} errdefer x ☃️ i32 {
/* ... */
} errdefer (x, y) ☃️ (u32, ()) {
/* ... */
}
try fn with errdefer block
If we have try fn functions, which have a try-wrapped function body, we can add errdefer as well.
try fn send(&mut self, doc: &[u8]) -> std::io::Result<usize> {
/* main logic */
self.buf.write(doc)?;
self.buf.cursor_pos()
} errdefer {
/* cleanup logic */
self.buf
.try_close()
.inspect_err(|e| eprintln!("Error when close: {e}"))
.ok();
}Closures are fine since they have formatting benefits on return-position blocks.
let f = || try {
/* ... */
} errdefer {
/* ... */
};A similar defer block syntax
In Zig, defer affects both error and non-error cleanup code.
Since the cleanup code can be simply added after the try blocks or use RAII guards, if we have it, it is just a syntax sugar and can be added to any scoped blocks, not limited to try blocks, which can be confusing with today's Drop impls.
Open Questions
- Final keyword choice? (
errdefer,else,final, etc.)