Loading learning content...
We've established that Abstract Data Types are defined by their operations—not by how they're stored or implemented. Now let's see this principle in vivid detail by examining one of the most ubiquitous ADTs in programming: the List.
The List ADT appears in virtually every programming language and underlies countless applications. Yet most developers interact with Lists without fully appreciating what operations define it, what contracts those operations establish, and why those definitions matter independently of implementation.
This page will dissect the List ADT operation by operation, showing how each contributes to defining what a List is. By the end, you'll understand that a List is not "a thing made of arrays or nodes"—it's an ordered, indexed collection with specific behavioral guarantees. The implementation is merely how those guarantees are fulfilled.
By the end of this page, you will understand the complete specification of the List ADT, recognize how operations collectively define an ADT's identity, appreciate the difference between essential operations and convenience methods, and see how the same List ADT can be implemented with dramatically different data structures.
Before examining individual operations, let's establish the List ADT's core identity:
The List ADT represents an ordered, indexed collection of elements where:
- Elements are arranged in a linear sequence
- Each element occupies a specific position (index), starting from 0
- Elements can be added, removed, and accessed by position
- Duplicate elements are permitted
- The collection can grow and shrink dynamically
This description captures the essence of a List—but notice what it does NOT say:
All of those are implementation concerns, not part of the ADT itself.
The List ADT vs. Similar ADTs:
It helps to contrast List with related ADTs to sharpen our understanding:
| ADT | Ordered? | Indexed? | Duplicates? | Key Property |
|---|---|---|---|---|
| List (Sequence) | Yes | Yes (by position) | Yes | Access by index; insertion preserves order |
| Set | Varies | No | No | Uniqueness; membership testing |
| Bag/Multiset | No | No | Yes (with counts) | Membership with multiplicity |
| Stack | Yes (LIFO implicit) | No (access only top) | Yes | LIFO access pattern |
| Queue | Yes (FIFO implicit) | No (access only front) | Yes | FIFO access pattern |
| Deque | Yes | No (access ends only) | Yes | Double-ended access |
The List's distinctive features are:
These characteristics emerge from the List's operations, which we'll now examine in detail.
Different communities use different terms: 'List', 'Sequence', 'Linear List', 'Ordered Collection'. Java uses 'List', Python uses 'list', C++ uses 'vector' and 'list' (confusingly, for different implementations). The ADT concept is the same regardless of naming.
Every ADT has a set of core operations—the minimal set that defines its essential nature. For the List ADT, these core operations are:
| Operation | Signature | Description |
|---|---|---|
| add | add(element) | Append element to end of list |
| get | get(index) → element | Retrieve element at specified index |
| set | set(index, element) | Replace element at specified index |
| remove | remove(index) | Remove element at specified index |
| size | size() → integer | Return count of elements |
| isEmpty | isEmpty() → boolean | Return true if list has no elements |
Let's examine each core operation in detail:
add(element) — Append to End
Operation: add(e)
Precondition:
- None (can add to empty or non-empty list)
Postcondition:
- Element e is now at position size()-1 (the last position)
- All existing elements retain their original indices
- size() increases by 1
Behavior:
- The list [A, B, C] after add(D) becomes [A, B, C, D]
- D is now at index 3
- Indices of A (0), B (1), C (2) unchanged
get(index) — Access by Position
Operation: get(i) → e
Precondition:
- 0 ≤ i < size() // Valid index range
Postcondition:
- Returns element at position i
- List is unchanged (this is a pure query)
Error Behavior:
- If i < 0 or i ≥ size(): IndexOutOfBoundsException
Behavior:
- List [A, B, C]: get(0)→A, get(1)→B, get(2)→C, get(3)→Error
set(index, element) — Modify by Position
Operation: set(i, e)
Precondition:
- 0 ≤ i < size() // Valid index range
Postcondition:
- Element at position i is now e
- Size unchanged
- All other elements unchanged
Typically Returns:
- The old element that was replaced (useful for undo operations)
Behavior:
- List [A, B, C]: after set(1, X): [A, X, C]
- Old element B may be returned
remove(index) — Delete by Position
Operation: remove(i)
Precondition:
- 0 ≤ i < size() // Valid index range
Postcondition:
- Element at position i is removed
- Elements at positions i+1, i+2, ... shift down by 1
- size() decreases by 1
Typically Returns:
- The removed element
Behavior:
- List [A, B, C, D]: after remove(1): [A, C, D]
- C moves from index 2 to index 1
- D moves from index 3 to index 2
size() — Query Count
Operation: size() → n
Precondition:
- None
Postcondition:
- Returns the current count of elements (n ≥ 0)
- List is unchanged
Invariant:
- After any add: new_size = old_size + 1
- After any remove: new_size = old_size - 1
- Empty list: size() = 0
These six operations—add, get, set, remove, size, isEmpty—are what make a List a List. Anything that provides these operations with the specified behavior IS a List, regardless of internal structure. Anything missing these operations or providing different behavior is NOT a List, regardless of naming.
Beyond core operations, most List implementations provide extended operations for convenience. These are derivable from core operations but provided directly for usability:
| Operation | Signature | Derivable From |
|---|---|---|
| add at index | add(index, element) | Shift elements, insert |
| contains | contains(element) → boolean | Iterate with get(), compare each |
| indexOf | indexOf(element) → integer | Iterate with get(), find match position |
| clear | clear() | Repeated remove() or direct reset |
| addAll | addAll(collection) | Repeated add() for each element |
| toArray | toArray() → array | Create array, iterate with get() |
| subList | subList(from, to) → List | Create new List, add elements in range |
| iterator | iterator() → Iterator | Enable traversal without index tracking |
Why Extended Operations Exist:
You could implement contains(e) using only core operations:
def contains(self, element):
for i in range(self.size()):
if self.get(i) == element:
return True
return False
But providing it directly offers advantages:
list.contains(x) is more readable than a loopImportant Distinction:
Extended operations don't add to the ADT's definition—they're conveniences built atop the core. However, they may have different performance characteristics depending on implementation:
| Operation | ArrayList | LinkedList |
|---|---|---|
| contains(e) | O(n) - scan array | O(n) - traverse nodes |
| indexOf(e) | O(n) - scan array | O(n) - traverse nodes |
| add(index, e) | O(n) - shift elements | O(n) - find position, O(1) insert |
Even though both provide identical behavior, performance differs based on underlying structure.
The add(index, element) Operation — Insert at Position:
Operation: add(i, e)
Precondition:
- 0 ≤ i ≤ size() // Note: i = size() means append
Postcondition:
- Element e is now at position i
- Elements formerly at positions i, i+1, ... shift to i+1, i+2, ...
- size() increases by 1
Behavior:
- List [A, B, C]: after add(1, X): [A, X, B, C]
- X is inserted at index 1
- B moves from index 1 to index 2
- C moves from index 2 to index 3
- add(0, e) inserts at front
- add(size(), e) is equivalent to add(e) — appends to end
This operation is particularly interesting because it shows how the List maintains its ordering invariant: elements shift to make room, preserving relative order of existing elements.
What counts as 'core' vs. 'extended' can vary by specification. Some List definitions include contains() as core; others derive it. The key insight is that a minimal set of operations can define an ADT, with additional operations provided for convenience without changing the ADT's nature.
Beyond individual operations, the List ADT is defined by invariants—properties that always hold, no matter what sequence of operations is performed. These invariants distinguish List from other ADTs.
Why Invariants Matter:
Invariants are the rules of the game. They're what clients rely on:
# Client relies on contiguous indexing invariant
for i in range(list.size()):
print(list.get(i)) # This MUST work for all i in range
# Client relies on order preservation
list.add("first")
list.add("second")
assert list.get(0) == "first" # Order is preserved
assert list.get(1) == "second" # Not scrambled
# Client relies on size consistency
initial_size = list.size()
list.add("new")
assert list.size() == initial_size + 1 # Size updated correctly
If an implementation violated any invariant, client code would break. For example:
When you implement a List, you MUST maintain these invariants. Violating them isn't just 'suboptimal'—it means you're not implementing the List ADT at all, regardless of what your class is named.
For those who appreciate mathematical rigor, we can specify the List ADT using equational axioms. These axioms define List behavior precisely and independently of any implementation.
Signature (Operations):
SORT List[E] -- A List containing elements of type E
NEW empty() -> List -- Create empty list
MUTATOR add(List, E) -> List -- Add element to end
MUTATOR addAt(List, N, E) -> List -- Add element at index
MUTATOR remove(List, N) -> List -- Remove element at index
MUTATOR set(List, N, E) -> List -- Set element at index
QUERY get(List, N) -> E -- Get element at index
QUERY size(List) -> N -- Count of elements
QUERY isEmpty(List) -> Bool -- Check if empty
123456789101112131415161718192021222324252627282930313233
-- List ADT Algebraic Axioms-- N represents natural numbers (non-negative integers)-- E represents element type -- Size AxiomsAXIOM 1: size(empty()) = 0AXIOM 2: size(add(L, e)) = size(L) + 1AXIOM 3: size(addAt(L, i, e)) = size(L) + 1 [when 0 ≤ i ≤ size(L)]AXIOM 4: size(remove(L, i)) = size(L) - 1 [when 0 ≤ i < size(L)]AXIOM 5: size(set(L, i, e)) = size(L) [when 0 ≤ i < size(L)] -- isEmpty AxiomsAXIOM 6: isEmpty(empty()) = TRUEAXIOM 7: isEmpty(add(L, e)) = FALSE -- get Axioms (most complex - define positional access)AXIOM 8: get(add(L, e), size(L)) = e -- Last element is the one we addedAXIOM 9: get(add(L, e), i) = get(L, i) [when i < size(L)] -- Others unchangedAXIOM 10: get(set(L, i, e), i) = e -- Set position: new valueAXIOM 11: get(set(L, i, e), j) = get(L, j) [when i ≠ j] -- Other positions unchanged -- addAt Axioms (insertion shifts elements)AXIOM 12: get(addAt(L, i, e), i) = e -- Inserted element at iAXIOM 13: get(addAt(L, i, e), j) = get(L, j) [when j < i] -- Elements before: unchangedAXIOM 14: get(addAt(L, i, e), j) = get(L, j-1) [when j > i] -- Elements after: shifted -- remove Axioms (removal shifts elements)AXIOM 15: get(remove(L, i), j) = get(L, j) [when j < i] -- Elements before: unchangedAXIOM 16: get(remove(L, i), j) = get(L, j+1) [when j ≥ i] -- Elements after: shifted left -- Error ConditionsAXIOM 17: get(empty(), i) = ERROR -- Can't get from empty listAXIOM 18: get(L, i) = ERROR [when i < 0 or i ≥ size(L)]What These Axioms Achieve:
These axioms completely specify List behavior:
Example Derivation:
Let's trace: get(add(add(empty(), 'A'), 'B'), 0)
get(add(add(empty(), 'A'), 'B'), 0)
= get(add([A], 'B'), 0) -- Inner add produces [A]
= get([A], 0) by Axiom 9 -- 0 < size([A])=1, so get from inner
= 'A' -- Element at index 0
Verification: Starting with empty list, we added 'A' (index 0), then 'B' (index 1). get(0) should return 'A'. ✓
Any implementation satisfying these axioms IS a correct List. Any implementation violating any axiom is NOT a List, regardless of naming. This mathematical rigor is why we can trust that different List implementations are truly interchangeable—they all satisfy the same axioms.
The List ADT can be implemented with fundamentally different data structures, each satisfying the same behavioral contract but with different performance trade-offs.
Implementation 1: Dynamic Array (ArrayList)
class ArrayList:
def __init__(self):
self._data = [None] * 10 # Initial capacity
self._size = 0 # Actual element count
def add(self, element):
if self._size == len(self._data):
self._resize() # Double capacity when full
self._data[self._size] = element
self._size += 1
def get(self, index):
if not 0 <= index < self._size:
raise IndexError()
return self._data[index] # Direct array access
def remove(self, index):
if not 0 <= index < self._size:
raise IndexError()
# Shift elements left
for i in range(index, self._size - 1):
self._data[i] = self._data[i + 1]
self._size -= 1
def size(self):
return self._size
Key Characteristics:
Implementation 2: Linked List (LinkedList)
class Node:
def __init__(self, value):
self.value = value
self.next = None
class LinkedList:
def __init__(self):
self._head = None
self._size = 0
def add(self, element):
new_node = Node(element)
if self._head is None:
self._head = new_node
else:
current = self._head
while current.next:
current = current.next
current.next = new_node
self._size += 1
def get(self, index):
if not 0 <= index < self._size:
raise IndexError()
current = self._head
for _ in range(index):
current = current.next
return current.value
def remove(self, index):
if not 0 <= index < self._size:
raise IndexError()
if index == 0:
self._head = self._head.next
else:
current = self._head
for _ in range(index - 1):
current = current.next
current.next = current.next.next
self._size -= 1
def size(self):
return self._size
Key Characteristics:
| Operation | ArrayList | LinkedList | Key Insight |
|---|---|---|---|
| get(index) | O(1) | O(n) | Array wins: direct address calculation |
| set(index, e) | O(1) | O(n) | Array wins: same as get |
| add(e) (append) | O(1) amortized | O(n) or O(1)* | *O(1) if tail pointer maintained |
| add(0, e) (prepend) | O(n) | O(1) | Linked wins: just update head |
| add(i, e) (middle) | O(n) | O(n) | Both O(n), but linked doesn't shift elements |
| remove(0) | O(n) | O(1) | Linked wins: just update head |
| remove(last) | O(1) | O(n) | Array wins: no traversal needed |
| Memory | Potential waste | Overhead per node | Array may over-allocate; linked has pointer overhead |
Both ArrayList and LinkedList implement the SAME List ADT—same operations, same behavioral guarantees. But performance differs dramatically. Choosing between them is an implementation decision based on access patterns, not an ADT decision.
The true power of thinking in ADTs is that client code can be written against the List interface without any knowledge of—or dependence on—the implementation.
Example: ADT-Focused Code
// This code works with ANY List implementation
public class ListProcessor {
public static <T> T findLast(List<T> list) {
if (list.isEmpty()) {
throw new NoSuchElementException("List is empty");
}
return list.get(list.size() - 1);
}
public static <T> void removeDuplicates(List<T> list) {
Set<T> seen = new HashSet<>();
int i = 0;
while (i < list.size()) {
if (seen.contains(list.get(i))) {
list.remove(i); // Don't increment i; next element slides to i
} else {
seen.add(list.get(i));
i++;
}
}
}
public static <T> List<T> reverse(List<T> list) {
List<T> reversed = new ArrayList<>(); // Could parameterize this too
for (int i = list.size() - 1; i >= 0; i--) {
reversed.add(list.get(i));
}
return reversed;
}
}
Note what this code does NOT do:
Switching Implementations:
Because ListProcessor depends only on the List ADT, we can use it with any implementation:
public class Main {
public static void main(String[] args) {
// Works with ArrayList
List<Integer> arrayList = new ArrayList<>();
arrayList.add(1); arrayList.add(2); arrayList.add(3);
System.out.println(ListProcessor.findLast(arrayList)); // 3
// Works identically with LinkedList
List<Integer> linkedList = new LinkedList<>();
linkedList.add(1); linkedList.add(2); linkedList.add(3);
System.out.println(ListProcessor.findLast(linkedList)); // 3
// Works with any future List implementation
List<Integer> customList = new MyCustomList<>(); // Some new class
customList.add(1); customList.add(2); customList.add(3);
System.out.println(ListProcessor.findLast(customList)); // 3
}
}
This is the payoff of ADT thinking:
ListProcessor code never changes regardless of implementationWhen code depends on the ADT (List) rather than the implementation (ArrayList), you gain implementation freedom. Today's ArrayList can become tomorrow's LinkedList, or a custom database-backed list, or a distributed list—with zero changes to client code.
The List ADT is universal, but its expression varies across languages. Understanding how different languages represent the same ADT deepens your appreciation of the abstraction.
| Language | Interface | Common Implementations | Notes |
|---|---|---|---|
| Java | java.util.List<E> | ArrayList, LinkedList, Vector, CopyOnWriteArrayList | Explicit interface in type system |
| C# | System.Collections.Generic.IList<T> | List<T>, LinkedList<T> | IList is the interface; List is array-based |
| Python | collections.abc.MutableSequence | list (built-in), collections.deque | Duck typing; list IS the implementation |
| C++ | (implicit via STL concepts) | std::vector, std::list, std::deque | No explicit interface; templates provide polymorphism |
| JavaScript | (no explicit interface) | Array (built-in) | Array is the only built-in ordered collection |
| Go | (no generic interface) | slice (built-in) | Slices are the ordered collection type |
| Rust | (via traits) | Vec<T>, LinkedList<T> | Traits define behavior; Vec is the common choice |
Observations:
Languages with explicit interfaces (Java, C#) make the ADT visible in the type system. You can declare a variable of type List<T> without committing to an implementation.
Languages with duck typing (Python, JavaScript) rely on convention. If it has the right methods, it's a List—but there's no formal type checking.
Languages with templates/generics but no interfaces (C++, Go) often provide a single dominant implementation that becomes the de facto ADT representation.
The ADT Transcends Language:
Despite these differences, the concept of List is the same everywhere:
Whether you write list.get(i) (Java) or list[i] (Python) or list.at(i) (C++), you're performing the same ADT operation. The syntax varies; the semantics are identical.
When learning a new language, identify: 'What is this language's List?' Find the ordered, indexed, dynamically-sized collection and apply your List ADT understanding. The operations will have the same meaning—only syntax differs.
We've thoroughly examined the List ADT as a case study in operation-defined abstraction. Let's consolidate:
What's Next:
Now that we've seen how the List ADT can have multiple implementations, let's generalize this principle. The next page explores how the same ADT can be implemented with vastly different data structures—and how understanding this multiplicity enables informed trade-off decisions in real engineering.
You've now experienced how operations define an ADT through the lens of the List. The add, get, set, remove operations—and their behavioral contracts—are what make a List a List. This understanding prepares you to recognize ADT thinking in all data structures you encounter.