Numeral systems: decimal, binary, hexadecimal, and octal

For Context
This lesson is optional. Future lessons reference hexadecimal numbers, so you should at least understand the basic concept before continuing.

In everyday life, we use decimal numbers, where each digit can be 0 through 9. Decimal is also called "base 10" since there are 10 possible digits. We count: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ... By default, C++ assumes all numbers are decimal.

int score { 75 }; // 75 is assumed to be a decimal number

In binary, only 2 digits exist: 0 and 1, making it "base 2". In binary, we count: 0, 1, 10, 11, 100, 101, 110, 111, ... Longer binary numbers are often grouped by fours for readability (e.g., 1010 0011).

Decimal and binary are examples of numeral systems - collections of symbols (digits) used to represent numbers. C++ supports four main numeral systems in order of popularity: decimal (base 10), binary (base 2), hexadecimal (base 16), and octal (base 8).

Nomenclature note: In both decimal and binary, 0 and 1 mean the same thing. We call these "zero" and "one."

But what about 10? The number 10 appears after the last single-digit number. In decimal, 10 equals nine plus one, and we call it "ten."

In binary, 10 represents one plus one (equivalent to decimal two). Calling binary 10 "ten" would be confusing since "ten" means nine plus one, not one plus one.

Therefore, names like "ten," "eleven," "twelve" are reserved for decimal. In non-decimal systems, we say "one-zero," "one-one," "one-two," etc. Binary 101 isn't "one hundred one" - it's "one-zero-one."

Octal and hexadecimal literals

Octal is base 8, using only digits: 0, 1, 2, 3, 4, 5, 6, and 7. In octal, we count: 0, 1, 2, 3, 4, 5, 6, 7, 10, 11, 12, ... (notice: no 8 or 9, jumping from 7 to 10).

Decimal 0 1 2 3 4 5 6 7 8 9 10 11
Octal 0 1 2 3 4 5 6 7 10 11 12 13

To create an octal literal, prefix your number with 0 (zero):

#include <iostream>

int main()
{
    int x { 015 }; // 0 prefix means this is octal
    std::cout << x << '\n';
    return 0;
}

This program prints:

13

Why 13 instead of 15? Because numbers are displayed in decimal by default, and 15 octal equals 13 decimal.

Warning
Octal is rarely used in modern C++. Avoid using octal literals, as the `0` prefix can be confusing and lead to unexpected bugs.

Hexadecimal is base 16. In hexadecimal, we count: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, 10, 11, 12, ...

Decimal 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
Hexadecimal 0 1 2 3 4 5 6 7 8 9 A B C D E F 10 11

Lowercase letters (a-f) also work, though uppercase is more common.

To create a hexadecimal literal, prefix your number with 0x:

#include <iostream>

int main()
{
    int x { 0x2A }; // 0x prefix means this is hexadecimal
    std::cout << x << '\n';
    return 0;
}

This program prints:

42

You can use 0X prefix, but 0x is conventional and more readable.

Numeral systems comparison table

Here are the four numeral systems side-by-side:

Decimal         0     1     2     3     4     5     6     7     8     9    10    11    12    13    14    15
Binary          0     1    10    11   100   101   110   111  1000  1001  1010  1011  1100  1101  1110  1111
Octal           0     1     2     3     4     5     6     7    10    11    12    13    14    15    16    17
Hexadecimal     0     1     2     3     4     5     6     7     8     9     A     B     C     D     E     F

Decimal        16    17    18    19    20    21    22    23    24    25    26    27    28    29    30    31
Binary      10000 10001 10010 10011 10100 10101 10110 10111 11000 11001 11010 11011 11100 11101 11110 11111
Octal          20    21    22    23    24    25    26    27    30    31    32    33    34    35    36    37
Hexadecimal    10    11    12    13    14    15    16    17    18    19    1A    1B    1C    1D    1E    1F

Each row follows the same pattern: The rightmost digit increments from 0 to (base-1). When reaching (base), it resets to 0 and the left digit increments by 1. If that left digit reaches (base), it resets to 0 and the digit to its left increments. This pattern continues indefinitely.

Using hexadecimal to represent binary

Since hexadecimal has 16 possible values per digit, one hexadecimal digit represents exactly 4 bits. Therefore, two hexadecimal digits represent one full byte precisely.

Hexadecimal 0 1 2 3 4 5 6 7 8 9 A B C D E F
Binary 0000 0001 0010 0011 0100 0101 0110 0111 1000 1001 1010 1011 1100 1101 1110 1111

Consider a 32-bit integer with binary value 1011 0101 1110 0011 0100 1101 0001 1010. Because of length and repetition, that's hard to read. In hexadecimal, this same value is: B5E3 4D1A, which is much more concise. Hexadecimal is often used to represent memory addresses or raw memory data.

Binary literals

Before C++14, binary literals weren't supported. Hexadecimal provided a workaround (still seen in older code):

#include <iostream>

