Free Handbook · Every example compiled & verified

Arrays & Strings

C arrays and pointer decay, std::array, std::vector, 2-D grids, std::string operations, string_view, and the difference between C strings and std::string.

0 / 145 lessons🔥 0 day streak
ShareXLinkedIn

Module 04 · what you'll be able to do

  • Explain why a C array "forgets" its size when passed to a function, and use std::array instead
  • Build, grow and loop over a std::vector, and know when at() is worth it over []
  • Create and walk a 2-D grid with std::vector<std::vector<int>>
  • Search, slice, join and convert strings with find, npos, substr, std::to_string and std::stoi
  • Pass text cheaply with std::string_view and convert safely between C strings and std::string
01

C arrays and pointer decay

A built-in (C-style) array is a fixed number of elements of one type, stored side by side in memory: int marks[5];. The size must be a compile-time constant and can never change. Indexes start at 0, so the last element of a 5-element array is marks[4]. Reading or writing past the end is not checked: it is undefined behaviour that may corrupt other variables silently.

C++main.cpp
#include <iostream>
#include <iterator>

int main() {
    int marks[5] = {72, 88, 95, 60, 81};
    double prices[] = {9.5, 12.0, 3.25};   // size deduced: 3
    int zeros[4] = {};                      // all four set to 0
    int partial[4] = {1, 2};                // rest become 0: {1, 2, 0, 0}

    std::cout << "first " << marks[0] << ", last " << marks[4] << '\n';
    std::cout << "prices has " << std::size(prices) << " elements\n";
    std::cout << "bytes: " << sizeof(marks) << '\n';        // 5 * 4

    marks[3] += 10;
    int total = 0;
    for (int m : marks) total += m;
    std::cout << "total " << total << '\n';
    std::cout << zeros[3] << ' ' << partial[1] << ' ' << partial[3] << '\n';
}
Outputcompiled & run with real C++
first 72, last 81
prices has 3 elements
bytes: 20
total 406
0 2 0
Your turn

Change int zeros[4] = {}; to int zeros[4]; inside main and print it. Local arrays without an initializer hold garbage, just like single variables.

The catch comes when you pass an array to a function. The array is not copied; it decays into a pointer to its first element, and the size is lost. Inside the function, int arr[] really means int* arr. That is why C-style code always passes the length as a separate parameter.

Error you will hit

sizeof on array function parameter will return size of 'int *'

C++
#include <iostream>

void printSize(int arr[]) {
    std::cout << sizeof(arr) << '\n';
}

int main() {
    int nums[5] = {1, 2, 3, 4, 5};
    printSize(nums);
}
main.cpp:4:24: warning: sizeof on array function parameter will return size of 'int *' instead of 'int[]' [-Wsizeof-array-argument]
    4 |     std::cout << sizeof(arr) << '\n';
      |                        ^
