C++ Stack: Overflow Checks, ASan Findings, Benchmarks

Array and linked stacks in C++ re-run under AddressSanitizer: overflow, underflow and leak bugs fixed, bracket matching, and speed vs std::stack and C# Stack<T>.

Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled both C++ examples with Apple clang 21 (-Wall -Wextra, no warnings) and ran them again under AddressSanitizer and UndefinedBehaviorSanitizer. The sample main functions still print the expected output, but the classes themselves had no overflow check, no underflow check, a double free on copy, and a linked stack that leaked every node and walked the whole list on every push and pop. Below are the sanitizer reports, the fixed code, a bracket-matching example and measured timings.

This article is for anyone implementing a stack in C or C++ for a course, an interview or a parser, and for C# developers who want to know what Stack<T> does for them. In short: a stack is last in, first out (LIFO), every operation happens at one end, and the two things that go wrong are pushing onto a full stack and popping from an empty one. A preallocated array with two checks is the fastest version we measured; a linked stack is about 50 times slower and only makes sense when no upper bound exists.

What a stack is, and where it shows up

Think of a pile of plates: you add a plate on top and take the top plate off. Adding is called push, removing is pop, and reading the top without removing it is peek (the 2021 code called it getTop). Because only the top is touched, push and pop are O(1) in any reasonable implementation.

Real places you meet a stack:

  • the call stack of every program (and the "stack overflow" crash when recursion goes too deep; see sorting, where quicksort crashed exactly that way),
  • undo history in editors,
  • bracket matching and expression parsing in compilers,
  • depth-first search, which is a loop over an explicit stack.

Running the 2021 array stack under AddressSanitizer

The 2021 article showed a template class backed by new T[maxSize], with top counting the stored items. Only the #include <iostream> and using namespace std; lines were missing from the snippet; with those added, the original main (push a to e, then pop five times) compiles cleanly with clang++ -std=c++17 -Wall -Wextra and prints the same sequence as in 2021: e, d, c, b, a.

The problem is what the class does outside that happy path. push is stack[top++] = data; and pop is return stack[--top];, with no checks, and the isFull function was commented out. We wrote three small misuse tests and built them twice: plainly, and with -fsanitize=address,undefined -g.

Misuse Plain build With ASan/UBSan
6th push into Stack<char>(5) prints f, exit code 0 heap-buffer-overflow, WRITE of size 1, "0 bytes after 5-byte region" in Stack<char>::push
pop() on an empty stack prints 0, exit code 0 heap-buffer-overflow, READ of size 1, "1 bytes before 5-byte region" in Stack<char>::pop
Stack<char> copy = s; exit code 133, no message attempting double-free in Stack<char>::~Stack()

The first two rows are the dangerous ones: without a sanitizer the program looks fine. It writes one byte past the allocation, or reads one byte before it, and carries on. The third row is the rule of three: the class owns a raw pointer but has a compiler-generated copy constructor, so two objects end up deleting the same array.

The fixed array stack

The fix adds the two checks, reports failures as a return value, and deletes copying. A peek on an empty stack throws because there is no value to return.

template <class T>
class ArrayStack {
    T* data_; int top_ = 0; int cap_;
public:
    explicit ArrayStack(int cap) : data_(new T[cap]), cap_(cap) {}
    ~ArrayStack() { delete[] data_; }
    ArrayStack(const ArrayStack&) = delete;             // no double free on copy
    ArrayStack& operator=(const ArrayStack&) = delete;
    bool push(const T& v) {                              // overflow: report, don't write
        if (top_ == cap_) return false;
        data_[top_++] = v; return true;
    }
    bool pop(T& out) {                                   // underflow: report, don't read
        if (top_ == 0) return false;
        out = data_[--top_]; return true;
    }
    const T& peek() const {
        if (top_ == 0) throw std::out_of_range("peek on empty stack");
        return data_[top_ - 1];
    }
    bool empty() const { return top_ == 0; }
    bool full()  const { return top_ == cap_; }
};
Flow diagram. Top: push(v) leads to the decision top == cap; if no, it writes data[top++] = v. Bottom: pop(out) leads to the decision top == 0; if no, it reads out = data[--top]. Both decisions share one middle box, return false, reached on overflow from push and on underflow from pop.
The two checks the 2021 array stack was missing. Each one turns silent memory corruption into a return value the caller can test.

Pushing a to f into a 5-slot stack and draining it now prints:

push('f') rejected: stack full
e d c b a
pop on empty -> false

The same tests under ASan/UBSan finish without a report. Whether you return false, throw, or grow the array is a design choice; the one thing you cannot do is write anyway.

Application: bracket matching

