C++ / CONTROL FLOW
goto and why it stays out of modern code
Use goto correctly when you must, say why modern C++ rarely needs it, and rewrite a nested-loop jump as a function that returns early.
What you will learn
- Jump only to labels in the same function; labels are function-scoped, never global.
- Leaving a scope with goto still runs destructors, innermost object first.
- Jumping into a scope past an initializer is a compile error, not a runtime bug.
- Rewrite a nested-loop goto as a helper function that returns early.
Understanding goto and why it stays out of modern code
The statement goto label; transfers control to a labelled statement somewhere in the same function, forwards or backwards. Labels have function scope: the name is visible throughout the function body no matter how deeply nested it is, and it is invisible outside, so a jump can leave a loop or a block but can never cross into another function. The jump is not a raw machine branch either; the compiler treats it as an ordinary scope exit and runs the destructors of every automatic object whose scope is left, in reverse order of construction. It also refuses to let control enter the scope of a variable whose initializer would be skipped, which is why certain goto statements are compile errors rather than runtime surprises.
The reason goto fell out of use is about reading, not speed. When a block is entered by if, while, or for, control can only arrive from the point directly above it, so you can work out what must be true on entry by reading upwards. A label has no such guarantee: any goto anywhere in the function can land on it, so the state at the label is the union of the states at all those jump sites, and you must scan the whole function to enumerate them. Structured statements have one entry and one exit, and that property is what makes local reasoning about a function possible; each goto chips away at it.
C code leans on goto cleanup because a function with five failure paths would otherwise repeat the same fclose and free sequence five times. C++ removes that motivation: destructors run on every exit, including a thrown exception, so the cleanup label has nothing left to release. What remains is escaping more than one loop level at once, and even there, lifting the loops into a small named function and using return performs the same multi-level exit while giving the search a name. The honest exceptions are machine-generated code and hand-tuned state machines or parsers, where a table of labels is the shape of the problem, and those are cases you measure your way into rather than start from.
<iostream>
int main() {
const int grid[3][4] = {{3, 8, 1, 9},
{4, 7, 6, 2},
{5, 0, 7, 7}};
int row = -1;
int col = -1;
for (int r = 0; r < 3; ++r) {
for (int c = 0; c < 4; ++c) {
if (grid[r][c] == 6) {
row = r;
col = c;
goto found; // leaves both loops at once
}
}
}
std::cout << "6 is not in the grid\n";
return 0; // without this, the miss path falls into found:
found:
std::cout << "found 6 at row " << row << ", col " << col << '\n';
return 0;
}
A goto adds another way for control to arrive at a label, and the price is that you can no longer tell what is true there without reading the whole function.
Worked examples
goto still unwinds scopes
Jumping out of two nested blocks destroys the automatic objects in those blocks before control reaches the label.
<iostream>
struct Trace {
const char* name;
explicit Trace(const char* n) : name(n) { std::cout << "enter " << name << '\n'; }
~Trace() { std::cout << "leave " << name << '\n'; }
};
int main() {
{
Trace outer("outer");
{
Trace inner("inner");
std::cout << "jumping out\n";
goto done;
}
}
done:
std::cout << "at label\n";
return 0;
}
Example explained
Line 1goto done; leaves the inner block and then the outer block, so both Trace objects are destroyed on the way.
Line 2The two leave lines print before at label, which shows the jump is a real scope exit and not a bare branch.
Line 3Destruction order is reverse of construction: inner goes first, then outer.
Line 4Reverse the direction, jumping from outside into a block that declares Trace inner("inner"), and the compiler rejects it because the initializer would be skipped.
The same jump without a label
Moving the nested loops into a function turns the multi-level escape into a plain return and names the operation.
<iostream>
<optional>
<utility>
std::optional<std::pair<int, int>> find_value(const int (&grid)[3][4], int target) {
for (int r = 0; r < 3; ++r) {
for (int c = 0; c < 4; ++c) {
if (grid[r][c] == target) {
return std::pair<int, int>{r, c};
}
}
}
return std::nullopt;
}
int main() {
const int grid[3][4] = {{3, 8, 1, 9},
{4, 7, 6, 2},
{5, 0, 7, 7}};
if (auto hit = find_value(grid, 6)) {
std::cout << "6 at " << hit->first << ',' << hit->second << '\n';
} else {
std::cout << "6 missing\n";
}
if (auto hit = find_value(grid, 99)) {
std::cout << "99 at " << hit->first << ',' << hit->second << '\n';
} else {
std::cout << "99 missing\n";
}
return 0;
}
Example explained
Line 1find_value owns both loops, so return performs exactly the two-level exit the goto version needed.
Line 2std::optional makes "nothing found" a value rather than a sentinel pair of -1, so the caller cannot silently use a miss as a hit.
Line 3if (auto hit = find_value(grid, 6)) tests the optional and confines hit to the branch where it holds a value.
Line 4The parameter type const int (&grid)[3][4] is a reference to the array, so the dimensions stay in the type and a differently shaped grid will not compile.
Why one goto refuses to compile
Braces end a string's scope before the label, which is what makes the forward jump legal.
<iostream>
<string>
int main() {
int code = 7;
if (code != 0) {
goto fail;
}
{ // braces end message's scope before fail:
std::string message = "parsed cleanly";
std::cout << message << '\n';
}
return 0;
fail:
std::cout << "gave up, code " << code << '\n';
return 1;
}
Example explained
Line 1Without the inner braces, fail: would sit inside message's scope and goto fail; would enter that scope with the initializer skipped, which the language forbids.
Line 2The braces make message's scope end before the label, so the jump only leaves scopes and is accepted.
Line 3This is a compile-time rule: the bad version produces a diagnostic about the jump crossing an initialization and never runs.
Line 4A scalar declared with no initializer, such as int n;, may be jumped over, but reading it afterwards is undefined behaviour.
Important notes
goto cannot leave a function. longjmp can, but it skips destructors, and doing so with objects that have non-trivial destructors is undefined behaviour, so goto is the tame one of the two.
case and default are not goto targets; only a switch can reach them. You may still place an ordinary label inside a switch body and jump to it from elsewhere in the function.
Common mistakes
Forgetting the return or break before the label, so the normal path falls straight into the label's code and the not-found case reports "found 6 at row -1, col -1".
Jumping forward over a declaration such as std::string s = load(); while the label is still inside s's scope, then hunting for a runtime bug when the real result is a compile error about crossing an initialization.
Using goto to enter a loop body from outside: with for (int i = 0; ...) it will not compile because the jump enters i's scope past its initializer, and with a counter declared before the loop it compiles but runs the body with whatever value the counter happened to hold.
Try it yourself
Change, predict, then run
In a browser editor, fill a 4x4 int array with mostly positive numbers and find the first negative element, first with a goto that leaves both loops, then as a function returning std::optional<std::pair<int, int>>. Check that both versions behave correctly on an array with no negative element.
Open the C++ workspaceCheck your understanding
A function creates a std::ofstream inside a nested block and then uses goto to jump to a label outside that block. What happens to the stream object?
- It leaks: goto bypasses destructors the way longjmp does.
- Nothing happens until the function returns, so the destructor runs after the label's code.
- Its destructor runs as control leaves the block, so the file is closed before the label is reached.
- The code is rejected by the compiler, because goto may not leave a block containing an object with a destructor.
Show answer
Leaving a scope by goto is an ordinary scope exit, so automatic objects are destroyed in reverse order of construction before control arrives at the label. The first option confuses goto with longjmp, which genuinely can skip destructors and is undefined behaviour when it does; the restriction in the last option applies to the opposite direction, jumping into a scope past a variable's initializer.