Our Python codebase has metrics-related code that looks like this:
class Timer:
def __enter__(self, name):
self.name = name
self.start = time.time()
def __exit__(self):
elapsed = time.time() - self.start
log.info('%s took %f seconds' % (self.name, elapsed))
...
with Timer('foo'):
do some work
with Timer('bar') as named_timer:
do some work
named_timer.some_mutative_method()
do some more work
In Python's terminology, the timer is a contextmanager.
Now we want to implement the same thing in C++, with an equally nice syntax. Unfortunately, C++ doesn't have with
. So the "obvious" idiom would be (classical RAII)
class Timer {
Timer(std::string name) : name_(std::move(name)) {}
~Timer() { /* ... */ }
};
if (true) {
Timer t("foo");
do some work
}
if (true) {
Timer named_timer("bar");
do some work
named_timer.some_mutative_method();
do some more work
}
But this is extremely ugly syntactic salt: it's many lines longer than it needs to be, we had to introduce a name t
for our "unnamed" timer (and the code breaks silently if we forget that name)... it's just ugly.
What are some syntactic idioms that people have used to deal with "contextmanagers" in C++?
I've thought of this abusive idea, which reduces the line-count but doesn't get rid of the name t
:
// give Timer an implicit always-true conversion to bool
if (auto t = Timer("foo")) {
do some work
}
Or this architectural monstrosity, which I don't even trust myself to use correctly:
Timer("foo", [&](auto&) {
do some work
});
Timer("bar", [&](auto& named_timer) {
do some work
named_timer.some_mutative_method();
do some more work
});
where the constructor of Timer
actually invokes the given lambda (with argument *this
) and does the logging all in one go.
Neither of those ideas seems like a "best practice", though. Help me out here!
Another way to phrase the question might be: If you were designing std::lock_guard
from scratch, how would you do it so as to eliminate as much boilerplate as possible? lock_guard
is a perfect example of a contextmanager: it's a utility, it's intrinsically RAII, and you hardly ever want to bother naming it.
Context managers can be written using classes or functions(with decorators). Creating a Context Manager: When creating context managers using classes, user need to ensure that the class has the methods: __enter__() and __exit__().
contextmanager() uses ContextDecorator so the context managers it creates can be used as decorators as well as in with statements.
If an error is raised in __init__ or __enter__ then the code block is never executed and __exit__ is not called. Once the code block is entered, __exit__ is always called, even if an exception is raised in the code block.
It's possible to mimic the Python syntax and semantics quite closely. The following test case compiles and has largely similar semantics to what you'd have in Python:
// https://github.com/KubaO/stackoverflown/tree/master/questions/pythonic-with-33088614
#include <cassert>
#include <cstdio>
#include <exception>
#include <iostream>
#include <optional>
#include <string>
#include <type_traits>
[...]
int main() {
// with Resource("foo"):
// print("* Doing work!\n")
with<Resource>("foo") >= [&] {
std::cout << "1. Doing work\n";
};
// with Resource("foo", True) as r:
// r.say("* Doing work too")
with<Resource>("bar", true) >= [&](auto &r) {
r.say("2. Doing work too");
};
for (bool succeed : {true, false}) {
// Shorthand for:
// try:
// with Resource("bar", succeed) as r:
// r.say("Hello")
// print("* Doing work\n")
// except:
// print("* Can't do work\n")
with<Resource>("bar", succeed) >= [&](auto &r) {
r.say("Hello");
std::cout << "3. Doing work\n";
} >= else_ >= [&] {
std::cout << "4. Can't do work\n";
};
}
}
That's given
class Resource {
const std::string str;
public:
const bool successful;
Resource(const Resource &) = delete;
Resource(Resource &&) = delete;
Resource(const std::string &str, bool succeed = true)
: str(str), successful(succeed) {}
void say(const std::string &s) {
std::cout << "Resource(" << str << ") says: " << s << "\n";
}
};
The with
free function passes all the work to the with_impl
class:
template <typename T, typename... Ts>
with_impl<T> with(Ts &&... args) {
return with_impl<T>(std::forward<Ts>(args)...);
}
How do we get there? First, we need a context_manager
class: the traits class that implements the enter
and exit
methods - the equivalents of Python's __enter__
and __exit__
. As soon as the is_detected
type trait gets rolled into C++, this class can also easily forward to the compatible enter
and exit
methods of the class type T
, thus mimicking Python's semantics even better. As it stands, the context manager is rather simple:
template <typename T>
class context_manager_base {
protected:
std::optional<T> context;
public:
T &get() { return context.value(); }
template <typename... Ts>
std::enable_if_t<std::is_constructible_v<T, Ts...>, bool> enter(Ts &&... args) {
context.emplace(std::forward<Ts>(args)...);
return true;
}
bool exit(std::exception_ptr) {
context.reset();
return true;
}
};
template <typename T>
class context_manager : public context_manager_base<T> {};
Let's see how this class would be specialized to wrap the Resource
objects, or std::FILE *
.
template <>
class context_manager<Resource> : public context_manager_base<Resource> {
public:
template <typename... Ts>
bool enter(Ts &&... args) {
context.emplace(std::forward<Ts>(args)...);
return context.value().successful;
}
};
template <>
class context_manager<std::FILE *> {
std::FILE *file;
public:
std::FILE *get() { return file; }
bool enter(const char *filename, const char *mode) {
file = std::fopen(filename, mode);
return file;
}
bool leave(std::exception_ptr) { return !file || (fclose(file) == 0); }
~context_manager() { leave({}); }
};
The implementation of the core functionality is in the with_impl
type. Note how the exception handling within the suite (the first lambda) and the exit
function mimic Python behavior.
static class else_t *else_;
class pass_exceptions_t {};
template <typename T>
class with_impl {
context_manager<T> mgr;
bool ok;
enum class Stage { WITH, ELSE, DONE } stage = Stage::WITH;
std::exception_ptr exception = {};
public:
with_impl(const with_impl &) = delete;
with_impl(with_impl &&) = delete;
template <typename... Ts>
explicit with_impl(Ts &&... args) {
try {
ok = mgr.enter(std::forward<Ts>(args)...);
} catch (...) {
ok = false;
}
}
template <typename... Ts>
explicit with_impl(pass_exceptions_t, Ts &&... args) {
ok = mgr.enter(std::forward<Ts>(args)...);
}
~with_impl() {
if (!mgr.exit(exception) && exception) std::rethrow_exception(exception);
}
with_impl &operator>=(else_t *) {
assert(stage == Stage::ELSE);
return *this;
}
template <typename Fn>
std::enable_if_t<std::is_invocable_r_v<void, Fn, decltype(mgr.get())>, with_impl &>
operator>=(Fn &&fn) {
assert(stage == Stage::WITH);
if (ok) try {
std::forward<Fn>(fn)(mgr.get());
} catch (...) {
exception = std::current_exception();
}
stage = Stage::ELSE;
return *this;
}
template <typename Fn>
std::enable_if_t<std::is_invocable_r_v<bool, Fn, decltype(mgr.get())>, with_impl &>
operator>=(Fn &&fn) {
assert(stage == Stage::WITH);
if (ok) try {
ok = std::forward<Fn>(fn)(mgr.get());
} catch (...) {
exception = std::current_exception();
}
stage = Stage::ELSE;
return *this;
}
template <typename Fn>
std::enable_if_t<std::is_invocable_r_v<void, Fn>, with_impl &> operator>=(Fn &&fn) {
assert(stage != Stage::DONE);
if (stage == Stage::WITH) {
if (ok) try {
std::forward<Fn>(fn)();
} catch (...) {
exception = std::current_exception();
}
stage = Stage::ELSE;
} else {
assert(stage == Stage::ELSE);
if (!ok) std::forward<Fn>(fn)();
if (!mgr.exit(exception) && exception) std::rethrow_exception(exception);
stage = Stage::DONE;
}
return *this;
}
template <typename Fn>
std::enable_if_t<std::is_invocable_r_v<bool, Fn>, with_impl &> operator>=(Fn &&fn) {
assert(stage != Stage::DONE);
if (stage == Stage::WITH) {
if (ok) try {
ok = std::forward<Fn>(fn)();
} catch (...) {
exception = std::current_exception();
}
stage = Stage::ELSE;
} else {
assert(stage == Stage::ELSE);
if (!ok) std::forward<Fn>(fn)();
if (!mgr.exit(exception) && exception) std::rethrow_exception(exception);
stage = Stage::DONE;
}
return *this;
}
};
Edit: After reading Dai's comment more carefully, and thinking a bit more, I realized this is a poor choice for C++ RAII. Why? Because you are logging in the destructor, this means you are doing io, and io can throw. C++ destructors should not emit exceptions. With python, writing a throwing __exit__
isn't necessarily awesome either, it can cause you to drop your first exception on the floor. But in python, you definitively know whether the code in the context manager has caused an exception or not. If it caused an exception, you can just omit your logging in __exit__
and pass through the exception. I leave my original answer below in case you have a context manager which doesn't risk throwing on exit.
The C++ version is 2 lines longer than the python version, one for each curly brace. If C++ is only two lines longer than python, that's doing well. Context managers are designed for this specific thing, RAII is more general and provides a strict superset of the functionality. If you want to know best practice, you already found it: have an anonymous scope and create the object at the beginning. This is idiomatic. You may find it ugly coming from python, but in the C++ world it's just fine. The same way someone from C++ will find context managers ugly in certain situations. FWIW I use both languages professionally and this doesn't bother me at all.
That said, I'll provide a cleaner approach for anonymous context managers. Your approach with constructing Timer with a lambda and immediately letting it destruct is pretty weird, so you're right to be suspicious. A better approach:
template <class F>
void with_timer(const std::string & name, F && f) {
Timer timer(name);
f();
}
Usage:
with_timer("hello", [&] {
do something;
});
This is equivalent to the anonymous context manager, in the sense that none of Timer's methods can be called other than construction and destruction. Also, it uses the "normal" class, so you can use the class when you need a named context manager, and this function otherwise. You could obviously write with_lock_guard in a very similar way. There it's even better as lock_guard doesn't have any member functions you're missing out on.
All that said, would I use with_lock_guard, or approve code written by a teammate that added in such a utility? No. One or two extra lines of code just doesn't matter; this function doesn't add enough utility to justify it's own existence. YMMV.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With