Chapter 5
25 min read
Section 29 of 261

Implementing Dynamic Arrays

Arrays

We have spent six sections using arrays. Now we build one. By the end of this section you will have written, line by line, both a Python DynamicArray backed by raw ctypes memory and a C++ Vector<T> with proper RAII and move semantics. These are not toys: the same ideas, often the same code shape, power CPython's list, libstdc++ std::vector, Java ArrayList, Go slices, Rust Vec, and JavaScript Array. Once you understand this section you understand all of them.

The thesis of this section. A dynamic array is just a fixed buffer plus two integers — size and capacity — managed by exactly two policies: geometric growth (double when full) and hysteretic shrink (halve when usage drops to a quarter). Get those two right and every operation is amortized O(1) or, where it can't be, optimal O(n − i).

The Contract: What a Dynamic Array Promises

Before we write a single line, we pin down the abstract data type — the public surface every reasonable dynamic array must expose, and the cost model the user can rely on:

OperationWorst caseAmortizedNotes
len(a) / a.size()O(1)O(1)Read a single integer
a[i] (read or write)O(1)O(1)Pointer + offset; bounds-checked
a.append(v)O(n)O(1)Slow only when buffer is full
a.pop()O(n)O(1)Slow only when shrinking the buffer
a.insert(i, v)O(n)O(n − i)Shift A[i..n−1] right
a.delete(i)O(n)O(n − i)Shift A[i+1..n−1] left
iterateO(n)O(n)One pointer walk; O(1) per element

Three private fields back this entire interface: a pointer to a contiguous buffer, a logical size nn, and an allocated capacity cc with the perpetual invariant 0nc0 \leq n \leq c. Live elements occupy slots A[0..n1]A[0..n-1]; slots A[n..c1]A[n..c-1] are reserved memory waiting to be used.

Memory Model: Going Below Python's list

There is a chicken-and-egg problem: Python's built-in list is itself a dynamic array, so if we build “our” dynamic array on top of it, we're really just renaming list operations. We need a real fixed-size primitive. The right tool is ctypes:

🐍python
1import ctypes, sys
2
3cap = 4
4A = (ctypes.py_object * cap)()   # one allocation, 4 PyObject* slots
5print(sys.getsizeof(A))          # ~ 64 bytes on a 64-bit system
6A[0] = "hello"; A[1] = 42
7print(A[0], A[1])                # hello 42
8# A is FIXED-SIZE: there is no A.append, no A.extend.

The expression (ctypes.py_object * 4)() calls into the C allocator and reserves exactly4sizeof(void*)=324 \cdot \textsf{sizeof}(\text{void*}) = 32 bytes (plus a small header) for four object pointers, contiguous in memory. Crucially, that buffer cannot grow itself. To grow, we allocate a bigger one and copy.

This is exactly how CPython lists work

Open Objects/listobject.c in the CPython source: a PyListObject has fieldsob_item (pointer to a PyObject** buffer), ob_size (n), andallocated (capacity). The growth function is list_resize. Same three fields, same algorithm, written in C instead of Python.

Growth Strategy: Why Doubling Wins

When append finds the buffer full, it must allocate a new one of size cc' and copy nn elements over. The choice of cc' determines everything. Three strategies:

  1. Constant additive growth: c=c+kc' = c + k for some constant kk. Every kk appends triggers a resize, costing Θ(n)\Theta(n) each time. Total cost of nn appends is Θ(n2/k)\Theta(n^2 / k). Catastrophic.
  2. Geometric growth with factor g>1g > 1: c=gcc' = \lceil g \cdot c \rceil. Resizes happen at sizes g0,g1,g2,g^0, g^1, g^2, \ldots; total copy cost is k=0loggngk=gloggn+11g1gg1n\sum_{k=0}^{\log_g n} g^k = \frac{g^{\log_g n + 1} - 1}{g - 1} \leq \frac{g}{g-1} n. For g=2g = 2: at most 2n2n total copies. Linear total work, O(1) amortized.
  3. Doubling (g=2g = 2): Simplest, cleanest, and what almost everyone uses. The newly-allocated unused space equals the previously used space, which makes the accounting argument fall out cleanly (next subsection).