int main()
{
    int flags {};     // assume 16-bit ints
    flags = 0x0001;   // assign binary 0000 0000 0000 0001
    flags = 0x0004;   // assign binary 0000 0000 0000 0100
    flags = 0x0010;   // assign binary 0000 0000 0001 0000
    flags = 0x0080;   // assign binary 0000 0000 1000 0000
    flags = 0x00FF;   // assign binary 0000 0000 1111 1111
    flags = 0x0C7A;   // assign binary 0000 1100 0111 1010
    flags = 0xAB12;   // assign binary 1010 1011 0001 0010

    return 0;
}

In C++14 and later, use binary literals with the 0b prefix:

#include <iostream>

int main()
{
    int flags {};         // assume 16-bit ints
    flags = 0b1;          // assign binary 0000 0000 0000 0001
    flags = 0b11;         // assign binary 0000 0000 0000 0011
    flags = 0b1100;       // assign binary 0000 0000 0000 1100
    flags = 0b10101010;   // assign binary 0000 0000 1010 1010

    return 0;
}

Digit separators

Long literals can be hard to read. C++14 adds the ability to use single quotation marks (') as digit separators:

#include <iostream>

int main()
{
    int flags { 0b1010'1100 };     // assign binary 1010 1100
    long population { 7'800'000'000 }; // much easier than 7800000000

    return 0;
}

Note: The separator cannot appear before the first digit:

int flags { 0b'1010'1100 };  // error: ' used before first digit

Digit separators are purely visual and don't affect the literal's value.

Best Practice
Use digit separators to make long literals more readable. For binary, group by 4 bits (nibbles). For decimal, group by 3 digits (thousands).

Outputting values in decimal, octal, or hexadecimal

By default, C++ outputs values in decimal. However, you can change output format using std::dec, std::oct, and std::hex I/O manipulators:

#include <iostream>

int main()
{
    int value { 42 };
    std::cout << value << '\n';              // decimal (by default)
    std::cout << std::hex << value << '\n';  // hexadecimal
    std::cout << value << '\n';              // still hexadecimal
    std::cout << std::oct << value << '\n';  // octal
    std::cout << std::dec << value << '\n';  // back to decimal
    std::cout << value << '\n';              // still decimal

    return 0;
}

This prints:

42
2a
2a
52
42
42

Note: Once applied, the I/O manipulator remains active until changed again.

Outputting values in binary

Outputting binary is trickier since std::cout doesn't include built-in binary support. Fortunately, the C++ standard library provides std::bitset (in the <bitset> header).

To use std::bitset, define a std::bitset variable and specify how many bits to store. The bit count must be a compile-time constant. std::bitset can be initialized with any integral value in any format (decimal, octal, hex, or binary).

#include <bitset>   // for std::bitset
#include <iostream>

int main()
{
    // std::bitset<8> means we want to store 8 bits
    std::bitset<8> pattern1 { 0b1100'1001 }; // binary literal for binary 1100 1001
    std::bitset<8> pattern2 { 0xC9 };        // hexadecimal literal for binary 1100 1001

    std::cout << pattern1 << '\n' << pattern2 << '\n';
    std::cout << std::bitset<4>{ 0b1101 } << '\n'; // create temporary std::bitset and print it

    return 0;
}

This prints:

11001001
11001001
1101

The last line creates a temporary (unnamed) std::bitset object with 4 bits, initializes it with binary literal 0b1101, prints it in binary, then discards the temporary object.

Outputting values in binary using Format/Print Library (Advanced)

In C++20 and C++23, better options exist for printing binary via the Format Library (C++20) and Print Library (C++23):

#include <format>   // C++20
#include <iostream>
#include <print>    // C++23

int main()
{
    std::cout << std::format("{:b}\n", 0b1101);   // C++20, {:b} formats as binary digits
    std::cout << std::format("{:#b}\n", 0b1101);  // C++20, {:#b} formats as 0b-prefixed binary

    std::println("{:b} {:#b}", 0b1101, 0b1101);   // C++23, format/print two arguments with newline

    return 0;
}

This prints:

1101
0b1101
1101 0b1101

Summary

  • Numeral systems are collections of symbols used to represent numbers
  • Decimal (base 10): The default system in C++, uses digits 0-9
  • Binary (base 2): Uses only 0 and 1, created with 0b prefix (e.g., 0b1010)
  • Hexadecimal (base 16): Uses digits 0-9 and letters A-F, created with 0x prefix (e.g., 0x2A)
  • Octal (base 8): Uses digits 0-7, created with 0 prefix - avoid using this
  • Hexadecimal and binary: One hexadecimal digit represents exactly 4 binary bits (a nibble)
  • Digit separators: Use single quotes (') to make long literals readable (e.g., 0b1010'1100 or 7'800'000)
  • Output formatting: Use std::hex, std::oct, and std::dec to change output format
  • Binary output: Use std::bitset for binary output, or std::format with {:b} in C++20+

Understanding different numeral systems, especially hexadecimal and binary, is essential for low-level programming, bit manipulation, and working with memory addresses.