main.cpp:3:20: note: declared here
    3 | void printSize(int arr[]) {
      |                    ^
Why the compiler said that

The program compiles and prints 8: the size of a pointer on a 64-bit machine, not 20 bytes of array. Any loop bound computed as sizeof(arr) / sizeof(arr[0]) inside the function is therefore wrong (it gives 2, not 5).

The fix

Pass a container that knows its size: a std::array, a std::vector, or a std::span (C++20) that views any contiguous sequence.

C++
#include <iostream>
#include <span>

void printSize(std::span<const int> arr) {
    std::cout << arr.size() << '\n';          // 5
}

int main() {
    int nums[5] = {1, 2, 3, 4, 5};
    printSize(nums);
}
Error you will hit

array type is not assignable

C++
int main() {
    int a[3] = {1, 2, 3};
    int b[3];
    b = a;
}
main.cpp:4:7: error: array type 'int[3]' is not assignable
    4 |     b = a;
      |     ~ ^
Why the compiler said that

Built-in arrays cannot be copied with =, compared with == or returned from functions. It is a limitation inherited from C, and one of the reasons std::array exists.

The fix

Use std::array, which copies, compares and returns like any other value.

C++
#include <array>

int main() {
    std::array<int, 3> a{1, 2, 3};
    std::array<int, 3> b = a;         // element-by-element copy
    return b == a ? 0 : 1;            // and comparison works too
}
02

std::array: a fixed-size array that behaves

std::array<T, N> (from <array>) is a thin wrapper around a C array: same memory layout, same speed, no heap allocation. What it adds is everything the C array lacks: it knows its size(), copies with =, compares with ==, can be returned from a function, and offers a bounds-checked at(). Use it whenever the number of elements is fixed and known at compile time.

C++main.cpp
#include <algorithm>
#include <array>
#include <iostream>
#include <string>

std::array<int, 3> rgb(const std::string& name) {
    if (name == "teal") return {0, 128, 128};
    return {0, 0, 0};
}

int main() {
    std::array<std::string, 7> days{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
    std::cout << days.size() << " days, weekend starts " << days[5] << '\n';
    std::cout << "front " << days.front() << ", back " << days.back() << '\n';

    std::array<int, 5> temps{31, 28, 35, 30, 29};
    std::array<int, 5> copy = temps;              // real copy
    std::sort(copy.begin(), copy.end());
    std::cout << "sorted: ";
    for (int t : copy) std::cout << t << ' ';
    std::cout << "\noriginal first: " << temps[0] << '\n';

    auto [r, g, b] = rgb("teal");
    std::cout << r << ',' << g << ',' << b << '\n';
}
Outputcompiled & run with real C++
7 days, weekend starts Sat
front Mon, back Sun
sorted: 28 29 30 31 35
original first: 31
0,128,128
Your turn

Write double average(const std::array<int, 5>& a). Notice the size is part of the type: an std::array<int, 6> will not fit. When the size varies, use a vector or std::span.

03

std::vector basics

std::vector<T> (from <vector>) is the container you will use most in C++: a dynamic array that grows as you add elements and frees its memory automatically. Elements are still contiguous, so indexing is as fast as with a C array. When it runs out of room it allocates a bigger block (usually double) and moves the elements across, which is why push_back is cheap on average.

C++main.cpp
#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> queue;              // empty
    queue.push_back("Asha");
    queue.push_back("Ravi");
    queue.emplace_back("Meera");                  // builds the string in place
    std::cout << queue.size() << " waiting, first " << queue.front()
              << ", last " << queue.back() << '\n';

    queue.pop_back();                             // removes Meera
    queue.insert(queue.begin(), "Zoya");          // adds at the front (slow for big vectors)
    queue.erase(queue.begin() + 1);               // removes the element at index 1
    for (const auto& name : queue) std::cout << name << ' ';
    std::cout << '\n';

    std::vector<int> scores(4, 10);               // four elements, all 10
    std::vector<int> ids{4, 10};                  // two elements: 4 and 10
    std::cout << scores.size() << " vs " << ids.size() << '\n';

    scores[0] = 99;                               // [] : fast, unchecked
    scores.at(1) = 77;                            // at(): checked, throws if out of range
    std::cout << scores[0] << ' ' << scores.at(1) << ' ' << std::boolalpha << ids.empty() << '\n';
}
Outputcompiled & run with real C++
3 waiting, first Asha, last Meera
Zoya Ravi
4 vs 2
99 77 false
Your turn

Call scores.reserve(1000) and then print scores.capacity() next to scores.size(). Reserving up front avoids repeated regrowth when you know roughly how many elements are coming.

( ) and { } mean different things for vectors
std::vector<int> v(4, 10); is "4 copies of 10". std::vector<int> v{4, 10}; is "the two elements 4 and 10", because braces prefer the list constructor. The same applies to v(5) (five zeros) versus v{5} (one element, 5).
Error you will hit

terminating due to uncaught exception of type std::out_of_range

C++
#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{90, 75, 82};
    std::cout << scores.at(3) << '\n';
}
libc++abi: terminating due to uncaught exception of type std::out_of_range: vector
Why the compiler said that

