C++ container choice is not just about whether push_back, insert, or pop_front is available. The container you choose affects runtime performance, memory consumption, allocation patterns, determinism, scalability, and whether the same code still makes sense in an embedded system. In a small setup table it rarely matters, but in a packet queue, logging buffer, GUI model, Linux service, Qt tool, or data acquisition path, the wrong container can turn a clean design into a slow and fragile place to debug.
This article looks at the practical tradeoffs behind standard C++ containers, beyond their basic APIs. Performance is only one part of the decision. Memory usage and allocation behavior are often just as important, especially when code moves between desktop software, Qt applications, Linux systems, tools, utilities, and embedded projects. A later section covers what changes when the target is a resource-constrained microcontroller.
The Short Version
For most project code, start with this decision list:
- Use
std::vectorwhen you mostly append at the end, read by index, and iterate over all elements. - Use
std::dequewhen adding or removing at both ends is part of the normal workload. - Use
std::listwhen stable nodes, saved iterators, or splicing are the real requirement. - Use
std::forward_listonly when singly linked storage is truly useful andback()is not needed. - Use
std::set,std::map,std::unordered_set, andstd::unordered_mapwhen the operation is about keys, not positions.
This is why std::vector is still the default answer so often. It is not because every vector operation is cheap. It is because the common operations, appending, indexing, and linear iteration, match the hardware well.
push_back: Add at the End
push_back appends one element to the end of a sequence container.
|
1 2 3 4 5 6 7 8 9 10 |
#include <vector> std::vector<int> adc_readings; adc_readings.push_back(120); /* Good: append matches vector's strongest operation */ adc_readings.push_back(121); adc_readings.push_back(118); |
For std::vector, push_back is amortized constant time. Most calls construct the new element in already allocated capacity. When capacity runs out, the vector allocates a larger block and moves or copies the existing elements.
That occasional growth is the part that matters in timing-sensitive code. A single reallocation in the wrong place can create a latency spike and invalidate pointers or references into the vector.
|
1 2 3 4 5 6 7 8 9 |
std::vector<int> capture_buffer; capture_buffer.reserve(4096); /* Good: one planned allocation before capture starts */ for (int sample : incoming_samples) { capture_buffer.push_back(sample); /* Good: no repeated growth while samples arrive */ } |
std::deque also has efficient push_back, but its storage is split into blocks. It grows well at both ends, although iteration is usually less cache friendly than vector iteration.
std::list has constant time push_back, but each element is a separate node. That usually means more allocations and more pointer chasing. Big O says the operation is cheap; the CPU cache may disagree.
push_front: Add at the Beginning
push_front inserts at the front of a container. std::deque, std::list, and std::forward_list support it efficiently.
|
1 2 3 4 5 6 7 8 9 |
#include <deque> std::deque<int> pending_messages; pending_messages.push_front(42); /* Good: deque is built for front insertion */ pending_messages.push_front(17); |
std::vector does not provide push_front. Adding at index zero would require shifting every existing element one position to the right.
You can force front insertion into a vector with insert(begin(), value), but in production code that line deserves attention during review.
|
1 2 3 4 5 6 7 |
std::vector<int> queue_like_samples; queue_like_samples.insert(queue_like_samples.begin(), 99); /* Risky: every existing element may be shifted on each front insert */ |
For occasional setup data, that may be fine. For queue-like behavior in a normal execution path, use a container that matches the operation.
|
1 2 3 4 5 6 |
std::deque<int> queue_like_samples; queue_like_samples.push_front(99); /* Good: operation and container shape agree */ |
pop_back and pop_front: Remove from the Ends
pop_back removes the last element. pop_front removes the first element. Neither function returns the removed value, which is intentional. Read the value first if you need it.
|
1 2 3 4 5 6 7 |
if (!adc_readings.empty()) { int last_sample = adc_readings.back(); /* Good: read before removal */ adc_readings.pop_back(); /* Good: vector removes from the end cheaply */ } |
For std::vector, pop_back is constant time. It destroys the final element and decreases the size. Capacity normally stays allocated, which is often good for reuse.
std::vector has no pop_front, because removing the first element would shift all remaining elements to the left. std::deque supports both ends:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <deque> std::deque<int> uart_rx_queue; uart_rx_queue.push_back(10); uart_rx_queue.push_back(20); if (!uart_rx_queue.empty()) { int next_byte = uart_rx_queue.front(); /* Good: inspect oldest byte */ uart_rx_queue.pop_front(); /* Good: queue-like removal */ } |
This is why std::deque is a useful default storage choice for queue-like code. You still need to think about bounds and ownership, but the operation shape is right.
front and back: Look, Do Not Remove
front() returns a reference to the first element. back() returns a reference to the last element. They do not change the container.
|
1 2 3 4 5 6 7 |
if (!uart_rx_queue.empty()) { int first_byte = uart_rx_queue.front(); /* Good: empty check prevents undefined behavior */ int last_byte = uart_rx_queue.back(); } |
Calling front() or back() on an empty container is undefined behavior. This is a small mistake that can waste real debugging time because the crash may appear far away from the call site.
std::forward_list has front() but not back(). A singly linked list can find the first node immediately. Finding the last node requires walking the whole list.
insert: Useful, but Not Automatically Cheap
insert places an element at a specified position.
|
1 2 3 4 5 6 7 |
std::vector<int> channel_order {1, 2, 4, 5}; auto pos = channel_order.begin() + 2; channel_order.insert(pos, 3); /* OK: small setup list, readable and cheap enough */ |
For a vector, inserting in the middle is linear. Elements after the insertion point must move. If the vector needs more capacity, all elements may move to a new allocation.
For a deque, middle insertion is also linear. Deques are optimized for the ends, not arbitrary middle positions.
For a list, insertion at a known iterator position is constant time:
|
1 2 3 4 5 6 7 8 9 10 11 |
#include <list> std::list<int> active_channels {1, 2, 4}; auto place = active_channels.begin(); std::advance(place, 2); /* Tradeoff: finding the position still walks the list */ active_channels.insert(place, 3); /* Good: cheap once the iterator is already known */ |
That last point is the one that matters. A list makes insertion cheap after you already have the node position. It does not make random searching cheap.
emplace: Construct in Place
emplace_back, emplace_front, and emplace construct an element directly inside the container.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <string> #include <vector> struct Device { int address; std::string name; }; std::vector<Device> devices; devices.emplace_back(12, "temperature"); /* Good: constructs Device directly from constructor arguments */ |
This can avoid a temporary object. With modern move semantics, the benefit is often small for cheap types, but emplace is still useful for objects with expensive construction or no convenient temporary form.
Do not use emplace automatically. Sometimes explicit construction is easier to read and review:
|
1 2 3 4 5 |
devices.push_back(Device{13, "pressure"}); /* OK: explicit object construction is clear and usually optimized well */ |
In code review, clarity matters. If emplace_back hides which constructor is being called, push_back with an explicit object may be better.
A Useful Benchmark Shape
Benchmark the operation pattern you actually care about. Separate end operations from middle operations, and avoid accidentally benchmarking repeated growth when the real system would reserve memory up front.
|
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 |
#include <chrono> #include <deque> #include <iostream> #include <list> #include <vector> template <class Fn> auto measure_ms(Fn action) { auto start = std::chrono::steady_clock::now(); action(); auto stop = std::chrono::steady_clock::now(); return std::chrono::duration_cast<std::chrono::milliseconds>(stop - start).count(); } int main() { constexpr int count = 200000; auto vector_back = measure_ms([&] { std::vector<int> data; data.reserve(count); /* Good: measure append cost, not repeated reallocation */ for (int i = 0; i < count; ++i) { data.push_back(i); } }); auto deque_front = measure_ms([&] { std::deque<int> data; for (int i = 0; i < count; ++i) { data.push_front(i); /* Good: front insertion matches deque */ } }); auto list_back = measure_ms([&] { std::list<int> data; for (int i = 0; i < count; ++i) { data.push_back(i); /* Tradeoff: cheap link operation, separate allocation per node */ } }); std::cout << "vector push_back: " << vector_back << " ms\n"; std::cout << "deque push_front: " << deque_front << " ms\n"; std::cout << "list push_back: " << list_back << " ms\n"; } |
The exact numbers depend on compiler, optimization flags, allocator, CPU, and element type. The trend is usually more useful than the raw value.
For int, a reserved vector tends to be fast because writes are contiguous. A deque is usually strong at front and back operations. A list often loses simple append and iteration tests because every node is separate memory.
Add one deliberately poor case when you benchmark. It makes the cost visible:
|
1 2 3 4 5 6 7 8 9 10 |
auto vector_front = measure_ms([&] { std::vector<int> data; for (int i = 0; i < count; ++i) { data.insert(data.begin(), i); /* Bad: repeated front insertion shifts existing elements every time */ } }); |
This does not mean insert(begin()) is forbidden. It means you should ask whether the operation is rare, whether the container is small, and whether a deque expresses the workload better.
Iterator and Reference Invalidation
Performance is only part of the decision. Operations can invalidate iterators, pointers, and references.
Vector reallocation invalidates everything that points into the vector. Middle insertion invalidates iterators and references at or after the insertion point. Deque has its own invalidation rules, especially around insertions. List insertion normally keeps existing iterators valid because nodes do not move.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
std::vector<int> samples; samples.reserve(4); samples.push_back(10); int* first_sample = &samples[0]; samples.push_back(20); samples.push_back(30); samples.push_back(40); samples.push_back(50); /* Risky: growth may reallocate and invalidate first_sample */ |
If stored pointers or iterators cross function boundaries, the container choice becomes part of the API contract. That is where a “minor” container change can create difficult bugs later.
What Changes on Microcontrollers?
The container rules above still apply in normal C++ environments, but MCU firmware adds constraints that desktop and embedded Linux code can often hide. Many embedded C++ toolchains support large portions of the standard library. What you actually get depends on the compiler, standard library, runtime configuration, linker setup, and project settings. Exceptions and RTTI are also often disabled in firmware builds. The challenge is usually memory and determinism, not whether the syntax itself is valid C++.
std::array is usually a natural fit for MCU firmware because its size is fixed at compile time and it does not allocate from the heap. A C array gives similar storage predictability, but with manual bounds management. Containers such as std::vector, std::string, std::deque, std::map, std::list, and std::unordered_map may compile, but they usually depend on dynamic allocation unless you control their usage carefully.
MCUs often have limited SRAM, limited Flash, no virtual memory, no process isolation, and usually no MMU. There is no operating-system safety net like a desktop Linux or Windows process boundary. Allocation failures, heap fragmentation, and non-deterministic allocation time can directly affect system behavior. In long-running firmware, a bad allocation strategy can become a fault, watchdog reset, missed deadline, or corrupted state.
For hard real-time paths, ISRs, control loops, communication drivers, and safety-related code, fixed-size storage is usually preferred. Many MCU projects avoid heap allocation after startup, avoid allocation inside interrupts, and avoid container growth inside control loops. Memory pools, ring buffers, static arrays, and fixed-capacity containers are common alternatives. Standard containers are still useful tools in embedded Linux, test tools, Qt applications, PC-side utilities, simulation tools, code generators, and non-real-time firmware setup code.
| Container | MCU Firmware Suitability | Main Concern |
|---|---|---|
std::array |
Usually good | Fixed size known at compile time |
| C array | Usually good | Manual bounds management |
std::vector |
Usable with care | Heap allocation and reallocation |
std::string |
Usable with care | Heap allocation and hidden growth |
std::deque |
Usually avoid in small MCUs | Multiple allocations and implementation overhead |
std::list |
Usually avoid | Per-node allocation and memory overhead |
std::map |
Usually avoid in hot paths | Node allocation and pointer-heavy structure |
std::unordered_map |
Usually avoid in hot paths | Buckets, allocation, memory growth |
| Fixed-capacity vector | Often good | Capacity must be chosen upfront |
| Ring buffer | Very good | Requires overflow strategy |
Embedded Firmware Checklist
- Prefer fixed-size storage in hot paths.
- Avoid allocation inside ISRs.
- Avoid container growth inside control loops.
- Reserve memory during initialization when using
std::vector. - Measure RAM and Flash usage.
- Measure worst-case execution time.
- Verify allocator behavior.
- Consider fixed-capacity containers and ring buffers.
Practical Selection Rules
Use this as a working checklist:
- Start with
std::vectorwhen there is no strong reason to choose something else. - Switch to
std::dequewhen front operations are part of the normal workload. - Use
std::listwhen stable node identity, splicing, or iterator-based insertion is the actual requirement. - Use associative containers when the problem is key lookup, not positional insertion.
- Benchmark hot paths with the real element type and allocator behavior.
Avoid choosing a container only by the name of one operation. push_back, push_front, insert, pop_back, and pop_front describe what you want to do. The container decides what that operation costs, what memory it touches, what allocation pattern it creates, and what references it invalidates.
C++ containers are useful tools, but on microcontrollers the challenge is controlling memory ownership, allocation behavior, and timing predictability.
