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.

An abstract data type contract above interchangeable representations Clients rely on observable values, operations, laws, failure behavior, complexity, and lifetime. A proof boundary separates that contract from three possible representations: a dynamic array, linked nodes, and a ring buffer. Each implementation has its own representation invariant and abstraction function. Abstract contract values · operations · laws · errors complexity · stability · ownership · concurrency the complete set of legal client assumptions refinement / proof boundary Dynamic array I: live prefix within capacity Linked nodes I: one finite reachable chain Ring buffer I: head + size define sequence Each valid concrete state c maps through α(c) to the same abstract value.
The proof boundary lets several representations realize one observable contract without leaking their layouts to clients.

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:

  1. Abstract states or values. A queue can be modeled as a finite sequence q=⟨x0,…,xn−1⟩q=\langle x_0,\ldots,x_{n-1}\rangle.
  2. Operations. empty, size, front, push_back, and pop_front.
  3. Preconditions and results. Is front(empty) illegal, optional, blocking, or a sentinel?
  4. Laws. For example, after pushing xx into an empty queue, front returns xx; removing the front from ⟨x⟩+q\langle x\rangle\mathbin{+}q yields qq.

Guttag's algebraic view specifies an operation by equations over other operations rather than by exposing fields. For a FIFO queue:

front(push(⟨⟩,x))=x,\mathop{\mathrm{front}}(\mathop{\mathrm{push}}(\langle\rangle,x))=x,

front(push(q,x))=front(q)when q≠⟨⟩.\mathop{\mathrm{front}}(\mathop{\mathrm{push}}(q,x))=\mathop{\mathrm{front}}(q) \quad\text{when }q\ne\langle\rangle.

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 O(1)O(1) for a linked tail, amortized O(1)O(1) for a growing vector, or expected O(1)O(1) 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 CC;
  • a representation invariant I(c)I(c) identifying valid concrete states;
  • an abstraction function α:C→A\alpha:C\to A mapping a valid concrete state to its abstract value.

For a ring-buffer queue with array data, capacity m>0m>0, logical length nn, and head index hh:

0≤n≤m,0≤h<m,0\le n\le m,\qquad 0\le h<m,

α(data,h,n)=⟨data[h],data[(h+1) mod m],…,data[(h+n−1) mod m]⟩.\alpha(data,h,n)= \langle data[h],data[(h+1)\bmod m],\ldots,data[(h+n-1)\bmod m]\rangle.

The physical array may wrap, but the abstract queue is one sequence. push_back(x) writes at $(h+n)\bmod m; pop_front()` advances hh and decrements nn. Correctness requires proving each operation:

  1. accepts only its declared precondition;
  2. preserves II;
  3. produces the abstract transition required by the contract;
  4. 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 nn elements:

Representation top push pop Stable element addresses Main cost
dynamic array O(1)O(1) O(1)O(1) amortized, O(n)O(n) growth step O(1)O(1) no across reallocation excellent locality, occasional relocation
singly linked head O(1)O(1) O(1)O(1) worst-case after allocation O(1)O(1) yes for other nodes allocation and pointer chasing
fixed-capacity array O(1)O(1) O(1)O(1) until full O(1)O(1) 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.

stack_contract.cppcpp
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,c then repeatedly reading/removing front yields a,b,c;
  • size changes 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:

  1. What mathematical value does one instance represent?
  2. What does each operation observe or change?
  3. What happens on empty, full, absent, duplicate, and invalid input?
  4. Which ordering is observable?
  5. Which complexity quantifier and cost model are promised?
  6. What survives mutation: values, addresses, iterators, indices, or no handles?
  7. Who owns each object and when may it be reclaimed?
  8. What state remains after allocation, comparison, or user-callback failure?
  9. Which concurrent histories are legal?
  10. 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 #

  1. Specify a bounded queue whose push rejects when full. Give abstract states, operations, and two laws.
  2. Define an abstraction function for a stack stored in data[0:size).
  3. Explain why std::priority_queue and a sorted set are not the same ADT despite both returning a maximum.
  4. Design a law-based test that distinguishes FIFO from LIFO without inspecting representation.
Solutions
  1. States are sequences of length at most capacity cc. push(q,x) succeeds with q+⟨x⟩q+\langle x\rangle when ∣q∣<c|q|<c and returns full without changing qq otherwise. pop removes the first element when nonempty. Laws include front(push(empty,x))=x and failed push preserves both sequence and size.
  2. II requires 0 <= size <= capacity and exactly data[0:size) contains live elements. $\alpha(data,size)=\langle data[0],...,data[size-1]\rangle, with data[size-1]` the abstract top.
  3. 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.
  4. Push distinct a, then b; the first removal must be a for FIFO and b for LIFO.

Sources

  1. bookAlgorithms in C++, Third Edition, Parts 1–4: Fundamentals, Data Structures, Sorting, Searchingnotes
  2. paperAbstract Data Types and the Development of Data Structures (Guttag, 1977)
  3. docsC++ Working Draft N5050 — Containers library

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close