A vector of 3 elements has valid indexes 0, 1 and 2. at() checks the index and throws std::out_of_range; nothing catches it, so the program is terminated (exit code 134, "Abort trap"). That is the good outcome. scores[3] would not check at all: it reads past the end, which is undefined behaviour: when we tried it, it printed 0 with no error at all, which is far worse than a crash.

The fix

Loop with i < v.size(), or better a range-based for. Use at() when an index comes from outside (user input, a file) and catch the exception at a sensible place. Building with -fsanitize=address catches bad [] reads too.

C++
#include <iostream>
#include <stdexcept>
#include <vector>

int main() {
    std::vector<int> scores{90, 75, 82};
    try {
        std::cout << scores.at(3) << '\n';
    } catch (const std::out_of_range& e) {
        std::cout << "bad index: " << e.what() << '\n';
    }
}
04

Looping over vectors and removing elements safely

There are three ways to walk a vector. A range-based for is the default. An index loop is for when you need the position; make the index std::size_t so it matches size(). Iterators (begin(), end()) are what the standard algorithms take: end() points one past the last element, so a range is always "from begin up to but not including end".

C++main.cpp
#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{5, 8, 3, 8, 1};

    for (std::size_t i = 0; i < v.size(); ++i) {       // index loop
        std::cout << i << ':' << v[i] << ' ';
    }
    std::cout << '\n';

    for (auto it = v.begin(); it != v.end(); ++it) {   // iterator loop
        *it *= 10;                                     // *it is the element
    }

    auto pos = std::find(v.begin(), v.end(), 80);      // first 80
    std::cout << "80 at index " << (pos - v.begin()) << '\n';
    std::cout << "count of 80: " << std::count(v.begin(), v.end(), 80) << '\n';

    std::erase(v, 80);                                 // C++20: remove every 80
    std::erase_if(v, [](int x) { return x < 20; });    // and everything under 20
    for (int x : v) std::cout << x << ' ';
    std::cout << '\n';
}
Outputcompiled & run with real C++
0:5 1:8 2:3 3:8 4:1
80 at index 1
count of 80: 2
50 30
Your turn

Walk the vector backwards. With std::size_t i = v.size() - 1; i >= 0; --i the loop never ends (why?). Use v.rbegin() / v.rend() or for (std::size_t i = v.size(); i-- > 0;) instead.

Do not push_back or erase inside a range-based for over the same vector
When a vector grows it may move to new memory, and erasing shifts the elements after the gap. Either way, the iterators the loop is holding become invalid, and using them is undefined behaviour. To remove elements, use std::erase / std::erase_if (C++20) as above. To add elements based on existing ones, collect them in a second vector and append afterwards.
05

2-D grids with vectors

A grid (a spreadsheet, a game board, a matrix) is a vector of rows, each of which is a vector: std::vector<std::vector<int>>. grid[r][c] is row r, column c. Build a rows × cols grid filled with a value using the size-and-value constructor twice: grid(rows, std::vector<int>(cols, 0)).

C++main.cpp
#include <iostream>
#include <vector>

int main() {
    const std::size_t rows = 3, cols = 4;
    std::vector<std::vector<int>> grid(rows, std::vector<int>(cols, 0));

    for (std::size_t r = 0; r < rows; ++r)
        for (std::size_t c = 0; c < cols; ++c)
            grid[r][c] = static_cast<int>(r * 10 + c);

    for (const auto& row : grid) {
        for (int cell : row) std::cout << cell << '\t';
        std::cout << '\n';
    }

    // Column sums: the outer loop is now the column
    for (std::size_t c = 0; c < cols; ++c) {
        int sum = 0;
        for (std::size_t r = 0; r < rows; ++r) sum += grid[r][c];
        std::cout << "col " << c << " sum " << sum << '\n';
    }

    std::vector<std::vector<char>> jagged{{'a'}, {'b', 'c'}, {'d', 'e', 'f'}};
    std::cout << "row 2 has " << jagged[2].size() << " cells\n";
}
Outputcompiled & run with real C++
0	1	2	3
10	11	12	13
20	21	22	23
col 0 sum 30
col 1 sum 33
col 2 sum 36
col 3 sum 39
row 2 has 3 cells
Your turn

