C++ Part I · Module 4 · Expressions and Operators
Arithmetic Operators and the Type of a Result
The arithmetic operators are the part of C++ that looks like it needs no explanation. You already know what plus does. The interesting question is not what value an arithmetic expression produces but what type it produces, and the answer is frequently not the type of either operand. Add two 8-bit values and you get a 32-bit int. Add a signed value to an unsigned one and the result is unsigned, with everything that implies from Chapter 3.3. Add an int to a double and the int is converted first. These are not special cases to memorise individually — they are two rules that C++ applies to every arithmetic expression before the operator does any work at all. Chapter 3.3 needed a cast and did not say why; Chapter 3.4 called it arithmetic width and handed it here. This is where both debts are paid.
Foundation15 min readC++Part IFundamentalsOperators
Part I · Module 4 · Chapter 4.2 · Arithmetic Operators and the Type of a Result
1. The Operators
Five binary arithmetic operators and two unary ones:
a + b // addition
a - b // subtraction
a * b // multiplication
a / b // division
a % b // remainder
-a // negation
+a // unary plus — rarely usefulFor the familiar cases these do what you expect. Two of them do not, and they have their own chapter:
/on integers truncates rather than producing a fraction.%gives a remainder and its sign with negative operands surprises people.
Chapter 4.3 covers both, along with what division by zero does. This chapter is about something that applies to all seven: the type of the result.
2. The Question Worth Asking
Chapter 4.1 §2 established that an expression produces a value of a type. For arithmetic, people reason about the value and assume the type follows the operands.
It frequently does not. Here is the measurement:
#include <cstdint>
#include <iostream>
#include <type_traits>
int main()
{
char a = 10, b = 20;
std::uint8_t x = 10, y = 20;
short s = 10, t = 20;
unsigned int u = 10;
int i = 20;
std::cout << "char + char is int? " << std::is_same<decltype(a + b), int>::value << '\n';
std::cout << "uint8 + uint8 is int? " << std::is_same<decltype(x + y), int>::value << '\n';
std::cout << "short + short is int? " << std::is_same<decltype(s + t), int>::value << '\n';
std::cout << "int + unsigned is unsigned? " << std::is_same<decltype(i + u), unsigned int>::value << '\n';
std::cout << "int + double is double? " << std::is_same<decltype(i + 1.0), double>::value << '\n';
std::cout << "sizeof(a+b) = " << sizeof(a + b) << " (sizeof(char) = " << sizeof(char) << ")\n";
return 0;
}Expected output:
char + char is int? 1
uint8 + uint8 is int? 1
short + short is int? 1
int + unsigned is unsigned? 1
int + double is double? 1
sizeof(a+b) = 4 (sizeof(char) = 1)All ones. Read the last line twice: both operands occupy one byte and the result occupies four.
Two rules produce all of that, and they run in order.
3. Rule One: Integer Promotion
Before arithmetic, any integer type narrower than
intis converted toint.
That covers char, signed char, unsigned char, short, unsigned short, bool, and therefore std::uint8_t and std::uint16_t — which Chapter 3.6 §7 established are character and short types underneath.
This is called integer promotion, and it happens whether you want it or not. There is no arithmetic on 8-bit values in C++; there is arithmetic on int values that came from 8-bit objects.
The reason is historical and practical: int is meant to be the size the machine computes with most naturally, so promoting small types to it costs nothing and avoids defining arithmetic separately for every narrow type.
What it explains
Chapter 3.3 §4 needed a cast, and now you can see why:
std::uint8_t packet_count = 255;
packet_count = static_cast<std::uint8_t>(packet_count + 1);packet_count + 1 promotes packet_count to int, adds 1, and produces the int value 256. There is no wraparound at this point — an int holds 256 comfortably. The wraparound happens when that 256 is converted back to std::uint8_t on assignment.
So the modulo-2⁸ behaviour Chapter 3.3 described is real, and it happens at the assignment rather than in the addition. Without the cast you are storing an int into a std::uint8_t, which Chapter 3.7 §4 identified as a narrowing conversion.
Chapter 3.4 §5's "arithmetic width" is this rule. A 5-bit field stored in a std::uint8_t is computed on as an int, and nothing about the storage type constrains the arithmetic.
4. Rule Two: The Usual Arithmetic Conversions
Promotion handles types narrower than int. The second rule handles everything else:
If the two operands still have different types after promotion, both are converted to a common type before the operator runs.
This is called the usual arithmetic conversions. The full ranking has more steps than you need now; three consequences cover almost everything:
| Operands | Result type | Why |
|---|---|---|
int and double | double | Floating point wins over integer |
int and unsigned int | unsigned int | At equal rank, unsigned wins |
int and long | long | The wider type wins |
The second row is the one that causes bugs, and it is Chapter 3.3 §7's trap restated as a rule about types:
int offset = -1;
unsigned int length = 4;
offset < length // offset is converted to unsigned FIRSTChapter 3.3 showed the result and deferred the mechanism. The mechanism is this rule: at equal rank the signed operand is converted to unsigned, and −1 becomes a very large number by the modulo-2ᴺ rule. Chapter 4.4 works the comparison case through fully.
The first row is the useful one:
double average = total_cycles / 1.0; // int / double → double5. A Worked Example
#include <cstdint>
#include <iostream>
int main()
{
std::uint8_t low_byte = 200;
std::uint8_t high_byte = 100;
int transfers = 7;
double clock_mhz = 133.5;
std::cout << "u8 + u8 = " << (low_byte + high_byte) << '\n';
std::cout << "stored back in u8= "
<< static_cast<unsigned>(static_cast<std::uint8_t>(low_byte + high_byte)) << '\n';
std::cout << "int * double = " << (transfers * clock_mhz) << '\n';
return 0;
}Expected output:
u8 + u8 = 300
stored back in u8= 44
int * double = 934.5Three lines, three lessons.
200 + 100 gave 300, not 44. Both operands were promoted to int, so the addition had room. The result type is int.
Storing it back gave 44. 300 modulo 256 is 44. The loss happened at the conversion into std::uint8_t, exactly where Chapter 3.3 §4 said unsigned wraparound occurs — and nowhere else.
7 * 133.5 gave 934.5. The int was converted to double and the multiplication happened in floating point.
Notice what you can now predict without running anything: the type of each result, and therefore where a value can and cannot be lost.
6. Where This Chapter Stops
The conversions above happen before an arithmetic operator runs, and this chapter covers them because the result type cannot be explained without them.
Conversions as a subject are larger than that. They also happen when you assign, when you initialise, when you pass an argument, and when you ask for one explicitly with a cast. Chapter 4.10 owns all of it, including:
- making a conversion visible with
static_castrather than letting it happen silently, - why a C-style cast is a poor way to say the same thing,
- and how narrowing (Chapter 3.7) fits into the wider picture.
For now you have the two rules that govern arithmetic, and that is enough to reason about any expression in this module.
7. One Thing Arithmetic Does Not Save You From
A reminder rather than a new rule, because it combines with everything above.
Chapter 3.3 §5 established that signed integer overflow is undefined behaviour. Promotion does not change that — it moves the arithmetic to int, and int can still overflow:
int cycle_count = 2'000'000'000;
int doubled = cycle_count * 2; // overflows int: undefined behaviourBoth operands are already int, so no promotion occurs and no conversion saves you. The product does not fit and the program has no defined meaning.
The fix is to compute in a type that is wide enough, which you choose deliberately:
std::int64_t doubled = static_cast<std::int64_t>(cycle_count) * 2;Note where the cast goes: on an operand, not on the result. Casting the result would be too late — the overflow would already have happened in int arithmetic before anything was converted. This is a direct consequence of §2's point that the operator works on the converted operand types.
8. Mistakes Worth Making Once
Assuming the result has the operands' type. Two std::uint8_t values added produce an int. Measured: sizeof(a+b) is 4 while sizeof(char) is 1.
Expecting small-type arithmetic to wrap during the operation. It does not — promotion gives it room. The wrap happens when you store the result back into the narrow type.
Forgetting that int meets unsigned as unsigned. This is Chapter 3.3's trap as a type rule, and Chapter 4.4 shows it biting.
Casting the result instead of an operand. static_cast<std::int64_t>(a * b) computes a * b in the original type first. If that overflows, the cast arrives too late.
Thinking promotion protects against overflow generally. It only helps types narrower than int. int * int has no wider type to be promoted to.
Mixing integer and floating-point operands without noticing. It works and the result is floating point — which is usually what you wanted, and occasionally is how a value you expected to be exact becomes an approximation (Chapter 3.5).
9. What You Should Be Able to Say Now
- The arithmetic operators are
+ - * / %and unary-and+. Division and remainder have their own chapter. - The interesting question is the type of the result, not only its value.
- Integer promotion: any integer type narrower than
intis converted tointbefore arithmetic. That includeschar,short,bool,std::uint8_tandstd::uint16_t. - Measured:
char + char,uint8 + uint8andshort + shortall produceint, andsizeof(a+b)is 4 whilesizeof(char)is 1. - There is no 8-bit arithmetic in C++ — only arithmetic on
intvalues that came from 8-bit objects. - Usual arithmetic conversions: operands that still differ after promotion are brought to a common type.
intwithdoublegivesdouble;intwithunsigned intgivesunsigned int;intwithlonggiveslong. - That unsigned rule is the mechanism behind Chapter 3.3 §7's surprising comparison. Chapter 4.4 completes it.
- Promotion is protective:
200 + 100inuint8_tobjects gives 300 rather than overflowing, and the loss happens visibly at the store-back (44). - These conversions happen before the operator runs. Conversions as a subject — casts, assignment, narrowing — are Chapter 4.10.
- Promotion does not prevent
intoverflow, which remains undefined behaviour. - To compute in a wider type, cast an operand, not the result.
10. Exercises
Exercise 1 — name the result type. Given std::uint8_t b, short s, int i, unsigned u, long l, double d: (a) b + b (b) s * 2 (c) i + u (d) i + l (e) i / d (f) b + d.
Exercise 2 — predict two values. With std::uint8_t x = 200, y = 100; say what x + y produces and what static_cast<std::uint8_t>(x + y) produces, and name where the loss occurs.
Exercise 3 — explain the old cast. Chapter 3.3 §4 wrote static_cast<std::uint8_t>(packet_count + 1). Explain what the addition produced, why no wraparound happened there, and what the cast is actually doing.
Exercise 4 — fix the overflow. int cycles = 2'000'000'000; int doubled = cycles * 2; is undefined behaviour. Give a correct version and explain why putting the cast around cycles * 2 would not work.
Exercise 5 — the unsigned surprise, early. Without reading Chapter 4.4, predict the type of i + u where i is int and u is unsigned int, and say what that implies if i is negative.
Exercise 6 — why promotion helps. Argue that integer promotion makes narrow-type arithmetic safer rather than more confusing, using the 200 + 100 case. Then state the cost it imposes on the programmer.
Exercise 7 — the accidental double. int total = 7; int count = 2; auto average = total / count; gives 3. A colleague changes count to 2.0 and the result becomes 3.5. Explain both results in terms of the rules in this chapter, and say what the type of average is in each case.
11. Summary
- The operators are the easy half. The result's type is where the engineering is.
- Integer promotion: types narrower than
intbecomeintbefore arithmetic —char,short,bool,std::uint8_t,std::uint16_t. - Measured: two one-byte operands produce a four-byte result.
- There is no narrow-type arithmetic in C++, only arithmetic on promoted values.
- Usual arithmetic conversions then bring differing operands to one type: floating point beats integer, unsigned beats signed at equal rank, wider beats narrower.
intmeetsunsignedas unsigned — the mechanism behind Chapter 3.3's trap, completed in Chapter 4.4.- Promotion is protective:
200 + 100gave 300, and the wrap to 44 happened visibly at the store-back. - These conversions precede the operator. Conversions as a subject are Chapter 4.10.
- Promotion does not stop
intoverflow, which is still undefined behaviour. - Cast an operand, not the result — by the time you cast a result the arithmetic has already happened.
Two of the operators listed in §1 were set aside, and they are the two that behave least like the arithmetic you learned at school. Integer division throws away the fractional part rather than rounding it, which means the answer to 7 / 2 is not 3.5 and is also not 4. The remainder operator interacts with that truncation in a way that decides its sign, and the rule is not the one most people guess when the operands are negative. Chapter 4.3 takes both, along with the one arithmetic operation that has no defined result at all.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
