Skip to main content
C++ · lesson 4 of 24
C++ beginner Lesson 4 of 24

Data Types in C++

C++ data types run for real: sizes, overflow and float precision, fixed-width ints, std::string and string_view, std::array, vector growth and enum class.

By SkillByExample · Updated

Fundamental Types

C++ doesn’t hide the hardware: type sizes vary by platform, unsigned arithmetic wraps, and floating-point is binary. One program shows all three on this machine (x86-64 Linux).

#include <climits>
#include <iomanip>
#include <iostream>
#include <limits>

int main() {
    std::cout << "bool " << sizeof(bool) << ", char " << sizeof(char)
              << ", short " << sizeof(short) << ", int " << sizeof(int)
              << ", long " << sizeof(long) << ", long long " << sizeof(long long) << "\n";
    std::cout << "float " << sizeof(float) << ", double " << sizeof(double)
              << ", long double " << sizeof(long double) << "\n";

    std::cout << "INT_MAX = " << INT_MAX << "\n";
    unsigned int u = 0;
    u = u - 1;                                   // unsigned wraps: well-defined
    std::cout << "0u - 1 = " << u << "\n";

    float f = 0.1f;
    double d = 0.1;
    std::cout << std::setprecision(20) << "float 0.1  = " << f << "\n"
              << "double 0.1 = " << d << "\n";
    std::cout << "0.1 + 0.2 == 0.3? " << std::boolalpha << (0.1 + 0.2 == 0.3) << "\n";
    std::cout << "float digits: " << std::numeric_limits<float>::digits10
              << ", double digits: " << std::numeric_limits<double>::digits10 << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o sizes sizes.cpp
$ ./sizes
bool 1, char 1, short 2, int 4, long 8, long long 8
float 4, double 8, long double 16
INT_MAX = 2147483647
0u - 1 = 4294967295
float 0.1  = 0.10000000149011611938
double 0.1 = 0.10000000000000000555
0.1 + 0.2 == 0.3? false
float digits: 6, double digits: 15

The standard only guarantees minimums — int at least 16 bits, long at least 32 — so long is 8 bytes here but 4 on 64-bit Windows. 0u - 1 isn’t an error: unsigned arithmetic is defined to wrap modulo 2³², giving 4294967295. (Signed overflow, by contrast, is undefined behavior.) And 0.1 has no exact binary representation: printed to 20 digits, the float is off in the 9th digit and the double in the 18th, which is why 0.1 + 0.2 == 0.3 is false. Compare floating-point values with a tolerance, never ==.

Integer Literals

Literals can be written in four bases, and C++14 digit separators (') are ignored by the compiler.

#include <iostream>

int main() {
    int decimal = 255;
    int hex     = 0xFF;
    int octal   = 0377;            // leading 0 means octal
    int binary  = 0b1111'1111;     // C++14 binary literal + digit separator
    long long billion = 1'000'000'000LL;

    std::cout << decimal << " " << hex << " " << octal << " " << binary << "\n";
    std::cout << billion << "\n";

    int zip = 010;                 // looks like ten...
    std::cout << "010 = " << zip << "\n";
    std::cout << std::hex << "255 in hex: " << 255 << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o literals literals.cpp
$ ./literals
255 255 255 255
1000000000
010 = 8
255 in hex: ff

All four spellings are 255. The trap is the leading zero: 010 is octal, so it is 8, not 10 — which bites anyone who pads numbers like zip codes or times with zeros. std::hex is a sticky stream manipulator; every integer printed after it on that stream comes out in hexadecimal until you switch back with std::dec.

Fixed-Width Integer Types

When the exact size matters, use <cstdint>: std::int8_t through std::uint64_t, plus std::size_t for sizes and indices.

#include <cstdint>
#include <iostream>
#include <limits>

int main() {
    std::int32_t  big   = std::numeric_limits<std::int32_t>::max();
    std::uint64_t huge  = std::numeric_limits<std::uint64_t>::max();
    std::uint8_t  byte  = 65;
    std::int8_t   small = -1;

    std::cout << "int32 max:  " << big << "\n";
    std::cout << "uint64 max: " << huge << "\n";
    std::cout << "uint8 65:   " << byte << "\n";          // prints as a char!
    std::cout << "as number:  " << +byte << "\n";         // unary + promotes to int
    std::cout << "int8 -1 as unsigned byte: "
              << static_cast<int>(static_cast<std::uint8_t>(small)) << "\n";
    std::cout << "sizeof(std::size_t) = " << sizeof(std::size_t)
              << ", sizeof(std::intptr_t) = " << sizeof(std::intptr_t) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o fixed fixed.cpp
$ ./fixed
int32 max:  2147483647
uint64 max: 18446744073709551615
uint8 65:   A
as number:  65
int8 -1 as unsigned byte: 255
sizeof(std::size_t) = 8, sizeof(std::intptr_t) = 8

std::uint8_t holding 65 printed A, not 65: on every mainstream platform it is a typedef for unsigned char, and << prints chars as characters. Unary + promotes it to int. Casting int8_t{-1} to uint8_t gives 255 — the same bit pattern, 0xFF, read as unsigned. std::size_t and std::intptr_t are pointer-sized, 8 bytes on a 64-bit build.

std::string vs C-Strings

A C-string is a char array ending in '\0' with no bounds checking. std::string owns a growable buffer and knows its length.

#include <iostream>
#include <string>

int main() {
    std::string s = "Hello";
    s += ", World!";
    s.append(" How are you?");
    std::cout << s << " (" << s.length() << " chars)\n";
    std::cout << "substr(7, 5): " << s.substr(7, 5) << "\n";

    if (auto pos = s.find("World"); pos != std::string::npos)
        s.replace(pos, 5, "C++");
    std::cout << s << "\n";
    std::cout << "find(\"xyz\") = " << s.find("xyz") << "\n";   // npos

    std::string a = "apple", b = "Banana";
    std::cout << std::boolalpha << "\"apple\" < \"Banana\": " << (a < b) << "\n";

    int n = std::stoi("42abc");                // parses the leading digits
    std::cout << "stoi(\"42abc\") = " << n << ", to_string(3.5) = "
              << std::to_string(3.5) << "\n";
    try {
        std::stoi("abc");
    } catch (const std::invalid_argument& e) {
        std::cout << "stoi(\"abc\") threw invalid_argument: " << e.what() << "\n";
    }
}
$ g++ -std=c++20 -Wall -Wextra -o strings strings.cpp
$ ./strings
Hello, World! How are you? (26 chars)
substr(7, 5): World
Hello, C++! How are you?
find("xyz") = 18446744073709551615
"apple" < "Banana": false
stoi("42abc") = 42, to_string(3.5) = 3.500000
stoi("abc") threw invalid_argument: stoi

A few surprises in there. find returns std::string::npos on a miss, which is the largest size_t — always compare against npos, never against -1 or 0. "apple" < "Banana" is false because comparison is by character code and uppercase B (66) sorts before lowercase a (97). std::stoi("42abc") stops at the first non-digit and returns 42 instead of complaining; only input with no leading number throws std::invalid_argument. std::to_string(3.5) always uses six decimals.

std::string_view (C++17)

std::string_view is a pointer and a length — a read-only window onto characters someone else owns.

#include <iostream>
#include <string>
#include <string_view>

void print_name(std::string_view name) {       // no copy, no allocation
    std::cout << "[" << name << "] length " << name.size() << "\n";
}

int main() {
    std::string full = "Ada Lovelace";
    print_name("Alice");                        // string literal
    print_name(full);                           // std::string
    std::string_view first = std::string_view(full).substr(0, 3);
    print_name(first);                          // a window into full, not a copy

    full[0] = 'E';                              // the view sees the change
    print_name(first);
    std::cout << "sizeof(string_view) = " << sizeof(std::string_view)
              << ", sizeof(string) = " << sizeof(std::string) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o views views.cpp
$ ./views
[Alice] length 5
[Ada Lovelace] length 12
[Ada] length 3
[Eda] length 3
sizeof(string_view) = 16, sizeof(string) = 32

One print_name(std::string_view) accepts a literal, a std::string and a substring, with no copies. But first doesn’t own anything: after full[0] = 'E', it reads Eda. The same property makes a view dangerous if the string it points into is destroyed or reallocated — then it dangles. Use it for parameters, not for storing.

std::array vs C-Arrays

A C array decays to a pointer when passed to a function, losing its size. std::array keeps the size in the type.

#include <algorithm>
#include <array>
#include <iostream>
#include <stdexcept>

void takes_c_array(int arr[5]) {               // really: int* arr
    std::cout << "inside function: sizeof = " << sizeof(arr) << "\n";
}

int main() {
    int c_arr[5] = {5, 3, 1, 4, 2};
    std::cout << "in main: sizeof = " << sizeof(c_arr) << "\n";
    takes_c_array(c_arr);                      // the size is lost

    std::array<int, 5> arr = {5, 3, 1, 4, 2};
    std::ranges::sort(arr);
    for (int x : arr) std::cout << x << " ";
    std::cout << "| size " << arr.size() << "\n";

    std::array<int, 5> copy = arr;             // C arrays can't be copied like this
    std::cout << std::boolalpha << "copy == arr: " << (copy == arr) << "\n";
    try {
        arr.at(10) = 1;
    } catch (const std::out_of_range& e) {
        std::cout << "at(10): " << e.what() << "\n";
    }
}
$ g++ -std=c++20 -Wall -Wextra -o arrays arrays.cpp
arrays.cpp: In function 'void takes_c_array(int*)':
arrays.cpp:7:57: warning: 'sizeof' on array function parameter 'arr' will return size of 'int*' [-Wsizeof-array-argument]
    7 |     std::cout << "inside function: sizeof = " << sizeof(arr) << "\n";
      |                                                        ~^~~~
arrays.cpp:6:24: note: declared here
    6 | void takes_c_array(int arr[5]) {               // really: int* arr
      |                    ~~~~^~~~~~
$ ./arrays
in main: sizeof = 20
inside function: sizeof = 8
1 2 3 4 5 | size 5
copy == arr: true
at(10): array::at: __n (which is 10) >= _Nm (which is 5)

GCC warns at compile time, and the output confirms it: sizeof is 20 in main but 8 in the function, because int arr[5] as a parameter is really int* arr — the 5 is ignored. std::array keeps its size, sorts with the standard algorithms, copies with = and compares with ==, none of which a C array supports. at() bounds-checks and throws std::out_of_range; operator[] doesn’t check, and arr[10] is undefined behavior.

std::vector

std::vector is a contiguous, growable array — the default container. Watch its capacity as it grows.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    std::cout << "empty: size " << v.size() << ", capacity " << v.capacity() << "\n";
    for (int i = 1; i <= 9; ++i) {
        v.push_back(i);
        std::cout << "push " << i << ": size " << v.size()
                  << ", capacity " << v.capacity() << "\n";
    }
    std::vector<int> r;
    r.reserve(100);                              // one allocation up front
    r.push_back(1);
    std::cout << "reserved: size " << r.size() << ", capacity " << r.capacity() << "\n";
    v.resize(3);
    std::cout << "after resize(3): size " << v.size() << ", capacity " << v.capacity() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o vector vector.cpp
$ ./vector
empty: size 0, capacity 0
push 1: size 1, capacity 1
push 2: size 2, capacity 2
push 3: size 3, capacity 4
push 4: size 4, capacity 4
push 5: size 5, capacity 8
push 6: size 6, capacity 8
push 7: size 7, capacity 8
push 8: size 8, capacity 8
push 9: size 9, capacity 16
reserved: size 1, capacity 100
after resize(3): size 3, capacity 16

size() is how many elements exist; capacity() is how many fit before the vector must allocate a bigger buffer and move everything. GCC’s libstdc++ doubles the capacity (1, 2, 4, 8, 16) — MSVC grows by 1.5× instead, so don’t depend on the exact numbers. reserve(100) allocates once when you know the size in advance. resize(3) drops elements but keeps the capacity; memory isn’t returned until shrink_to_fit() or destruction.

Initializer Lists

Braces initialize almost anything, but for containers they prefer the std::initializer_list constructor — which changes meaning.

#include <iostream>
#include <map>
#include <string>
#include <vector>

struct Point { int x, y; };

int main() {
    std::vector<int> parens(3, 1);   // constructor: 3 elements, each 1
    std::vector<int> braces{3, 1};   // initializer list: elements 3 and 1
    std::cout << "parens: size " << parens.size() << " -> ";
    for (int x : parens) std::cout << x << " ";
    std::cout << "\nbraces: size " << braces.size() << " -> ";
    for (int x : braces) std::cout << x << " ";
    std::cout << "\n";

    std::map<std::string, int> m = {{"b", 2}, {"a", 1}};
    std::vector<Point> points = {{0, 0}, {1, 2}, {3, 4}};
    std::cout << "m.begin(): " << m.begin()->first << ", points[2].y = " << points[2].y << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o braces braces.cpp
$ ./braces
parens: size 3 -> 1 1 1 
braces: size 2 -> 3 1 
m.begin(): a, points[2].y = 4

std::vector<int>(3, 1) calls the (count, value) constructor: three 1s. std::vector<int>{3, 1} is a two-element list: 3 and 1. The same brace syntax fills maps, aggregates like Point, and nested containers, which is why it’s the default — just remember the vector exception when both arguments are the element type.

enum class

A plain enum leaks its names into the enclosing scope and converts to int. enum class fixes both.

#include <cstdint>
#include <iostream>

enum Direction { North, South, East, West };      // plain enum: names leak out
enum class Color { Red, Green, Blue };            // scoped
enum class Flags : std::uint8_t { None = 0, Read = 1, Write = 2, Exec = 4 };

const char* name(Color c) {
    switch (c) {
        case Color::Red:   return "Red";
        case Color::Green: return "Green";
        case Color::Blue:  return "Blue";
    }
    return "?";
}

int main() {
    int oops = East + West;                        // compiles: plain enums are ints
    std::cout << "East + West = " << oops << "\n";

    Color c = Color::Blue;
    std::cout << name(c) << " = " << static_cast<int>(c) << "\n";
    std::cout << "sizeof(Flags) = " << sizeof(Flags)
              << ", sizeof(Color) = " << sizeof(Color) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o enums enums.cpp
$ ./enums
East + West = 5
Blue = 2
sizeof(Flags) = 1, sizeof(Color) = 4

East + West compiled and produced 5 (2 + 3) — meaningless, but legal for a plain enum. The scoped Color needs static_cast<int> to become a number, and Flags : std::uint8_t shrinks the enum to one byte. Because name covers every enumerator, GCC’s -Wall stays quiet; delete one case and it warns that the enumeration value isn’t handled.

The implicit conversion really is gone:

enum class Color { Red, Green, Blue };

int main() {
    int n = Color::Red;   // no implicit conversion from a scoped enum
    return n;
}
$ g++ -std=c++20 -Wall -Wextra -o enum_fail enum_fail.cpp 2>&1 | grep error
enum_fail.cpp:4:20: error: cannot convert 'Color' to 'int' in initialization

Practice

  1. Print std::numeric_limits<int>::min() and max(), then compute max() + 1 in a long long so it doesn’t overflow.

    Solution
    #include <iostream>
    #include <limits>
    
    int main() {
        auto lo = std::numeric_limits<int>::min();
        auto hi = std::numeric_limits<int>::max();
        long long next = static_cast<long long>(hi) + 1;
        std::cout << lo << " " << hi << " " << next << "\n";
    }

    Prints -2147483648 2147483647 2147483648. Casting before adding performs the addition in 64 bits; hi + 1 in int would be signed overflow, which is undefined behavior.

  2. Write bool nearly_equal(double a, double b, double eps = 1e-9) and use it to show that 0.1 + 0.2 is “equal” to 0.3.

    Solution
    #include <cmath>
    bool nearly_equal(double a, double b, double eps = 1e-9) {
        return std::fabs(a - b) < eps;
    }
    // nearly_equal(0.1 + 0.2, 0.3) -> true

    The difference is about 5.5e-17, far below the tolerance. For values of very different magnitudes, scale eps by std::max(std::fabs(a), std::fabs(b)).

  3. Change the loop in vector.cpp to call v.reserve(9) first. What happens to the capacity column?

    Solution

    Capacity reads 9 on every line: one allocation up front, no regrowth, no element moves. That’s the point of reserve when the final size is known.

  4. Add a Color::Yellow enumerator without updating name(). What does g++ -Wall report?

    Solution

    warning: enumeration value 'Yellow' not handled in switch [-Wswitch]. That warning is a reason to switch over enum class values without a default: — a default would silence it.

  5. Make print_name in views.cpp return its argument, and store print_name(std::string("temp")) in a std::string_view. Why is using that view afterwards a bug?

    Solution

    std::string("temp") is a temporary destroyed at the end of the full expression, so the stored view points at freed memory — reading it is undefined behavior. It may even print the right text by luck, which is what makes dangling views hard to find. Store a std::string when you need to keep the text.

Frequently Asked Questions

Should I use int or int32_t?
Use int for ordinary counting and arithmetic. Use the exact-width types from <cstdint>, such as std::int32_t or std::uint64_t, when the size is part of a contract: network protocols, binary file formats or hardware registers, where a wrong size corrupts data.
Why prefer std::string over char*?
std::string owns and grows its own buffer, knows its length, and offers find, replace and substr. A char* C-string relies on a terminating null byte and manual buffer sizing, which is how buffer overflows happen.
Why does std::cout print a uint8_t as a character?
std::uint8_t is almost always a typedef for unsigned char, and the stream overload for char types prints a character. Print it as a number by promoting it first, with static_cast<int>(value) or unary plus: +value.
When should a function take std::string_view instead of const std::string&?
Use std::string_view for parameters that only read text: it accepts string literals, std::string and substrings without allocating. Don't store a string_view beyond the lifetime of the string it points into, because it owns nothing.
What is the difference between enum and enum class?
A plain enum puts its names in the enclosing scope and converts implicitly to int, so nonsense like East + West compiles. An enum class is scoped (Color::Red), never converts implicitly, and lets you choose its underlying type.