Types and Literals

Explore how C++ programs store and manage numbers in computer memory, including integer and floating-point types, memory allocation, and overflow handling.

Ryan McCombe
Updated

Programming languages are often arranged on a spectrum from "high-level" or "low-level". Higher level languages (like Python) focus on making it easy for humans to write code, hiding many technical details about how computers work.

Lower 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.

Memory

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:

  • an integer from 0-255
  • one of 256 different alphabetic characters
  • a color from a palette of 256 options

When we create a variable in C++, the type of that variable determines how many bits of memory are allocated to it, 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.

Integer Width

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. 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 memory. 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};
}

Fixed-Width Integers

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};
}

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:

  • Use the basic int type, unless we have a specific reason to intervene in the size
  • If we do need to control the size, use an appropriate fixed-width type like int8_t

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

Overflows

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:

  • If we go above the maximum value, the number wraps around to the minimum value
  • If we go below the minimum value, the number wraps around to the maximum value

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;
  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

This is generally 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.

Signed and Unsigned Integers

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. We might switch it from an int to an int64_t for example.

However, if the variable we're creating is never intended to be negative, we have another option to increase it's maximum value. We could eliminate the negative numbers from its range of possibilities, leaving more room for larger positive values. 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.

Signed Integers

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. If we map our 256 possibilities to a range that includes the same quantity of negative and non-negative 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

Unsigned Integers

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 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};
}

Why Unsigned Integers are Problematic

Unless we're creating software for an environment that is extremely memory-constrained, using unsigned integers for memory optimization is an unnecessary and risky choice.

The first problem with unsigned integers is that their lower limit is 0 and, in most real-world scenarios, the values we work with are quite close to 0. This means that we're much more likely to accidentally introduce overflow-related bugs 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.

Because of these problems, when we need to support larger numbers than our type allows, 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.

Floating Point Width

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 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 doubles gives more accurate outputs than the same expression using floats.

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 a higher value, such as 16, 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 };
  cout << "Float Precision:  "
    << A + A << '\n';

  double B { 1.1111111111111111 };
  cout << "Double Precision: "
    << B + B;
}
Float Precision:  2.222222328186035
Double Precision: 2.222222222222222

The basic float will be sufficient for our needs in this course. What's important is just to recognize that there are different options, and you may see them being used in other code samples.

Literals and Conversions

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 creating based on the exact syntax we use. For example,

  • 6 is an int literal
  • true and false are bool literals
  • 4.0 is a double literal
  • 4.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 explain what the * represents later in this course, and we cover string representations more generally in the advanced 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:: syntax 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.

Summary

In this lesson, we explored how C++ manages computer memory and different ways to store numbers. Here are the key points to remember:

  • Computer memory is made up of bits, with 8 bits forming a byte
  • Different number types (like int8_t, int16_t, etc.) use different amounts of memory
  • Using more memory allows us to store bigger numbers or more precise decimal values
  • Unsigned integers can only store positive numbers and zero
  • It's usually better to use signed integers and increase their size if needed
  • When numbers exceed their type's range, they "overflow" and wrap around
  • Floating point numbers come in different precisions (float vs double)
  • Literals are fixed values in our code and have specific types
Next Lesson
Lesson 7 of 60

Introduction to Debugging

Creating a tiny program using numbers and booleans, then adding some breakpoints so we can step through our code in a debugger.

Questions & Answers

Answers are generated by AI models and may not have been reviewed. Be mindful when running any code on your device.

Memory Usage in Modern Computers
Why do we need to care about memory usage in modern computers when they have so much RAM available?
How Computers Store Text
In the Memory section, you mention bits can be used to represent characters. How can 0s and 1s represent letters?
Monitoring Program Memory Usage
How can I know how much memory my entire program is using? Is there a way to check this?
When to Use Small Integer Types
In the real world, when would I choose to use a smaller integer type like int8_t instead of just using regular int?
Uses for Unsigned Integers
If unsigned integers are problematic, why do they exist at all? Are there any real-world cases where they're useful?
Preventing Number Overflow
How can I check if my number will cause an overflow before performing a calculation?
Controlling Decimal Places
When I print floating point numbers, they sometimes show way more decimal places than I want. How can I control this?
Or Ask your Own Question
Get an immediate answer to your specific question using our AI assistant