We have learned about Types, and built a simplified model of the processor and its surrounding. Now that we have the raw material (data) and a workbench (the hardware), it is time to set things in motion and start crafting.
06 - Expressions
Expressions and statements
In C++, expressions are defined as "a sequence of operators and operands that specifies a computation". In other words, they are the individual steps of the recipes our apprentice (CPU) elaborates at the desk in the stone vessels (registers).
Statements, on the other hand, are instructions for the program to do
something. In C++, one of the simplest form of statement is an expression
followed by a semicolon ;.
-
1 + 1is an expression that evaluates to2. -
1 + 1;is a statement that instructs the compiler to compute the expression above, and then throws that result away.
In this case, this statement will have no effect as nothing changed, but we will soon see that many expressions modify the states of the program.
This chapter focuses on expressions. The next will look at statements.
Homogeneous operations
There are many operations available. We will present some of them concisely, in their simplest form where operands are of the same native type, then present some subtleties.
Arithmetic operations
+, -, *, / are relatively self-explanatory, although it is worth noting
that dividing two integral numbers results in an integral result.
In addition, we also get the % operator, called remainder or modulo operator.
It gives the remainder of a division: 11 % 3 is 2, because 11 is 3×3 +
2. Note that it defines the sign of the result like a division operation would.
You can test it above.
▸ For readers adept at binary: bitwise operators
There are also operators acting on the bitwise representation of the values.
C++ offers specific literals to write numbers in
binary and hexadecimal, by prefixing the numbers by 0b and 0x respectively:
the number 12 in decimal can be written as 0b1100 in binary, or 0x0c in
hexadecimal. For convenience, we will use numbers such as 0xffff0000 and
0x00ffff00 for our examples. 0xf corresponds to four consecutive 1s in
binary: 0b1111.
-
~is the unary operator for the "bitwise complement" operation: swapping all the 1s for 0s and all the 0s for 1s in the binary representation.~0x00ffff00is0xff0000ff. -
&is the bitwise AND operator:0x0000ffff & 0x00ffff00is0x0000ff00(only the bits present in both operands are kept). -
|is the bitwise OR operator:0xff000000 | 0x000000ffis0xff0000ff(any bit that is 1 in either operand is kept). -
^is the bitwise XOR operator (eXclusive OR):0x0000ffff ^ 0x00ffff00is0x00ff00ff(only the bits differing between both operands are kept) -
<<shifts the bits of the left-hand-side operand by the right-hand-side operand number of bits to the left:0x0000ffff<<4is0x000ffff0. The vacated bits are filled with 0s. Since C++20, left shift of negative numbers is well defined (it used to be Undefined Behaviour). -
>>shifts the bits of the left-hand-side operand by the right-hand-side operand number of bits to the right:0x0000ffff>>4is0x00000fff. The vacated bits are filled with 0s for unsigned ints and for positive signed ints, but with 1s for negative signed ints (so that the sign bit is kept). This is guaranteed since C++20.
Logical operations
Boolean values have their own operators:
-
&&is the "and" operator. It evaluates totrueonly when both operands aretrue. -
||is the "or" operator. It evaluates totrueif any operand istrue. -
!is the unary "not" operator. It changestrueintofalse, andfalseintotrue.
▸ Logical operations "short-circuit"
The one thing to note about these operators is that they "short-circuit":
- When evaluating an "or" operation
||, since if either operand istrue, the expression evaluates totrue, the first operand (left-hand side operand) is evaluated, and if it istrue, the evaluation stops there, returningtrue. Only if it isfalseis the second (right-hand side operand) evaluated. - Similarly, when evaluating an
andoperation&&, if either operand isfalse, the expression evaluates tofalse. So the second operand (right-hand side) is only evaluated if the first one (left-hand side) evaluates totrue.
Comparison operations
Values can also be compared. We use == (equal), != (not equal), <
(strictly less), > (strictly greater), <= (less or equal), and >= (greater
or equal). All of these operations evaluate to a
boolean for native
types.
Note that a single = sign is an assignment and not a comparison.
▸ The <=> spacecraft operator
While the comparison operators presented above are relatively self-explanatory, the semantics (specific meaning) of these operations can sometimes be ambiguous.
- The unambiguous case would be what we call "strong ordering". This is when we can order all the elements, and when two elements are equal, they are indistinguishable.
Remember our light intensities? Given
two different light intensity values (197 and 63), the order is obvious (63 < 197). And if two pixels of an image have the same light-intensity value,
that's genuinely the same light intensity: we can exchange them and the image is
unchanged.
- But sometimes, equality is not the same as being indistinguishable. This is what we call "weak ordering".
Suppose you compare children based on their Date of Birth. In most cases, the ordering will be easy: the '29th of February 2020' comes before the '1st of May 2020', fine. But being born on the same day doesn't make two children indistinguishable. Returning to parents a child that lived the same number of days as their own might not be good enough, some are bound to notice.
- Finally, there are things for which some values can be compared, but other values can't be compared. Because some parts can be ordered and other parts can't, we call this "partial ordering".
Now we're organising songs and musical pieces, and we had the brilliant idea to sort them by their tonality, or rather by their fundamental frequency. If a piece changes fundamental, we use the first one. So far, that's weak ordering: several pieces could have the same fundamental frequency while retaining their unique identity.
Then we stumble on John Cage's 4'33", a piece that is entirely silent. No sound, no fundamental, no tonality... Fine, we call it 0 frequency, and it will be the very first of our list. Our ordering survived that one.
But then comes Merzbow (Masami Akita), a Japanese artist known for his harsh noise music, and who uses a lot of white and pink noise in his compositions. Now we have the opposite problem: white and pink noise are "all frequencies at the same time".
"What is the fundamental frequency of your piece?
- Merzbow:
We could come up with more rules to make up an arbitrary ordering, but objectively, they don't really compare with the other songs.
That's where the C++20 "three-way comparison operator <=> comes in. It is the
all-in-one comparison: it answers at the same time whether the operands are
equal, less or greater, or in some case if they can't be compared (unordered).
The semantics of this newer operator solve the issue explained above. It returns one of these three types:
-
std::strong_ordering, which can have one of these values:-
less -
equivalentorequal -
greater
-
-
std::weak_ordering, which can have one of these values:-
less -
equivalent(but not)equal -
greater
-
-
std::partial_ordering, which can have one of these values:-
less -
equivalent(but not)equal -
greater -
unordered
-
std::strong_ordering::equivalent and std::weak_ordering::equivalent are
different values. The former means that the operands represent the same thing,
interchangeable, while the latter only means they have the same rank in the
ordering.
Assignment operations
Assignment operations are different from the operations we have seen so far because they modify one of their operand.
We can't modify a literal (10 can't be changed to
become 42), but we can modify a variable.
The operator = is the assignment operator (remember that to evaluate whether
two values are equal, we use ==). It is an operator that assigns the value on
its right-hand side to the left-hand side operand, and evaluates to that
left-hand side operand (with its new value).
Just like 1 + 1 evaluates to 2, i = 42 evaluates to i, but also changes
the value of i to 42 along the way. So it evaluates to i which is now
42.
There are many other assignment operations in C++ such as: +=, -=, *=,
/=, and %=. i += 3 is (roughly) equivalent to i = i + 3, and the others
follow the same pattern. Try them above. (The bitwise operations also have their
matching "assignment" variants: &=, |=, ^=, <<=, and >>=).
Increment and decrement operations
Finally, the operations C++ takes its name from. Increment and decrement operations are somewhat similar to assignment operations, as they, too, modify the variable they are applied to.
There are 4 such operations.
- Pre-increment (
++before the variable name): it increments (adds one to) the variable it is applied to, and evaluates to the new value of the variable. - Post-increment (
++after the variable name): it increments (adds one to) the variable it is applied to, and evaluates to the old value of the variable. - Pre-decrement (
--before the variable name): it decrements (subtracts one from) the variable it is applied to, and evaluates to the new value of the variable. - Post-decrement (
--after the variable name): it decrements (subtracts one from) the variable it is applied to, and evaluates to the old value of the variable.
Suppose we have:
int i {5};
-
++iis the pre-increment operation oni. It changesivalue to6, and evaluates to6.- It is similar to
i += 1, which is similar toi = i + 1.
- It is similar to
-
i++is the post-increment operation oni. It changesivalue to6, and evaluates to5(its value before the increment). -
--iis the pre-decrement operation oni. It changesivalue to4, and evaluates to4.- It is similar to
i -= 1, which is similar toi = i - 1.
- It is similar to
-
i--is the post-decrement operation oni. It changesivalue to4, and evaluates to5(its value before the decrement).
And that's why C++ is called that: it is an "increment" over the C language.
Ternary conditional operator and more…
C++ has a special operation that takes three operands. The first operator is a
condition, the second is the result of the operation if the condition evaluates
to true, and the third operator is the result of the operation if the
condition evaluates to false:
Note the ternary conditional operator is an expression, not a statement. We will see conditional statements soon. Also, only one of the selected operands, between the second and third, is evaluated.
There are more operations available, and we will see more as we progress. You
might remember for instance the subscript []
operator to access
members of an array we saw previously. We'll come back to it later as well.
Composing operations
Of course, we can compose these operations in many different ways. Operations have priorities (precedence), and also an associativity direction (left-to-right or right-to-left) for when the precedence isn't enough.
5 + 6 * 2
Because multiplications have a higher precedence than additions, this is
interpreted as:
5 + (6 * 2).
5 * 6 / 2 * 8
Multiplications and divisions have the same precedence, so we use associativity
instead. Multiplication and division have left-to-right associativity, meaning
that we group from the left to the right: (((5 * 6) / 2) * 8).
For the operations we have presented here, the increment/decrement operations come first in terms of precedence, along with the other unary operations.
Then multiplication, division and modulo, followed by addition and subtractions.
Comparison comes after, followed by the binary logical operations.
It is worth noting that the logical "and" && has a higher priority than the
logical "or" || operation.
This means that true || false && false evaluates to true, as it is
interpreted as true || (false && false).
The assignments come last. They use right-to-left associativity.
The best place to check precedences and associativity direction when in doubt is not this course, but a well trusted reference.
While some priorities are well known, some are less known, and to avoid any
mistakes it is usually preferable to use parentheses (
) where there is any
risk of ambiguity.
While the precedence and associativity direction tell you how parts of an expression are grouped, it does not tell you in which order the parts of the expression will be evaluated.
And for a good reason: this order is not guaranteed at all. In other words, unless otherwise specified, the compiler is free to evaluate any part of an expression in whichever order it chooses to.
int i {5};
int undefined;
undefined = ++i + i;
We can't know what value undefined will have with certainty.
We have 3 operations:
-
++has the highest precedence. -
+has a lower precedence than++. ==has the lowest precedence.
So we know that the expression will be grouped like this:
undefined = ((++i) + i);
We know for sure that it will not be grouped like this:
-
(undefined = (++i)) + i; -
undefined = ++(i + i);
So it tells us what the operation is. But it does not tell us in which order the part of the operation are evaluated.
- If
(++i)is evaluated first, then it evaluates to6, and nowihas the value6.- Then, when the other side of the
+is evaluated, it isi, it evaluates to6. -
undefinedwill be12at the end of the evaluation.
- Then, when the other side of the
- If
iis evaluated first, then it evaluates to5.- Then, when the other side of the
+is evaluated, it is(++i). It evaluates to6, and nowihas the value6. -
undefinedwill be11at the end of the evaluation.
- Then, when the other side of the
- But it's even worse. It is not just
11or12. It is Undefined Behaviour (UB). Which means that anything can happen.- It could evaluate to any value.
- It could be ignored (no operation happens at all).
- It could break the program downstream from there.
▸ The assignment is not a problem here
The order in which the two operands of the assignment = are evaluated doesn't
matter here for two reasons:
-
undefineddoesn't change based on the order of evaluation, so whether it is evaluated first or last doesn't matter. - Since C++17, the assignment is guaranteed to evaluate the right-hand side before evaluating the left-hand side. So this very specific order of evaluation is guaranteed.
This only happens because the expression uses a value it mutates in multiple places.
- If a value appears multiple times in an expression, it should not be mutated.
- If a value is mutated in an expression, it should not appear more than once.
Different operations for different types
Since different types should be interpreted differently depending on their semantics (what they represent), they must also be processed according to their nature.
For instance, signed integral numbers split their range so that the upper half of their values is interpreted as negative numbers.
For integral numbers over a single Byte, what would be interpreted as 128 for
an unsigned number is interpreted as -128 for a signed one, and what would be
129 for an unsigned type is interpreted as -127 for the signed one.
So if we are comparing 127 (which is 127 for both signed and unsigned) with
129/-127, we want the comparison to tell us that 127 is smaller in the
unsigned case (127 < 129), but larger in the signed case (127 > -127), despite
the underlying data being exactly the same.
Thankfully, the typing system already tracks the type, and with it the semantics of our values. So we don't have to worry about it: we say we want to perform an operation on the values, and the type system will do the leg work to figure out which operation is appropriate.
As a simplified model, we could think about it this way:
- If we multiply two unsigned integral types, the compiler will use the unsigned
integral multiplication (e.g. x86
MUL). - If we multiply two signed integral types, it will use the signed integral
multiplication (e.g. x86
IMUL). - If we multiply two single-precision floating points (
float), it will use the single precision floating point multiplication (e.g. x86MULSS). - If we multiply two double-precision floating points (
double), it will use the double precision floating point multiplication (e.g. x86MULSD).
But this can only work when the processor has in its silicon the specific operation available. A processor provides a finite set of primitive operations, and the compiler must express the language’s operations in terms of those primitives.
Heterogeneous operations
Usually, processors don't come with operations over mixed types. This is where things can become more complicated and sometimes surprising.
Floating Point
For instance, floating-point numbers are typically handled in a different area of the processor than other logical operations (different Execution Units). This is in large part because of their more complex interpretation.
Floating point operations even have to be performed over their very own specialized registers (a specific set of stone vessels on the apprentice desk).
So what happens if we try to add or divide a floating-point number with an integral one, or the other way around?
Since there are no mixed-type operation between floating point and integral, and the available operations even use completely different registers, the processor has to settle on a resolution.
In this case, there is a simple rule: when an operation mixes integral and floating point types: The integral type operand is converted into the floating point type.
We mentioned above that the division instructions the processor offers for integral types results in integral types, which means it truncates the result to the unit.
But now we know that if either of the operand is a floating-point, the compiler will convert the other operand to the same floating-point and perform the floating-point division instead.
▸ How this rule can bite
As long as we use relatively small numbers, we are safe, because floating point
numbers are as or more precise than integral up to 16777216 for 32-bit
floating points. But past this point, not all integral numbers exist in the
floating-point representation. 16777217 cannot be represented as a 32-bit
floating point (assuming IEEE-754). The next value after 16777216.0f is
16777218.0f, and it continues this way 2 by 2 until the next exponent, from
which point it becomes 4 by 4, then 8 by 8, doubling with each subsequent
exponent (see Bytes as floating point.
On the other hand, the integral values continue to grow 1 by 1 across their full
range.
This gives a whole new meaning to "less is more". Let's unpack what happens there:
- Since
500000000.0Fis afloat,18446744073709550000ULLis converted tofloat. - But
18446744073709550000is not representable infloat. So far away from0, the single precision floating point values are1099511627776apart! - The representable values closest to
18446744073709550000are-
18446742974197923840.0F(1099511626160 below), -
18446744073709551616.0F(1616 above).
-
- Of course,
18446744073709551616.0Fis the closest candidate. - Our operation is now using two
floatoperands and has become:
18446744073709551616.0F - 500000000.0F - The mathematical result of this subtraction should be
18446744073209551616, but that number is not representable as afloateither. The representable values around it are the same as for our initial number:-
18446742974197923840.0F(1099011627776 below), -
18446744073709551616.0F(500000000 above).
-
- And the closest is
18446744073709551616.0F, so that's our result.
Try removing the .0F part from 500000000.0F, so that the operation is
between integral numbers, without a conversion to float.
Mixing Signed and Unsigned Integral
Contrary to floating-point, signed and unsigned integral types share the same registers, but the problem remains: the processor offers instructions for signed on signed operations, or for unsigned on unsigned operations, but not for a mix of signed and unsigned. So the compiler will have to make a call between going with the signed or the unsigned instruction.
Unfortunately, the rules determining which is picked are more complicated in this case, and not as well-known as the rule for mixing floating-point and integral types.
Avoid executing operations on values with different semantics, unless the readers of your code are expected to know the rules governing their interaction.
The easiest solution is simply to avoid such situations, typically by explicitly converting the operands into equivalent types so that the outcome is unsurprising. We will see the conversion operations further down in this page.
▸ Learning one more conversion rule for signed/unsigned integral
One rule that is worth knowing is that, in such mix, if the signed type is large enough to represent all the values of the unsigned type (in addition to the negative values it can represent as well), then the unsigned type is converted in the signed type.
This is safe in all architectures where a long long type (50LL) is 64-bit
and an unsigned int (75U) is 32-bit: the unsigned int will be converted
into a (signed) long long which can represent all its values (and much more),
and the result will be a signed long long.
▸ The whole signed/unsigned integral story
Again, you don't need to learn these rules. Many C++ programmers don't know them, or won't have them in mind while reading your code, so relying on them will be confusing for no good reasons. It is best and free to avoid these situations.
The main reason for looking into them is to be able to understand what happened in situations where things would have gone wrong.
There are 3 rules for the signed/unsigned of integral types. One is explained in the box above. Before we introduce the two remaining rules for signed/unsigned integral mix, we need to explain the difference between a type "rank" and its size.
Back when we introduced the
long and long long
types, we said that the size of a
long is either the same or larger than the size of an int. This size is an
undisputable fact, but depends on the specific architecture. On the other hand,
without knowing the specific architecture, we know that long has the
potential to be wider than int, and never narrower. This potential is the
"rank". Similarly, since long long is as wide or wider than long, long long has a higher rank than long.
Here are the two missing rules for signed/unsigned integral types:
- If the types have the same rank, or if the unsigned type outranks the signed type, we use the unsigned type.
What happens here is an underflow. We try to represent a negative value with an unsigned type. We can't and what happens is that the values wrap around: one below 0 with unsigned integers wraps all the way to the maximum value it can represent. In this case, 36 below 0 is 35 below the maximum value.
- The last case is when the signed type outranks the unsigned type, but is not
wide enough to represent all the unsigned values. This can happen because the
rank is independent from the architecture, while the size depends on it. So a
signed
long longtype outranks anunsigned longeven if they are the same size. In this case, both operands are converted into a new type: the unsigned version of the signed type. In thelong longandunsigned longcase:unsigned long long.
You might notice that in these two new cases, the unsigned type is preferred over the signed type. This might seem strange at first, but we have to consider a few things:
- If the signed type was preferred instead, a valid unsigned value could become unexpectedly negative.
- Overflow (going past the largest value) and underflow (going below the smallest value) of unsigned types is well defined in C++, while it is "Undefined Behaviour" (UB) for signed types.
Sub-integer types
Sub-integer types designate the numeric types that are smaller than an int,
such as bool, char, short.
In part for historical reasons, C++ converts these types into int or unsigned int before performing an arithmetic operation on them.
▸ Why?
- Some old architectures could simply not perform some of these operations.
- Others could but would actually use the
intinstruction, which meant loading a sub-integer into part of theintregister, and then zero-ing the part of the register the sub-integer type didn't occupy, then performing the operation. - So historically, it made sense to avoid the problem entirely by promoting the values to the word size before making calculations with them.
- Nowadays, modern CPUs can usually perform these operations, and wouldn't suffer from these issues as much, but the parallelisation happening inside the processor has introduced new headaches with these sub-integer types, making their native support complex and somewhat inconsistent.
- In addition, the compilers are clever enough to take liberties with how they translate the operations into machine code, and can skip the conversion in some places, bundle some values together in others, minding their business as they see fit under the "as if" rule of C++ (they are allowed to do what they want as long as the program behaves "as if" they followed the rules).
- Long story short, changing this rule now would be extraordinarily difficult because of all the C++ code that relies on this historical behaviour, and in addition, the benefit for modern architectures might not be worth it, especially considering that compilers have plenty of freedom to adapt the code to the architecture without revising the rule.
Both operands are converted to int before the addition is performed, and so
the result is also an int. This is another instance of the compiler resolving
operations by converting operands to a common type.
Explicit conversion
The best way to stay on top of type conversions and to make your code easier to read by others is to be explicit about your intent.
static_cast allows to change the type of a value into a different one, if the
conversion is possible. The syntax is a bit different from what we have seen so
far, with the target type between <> and the value between ():
For instance, static_cast<int>(512.7f) will transform the value 512.7 (a
float) into an int (discarding the decimal part).
Whenever the type conversion happening in an operation are unclear, it will be helpful to explicitly convert them to a common type resolving the ambiguity. Some modern languages such as Rust enforce this as a rule, for instance.
Alternatively, we can also use the conversion rules to our advantage:
Using the floating-point conversion rule, by explicitly converting one operand, we get a floating point result out of the division between two integral values.
- Expressions are sequence of operators and operands that specifies a computation.
- Statements are instructions for the program.
- There are many different operations available in C++.
- Only certain operations are available for certain types.
- Some operations modify their operands.
- The compiler will change (convert, promote) the type of the operands in some
cases:
- When operations are performed on numeric types smaller than an int.
- When operations are performed over operands of different types.
- For arithmetic operations, when one operand is a floating-point type, the result will be a floating-point type as well.
- To choose the type an expression evaluates to and to avoid confusing or
unclear situation with implicit type conversions,
static_castallows to explicitly convert a value into a chosen type.