What Are Relational Operators?

Almost every decision a program makes starts as a question about two values: is this one bigger, is that one smaller, are they the same? Relational operators are the six operators that ask those questions. Each takes two operands, and each hands back a bool, which is exactly the kind of value an if statement or a conditional expression wants to consume.

They are also the first place where the tidy arithmetic you learned in the previous lessons stops matching what the hardware actually stores. Comparisons on integers are exact. Comparisons on calculated floating-point values are not, and the second half of this lesson is about what to do instead.

The Six Comparisons at a Glance

Symbol Reads as Result is true when
< less than the left operand sits below the right
<= less than or equal to the left operand sits below the right, or matches it
> greater than the left operand sits above the right
>= greater than or equal to the left operand sits above the right, or matches it
== equal to both operands hold the same value
!= not equal to the operands hold different values

Notice that == is two equals signs. A single = is assignment, and mixing the two is one of the classic beginner bugs.

With integers there is nothing subtle going on. Print the six results side by side and they read like arithmetic:

#include <iostream>

int main()
{
    constexpr int shelfStock{18};
    constexpr int reorderLevel{25};

    std::cout << std::boolalpha;
    std::cout << "stock == level: " << (shelfStock == reorderLevel) << '\n';
    std::cout << "stock != level: " << (shelfStock != reorderLevel) << '\n';
    std::cout << "stock <  level: " << (shelfStock < reorderLevel) << '\n';
    std::cout << "stock <= level: " << (shelfStock <= reorderLevel) << '\n';
    std::cout << "stock >  level: " << (shelfStock > reorderLevel) << '\n';
    std::cout << "stock >= level: " << (shelfStock >= reorderLevel) << '\n';

    return 0;
}
stock == level: false
stock != level: true
stock <  level: true
stock <= level: true
stock >  level: false
stock >= level: false

std::boolalpha is what makes the results print as true and false rather than 1 and 0. Without it you would see six digits, because a bool converts to int on its way to std::cout.

A Condition Is Already a Boolean

An if statement does not need to be told what true looks like. It evaluates whatever expression you hand it and branches on the resulting bool, so a bool variable can go in on its own:

if (isReady) // reads as "if ready"

Comparing that variable against true asks the same question a second time and throws the answer away:

if (isReady == true) // the comparison adds nothing

The negative case works the same way. Rather than testing against false, apply logical NOT:

if (!isReady) // the ! flips the condition
Best Practice
When the thing you are testing is already a `bool`, `== true` and `== false` cost you characters and buy you nothing. Let the variable stand on its own, and reach for `!` when you want the opposite.

Where Comparison Stops Being Obvious

Integers are stored exactly, so the six operators tell the exact truth about them. A double is different. It holds a binary approximation of the decimal you wrote, and arithmetic on approximations produces approximations. That is enough to break a comparison that looks unarguable on paper:

#include <iomanip>
#include <iostream>

int main()
{
    constexpr double drainedFromTank{40.0 - 37.6}; // 2.4 litres by hand
    constexpr double drainedFromJug{6.0 - 3.6};    // also 2.4 litres by hand

    std::cout << std::setprecision(17);
    std::cout << "tank drop: " << drainedFromTank << '\n';
    std::cout << "jug drop:  " << drainedFromJug << '\n';

    if (drainedFromTank == drainedFromJug)
        std::cout << "both figures match\n";
    else if (drainedFromTank < drainedFromJug)
        std::cout << "tank figure came out smaller\n";
    else
        std::cout << "jug figure came out smaller\n";

    return 0;
}

Both subtractions describe the same 2.4 litres. The program disagrees:

tank drop: 2.3999999999999986
jug drop:  2.3999999999999999
tank figure came out smaller

Turning the precision up to seventeen significant digits shows where the two answers parted company. The jug subtraction landed on the closest double there is to 2.4, printed here as 2.3999999999999999. The tank subtraction came up short of it by roughly 1.3e-15, which is nothing at all as a quantity of fuel and everything as far as == is concerned. Nothing here is a compiler defect: it is the arithmetic behaving exactly as the format requires.

Related Content
The mechanics behind these rounding errors, and why some decimal fractions have no exact binary form, are covered in the Floating Point Numbers lesson.

