03 - Types
In this previous chapter, we have seen that the same value can be interpreted in many different ways. The processor has no idea about this and will operate on the values as it is told to, without consideration for what these values represent (we will look into this in more details in the next chapter).
Yet, getting this wrong would lead to potentially catastrophic failures, so since the processor doesn't take care of it, someone or something else has to. Different languages have different solutions for this. In C++, the compiler helps the programmer keeping track of what values represent through its type system.
The C++ type system
The main job of this system is to prevent mistakes with how values should be interpreted while avoiding excessive friction. The programmer remains in control.
A Type is a compile-time attribute that keeps track of the size and semantics of a category of values.
When specifying a value, the programmer is encouraged to also specify its type. The compiler keeps track of this type throughout the lifespan of the value and uses it to enforce some boundaries or warn the programmer about misuse or likely transgressions.
C++ is statically typed, which means that these verifications happen once when the application is built from the code (compile-time), and not during the application's execution (runtime).
But this information is not only for the compiler's benefit. It is extremely important for the programmer who writes or reads the code.
When a program mentions a pitch, it could be a short text introducing an idea,
the specific frequency of a musical note, the distance between threads on a
screw, a dark sticky polymer, or a football field. All these things can be
designated by the same name: "pitch", but have a different meaning, different
semantics, and thus should be interpreted differently.
The Type specifies the semantics (the meaning) and avoids ambiguities.
Keeping track of the "semantics"
- The type
charmeans a Byte that should be interpreted as an ASCII code for a character. - The type
boolusually means a single Byte, that should be interpreted as a Boolean value. - The type
std::int8_tmeans a Byte that should be interpreted as a signed integer. - The type
std::uint8_tmeans a Byte that should be interpreted as an unsigned integer. - The type
std::bytespecifically means a Byte without specifying a designated way of interpreting it and is used to mean a Byte as "raw data".
On a typical architecture, these 5 types are strictly the same thing from the processor's point of view. But we should read their bits in very different ways.
In this case, the distinction between these types doesn't inform the processor on how these values should be processed; it informs the programmer of how they should be interpreted, and it informs the compiler of which of the processor operations are valid on these values.
Keeping track of the size
The type also encodes in the compiler the number of Bytes (the size) of the value.
-
std::uint8_tis a single Byte unsigned integral value. -
std::uint16_tis a two Bytes unsigned integral value. -
std::uint32_tis a four Bytes unsigned integral value. -
std::uint64_tis an eight Bytes unsigned integral value.
In this case, in addition to carrying the information of how these values should be read (as unsigned integral values), the type also encodes the minimal size required to manipulate these values.
▸ pitfall: types across different platforms
There is an important distinction to be made here: the type tells the compiler the size of the value, but different compilers/architectures can give different sizes to the same type.
A given compiler has to be consistent, but different compilers don't have to agree.
For instance, the type std::size_t has a size of 4 Bytes on x86 architectures,
while it has a size of 8 Bytes on x64 architectures.
This remains consistent on the given architecture, but changes across architectures. In other words, while the size of a type is known with certainty by the compiler, it can sometimes be confusing for the programmer, and change from one computer to another.
The semantics is our compass:
The mission of a std::size_t is to store the size (in Bytes) of pieces of
data. It is guaranteed to be large enough to encode the maximum size of any
piece of data on the given architecture.
The very point of the x64 architecture is to support larger data-types than
x86, so it is only natural for std::size_t to differ between these two
architectures.
There is no value without a type
In C++, every value has a type, and this type cannot change throughout the entire program.
Literals
This type can be deduced from how we spell out the value. So we will have to be a little careful.
- If we write
163, the value is of typeint
(through the unsigned integer lense, it would read as163). - If we write
163u, the value is of typeunsigned int
(through the unsigned integer lense, it would read as163) - If we write
163.0, the value is of typedouble
(through the unsigned integer lense, it would read as4639939069214720000). - If we write
163.0f, the value is of typefloat
(through the unsigned integer lense, it would read as1126367232). - If we write
'£', the value is of typechar
(through the unsigned integer lense, it would read as163with Windows-1252 or ISO-8859-1 encoding, or153with CP437 encoding. UTF-8 should use the literal u8'£' or may fail). -
trueandfalseare special values of typebool
(with underlying values1and0respectively, although any non-zero value is interpreted astrue).
This might be intimidating at first, but it is actually somewhat convenient: note that we wrote what we meant, mostly. It allows us to pretty much ignore the underlying representation and its potential complexity.
Since a char should be interpreted through the ASCII lens, '£' is much
clearer than 163.
By writing 163 as 163.0, we made it clear that we minded the decimal part, and
that the correct representation would be a floating-point. And that saved us the
headache of figuring out the underlying representation which is very complex.
For the most part, we don't have to worry too much about the underlying value: the C++ compiler takes care of this for us, as long as we don't stray from its protecting embrace.
▸ Why going over these underlying values, then?
The reason we didn't skip directly to this way of writing values with a type and the interpretation handled for us by the compiler is two-fold:
- This is very much the philosophy of this course: we build everything from the very ground up. We appreciate the work the compiler does for us only when we are aware of it. We build an understanding first and only then use the tools that hide or ease this complexity.
- These underlying values are important in several contexts. Knowing about them will give us more options and more tools to understand and explain otherwise puzzling situations. Beyond having some applications when debugging, it points us in the right direction to understand semantics.
Note that in the most common architectures, 163, 163u, and 163.0f will have
a size of 4 Bytes, while 163.0 will take 8 Bytes, and '£' only 1 Byte. It
will be important to be aware of these differences when we try to make our
programs efficient.
Variables
A variable is a placeholder value. Instead of giving the value directly (42),
we use a label, representing that value. And since there is no value without a
type, we have to attach a type to this label as well.
The syntax to do so follows this pattern:
Type label;
The technical term for the label is "identifier", but we often call it the variable's "name".
char firstLetter;
bool isUppercase;
std::uint8_t alpha;
In C++, there are few constraints for how identifiers are formatted:
- They must start with a letter (or an underscore),
- and be composed of letters, numbers, and underscores.
▸ But the usage of underscores is strictly limited
Some patterns are reserved for the compiler and the Standard Library usage:
- Identifiers starting by an underscore followed by an uppercase letter are reserved.
- Identifiers containing a double underscore (
__) are reserved. - Identifiers starting by an underscore followed by a lowercase letter are reserved in the global scope only.
To keep things simple, we suggest avoiding starting any identifier with an underscore, at least for now.
Different people, different companies, different projects will use different naming conventions. Usually a mix of:
-
snake_case(where an identifier doesn't use uppercase letters and separates words with underscores), -
camelCase(where an identifier starts with a lowercase letter and uses a single uppercase letter to separate words), -
PascalCase(where an identifier starts with a single uppercase letter and uses a single uppercase letter to separate words), -
UPPER_CASE(where an identifier doesn't use lowercase letters and separates words with underscores).
In this series, I will try to stick to this convention, but keep in mind it is arbitrary and you are free to use your own:
-
PascalCasefor Types, -
camelCasefor variable names.
Since the convention used by the Standard Library is different, types coming
from it will stand out as snake_case.
Note that we have given these 3 variables a type and an identifier, but no value. This is absolutely fine to do so, but it is important to know that when we do that, in some cases, the variables will have any value.
▸ Some cases?
Understanding in which cases is beyond the scope of this lesson. We'll detail this later when we have the building blocks to express these conditions.
If you really want to jump ahead, you can lookup the part of this page where it says "no initialization is performed".
This is not a bug of a language but a feature. A core-design principle of C++ is: "Pay for what you use".
Maybe we will set the values of these variables later, from the user input, and thus, it doesn't matter which value they had initially. In this case, making the effort of giving them a specific value to replace it later would have been paying for something we don't use: wasteful.
If you want the variables to have a specific value (e.g. 0), it is safer to do
so explicitly.
We can also give an initial value to our variable when we define it. We call this operation initialization. The syntax becomes:
Type label {value};
There are other ways to initialize variables, so don't be too surprised if you see something different in C++ code you read elsewhere.
char firstLetter {'@'};
bool isUppercase {true};
std::uint8_t alpha {42u};
Varying variables
Variables are labels for values, but sometimes, values can change over time. Suppose we read a file, and consider the line of the file we are currently reading. It is a value that will increase as we progress through the file.
std::size_t currentLine {0};
// Further in the program we could change that value.
// For instance when we reach the 10th line:
currentLine = 10uz;
We define the variable currentLine, with the type std::size_t, and
initialize it to 0. Later, we change its value to 10.
uz is the literal suffix associated with the type std::size_t since C++23.
If you use an older standard of C++ it may fail to parse. You can use ul
instead.
Note that we don't reiterate the type of currentLine when we change its value.
Since the type never changes, it would be redundant.
Assigning a different value with = is only one way of varying the value a
variable stands for. We will see other ways later.
Constant variables
Conversely, some values never change. Suppose I define the variable pi, for
instance:
float pi {3.14159265f};
It would be odd to later change its value. It would likely be a mistake.
The type system gives us a tool to guard against such mistakes: we can declare
that pi is meant to never change, to be constant, using the keyword const.
const float pi {3.14159265f};
Remember how the syntax is Type identifier {value};? Now, the type of pi is
const float, which means it is a floating-point value that is meant to never
change, or a "read-only floating-point".
▸ constexpr
constexpr is a different keyword in C++. Contrary to const, constexpr is
not part of the type of the variable, but it implicitly makes the variable's
type const.
const indicates that the variable should never change after its
initialization. constexpr says something more: not only should it never
change after initialization, but also, we know the value it will be initialized
with ahead of time (at compile time, specifically).
Suppose that in a program, we ask for the name of the user. It cannot be known
ahead of time, so we can store this data in a const variable but not in a
constexpr variable.
But pi is known ahead of time, and could (should) be stored in a constexpr
variable rather than "only" a const one.
constexpr float pi {3.14159265f} is equivalent to constexpr const float pi {3.14159265f} (so we usually write the first because it's more concise). In
both cases, the type of pi is const float.
▸ West const or East const?
The keyword const can be placed on either side of the Type:
- West const:
const int i {5}; - East const:
int const i {5};
Which is best and should be preferred has been the topic of passionate debates for decades. With people being strongly opinionated about the question, it will not be possible to give satisfaction to everyone.
This makes strictly no difference for the compiler and is a pure matter of style, taste, and convention.
This series will essentially use a third convention, that we will call Outer const and is essentially the same as West const, but makes its intent clearer.
▸ Outer const (for readers already familiar with pointers)
Consider the following code:
int const * pI;
This is the East const convention for a non-const pointer to a constant integer.
Our argument is simple: there are readers who will have learned and remember
that in this case const applies to int and not to *. But there will also
be readers who ignore this, or have forgotten it.
For the second category of readers, it is ambiguous.
In contrast, using the West const convention:
const int * pI;
The same type is unambiguous for both categories of readers.
We call this conversion Outer const because our objective is not to put
const to the left (west) of the type it qualifies, but to put it to the
outside of it to avoid ambiguity.
For instance, if both int and the pointer are const, we will write:
const int * const pI;
This is exactly the same as West const, but specifically with the intent of
avoiding const between two type markers, where it would be ambiguous.
This convention aims at facilitating how the code is read rather than written.
So far, it is exactly the same as West const, and the next step is to ask what
happens with a pointer to pointer of int: int**.
This is where things might start to diverge a little, but first, note that with our Outer const convention, few situations are really ambiguous:
int * * pI1;
const int * * pI2;
int * * const pI3;
const int * * const pI4;
int * const * const pI5;
const int * const * const pI6;
are all unambiguous for readers unfamiliar with which side const should attach
to.
Out of 8 possible compositions of const in the type, only two would still carry
the ambiguity:
int * const * pI7;
const int * const * pI8;
The likelihood of encountering pI7 in the wild, or to need this specific type,
seems lower than pI8 case. But regardless, our Outer const convention offers
the same solution in both case, and for even more complex cases:
Use type aliasing to avoid ambiguity.
using IntPtr = int *;
const IntPtr * pI7; // No ambiguity
using ConstIntPtr = const int *;
const ConstIntPtr * pI8;
Although we hope that our readers will remain engaged in learning C++ for a long time and eventually learn its subtle rules, we would prefer them to focus on more fundamental aspects for the time being.
The main benefit of this convention, and the reason we chose it is that it doesn't require the reader to be aware of the convention to make what part of the type is const unambiguous.
Let it be also mentioned that the C++ standard, the C++ Core Guidelines, and the website cppreference all use the West const convention.
We don't wish to present it as better, but simply better suited to our educational endeavour.
In practice
First an example where we do things properly. If you press "Run", it should say:
This compiled and ran without error..
But if we attempt to do nonsensical things, like initializing a character from a number or an integral number from a floating point, or a 4-Byte floating point from a value it cannot hold, or a boolean from a number, the compiler stops us.
It stops us if we attempt to modify a variable we have indicated as being constant.
- In C++, every value has a type.
- This type is defined at compile-time (it doesn't change during the program execution).
- The type tells the programmer and the compiler how the value should be
interpreted (its semantics), its size in Bytes, and whether it can be
modified after its initialization.
- This is to help structuring the code and avoiding mistakes.
- Literals are values written directly in code.
- Their format indicates their type.
- Variables are labeled values.
- They have a Type and an identifier.
- The type can be
const, indicating that this variable should not change value after its initialization. - Variables can be initialised.
- If they are not initialised, their value can in some cases be anything as the value previously in memory is reused without being erased.