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 IDisposables (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.awaitinside 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.awaitoperate 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 awaited 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'
Outis 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()
}