Ordering Operators: Usually Fine, Occasionally Wrong

How much that matters depends on which operator you reach for.

<, >, <= and >= answer correctly whenever the operands are meaningfully apart, because an error in the sixteenth digit cannot flip a question about two values that already differ in the third. They only turn into a coin toss inside the narrow band where the operands are nearly the same, and in that band the rounding error, not the data, decides the answer.

Whether that is a defect is a question about your program rather than about C++. Picture a thermostat that fires the heater whenever the measured temperature drops below the setpoint. When the room is genuinely cold the comparison is correct and the heater runs. When the reading sits a millionth of a degree either side of the setpoint, the operator may go either way, and neither answer is wrong in any way a person could notice: the next sample arrives a second later and settles it. Applications like that can use the ordering operators as they stand.

Equality: Wrong Far More Often Than You Expect

== has no tolerance built into it at all. It reports true only for an exact match, so a rounding error anywhere in the final digit is enough to separate two values that agree to fifteen decimal places. != inherits the same weakness, since it is == with the answer flipped.

#include <iostream>

int main()
{
    std::cout << std::boolalpha;
    std::cout << (1.1 + 2.2 == 3.3) << '\n';

    return 0;
}
false

The sum lands on 3.3000000000000003, while the literal 3.3 converts to 3.2999999999999998. Two different bit patterns, so two different values, so false.

Warning
If a floating-point value came out of arithmetic of any kind, `==` and `!=` are the wrong tools for inspecting it. Treat a calculated `double` as approximate, and compare it with a tolerance instead.

The One Case Where Exact Equality Is Fine

There is a narrow situation where == on floating-point values is defensible. When a variable is initialised from a literal, never touched by arithmetic, and then compared against that same literal written the same way in the same type, both sides go through an identical decimal-to-binary conversion and arrive at an identical bit pattern.

#include <iostream>

int main()
{
    constexpr double gearRatio{2.7};

    std::cout << std::boolalpha;
    std::cout << (gearRatio == 2.7) << '\n';  // identical text, identical type on both sides
    std::cout << (gearRatio == 2.7f) << '\n'; // float form widened back up

    return 0;
}
true
false

The first line holds because the conversion ran twice on the same text with the same target type. The second fails because 2.7f and 2.7 are the same text converted into two different formats: float keeps fewer bits, so widening it back to double produces a value that no longer matches. Mixing literal types breaks the guarantee.

There is a ceiling on the guarantee too, set by how much a type is required to carry:

Type Digits it can be relied on to hold
float 6
double 15

Write a literal with more digits than its type can hold and the value you stored is already rounded away from the text you typed, which puts you straight back to comparing approximations.

The pattern shows up most often with functions whose every return statement hands back a literal, typically a fixed rate or a sentinel:

double surchargeRate(); // every return statement here hands back a written-out value

if (surchargeRate() == 0.35) // fine: no arithmetic ran anywhere
    // apply standard surcharge
Tip
Exact equality survives three conditions at once: the same type on both sides, both sides tracing back to the same literal, and that literal short enough to fit inside the type's guaranteed precision (6 digits for `float`, 15 for `double`). Mixed types break it, and so does any arithmetic in between.

Comparing With a Tolerance

For everything else, change the question. Instead of asking whether two doubles are identical, ask whether the gap between them is small enough to ignore. The size of gap you are willing to ignore has a traditional name: epsilon. It is a small positive number, often written in scientific notation as something like 1e-8, and it converts an exact test into a test with slack in it.

The first version most people write uses a fixed distance:

#include <cmath> // for std::abs

// flatMargin: one fixed gap, applied at any input size
bool closeEnoughFlat(double left, double right, double flatMargin)
{
    return std::abs(left - right) <= flatMargin;
}

std::abs(), from <cmath>, discards the sign of its argument, so std::abs(left - right) is the distance between the two operands no matter which of them is larger. Measure that distance, compare it against the margin, and you have your verdict.

The trouble is that one fixed distance cannot serve numbers of every size. Suppose you settle on a margin of 0.001:

Pair being compared Verdict Reasonable?
5.0 and 5.0005 equal Yes, they differ by a hundredth of a percent
0.0002 and 0.0009 equal No, one operand is more than four times the other
82000.0 and 82000.4 different No, they agree to five significant digits

