C++ Part I · Module 4 · Expressions and Operators
Comparison and Equality Operators
The operators in this chapter produce answers rather than numbers, and most of the time they are completely unremarkable. The interesting part is that comparison is where the conversion rules from Chapter 4.2 become visible as bugs rather than as curiosities. Chapter 3.3 showed a comparison between a signed and an unsigned value returning the opposite of what anyone expected, and deferred the explanation twice; Chapter 4.2 supplied the rule that causes it without applying it. This is where the two meet, and seeing the connection is what turns a memorised gotcha into something you can predict for cases you have not met. The chapter also covers the case that looks identical and is actually fine — because a rule of the form never mix integer types is both wrong and unhelpful — and the comparison that reads like ordinary mathematics and compiles into something that is true no matter what you put in it.
Foundation15 min readC++Part IFundamentalsOperators
Part I · Module 4 · Chapter 4.4 · Comparison and Equality Operators
1. The Six Operators
a == b // equal
a != b // not equal
a < b // less than
a <= b // less than or equal
a > b // greater than
a >= b // greater than or equalEach produces a bool — Chapter 3.6's logical type, with exactly two values.
That result type is worth noticing rather than skipping. Chapter 4.1 §2 said every expression produces a value of a type, and here the result type is unrelated to the operand types: comparing two double values gives a bool, not a double. Section 5 shows what goes wrong when someone forgets that.
2. The Easy Case
When both operands have the same type, comparison does exactly what you expect:
int measured = 19;
int expected = 23;
measured == expected // false
measured < expected // trueNothing to learn. The rest of the chapter is about what happens when the operands' types differ, because the conversions from Chapter 4.2 run first — and a comparison is where their effect becomes a wrong answer rather than an unexpected type.
3. Mixed Signedness: The Debt from Chapter 3.3
Here is the program Chapter 3.3 §7 ran and left unexplained:
#include <iostream>
int main()
{
int offset = -1;
unsigned int length = 4;
if (offset < length) {
std::cout << "offset < length\n";
} else {
std::cout << "offset >= length <-- surprising\n";
}
return 0;
}Expected output:
offset >= length <-- surprising−1 is not less than 4. Now the mechanism, which you already have.
Chapter 4.2 §4 established the usual arithmetic conversions, including this row:
| Operands | Converted to |
|---|---|
int and unsigned int | unsigned int |
Those conversions apply to comparison operators exactly as they apply to arithmetic. So before < runs, both operands are brought to unsigned int, which means offset is converted first.
What does −1 become as an unsigned int? Chapter 3.3 §4 answered that: unsigned conversion is modulo 2ᴺ, so −1 becomes 4294967295 — the largest unsigned int.
The comparison actually performed is therefore:
4294967295 < 4 → falseEvery step is well defined. The conversion is specified, the comparison is specified, and the result is specified. The program is not undefined — it is simply wrong. That is a worse category than undefined in one respect: nothing will ever flag it at run time, because nothing is broken.
Something flags it at compile time:
mixed.cpp:7:16: warning: comparison of integers of different signs:
'int' and 'unsigned int' [-Wsign-compare]This is -Wall doing the job Chapter 2.3 §5 argued for.
Do not compare a signed value with an unsigned one. Keep
-Wsign-compareon so you are told when you have.
4. Mixed Width: The Case That Is Fine
It would be easy to conclude "never compare different integer types". That rule is wrong, and believing it makes code worse rather than safer. Here is a comparison that mixes types and is completely safe:
#include <cstdint>
#include <iostream>
int main()
{
std::uint8_t small_count = 200;
int limit = 300;
std::cout << "uint8(200) < int(300) ? "
<< (small_count < limit ? "true" : "false") << '\n';
return 0;
}Expected output:
uint8(200) < int(300) ? trueCorrect, and no warning.
Why is this one fine when §3's was not? Chapter 4.2 §3: std::uint8_t is narrower than int, so it is promoted to int before anything else happens. Both operands are then int, there is no signed-to-unsigned conversion, and every value a std::uint8_t can hold fits in an int exactly.
So the real rule is narrower and more useful than "never mix":
The danger is mixing signedness, not mixing width. A narrow type that promotes to
intis safe; a type that forces the other operand to unsigned is not.
The practical form: std::uint8_t and std::uint16_t compare safely against int, because they promote. unsigned int, std::uint32_t and std::uint64_t do not promote to int — they are at least as wide — so comparing them with a signed value triggers §3's conversion.
That distinction is why -Wsign-compare warned about one of these programs and not the other. The compiler is applying the real rule, not the crude one.
5. Chained Comparison
This looks like mathematics and is not:
if (1 < voltage < 3) { /* ... */ } // INCORRECTThe intent is obvious: is voltage between 1 and 3? What C++ does is apply < twice, left to right.
Written out, with voltage holding 5:
#include <iostream>
int main()
{
int voltage = 5;
bool first = (1 < voltage); // true
bool wrong = (first < 3); // true converts to 1; 1 < 3 is true
std::cout << "(1 < voltage) is " << first << '\n';
std::cout << "then that < 3 is " << wrong << " <-- always true\n";
bool right = (1 < voltage) && (voltage < 3);
std::cout << "(1 < voltage) && (voltage < 3) is " << right << '\n';
return 0;
}Expected output:
(1 < voltage) is 1
then that < 3 is 1 <-- always true
(1 < voltage) && (voltage < 3) is 0The first comparison produces a bool. That bool then becomes the left operand of the second comparison, where it converts to 0 or 1 (Chapter 3.6 §3). Both 0 and 1 are less than 3, so the whole expression is true for every possible value of voltage — including 5, which is plainly not between 1 and 3.
The correct form compares voltage twice and joins the results, which gives false as it should. Chapter 4.5 covers && properly.
6. Comparing Floating-Point Values
Chapter 3.5 §4 established that == on computed floating-point values is a bug pattern, and showed (0.1 + 0.2) == 0.3 being false while (0.5 + 0.25) == 0.75 is exactly true.
Everything there applies to the operators in this chapter and is not repeated. The one line to carry:
==and!=ask whether two floating-point values are bit-for-bit identical. Compare computed values with a tolerance instead, chosen for the magnitudes involved.
The ordering comparisons — <, >, <=, >= — are less fragile, because they ask which side of a boundary a value falls on rather than demanding exactness. A value that is off by one part in 10¹⁶ still compares correctly against a threshold it is nowhere near. They are not immune, though: a comparison against a value the computation was supposed to land exactly on has the same problem == does.
7. Mistakes Worth Making Once
Comparing a signed value with an unsigned one. The signed operand converts to unsigned first, so -1 < 4u is false. Defined, and wrong.
Concluding "never mix integer types". Mixing width is usually safe because narrow types promote to int. Mixing signedness is the problem.
Assuming std::uint32_t behaves like std::uint8_t in a comparison. It does not — uint8_t promotes to int and uint32_t does not.
Writing 1 < x < 3. It compares twice, left to right, and is always true. Write (1 < x) && (x < 3).
Forgetting a comparison produces a bool. That is exactly what makes the chained form compile: the bool becomes an operand of the next comparison.
Using == on computed floating-point values. Chapter 3.5. Use a tolerance.
Turning off -Wsign-compare because it is noisy. The noise is the point. A codebase with many of these warnings has many places where a negative value would be read as four billion.
8. What You Should Be Able to Say Now
- The six comparison operators all produce a
bool, whatever the operand types are. - C++20 adds
<=>; it is out of this curriculum's C++17 baseline and belongs to Chapter 15.4. - When operand types differ, the usual arithmetic conversions from Chapter 4.2 §4 run before the comparison.
intcompared withunsigned intconverts theintto unsigned. −1 becomes 4294967295, so-1 < 4uis false.- That program is not undefined — every step is specified. It is simply wrong, which means nothing will flag it at run time.
-Wsign-compareflags it at compile time. Measured on the mixed-sign case; silent on the safe one.- Comparing a
std::uint8_twith anintis safe, because the narrow type promotes tointand every value fits. - The real rule is about signedness, not width.
uint8_t/uint16_tpromote;uint32_t/uint64_tdo not. 1 < x < 3compares twice: the first comparison'sboolbecomes an operand of the second, and the result is always true.- The correct form is
(1 < x) && (x < 3). - Some compilers now make the chained form an error — measured here — and others warn. The semantics are the lesson; the severity is a toolchain fact.
==on computed floating-point values is a bug pattern (Chapter 3.5). Ordering comparisons are more robust but not immune.
9. Exercises
Exercise 1 — predict four comparisons. Given int i = -1; unsigned u = 1; std::uint8_t b = 200; int n = 300; say what each produces and whether it warns: (a) i < u (b) i < 1 (c) b < n (d) b == 200.
Exercise 2 — derive the surprise. Without looking at §3, explain step by step why -1 < 4u is false. Name the conversion rule and the chapter that established it.
Exercise 3 — explain the asymmetry. Comparing std::uint8_t with int is safe and comparing std::uint32_t with int is not. Explain the difference in one sentence, naming the mechanism.
Exercise 4 — what does it actually compute? For if (0 < count < 10), write out the two comparisons that are performed and say for which values of count the whole expression is true.
Exercise 5 — fix a range check. A reviewer finds if (min_cycles < measured < max_cycles). Give the corrected line, and explain what you would say in the review beyond "use &&".
Exercise 6 — judge the warning. A build produces 40 -Wsign-compare warnings and a colleague proposes turning the flag off to clear the noise. Give the argument against, and a practical alternative that reduces the count honestly.
Exercise 7 — classify the risk. For each, say whether floating-point comparison is reasonable: (a) if (voltage > 1.5) where voltage is measured; (b) if (total == 0.3) where total is 0.1 + 0.2; (c) if (delay >= limit) where both come from the same calculation.
10. Summary
- Six operators, all producing
boolregardless of operand type. - Conversions run first. A comparison is where Chapter 4.2's rules become wrong answers rather than unexpected types.
intwithunsigned intconverts the signed operand to unsigned — so-1 < 4uis false, defined, and wrong.- Nothing catches it at run time because nothing is broken.
-Wsign-comparecatches it at compile time. - Width is usually safe; signedness is the danger.
uint8_tanduint16_tpromote toint;uint32_tanduint64_tdo not. 1 < x < 3is always true — the first comparison'sboolbecomes an operand of the second. Use(1 < x) && (x < 3).- Some compilers reject the chained form outright; others warn. Semantics are the lesson, severity is the toolchain.
==on computed floating-point values is a bug pattern; ordering comparisons are sturdier but not immune.
A comparison produces a single answer, and real conditions usually need more than one — the corrected range check in §5 needed two comparisons joined together. The operator that joined them has a property that looks like a convenience and is actually a guarantee you can build on: it is allowed to skip evaluating its right-hand side entirely, and the standard says exactly when. That turns out to be what makes a whole category of safety check possible, including the guard that stops Chapter 4.3's division by zero from ever running. Chapter 4.5 is &&, || and short-circuit evaluation.
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.
