Abstract Data Types & Contracts
An abstract data type defines observable values and operations independently of representation. This chapter turns that separation into an engineering method: specify semantics, errors, complexity, stability, ownership, and concurrency; then prove a concrete representation preserves the contract.
An abstract data type (ADT) answers what clients may observe. A data structure answers how one implementation realizes it. A stack is LIFO whether its elements live in a dynamic array or linked nodes; a priority queue returns an extremal-priority item whether it uses a heap, balanced tree, or buckets. Keeping those layers separate lets an implementation change without rewriting every client—and exposes when two containers with similar method names actually promise different behavior.
The separator is a proof boundary. Client code reasons from operations and laws. Implementation code preserves a representation invariant and shows that every operation realizes the same abstract transition.
An ADT is values, operations, and laws #
A useful specification has at least four parts:
- Abstract states or values. A queue can be modeled as a finite sequence .
- Operations.
empty,size,front,push_back, andpop_front. - Preconditions and results. Is
front(empty)illegal, optional, blocking, or a sentinel? - Laws. For example, after pushing into an empty queue,
frontreturns ; removing the front from yields .
Guttag's algebraic view specifies an operation by equations over other operations rather than by exposing fields. For a FIFO queue:
Those equations reject a stack implementation even if both containers happen to offer push and pop: the observable removal order differs.
A production contract is wider than method names #
Two APIs can expose insert, erase, and find yet be incompatible. Specify every observable axis that clients may rely on:
| Axis | Questions that must have one answer |
|---|---|
| identity and duplicates | Are keys unique? Does a duplicate replace, reject, count, or append another value? |
| absence and invalid operations | Option/result, exception, blocking wait, sentinel, or undefined precondition? |
| ordering | Is iteration insertion-ordered, key-ordered, heap-ordered only at the root, or unspecified? |
| complexity | Worst-case, expected, or amortized? In comparisons, key units, allocations, or block transfers? |
| stability | Which pointers, references, iterators, indices, or handles survive insert/erase/growth? |
| ownership and lifetime | Does the container own values, borrow them, share them, or store handles? |
| failure atomicity | On allocation/comparison failure, is state unchanged, valid-but-unspecified, or partially updated? |
| concurrency | Which operations may overlap, under what synchronization and memory-reclamation protocol? |
Complexity is part of the interface only when its quantifier is explicit. “Constant append” can mean worst-case for a linked tail, amortized for a growing vector, or expected for a hash-backed structure. These promises are not interchangeable on a latency-critical path.
From abstract value to representation #
To implement an ADT rigorously, define:
- a concrete representation set ;
- a representation invariant identifying valid concrete states;
- an abstraction function mapping a valid concrete state to its abstract value.
For a ring-buffer queue with array data, capacity , logical length , and head index :
The physical array may wrap, but the abstract queue is one sequence. push_back(x) writes at $(h+n)\bmod m; pop_front()` advances and decrements . Correctness requires proving each operation:
- accepts only its declared precondition;
- preserves ;
- produces the abstract transition required by the contract;
- meets the declared cost and failure guarantee.
This distinction also prevents representation leakage. A client that depends on the ring buffer's current head index is no longer programming against the queue ADT.
One ADT can have several honest representations #
Assume a stack of elements:
| Representation | top |
push |
pop |
Stable element addresses | Main cost |
|---|---|---|---|---|---|
| dynamic array | amortized, growth step | no across reallocation | excellent locality, occasional relocation | ||
| singly linked head | worst-case after allocation | yes for other nodes | allocation and pointer chasing | ||
| fixed-capacity array | until full | stable if object does not move | overflow must be in the contract |
All three can satisfy LIFO laws. They do not satisfy the same resource, stability, or failure contract. Representation selection is therefore a workload decision made after semantics are fixed.
template<class T>
class Stack {
public:
[[nodiscard]] bool empty() const noexcept;
[[nodiscard]] std::size_t size() const noexcept;
// Preconditions: !empty(). Reference remains valid only under the
// invalidation rules promised by this particular Stack type.
T& top();
const T& top() const;
void push(T value);
void pop(); // Preconditions: !empty().
};The interface sketch is still incomplete until documentation states invalidation, exception, allocator, and concurrency behavior. Types encode some obligations; prose and tests must encode the rest.
Refinement tests exercise laws, not layouts #
An implementation test should not assert that a queue's head index equals three. It should assert observable laws across command sequences:
- pushing
a,b,cthen repeatedly reading/removing front yieldsa,b,c; sizechanges by exactly one after successful push/pop;- wraparound produces the same sequence as a non-wrapped reference model;
- a failed growth operation leaves the queue in its promised state;
- retained handles behave according to the invalidation contract.
A small executable reference model—often a plain sequence—can generate expected results for randomized operation traces. This is refinement testing: the concrete implementation and the model must remain observationally equivalent.
Representation invariants still deserve internal assertions and targeted tests. They diagnose the concrete cause; ADT-law tests prove clients cannot see a semantic difference.
Abstraction boundaries fail in predictable ways #
| Symptom | Lost contract |
|---|---|
| changing vector to list breaks callers | callers depended on indexing or contiguity not declared by the abstract interface |
| “constant-time” operation stalls | expected/amortized/worst-case quantifier was omitted |
| empty pop corrupts state | invalid-operation semantics were never specified or checked |
| duplicate insert behaves inconsistently | unique/multi/replacement policy leaked from representation |
| iterator works in tests but dangles in production | mutation and invalidation contract was missing |
| lock added around methods but iteration races | operation-level atomicity did not define multi-step observations or lifetime |
| exception leaves half of a secondary index updated | failure atomicity and cross-index invariant were not part of the ADT |
Choosing and reviewing an ADT #
Before choosing a container or approving an interface, answer:
- What mathematical value does one instance represent?
- What does each operation observe or change?
- What happens on empty, full, absent, duplicate, and invalid input?
- Which ordering is observable?
- Which complexity quantifier and cost model are promised?
- What survives mutation: values, addresses, iterators, indices, or no handles?
- Who owns each object and when may it be reclaimed?
- What state remains after allocation, comparison, or user-callback failure?
- Which concurrent histories are legal?
- What invariant and abstraction function let an implementation prove refinement?
If those questions have crisp answers, selecting a data structure becomes engineering. Without them, switching containers is guesswork.
Exercises #
- Specify a bounded queue whose
pushrejects when full. Give abstract states, operations, and two laws. - Define an abstraction function for a stack stored in
data[0:size). - Explain why
std::priority_queueand a sorted set are not the same ADT despite both returning a maximum. - Design a law-based test that distinguishes FIFO from LIFO without inspecting representation.
Solutions
- States are sequences of length at most capacity .
push(q,x)succeeds with when and returnsfullwithout changing otherwise.popremoves the first element when nonempty. Laws includefront(push(empty,x))=xand failed push preserves both sequence and size. - requires
0 <= size <= capacityand exactlydata[0:size)contains live elements. $\alpha(data,size)=\langle data[0],...,data[size-1]\rangle, withdata[size-1]` the abstract top. - A priority queue normally exposes only an extremum and does not promise full ordered iteration, unique keys, predecessor, or arbitrary lookup. A sorted set promises uniqueness and order-sensitive queries; its stronger contract constrains representation and costs.
- Push distinct
a, thenb; the first removal must beafor FIFO andbfor LIFO.