Every row uses that one margin, and only the first row comes out sensibly. To use this function well you would have to pick a new margin at every call site, sized to the numbers going in. If the caller has to do that scaling by hand, the function may as well do it instead.

Scaling the Tolerance to the Numbers

Donald Knuth described the standard fix in The Art of Computer Programming, Volume II: Seminumerical Algorithms. Make the margin a fraction of the values being compared rather than a fixed distance:

#include <algorithm> // for std::max
#include <cmath>     // for std::abs

// gap has to fit inside scaledMargin of whichever magnitude is bigger
bool closeEnoughScaled(double left, double right, double scaledMargin)
{
    return std::abs(left - right) <= std::max(std::abs(left), std::abs(right)) * scaledMargin;
}

Read the two sides of <= separately.

On the left, std::abs(left - right) is the same distance as before.

On the right, std::max(std::abs(left), std::abs(right)) takes the larger of the two magnitudes as a stand-in for the scale of the whole comparison, and multiplying it by scaledMargin turns that scale into an allowance. scaledMargin therefore reads as a proportion rather than a distance: pass 0.01 to accept a gap of up to one percent of the larger operand, or 0.002 to tighten it to two tenths of a percent. The same call now behaves sensibly whether the operands are measured in millionths or in millions.

To ask the opposite question, call the function and negate the answer:

if (!closeEnoughScaled(measured, expected, 0.002))
    std::cout << "the reading is off\n";

The catch appears once the operands themselves shrink toward zero:

#include <algorithm> // for std::max
#include <cmath>     // for std::abs
#include <iostream>

// gap has to fit inside scaledMargin of whichever magnitude is bigger
bool closeEnoughScaled(double left, double right, double scaledMargin)
{
    return std::abs(left - right) <= std::max(std::abs(left), std::abs(right)) * scaledMargin;
}

int main()
{
    constexpr double travelled{1.1 * 7.0}; // seven 1.1 mm steps, 7.7 mm by hand
    constexpr double scaledMargin{1e-8};

    std::cout << std::boolalpha;
    std::cout << closeEnoughScaled(travelled, 7.7, scaledMargin) << '\n';
    std::cout << closeEnoughScaled(travelled - 7.7, 0.0, scaledMargin) << '\n';

    return 0;
}
true
false

Both lines ask about the same rounding error. The first compares the computed total against 7.7 and passes. The second subtracts 7.7 first and asks whether the leftover is zero, and it fails.

The reason is on the right-hand side of the expression. Once both operands sit near zero, the larger magnitude is itself tiny, and a fraction of a tiny number is tinier still. The allowance collapses faster than the error does, and in the limit only an exact match gets through.

Combining Both Margins

Neither margin covers the whole range on its own, so use each one where it works. Test the flat distance first, because that is the test that survives near zero, and fall back to the proportional test everywhere else:

#include <algorithm> // for std::max
#include <cmath>     // for std::abs
#include <iostream>

bool closeEnoughScaled(double left, double right, double scaledMargin)
{
    return std::abs(left - right) <= std::max(std::abs(left), std::abs(right)) * scaledMargin;
}

bool closeEnough(double left, double right, double flatMargin, double scaledMargin)
{
    if (std::abs(left - right) <= flatMargin) // rescues operands huddled around 0
        return true;

    return closeEnoughScaled(left, right, scaledMargin);
}

int main()
{
    constexpr double travelled{1.1 * 7.0};
    constexpr double flatMargin{1e-12};
    constexpr double scaledMargin{1e-8};

    std::cout << std::boolalpha;
    std::cout << closeEnoughScaled(travelled, 7.7, scaledMargin) << '\n';
    std::cout << closeEnoughScaled(travelled - 7.7, 0.0, scaledMargin) << '\n';
    std::cout << closeEnough(travelled, 7.7, flatMargin, scaledMargin) << '\n';
    std::cout << closeEnough(travelled - 7.7, 0.0, flatMargin, scaledMargin) << '\n';

    return 0;
}
true
false
true
true

The first two lines are the scaled test on its own, repeated for comparison. The last two are the combined version answering both questions correctly. The near-zero case now passes on the flat margin before the proportional arithmetic ever runs.

