Programming languages can be categorized as either "high-level" or "low-level". High-level languages (like Python) focus on making it easy for humans to write code, hiding many technical details about how computers work.
Low-level languages give programmers more direct control over the computer's hardware and memory, which can make programs faster and more efficient.
C++ is considered a relatively low-level language, which means we sometimes need to understand what's happening "under the hood" when we write our code. This is particularly true when it comes to how our programs use computer memory.
This lesson is a little more advanced and theoretical. It discusses what’s going on behind the scenes when we create variables, and explains why there are alternatives to types like int
and float
.
It can be useful to know that alternatives exist, and why they exist, particularly if you’re using this course in conjunction with other learning material.
However, it’s not entirely necessary at this stage. We’ll stick with int
and float
for the rest of this beginner course, so feel free to move on from this lesson if it’s not interesting or helpful, or doesn’t entirely make sense.
When we create variables in C++, we’re requesting the operating system give us a block of memory we can store our data in. The variable name lets us return to that memory location later, and retrieve or modify the value stored there.
Memory is made up of a series of bits (short for "binary digit"). A bit is the smallest unit of data in computing. It can have one of only two possible values, which we usually represent by 0
and 1
.
By combining multiple bits, we can represent more and more complex data. Two bits can have four possible configurations (00
, 10
, 01
, or 11
). Three bits can have 8, four can have 16 and, in general, each additional bit doubles the number of distinct values that can be represented.
A byte is a combination of 8 bits, for example, 01001110
or 10101001
. There are 256 different possible configurations for 8 bits. Therefore, a byte can store one of 256 possible values.
Those values could represent anything we want - for example:
When we create a variable in C++, the type of that variable determines how many bits of memory are allocated to that variable, and how those bits are used.
So far, we've just been using the simple int
data type for storing our integers, but there are other options.
One reason we have different types to store integers is to control how much memory we want to allocate for our number.
For example, if we wanted to store a variable representing the level of a monster, and our maximum Level
is 50, we don't need to allocate a huge amount of memory for that variable. 8 bits of memory would be enough - that would allow for 256 possible values in that space.
Approximately half of that range is assigned to negative numbers, so 8-bit integers can typically range from -128 to 127
The amount of space a type uses in memory is sometimes called its width. For example, a type that uses 8 bits of memory has a width of 8 bits. To create an integer that uses only 8 bits of memory, we can use the int8_t
type:
#include <iostream>
using namespace std;
int main() {
int8_t Level{50};
}
If we use a variable that has more bits, we can store a wider range of values, at the expense of our program using some additional resources. 16-bit integers can store 65,536 different possibilities; 32 bits could store over 4 billion.
Just as we can use int8_t
to store small integers, similar types exist for 16, 32, and 64 bit integers:
#include <iostream>
using namespace std;
int main() {
int8_t SmallNumber{100};
int16_t MediumNumber{10'000};
int32_t LargeNumber{1'000'000'000};
int64_t HugeNumber{1'000'000'000'000'000'000};
}
Types like int32_t
are typically referred to as "fixed-width integers", as their width is guaranteed to be the same across all platforms. The basic int
type is not fixed width. The C++ specification states it should be at least 16 bits but it can be larger and, on most modern platforms, it is.
C++ includes other named integer types with non-fixed width, such as short
(typically smaller than an int
) and long
(typically larger than an int
):
#include <iostream>
using namespace std;
int main() {
short SmallNumber{100};
int MediumNumber{10'000};
long LargeNumber{1'000'000'000};
}
Naturally, this is all quite messy, so most developers and teams settle on a convention on which types should (and should not) be used. For integers, a common convention is:
int
type, unless we have a reason to intervene in the sizeint_8t
Conventions like these are normally documented in an organization’s style guide, which specifies how code should be written within that company. Some organizations, such as Google, also publish their style guide:
Of the built-in C++ integer types, the only one used is int
. If a program needs an integer type of a different size, use an exact-width integer type such as int16_t
Now that we know that every numeric type has a maximum and minimum value it can store, what happens when we go beyond those limits? When we try to store a number outside these limits, we get what's called an overflow.
Think of it like an odometer in a car - when it reaches its maximum value and you drive further, it wraps back to zero. Numbers in C++ behave similarly:
Here's an example using an int
which, assuming it uses 32 bits, can hold a maximum value of around 2.1 billion. We initialize it with a value of 2 billion, which is in the valid range. However, when we add another billion, it goes beyond what the type can store:
#include <iostream>
using namespace std;
int main() {
int Number{2'000'000'000};
Number += 1'000'000'000;
std::cout << Number;
}
The value stored in our variable wraps around to the other side of its range, resulting in a negative value in this case:
-1294967296
Naturally, this is often going to result in a bug, so we should be sure the width of our variable is sufficient to accommodate the range of values we expect that variable to store.
The main way we prevent overflows is simply to increase the width of the variable to ensure it can store a wider range of possibilities.
However, if the value we’re storing won’t be negative, we could instead shift our range to only include non-negative integers. This is the idea behind unsigned integers.
In almost all cases, this is not recommended - using a larger type is a better approach for reasons we’ll cover in the next section. However, we should understand that unsigned integers exist, so let’s cover them briefly.
We saw how 8 bits of memory can store 256 different possible values. But what range of integers should we map to those options?
Most integer types are signed, meaning they can store negative values. We’ve seen how 8 bits can store one of 256 different values. If we map that to a range that includes a similar quantity of negative and positive numbers, an 8-bit signed integer can store values in the range of -128 to 127:
#include <iostream>
using namespace std;
int main() {
// These are fine
int8_t NegativeNumber{-100};
int8_t PositiveNumber{100};
// Error: This is out of range
int8_t LargerNumber{200};
}
error C2397: conversion from 'int' to 'int8_t' requires a narrowing conversion
An unsigned integer cannot be negative, so it allows us to map those 256 possibilities to a range that only includes non-negative numbers. As such, an 8-bit unsigned integer can store values in the range 0 - 255.
The unsigned variation of int8_t
is available available by prepending u
, as in uint8_t
:
#include <iostream>
using namespace std;
int main() {
// Error: This is out of range
uint8_t NegativeNumber{-100};
// These are fine
uint8_t PositiveNumber{100};
uint8_t LargerNumber{200};
}
error C2397: conversion from 'int' to 'uint8_t' requires a narrowing conversion
Similarly, we have uint16_t
, uint32_t
, and uint64_t
. An unsigned form of the basic int
type is also available, called unsigned int
:
#include <iostream>
using namespace std;
int main() {
uint8_t SmallNumber{100};
uint16_t MediumNumber{10'000};
uint32_t LargeNumber{1'000'000'000};
uint64_t HugeNumber{1'000'000'000'000'000'000};
unsigned int BasicUnsignedInt{10,000};
}
Unless we’re creating software for an environment that is extremely memory-constrained, using unsigned integers is an unnecessary and risky optimization.
The first problem with unsigned integers is that their lower limit is 0
and, in many contexts, we are working with values that are close to 0
. This means that we’re much more likely to cause overflow-related issues when we’re using unsigned types:
#include <iostream>
using namespace std;
int main() {
unsigned int Health { 0 };
Health -= 1;
cout << Health;
}
4294967295
Worse, the interaction between signed and unsigned integers can be unpredictable. C++ compilers will convert signed integers to unsigned integers in situations we wouldn’t intuitively expect, which can create unintended behaviors.
In the following example, to compare our signed and unsigned integers, the compiler is converting -1
to an unsigned value. -1
is outside the valid range of an unsigned integer, so it wraps around to a huge positive number:
#include <iostream>
using namespace std;
int main() {
int Signed{-1};
unsigned int Unsigned{1};
if (Signed > Unsigned) { // true
cout << Signed << " is greater than "
<< Unsigned << " ???";
}
}
That huge positive number is indeed greater than 1
, so we get the unintuitive output:
-1 is greater than 1 ???
We cover these implicit conversions in more detail in the next chapter.
Whilst the previous example is valid C++ code and compilers will implement it in accordance with the C++ rules, they will likely also detect that our code is doing something unusual.
To help us with scenarios like this, compilers will typically output warnings. Warnings will not prevent our code from compiling - their purpose is to alert us that the compiler found something in our code that may require our attention.
IDEs will generally place compiler warnings somewhere on our screen after our build has completed. In Visual Studio, these are available in the Error List window which is included in the default UI, or can be opened from the View menu.
Because of these problems, when we need to support larger numbers than our type supports, it’s almost always better to simply use a type of larger size rather than switching to an unsigned type.
This could mean, for example, switching from an int16_t
to an int32_t
, or from an int
to an int64_t
.
Like with integers, we have different options for how much memory we want our floating point number to consume. Unlike integers, however, floating points are often implemented slightly differently in terms of how they use their available memory.
What changes when we give floating point numbers more memory is both their precision (significant digits) and range (magnitude of values they can represent).
The most common data types for floating point numbers are float
and double
.
#include <iostream>
using namespace std;
int main() {
float A{3.14};
double B{9.8};
}
As with basic integers, the C++ specification doesn’t specify how many bits should be allocated but, in practice, it’s usually 32 bits for a float
and 64 bits for a double
. This means a double
is more precise than a float
at the expense of consuming more memory.
This is also the source of the phrase "double precision", which you may have heard in the context of science or programming. The following program shows an example of this, where arithmetic using double
s gives more accurate outputs than the same expression using float
s.
Note that, by default, cout
will round floating point numbers to 6 significant figures when outputting them to the terminal. We can increase this to 16, for example, using cout.precision(16)
:
#include <iostream>
using namespace std;
int main() {
// Cause cout to show more decimal places
// when outputting floating point numbers
cout.precision(16);
float A { 1.1111111111111111 };
std::cout << "Float Precision: "
<< A + A << '\n';
double B { 1.1111111111111111 };
std::cout << "Double Precision: "
<< B + B;
}
Float Precision: 2.222222328186035
Double Precision: 2.222222222222222
The basic float
will be sufficient for our needs. What's important is just to recognize that there are different options, and you may see them being used in other code samples.
We've been using literals quite a lot already, but it's worth taking a moment to explain them in a bit more depth.
When we write an expression like 4.0
or "Goblin Warrior"
, we are using a literal. A literal is a way of expressing a fixed value in our code.
Like variables, literals also have a type. Rather than needing to explicitly state it, the compiler can infer the type of literal we’re using based on the exact syntax we use. For example,
6
is an int
literaltrue
and false
are bool
literals4.0
is a double
literal4.0f
(note the f
suffix) is a float
literal'a'
(straight single quotes) is a char
literal - a single character"Goblin Warrior"
(straight double quotes) is a char*
literal. A char*
is one of many ways to represent strings of characters. We’ll cover string representations in more detail later in the course"Goblin Warrior"s
(straight double quotes and s
suffix) is a standard string
literal, also known as a std::string
literal. We’ll cover the meaning of the std::
prefix later in the course.We’ve seen in previous code examples that conversions from these literals into other types can often be done automatically We’ve been creating float
objects from double
literals, and string
objects from char*
literals:
// Creating a float from a double literal
float MyNumber { 4.0 };
// Creating a string from a char* literal
string MyString { "Goblin Warrior" };
If preferred, we can add the f
and s
suffix to our floats and strings. The previous code could be written like this:
float MyNumber { 4.0f };
string MyString { "Goblin Warrior"s };
Feel free to include these suffixes if you feel more comfortable. To make our code samples as simple as possible for beginners, we will not include them in this course, except in the rare scenarios where doing so is impactful to the program’s behavior. In those situations, we will point it out and explain why.
In this lesson, we explored how C++ manages computer memory and different ways to store numbers. Here are the key points to remember:
int8_t
, int16_t
, etc.) use different amounts of memoryfloat
vs double
)In our upcoming lesson, we'll introduce an essential tool for every programmer: the debugger. Here's a sneak peek of what you'll learn:
Explore how C++ programs store and manage numbers in computer memory, including integer and floating-point types, memory allocation, and overflow handling.
Become a software engineer with C++. Starting from the basics, we guide you step by step along the way