08 - operators

Basic arithmetic operators in C++ are written using symbols like +, -, * etc. An operator can be thought as a function (in mathematical sense) - it has certain input and produces certain output. An operator does not mean a symbol - some operators consist of multiple characters and some are even keywords. Operator is an abstract term.

In majority of programming languages (including languages of C family), there is no juxtaposition or any implicit use of operators. This means that mathematical expressions like 2(x + y) will create syntax errors - you need to write 2 * (x + y).

The number of inputs (operands) an operator takes is arity. C++ features operators with many arities:

  • nullary - 0

  • unary - 1

  • binary - 2

  • ternary - 3

  • n-ary - n

What's the point of a nullary operator? How can it produce something meaningful with no input?

Well, don't really be concerned with it for now. As you learn more about functions (operators are a specific subset of functions) you will realize how vastly different they can be and that functions taking 0 arguments are still useful. A short example for now can be a function returning current date/time - it doesn't require any input but always provides meaningful output.

Some operators exist with multiple arities. For example:

  • unary minus: x = -x (negates number)

  • binary minus z = x - y (performs subtraction)

Many operators can be chained to form nested expressions, but they may have different associativity:

  • left-to-right: x / y / z is equivalent to (x / y) / z

  • right-to-left: x = y = z is equivalent to x = (y = z)

For binary operators, there is a syntax sugar available when you want to perform assignment to the first of the operands:

  • x += y is the same as x = x + y.

  • x *= y is the same as x = x * y.

  • x %= y is the same as x = x % y.

  • and so on...

What if I write z *= y + x? How it is processed?

It works as z = z * (x + y). The entire right side is always evaluated first.

Much later, you will also learn about operator overloading. Because C++ allows to define new types (using specific keywords) and most built-in operators work only with built-in types, C++ allows to define meaning for operators when user-defined types are involved. You have already used overloaded operator<< with std::cout. Many IDEs will color overloaded operators to signal that their meaning has been specified in code, not in the language.

Comparison

  • less than: x < y

  • less than or equal: x <= y

  • greater than: x > y

  • greater than or equal: x >= y

  • equal: x == y

  • not equal: x != y

All them produce a value of type bool.

Arithmetic

There are 5 basic arithmetic operators: +, -, *, /, %. All can be used on integer types and all except % can be used on floating-point types.

Arithmetic expressions can generate different machine code depening on types of used variables. Math is done very differently on integer and floating-point types.

Since you are learning programming I'm pretty sure you are already familiar with math, but there are some special situations which you should understand - while math is flawless reality has its limitations.

Integer operations

Integer division is done ... on integers. The result is also an integer so any fractional part is lost. If you want the remainer, use modulo (%).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main()
{
	int x = 22;
	int y = 5;

	int quotient = x / y;
	int remainder = x % y;
	std::cout << "quotient: " << quotient << "\nremainder: " << remainder << "\n";

	// this should always print x
	std::cout << "reversed operation: " << quotient * y + remainder << "\n";
}

