Embercode
✕
C++ OperatorsLesson 12 of 14

40 min read

Compound assignment narrowing

Why this matters

A compound assignment such as += looks like a small shortcut for a simple change. Most of the time it does exactly what you expect. The trouble starts when the variable is small, such as a char or a short, or when the number you add is not a whole number. Then the value stored in the variable can differ from the value the arithmetic produced, and C++ does not stop the program or print an error.

The effect is quiet. A health bar that drops after a heal, a total that turns negative after many additions, or a counter that stays at 0 after ten additions can all come from this one rule. This lesson covers only what happens when a result is stored back into a variable. The arithmetic operators themselves are in the arithmetic lesson of this module.

The idea: += adds, then stores

The line x += 3; means the same as x = x + 3;. The C++ standard describes E1 op= E2 as having the same effect as E1 = E1 op E2, except that E1 is evaluated only once. You do not need to remember the standard's wording. You need to remember the two steps. First the sum is worked out. Then the sum is stored into x. The trouble happens in the second step.

Every variable is a box of a fixed size. A bit is a single binary digit, either 0 or 1. On the builds used in this course, a char is 8 bits, so it can hold 256 different patterns. On the g++ 15 build used for this lesson, a plain char holds numbers from -128 to 127. When a number is too big for its box, the bits that do not fit are dropped. The box keeps only the low bits. Nothing tells you that this happened.

Worked example 1: a char that wraps

Here is the smallest program that shows the problem. Read it, then predict the output before you scroll on.

C++ · editable

The output is:

c holds -112

Here is what happens, one step at a time.

  1. c holds 100.
  2. The right side is c + 300. Because c is smaller than an int, it is widened to int before the addition, so the sum is 400. Nothing has been stored yet.
  3. 400 in binary is 1 1001 0000, which takes nine bits. A char has room for eight. The top bit is dropped, which leaves 1001 0000. As an unsigned number that pattern is 144.
  4. A plain char treats its top bit as a sign bit, so the pattern means 144 minus 256, which is -112.

The static_cast in the program is only there to print the number. Printing a char directly would print a letter or a symbol, not the number. The program itself never has to cast anything, and that is the point: no line in the code signals that the value changed size.

The same thing happens with the longer form, which makes the narrowing easier to see:

C++ · editable
d holds -112

The compound form and the long form do the same work. The compound form only hides the step.

Worked example 2: an unsigned health bar

An unsigned char holds 0 to 255. It has no negative values. Now imagine a game that stores health in one, and a potion that adds 200 health.

C++ · editable
health after the potion: 34

The sum is 290. The box holds at most 255, so 290 wraps to 290 minus 256, which is 34. The potion lowers the health from 90 to 34. The program prints a number and continues, so a player would only notice that a potion made them weaker.

Worked example 3: a short that overflows during a loop

A short on this build holds numbers from -32768 to 32767. A total that adds 1000 forty times should reach 40000, which is too big. Watch what the box holds as the loop runs.

C++ · editable
total after forty additions: -25536

After 32 additions the total is 32000, which still fits. The 33rd addition makes 33000. That does not fit, so the box holds 33000 minus 65536, which is -32536. The remaining seven additions add 7000 more, and -32536 plus 7000 is -25536. The loop never stops or warns. The number turns negative partway through.

Worked example 4: a fraction added to an int

The same thing happens when the number you add has a decimal part. The right side is worked out as a double, which can hold fractions. The left side is an int, which cannot. When the result is stored, the decimal part is removed. C++ calls this truncation, and it cuts toward zero. The number 2.7 becomes 2, and -2.7 becomes -2.

C++ · editable
counter after ten additions of 0.3: 0

Each step adds 0.3 to the current value and then stores the result in the int. The first step gives 0.3, which is stored as 0. The next step gives 0 plus 0.3, which is again stored as 0. The total never gets past 0, so the loop looks as though it never counted at all.

Multiplication has the same effect. The line x *= 0.5; with x equal to 5 gives 2.5, which is stored as 2.

Worked example 5: a discount and a counter

The last example puts both problems in one program. A price of 999 gets 10 percent off, and a counter that is an unsigned char counts 300 sends.

C++ · editable
price after 10 percent off: 899
price with integer math: 899
packets after 300 sends: 44

The discount is 999 times 0.9, which is 899.1 as a double. The store removes the decimal part and leaves 899. The store always cuts, so this answer is right only because cutting is the rule you want. The integer version exact * 9 / 10 gives the same whole number, and it makes the cut visible in the code: the division by 10 is where the decimal part is dropped.

The counter is the wrap from the earlier examples in a more realistic form. An unsigned char counts 0 to 255. After 256 sends it is back at 0, and after 300 sends it holds 44. A counter like this does not stop at 255. It starts again, so a check on the count will see a small number and report that few packets were sent.

What the compiler tells you

None of the five examples above makes the compiler fail. They compile with -Wall, and the compiler prints nothing. The warning that catches this kind of change is not part of -Wall. You turn it on with -Wconversion, which warns when a conversion may change a value.

Save this program as narrow.cpp:

#include <iostream>
using namespace std;
 
int main() {
    char c = 100;
    c += 300;
    cout << "c holds " << static_cast<int>(c) << endl;
 
    int i = 0;
    i += 2.7;
    cout << "i holds " << i << endl;
}

Compile it with that flag:

g++ -std=c++20 -Wall -Wconversion narrow.cpp -o narrow

The compiler reports both lines:

narrow.cpp: In function ‘int main()’:
narrow.cpp:6:7: warning: conversion from ‘int’ to ‘char’ may change value [-Wconversion]
    6 |     c += 300;
      |     ~~^~~~~~
narrow.cpp:10:7: warning: conversion from ‘double’ to ‘int’ may change value [-Wfloat-conversion]
   10 |     i += 2.7;
      |     ~~^~~~~~

Without -Wconversion, the same file compiles silently and runs to this output:

c holds -112
i holds 2

The warning names both types, so you can see which store is narrowing. Use -Wconversion while you write arithmetic on small types or on mixed int and double. The warning can be noisy in large programs, so a common habit is to fix only the warnings that point at real totals and counters.

Fixing it

There are four ordinary ways to keep the stored value correct. Each one is a choice about what the program should do.

  1. Store the total in a type that is wide enough. A total that grows over a loop should be an int, or a long long if it may grow very large.
  2. Compute the result in a wider type, check it, and then store it.
  3. Limit the result to a range with std::min or std::max, when a value must stay between two bounds.
  4. Write static_cast at the exact line where the narrowing is intended. The cast tells the reader, and the compiler, that the cut was on purpose.

Here is a program that uses all four. The #include <algorithm> line provides std::min.

C++ · editable

The output is:

total with int: 3000
too big for char, wanted 400
level after check: 100
health after clamp: 255
stock after explicit cast: 2

Fix 2 leaves level unchanged, because a check that fails should not store a wrong value. Fix 3 is a clamp, which means the health stops at the largest value the box can hold. For a game, you would usually clamp to the real maximum health instead, such as 100. Fix 4 is the only case where a cast is right: you have decided that the cut is correct, and you want the next reader to see that decision. With the cast in place, -Wconversion stays silent.

A helper that caps health at 100 shows the same idea with a clear limit. It does the sum in int, caps the value, and only then stores it in the small box:

C++ · editable
heal 90 by 200: 100
heal 10 by 5: 15

The & in unsigned char& health means the function changes the caller's variable, not a copy. Read it as "the function works on the variable it was given".

Common mistakes

Assuming the right side decides the size. The right side is worked out as an int or a double, but the store uses the size of the left side. char c = 100; c += 300; starts with an int sum of 400 and stores into a box that holds 8 bits. Always ask what the left side can hold.

Expecting -Wall to catch it. The compile of the first example with only -Wall printed nothing. A program can compile cleanly and still store -112. Add -Wconversion when the program does arithmetic on small types or fractions.

Expecting a fraction to add up. A loop that adds 0.3 to an int ten times leaves 0, not 3. The fraction is removed at every store, so the total never grows. If the total should be a fraction, the variable should be a double.

Using short or char for totals. A short ran out of room at 32767 in the third example. Counters, sums and running totals should be int unless you have a specific reason for a smaller type, and you should write that reason down.

Blaming the compiler for the wrap. The wrap is not a compiler fault. The standard says that converting a value too big for an unsigned type wraps around. Since C++20 it also defines the same wrap for a signed type, so -112 is the required result of c += 300, not a quirk of one build. The fault is in the logic of the program.

Practice

Exercise 1. What does this program print?

signed char s = 127;
s += 1;
cout << static_cast<int>(s);

Show the answer

It prints -128. The sum is 128, which needs eight bits plus one more. A signed char holds at most 127 before it wraps to the lowest value, -128.

Exercise 2. What does this program print?

unsigned char u = 0;
u -= 1;
cout << static_cast<int>(u);

Show the answer

It prints 255. The sum is -1. An unsigned char cannot hold -1, so the stored value is -1 plus 256, which is 255. Subtraction can narrow just like addition.

Exercise 3. What does this program print, and why?

int n = 7;
n /= 2.0;
cout << n;

Show the answer

It prints 3. The division 7 / 2.0 gives 3.5 as a double. Storing 3.5 into the int removes the decimal part, which leaves 3.

Exercise 4. A short total starts at 0 and gets 1000 added in a loop of 40 steps. The program prints -25536. At which addition does the total first go wrong, and what is the first wrong value?

Show the answer

It goes wrong at the 33rd addition. After 32 additions the total is 32000, which fits. The 33rd addition makes 33000, which is above 32767. The stored value is 33000 minus 65536, which is -32536.

Exercise 5. Write a function heal that takes an unsigned char& health and an int amount. It should raise health by the amount but never above 100. Test it with health 90 and amount 200, then with health 10 and amount 5.

Show the answer

void heal(unsigned char& health, int amount) {
    int sum = health + amount;
    if (sum > 100) {
        sum = 100;
    }
    health = static_cast<unsigned char>(sum);
}

Health 90 with amount 200 becomes 100. Health 10 with amount 5 becomes 15. The sum is done in int, so it cannot wrap, and the cast at the end is the only narrowing, written on purpose.

Summary

  • x += y works out x + y and then stores the result back into x. The store uses the size of x.
  • A char, a short or an unsigned char keeps only the low bits of a larger result. The result wraps, and no error is printed.
  • Storing a fraction into an int removes the decimal part, so small steps can add up to 0.
  • -Wall does not warn about these lines. Add -Wconversion to find them.
  • Use a wide enough type for totals, check the result before storing it, or write a static_cast where the cut is intended.
Check yourself: Shifts and narrowing (7 questions) →Practice this topic: coding problems for C++ Operators →

Stuck? Ask the tutor

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

Sign in to ask the tutor.

← Shifts on negative and unsigned values