Abstract¶
Instead of managing individual variables separately, composite data types enable developers to bundle related data together and access them conveniently. This concept is essential for modeling complex information and reducing redundancy. In this notebook, we will explore ordered sequences including fixed-length arrays and variable-length vectors in C++.
from __init__ import *
%reload_ext divewidgetsThe history saving thread hit an unexpected error (OperationalError('attempt to write a readonly database')).History will not be written to the database.
Motivation¶
Composite data refers to structured groupings of multiple values under a single entity, allowing for more organized and efficient data handling. In this notebook, we will explore ordered sequences including fixed-length arrays and variable-length vectors in C++. Let us begin with some motivating examples.
Cross Product¶
Consider computing the cross product of two vectors, which has many applications in physics and computer graphics:
One way to implement the cross product is to use C-style fixed-size arrays:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19/** * @brief Computes the cross product of two 3D vectors. * * This function calculates the cross product of two input vectors `u` and `v`, * each represented as a fixed-size array of 3 doubles. The result is stored in * the output array `w`, which must also be of size 3. * * @param u A constant reference to a 3-element array representing the first vector. * @param v A constant reference to a 3-element array representing the second vector. * @param w A reference to a 3-element array where the result (cross product) will be stored. * @example * double w[3]; * cross((double[]){1,0,0}, (double[]){0,1,0},w); // (double[3]) { 0.0, 0.0, 1.0 } */ void cross(const double (&u)[3], const double (&v)[3], double (&w)[3]) { w[0] = u[1]*v[2] - u[2]*v[1]; w[1] = u[2]*v[0] - u[0]*v[2]; w[2] = u[0]*v[1] - u[1]*v[0]; }
Program 1:Compute the cross product using C-style array.
%%cpp
void cross(const double (&u)[3], const double (&v)[3], double (&w)[3]) {
w[0] = u[1]*v[2] - u[2]*v[1];
w[1] = u[2]*v[0] - u[0]*v[2];
w[2] = u[0]*v[1] - u[1]*v[0];
}
cross(void (*)(const double (&)[3], const double (&)[3], double (&)[3])) Function @0x779283eda000
at input_line_55:1:
void cross(const double (&u)[3], const double (&v)[3], double (&w)[3]) {
w[0] = u[1]*v[2] - u[2]*v[1];
w[1] = u[2]*v[0] - u[0]*v[2];
w[2] = u[0]*v[1] - u[1]*v[0];
}
For instance, to compute with
%%cpp
double w[3];
cross((double[]){1,0,0}, (double[]){0,1,0},w);
w(double[3]) { 0.0000000, 0.0000000, 1.0000000 }
Solution to Exercise 1
Returning w as follows does not work since return value optimization applies only to the pointer to the first element of w, but not the elements of w:
double* cross(const double (&u)[3], const double (&v)[3]) {
double w[3];
w[0] = u[1]*v[2] - u[2]*v[1];
w[1] = u[2]*v[0] - u[0]*v[2];
w[2] = u[0]*v[1] - u[1]*v[0];
return w;
}Declaring w as static double w[3]; is not recommended as the program will not be thread-safe unless w is a constant. It is also problematic to allocate w to the heap memory using auto w=new double[3]; as it requires programmers to manually clean up the storage to avoid memory leak. Passing w into the function circumvent the above issues since the caller of cross has complete control over how the memory should be allocated for w.
We’ll explore an alternative implementation of the dot product using C++11’s fixed-size container std::array from <array>, which can be safely and properly returned by a function similar to other basic data types.
Dot Product¶
Consider computing the dot product of two vectors, which measures how much one vector points in the direction of another. The dot product has numerous applications beyond physics and computer graphics, including statistics and machine learning.
To implement (5) properly with C-style fixed-size array:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20/** * @brief Computes the dot product of two arrays of doubles. * * This function calculates the dot product of two fixed-size arrays `u` and `v`. * It assumes both arrays have the same length `n`. * * @param u A reference to an array of doubles. * @param v A reference to an array of doubles. * @param n The number of elements in each array. * @return The dot product of arrays `u` and `v` as a double. * * @example * double dp = dot((double[]){1,1,1,1}, (double[]){1,1,1,1}, 4); // 4.0 */ auto dot(const double (&u)[], const double (&v)[], const size_t n) { auto prod = 0.; for (size_t i = 0; i < n; ++i) prod += u[i] * v[i]; return prod; }
Program 2:Compute the dot product using C-style array.
Note that the input vectors and are not restricted to the three-dimensional real vector space, i.e., .
%%cpp
auto dot(const double (&u)[], const double (&v)[], const size_t n) {
auto prod=0.;
for (size_t i=0; i<n; ++i) prod += u[i]*v[i];
return prod;
}
cout << dot((double[]){1,0}, (double[]){0,1}, 2) << ' ';
cout << dot((double[]){1,0,0}, (double[]){0,0,1}, 3) << ' ';
cout << dot((double[]){1,0,0,0}, (double[]){0,0,0,1}, 4) << ' ';
cout << dot((double[]){1,0,0,0,0}, (double[]){0,0,0,0,1}, 5) << ' ';
cout << dot((double[]){1,0,0,0,0,0}, (double[]){0,0,0,0,0,1}, 6) << ' ';0 0 0 0 0 The above computes the dot products of orthogonal vectors in for different dimensions . However, the dimension needs to be passed into the function as a separate parameter n.
To implement the norm in (6):
%%cpp
auto norm(const double (&v)[], const size_t n) {
return sqrt(dot(v, v, n));
}
cout << norm((double[0]){}, 0) << ' ';
cout << norm((double[1]){1}, 1) << ' ';
cout << norm((double[2]){1,1}, 2) << ' ';
cout << norm((double[3]){1,1,1}, 3) << ' ';
cout << norm((double[4]){1,1,1,1}, 4) << ' ';0 1 1.41421 1.73205 2 This calculates the norm of the vectors of all ones in for different dimensions .
Solution to Exercise 2
To allocate the memory statically for v, its size n must be a compile-time constant.
We will learn to use the C++11 container std::vector from <vector>, whose length can be changed dynamically.
Compound Literal¶
How to enter an array of elements?
Recall that a string is a constant array of characters:
%%cpp
"abc"(const char[4]) "abc"
The size is fixed and can be obtained at compile time:
%%cpp
sizeof("Ava") // in bytes(unsigned long) 4
Instead of a string literal, we can enter a string as an array using the C-style compound literal:
%%cpp
cout << (const char[]){'A','v','a', 0}; // 😈: Oh, there is a typo... `, 0`.AvaWe can also specify a bigger size for an array than the size of the initializer:
%%cpp
cout << (const char[4]){'A','v','a'}; // remaining entries initialized to 0AvaThe entries of an array need not be characters but they must be of the same type:
%%cpp
(int[]){1,2,3}(int[3]) { 1, 2, 3 }
%%cpp
sizeof((double[]){1,2,3})(unsigned long) 24
%%cpp
sizeof((int[6]){1,2,3}) // `double` is not double the size of `int`?(unsigned long) 24
This is unlike Python list and tuple, which can be heterogeneous, i.e., with elements of different types:
[(1, '2'), [False, 3]][(1, '2'), [False, 3]]The C-style array is more like array from numpy, which must be homogenous, i.e., with elements of the same types:
import numpy as np
np.array([(1, '2'), [False, 3]])array([['1', '2'],
['False', '3']], dtype='<U21')%%hermes
Explain briefly the similarities and differences between numpy arrays and
C-style arrays.🌿 Fork/new: main (will create on click)
Hermes
Similar — both are fixed-size, homogeneous, contiguous collections:
A C-style array (e.g. the lecture’s
double[3]) and a numpy array both storenelements of one type in contiguous memory, soa[i]is O(1) andsizeof/.nbytesis justn × element size(as the earliersizeof((int[6]){1,2,3})cell showed). Neither can hold mixed types like Python’s list/tuple.
Different — mostly in when the homogeneity is enforced and what the size means:
Compile-time vs run-time coercion. In C, the element type is part of the type itself, so
(double[]){1, 'a'}is a compile error. numpy enforces homogeneity at construction, silently: the cell above (np.array([(1, '2'), [False, 3]])) doesn’t error — it coerces everything to a common dtype, here a 2×2 array of 21-char strings,array([['1','2'],['False','3']], dtype='<U21'). Homogeneous result, but your numbers and booleans became strings.Size is type vs data. For a C array, the length is baked into the type (
int[6]is a different type fromint[5]) and fixed at declaration — which is why the lecture’sex:dot2couldn’t loop overn. A numpy array’s shape is runtime metadata (.shape, e.g.(2, 3)), can be multidimensional, and is created/allocated at run time.Primitive vs object. A C-style array is a language primitive (it decays to a pointer, has no methods); a numpy array is a full object with
.dtype,.shape, and vectorized operations — conceptually closer to the C++11std::array/std::vectorcontainers the lecture introduces next.
An array in C++ can also be multi-dimensional, i.e., an array of arrays of arrays...:
%%cpp
(int[][2]){{1,2},{3, 4}} // 2D array(int[2][2]) { { 1, 2 }, { 3, 4 } }
%%cpp
sizeof((int[][2]){{1,2},{3, 4}})(unsigned long) 16
%%cpp
(int[][2][3]){{{1,2,3},{4,5,6}},{{7,8,9}, {10,11,12}}} // 3D array(int[2][2][3]) { { { 1, 2, 3 }, { 4, 5, 6 } }, { { 7, 8, 9 }, { 10, 11, 12 } } }
The memory for a compound literal is statically allocated, and so the length must be fixed at compile time:
%%cpptutor
int main() {
auto d=3u; // mutable
(int[d]){1,2,3}; // fails: `d` is not a constant
return 0;
}%%cpp
constexpr auto d=3u;
(int[d]){1,2,3} // works: `d` is a compile-time constant(int[3]) { 1, 2, 3 }
%%cpptutor
int main() {
const unsigned d=1+rand()%3;
(int[d]){}; // fails: `d` is not known at compile time
return 0;
}%%cpp
const auto d=3u; // also a compile-time constant due to compiler optimization
(int[][d]){{1,2},{3, 4}}(int[2][3]) { { 1, 2, 0 }, { 3, 4, 0 } }
Array Declaration¶
How to declare an array variable?
Consider the following character array:
%%cpp
(const char[4]){'a','b','c'}(const char[4]) "abc"
The following somehow fails to assign it to a variable:
%%cpp
auto s = (const char[4]){'a','b','c'};
s(const char *) "\x08" "F"
input_line_102:2:11: warning: temporary whose address is used as value of local variable 's' will be destroyed at the end of the full-expression [-Wdangling]
auto s = (const char[4]){'a','b','c'};
^~~~~~~~~~~~~~~~~~~~~~~~~~~~
This is unlike the initialization/assignment from string literal:
%%cpp
auto s = "abc";
s(const char *) "abc"
It also works if we bind the reference of the compound literal:
%%cpp
auto &s = (const char[4]){'a','b','c'};
s // the type of s is const char[4] instead of const char *(const char[4]) "abc"
There are no issues if the compound literal is passed as an argument:
%%cpp
void print_s(const char * s) {
cout << s << '\n';
}%%cpp
print_s((const char[4]){'a','b','c'});abc
Why not initialize a variable with compound literal?
Compound literals are short-lived and therefore not suitable to be assigned to a variable. E.g., the above code fails to give s[0] because a compound literal is destroyed at the end of the full-expression. Indeed, compound literals, as defined in C99, are not a standard feature of C++. While many C++ compilers, such as GCC and Clang, support compound literals as an extension, the behavior of compound literals can differ between C and C++. For instance, the lifetime of a compound literal in C++ typically extends only until the end of the full expression in which it is used, which is shorter than in C.
%%hermes
Explain why the following fails to retain the value of the array:
auto s = (const char[4]){'a','b','c'};
but the followings can:
auto &s = (const char[4]){'a','b','c'};
auto s = "abc";Hermes
All three initializers are const char[4] arrays, and the fate of each s hinges on two rules: how auto deduces an array and the storage duration of the object the pointer/reference points at.
1. auto s = (const char[4]){'a','b','c'}; — decays, and to something short-lived
Two things happen at once:
Deduction decays. For a
const char[4]lvalue,autodoes not deduce the array type — it applies the standard array-to-pointer conversion, sosis deduced asconst char*, notconst char[4]. (sizeof s == 8, not4;std::is_same_v<decltype(s), const char*>holds — verified with g++ and clang++.) Sosis never an array; it’s a pointer to the array object.The object dies at the end of the full-expression. A compound literal in an expression context designates an unnamed object with automatic storage duration: its lifetime ends when the full-expression (the
;-terminated declaration) finishes. That’s why clang++ actually diagnoses it:warning: temporary whose address is used as value of local variable 's' will be destroyed at the end of the full-expression [-Wdangling]After the statement,
spoints at an array whose lifetime has ended — a dangling pointer. Reading through it (e.g. printing it) is undefined behavior; it looks fine because nothing has overwritten the memory yet.
2. auto &s = (const char[4]){'a','b','c'}; — binds to the array itself
With auto &, reference binding prevents the decay: s is deduced as const char(&)[4] (a reference to a const char[4], matching the type comment in your cell). No pointer is formed, so the value that matters is the array itself, and printing works because the string data is contiguous. The important caveat — which your own kernel input already shows — is that this object is still automatic storage duration, so s is equally dangling after the full-expression. It “retains the value” only in the same fragile sense as case 1: it works in practice because nothing has clobbered the stack slot, not because the standard blesses it. (g++ compiled it with no -Wdangling here because the decay form is what it pattern-matches on, not because the lifetime is valid.)
3. auto s = "abc"; — decays, but to something that lives forever
"abc" is a string literal of type const char[4] (sizeof("Ava") cell), but unlike a compound literal it has static storage duration. The same decay happens — s is again const char* — yet the pointer targets a literal object that lives for the entire program, so s stays valid.
Summary: the deduction is identical in (1) and (3) (const char*) and different in (2) (const char(&)[4]); the real discriminator is the storage duration of the object — static for "abc", automatic for both compound literals. So (3) is the only one of the three that is genuinely valid to keep around; (2) only appears to work, and (1) is UB waiting to happen (clang++ warns about it).
To declare an array of double, simply add the square brackets [] after the variable name to designate the size of the array:
%%cpp
int v[3];
v(int[3]) { 0, 0, 0 }
Since v has a static storage duration, its elements are initialized to 0.0 by default.
An array can also be initialized by a form of list initialization called aggregate initialization:
%%cpp
int u[3]{1};
int v[]{0,1,0}, (&w)[3]=u;
// int v[]; // fails: needs an explicit size or an initializer
// int w[3]=u; // fails: array initializer must be an initializer list. Why?
v(int[3]) { 0, 1, 0 }
In the above declaration:
u[3]{1}initializes the first element as1, and the subsequent elements are initialized as0by default.v[]is an array of unknown bound. Such an array can be initialized with a non-empty list initializer such as{1,0,0}, from which the compiler can deduce how much space to allocate.
Solution to Exercise 3
int v[];fails because it is missing an explicit size or an initializer for the compiler to determine how much space to allocate.int w[3]=u;fails because an array initializer must be an initializer list. C-style array is not a first-class citizen as it is not directly assignable to a variable.
Array as Pointer¶
After an array has been declared, the same brackets [] are used as the member access operator to access or modify individual elements. E.g., the following function prints a 3D vector represented as an array of three elements:
1 2 3void print_vector3_(const auto v[3]) { cout << format("[{}, {}, {}]\n", v[0], v[1], v[2]); }
Program 3:Print a 3D vector represented as an array of three elements.
%%cpp
void print_vector3_(const auto v[3]) {
cout << format("[{}, {}, {}]\n", v[0], v[1], v[2]);
}
print_vector3_((int[]){1,2,3});[1, 2, 3]
%%cpp
{
int u[3];
print_vector3_(u);
}[0, -700535568, 25847]
%%cpp
{
int u[3] {};
print_vector3_(u);
}[0, 0, 0]
Solution to Exercise 4
The first cell does not initialize
ubecause the declaration has no initializer, andudoes not have static storage duration since it is declared in a block scope.The second cell initializes the elements of
uto0by default since an initializer list is given even though it is empty.
The definition of print_vector3_ is unsafe because it can cause buffer overrun:
%%cpp
print_vector3_((int[]){1,2}); // 😈: What is that last element...[1, 2, -2124132280]
Even though the input array only has two elements, print_vector3_ prints the element after the second one, accessing an out-of-bound memory location!
void print_vector3_(const auto v[3]) {
... format(... v[2]);
}If the input array is passed by reference instead, the size is enforced:
1 2 3void print_vector3(const auto (&v)[3]) { // pass by constant reference cout << format("[{}, {}, {}]\n", v[0], v[1], v[2]); }
Program 4:Input array of three elements passed by reference.
%%cpp
void print_vector3(const auto (&v)[3]) {
cout << format("[{}, {}, {}]\n", v[0], v[1], v[2]);
}
print_vector3((int[]){1,2,3});
// print_vector3((int[]){1,2}); // fails since double[3] is expected.[1, 2, 3]
%%hermes
In the above code, why pass by reference works but pass by value doesn’t?Hermes
The short answer: v[3] in a parameter declaration doesn’t pass an array by value at all — it decays to a pointer, and the [3] bound is silently dropped. A reference parameter is the only form that keeps the array’s full type, so it’s the only one that can enforce the size.
Why print_vector3_(const auto v[3]) “fails”
Arrays aren’t copyable objects in C++, so there’s no such thing as passing an array by value. When a parameter is declared T[3], the array-to-pointer conversion applies: the function’s actual parameter is const T*. Here auto deduces const int* — verified with static_assert(std::is_same_v<decltype(v), const int*>), and sizeof v inside the function is 8 (a pointer), not 12 (three ints). g++ even diagnoses it: -Wsizeof-array-argument: "‘sizeof’ on array function parameter ‘v’ will return size of 'const int’"*. The [3] in the declaration is pure documentation.
Consequence: the compiler only requires a pointer to int, so it happily accepts (int[]){1,2} — a 2-element array. The call compiles, and the out-of-bounds read of v[2] is undefined behavior, exactly the “😈” cell above; under ASan it’s confirmed as a real bug:
ERROR: AddressSanitizer: stack-buffer-overflow
#0 void print_vector3_<int>(int const*) ...
SUMMARY: stack-buffer-overflow in void print_vector3_<int>(int const*)Note the mangled signature — print_vector3_<int>(int const*) — the template instantiated to a pointer parameter.
Why print_vector3(const auto (&v)[3]) works
A reference-to-array parameter doesn’t decay. auto deduces const int(&)[3] (verified: static_assert(std::is_same_v<decltype(v), const int(&)[3]>), and sizeof v inside is 12). So the argument must actually be an int[3] — a 2-element array is a different type, and the commented-out call in the cell is rejected at compile time with:
error: no matching function for call to 'print_vector3(int [2])'
note: template argument '1' does not match '2'In one line: the by-value form carries only the element type (a const int* has forgotten how many ints there are), while the by-reference form carries the complete array type int[3] — bound included — so the size check moves from “silently UB” to “compile error.” This is the same reason the lecture’s cross(const double (&u)[3], ...) and dot(const double (&v)[], ...) take reference-to-array parameters: to get the type system to enforce (or, with [], deliberately suspend) the length.
C++23 adds std::println in
<print>, which formats an entire array or vector in a single call — no
manual loops and no std::format.
%%cpp
#include <print>%%cpp
std::println("{}", (int[]){1});
std::println("{}", (int[]){1,2,1});
std::println("{}", (int[]){1,3,3,1});[1]
[1, 2, 1]
[1, 3, 3, 1]
std::println appends a newline automatically, and std::print is the
variant without one.
%%cpp
std::print("{}, ", (int[]){1});
std::print("{}, ", (int[]){1,2,1});
std::print("{}", (int[]){1,3,3,1});[1], [1, 2, 1], [1, 3, 3, 1]1D Array¶
An array can be implicitly converted to a pointer that references the first element of the array:
%%cpp
int u[]{1,2,3};
*u // first element(int) 1
Indeed, the address of an array is the same as the address of its first element:
%%cpp
cout << format("&u : {:p}\n", static_cast<void *>(&u));
cout << format("&u[0] : {:p}\n", static_cast<void *>(&u[0]));&u : 0x7791e04b3000
&u[0] : 0x7791e04b3000
Subsequent elements are stored in the subsequent locations:
%%cpp
cout << format("&u[1] : {:p}\n", static_cast<void *>(&u[1]));
*(u+1) // second element&u[1] : 0x7791e04b3004
(int) 2
%%cpp
cout << format("&u[2] : {:p}\n", static_cast<void *>(&u[2]));
*(u+2) // third element&u[2] : 0x7791e04b3008
(int) 3
The array can be assigned to a pointer or to another array by lvalue reference:
%%cpp
int u[]{1,2,3};
auto *v=u, (&w)[3]=u;
print_vector3_(v);
print_vector3(w);[1, 2, 3]
[1, 2, 3]
%%cpp
cout << format("sizeof(u): {}\n", sizeof(u));
cout << format("sizeof(v): {}\n", sizeof(v));
(u==v)sizeof(u): 12
sizeof(v): 8
(bool) true
Solution to Exercise 5
sizeof calculates the size of the array u at compile time because its length is known. The size is 12 because the array contains 3 int, each of size 4.
However, when u is assigned to a pointer v, the size information is lost — sizeof(v) only returns the size of the pointer itself (typically 8 bytes on a 64-bit system), not the size of the array it points to.
In other words, the subscript operation v[i] is equivalent to *(v+i) since elements of an array are stored in consecutive memory locations. What a clever trick! This implements an array efficiently without any overhead. Even the size of the array, which must be a compile-time constant, needs not be stored as meta data in the executable!
%%hermes
Explain briefly how memory is statically allocated to an array in C++, and
whether the size of the array needs to be stored in the executable?Hermes
How memory is statically allocated to an array
“Static” here means the storage is decided at compile time, not at run time — no heap call, no length negotiation. The compiler knows n × sizeof(element) up front (which is why the bounds in this lecture must be compile-time constants, e.g. the constexpr requirement in the (int[d]){1,2,3} cell), and it reserves a fixed, contiguous block:
Global /
staticstorage duration → the block is part of the program’s data segment, laid out when the OS loads the executable. An initialized global likeint v[3]{1,2,3}goes into.data, with its initial values embedded literally in the binary (I verified: the bytes01 00 00 00 02 00 00 00 03 00 00 00appear in.data). An uninitialized global likeint w[5]goes into.bss, which is aNOBITSsection — the executable stores zero bytes for it; the loader just reserves that region in memory and the kernel zero-fills it on load.Automatic (stack) storage duration → the compiler reserves the block in the function’s stack frame with a single prologue adjustment, and the array lives at a fixed offset from the frame pointer. My
-O0compile ofint local[4]{7,8,9,10}shows it exactly: the frame is carved out,local[2]is addressed asmovl -24(%rbp), %eax(baserbp-32plus2 × 4bytes). No runtime allocation — just a fixed region of the frame.
Accessing an element is the same story: v[i] compiles to base address + i × sizeof(T), all static arithmetic (v[1] → movl 4+v(%rip), %edx in my test).
Is the size stored in the executable?
No — at least not as a length attached to the data. There is no hidden “count” field in front of the array’s bytes. The size lives in two places that are not the array itself:
The variable’s type.
vis of typeint[3]; the bound is part of the type, which is whysizeof(v)works at compile time in the program (thesizeof(u) == 12in the cell above) even though the stored bytes don’t say “3” anywhere.For globals, the ELF section header carries a
Sizefield (myreadelfshows.bssof size0x28), which tells the loader how much memory to reserve/zero — but that’s the size of the whole section, not a per-array length stored next to the array.
Because the array’s bytes carry no length with them, the extent only exists in the type. The moment the array decays to a bare pointer (int*) — e.g. when passed by value — the size is gone with the type, and the compiler can no longer check it. That’s precisely why print_vector3_(const auto v[3]) couldn’t enforce the size (the parameter is really const int*, and sizeof v there is 8, not 12), and why the reference form const auto (&v)[3] works: a reference-to-array keeps the full int[3] type — and therefore its size — intact.
While an array can be assigned to a pointer, it cannot be assigned to an array:
double u[3]={1,2,3}, v[3];
v = u; // fails: array type 'int[3]' is not assignable%%hermes
Explain briefly why a statically allocated array is not assignable?Hermes
Because an array isn’t a value that can be replaced — it’s a fixed region of memory whose identity is nailed down at declaration, and the language deliberately refuses to pretend = does more than that.
What v = u; would have to mean, and why each reading fails
“Make
vrefer tou’s memory” — impossible. As established in the previous answer, static allocation fixes the array’s address and extent at compile/load time:&vis a compile-time constant, and other entities can be bound to it (the lecture’s own cell 77 declares(&w)[3]=u—wis an alias forufor life). Re-pointingvwould silently break everything aliased to it. Arrays are the one type you can never convert into — decay only ever goes array → pointer, never the reverse — so the left-hand side of an assignment can never “become” an array.“Copy
u’s elements intov” — also refused, because=would then be a lie. For every other type, copy-assignment replaces a value in O(1); for arrays it would hide an O(n) element copy. And the array’s bytes carry no length with them (the size exists only in the type, per the previous answer), so the operation is defined only through the type — the language would rather make you say it explicitly.
So g++ rejects the cell’s example outright — I verified with its exact types:
error: invalid array assignment
5 | v = u;That’s not a missing operator to be worked around; array assignment is explicitly ill-formed by the standard.
The pattern it fits: the rule is “you can change what’s in a statically allocated array, but not what it is.” Initialization at birth works (double u[3]{1,2,3};), binding at birth works (auto (&w)[3] = u; — that’s reference initialization, not assignment), and mutating the contents works element-by-element (v[i] = u[i];, or via pointer/reference). When you genuinely need copy-assignment semantics, C++ gives you objects that have them: std::array (a struct wrapping an array — assignable because it’s a class type with a defined copy-assignment) and std::vector (the next topic in this lecture).
When the array argument is passed by reference, the function signature is (void (*)(int (&)[3])) as expected.
%%cpp
void bar1(int (&v)[3]) {}
bar1(void (*)(int (&)[3])) Function @0x7791e049e000
at input_line_133:1:
void bar1(int (&v)[3]) {}
However, when the argument is passed by value, the function type is (void (*)(int *)):
%%cpp
void bar2(int v[3]) {}
bar2(void (*)(int *)) Function @0x7791e049c000
at input_line_134:1:
void bar2(int v[3]) {}
In other words, an array cannot be passed by value without decaying to a pointer. In particular, the size information is lost as the parameter decays from int v[3] to a pointer int *.
We can now answer the question:
The size of an input array is enforced when the array is passed by reference instead of value. Why?
Even though the parameter of
print_vector3_is declared as an arrayauto v[3], it decays to a pointerauto *vso that the address of the first element of the array can be assigned to the parameter as a pointer. However, the size information is lost in doing so. In other words, the parameter is effectively declared with an unknown bound (auto v[]), which allows the compiler to happily accept an array of any size. The function does not know the array’s length, potentially leading to a buffer overrun.When the parameter is passed by reference (
double (&v)[3]) toprint_vector3, however, the compiler does not treat it asdouble *v, and so the size information is retained.
Multi-Dimensional Array¶
A multi-dimensional array can also decay to a pointer in the same way. Consider the following 2-by-2-by-3 array:
%%cpp
int v[][2][3]{
// v[0] or *v
{
// v[0][0] or **v
{
// v[0][0][0] or *(**v + 0)
1,
// v[0][0][1] or *(**v + 1)
2,
// v[0][0][2] or *(**v + 2)
3
},
// v[0][1] or *(*v + 1)
{
// v[0][1][0] or *(**v + 3)
4,
// v[0][1][1] or *(**v + 4)
5,
// v[0][1][2] or *(**v + 5)
6
}
},
// v[1] or *(v + 1)
{
// v[1][0] or *(*v + 2)
{
// v[1][0][0] or *(**v + 6)
7,
// v[1][0][1] or *(**v + 7)
8,
// v[1][0][2] or *(**v + 8)
9
},
// v[1][1] or *(*v + 3)
{
// v[1][1][0] or *(**v + 9)
10,
// v[1][1][1] or *(**v + 10)
11,
// v[1][1][2] or *(**v + 11)
12
}
}
};
v(int[2][2][3]) { { { 1, 2, 3 }, { 4, 5, 6 } }, { { 7, 8, 9 }, { 10, 11, 12 } } }
%%hermes
Why can we skip the bound of the first (instead of the last) dimension of a multi-dimensional array?Hermes
Because the outermost bound is the only dimension that isn’t needed to define the type of the array’s elements — and that’s exactly the dimension the compiler can recover or discard. It works in both places the question touches: declarations (int v[][2][3]{...}) and decay (int v[2][3] as a parameter → int (*)[2][3]).
In a declaration: the outer bound is inferable, the inner ones aren’t
int v[][2][3] declares an array of int[2][3]. The inner bounds [2][3] define the element type (a 24-byte block); the outer bound is just a count of blocks. With an initializer, the compiler counts the ints (12) and divides by the element size (6 ints per int[2][3]) → outer bound 2, inferred. (My probe: sizeof v == 48, i.e. 2 × 24.)
Skip an inner bound instead and you’re stuck: you can’t count “how many blocks” unless you know how big one block is. The element type itself would be ill-defined (int[][] has no meaning) — the last bound must be known first, which is circular. The 1D case is the same rule: int v[]{1,2,3} works because the element type int is fully known and the bound is just a count.
In decay: the conversion happens once, at the outer level, and the inner bounds are load-bearing
Array-to-pointer decay strips exactly one layer: int[2][2][3] → int (*)[2][3] — a pointer to int[2][3] (verified: my function’s mangled signature is _Z1fPA2_A3_Ki = f(int (*)[2][3]), and sizeof *p == 24). It stops there because the result is a pointer, and pointers don’t decay — only arrays do.
The inner bounds must survive the decay because subscript arithmetic is built from them. p[i][j][k] addresses as base + i·24 + j·12 + k·4 — the 24 and 12 strides are 2×3×sizeof(int) and 3×sizeof(int), both from the inner dimensions. My -O0 disassembly of p[1][0][2] shows precisely this:
movq -8(%rbp), %rax # p
addq $24, %rax # i=1 → +1×24
movl 8(%rax), %eax # j=0, k=2 → +8 (= 0×12 + 2×4)The outer bound — the “2” in int v[2][2][3] — appears nowhere in the addressing. p[i] is plain pointer arithmetic on a pointer whose element size (24) it already knows; how many elements exist is irrelevant to computing any one of them.
One-line version: the inner bounds define what the elements are (their size, the strides), so they can’t be skipped; the outer bound is only how many elements there are, which the initializer can count — and which pointer arithmetic never consults. That’s why int v[][2][3] compiles while int v[2][] doesn’t, and why the decayed parameter int (*p)[2][3] keeps everything after the first bracket.
Solution to Exercise 6
This is because the array is runtime constant but not compile-time constant.
v defined above can be passed into a function with a parameter declared as int (&v)[2][2][3], int v[][2][3], or int (*v)[][3].
%%cpp
void foo1(int (&v)[2][2][3]) {}
foo1(v);
foo1(void (*)(int (&)[2][2][3])) Function @0x7791e04a1000
at input_line_136:1:
void foo1(int (&v)[2][2][3]) {}
%%cpp
void foo2(int v[][2][3]) {}
foo2(v);
foo2(void (*)(int (*)[2][3])) Function @0x7791e0492000
at input_line_137:1:
void foo2(int v[][2][3]) {}
%%cpp
void foo3(int (*v)[2][3]) {}
foo3(v);
static_cast<int (*)[2][3]>(v);
foo3(void (*)(int (*)[2][3])) Function @0x7791e0490000
at input_line_138:1:
void foo3(int (*v)[2][3]) {}
Solution to Exercise 7
The arrays v point to are not pointers, and they cannot be converted implicitly to pointers either, unlike the array v.
The 3D array v is actually stored like a 1D array **v in memory as shown below:
%%cpp
print_vector3(**v);
print_vector3(*(*v+1));
print_vector3(*(*v+2));
print_vector3(*(*v+3));[1, 2, 3]
[4, 5, 6]
[7, 8, 9]
[10, 11, 12]
To print all elements in one go:
%%cpp
for (auto l=0u; l<2*2*3; l++)
cout << (**v)[l] << ' '; // `(**v)[l]` is the same as `(&v[0][0][0])[l]`1 2 3 4 5 6 7 8 9 10 11 12 **v is the same as &v[0][0][0], i.e., the address to the first element of the 3D array.
This is equivalent to printing each element of the 3D array using nested for loops:
%%cpp
constexpr unsigned d[]{2,2,3};
for (auto i=0u; i<d[0]; i++)
for (auto j=0u; j<d[1]; j++)
for (auto k=0u; k<d[2]; k++)
cout << v[i][j][k] << ' ';1 2 3 4 5 6 7 8 9 10 11 12 v[i][j][k] is the same as *(**v+i*(d[1]*d[2])+j*(d[2])+k):
%%cpp
for (auto i=0u; i<d[0]; i++)
for (auto j=0u; j<d[1]; j++)
for (auto k=0u; k<d[2]; k++)
cout << *(**v+i*(d[1]*d[2])+j*(d[2])+k) << ' ';1 2 3 4 5 6 7 8 9 10 11 12 %%hermes
Explain the difference between F-ordering and C-ordering, and whether one is
better than the other.Hermes
They are two conventions for laying out the same multi-dimensional array in a single block of contiguous memory, and they differ in which index changes fastest in memory.
C-ordering (row-major): the last index is contiguous. In the lecture’s int v[2][2][3], the three elements v[i][j][0..2] sit next to each other, then v[i][j+1]'s three, then the next i. This is exactly the formula the cell above derives: v[i][j][k] = *(**v + i*(d[1]*d[2]) + j*d[2] + k) — k multiplies 1 (fastest), j multiplies 3, i multiplies 6 (slowest). It’s also why skipping the first bound is legal in decay: the outermost dimension’s stride is the last thing the address arithmetic needs.
F-ordering (column-major, from Fortran): the first index is contiguous, i.e. v[0..1][j][k] sit next to each other. The offset formula is the mirror image: i*1 + j*d[0] + k*d[0]*d[1] — the later (“column”) index increments slower, as the lecture’s footnote says.
Same logical array, same bytes, different address formulas. Verified with numpy on the lecture’s 2×2×3 values (1..12):
C-order flat list : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
F-order flat list : [1, 7, 4, 10, 2, 8, 5, 11, 3, 9, 6, 12](Both agree v[1][0][2] is the 9 — the value is the same; only where each value lives differs: offset 8 in C, 9 in F.)
Is one better? No — it’s a tie by design. Performance depends only on which axis your inner loop traverses, not on the convention itself:
In C-order, a loop
for k { ... v[i][j][k] }(innermost index) reads memory sequentially — cache-friendly; the same loop in F-order would stride 6 ints at a time.In F-order, the innermost loop over the first index is the cache-friendly one.
Since you can always write the innermost loop over the last (or first) index respectively, either convention achieves optimal locality for a given algorithm — you just pick whichever matches the language’s convention. The real cost of the “wrong” one is only when you can’t change the loop structure (e.g. calling an existing BLAS routine that assumes its own layout), where one layout may need a transpose/copy. That’s why the ecosystem is split exactly along language lines — C/C++ row-major, Fortran column-major — and each side considers its convention the sensible one.
Array Container¶
Program 1 implements the cross product using C-style fixed array:
void cross(..., ..., double (&w)[3]) {
w[0] = ...
w[1] = ...
w[2] = ...
}Isn’t it strange that the function takes the cross product w as an input argument instead of returning it as an output?
To understand the issue, consider a simplified function that returns an array:
%%cpp
auto foo() {
double w[1]{123};
cout << *w << " @" << static_cast<void *>(w) << '\n';
return w;
}
auto w=foo()input_line_65:4:12: warning: address of stack memory associated with local variable 'w' returned [-Wreturn-stack-address]
return w;
^
123 @0x7fffa7cff7a8
(double *) @0x7fffa7cff470
The return type is double * instead of double[1], showing that an array decays to a pointer when returned by a function.
Note however that there is a warning about the address of stack memory ... returned. To see the value it points to:
%%cpp
cout << *w << " @" << static_cast<void *>(w) << '\n';
w[0] // equal 1?6.95328e-310 @0x7fffa7cff7a8
(double) 0.0000000
%%hermes
Explain briefly what stack memory is in C++ and why returning address of stack
memory gives a warning?Hermes
What stack memory is
The stack is the per-thread region where automatic-storage-duration objects live — function parameters and local variables (like the lecture’s double w[1]{123} in foo). “Automatic” means the storage is created by advancing a single pointer (the stack pointer) when the function is called and reclaimed by moving it back when the function returns. There’s no allocation call, no bookkeeping, no dynamic sizing — which is why it’s fast and safe, and why every local’s address is a fixed offset from the frame pointer (the movl $123, -8(%rbp)-style code we saw when discussing static allocation of arrays).
The crucial property: the region is valid only while the function’s frame exists. On return, the compiler just rewinds the stack pointer — the memory isn’t even explicitly freed, it’s simply reused for whatever is called next.
Why returning the address warns
The kernel’s exact warning (reproduced with the same clang here):
warning: address of stack memory associated with local variable 'w' returned [-Wreturn-stack-address]
return w;The chain is: return w; decays the array w[1] to a double* pointing at the local w (which is why foo’s return type came out as double*, not double[1]) — but w lives in foo’s frame, which is destroyed by the return itself. So the function hands back a pointer whose referent stops existing on the very statement that hands it out: a dangling pointer. Reading or writing through it afterward is undefined behavior, and the compiler can prove that from the language rules alone, so it warns.
The notebook’s caution cell is this in action: the global w and the local w print the same address (@0x7ffee437e298) but different values — 123 inside foo, then 1.06088e-259 / 0.0 afterward. Nothing was ever “changed” — the stack slot was simply recycled by the next function call (the cout chain) that overwrote it. My g++ run makes the same point more dramatically: the g++ 15 build warns identically (-Wreturn-local-addr) and then segfaults on reading the dangling pointer, because the g++ side had already overwritten that stack region.
The fix follows the storage-duration rules already covered in this lecture: give the data outliving storage — return a value (return w[0]; for a scalar), copy into the caller’s array (the lecture’s cross(u, v, w) pattern), or allocate with static / new (heap) when the object genuinely must outlive the function.
As a comparison, consider the following function, which returns a locally defined double?
%%cpp
auto foo() {
double w=123.;
return w;
}
auto w=foo()(double) 123.00000
Why the above code does not trigger any warning even though the variable w has automatic storage duration?
w has automatic storage duration?Due to the return value optimization, the result returned by a function is constructed directly into the function call’s result object, which is therefore in the scope where the function is called. However, if w is a pointer to an array, it is the pointer w itself—not the array it points to—that is constructed into the caller’s result object. This explains why the pointer/address static_cast<void *>(w) remains the same but *w has changed.
One way to return an array is to declare it as static:
%%cpp
auto foo() {
static const double w[1]{123};
return w;
}
auto w=foo();
w[0](const double) 123.00000
The memory location w[0] has static storage duration, and therefore remains available throughout the execution of the program. However, the function is not thread-safe unless w is declared to be constant.
How to return a mutable array safely?
%%cpp
auto foo() {
auto w=new double[1]{123};
return w;
}
auto w=foo();
++w[0](double) 124.00000
The above function returns a mutable array by dynamically allocating memory on the heap memory using the new expression. It is thread-safe because every call to the function creates a distinct heap-allocated array, and so there is no shared state between threads. The heap memory remains accessible after the function call because it is not tied to the function’s stack frame.
Since every function call creates a new array, and the memory is not released after the call completes, the caller is responsible for freeing the memory with delete[].
%%cpp
delete[] w;
// w // fails as the memory for w is released.
// delete[] w; // fails for the same reason.
w=nullptr; // prevents accidental reuse or double deletion.Solution to Exercise 8
The array
vcannot be declared with sizedbecausedis not a compile-time constant.vshould not be returned because its elements are in stack memory that is automatically cleaned up after the function call completes.The array
xcan only be initialized with an initializer list, not a pointer.
Thanks to object-oriented programming, C++11 defines a value-type container std::array from <array>:
%%cpp
auto foo() {
auto w=array<double, 1>{123};
return w;
}
auto w=foo();
++w[0];
w(std::array<double, 1> &) { 124.00000 }
This works as if w were a simple data type:
The array object is correctly returned because it is directly constructed into the caller’s result object via the return value optimization.
The function is thread-safe since each call creates a distinct array object.
The memory is released automatically when the result object goes out of scope.
We can now modify Program 1 to return the cross product:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22/** * @brief Computes the cross product of two 3D vectors. * * This function calculates the cross product of two 3-dimensional (3D) vectors `u` and `v`, * both represented as `std::array<double, 3>`. The result is a new vector that is * perpendicular to both `u` and `v`, following the right-hand rule. * * @param u The first input vector. * @param v The second input vector. * @return A 3D vector representing the cross product of `u` and `v`. * @example * auto w = cross({1,0,0}, {0,0,1}); // (vector3D &) { 0.0, -1.0, 0.0 } */ using vector3d = std::array<double, 3>; // type alias vector3d cross(const vector3d &u, const vector3d &v) { vector3d w; w[0] = u[1]*v[2] - u[2]*v[1]; w[1] = u[2]*v[0] - u[0]*v[2]; w[2] = u[0]*v[1] - u[1]*v[0]; return w; }
Program 5:Compute the cross product using std::array from <array>.
For convenience, the code defines a type alias vector3d for the type std::array<double, 3>:
%%cpp
using vector3d=std::array<double, 3>;The type alias can then be used to declare the parameters and return value:
%%cpp
vector3d cross(const vector3d &u, const vector3d &v) {
vector3d w;
w[0] = u[1]*v[2] - u[2]*v[1];
w[1] = u[2]*v[0] - u[0]*v[2];
w[2] = u[0]*v[1] - u[1]*v[0];
return w;
}
auto w = cross({1,0,0}, {0,0,1});
w(vector3d &) { 0.0000000, -1.0000000, 0.0000000 }
The array container w is defined within the function and returned to the caller, just like other simple data types:
vector3d cross(..., ...) {
vector3d w;
w[0] = ...
w[1] = ...
w[2] = ...
return w;
}As another example demonstrating the benefit of array container, the following code implements the wheel factorization to check whether a number n is prime or not:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47/** * @brief Determines whether a given unsigned long long integer is prime using wheel factorization. * * This function checks for primality by first testing divisibility against a small set of base primes {2, 3, 5}. * It then uses wheel factorization based on the LCM of these primes (30) to efficiently skip non-candidate divisors. * Candidate divisors are generated by adding coprime offsets to multiples of 30. * * @param n The number to check for primality. * @return true if n is prime, false otherwise. * * @details * - The wheel is constructed using the coprimes of 30: {1, 7, 11, 13, 17, 19, 23, 29}. * - These offsets are added to each multiple of 30 to generate candidate divisors. * - The function stops checking once the candidate divisor exceeds sqrt(n). * * @note This method avoids redundant checks for multiples of 2, 3, and 5, improving efficiency for large numbers. * * @example * bool result = is_prime(97); // returns true */ bool is_prime(const unsigned long long n) { if (n < 2) return false; // Base primes used for initial divisibility check auto basis = to_array<unsigned long long>({2, 3, 5}); // Offsets that are coprime to 30 (LCM of 2, 3, 5) auto offsets = to_array<unsigned long long>({1, 7, 11, 13, 17, 19, 23, 29}); const unsigned long long m = 30; // LCM of base primes const unsigned long long limit = sqrt(n); // No need to check beyond sqrt(n) // Check divisibility by base primes for (auto p : basis) if (p > limit) return true; // No need to check further else if (n % p == 0) return false; // Use wheel factorization to check remaining candidates unsigned long long d; for (unsigned long long i = 0uLL;; i += m) { for (auto j : offsets) { d = i + j; if (d > limit) return true; // No divisor found up to sqrt(n) else if (n % d == 0 && d > 1) return false; // Found a divisor } } }
Program 6:Check whether a non-negative integer is prime.
%%cpp
bool is_prime(const unsigned long long n) {
if (n<2) return false;
auto basis=to_array<unsigned long long>({2,3,5});
auto offsets=to_array<unsigned long long>({1, 7, 11, 13, 17, 19, 23, 29});
const unsigned long long m=30, limit=sqrt(n);
for (auto p : basis)
if (p > limit) return true; else
if (n % p == 0) return false;
unsigned long long d;
for (unsigned long long i=0uLL;; i+=m)
for (auto j : offsets)
if ((d=(i+j))>limit) return true; else
if (n%d==0 && d>1) return false;
}%%cpp
for (auto n: views::iota(0, 100)) if (is_prime(n)) cout << n << ' ';2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 Vector Container¶
Recall that the declaration const auto v[3] in void print_vector3_(const auto v[3]) is the same as const auto v[], which decays to const auto *v. Since the size information is lost, we might as well define a more generate function below to print an arbitrarily long vector:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23/** * @brief Prints the contents of a vector-like array to standard output in formatted style. * * This function takes an array of elements and its size, then prints the elements * in a comma-separated list enclosed in square brackets. The formatting uses `std::format` * for consistent output. * * @tparam T Type of the elements in the array (deduced automatically). * @param v Array of elements to print. * @param n Number of elements in the array. * * @note If the array is empty (`n == 0`), nothing is printed. * @example * int arr[] = {1, 2, 3}; * print_vector(arr, 3); // Output: [1, 2, 3] */ void print_vector(const auto v[], const size_t n) { if (n) { cout << format("[{}", v[0]); for (size_t i = 1; i < n; ++i) cout << format(", {}", v[i]); cout << "]\n"; }
Program 7:Print the contents of a C-style array.
%%cpp
void print_vector(const auto v[], const size_t n) {
if (n) {
cout << format("[{}", v[0]);
for (size_t i=1; i<n; ++i) cout << format(", {}", v[i]);
cout << "]\n";
}
}
print_vector((int[]){1}, 1);
print_vector((int[]){1, 2}, 2);
print_vector((int[]){1, 2, 3}, 3);
print_vector((int[]){1, 2, 3, 4}, 4);
print_vector((int[]){1, 2, 3, 4, 5}, 5);[1]
[1, 2]
[1, 2, 3]
[1, 2, 3, 4]
[1, 2, 3, 4, 5]
In the function calls, the size is passed as a separate argument n because the size information in v is lost as v decays to a pointer.
To print std::array instead:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22/** * @brief Prints the contents of a fixed-size std::array in vector format. * * This function outputs the elements of the array in the format: [x1, x2, ..., xN]. * It uses C++11's std::array from <array> and std::format for formatting. * * @tparam T Type of the elements in the array. * @tparam N Size of the array. * @param v The std::array to be printed. * @example * print_vector(array<int, 4>{1, 2, 3, 4}); * print_vector(array<int, 5>{1, 2, 3, 4, 5}); */ template <class T, size_t N> void print_vector(const array<T, N> &v) { if (N>0) { auto it=v.begin(); cout << format("[{}", *it++); for (;it != v.end(); it++) cout << format(", {}", *it); cout << "]\n"; } }
Program 8:Print the contents of an array container.
%%cpp
template <class T, size_t N>
void print_vector(const array<T, N> &v) {
if (N>0) {
auto it=v.begin();
cout << format("[{}", *it++);
for (;it != v.end(); it++) cout << format(", {}", *it);
cout << "]\n";
}
}
print_vector(to_array({1}));
print_vector(to_array({1, 2}));
print_vector(to_array({1, 2, 3}));
print_vector(to_array({1, 2, 3, 4}));
print_vector(to_array({1, 2, 3, 4, 5}));[1]
[1, 2]
[1, 2, 3]
[1, 2, 3, 4]
[1, 2, 3, 4, 5]
The above code is a template declaration which allows the function print_vector to take std::array<T, N> with any typename or class T and a fixed size N. It also uses iterators to traverse the array.
%%hermes
Explain briefly how to use iterators to traverse an array efficiently in C++.Hermes
What an iterator is here. For std::array (and for C-style arrays, via std::begin/std::end), an iterator is a pointer into the data — v.begin() is the address of the first element, v.end() the address one-past-the-last. So “traverse with iterators” means: start at begin(), and while it != v.end(), use *it and advance with ++it.
The idiom (exactly the pattern in the lecture’s print_vector template above):
for (auto it = v.begin(); it != v.end(); ++it)
cout << *it;Two details worth noting from the lecture’s own code: the empty case is handled for free — if begin() == end() the loop body simply never runs — and that’s why print_vector prints the first element separately before the loop: dereferencing *it when v is empty would read out of bounds, so it guards N>0 first.
Why it’s “efficient” rather than just idiomatic.
Iterator arithmetic is pointer arithmetic. For contiguous ranges
++itis a singleaddofsizeof(T)and*ita plain load — noindex × stridemultiplication, and no bounds-check (the standard’soperator[]onstd::arraylikewise checks nothing). An index loop and an iterator loop do the same memory work; the iterator form just makes the “one-past-the-end” stop condition part of the loop instead of a separate counter.It’s the interface everything else uses. Range-for,
std::for_each,std::accumulate, etc. all lower to this same begin/end protocol, so the same loop works unchanged forstd::array,std::vector, or a raw array — which is what the lecture’s templateprint_vector<T, N>exploits.Zero cost at
-O2. I verified the claim, not just the idiom: two identical summing functions, one written withbegin()/end()iterators, one withfor (i = 0; i < N; ++i)— at-O2the compiler inlines and unrolls both to the same 4-instruction body (4addsds, no loop at all). Iterator traversal over an array is indistinguishable from the index version in machine code, so you get the algorithmic flexibility of iterators without paying any traversal overhead.
The one place to be careful: iterators are not safer than indices — *it past end() is UB, same as v[i] out of range (the lecture’s buffer-overflow exercise is the index version of the same trap). The efficiency claim is “identical to the best index loop,” not “faster than it.”
Solution to Exercise 9
template provides a stronger compile-time guarantee by declaring
the argument to be
array<T, N>, which guaranteesemptyis available; andthe types of
TandNto beclassandsize_t, which ensuresarray<T, N>is a valid array type.
Solution to Exercise 10
This is because the size of a statically allocated array must be known at compile time.
A common strategy is to set the size to a large enough constant:
%%cpp
constexpr auto N=5u;
int v[N];
for (auto i=0u; i<N; ++i) v[i]=i+1;
for (auto n=1u; n<=N; ++n) print_vector(v, n);[1]
[1, 2]
[1, 2, 3]
[1, 2, 3, 4]
[1, 2, 3, 4, 5]
But what if we do not know how large is large enough at compile time? An alternative is to dynamically allocate the memory to the heap:
%%cpp
for (auto n=1u; n<=5; ++n) {
auto v=new int[5];
for (auto i=0u; i<n; ++i) v[i]=i+1;
print_vector(v, n);
delete[] v; // 😈: This line can be removed. 👨🏻🏫: No.
}[1]
[1, 2]
[1, 2, 3]
[1, 2, 3, 4]
[1, 2, 3, 4, 5]
To avoid memory leak, C++11 offers variable-length containers such as std::vector from <vector>. This is used in the following implementation of print_vector:
%%cpp
template <class T>
void print_vector(const vector<T> &v) {
if (!v.empty()) {
cout << format("[{}", v[0]);
for (auto vi: v) cout << format(", {}", vi); // range-based for loop
cout << "]\n";
}
}The print_vector function used the method empty to check whether v is an empty vector.
We can now use a for loop to modify the length of a vector:
{
vector<double> v; /* Creates an empty vector using allocator<double> internally.
No memory is allocated yet; capacity is 0. */
for (auto n = 1u; n <= 5; ++n) { // Loop to insert values 1 to 5
v.push_back(n); /* Adds 'n' to the vector.
If current capacity is full, vector allocates more memory:
- Calls allocator.allocate(new_capacity)
- Moves existing elements to new memory
- Calls allocator.deallocate(old_memory)
- Constructs new element with allocator.construct() */
print_vector(v);
}
}
/* When 'v' goes out of scope at the end of main:
- vector's destructor is called
- Calls allocator.destroy() for each element
- Calls allocator.deallocate() to free the memory block */%%cpp
{
vector<double> v;
for (auto n=1u; n<=5; ++n) {
v.push_back(n);
print_vector(v);
}
}[1, 1]
[1, 1, 2]
[1, 1, 2, 3]
[1, 1, 2, 3, 4]
[1, 1, 2, 3, 4, 5]
%%hermes
Explain briefly what allocators are in C++ and the benefits of using allocators
instead of new and delete in C++?Hermes
What an allocator is. An allocator is a type that owns the “where does memory come from” decision: it has allocate(n) / deallocate(p, n) (plus traits like value_type and rebind), and containers receive it as a second template parameter — std::vector<T, Allocator>, with std::allocator<T> as the default. The default allocator is essentially a thin, stateless wrapper around the global operator new / operator delete, so new/delete is not the only strategy — it’s just the built-in one.
Benefits over raw new/delete — the lecture’s two heap cells are exactly where the pain appears (new double[1] you must remember to delete[]; the for loop with new int[5] that leaks unless you pair every new with a delete[]):
No leaks by construction. The container owns its memory and releases it via the allocator in its destructor (RAII).
delete[] w;becomes unnecessary and therefore can’t be forgotten, doubled, or applied to the wrong pointer.Bulk, not per-element, allocation.
newgives you one allocation at a time; an allocator can hand a container a whole chunk. Measured on 10,000 elements:std::list(each node individually allocated) performs 10,000operator newcalls, whilestd::vectorwith its default allocator performs 1 (withreserve) — better locality, far less bookkeeping.Pluggable memory strategies — no code changes to the container. Swap the allocator and the same
vector/listcode runs on completely different memory: a static arena I used in the test performs the same 10,000-element fill with 0 calls to the global heap (1 arenaallocate, 40,000 bytes from a fixed buffer). That’s how embedded/real-time code avoids the heap at runtime, and how game engines use pool allocators for speed and deterministic lifetimes.Shared resources across containers (C++17
std::pmr). Apolymorphic_allocatorlets many containers draw from onememory_resource/pool — verified working: apmr::vector<int>filled from anunsynchronized_pool_resourcealongside everything above. With rawnewthere is no way to say “these 50 containers should share one slab.”
In short: new/delete hard-couple usage of memory to one strategy (the global heap) and put all release discipline on the programmer; the allocator interface pushes the strategy into a type, letting the standard containers — std::array (static, no allocator), std::vector, std::list — get safe, efficient, replaceable memory management “for free.”
The code used the method push_back to append an element to the end of the vector.
The dot product in Program 2 can also be reimplemented to use the vector container:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25using vectord = std::vector<double>; /** * @brief Computes the dot product of two vectors of type vectord. * * This function calculates the dot product of two vectors `u` and `v`. * It throws a runtime error if the vectors are not of the same length. * * @param u A vector of doubles. * @param v A vector of doubles. * @return The dot product of vectors `u` and `v` as a double. * * @throws std::runtime_error if the input vectors have different lengths. * * @example * dot({1,1,1,1}, {1,1,1,1}); // 4.0 */ auto dot(const vectord &u, const vectord &v) { if (u.size() != v.size()) throw std::runtime_error("u and v cannot have different lengths."); auto prod = 0.; for (size_t i = 0, n = u.size(); i < n; ++i) prod += u[i] * v[i]; return prod; }
Program 9:Compute the dot product using std::vector from <vector>.
%%cpp
using vectord=std::vector<double>;%%cpp
auto dot(const vectord &u, const vectord &v) {
if (u.size()!=v.size())
throw runtime_error("u and v cannot have different lengths.");
auto prod=0.;
for (size_t i=0, n=u.size(); i<n; ++i) prod += u[i]*v[i];
return prod;
}
// dot({1,1,1}, {1,1,1,1}); // fails: size mismatch
dot({1,1,1,1}, {1,1,1,1})(double) 4.0000000
The code ensures the input values u and v are valid by checking whether they have the same size using the method size.
To repeatedly calculate the norms of the vectors of all ones for different dimensions:
%%cpp
auto norm(const vectord &v) {
return sqrt(dot(v, v));
}
vectord v;
for (auto n=0u; n<10; ++n) {
cout << norm(v) << ' ';
v.push_back(1);
}0 1 1.41421 1.73205 2 2.23607 2.44949 2.64575 2.82843 3 To repeatedly calculate the dot products of orthogonal vectors for different dimensions:
%%cpp
vectord u{0}, v{0};
for (auto n=0u; n<10; ++n) {
u.insert(u.begin(),1);
v.push_back(1);
cout << dot(u, v) << ' ';
u[0]=v[v.size()-1]=0;
}0 0 0 0 0 0 0 0 0 0 The code used the method insert to insert 1 to the beginning position specified by the method begin.
%%cpp
auto zeros(const int d) {
/*
### BEGIN SOLUTION
*/
return vector<double>(d);
/*
### END SOLUTION
*/
}
// test
zeros(11)(std::vector<double>) { 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000 }
Complete the following function to implement the Sieve of Eratosthenes to return a vector container of all prime numbers less than or equal to the argument n.
%%cpp
/**
* @brief Generates all prime numbers less than or equal to a given number.
*
* This function uses the Sieve of Eratosthenes algorithm to find all prime numbers
* up to and including the input value `n`. It returns a std::vector of the primes.
*
* @param n The upper bound (inclusive) for prime number generation.
* @return std::vector<unsigned long long> A list of prime numbers ≤ n.
*/
std::vector<unsigned long long> prime_below(unsigned long long n) {
std::vector<unsigned long long> prime_numbers;
std::vector<bool> is_prime(n+1, true);
/*
### BEGIN SOLUTION
*/
for (auto i = 2uLL; i <= n; ++i)
if (is_prime[i]) {
prime_numbers.push_back(i);
if (i <= n/i)
for (auto j = i * i; j <= n; j += i)
is_prime[j] = false;
}
return prime_numbers;
/*
### END SOLUTION
*/
}
prime_below(100)(std::vector<unsigned long long>) { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97 }
%%cpp
if (prime_below(100000uLL).back() != 99991uLL) throw runtime_error("test failed");In comparison, Fortran uses a column-major ordering or F-ordering, where the index of a later dimension (known as a column index) incremented slower.