C++ · lesson 4 of 24
Beginner
6Intermediate
11- 5 Operators and Operator Overloading in C++
- 8 Classes and Object-Oriented Programming in C++
- 9 Inheritance and Polymorphism in C++
- 11 STL Containers in C++
- 12 STL Algorithms in C++
- 13 Smart Pointers in C++
- 15 Lambdas and Functional Programming in C++
- 18 Error Handling in C++
- 19 File I/O in C++
- 23 C++ Interview Preparation: Top 35 Questions
- 24 Static Members in C++
Advanced
7Data 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
-
Print
std::numeric_limits<int>::min()andmax(), then computemax() + 1in along longso 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 + 1inintwould be signed overflow, which is undefined behavior. -
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) -> trueThe difference is about 5.5e-17, far below the tolerance. For values of very different magnitudes, scale
epsbystd::max(std::fabs(a), std::fabs(b)). -
Change the loop in
vector.cppto callv.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
reservewhen the final size is known. -
Add a
Color::Yellowenumerator without updatingname(). What doesg++ -Wallreport?Solution
warning: enumeration value 'Yellow' not handled in switch [-Wswitch]. That warning is a reason to switch overenum classvalues without adefault:— adefaultwould silence it. -
Make
print_nameinviews.cppreturn its argument, and storeprint_name(std::string("temp"))in astd::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 astd::stringwhen you need to keep the text.