Smaller factors like g=1.5g = 1.5 waste less memory but copy more often. Some implementations (libc++ std::vector, Java ArrayList) prefer g=1.5g = 1.5 because it improves the chance the next allocation can reuse the freed previous buffer (with g2g \geq 2, the new buffer is always larger than the sum of all previous ones, so it cannot fit). This is a real practical benefit on systems with simple bump allocators.

Amortized Analysis: Two Proofs of O(1) append

Aggregate method

Consider nn appends starting from capacity 1 and doubling. The total cost is the cost of writing each element (nn writes, one each) plus the cost of all resizes. Resizes happen exactly when the buffer is full, at sizes 1,2,4,8,,2log2n1, 2, 4, 8, \ldots, 2^{\lfloor \log_2 n \rfloor}, copying that many elements each time:

T(n)  =  nwrites  +  k=0log2n2kresize copies    n+2n  =  3n.T(n) \;=\; \underbrace{n}_{\text{writes}} \;+\; \underbrace{\sum_{k=0}^{\lfloor \log_2 n \rfloor} 2^k}_{\text{resize copies}} \;\leq\; n + 2n \;=\; 3n.

Dividing by nn gives an amortized cost of at most 3 per append — independent of nn, hence O(1)O(1).

Accounting (banker's) method

Charge each append a flat amortized cost of 3 dollars. Spend 1 dollar on the actual write, and store 2 dollars as “credit” on the new element. When the buffer fills at capacity cc, every one of the cc live elements carries at least 2 dollars of credit (the latest-doubled half each carry 2; the older half carry even more, since they survived the previous resize too). The resize costs cc copies, payable from the 2c2c dollars in credit — with cc dollars to spare. The invariant holds for all time, so amortized cost ≤ 3.

Amortized analysis is a statement about sequences of operations, not individual operations. A single append can still cost Θ(n)\Theta(n) in the worst case (when it triggers the resize). Real-time systems that must guarantee per-operation latency therefore prefer fixed-capacity arrays or specialized structures like the “deque of fixed-size chunks”.

Shrink Policy: The Hysteresis Trick

Symmetric reasoning suggests: when pop drops the size below half the capacity, halve the buffer to recover memory. This is wrong. Consider a sequence that alternates append and pop right at the boundary:

  1. Suppose n=c=4n = c = 4 (full, capacity 4).
  2. append → grows to capacity 8, copies 4, now n=5,c=8n = 5, c = 8.
  3. popn=4n = 4. With shrink-at-half: 48/24 \leq 8/2 → halve to capacity 4, copies 4.
  4. append → full again → grows to 8, copies 4. pop shrinks to 4, copies 4.Repeat forever.

Each operation now costs Θ(n)\Theta(n). The amortized argument fails because we spend the credit immediately and have nothing left for the next resize.

Fix: shrink only when the size has dropped to a quarter of capacity, and when shrinking, halve to half of capacity. Now after a shrink we're at 50% utilization, and we need to either drop to 25% (requiring c/4c/4 pops) or fill to 100% (requiring c/2c/2 appends) before another resize. Every resize is matched by a long stretch of cheap operations — the credit fund stays solvent.

Shrink-at-1/2 is the most common amortized-analysis bug

It's the obvious thing to do, and it's wrong. Open the “Bad oscillation” scenario in the visualizer below with shrink set to at size ≤ cap/2 and watch the total copies grow without bound.

Insert and Delete: The O(n - i) Shifts

Random-position insert(i, v) and delete(i) are not amortized O(1). They require physically moving every element to the right of position ii by one slot — nin - i elements in the worst case. Inserting at the front is Θ(n)\Theta(n); inserting at the end is O(1)O(1) amortized.

The shift loop must be written in the right direction: when inserting, copy from A[k1]A[k-1] to A[k]A[k] for k=n,n1,,i+1k = n, n-1, \ldots, i+1backwards, otherwise the second copy overwrites a value before we've read it. When deleting, copy A[k+1]A[k+1] to A[k]A[k] for k=i,i+1,,n2k = i, i+1, \ldots, n-2forwards. Get this wrong and your container silently corrupts data.

Bounds Checks and the Iterator Protocol

Two more responsibilities round out the implementation:

  1. Bounds checks on every read/write/delete. Negative indices should be normalized (A[1]=A[n1]A[-1] = A[n-1]) the way Python does. Out-of-range indices must raise IndexError — never write past the buffer end. Skipping this is the most common source of memory-corruption bugs in C-level dynamic arrays.
  2. Iterator protocol. __iter__ yields A[0..n-1]. A subtle real-world bug: if the user mutates the array during iteration (e.g., append inside a for loop), a resize relocates the buffer; if your iterator captured the old pointer, it is now dangling. Python's built-in list reads self._A[k] on each step and tolerates appends-during-iteration (you may or may not see the new elements); insert/delete during iteration is undefined behavior even there. We follow the same rule.

Full Implementation: Python with ctypes

Here is the complete DynamicArray. Each line is annotated below — read the code, then click any line on the left to see why it does what it does.

DynamicArray — full Python implementation
🐍dynamic_array.py
1ctypes — get below Python's list

ctypes lets us allocate a real fixed-size C array of Python object pointers. We need this because Python's built-in list is itself a dynamic array, so using it would defeat the lesson. ctypes.py_object is a C-level pointer to a PyObject; an array of N of them is exactly capacity * sizeof(PyObject*) bytes contiguous in memory.

4Class declaration

DynamicArray exposes the public ADT: random access, append, pop, insert, delete, iteration. Internally it stores three things: a buffer A, a logical size n, and an allocated capacity ≥ n.

11__init__: three invariants

We establish: (1) self._n = 0 (empty), (2) capacity ≥ 1 — never 0, because growth multiplies and 0×k=0 forever, (3) self._A is a freshly allocated ctypes array of py_object slots. After this, the invariant 0 ≤ n ≤ capacity must always hold.

EXECUTION STATE
self._n = 0
self._capacity = 4 (default)
self._A = (py_object * 4)() # 4 null slots
12

_n holds the number of elements actually stored. Type-annotated for readers; Python won't enforce it but mypy will.

13

max(1, initial_capacity) guards against a 0 or negative argument. A 0 capacity would deadlock the doubling rule (2 * 0 = 0).

14

Allocate the backing buffer. The ctypes call literally requests `capacity * sizeof(void*)` bytes from the C allocator — no Python list under the hood.

16_make_array: low-level allocation

(ctypes.py_object * capacity) creates a new ctypes array TYPE; calling it () instantiates an array of that size, zero-initialized. Each slot can hold a Python object reference. This is the closest Python lets us get to malloc(capacity * sizeof(PyObject*)).

20__len__: O(1)

Returns the logical size, NOT the capacity. len(da) reports how many elements you've appended, not how much memory is reserved.

23_check_index: Pythonic negative-index handling + bounds

Two responsibilities: (1) translate negative indices (a[-1] → a[n-1]), (2) raise IndexError if out of range. We return the normalized non-negative index so callers get the right slot. NEVER skip this — silent out-of-bounds writes corrupt memory in C-level dynamic arrays and silently return garbage in Python.

LOOP TRACE · 2 iterations
i = -1, n = 5
i (after +=) = 4
0 <= 4 < 5 = True → return 4
i = 10, n = 5
0 <= 10 < 5 = False → IndexError
30__getitem__: O(1) random access

After bounds-checking, a single pointer dereference: self._A[i]. This is the whole reason dynamic arrays exist — contiguous memory + integer index = constant-time random access.

34__setitem__: O(1) overwrite

Same as getitem but writes. Crucially, this does NOT change size: setting da[5] when n=10 modifies an existing element; it doesn't grow the array. To grow, use append.

38append: the famous amortized O(1) operation

The hot path: when there is space (n < capacity), one write and one increment — a few CPU cycles. The slow path: n == capacity, so we double the buffer first. Doubling means we copy O(n) elements RARELY (only at powers-of-2 sizes), giving amortized O(1) per call.

EXECUTION STATE
before = n=4, cap=4 (full)
_resize(8) copies 4 elements = n=4, cap=8
after write+increment = n=5, cap=8
39

The fullness check. Note we never let n exceed capacity, so this is the only place the slow path triggers.

40

Double, not +1. Doubling is the geometric growth that powers the amortized argument: total copies after n appends ≤ 2n.

41

Now there's room. Write at index n (the first free slot — note that after appending at n=4 we wrote into index 4, which is the 5th slot since indexing is 0-based).

42

Bump size. After this both invariants hold again: n ≤ capacity, and _A[0..n-1] is the live region.

44pop: remove and possibly shrink

We logically shorten by decrementing n, then null out the slot to release the Python reference (otherwise the popped value would be kept alive by the ctypes array — a subtle memory leak). The shrink check uses size ≤ capacity // 4, NOT capacity // 2 (see hysteresis section).

EXECUTION STATE
before = n=3, cap=8
after pop() = n=2, cap=8 (no shrink: 2 > 8//4=2 is FALSE → 2 ≤ 2 → shrink to cap=4)
45

Cannot pop from empty. CPython's list raises IndexError here — we follow the same contract.

47

Decrement first, THEN read. self._A[self._n] is now the (former) last element.

48

Read the value before clearing the slot — order matters.

49

Null out so the Python object can be garbage-collected. ctypes.py_object slots hold strong references; without this the popped object stays alive until the slot is overwritten or the buffer is freed.

50Hysteresis: shrink at 1/4, halve to 1/2

Two tricks combined: (a) shrink when used ≤ capacity/4, not /2 — this leaves a gap so we don't oscillate when the user alternates push/pop; (b) when shrinking, halve to capacity/2 — so we land at 50% utilization, well above the 25% trigger. Without this gap, push-pop near a power of 2 boundary costs O(n) per call.

53insert: O(n - i) shift right

First grow if full (same rule as append). Then shift everything from index i to n-1 one slot to the right (looping BACKWARDS — going forward would overwrite the next element before we read it). Finally write the new value and bump size.

LOOP TRACE · 1 iterations
insert(1, 99) into [10, 20, 30]
before = _A = [10, 20, 30, _], n=3
k=3 → A[3] = A[2] = _A = [10, 20, 30, 30]
k=2 → A[2] = A[1] = _A = [10, 20, 20, 30]
place 99 at A[1] = _A = [10, 99, 20, 30], n=4
54

i == n is allowed (insert at end == append). i > n is not — it would leave a hole.

56

Same growth rule as append. We grow BEFORE shifting because we need n+1 slots after.

58

Backward loop: range(self._n, i, -1) yields n, n-1, ..., i+1. For each k, copy A[k-1] to A[k]. Going forward would overwrite a value before we shift it.

60

Slot i is now duplicated (its old value lives at i+1) — overwrite it with the new value.

63delete: O(n - i) shift left

Mirror of insert. Shift A[i+1..n-1] one slot LEFT (going forward this time — we read A[k+1] before we write to A[k]). Null the now-redundant last slot, decrement, possibly shrink.

65

Forward loop: A[i] = A[i+1], A[i+1] = A[i+2], …, A[n-2] = A[n-1]. Reads each slot before its previous neighbor overwrites it.

67

After shifting, A[n-1] holds a duplicate of the previous last element. Null it so the object can be GC'd.

71_resize: the only place the buffer pointer changes

Allocate a new buffer of the requested capacity, copy n live elements, swap pointers. The OLD buffer is now unreferenced and the C allocator reclaims it (Python's GC handles ctypes arrays). After this, _A points to fresh contiguous memory.

EXECUTION STATE
B = make_array(new_cap) = fresh buffer, all None
copy loop = B[0..n-1] = A[0..n-1]
swap = self._A = B, self._capacity = new_cap
78__iter__: generator over the live region

yield from range(self._n) would be subtly wrong if you mutate during iteration; this version reads _A[k] each step so adds AT THE END are visible (matching list behavior). Inserting/deleting during iteration is undefined — same caveat as Python list.

82__repr__: friendly string output

Helpful for debugging. Reports both contents and capacity so you can see growth/shrink in logs: DynamicArray([1, 2, 3], cap=8).

53 lines without explanation
1import ctypes
2
3
4class DynamicArray:
5    """A dynamic array built on a fixed-capacity ctypes buffer.
6
7    This is essentially what CPython's list does internally,
8    minus the per-element refcount bookkeeping in the C layer.
9    """
10
11    def __init__(self, initial_capacity: int = 4) -> None:
12        self._n: int = 0
13        self._capacity: int = max(1, initial_capacity)
14        self._A = self._make_array(self._capacity)
15
16    @staticmethod
17    def _make_array(capacity: int):
18        return (ctypes.py_object * capacity)()
19
20    def __len__(self) -> int:
21        return self._n
22
23    def _check_index(self, i: int) -> int:
24        if i < 0:
25            i += self._n
26        if not 0 <= i < self._n:
27            raise IndexError(f"index {i} out of range for size {self._n}")
28        return i
29
30    def __getitem__(self, i: int):
31        i = self._check_index(i)
32        return self._A[i]
33
34    def __setitem__(self, i: int, value) -> None:
35        i = self._check_index(i)
36        self._A[i] = value
37
38    def append(self, value) -> None:
39        if self._n == self._capacity:
40            self._resize(2 * self._capacity)
41        self._A[self._n] = value
42        self._n += 1
43
44    def pop(self):
45        if self._n == 0:
46            raise IndexError("pop from empty array")
47        self._n -= 1
48        value = self._A[self._n]
49        self._A[self._n] = None
50        if 0 < self._n <= self._capacity // 4 and self._capacity > 1:
51            self._resize(self._capacity // 2)
52        return value
53
54    def insert(self, i: int, value) -> None:
55        if not 0 <= i <= self._n:
56            raise IndexError(f"insert index {i} out of range")
57        if self._n == self._capacity:
58            self._resize(2 * self._capacity)
59        for k in range(self._n, i, -1):
60            self._A[k] = self._A[k - 1]
61        self._A[i] = value
62        self._n += 1
63
64    def delete(self, i: int) -> None:
65        i = self._check_index(i)
66        for k in range(i, self._n - 1):
67            self._A[k] = self._A[k + 1]
68        self._A[self._n - 1] = None
69        self._n -= 1
70        if 0 < self._n <= self._capacity // 4 and self._capacity > 1:
71            self._resize(self._capacity // 2)
72
73    def _resize(self, new_cap: int) -> None:
74        B = self._make_array(new_cap)
75        for k in range(self._n):
76            B[k] = self._A[k]
77        self._A = B
78        self._capacity = new_cap
79
80    def __iter__(self):
81        for k in range(self._n):
82            yield self._A[k]
83
84    def __repr__(self) -> str:
85        items = ", ".join(repr(self._A[k]) for k in range(self._n))
86        return f"DynamicArray([{items}], cap={self._capacity})"

Drive it with the canonical example:

🐍python
1da = DynamicArray()             # n=0, cap=4
2for i in range(10):
3    da.append(i)
4    print(f"after append({i}): n={len(da)}, cap={da._capacity}")
5
6# Output:
7# after append(0): n=1, cap=4
8# after append(1): n=2, cap=4
9# after append(2): n=3, cap=4
10# after append(3): n=4, cap=4   # one slot left
11# after append(4): n=5, cap=8   # FULL → resize, then write
12# after append(5): n=6, cap=8
13# after append(6): n=7, cap=8
14# after append(7): n=8, cap=8
15# after append(8): n=9, cap=16  # FULL → resize again
16# after append(9): n=10, cap=16

Capacity follows the geometric sequence 4,8,16,32,4, 8, 16, 32, \ldots, growing precisely at appends 5, 9, 17, 33, … Total copy cost up to n=10n = 10 appends: 4+8=124 + 8 = 12 copies plus 1010 writes = 22 ≤ 3 · 10. The bound holds.

Full Implementation: C++ with RAII and Move Semantics

The Python version is short because Python hides many costs. C++ forces us to confront them: separate allocation from construction, manage destructor calls explicitly, decide between move and copy on resize. The result is a Vector<T> close enough to std::vector that the same optimizations apply.

Vector<T> — full C++ implementation
📄vector.hpp
1Headers — minimum needed

<cstddef> for std::size_t (the unsigned size type), <stdexcept> for std::out_of_range, <utility> for std::move and std::move_if_noexcept, <new> for placement-new and ::operator new (the raw allocator).

6Class template

template <typename T> makes Vector generic over element type — Vector<int>, Vector<std::string>, Vector<MyType>. The compiler instantiates a fresh class per T at compile time.

8Default constructor: zero-allocation start

Initialize data_ = nullptr, size_ = 0, cap_ = 0. We allocate nothing until the first push_back. This matches std::vector<T> v; — no heap touch.

10Sized constructor with raw allocation

::operator new(bytes) is C++'s untyped malloc — it returns raw uninitialized storage. We do NOT use new T[n] because that would default-construct n objects up front; we want capacity without construction so push_back can placement-new into the slots.

EXECUTION STATE
::operator new(N * sizeof(T)) = raw bytes, no T constructors run
size_ = 0 (no live objects yet)
cap_ = N (slots available)
14Destructor: destroy live elements, then free

Order matters. (1) Run T's destructor on each of the size_ live elements. (2) Release the raw storage with ::operator delete. Skipping (1) leaks resources owned by T (file handles, allocations). Doing them in the wrong order is undefined behavior.

19Move constructor — the secret weapon

Steals the other vector's buffer in O(1): copy three pointers/integers, null the other. This is what makes returning Vector<T> by value cheap. The noexcept matters: containers use std::move_if_noexcept and only move (instead of copy) when this guarantee is present.

24Move assignment

Three steps: (1) self-assignment guard (if (this != &other)), (2) clean up our own buffer (destruct + free), (3) steal other's. Without the self-check, v = std::move(v) would destroy v's buffer, then read freed memory.

36operator[] with bounds checking

Standard std::vector::operator[] does NOT bounds-check (it's UB on out-of-range). We're more cautious here for teaching. Real production code typically uses .at() for checked access and operator[] for hot loops where the caller knows the index is valid.

41push_back: the central operation

If full, grow first (2× from current, or 1 if empty — never multiply by 0). Then placement-new (the new (ptr) T(...) syntax) constructs T directly in the slot data_[size_], copying from value. This is how std::vector avoids default-constructing capacity slots.

EXECUTION STATE
before, full case = size_=4, cap_=4
grow_(8) called = size_=4, cap_=8 (4 elements moved)
placement-new at data_[4] = size_=4 → ++size_ → 5
42

Notice cap_ == 0 ? 1 : 2 * cap_. We can't double zero — that's the same trap as Python. Bootstrap to 1 on the first push, then geometric growth.

43

new (data_ + size_) T(value) is placement-new: it does NOT allocate; it constructs a T at the address data_ + size_. The slot was raw uninitialized bytes from ::operator new — now it's a live T.

47pop_back: explicit destructor + maybe shrink

C++ requires us to manually destroy the popped element via data_[size_].~T() — this is one of very few places where calling a destructor explicitly is correct. Decrementing size_ alone would leak any resources T owns. Same shrink-at-1/4 rule.

51

Calling ~T() does not deallocate the slot — only ::operator delete does. The slot becomes raw bytes again, ready for a future placement-new.

56grow_: exception-safe move/copy

Allocate new raw buffer, then for each old element placement-new in the new buffer with std::move_if_noexcept(data_[i]). This moves if T's move is noexcept, otherwise copies. Why? If a copy/move throws halfway through, std::move would have left the old buffer corrupted; copy-on-throw means we can fall back to the original on error (the strong exception-safety guarantee).

EXECUTION STATE
new_data = ::operator new(new_cap * sizeof(T)) = raw bytes
loop i=0..size_-1 = placement-new + destruct old
free old, swap pointers = data_ = new_data, cap_ = new_cap
59

std::move_if_noexcept(x) returns std::move(x) if T's move constructor is marked noexcept; otherwise it returns a const-ref (which forces copy). This single function is why noexcept moves matter: it's the difference between O(n) pointer-fiddle and O(n) deep-copy at every resize.

60

After moving the element to the new buffer, destruct the moved-from one. A moved-from T is in a 'valid but unspecified' state — we still must call its destructor to release any resources it didn't transfer.

67

Three private fields: a pointer to raw storage, a logical size, an allocated capacity. Same trio as the Python version — the names just differ.

54 lines without explanation
1#include <cstddef>
2#include <stdexcept>
3#include <utility>
4#include <new>
5
6template <typename T>
7class Vector {
8public:
9    Vector() : data_(nullptr), size_(0), cap_(0) {}
10
11    explicit Vector(std::size_t initial_cap)
12        : data_(static_cast<T*>(::operator new(initial_cap * sizeof(T)))),
13          size_(0), cap_(initial_cap) {}
14
15    ~Vector() {
16        for (std::size_t i = 0; i < size_; ++i) data_[i].~T();
17        ::operator delete(data_);
18    }
19
20    Vector(Vector&& other) noexcept
21        : data_(other.data_), size_(other.size_), cap_(other.cap_) {
22        other.data_ = nullptr; other.size_ = 0; other.cap_ = 0;
23    }
24
25    Vector& operator=(Vector&& other) noexcept {
26        if (this != &other) {
27            for (std::size_t i = 0; i < size_; ++i) data_[i].~T();
28            ::operator delete(data_);
29            data_ = other.data_; size_ = other.size_; cap_ = other.cap_;
30            other.data_ = nullptr; other.size_ = 0; other.cap_ = 0;
31        }
32        return *this;
33    }
34
35    std::size_t size() const noexcept { return size_; }
36    std::size_t capacity() const noexcept { return cap_; }
37
38    T& operator[](std::size_t i) {
39        if (i >= size_) throw std::out_of_range("index");
40        return data_[i];
41    }
42
43    void push_back(const T& value) {
44        if (size_ == cap_) grow_(cap_ == 0 ? 1 : 2 * cap_);
45        new (data_ + size_) T(value);   // placement new — copy-construct in place
46        ++size_;
47    }
48
49    void pop_back() {
50        if (size_ == 0) throw std::out_of_range("pop_back");
51        --size_;
52        data_[size_].~T();              // explicit destructor
53        if (cap_ > 1 && size_ <= cap_ / 4) grow_(cap_ / 2);
54    }
55
56private:
57    void grow_(std::size_t new_cap) {
58        T* new_data = static_cast<T*>(::operator new(new_cap * sizeof(T)));
59        for (std::size_t i = 0; i < size_; ++i) {
60            new (new_data + i) T(std::move_if_noexcept(data_[i]));
61            data_[i].~T();
62        }
63        ::operator delete(data_);
64        data_ = new_data;
65        cap_ = new_cap;
66    }
67
68    T* data_;
69    std::size_t size_;
70    std::size_t cap_;
71};

Why placement-new and explicit ~T()?

std::vector reserves capacity without constructing those elements. If we used new T[cap] instead of ::operator new(cap * sizeof(T)), every reserved slot would be default-constructed up front — that's wrong: the user has not yet stored those values, and T may not even have a default constructor. Placement-new gives us the fine-grained control: construct only when push_back is called, destruct on pop_back, free the raw bytes only in the destructor.

Interactive: Build Your Vector

Step through any of the four scenarios below. The amber flash on the buffer marks a real reallocation and copy. Try toggling growth between 2×2\times, 1.5×1.5\times, and 1.25×1.25\times; you'll see fewer wasted slots on the smaller factors but more frequent (and slightly more expensive) copies. The fourth scenario is the hysteresis bug — set shrink to at size ≤ cap/2 and watch the total-copy counter explode as the size oscillates across the boundary.

0 / 10
0123····size = 0
capacity
4
size
0
total copies
0
amortized cost / op
Step note: Start: capacity=4, size=0

Green = newest element. Blue = occupied. Dashed = unused capacity. Amber = the buffer was just reallocated. Try the “Bad oscillation” scenario with shrink set to at size ≤ cap/2 to see why that policy fails: total copies grow linearly per operation.

Real-World Implementations

Every general-purpose language ships a dynamic array. The differences are in the constants:

ImplementationGrowth factorInitial capShrinkNotes
CPython list~1.125× + 60 (lazy)explicit onlyTuned for many small lists; over-allocation pattern in list_resize
libstdc++ std::vector0 (lazy)never (shrink_to_fit explicit)GCC's standard library
libc++ std::vector0 (lazy)never (shrink_to_fit explicit)Clang's standard library; some forks use 1.5×
Java ArrayList1.5× (oldCap + (oldCap >> 1))10trimToSize() explicitCapped at Integer.MAX_VALUE - 8
Go slice (append)2× until 1024, then 1.25×0never (re-slice releases nothing)Hybrid factor for memory friendliness on large slices
Rust Vec0 (lazy)shrink_to_fit / shrink_toMove semantics native; no exception safety needed
Swift Array0 (lazy)never (auto-COW)Copy-on-write — a copied Array shares storage until first mutation
V8 JS Arrayimplementation-defined (≈ 1.5–2×)0may switch to dictionary modeSparse arrays fall back to a hash table internally

Why Python uses such a small growth factor

CPython's list_resize uses new_alloc = (newsize + (newsize >> 3) + 6) & ~3, which is roughly 1.125n+61.125 \cdot n + 6. The reasoning is that Python programs create huge numbers of small lists; doubling would waste lots of memory across millions of lists. The amortized analysis still gives O(1) — the constant is just larger.

Edge Cases and Exception Safety

  1. Initial capacity 0 vs lazy alloc. std::vector and Rust Vec allocate nothing on construction; the first push_back bootstraps to capacity 1. Our Python version uses 4 for clearer demos. Both are valid. Never use 0 with a multiplicative growth rule unless you also handle the c2=0c \cdot 2 = 0 bootstrap.
  2. Capacity overflow. When cc approaches SIZE_MAX, doubling overflows. Real implementations check cmax_size()c' \leq \textsf{max\_size}() and either throw std::length_error or saturate.
  3. Concurrency. None of these structures is thread-safe. Concurrent appends race on both the buffer pointer and the size field; concurrent resizes can free a buffer another thread is reading. Concurrent variants exist (Java CopyOnWriteArrayList, lock-free rings) but have very different semantics and cost models.
  4. Exception safety on resize. If T's copy/move throws halfway through a grow, the container must remain in a usable state. The C++ idiom of std::move_if_noexcept is precisely the strong-guarantee pattern: move when safe, copy-with-rollback when not.
  5. Move-only types. Vector<std::unique_ptr<T>> requires move-construction in the resize loop — copies don't exist. Our implementation handles this because we always pass through std::move_if_noexcept.

Pitfalls and Common Bugs

  • Forgetting to null the popped slot in Python. The ctypes array holds a strong reference; without self._A[self._n] = None the popped object stays alive until the slot is overwritten. Subtle leak.
  • Off-by-one in the shift loop for insert/delete. The boundaries are range(n, i, -1) for insert (n down to i+1, exclusive of i) and range(i, n - 1) for delete (i up to n−2). Test with insert(0, v), insert(n, v), delete(0), delete(n−1) — the four corner cases catch most off-by-ones.
  • Iterator invalidation after resize. A C++ std::vector::iterator captured before push_back can be a dangling pointer afterward. Python iterators re-read the buffer pointer each step, so they are safer; but inserting/deleting during a Python loop still skips or repeats elements.
  • Shrink-at-1/2 oscillation. Already discussed. Use 1/4 trigger, halve on shrink.
  • Forgetting delete[] or ::operator delete in C++. Manual memory management is the price of control. RAII (the destructor we wrote) is what makes it bearable.
  • Pointer aliasing during resize. If two slots in the source buffer point to the same object and you copy them naively, fine; but with non-trivial constructors, double-construction is a real concern. Our placement-new + destruct-old loop handles this correctly because it processes each source slot exactly once.

Quick Checks

Quick Check

A DynamicArray starts with initial_capacity=4 and uses doubling. After 17 consecutive appends, how many element copies have occurred from resizes (excluding the 17 writes themselves)?

Quick Check

Why does shrinking when size ≤ capacity/2 (instead of ≤ capacity/4) break the amortized O(1) guarantee?


With this we close Chapter 5. Every section that follows — linked lists, stacks, queues, hash tables — builds on or contrasts with the dynamic array. When designers ask “is a dynamic array enough for this problem?”, they are weighing exactly the costs we have just measured: amortized O(1) append, O(1) random access, O(n − i) middle-of-array mutation, and contiguous memory for cache locality. In Chapter 6 we trade that contiguity for O(1) front insertion and pay for it in cache misses. The next chapter is, in many ways, this chapter's shadow.

Loading comments...