Context managers are Python's way to do RAII:
with open("foo.txt", "w") as f:
f.write("bar")
Unlike destructor-based RAII (like C++ and Rust have) where the cleanup is implicit (even if well defined) with context managers the cleanup is explicit - it happens at the end of the context block. This allows an async version where the cleanup is also asynchronous:
async with aiofiles.open("foo.txt", "w") as f:
await f.write("bar")
I think something like this could be good for Rust - it would solve the fallible cleanup issue and the async cleanup issue. Also, context managers are good for more than just resource cleanup.
I'm not suggesting 1:1 feature parity with Python's context managers - there are many things that make sense in one language but not the other - but that's my main source of inspiration.
Syntax
To avoid introducing a new keyword, I'll be borrowing the use
keyword. My precedent is C#, which uses the using
keyword for both importing things from modules and for IDisposable
s (C#'s version of context managers - much less powerful than Python's, but the basic idea is similar)
I also think we should follow the .await
example and make this postfix:
let data = File::open_cm("foo.txt").use f in {
let mut content = String::new();
f.read_to_string(&mut content)?;
content
}?;
Or the async version:
use tokio::fs::File;
let data = File::open_cm("foo.txt").async use f in {
let mut content = String::new();
f.read_to_end(&mut content).await?;
content
}?;
Note that:
.async use
(is this good syntax? I'm not sure I like it...) makes both entering and existing the context asynchronous.- Using
?
and.await
inside the context do not need explicit support from the context manager. Unlike a function that receives a closure - a context manager does not open a new frame, and thus the?
and.await
operate in the same frame as the enclosing function. - The
?
at the end of the context manager
open_cm
here will need to return a value that implements:
pub trait ContextManager<'a, Mid> {
type In: 'a';
type Out;
fn enter(&'a mut self) -> Self::In;
fn exit(self, mid: Mid) -> Self::Out;
fn bail(self);
}
pub trait AsyncContextManager<'a, Mid> {
type In: 'a';
type Out;
async fn enter(&'a mut self) -> Self::In;
async fn exit(self, mid: Mid) -> Self::Out;
async fn bail(self);
}
(we may want to bikeshed the names of the types (In
/Mid
/Out
))
The same type can implement both traits, and the one who gets used will be decided based on whether .use
or .async use
was used.
Semantics
The .use
sytnax invokes the enter
method of the context manager, which returns the In
- in the example, that would be the file handle. In
is allowed to mutably borrow the context manager, which mean we can guarantee the file handler does not escape the scope of the context, and also allows us to handle these very same resources in the exit
/bail
.
mid: Mid
is the value returned by the block. In the example - that would be the content
String
. If the block finishes, exit
is called and receives that value. exit
returns Out
- which is the value of the entire .use
expression. In the example - that would be an io::Result<String>
.
If the .use
block is terminated early (with return
/break
/continue
/?
/whatever new thing gets added in the future), bail
is called instead of exit
. It does not receive a Mid
- because the context block was early returned from and thus did not result in any value. And it does not return an Out
- because the program is not going through a path that would be able to do something with that output. But in the async case - it'd still be await
ed on.
Note that this means if you bail from the File::open_cm
context you won't see the exception from closing the file. I think we can live with that, because:
- The typical early exit is via
?
- which means we already have an error to propagate, and it's probably the error we care about (rather than the closing error) - We can have a Clippy lint that warns about other kinds of early exits when the context'
Out
is a#[must_use]
. - We can always add a combinator method for error handling:
let data = File::open_cm("foo.txt").on_error(|cm, err| { panic!("Got error when trying to close the file!"); }).use f in { let mut content = String::new(); f.read_to_string(&mut content)?; content }; // no ? here - on_error already handled it
Option<ContextManager>
Python has an ExitStack
class that can be used to dynamically decide which and how many context managers to run. A full ExitStack
implementation may better be left for third party crates, but I do think one of its usecases should be supported in the standard library: deciding whether or not to use a context manager.
In Python, it'd look like this:
with ExitStack() as stack:
if want_to_use_context:
stack.enter_context(my_context_manager())
something_that_may_or_may_not_happen_inside_context()
With Rust, we should just use Option
:
impl<'a, T, Mid> ContextManager<'a, Mid> for Option<T> where T: ContextManager<'a, Mid> {
type In = Option<T::In>;
type Out = Option<T::Out>;
fn enter(&'a mut self) -> Self::In {
self.as_mut()?.enter()
}
fn exit(self, mid: Mid) -> Self::Out {
self?.exit(mid)
}
fn bail(self) {
if let Some(inner) = self {
self.bail()
}
}
}
And then we can do this:
want_to_use_context.then(|| my_context_manager()).use _ in {
something_that_may_or_may_not_happen_inside_context()
}