Do do_return!

Document number:
P4323R0
Date:
2026-08-11
Audience:
EWG
Project:
ISO/IEC 14882 Programming Languages โ€” C++, ISO/IEC JTC1/SC22/WG21
Reply-to:
Jan Schultke <janschultke@gmail.com>
GitHub Issue:
wg21.link/P4323/github
Source:
github.com/eisenwave/cpp-proposals/blob/master/src/do-do-return.cow

[P2806R4] proposed to let the user omit the last semicolon in a do expression to yield a result. This paper argues against that feature for a variety of reasons.

Contents

1

Introduction

2

Problems with omitting do_return

2.1

Novelty and inconsistency

2.2

Competing styles

2.3

Visually grepping control flow

2.4

Accessibility problems

2.5

Textually grepping control flow, future direction

2.6

Irregular void and latent bugs

3

Conclusion

4

References

1. Introduction

[P2806R4] proposes a new feature of do expressions, allowing the user to put statements inside of an expression. One notable design aspect is that do expressions permit omitting the do_return statement to produce a result:

In its simplest form:

int x = do { do_return 42; }; int y = do { 42 }; // equivalent to above

A do expression consists of a sequence of statements, but is still, itself, an expression (and thus has a value and a type).

I argue that this is a bad idea, and that do_return should be required, for the reasons listed below.

2. Problems with omitting do_return

2.1. Novelty and inconsistency

The ability to omit keywords like return which would yield results from expressions is a staple of other languages, such as Kotlin and Rust.

Kotlin implicitly uses the last statement of a block as the result, in various places:

val x = run { // run invoked with a lambda println("Hello") 42 } val y = if (true) { // ... "yes" } else "no"

In Rust, the last statement of a block (not suffixed by a semicolon) is the result of a block:

let x = { let a = 3; let b = 4; a + b // value of the block because semicolon is omitted }; let y = if true { "yes" // value of the block because semicolon is omitted } else "no";

The key observation is that in those other languages, the last statement is result design is consistently applied across the language, for a wide variety of constructs. This is not the case in [P2806R4], making that design novel and surprising for do expressions. C++ users have been taught for 30 years that

There is an immense amount of value in these consistent and teachable rules, which [P2806R4] proposes to weaken, for the purpose of reducing verbosity. These rules would no longer be universal rules.

Furthermore, why is the design not applied to other constructs, even though it would be possible in principle?

The proposed feature could just as well be applied to lambda expressions:

int x = do { 42 }; int y = [] { 42 }(); // equivalent to above

There are two possible directions here:

Either way, the conclusion seems that [P2806R4] should not be proposing this design. Perhaps it could be done in a separate paper for various constructs in general.

2.2. Competing styles

An inevitable outcome of letting users omit do_return is that there will be competing styles, where some users prefer to always write do_return. This would be motivated by consistency with control flow in other places, like return and co_yield. A C++ developer used to omitting do_return would find it jarring to work on a code base where do_return is always required, and vice versa.

While having some disagreement over style is natural in a programming language, C++ already has a ton of options without a clear winner (initialization style, trailing return types, when to use auto, etc.), and this issue would be compounded. There is real cost to adding more and more competing styles which then either need to be litigated by each code base or lead to inconsistent code within one code base.

2.3. Visually grepping control flow

In the following code block, try to find all the places where control flow is altered in some way:

bool members_equal( std::span<const Group_Member_Value> xs, std::span<const Group_Member_Value> ys, const File_Source_Span& lhs_location, const File_Source_Span& rhs_location, Context& context ) { if (xs.size() != ys.size()) { return false; } for (std::size_t i = 0; i < xs.size(); ++i) { const auto name_equal = evaluate_internal_equality( xs[i].name, ys[i].name, lhs_location, rhs_location, context ); if (!name_equal || !*name_equal) { return false; } const auto value_equal = evaluate_internal_equality( xs[i].value, ys[i].value, lhs_location, rhs_location, context ); if (!value_equal || !*value_equal) { return false; } } return true; }

Toggle blur

Despite this block being blurry and thus completely unreadable, you can actually identify all the if, for, and return statements easily do to their shape and syntax highlighting. The same visual identification of control flow is done subconsciously by developers all the time. Searching for a return statement in a long function doesn't require reading the entire function from top-to-bottom, but rather scanning for the shape and color of a return statement.

As things stand, universally, builtin control flow constructs in C++ can be visually identified in this way. This would not be the case for do_return, which can be omitted.

2.4. Accessibility problems

In the proposed design, there is a major difference between the following expressions:

do { f() } // expression of the type of f's return value do { f(); } // expression of type void

This design is hostile to users with poor eyesight because a semicolon has major semantic impact. It also shows that having competing styles is not just a matter of preference; one user may prefer to omit do_return because they have 20/20 vision and they optimize for brevity, which is detrimental to other users with poor eyesight.

The design also poorly integrates with screen readers because it requires keeping the screen reader in high verbosity mode to read code. Historically, C++ semicolons could have been largely omitted as noise because they were merely statement terminators, with a few exceptions like for (;;).

That is, int x = 0; int y = 0; could have been pronounced as int x equals zero int y equals zero without loss of information.

2.5. Textually grepping control flow, future direction

Another way to scan for control flow is to perform a text search. If the user is confronted with a long do expression and wants to find all the places where it yields a result, they can search for do_return and jump from search result to search result. This is not possible when the only distinguishing factor between yielding a result and yielding void is a semicolon.

Perhaps initially, the issue is not so bad because the user only has to check the last statement of a do expression to see if it yields a result. However, a logical next step in language design is to allow the following:

auto x = do { if (condition) { // result of if as a whole is an int, 1 // which is the result of the do expression } else { 2 } };

This wouldn't be a breaking change because yielding a result of the if expression requires omitting semicolons inside the if statement, which is currently not possible.

Once again, we are confronted with uncomfortable choices:

2.6. Irregular void and latent bugs

Another observation is that in Kotlin and Rust, the following construct works fine, when it doesn't in C++:

fun f() { } // Kotlin function that returns Unit (like C++ void) val x = run { f() // fine, result is Unit } fn f() { } // Rust function that returns () let x = { f() // fine, result is () }; void f() { } auto x = do { f() // error, cannot have variable of type 'void' }

Consequently, the existence of the semicolon becomes hugely significant, especially in templates, where diagnostics are delayed until instantiation.

void f(std::invocable</* ... */> auto action) { // Nonsensical code assuming 'action' returns 'void', // but mistake is not diagnosed until instantiation: auto x = do { action() /* forgot semicolon */ }; }

If the user forgot to append a semicolon to the last statement but they intended the do expression to be a void expression, their code would inadvertently return something unexpected, and storing the result in a variable would compile even if it makes no sense. By comparison, it is much less likely that the user forgets prepend return before an expression.

42; is obviously wrong when you're used to seeing a big, syntax-highlighted return at the end of your blocks.

void f(std::invocable</* ... */> auto producer) { auto x = do { producer(); }; // error: 'auto' deduces to 'void' }

If the user accidentally added a semicolon (perhaps out of habit or due to muscle memory), their code would not compile because there is no regular void, so the result couldn't be stored in auto x. Again, the fact that there is a stray semicolon is much less obvious than a missing return or do_return.

Currently, this can be diagnosed prior to instantiation because the do expression must return void based on its syntax and void is not a valid type for x, but this could change in the future if void was made more regular in the language.

3. Conclusion

The discussion above shows that there are many serious problems with omitting do_return in do expressions. Consistency with other constructs, teachability, stylistic consistency, accessibility, visual and textual greppability, and other positives aspects of the language are affected where do expressions are used without do_return.

Either, that direction should not be pursued at all, or it should be pursued holistically. That is:

If we simply ignore these concerns, we risk losing sight of the big picture. This is unjustifiable, especially considering that the omission of do_return can be introduced at any point in the future without breaking changes, since the omitted-semicolon syntax used by the feature is currently ill-formed.

4. References

[P2806R4] Bruno Cardoso Lopes, Zach Laine, Michael Park, Barry Revzin. do expressions 2026-07-15 https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2026/p2806r4.html