Most computing involves creating collections of values and then manipulating such collections.
A class with the main purpose of holding objects is commonly called a
container. This chapter tours the standard library's containers —
vector, list, forward_list, map,
unordered_map, and the rest — and shows how they're built: every container is a
resource handle (RAII, §6.3) over elements it owns, templates over their element type (§7.2),
and sequences exposing begin()/end() (§13.1). The choice of which
container to use is a data-layout decision with measurable consequences.
Thread running through this chapter: containers are layouts with tradeoffs,
not interchangeable boxes. vector is contiguous and grows by doubling — cheap
subscripting and traversal, but insertion moves elements and
reserve() invalidates pointers to elements. list is doubly-linked
— stable element addresses, but expensive traversal and per-node overhead (vector is often
faster even for insert/erase). map is a red-black tree (O(log n), ordered);
unordered_map is a hash table (O(1) expected, requires ==, no
order, and a good hash). The interview story: vector is the default; measure before
distrusting it; store polymorphic objects by pointer; and don't assume
[] range-checks — that's what at() is for.
Providing suitable containers for a given task and supporting them with useful fundamental
operations are important steps in the construction of any program. To illustrate, the chapter
follows a phone-book program: a list of (name, number) pairs, where
Entry (§11.5) holds one entry. Different containers make different aspects of
that program "simple and obvious."
The most useful standard-library container is vector: a sequence of elements of a
given type, stored contiguously in memory. A typical implementation is a
handle holding pointers to the first element, one-past-the-last element, and one-past-the-last
allocated space (§13.1) — plus an allocator from which the vector acquires memory (default:
new/delete, §12.7). With a slightly advanced technique, simple
allocators need no stored data inside the vector object.
vector<Entry> phone_book = { {"David Hume",123456}, {"Karl Popper",234567}, {"Bertrand Arthur William Russell",345678} };
void print_book(const vector<Entry>& book) {
for (int i = 0; i!=book.size(); ++i) cout << book[i] << '\n'; // subscripting
}
// or, since the elements form a range:
void print_book(const vector<Entry>& book) {
for (const auto& x : book) cout << x << '\n'; // range-for (§1.7)
}
Indexing starts at 0; size() gives the number of elements. When defining a
vector, we give it an initial size — and the initializer syntax matters:
vector<int> v1 = {1, 2, 3, 4}; // size is 4
vector<string> v2; // size is 0
vector<Shape*> v3(23); // size is 23; initial element value: nullptr
vector<double> v4(32, 9.9); // size is 32; initial element value: 9.9
An explicit size is enclosed in ordinary parentheses; elements default to the element type's
default value (nullptr for pointers, 0 for numbers) unless a second
argument is given.
The initial size can be changed. The most useful operation is
push_back(), which adds a new element at the end, increasing the size by one —
e.g., for (Entry e; cin>>e; ) phone_book.push_back(e);. The
standard-library vector is implemented so that growing by repeated push_back()s
is efficient:
template<typename T> class Vector {
allocator<T> alloc; // standard-library allocator of space for Ts
T* elem; // pointer to first element
T* space; // pointer to first unused (and uninitialized) slot
T* last; // pointer to last slot
public:
int size() const { return space-elem; } // number of elements
int capacity() const { return last-elem; } // number of slots available for elements
void reserve(int newsz); // increase capacity() to newsz
void push_back(const T& t); // copy t into Vector
void push_back(T&& t); // move t into Vector
};
template<typename T> void Vector<T>::push_back(const T& t) {
if (capacity()<=size()) // make sure we have space for t
reserve(size()==0 ? 8 : 2*size()); // double the capacity
construct_at(space, t); // initialize *space to t ("place t at space")
++space;
}
Allocation and relocation happen only infrequently (amortized O(1) per
push_back). Stroustrup's note on reserve(): he used to call it for
performance, "but that turned out to be a waste of effort: the heuristic used by vector is on
average better than my guesses." He now only uses reserve() explicitly to
avoid reallocation of elements when he wants to use pointers to elements — because
when reserve() moves elements to a new, larger allocation,
any pointers to those elements are invalidated and must not be used.
A vector can be copied in assignments and initializations (vector<Entry> book2 = phone_book;) — implemented by the copy constructors/assignment operators of §6.2, and assigning copies
every element. For a vector holding many elements, innocent-looking assignments can
be expensive: use references or pointers, or move operations (§6.2.2), where copying is
undesirable. The standard-library vector is very flexible and efficient —
use it as your default container, unless you have a solid reason to use
another. "If you avoid vector because of vague concerns about 'efficiency,' measure. Our
intuition is most fallible in matters of the performance of container uses."
Just about any type qualifies as an element type: built-in numeric types, user-defined types
(string, Entry, list<int>,
Matrix<double,2>), and pointers. When you insert a new element,
its value is copied into the container — the element is not a reference or a
pointer to some other object. This makes for compact containers with fast access. Crucially:
vector<Shape> vs; // No, don't — no room for a Circle or a Smiley (§5.5)
vector<Shape*> vps; // better, but see §5.5.3 (don't leak)
vector<unique_ptr<Shape>> vups; // OK
If you have a class hierarchy relying on virtual functions for polymorphic behavior, do not store objects directly in a container — slicing would discard derived parts. Store a pointer (or a smart pointer, §15.2.1).
The standard-library vector does not guarantee range checking:
book[book.size()] compiles, and "is likely to place some random value in i rather
than giving an error." Out-of-range errors are common, so Stroustrup often uses a
range-checking adaptation:
template<typename T> struct Vec : std::vector<T> {
using vector<T>::vector; // use the constructors from vector (under the name Vec)
T& operator[](int i) { return vector<T>::at(i); } // range check
const T& operator[](int i) const { return vector<T>::at(i); } // range check const objects
// begin()/end() return checked iterators (see §13.1)
};
void checked(Vec<Entry>& book) {
try { book[book.size()] = {"Joe",999999}; } // will throw out_of_range
catch (out_of_range&) { cerr << "range error\n"; }
}
at() throws out_of_range (§4.2). If the user doesn't catch an
exception, the program terminates in a well-defined manner rather than proceeding or failing
in an undefined manner. One way to minimize surprises is a main() with a
try-block as its body, providing default handlers that print to cerr. Why doesn't
the standard guarantee range checking? Because many performance-critical applications use
vectors and checking all subscripting implies a cost on the order of 10% — an
overhead that can lead people to prefer the far more unsafe built-in arrays. A range-for
avoids range errors at no cost by implicitly accessing all elements in the range; the
standard-library algorithms do the same as long as their arguments are valid. If you use
vector::at() directly, you don't need the Vec
workaround.
The standard library offers a doubly-linked list called list —
for sequences where we want to insert and delete elements
without moving other elements (insertion and deletion of phone book entries could be
common). With a linked list we tend not to use subscripting; we search:
int get_number(const string& s) {
for (const auto& x : phone_book)
if (x.name==s) return x.number;
return 0; // use 0 to represent "number not found"
}
// equivalently, with explicit iterators:
int get_number(const string& s) {
for (auto p = phone_book.begin(); p!=phone_book.end(); ++p)
if (p->name==s) return p->number; // p->m is (*p).m
return 0;
}
Every standard-library container provides begin() and end(),
returning iterators to the first and to one-past-the-last elements — the range-for is roughly
what the compiler expands the explicit loop into. Insertion and removal are easy:
phone_book.insert(p, ee); // add ee before the element referred to by p (p may be end())
phone_book.erase(q); // remove the element referred to by q and destroy it
These list examples could be written identically using vector and, "surprisingly, unless you
understand machine architecture," often perform better with a vector. A vector
performs better for traversal (find(), count()) and for sorting and
searching (sort(), equal_range()). Unless you have a reason not to,
use a vector.
The standard library also offers a singly-linked list called
forward_list. It differs from a doubly-linked list by only allowing forward
iteration — the point is to save space: no predecessor pointer in each link, and the size of
an empty forward_list is just one pointer. It doesn't even keep its
number of elements: "If you need the number of elements, count. If you can't afford to count,
you probably shouldn't use a forward_list."
Writing code to look up a name in a list of (name,number) pairs is tedious, and linear search
is inefficient for all but the shortest lists. The standard library offers a
balanced binary search tree (usually a red-black tree) called
map — in other contexts known as an associative array or dictionary. A map is a
container of pairs optimized for lookup and insertion:
map<string,int> phone_book { {"David Hume",123456}, {"Karl Popper",234567}, {"Bertrand Arthur William Russell",345678} };
int get_number(const string& s) { return phone_book[s]; }
When indexed by a value of its first type (the key), a map returns the
corresponding value of the second type (the value or mapped type).
Subscripting a map is essentially the lookup — with one crucial twist:
if a key isn't found, it is entered into the map with a default value for its value.
The default value for an integer type is 0, which conveniently represents an invalid telephone
number here. If you want to avoid entering invalid numbers, use find() and
insert() (§12.8) instead of [].
The cost of a map lookup is O(log(n)) — pretty good: for 1,000,000 elements, only about 20 comparisons and indirections. In many cases we can do better with a hashed lookup. The standard-library hashed containers are called "unordered" because they don't require an ordering function:
unordered_map<string,int> phone_book { {"David Hume",123456}, {"Karl Popper",234567}, {"Bertrand Arthur William Russell",345678} };
int get_number(const string& s) { return phone_book[s]; } // like for a map
The standard library provides a default hash function for strings and other built-in and standard-library types; we can provide our own when needed — most commonly for an unordered container of one of our own types. A hash function is often implemented as a function object (§7.3.2):
struct Record { string name; int product_code; /* ... */ };
struct Rhash { // a hash function for Record
size_t operator()(const Record& r) const {
return hash<string>()(r.name) ^ hash<int>()(r.product_code);
}
};
unordered_set<Record,Rhash> my_set; // set of Records using Rhash for lookup
Designing good hash functions is an art and often requires knowledge of the data. Creating a
new hash by combining existing hashes with exclusive-or (^) is simple and often
very effective — but be careful that every value taking part really helps distinguish values
(combining the two hashes provides no benefit unless several names can share a product code or
vice versa). We can avoid passing the hash operation explicitly by defining a specialization
of the standard-library hash:
namespace std {
template<> struct hash<Record> {
using argument_type = Record;
using result_type = size_t;
result_type operator()(const Record& r) const {
return hash<string>()(r.name) ^ hash<int>()(r.product_code);
}
};
}
The differences between a map and an unordered_map:
map requires an ordering function (default <) and yields an
ordered sequence.
unordered_map requires an equality function (default ==) and
does not maintain order among its elements.
Given a good hash function, an unordered_map is much faster than a map for large containers. However, the worst-case behavior of an unordered_map with a poor hash function is far worse than that of a map.
By default, standard-library containers allocate space using
new — a general free store holding objects of arbitrary size and user-controlled
lifetime, implying time and space overheads that can be eliminated in many special cases. The
containers therefore offer the opportunity to install allocators with
specific semantics: pool allocators (performance), allocators that clean memory on deletion
(security), per-thread allocation, and non-uniform memory architectures.
The motivating example: a long-running system used an event queue of vectors passed as
shared_ptrs, logically simple and robust — but after 100,000 events had been
passed among 16 producers and 4 consumers,
more than 6GB of memory had been consumed
due to fragmentation. The traditional solution is a
pool allocator — one managing objects of a single fixed size, allocating
space for many objects at a time rather than individually. C++ offers direct support via the
pmr
("polymorphic memory resource") sub-namespace of std:
pmr::synchronized_pool_resource pool; // make a pool
struct Event { vector<int> data = vector<int>{512, &pool}; }; // let Events use the pool
list<shared_ptr<Event>> q {&pool}; // let q use the pool
void producer() {
for (int n = 0; n!=LOTS; ++n) {
scoped_lock lk {m}; // m is a mutex (§18.3)
q.push_back(allocate_shared<Event, pmr::polymorphic_allocator<Event>>{&pool});
cv.notify_one(); // cv is a condition_variable (§18.4)
}
}
Now, after the same 100,000 events, less than 3MB had been consumed — about a
2000-fold improvement, with memory use stable over time so the system could run for months.
(The amount of memory actually in use is unchanged; only the fragmentation waste is gone.)
Other polymorphic memory resources:
unsynchronized_polymorphic_resource (single-threaded only) and
monotonic_polymorphic_resource (fast, releases memory only upon destruction,
single-threaded). A polymorphic resource must derive from memory_resource and
define allocate(), deallocate(), and is_equal() — users
build their own resources to tune code.
The standard library provides some of the most general and useful container types:
| Container | Description |
|---|---|
vector<T> |
A variable-size vector (§12.2) |
list<T> |
A doubly-linked list (§12.3) |
forward_list<T> |
A singly-linked list |
deque<T> |
A double-ended queue |
map<K,V> |
An associative array (§12.5) |
multimap<K,V> |
A map in which a key can occur many times |
unordered_map<K,V> |
A map using a hashed lookup (§12.6) |
unordered_multimap<K,V> |
A multimap using a hashed lookup |
set<T> |
A set (a map with just a key and no value) |
multiset<T> |
A set in which a value can occur many times |
unordered_set<T> |
A set using a hashed lookup |
unordered_multiset<T> |
A multiset using a hashed lookup |
The unordered containers are hash tables, optimized for lookup with a key (often a string). In
addition, the standard library provides container adaptors —
queue<T>, stack<T>,
priority_queue<T> — and specialized container-like types such as
array<T,N> (§15.3.1) and bitset<N> (§15.3.2).
The standard containers and their basic operations are designed to be similar notationally,
with equivalent meanings: value_type, begin()/end(),
size(), empty(), capacity(), c[k] /
c.at(k), push_back(x), insert(p,x) /
erase(p), assignment, and equality/lexicographical ordering (via
<=>). This uniformity lets us write algorithms independently of individual
container types — and to provide new container types used like the standard ones. Each has
strengths and weaknesses: subscripting and traversing a vector is cheap, but vector elements
move when we insert or remove; list has exactly the opposite properties. Note that a vector is
usually more efficient than a list for short sequences of small elements — even for
insert() and erase(). The singly-linked forward_list is
optimized for the empty sequence: an empty forward_list occupies just one word, an empty
vector three — and empty sequences, or sequences with only an element or two, are surprisingly
common and useful.
An emplace operation takes arguments for an element's constructor and builds the object in a newly allocated space in the container, rather than copying an object in:
v.push_back(pair{1,"copy or move"}); // make a pair and move it into v
v.emplace_back(1,"build in place"); // build a pair in v directly
For simple examples, optimizations can result in equivalent performance for both calls.
Here is a summary of the guidance from this chapter. All 30 items, with the section where each is introduced. The C++ Core Guidelines link each item to its recommended practice.
| # | Guideline | § |
|---|---|---|
| 1 | An STL container defines a sequence. | 12.2 |
| 2 | STL containers are resource handles. | 12.2 |
| 3 | Use vector as your default container. |
12.2 |
| 4 | For simple traversals of a container, use a range-for loop or a begin/end pair of iterators. | 12.2 |
| 5 |
Use reserve() to avoid invalidating pointers and iterators to elements.
|
12.2 |
| 6 | Don't assume performance benefits from reserve() without measurement. |
12.2 |
| 7 |
Use push_back() or resize() on a container rather than
realloc() on an array.
|
12.2 |
| 8 | Don't use iterators into a resized vector. | 12.2 |
| 9 | Do not assume that [] range checks. |
12.2 |
| 10 | Use at() when you need guaranteed range checks. |
12.2 |
| 11 | Use range-for and standard-library algorithms for cost-free avoidance of range errors. | 12.2.2 |
| 12 | Elements are copied into a container. | 12.2.1 |
| 13 | To preserve polymorphic behavior of elements, store pointers (built-in or user-defined). | 12.2.1 |
| 14 |
Insertion operations, such as insert() and push_back(), are
often surprisingly efficient on a vector.
|
12.3 |
| 15 | Use forward_list for sequences that are usually empty. |
12.8 |
| 16 | When it comes to performance, don't trust your intuition: measure. | 12.2 |
| 17 | A map is usually implemented as a red-black tree. |
12.5 |
| 18 | An unordered_map is a hash table. |
12.6 |
| 19 | Pass a container by reference and return a container by value. | 12.2 |
| 20 |
For a container, use the ()-initializer syntax for sizes and the
{}-initializer syntax for sequences of elements.
|
5.2.3 |
| 21 | Prefer compact and contiguous data structures. | 12.3 |
| 22 | A list is relatively expensive to traverse. |
12.3 |
| 23 | Use unordered containers if you need fast lookup for large amounts of data. | 12.6 |
| 24 |
Use ordered containers (e.g., map and set) if you need to
iterate over their elements in order.
|
12.5 |
| 25 |
Use unordered containers (e.g., unordered_map) for element types with no
natural order (i.e., no reasonable <).
|
12.5 |
| 26 |
Use associative containers (e.g., map and list) when you need
pointers to elements to be stable as the size of the container changes.
|
12.8 |
| 27 | Experiment to check that you have an acceptable hash function. | 12.6 |
| 28 |
A hash function obtained by combining standard hash functions for elements using the
exclusive-or operator (^) is often good.
|
12.6 |
| 29 | Know your standard-library containers and prefer them to handcrafted data structures. | 12.8 |
| 30 | If your application is suffering performance problems related to memory, minimize free store use and/or consider using a specialized allocator. | 12.7 |
What happens when a vector outgrows its capacity during
push_back(), and why is repeated push_back() efficient?
Why does Stroustrup now use reserve() only to avoid reallocation when he
wants to use pointers to elements?
Why must a class hierarchy's objects NOT be stored directly in a container, and what is the correct pattern?
Why does the standard vector not guarantee range checking on [],
and what is the cost-free alternative?
How does the Vec workaround add range checking, and why wrap
main()'s body in a try-block?
When is a list genuinely better than a vector?
What does forward_list give up compared to list, and what is it
optimized for?
What does phone_book[s] do on a map when s is NOT a
key, and how do you avoid it?
What distinguishes map from unordered_map in requirements and
behavior?
How do you give an unordered_set<Record> a hash for your own type, and
what is the caveat about ^-combining?
What did the event-queue system gain from pmr::synchronized_pool_resource,
and what must a custom memory_resource define?
Why is vector<int> v(23) different from
vector<int> v{23}, and what does emplace_back do
differently from push_back?
contiguous storage ⇒ handle (elem/space/last pointers) + allocator
default container ⇒ measure before distrusting ; intuition most fallible
growth ⇒ double when full (8 if empty )
→ amortized O(1) push_back
reserve() ⇒ may move elements → invalidates pointers ;
only use to avoid reallocation
copy ⇒ copies elements → expensive for big vectors → use refs/move
init ⇒ () sizes | {} element sequences (v(23) v/s v{23})
values copied in ⇒ element ≠ reference → compact, fast
polymorphic ⇒ × objects directly (slicing) →
vector<Shape*> or vector<unique_ptr<Shape>>
range checking ⇒ [] unchecked (~10% cost) |
at() throws out_of_range | range-for ⇒ cost-free
Vec ⇒ inherit vector, redefine [] → at() ;
main() try-block → well-defined termination
list ⇒ doubly-linked → insert/erase without moving others
insert(p,e) before p | erase(q) removes +
destroys ; p->m ≡ (*p).m
vector often faster ⇒ even insert/erase for short small-element sequences
forward_list ⇒ singly → forward only ; empty = 1 word
| no size stored → count
red-black tree ⇒ O(log n) ; associative array / dictionary
[] ⇒ inserts missing key with default value → use
find()/insert() to avoid
requires < ⇒ ordered sequence
hash table ⇒ expected O(1) ; requires ==, no order
default hashes ⇒ strings + built-ins ; own types → hash fn object or
std::hash<T> specialization
^-combine ⇒ simple + effective → each part must distinguish
values
worst case ⇒ poor hash far worse than map ; × order maintenance
default ⇒ new/delete general free store
pmr ⇒ polymorphic memory resources (<memory_resource>)
pool ⇒ fixed-size objects in bulk → event queue: 6GB → 3MB (~2000×)
synchronized_pool_resource |
unsynchronized_polymorphic_resource 1-thread |
monotonic release-at-destruction
custom resource ⇒ derive memory_resource :
allocate/deallocate/is_equal
12 containers ⇒ vector, list, forward_list, deque, map/multimap, unordered_map/multimap, set/multiset, unordered_set/multiset
adaptors ⇒ queue, stack, priority_queue ;
specialized ⇒ array<T,N>, bitset<N>
uniform ops ⇒ begin/end, size, empty, capacity, [], at, push_back, insert, erase, ==, <=> → generic algorithms
emplace_back(args) ⇒ build in place v/s
push_back(x) copy/move in
forward_list ⇒ for usually-empty sequences (1 word)
Primary source: Stroustrup, B. (2022). A Tour of C++, 3rd ed.,
Chapter 12: "Containers." Addison-Wesley.
Reference:
Chapter 1–12 Quick Reference & Glossary —
keep it beside you while you study.
Recommended supplement: cppreference on
standard containers,
std::vector,
std::unordered_map, and
polymorphic memory resources.
Questions? Ask your agent — your teacher — about anything unclear: when pointer invalidation
bites, why [] on a map has a side effect, or how to sanity-check a hash function.
Follow-ups are expected, not optional.