Because the flat margin only has to catch values huddled around zero, it can be very small: 1e-12 is a reasonable setting. Anything that gets past it is far enough from zero for the proportional test to behave, and 1e-8 is a reasonable setting there.

Best Practice
No single tolerance suits every program, so treat these numbers as a starting point rather than a rule: a flat margin of `1e-12` with a scaled margin of `1e-8` handles the great majority of everyday comparisons. Revisit both when your data lives at an unusual scale.

Compile-Time Comparisons (Advanced)

When both operands are compile-time constants, there is no reason to run the comparison at runtime. Marking the helpers constexpr lets the compiler settle the question while it is still compiling.

One portability wrinkle stands in the way. std::abs was only required to work inside a constant expression from C++23 onward, so a strictly conforming C++20 compiler is entitled to reject a constexpr variable that is initialised by one of these functions. GCC folds std::abs at compile time as an extension and accepts it anyway, and the compiler behind this platform does too. Supplying the absolute value yourself removes the question entirely:

#include <algorithm> // for std::max
#include <iostream>

// std::abs is only guaranteed usable here from C++23, so roll ours
constexpr double magnitudeOf(double value)
{
    return (value < 0.0 ? -value : value);
}

constexpr bool closeEnoughScaled(double left, double right, double scaledMargin)
{
    return magnitudeOf(left - right) <= std::max(magnitudeOf(left), magnitudeOf(right)) * scaledMargin;
}

constexpr bool closeEnough(double left, double right, double flatMargin, double scaledMargin)
{
    if (magnitudeOf(left - right) <= flatMargin)
        return true;

    return closeEnoughScaled(left, right, scaledMargin);
}

int main()
{
    constexpr double travelled{1.1 * 7.0};
    constexpr bool matches{closeEnough(travelled, 7.7, 1e-12, 1e-8)};

    std::cout << std::boolalpha << matches << '\n';

    return 0;
}
true

matches is a constexpr bool, so the whole comparison is resolved before the program starts and nothing is left for the processor to do.

Advanced
`magnitudeOf()` here only accepts a `double`. A later lesson on function templates shows how one definition can serve `float`, `double` and `long double` at once, which is how a real library would write it.

Key Terminology

  • Relational operator: an operator that compares two operands and produces a bool
  • Rounding error: the gap between the decimal value you wrote and the binary value actually stored
  • Epsilon: the tolerance used to decide whether two floating-point values are close enough to treat as equal
  • Flat (absolute) margin: a tolerance expressed as a fixed distance, unaffected by the size of the operands
  • Scaled (relative) margin: a tolerance expressed as a proportion of the larger operand's magnitude

Looking Forward

Relational operators are usually combined rather than used alone, and the next lesson introduces the logical operators (&&, ||, !) that join several comparisons into one condition. The constexpr functions sketched above get proper treatment in the later lesson on constexpr functions.

Summary

  • The six relational operators are <, <=, >, >=, == and !=, and each yields a bool
  • On integers they are exact, and they behave exactly as arithmetic suggests
  • A bool needs no comparison to be used as a condition: prefer if (isReady) and if (!isReady)
  • A double stores an approximation, so calculated values routinely miss the number you expected by a fraction of a digit
  • Ordering operators (<, >, <=, >=) remain trustworthy while the operands are meaningfully apart, and become a coin toss when they are nearly identical
  • Equality operators (==, !=) demand an exact match, so any rounding error at all makes them disagree with the answer you wanted
  • Exact equality is safe only when both sides are the same type, both trace back to the same literal, and the literal fits the type's guaranteed precision (6 digits for float, 15 for double)
  • The alternative is a tolerance: compare the gap between the values against an epsilon instead of demanding a match
  • A flat margin is one fixed distance, and it is either too coarse for small numbers or too fine for large ones
  • A scaled margin (Knuth's approach) sizes the allowance to the larger operand, which works everywhere except near zero
  • closeEnough() runs the flat test first to survive near zero, then falls back to the scaled test; 1e-12 and 1e-8 are sound defaults
  • Marking the helpers constexpr moves the comparison to compile time, though a portable pre-C++23 version needs its own absolute value helper