Write a function that returns the transpose of the grid (a 4 × 3 grid where t[c][r] == grid[r][c]).

VisualizeSumming a 2 × 2 grid row by rowStep 1 / 6
std::vector<std::vector<int>> g{{1, 2}, {3, 4}};
int total = 0;
for (const auto& row : g)
for (int x : row)
total += x;
Line 1

Two rows of two numbers.

Variables now
g{{1,2},{3,4}}
All 6 steps as a table
StepLineWhat happenedVariables now
11Two rows of two numbers.g = {{1,2},{3,4}}
22total starts at 0.total = 0
33Outer loop: row refers to the first row.row = {1,2}
45Inner loop visits 1 then 2.x = 2 total = 3
53Inner loop finished; outer loop moves to the second row.row = {3,4}
65Inner loop visits 3 then 4.x = 4 total = 10
In real jobs
A vector of vectors allocates every row separately, which scatters the grid across memory. Performance-sensitive code (image processing, games, numerical work) usually stores a grid in one flat std::vector<int> of rows * cols elements and indexes it as data[r * cols + c]. Same maths, one allocation, much friendlier to the CPU cache.
06

std::string operations

Module 01 introduced std::string. Here is the toolkit you will use every day. find returns the index of the first match, or the special value std::string::npos when there is none: always compare against npos, never against -1 or 0. substr(pos, count) copies out a piece; count is a length, not an end index.

C++main.cpp
#include <iostream>
#include <string>

int main() {
    std::string email = "[email protected]";

    std::size_t at = email.find('@');
    std::string user = email.substr(0, at);           // from 0, 'at' characters
    std::string domain = email.substr(at + 1);        // from after @ to the end
    std::cout << user << " | " << domain << '\n';

    if (email.find("gmail") == std::string::npos) std::cout << "not gmail\n";
    std::cout << "last dot at " << email.rfind('.') << '\n';

    std::string path = "reports";
    path.append("/2024").append("/q3.csv");            // chainable
    path.insert(0, "/data/");
    path.replace(path.find("q3"), 2, "Q3");            // replace 2 chars at that index
    std::cout << path << '\n';

    std::cout << std::boolalpha << email.starts_with("asha")
              << ' ' << path.ends_with(".csv") << '\n';  // C++20
    std::cout << std::string("apple").compare("banana") << '\n';   // negative: apple sorts first
}
Outputcompiled & run with real C++
asha.k | example.com
not gmail
last dot at 14
/data/reports/2024/Q3.csv
true true
-1
Your turn

Split "red,green,blue" on commas: loop with find(',', start), take substr(start, pos - start), and move start to pos + 1 until find returns npos.

Numbers to strings and back

C++main.cpp
#include <cctype>
#include <iostream>
#include <string>

int main() {
    int qty = 3;
    double price = 49.5;
    std::string line = "qty=" + std::to_string(qty) + " price=" + std::to_string(price);
    std::cout << line << '\n';                  // to_string(double) always shows 6 decimals

    int n = std::stoi("42");
    double d = std::stod("2.75");
    std::size_t used = 0;
    int partial = std::stoi("128px", &used);     // stops at the first non-digit
    std::cout << n + 1 << ' ' << d * 2 << ' ' << partial << " (read " << used << " chars)\n";

    std::string shout = "hello";
    for (char& c : shout) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
    std::cout << shout << '\n';
}
Outputcompiled & run with real C++
qty=3 price=49.500000
43 5.5 128 (read 3 chars)
HELLO

For controlled formatting use std::format("{:.2f}", price) from <format> instead of std::to_string. The cast to unsigned char before std::toupper is not paranoia: passing a negative char (any non-ASCII byte) to it is undefined behaviour.

Error you will hit

invalid operands to binary expression ('const char[8]' and 'const char[6]')

C++
#include <iostream>
#include <string>

int main() {
    std::string greeting = "Hello, " + "world";
    std::cout << greeting << '\n';
}
main.cpp:5:38: error: invalid operands to binary expression ('const char[8]' and 'const char[6]')
    5 |     std::string greeting = "Hello, " + "world";
      |                            ~~~~~~~~~ ^ ~~~~~~~