The above warning should be self-explanatory. Such operations have no definition in mathematical world, and the same happens in C++. If you perform these operations and can not guarantee what the second operand is (e.g. it's provided by the user of the program) you should definitely check it to prevent any potential bugs.

Overflow

If the result of the operation can not be represented (e.g. multiplication of 2 large integers produced value that does not fit in range)

  • for signed integers: the behavior is undefined

  • for unsigned integers: the value wraps around

Previously it was said that operations on signed and unsigned integers can be done using the same circuit - and this is still true for most hardware. C++ defines them differently though.

When overflow happens, the value wraps around. A good analogy is an odometer or a clock. The set of possible values is finite and there is a continuity between lowest and highest value.

odometer rollover

During overflow, the most significant digit is lost. In binary system, the wrapping happens at powers of 2 - for an 8-bit integer adding 1 to 1111 1111 (255) would make it 1 0000 0000 so after discarding excess digit, it becomes 0000 0000 (0).

  • For unsigned integers, this behavior is very desirable. Various formulas intentionally use wrapping behavior to form cycles or some pattern of repetition in output. Cryptography, hashing, control checksums and compression are very prominent users of overflow wrapping.

  • For signed integers, the behavior has no good use because the lowest representatable value is not zero, but some negative number. So instead of starting over from zero, it starts over from some huge negative value. Such behavior has no practical use. Even worse, it's a very good source of bugs, most commonly found in games. If players find an overflow bug within a tycoon-style game, they can try to form a construction so expensive that its total cost overflows, which then causes the cost to be negative which then causes the game to subtract negative amount of in-game currency from their inventory, effectively giving them money instead of spending it.

Undefined behavior gives the compiler infinite freedom for optimization. Since anything can happen, any machine code will be fine as long as other requirements are fulfilled. This lets compilers optimize away code like if (x + 1 < x) as the only situation in which the statement could be triggered is overflow. If x is a signed number, compiler can assume overflow never happens (programs should be free of undefined behavior) and remove dead code.

But who would write such nonsensical code?

It happens more often than you think. This example is very trivial, but once you start using more complex abstractions you will realize how easy it is to accidentally create such situations. The other thing is that you are still at the beginning of the tutorial so I can't really provide more complex examples.

You can see the difference on compiler explorer - functions are almost identical; the one operating on signed integer doesn't do any comparison in its machine code.

Overflow is reversible:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <cstdint> // for fixed-width type aliases

int main()
{
	std::uint16_t x = 65535u;
	std::cout << "before overflow: " << x << "\n";

	x += 3;
	std::cout << "after overflow: " << x << "\n";

	x -= 3;
	std::cout << "after another overflow: " << x << "\n";
}

Summing it up:

  • Overflow on unsigned numbers is well-defined in C++ because underlying hardware behavior is useful.

  • Overflow on signed numbers is undefined in C++ because it's not useful and making it undefined gives more freedom for optimizations.

...and for this reason:

Floating-point operations

Unlike with integers, there is no way for undefined behavior to occur during floating-point arithmetic. In bad situations, you will end up with special values instead:

  • Division by zero will result in positive or negative infinity, depending on signs of operands.

  • Overflow-wrapping can not happen. Intead, results will get stuck on positive or negative infinity.

  • Nonsensical operations (zero divided by zero, square root of negative number, logarithm of negative number, etc.) will result in NaN (not-a-number).

  • Any operation where one of operands is NaN will always result in NaN.

  • Some of these operations can raise floating-point exceptions. These are not C++ exceptions (it's a very different thing) and shouldn't concern you now.

Many operations with floating-point types are affected by floating-point environment. This is usually a set of CPU settings that affect current thread. Compiler options can also change the environment. This information shouldn't bother you now though, knowledge what happens in typical bad situations (listed above) is much more important.

Underflow

First, some clarity as this term often gets misunderstood. There is a common mistake where going below minimum representatable value is referred to as underflow.

Underflow occurs when the value is so small that the closest representatable value is zero. The following program continuously divides the same variable untill its value underflows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main()
{
	auto x = 1.0f;

	while (x != 0)
	{
		std::cout << x << "\n";
		x /= 2.0f;
	}

	std::cout << x << "\n";
}

More floating point shenanigans

If you are really interested about floating-point, I recommend you to watch CppCon 2015: John Farrier - Demystifying Floating Point. The presentation goes over many peculiarities and gives insight into various solutions. Knowing these is what separates good programmers from the best programmers.

Short summary of key points in the presentation:

  • Normalize values to range -1 - 1. Calculations in this range have highest precision.

  • Use formulas which don't mix big numbers with small numbers. Separate big with big and small with small operations will achieve more accurate results.

  • Don't ever compare floating-point directly as their equality (except with zero) is basically unachieveable. Instead, make comparisons within +/- epsilon tolerance from expected result.

  • There are multiple ways to round floating-point numbers:

    • towards zero

    • towards nearest integer

    • towards positive infinity

    • towards negative infinity

  • If you aren't sure, prefer multiplication over division (x / y can be refactored to x * (1 / y)). It's usually more precise and faster.

  • Avoid accumulation of bias: instead of adding a floating-point constant after every iteration, count number of iterations (as integer) and multiply it with the constant.

  • Check how your hardware treats denormal numbers. Some may feature 100x slowdown in which case you might prefer to change settings to perform underflow.

Logical operators

3 logical functions are available:

  • negation (NOT): !x

  • conjunction (AND): x && y

  • disjunction (OR): x || y

All of them operate on and produce values of type bool.

Equality operators (== and !=) work on various types but if they are used on values of type bool then == is also equivalent to NXOR function and != is also equivalent to XOR function.

Bit negation

~ flips all bits. This operator is unary.

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <bitset>

int main()
{
	unsigned x = 0b01001100;

	std::cout << " x: " << std::bitset<8>(x)  << "\n";
	std::cout << "~x: " << std::bitset<8>(~x) << "\n";
}

Bitwise operators

These operators apply specific logical function bitwise. That is, for 2 inputs, each having the same amount of bits, each of the output bits is a result of separate logical function applied to consecutive pairs of input bits.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <bitset>

int main()
{
	unsigned x = 0b11101001;
	unsigned y = 0b01010101;

	std::cout << "x      : " << std::bitset<8>(x) << "\n";
	std::cout << "y      : " << std::bitset<8>(y) << "\n";

	std::cout << "x AND y: " << std::bitset<8>(x & y) << "\n";
	std::cout << "x  OR y: " << std::bitset<8>(x | y) << "\n";
	std::cout << "x XOR y: " << std::bitset<8>(x ^ y) << "\n";
}

Bit shift operators

Move bits left or right. Bits going out are discarded and new bits are 0s.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <bitset>

int main()
{
	unsigned x = 0b00001100;

	std::cout << "value           : " << std::bitset<8>(x) << "\n";
	std::cout << "shift left  by 4: " << std::bitset<8>(x << 4) << "\n";
	std::cout << "shift left  by 5: " << std::bitset<8>(x << 5) << "\n";
	std::cout << "shift left  by 6: " << std::bitset<8>(x << 6) << "\n";
	std::cout << "shift right by 2: " << std::bitset<8>(x >> 2) << "\n";
	std::cout << "shift right by 3: " << std::bitset<8>(x >> 3) << "\n";
	std::cout << "shift right by 4: " << std::bitset<8>(x >> 4) << "\n";
}

This is also a good example how the same operator (<< and >> in this case) can perform different task depending on what it's being used with (here: bit shifts and stream insertion).

Why such simple operation can result in undefined behavior?

Short answer: optimization. Long answer: some 1 hour long CppCon video that I can't find.

Other operators

Not all operators are written using symbols. Many C++ operators are keywords. There are 2 very simple unary operators which return memory-related properties:

  • sizeof - returns the size in bytes of specific type

  • alignof - returns the alignment in bytes of specific type

Both always return a non-zero integer of type std::size_t.

Alignment affects placement of objects in memory. For simplest types, its value will usually be the same as size. Explaining it in detail would take some time and would require more knowledge about memory so I'm only mentioning it as an operator example.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>

int main()
{
	std::cout << "size of bool       : " << sizeof(bool)        << "\n";
	std::cout << "size of int        : " << sizeof(int)         << "\n";
	std::cout << "size of long       : " << sizeof(long)        << "\n";
	std::cout << "size of long long  : " << sizeof(long long)   << "\n";
	std::cout << "size of char       : " << sizeof(char)        << "\n";
	std::cout << "size of char16_t   : " << sizeof(char16_t)    << "\n";
	std::cout << "size of char32_t   : " << sizeof(char32_t)    << "\n";
	std::cout << "size of wchar_t    : " << sizeof(wchar_t)     << "\n";
	std::cout << "size of float      : " << sizeof(float)       << "\n";
	std::cout << "size of double     : " << sizeof(double)      << "\n";
	std::cout << "size of long double: " << sizeof(long double) << "\n";

	std::cout << "alignment of bool       : " << alignof(bool)        << "\n";
	std::cout << "alignment of int        : " << alignof(int)         << "\n";
	std::cout << "alignment of long       : " << alignof(long)        << "\n";
	std::cout << "alignment of long long  : " << alignof(long long)   << "\n";
	std::cout << "alignment of char       : " << alignof(char)        << "\n";
	std::cout << "alignment of char16_t   : " << alignof(char16_t)    << "\n";
	std::cout << "alignment of char32_t   : " << alignof(char32_t)    << "\n";
	std::cout << "alignment of wchar_t    : " << alignof(wchar_t)     << "\n";
	std::cout << "alignment of float      : " << alignof(float)       << "\n";
	std::cout << "alignment of double     : " << alignof(double)      << "\n";
	std::cout << "alignment of long double: " << alignof(long double) << "\n";

	// This syntax is also valid, but because many type names consist of
	// multiple keywords and functions always use (), it's heavily
	// recommended to always use (), just like with functions.
	// std::cout << "size of int: " << sizeof int << "\n";

	// These operators can also be used with objects, in which case
	// they will output the size/alignment of the type of the object.
	std::cout << "size of character output object: " << sizeof(std::cout) << "\n";
	std::cout << "alignment of character output object: " << alignof(std::cout) << "\n";
}

Why is size of bool equal to 1? Shouldn't it be a single bit? Is it because the operator returns size in bytes?

When stored in memory, bool will occupy a full byte[1]. This is for multiple reaons:

  • Memory is addressed by bytes, not bits. It's not possible to fetch a single bit.

  • Multiple bools could be packed in a byte but then saving and retrieving single bit information would unnecessarily complicate machine code, slowing down the program.

  • Higher memory usage does not necessarily mean worse performance. Vast majority of today's software prefers to sacrifice memory for faster execution. Most of the time, there is a lot of free memory but the processor is highly utilized.

In some cases, the compiler can optimize bool to a single bit (more precisely, a single CPU flag register). These cases are usually comparisons where the value of type bool is never stored, only immediately used for an if (or similar) statement.

Do these operators have actual use or are they just a way to obtain implementation-defined details?

They have use in raw memory operations. Such operations work on a lower abstraction layer than most of C++ type system and information they provide is crucial in these operations.

Recommendations

Precedence

Some operators have higher priority than others. Because C++ has over 40 different operators, no one remembers their precedence perfectly - only some of them are strictly related to math. Things can get complicated once multiple very different operators are used in one expression.

Therefore, it's highly recommended to wrap complex subexpressions in parentheses:

1
2
 a +   b * c  % d   ^  e & f  // unclear order unless reader remembers rules perfectly
(a + ((b * c) % d)) ^ (e & f) // much cleaner

This will make reading code easier while also saving you some time from dealing with unexpected bugs.

Spacing

It's intuitive for unary operators to have higher priority than binary operators: x + !y is processed as x + (!y). While whitespace characters doesn't matter in this case, it's highly recommended to write spaces around binary operators and stick unary operators to their arguments.

1
2
3
4
5
6
// bad
a+!b +c [d] <<e
a + ! b + c[ d]<< e
a +! b+c [ d ] << e
// good
a + !b + c[d] << e

In other words, operators which are applied first should be closer to the object.