The classic use of a stack is checking that (, [ and { are closed in the right order. Push the position of each opener; on a closer, pop and compare. A closer with nothing to pop, a wrong pair, or an opener left at the end is an error, and keeping positions lets you report where.

long check_brackets(std::string_view s) {      // -1 if balanced, else index of the problem
    ArrayStack<long> st(static_cast<int>(s.size()) + 1);
    for (long i = 0; i < (long)s.size(); i++) {
        char c = s[i];
        if (c == '(' || c == '[' || c == '{') { st.push(i); continue; }
        if (c != ')' && c != ']' && c != '}') continue;
        long open;
        if (!st.pop(open)) return i;                      // closer with no opener
        char o = s[open];
        if ((c == ')' && o != '(') || (c == ']' && o != '[') || (c == '}' && o != '{'))
            return i;                                     // wrong kind
    }
    long open;
    return st.pop(open) ? open : -1;                      // opener never closed
}

Output for five inputs:

if (a[i] == f(b)) { x(); }   OK
f(a[1)]                      error at index 5 (')')
while (x) { y(               error at index 13 ('(')
a + b) * c                   error at index 5 (')')
{[()()]}                     OK

Note the third case: the error points at the innermost unclosed (, because that is what is on top of the stack at the end. Sizing the stack to the input length means it can never overflow here.

The 2021 linked stack: leaks, a crash, and O(n) per operation

The second 2021 example built a stack from nodes holding a std::string. It kept a pointer to the bottom node (list) and the top node, and appended new nodes at the tail. That design has three problems we could measure.

Every node leaks. pop() unlinks the top node and returns the pointer, but nobody deletes it, and the class has no destructor. AddressSanitizer's leak checker is not supported on Apple Silicon macOS (detect_leaks is not supported on this platform), so we used the system leaks tool:

$ leaks --atExit -- ./linked_stack_2021
Process 63361: 6 leaks for 288 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in Stack::push(std::basic_string<char>)>'

Six pushes, six leaked nodes.

Pop on an empty stack crashes the caller. pop() prints a message and returns top, which is NULL, and the sample code immediately does ->data on the result. Adding one extra pop() to the 2021 main printed the message and then exited with code 139 (segmentation fault). Under ASan:

ERROR: AddressSanitizer: SEGV on unknown address 0x000000000017
    #3 ... in std::operator<<(..., const std::string&) basic_ostream.h:577

Each push and pop walks the list. To push, the code loops from the bottom node to the tail; to pop, it loops again to find the node below the top. That makes each operation O(n) and N operations O(N²). The 2021 text noted that the linked version "takes longer"; the cause is the tail insertion, not linking itself. Timings (push N, then pop N, -O2):

N 2021 tail-walk Push at head
10,000 113.9 ms 0.218 ms
20,000 567.8 ms 0.444 ms
40,000 1,913.2 ms 0.869 ms

The fix is to make the newest node the head: head = new Node{v, head}; to push, and take head and move to head->next to pop. Both are O(1), and the destructor pops until empty so nothing is left behind. Linking nodes in general is covered in linked lists.

Measured: array vs linked vs std::stack vs C# Stack

Same workload for every version: 10 million pushes, then 10 million pops of int. C++ built with Apple clang 21 -O2, C# with .NET 10.0.14 in Release, on an Apple M1 Pro; median of three runs.

Bar chart: array stack 5.4 ms, std::stack on vector 15.6 ms, std::stack on deque 21.2 ms, C# Stack<int> 150.4 ms, linked stack with one new and delete per element 260.8 ms.
10 million pushes followed by 10 million pops, median of 3 runs. C++: Apple clang 21, -O2. C#: .NET 10.0.14, Release. Apple M1 Pro. Shorter is faster.
Implementation Run 1 Run 2 Run 3
C++ ArrayStack (preallocated) 11.1 ms 5.4 ms 5.4 ms
std::stack<int, std::vector<int>> 41.2 ms 15.6 ms 13.4 ms
std::stack<int> (deque) 26.4 ms 20.7 ms 21.2 ms
C# Stack<int> 188.7 ms 150.4 ms 136.2 ms
C++ linked stack (head insert) 353.0 ms 260.8 ms 238.7 ms

The first run of each C++ version includes page faults for fresh memory, which is why it is slower. The linked stack pays for one new and one delete per element and touches memory scattered across the heap; the array versions write consecutive addresses. std::stack<int> is only an adapter: with vector it behaves like our array (plus growth), with the default deque it allocates in blocks.

The C# equivalent, verified on .NET 10:

var s = new Stack<char>();
foreach (var c in "abcde") s.Push(c);
while (s.TryPop(out var c)) Console.Write($"{c} ");     // e d c b a
Console.WriteLine(s.TryPop(out _));                       // False
s.Pop();   // InvalidOperationException: Stack empty.

Stack<T> grows its internal array automatically, so overflow is not a concern; underflow is, and TryPop/TryPeek are the methods to use when empty is a normal case.

Which stack to use

Situation Use
C++, general purpose std::stack<T> or std::stack<T, std::vector<T>>
C++, hard upper bound known (embedded, parser depth) Fixed array with overflow and underflow checks
No upper bound and no reallocation pauses allowed Linked stack, pushing at the head, with a destructor
C# Stack<T> with TryPop / TryPeek
Learning or interview code Write the array version, then run it under -fsanitize=address,undefined

The queue counterparts are in circular queue and linked queue, where the same two edge cases, full and empty, turn out to be harder to tell apart.

Tested with Apple clang 21.0.0 (-std=c++17/20 -Wall -Wextra, -fsanitize=address,undefined -g), macOS leaks, and .NET SDK 10.0.403 / runtime 10.0.14 on an Apple M1 Pro.

FAQ

What happens if you push onto a full array stack without a check?

The write lands past the end of the array. In a plain build our 6th push into a 5-slot stack printed nothing unusual and exited with code 0, while AddressSanitizer stopped it with heap-buffer-overflow (WRITE of size 1, 0 bytes after the 5-byte region). Check top == capacity before writing and return false, or throw.

Is a linked-list stack faster than an array stack?

No. 10 million pushes and pops took 5.4 ms with a preallocated array, 15.6 ms with std::stack on vector, 21.2 ms with std::stack on deque and 260.8 ms with a linked stack that does one new and one delete per element (Apple clang 21 -O2, Apple M1 Pro). The linked version only wins when you cannot bound the size and cannot afford a reallocation pause.

What should I use instead of writing my own stack?

In C++ use std::stack (deque by default) or std::stack<T, std::vector<T>>. In C# use Stack<T> with TryPop and TryPeek: Pop() on an empty stack throws InvalidOperationException with the message "Stack empty.", while TryPop simply returns false.

  • #stack
  • #C++
  • #data structures
  • #AddressSanitizer
  • #C#

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