Why the compiler said that

A string literal is not a std::string; it is a plain character array ("Hello, " is 7 characters plus the terminating \0, so const char[8]). Built-in arrays have no +. It works as soon as either side is a std::string, because then std::string's + is used.

The fix

Make the first operand a std::string, or use the s literal suffix from std::string_literals.

C++
#include <iostream>
#include <string>

int main() {
    using namespace std::string_literals;
    std::string greeting = std::string("Hello, ") + "world";
    std::string other = "Hello, "s + "again";        // "..."s is a std::string
    std::cout << greeting << '\n' << other << '\n';
}
Error you will hit

uncaught exception of type std::invalid_argument: stoi: no conversion

C++
#include <iostream>
#include <string>

int main() {
    std::string input = "forty";
    int n = std::stoi(input);
    std::cout << n << '\n';
}
libc++abi: terminating due to uncaught exception of type std::invalid_argument: stoi: no conversion
Why the compiler said that

std::stoi throws std::invalid_argument when the text does not start with a number, and std::out_of_range when the number does not fit in an int. Text from a user or a file must always be treated as possibly invalid.

The fix

Catch the exceptions where you parse, or use std::from_chars from <charconv>, which reports failure through a return code and never throws.

C++
#include <iostream>
#include <stdexcept>
#include <string>

int main() {
    std::string input = "forty";
    try {
        int n = std::stoi(input);
        std::cout << n << '\n';
    } catch (const std::invalid_argument&) {
        std::cout << "not a number: " << input << '\n';
    }
}
07

std::string_view

std::string_view (C++17, <string_view>) is a read-only window onto characters that live somewhere else: a pointer and a length, nothing more. Creating one never copies or allocates, and taking a substr of a view is just adjusting the window. A string_view parameter accepts a std::string, a string literal or a char array equally, which makes it the modern choice for functions that only read text.

C++main.cpp
#include <iostream>
#include <string>
#include <string_view>

std::string_view extension(std::string_view filename) {
    std::size_t dot = filename.rfind('.');
    if (dot == std::string_view::npos) return {};
    return filename.substr(dot + 1);            // no copy: a smaller window
}

bool isHidden(std::string_view name) { return !name.empty() && name.front() == '.'; }

int main() {
    std::string report = "sales-q3.csv";
    std::cout << extension(report) << '\n';               // from a std::string
    std::cout << extension("photo.jpeg") << '\n';         // from a literal
    std::cout << '[' << extension("Makefile") << "]\n";   // empty view

    std::string_view line = "  padded text  ";
    line.remove_prefix(line.find_first_not_of(' '));
    line.remove_suffix(line.size() - line.find_last_not_of(' ') - 1);
    std::cout << '[' << line << "] " << std::boolalpha << isHidden(".env") << '\n';
}
Outputcompiled & run with real C++
csv
jpeg
[]
[padded text] true
A view does not own anything
A string_view is only valid while the characters it looks at still exist. Returning a view of a local std::string, or keeping a view of a temporary like std::string_view v = getName(); where getName() returns a std::string by value, leaves the view pointing at freed memory. It compiles, and may even print the right text in a test, but it is undefined behaviour. Rule: use string_view for parameters; return and store std::string unless you are sure who owns the text. Lifetimes are the subject of Module 05.
08

C strings versus std::string

Before std::string, text in C was a char array ending in a zero byte ('\0'), usually handled through a const char*. String literals are still C strings, and you will meet them in operating-system APIs and older libraries. The functions in <cstring> (std::strlen, std::strcmp, std::strcpy) work on them, and none of them check sizes.

C string (const char* / char[])std::string
Lengthstd::strlen(s), walks to the \0 every times.size(), stored, instant
Comparestd::strcmp(a, b) == 0; == compares addressesa == b compares text
Joinstd::strcat into a buffer you hope is big enougha + b, +=
Memoryfixed buffer, overflow is undefined behaviourgrows and frees itself
Convertstd::string s = cstr;s.c_str() gives a const char*
C++main.cpp
#include <cstring>
#include <iostream>
#include <string>

