Embercode
✕
C++ Functions and ScopeLesson 12 of 17

30 min read

Shadowing

Why this matters

A program often has two variables with the same name. One is at the top of the file, and another is inside a function or a loop. C++ allows this, and the code still compiles and runs. The trouble is that the program may use the wrong one, and it does not tell you.

This is called shadowing. The inner name casts a shadow over the outer name, so the outer name cannot be seen where the inner one is in use. A shadowed variable still exists. The program simply does not reach it from that spot.

Shadowing bugs are hard to find because nothing crashes. The program prints a number that looks plausible but is wrong. This lesson shows how the bug happens, how to see it with a compiler warning, and how to rename names so each one means one thing.

The scope and lifetime lesson earlier in this module explains where a name can be used. This lesson uses that idea and does not repeat it.

Define the terms

  • Name. The word you give a variable, function or parameter, such as total.
  • Variable. A named box in memory that holds a value.
  • Global variable. A variable declared outside every function. It can be used from any function below its declaration.
  • Local variable. A variable declared inside a function or a block. A block is the code between a pair of braces, { and }, such as the body of an if or a for loop.
  • Parameter. A variable in a function's header that receives a value when the function is called. In void set(int value), value is a parameter.
  • Member variable. A variable that belongs to a struct or class. Its name is used in the struct's functions.
  • Shadow. To hide an outer name by declaring an inner name with the same spelling.
  • Warning. A message from the compiler that does not stop the build. It points at code that compiles but is probably wrong.

The first example: a local hides a global

Here is a program that tries to keep a running total. It has a global variable named total, and the function declares its own total.

#include <iostream>
 
int total = 0;
 
void add_to_total(int amount) {
    int total = 0;
    total = total + amount;
}
 
int main() {
    add_to_total(5);
    add_to_total(7);
    std::cout << "total is " << total << '\n';
}

The output is:

total is 0

The program expected 12. It printed 0, and the compiler did not stop it.

Here is what happens when add_to_total(5) runs, one line at a time.

  1. The line int total = 0; creates a new variable named total inside the function. It is a local variable, and its value is 0.
  2. From this line to the closing brace, the name total means the local variable. The global total is hidden.
  3. The line total = total + amount; reads the local total, which is 0, adds 5, and stores 5 in the local variable.
  4. The function ends. The local variable is removed. The global total was never changed, so it is still 0.
  5. The second call does the same thing with 7, and the global is still 0.
  6. main prints the global, which is 0.

The bug is in step 2. The programmer wanted to use the global variable, but a declaration inside the function started a new variable with the same name.

Why nothing warns you by default

The program above compiles cleanly with -Wall, the flag this course uses. Shadowing is legal C++, so the normal warnings stay quiet. To see it, add -Wshadow:

g++ -std=c++20 -Wall -Wshadow -o total total.cpp
./total

The compiler now prints a warning before the program runs:

total.cpp: In function ‘void add_to_total(int)’:
total.cpp:6:9: warning: declaration of ‘total’ shadows a global declaration [-Wshadow]
    6 |     int total = 0;
      |         ^~~~~
total.cpp:3:5: note: shadowed declaration is here
    3 | int total = 0;
      |     ^~~~~
total is 0

The warning names the new declaration, the old one it hides, and the line of each. The file name in your output will be the name of your file. This is the fastest way to find shadowing. Add -Wshadow when you check code, and read every warning it gives.

Example 2: a loop index hidden by an inner loop

The next bug comes from nested loops. A nested loop is a loop inside another loop. Both loops often use the name i or row. If the inner loop declares the same name, the outer one is hidden inside the inner loop.

#include <iostream>
 
int main() {
    for (int row = 1; row <= 3; row++) {
        for (int row = 1; row <= 3; row++) {
            std::cout << row << ' ';
        }
        std::cout << '\n';
    }
}

The output is:

1 2 3 
1 2 3 
1 2 3 

Each line prints 1 2 3. The programmer wanted each line to show the outer row number, so the output should have been 1 1 1, then 2 2 2, then 3 3 3. The inner row hides the outer row inside the inner loop, so the outer number never reaches the output.

With -Wshadow, the compiler points at the inner declaration:

sh2.cpp: In function ‘int main()’:
sh2.cpp:5:18: warning: declaration of ‘row’ shadows a previous local [-Wshadow]
    5 |         for (int row = 1; row <= 3; row++) {
      |                  ^~~
sh2.cpp:4:14: note: shadowed declaration is here
    4 |     for (int row = 1; row <= 3; row++) {
      |              ^~~

The fix is to give the inner loop a different name, such as col, for the column count. Then the inner loop uses col and the outer loop keeps row. Here is the corrected program with its output:

#include <iostream>
 
int main() {
    for (int row = 1; row <= 3; row++) {
        for (int col = 1; col <= 3; col++) {
            std::cout << row << ' ';
        }
        std::cout << '\n';
    }
}
1 1 1 
2 2 2 
3 3 3 

This example shows a common pattern. Loops with names like i and j are easy to write, but two loops in the same function often end up using the same letter. Use names that say what the number counts, such as row and col.

Example 3: a parameter hides a member variable

A struct can have a member variable, and its functions can take a parameter with the same name. The parameter hides the member inside the function. This is a frequent mistake when writing a setter, which is a function that changes a value.

#include <iostream>
 
struct Counter {
    int value = 0;
 
    void set(int value) {
        value = value;
    }
 
    void set_fixed(int new_value) {
        value = new_value;
    }
};
 
int main() {
    Counter c;
    c.set(10);
    std::cout << "after set: " << c.value << '\n';
    c.set_fixed(10);
    std::cout << "after set_fixed: " << c.value << '\n';
}

The output is:

after set: 0
after set_fixed: 10

The line value = value; in set does not change the member. Both names inside the function refer to the parameter, so the parameter is assigned to itself. The member value keeps its old value, 0.

The set_fixed version uses a different name for the parameter, new_value. Inside that function, value means the member, so the assignment changes it.

There is a second way to fix set without renaming the parameter. The word this-> refers to the member of the object the function was called on. Here is set written that way:

void set(int value) {
    this->value = value;
}

With this version, c.set(10) prints after set: 10. The this-> form is common in constructors, which are functions that set up a new struct. Many teams still prefer a different parameter name, because the code reads more clearly and the warning goes away.

Without -Wshadow, the program compiles with no warning. That is why the warning is worth turning on.

Example 4: a name used in its own declaration

A subtle case happens when a new variable's starting value uses a name that is hidden at that spot. The variable is already in scope from the point of its declaration, so the expression on the right of = reads the new variable, not the outer one.

#include <iostream>
 
void show_price_bug(bool is_member) {
    int price = 100;
    if (is_member) {
        int price = price - 20;
        std::cout << "member price inside if: " << price << '\n';
    }
}
 
int main() {
    show_price_bug(true);
}

The programmer wanted price - 20 to use the outer price, which is 100, so the result is 80. The inner price is already in scope on the right side of its own declaration. The expression reads the new variable before it has a value. The result is not 80, and the program has undefined behaviour. That means the rules of C++ do not say what happens, so the printed number can be any value. On one run it printed 32745, and on the next run 32746. A different build or a different run can print another number.

The compiler warns with -Wall alone:

sh4.cpp:4:9: warning: unused variable ‘price’ [-Wunused-variable]
sh4.cpp:6:13: warning: ‘price’ may be used uninitialized [-Wmaybe-uninitialized]

The second warning is the important one. It says the program reads price before it has a value. The first warning says the outer price is never used, because the inner one is the one the code reads.

The fix is to give the two values different names, so the inner expression can reach the outer one:

#include <iostream>
 
void show_price(bool is_member) {
    int base_price = 100;
    if (is_member) {
        int member_price = base_price - 20;
        std::cout << "member price: " << member_price << '\n';
    }
    std::cout << "base price: " << base_price << '\n';
}
 
int main() {
    show_price(true);
}
member price: 80
base price: 100

Example 5: a receipt with a hidden tax

The last example is a longer function. It has an outer tax and a second tax inside an if block. Read it slowly, because the bug is easy to miss in a longer function.

#include <iostream>
 
void print_receipt(bool is_member) {
    int tax = 0;
    int price = 100;
    if (is_member) {
        price = price - 10;
        int tax = price / 10;
    }
    std::cout << "price " << price << ", tax " << tax << '\n';
}
 
int main() {
    print_receipt(true);
}

The output is:

price 90, tax 0

The programmer wanted the member price to take 10 off, and the tax to be one tenth of the new price, which is 9. The line price = price - 10; changes the outer price, so price correctly prints 90. The line int tax = price / 10; declares a new tax inside the if block. That tax hides the outer one. The assignment goes to the new variable, and when the block ends, the new variable is removed. The outer tax is still 0.

With -Wshadow, the compiler points at the new declaration:

receipt.cpp:8:13: warning: declaration of ‘tax’ shadows a previous local [-Wshadow]
receipt.cpp:4:9: note: shadowed declaration is here
receipt.cpp:8:13: warning: unused variable ‘tax’ [-Wunused-variable]

The unused-variable warning is a second clue. The new tax is never read after it is set, which means the value goes nowhere.

The fix is to remove the int in front of tax, so the line assigns to the outer variable:

if (is_member) {
    price = price - 10;
    tax = price / 10;
}

This version prints price 90, tax 9, and the warnings are gone. The lesson in one sentence: a declaration starts a new variable, and a plain assignment changes one that already exists.

How to rename

When a name is taken, pick a new name that says what the value holds. The name should describe the thing, not the role it plays in the code. These are good renames:

  • total for a global running total, and round_total for a total inside one round.
  • value for a struct member, and new_value for the parameter that sets it.
  • row and col for loop counts, instead of two loops both named i.
  • base_price for the normal price, and member_price for the discounted price.

Rename every use of the name in the scope where you changed it. A partial rename is a bug of its own. The next example shows that mistake.

#include <iostream>
 
int total = 0;
 
void report(int a, int b) {
    int round_total = a + b;
    std::cout << "round: " << total << '\n';
}
 
int main() {
    report(2, 3);
    report(4, 6);
}

The output is:

round: 0
round: 0

The programmer renamed the local variable to round_total but left the line that prints total. That name now refers to the global, which is still 0. The compiler does not treat this as an error, because the program is legal. With -Wall, the only warning is:

sh9.cpp:6:9: warning: unused variable ‘round_total’ [-Wunused-variable]

When you see an unused-variable warning right after a rename, check that the new name is used. The fix is to print round_total, which gives round: 5 and then round: 10.

Common mistakes

Declaring the same name again with copy and paste

Copying a block of code and pasting it under the original often leaves a second declaration of the same name. The new declaration hides the first one, and the code below it uses the new value. The bug in the first example came from this. The fix is to delete the second declaration, so the code uses the outer variable, or to rename the new one.

Assuming a parameter changes a member

A parameter with the same name as a member does not change that member. The bug in the struct example printed after set: 0, even though the function was called with 10. When a setter does nothing, look for a parameter name that matches a member name.

Renaming one place and not the others

After a rename, one line may still use the old name. If an outer variable with that name exists, the line silently reads the outer one. The round: example printed 0 twice for this reason.

Turning off the warning to make the output quiet

Some teams hide warnings to make a build clean. A shadowing warning points at a real chance of a wrong answer. Rename the variable instead, so the warning has nothing to report.

Practice

Each exercise is followed by its answer. Try the exercise on your own first, then compare.

Exercise 1. What does this program print? Explain which level each line reads.

#include <iostream>
 
int level = 1;
 
void raise_level() {
    int level = 5;
    level = level + 1;
    std::cout << "inside: " << level << '\n';
}
 
int main() {
    raise_level();
    std::cout << "outside: " << level << '\n';
}

Show the answer

The output is inside: 6, then outside: 1. Inside raise_level, the local level starts at 5 and becomes 6. In main, there is no local level, so the name means the global, which is still 1. With -Wshadow, the compiler warns on the line int level = 5;.

Exercise 2. Fix the Counter struct so that set changes the member, and keep the parameter named value.

Show the answer

Write this->value = value; inside set. The left side, this->value, is the member, and the right side, value, is the parameter. Then c.set(10) makes c.value equal to 10, which prints after set: 10.

Exercise 3. Rewrite the nested loop so each line of the output is the outer row number repeated three times: 1 1 1, 2 2 2, and 3 3 3. Use different names for the two loops.

Show the answer

for (int row = 1; row <= 3; row++) {
    for (int col = 1; col <= 3; col++) {
        std::cout << row << ' ';
    }
    std::cout << '\n';
}

The inner loop counts col, and the print uses row, which is the outer number. The output is 1 1 1 , 2 2 2 , and 3 3 3 , each followed by a space and a newline.

Exercise 4. Change show_price_bug so it prints member price: 80 and reads no unset value. Use the name base_price for the outer value.

Show the answer

void show_price_fixed(bool is_member) {
    int base_price = 100;
    if (is_member) {
        int member_price = base_price - 20;
        std::cout << "member price: " << member_price << '\n';
    }
}

The outer value is base_price, and the discounted value is member_price. Each name is declared once, and the expression on the right reads the outer name, so the output is member price: 80.

Exercise 5. The program below prints round: 0 twice. Find the line that causes the bug and fix it so the output is round: 5 and then round: 10.

#include <iostream>
 
int total = 0;
 
void report(int a, int b) {
    int round_total = a + b;
    std::cout << "round: " << total << '\n';
}
 
int main() {
    report(2, 3);
    report(4, 6);
}

Show the answer

The line std::cout << "round: " << total << '\n'; reads the global total. It should read the local round_total, which holds a + b. Change it to std::cout << "round: " << round_total << '\n';. The first call prints round: 5, and the second call prints round: 10.

Summary

  • Shadowing happens when an inner name has the same spelling as an outer name. The outer name cannot be reached from inside the inner scope.
  • The program still compiles, so the mistake shows up as a wrong number, not an error.
  • -Wall does not catch shadowing. Add -Wshadow when checking code, and read each warning.
  • Use this-> in a setter, or give the parameter a new name, so a parameter does not hide a member.
  • Give each loop and each variable a name that says what it counts or holds, and rename every use when you change one.
Practice this topic: coding problems for C++ Functions and Scope →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Generic functions