Feature requests for the Auto macro

From time to time readers send me “feature requests” for the Auto scope-guard macro (“The Auto macro” (2018-08-11)). Usually, I say “No need!” The neat thing about Auto’s particular syntax is that it’s conceptually just a way to defer code to the end of a scope. Feature requests usually take the form of modifying the deferred code in some way — which is already (and more explicitly) allowed simply by… writing the code that way.

Before we look at those “not-a-bug” examples, let’s see the one feature request I’ve actually agreed with and adopted:

Throwing from deferred code

“My deferred code might throw, which smacks into the implicit noexcept of the Auto object’s destructor and terminates. That destructor should be noexcept(false)!”

Quite true! Instead of the internal lambda-holder looking like this:

template<class L>
class AtScopeExit {
  L& m_lambda;
public:
  AtScopeExit(L& action) : m_lambda(action) {}
  ~AtScopeExit() { m_lambda(); }
};

its destructor should really look like this:

  ~AtScopeExit() noexcept(false) { m_lambda(); }

That explicit noexcept(false) undoes the implicit noexcept that C++ places on most destructors.

Formally, noexceptness is propagated from the bases and data members into the destructor’s implicit noexcept-spec ([except.spec]/8); in other words, given struct Y { X x; ~Y() {} }, ~Y will have the same noexceptness as ~X even though ~Y() {} is user-provided. This is different from how it works for, say, move constructors, where a user-provided Y(Y&&) {} is implicitly non-noexcept even if X(X&&) is noexcept.

Adding noexcept(false) to the internal type’s destructor allows us to support code like this (Godbolt):

void example1() {
  Auto(throw 42);
  if (cond)
    return; // here 42 is thrown
  neverThrows();
  // here 42 is thrown
}

Of course, if we’re exiting the scope because of an exception, and then the Auto’s code throws its own exception, the runtime will call std::terminate anyway (because you can’t propagate two exceptions at once). In that case, our addition of noexcept(false) is harmless but not helpful either.

Removing that implicit noexcept from the destructor arguably alters the meaning of (Godbolt):

void cleanup();
void test() {
  Auto(cleanup());
}

Before, test couldn’t ever propagate an exception; if cleanup threw, it would smack into the destructor’s implicit noexcept and terminate. So there was only one way to exit from test. After, there are two ways to exit, so the behavior isn’t as simple — but GCC 13 generates the same text section for both (pushing the whole difference into the unwind tables), and Clang 17 actually generates smaller code for the new Auto! Anyway, if this matters to you, you can generate even smaller code — identical before and after this change — by declaring void cleanup() noexcept.

Now let’s see some “rejected” feature requests.

Detecting in-flight exceptions

“My deferred code might throw, which terminates if there’s already an exception in flight. Therefore, Auto’s lambda should wrap my code in try/catch!”

“My deferred code might throw, which terminates if there’s already an exception in flight. Therefore, Auto’s lambda should wrap my code in an if, so it won’t run if there’s an exception in flight!”

The Auto-world answer is that you’re responsible for what code runs at the end of your scope; if you want a particular control-flow construct, just write it! Like this:

void example2() {
  Auto(
    try {
      mightThrowC();
    } catch (...) {}
  );
  mightThrowA();
  mightThrowB();
}

void example3() {
  int inflight = std::uncaught_exceptions();
  Auto(
    if (std::uncaught_exceptions() == inflight) {
      mightThrowC();
    }
  );
  mightThrowA();
  mightThrowB();
}

But in most cases such code isn’t needed. Auto’s simple definition ensures that “you don’t pay for what you don’t use.”

Explicit commit

“I should be able to name the Auto object and say guard.commit() at the end of my transaction; only uncommitted transactions should run the deferred code!”

The Auto-world answer is that conceptually there is no “Auto object”; Auto simulates a control-flow construct, not an object with state. Instead of something like this:

void fantasy4() {
  FantasyTransactionGuard g(
    puts("Transaction failed");
  );
  mightThrowA();
  if (rand()) return;
  mightThrowB();
  g.commit(); // now the puts won't run!
}

in Auto-world you’d write the boolean variable explicitly in your code:

void example4(int k, int v) {
  bool committed = false;
  Auto(
    if (!committed) {
      puts("Transaction failed");
    }
  );
  mightThrowA();
  if (rand()) return;
  mightThrowB();
  committed = true; // now the puts won't run!
}

Capture by value

Auto’s lambda captures everything by [&], but I need a version that captures by value instead!”

The Auto-world answer is that there is no “lambda”; Auto simulates a control-flow construct, not an object with state. If you need a copy of some variable i, just make a copy! Instead of something like this:

void fantasy5() {
  static int counter = 0;
  FantasyAutoCapturingByValue(
    printf("finished operation %d\n", counter);
  );
  counter += rand();
  // here the original value is printed
}

in Auto-world you’d write the copy operation explicitly in your code:

void example5() {
  static int counter = 0;
  int originalCounter = counter;
  Auto(
    printf("finished operation %d\n", originalCounter);
  );
  counter += rand();
  // here the original value is printed
}

This keeps all the important control flow and data-copying visible in your code, while hiding only the very smallest amount of “magic” behind the macro.

Posted 2024-02-14