int main() {
    const char* literal = "hello";
    char buffer[] = "world";                  // 6 bytes: w o r l d \0
    std::cout << std::strlen(literal) << ' ' << sizeof(buffer) << '\n';

    buffer[0] = 'W';                          // a char array is writable
    std::cout << buffer << '\n';

    std::cout << (std::strcmp("apple", "apple") == 0 ? "same" : "different") << '\n';

    std::string s = literal;                  // C string -> std::string
    s += ", ";
    s += buffer;
    const char* back = s.c_str();             // std::string -> C string (read-only)
    std::cout << back << " (" << std::strlen(back) << " chars)\n";
}
Outputcompiled & run with real C++
5 6
World
same
hello, World (12 chars)
Your turn

The pointer from c_str() is only valid until s is modified or destroyed. Add s += "!"; after taking back and explain why printing back afterwards is undefined behaviour even if it seems to work.

Error you will hit

array comparison always evaluates to false

C++
#include <iostream>

int main() {
    char typed[] = "admin";
    char expected[] = "admin";
    if (typed == expected) {
        std::cout << "match\n";
    } else {
        std::cout << "no match\n";
    }
}
main.cpp:6:15: warning: comparison between two arrays is deprecated; to compare array addresses, use unary '+' to decay operands to pointers [-Wdeprecated-array-compare]
    6 |     if (typed == expected) {
      |         ~~~~~ ^  ~~~~~~~~
main.cpp:6:15: warning: array comparison always evaluates to false [-Wtautological-compare]
Why the compiler said that

Both arrays decay to pointers, so == asks "are these the same memory address?", and two separate arrays never are. The program prints "no match" even though the text is identical. With two const char* variables pointing at literals it is worse: the answer may be true or false depending on the compiler, and you get no warning.

The fix

Compare text, not addresses: use std::strcmp, or turn one side into a std::string / std::string_view so == compares characters.

C++
#include <iostream>
#include <string_view>

int main() {
    char typed[] = "admin";
    char expected[] = "admin";
    if (std::string_view(typed) == expected) {
        std::cout << "match\n";
    } else {
        std::cout << "no match\n";
    }
}
C array
A built-in fixed-size array, int a[5]. No size information, no bounds checks, cannot be assigned.
Array decay
The automatic conversion of an array to a pointer to its first element, which happens when it is passed to a function. The size is lost.
std::array
A fixed-size array with a size known at compile time that copies, compares and knows its size().
std::vector
A growable, contiguous dynamic array that manages its own memory. The default container in C++.
Iterator
An object that points at an element of a container; begin() is the first, end() is one past the last.
Iterator invalidation
Iterators, pointers or references into a container becoming unusable after it reallocates or erases elements.
std::string::npos
The "not found" value returned by find, equal to the largest std::size_t.
std::string_view
A non-owning, read-only view of characters (pointer plus length). Cheap to pass; must not outlive the text.
C string
A char array terminated by a zero byte, '\0', usually handled as const char*.
std::span
A C++20 non-owning view over any contiguous sequence (array, vector, std::array) that keeps the size.
Quick check

What does std::string("2024-09-15").substr(5, 2) return?

Quick check

You check if (s.find("x") >= 0) to see whether s contains "x". What happens?

Frequently asked questions

Should I use std::array or std::vector in C++?
Use std::array when the number of elements is fixed and known at compile time; it lives on the stack with no allocation. Use std::vector when the size is decided at runtime or changes. Avoid raw C arrays in new code.
What is the difference between vector::at() and operator[]?
v.at(i) checks that i is a valid index and throws std::out_of_range if it is not. v[i] does no check, so an invalid index is undefined behaviour. [] is slightly faster and fine inside loops bounded by size().
When should I use std::string_view instead of const std::string&?
For function parameters that only read text, std::string_view is usually better: it accepts string literals and C strings without creating a temporary std::string. Do not store or return a string_view unless you are sure the underlying text outlives it.

Finish the C++ handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.