-- FPCourse/T05_AbstractTypesAndTypeClasses/W11_AbstractTypes.lean
import Mathlib.Data.List.Basic
import Mathlib.Data.Option.Basic
Abstract Types
Abstraction via type classes
An abstract type presents an interface — a collection of operations with specified types — while hiding the implementation. Callers program against the interface; the implementation can change without affecting callers.
In Lean, type classes express abstract types. A class declaration is an interface. An instance declaration is an implementation. Laws stated in the class are the specification: propositions that every implementation must satisfy.
The connection to Week 10: the specification for Dict is relational.
A dictionary is a partial function — a relation where each key maps to
at most one value. The laws of Dict are laws of partial functions.
namespace W11
11.1 The Dict interface
A dictionary maps keys to values. Operations: empty dict, insert, lookup, and delete.
class Dict (d : Type → Type → Type) where
empty : d k v
insert : k → v → d k v → d k v
lookup : [DecidableEq k] → k → d k v → Option v
delete : [DecidableEq k] → k → d k v → d k v
11.2 Laws: the specification for Dict
The laws below are propositions that every Dict implementation must
satisfy. They define what it MEANS to be a dictionary.
These are relational specifications in the sense of Week 10: they describe how the abstract state (a partial function from keys to values) changes under each operation.
class LawfulDict (d : Type → Type → Type) [DecidableEq k] extends Dict d where
lookup_empty : ∀ (key : k), lookup key (empty : d k v) = none
lookup_insert_same : ∀ (key : k) (val : v) (m : d k v),
lookup key (insert key val m) = some val
lookup_insert_diff : ∀ (k1 k2 : k) (val : v) (m : d k v),
k1 ≠ k2 → lookup k1 (insert k2 val m) = lookup k1 m
lookup_delete_same : ∀ (key : k) (m : d k v),
lookup key (delete key m) = none
lookup_delete_diff : ∀ (k1 k2 : k) (m : d k v),
k1 ≠ k2 → lookup k1 (delete k2 m) = lookup k1 m
11.3 Association list implementation
An association list stores key-value pairs in a list.
def AList (k v : Type) := List (k × v)
def AList.empty : AList k v := []
def AList.insert (key : k) (val : v) (m : AList k v) : AList k v :=
(key, val) :: m
def AList.lookup [DecidableEq k] (key : k) : AList k v → Option v
| [] => none
| (k, v) :: t => if key == k then some v else AList.lookup key t
def AList.delete [DecidableEq k] (key : k) : AList k v → AList k v
| [] => []
| (k, v) :: t => if key == k then AList.delete key t
else (k, v) :: AList.delete key t
instance : Dict AList where
empty := AList.empty
insert := AList.insert
lookup := AList.lookup
delete := AList.delete
-- Verify the laws hold. Provided as term-mode proofs:
theorem alist_lookup_empty [DecidableEq k] (key : k) :
AList.lookup key (AList.empty : AList k v) = none :=
rfl
theorem alist_lookup_insert_same [DecidableEq k] (key : k) (val : v) (m : AList k v) :
AList.lookup key (AList.insert key val m) = some val := by
simp [AList.lookup, AList.insert]
theorem alist_lookup_insert_diff [DecidableEq k] {k1 k2 : k} (val : v)
(m : AList k v) (hne : k1 ≠ k2) :
AList.lookup k1 (AList.insert k2 val m) = AList.lookup k1 m := by
simp [AList.lookup, AList.insert, hne]
theorem alist_lookup_delete_same [DecidableEq k] (key : k) (m : AList k v) :
AList.lookup key (AList.delete key m) = none := by
induction m with
| nil => rfl
| cons hd t ih =>
obtain ⟨k', v'⟩ := hd
by_cases h : key = k'
· subst h; simp [AList.delete, ih]
· simp [AList.delete, AList.lookup, h, ih]
theorem alist_lookup_delete_diff [DecidableEq k] {k1 k2 : k} (m : AList k v)
(hne : k1 ≠ k2) :
AList.lookup k1 (AList.delete k2 m) = AList.lookup k1 m := by
induction m with
| nil => rfl
| cons hd t ih =>
obtain ⟨k', v'⟩ := hd
by_cases h : k2 = k'
· subst h; simp [AList.delete, AList.lookup, ih, hne]
· by_cases h1 : k1 = k'
· subst h1; simp [AList.delete, AList.lookup, h]
· simp [AList.delete, AList.lookup, h, h1, ih]
All five laws now hold for AList, so we can package the implementation together with
its proofs as a LawfulDict instance — the formal claim that AList satisfies the
dictionary specification, not merely that it type-checks against the Dict interface.
instance {k : Type} [DecidableEq k] : LawfulDict (d := AList) (k := k) where
toDict := inferInstance
lookup_empty := alist_lookup_empty
lookup_insert_same := alist_lookup_insert_same
lookup_insert_diff := fun _ _ val m h => alist_lookup_insert_diff val m h
lookup_delete_same := alist_lookup_delete_same
lookup_delete_diff := fun _ _ m h => alist_lookup_delete_diff m h
Checkpoint —
AList.insertthenAList.lookup.insertconses(key, val)onto the front;lookupscans front-to-back. Predict what looking up a just-inserted key returns, then check.
#eval (AList.lookup 1 (AList.insert 1 100 (AList.empty : AList Nat Nat))) -- predict first
Checkpoint —
AList.lookupon an absent key (lookup_empty). Predict, fromlookup_empty, whatlookupreturns for a key that was never inserted, then check.
#eval (AList.lookup 5 (AList.insert 1 100 (AList.empty : AList Nat Nat))) -- predict first
Checkpoint — the most recent
insertwins (lookup_insert_same).insertnever deletes the old pair, butlookupfinds the front one first. Predict, fromlookup_insert_same, which value survives after inserting key1twice, then check.
#eval (AList.lookup 1 (AList.insert 1 200 (AList.insert 1 100 (AList.empty : AList Nat Nat)))) -- predict first
Checkpoint —
AList.deleteremoves a key (lookup_delete_same). Predict, fromlookup_delete_same, whatlookupreturns for a key after it is deleted, then check.
#eval (AList.lookup 1 (AList.delete 1 (AList.insert 1 100 (AList.empty : AList Nat Nat)))) -- predict first
Checkpoint —
deleteleaves other keys alone (lookup_delete_diff). Deleting key1must not disturb key2. Predict, fromlookup_delete_diff, the lookup of2, then check.
#eval (AList.lookup 2 (AList.delete 1 (AList.insert 2 20 (AList.insert 1 10 (AList.empty : AList Nat Nat))))) -- predict first
11.4 Opaque types: hiding implementation details
The opaque keyword makes an identifier’s definition irreducible to
the elaborator. Proofs about an opaque value must work through the
interface, not by unfolding the definition.
This is abstraction enforced by the type system: callers cannot depend on the implementation details even if they tried.
-- A counter type with an opaque implementation
opaque Counter : Type := Nat
@[instance] axiom Counter.instNonempty : Nonempty Counter
noncomputable opaque Counter.zero : Counter
noncomputable opaque Counter.incr : Counter → Counter
noncomputable opaque Counter.value : Counter → Nat
-- The specification is stated separately as axioms about the interface:
axiom Counter.value_zero : Counter.value Counter.zero = 0
axiom Counter.value_incr : ∀ c, Counter.value (Counter.incr c) =
Counter.value c + 1
-- Note: in a production library, these axioms would be proved as theorems
-- using the concrete implementation. The opaque/axiom pattern separates
-- the interface (what callers see) from the implementation.
Checkpoint — an
opaquevalue cannot be evaluated. UnlikeAList, aCounterhas no reducible definition, so#eval Counter.value Counter.zerowould fail to compute — everything you can know about it lives in the axioms. So here we#checkinstead of#eval. Predict the type Lean reports forCounter.value_incr(a∀-statement about the interface), then check.
#check @Counter.value_incr -- predict the ∀-statement; opaque, so no #eval
11.5 Stack: another abstract type
A stack supports push, pop, and peek, with a size operation. The specification: push then pop returns the original element and stack.
class Stack (s : Type → Type) where
empty : s α
push : α → s α → s α
pop : s α → Option (α × s α)
size : s α → Nat
class LawfulStack (s : Type → Type) extends Stack s where
pop_empty : pop (empty : s α) = none
pop_push : ∀ (x : α) (st : s α),
pop (push x st) = some (x, st)
size_empty : size (empty : s α) = 0
size_push : ∀ (x : α) (st : s α),
size (push x st) = size st + 1
-- List implementation of Stack:
instance : Stack List where
empty := []
push := List.cons
pop := fun
| [] => none
| h :: t => some (h, t)
size := List.length
instance : LawfulStack List where
pop_empty := rfl
pop_push := fun _ _ => rfl
size_empty := rfl
size_push := fun _ _ => rfl
Checkpoint —
popundoespush(pop_push). For theListstack,pushisconsandpopsplits head from tail. Predict, frompop_push, the pair returned after pushing5, then check.
#eval (Stack.pop (Stack.push 5 ([1, 2, 3] : List Nat))) -- predict first
Checkpoint —
pushgrowssizeby one (size_push). Predict, fromsize_push, the size after onepushonto a 3-element stack, then check.
#eval (Stack.size (Stack.push 5 ([1, 2, 3] : List Nat))) -- predict first
Checkpoint —
popon the empty stack (pop_empty). Predict, frompop_empty, whatpopreturns when there is nothing to remove, then check.
#eval (Stack.pop ([] : List Nat)) -- predict first
11.6 Representation independence
The key property of abstract types: any two implementations satisfying the laws are observationally equivalent from the caller’s perspective.
This is not just informal. Given two LawfulDict instances D1 and D2,
any sequence of empty, insert, lookup, delete operations produces
the same lookup results in both.
This can be stated as a theorem schema — for each sequence of operations,
the lookup results agree. We will not prove this in full generality;
stating it precisely is the skill being practiced.
Checkpoint — observations are what implementations must share. Representation independence says two lawful stacks agree on every observation (
pop,size), even if their internals differ. A push-push-pop sequence is such an observation. Predict the LIFO result below, then check.
#eval (Stack.pop (Stack.push 9 (Stack.push 8 ([] : List Nat)))) -- predict first (LIFO)
11.7 Representation invariants and abstraction functions
A concrete representation usually admits values the abstract type should never observe.
An AList can hold duplicate keys — [(1, 10), (1, 20)] — yet a dictionary is a
partial function, so a key must map to at most one value. A representation invariant
carves the legal representations out of the concrete type, and an abstraction function
maps each legal representation to the abstract value it denotes.
Lean expresses “the values satisfying an invariant” with a refinement type (subtype)
{ x // Inv x }: a pair of a value x and a proof that Inv x holds. The invariant
below is “keys are distinct” — (m.map Prod.fst).Nodup. The provided value carries a
machine-checked proof that its three keys are distinct. You will read this proof in the
exercises, never author one.
def wfExample : { m : AList Nat Nat // (m.map Prod.fst).Nodup } :=
⟨[(1, 10), (2, 20), (3, 30)], by decide⟩
Checkpoint — the representation invariant is decidable.
Nodupover a concrete list ofDecidableEqkeys is decidable — which is exactly whyby decidecan discharge the proof insidewfExample. Predict the Boolean below — dowfExample’s keys satisfy the invariant? — then check.
#eval decide ((([(1, 10), (2, 20), (3, 30)] : AList Nat Nat).map Prod.fst).Nodup) -- predict first
Exercises
Each exercise carries a banner — [id] · competency · tier · level · target — and,
where it asks you to build something, an acceptance check: paste it beneath your
definition in your own file and it must succeed. #guard is silent on success and
errors on failure, so the compiler is your grader. See EXERCISE_CONVENTIONS.md for
the schema. Do every core exercise; stretch exercises go deeper and are
optional.
[E11.1] · specification writing · tier 1 · core · target AList.size
Add a size operation to the association-list dictionary and specify how it relates to
the other operations. Define AList.size (m : AList k v) : Nat, then state — as Props
in your own words — the two laws “the empty dict has size 0” and “insert grows size
by exactly one” (AList never deduplicates, so the second holds literally, even for a
repeated key). Confirm them on instances; the checks are the grader. Effort: 1 line of
code; then say which check is the boundary case and which law each remaining check
witnesses.
#guard AList.size (AList.empty : AList Nat Nat) = 0
#guard AList.size (AList.insert 1 10 (AList.empty : AList Nat Nat)) = 1
#guard AList.size (AList.insert 1 99 (AList.insert 1 10 (AList.empty : AList Nat Nat))) = 2
#guard AList.size (AList.delete 1 (AList.insert 1 10 (AList.empty : AList Nat Nat))) = 0
[E11.2] · decidability identification · tier 1 · stretch
For each proposition about the dictionary, say whether decide can close it and why
(finite domain? decidable predicate? DecidableEq keys?) before checking — the
judgment is the point, not the tool-use:
(a) AList.lookup 2 (AList.insert 2 20 (AList.insert 1 10 (AList.empty : AList Nat Nat))) = some 20
(b) ∀ key ∈ ([1,2,3] : List Nat), AList.lookup key (AList.insert key 0 (AList.empty : AList Nat Nat)) = some 0
(c) ∀ (m : AList Nat Nat) (key : Nat), AList.lookup key (AList.insert key 0 m) = some 0
#guard AList.lookup 2 (AList.insert 2 20 (AList.insert 1 10 (AList.empty : AList Nat Nat))) = some 20
#guard decide (∀ key ∈ ([1, 2, 3] : List Nat),
AList.lookup key (AList.insert key 0 (AList.empty : AList Nat Nat)) = some 0) = true
-- (c) has no check on purpose: say why decide cannot close a ∀ over ALL AList values,
-- and name the §11.2 law that settles it instead.
[E11.3] · counterexample finding · tier 1 · core · target insertOrderCounterexample
A student claims “insert order never matters: inserting two pairs and then looking up
any key gives the same answer regardless of the order of the two inserts.” It is
wrong whenever the two keys coincide (the shadowing from §11.3). Find inputs where
swapping two inserts changes a lookup, and encode the witness so the check succeeds
(it confirms the two sides differ):
#guard AList.lookup 1 (AList.insert 1 200 (AList.insert 1 100 (AList.empty : AList Nat Nat)))
≠ AList.lookup 1 (AList.insert 1 100 (AList.insert 1 200 (AList.empty : AList Nat Nat)))
Then state, in one line, the correct condition under which insert order is irrelevant
(it is exactly the hypothesis of lookup_insert_diff).
[E11.4] · type-directed derivation · tier 2 · core · target peek
Derive peek [Stack s] : s α → Option α — the top element without removing it — from the
Stack interface alone. Produce a derivation trace in the Week 2 §2.6 format (the
trace is the graded artifact), then the def. First-step hint: the only operation that
inspects a stack is pop : s α → Option (α × s α); apply it first, then turn
Option (α × s α) into Option α — which functor operation does that with a projection?
Effort: ~3 trace steps, 1 line of code.
#guard peek ([1, 2, 3] : List Nat) = some 1
#guard peek ([] : List Nat) = none
#guard peek (Stack.push 7 ([1, 2] : List Nat)) = some 7
[E11.5] · type reading (free theorems) · tier 2 · stretch
Read two abstract signatures without running anything.
(a) Stack.pop : s α → Option (α × s α), polymorphic in the element type α and abstract
in the container s. State two things every inhabitant must satisfy (can it
manufacture an α it was never given? can a returned α be anything other than one
already in the stack?) and one thing the type cannot force (e.g. LIFO vs FIFO order —
why is that beyond the reach of the type?).
(b) Dict.lookup : k → d k v → Option v. What does polymorphism in v forbid lookup
from doing to the values it returns, and why must every non-none result be a value that
was previously inserted? (Builds on §7.2. No code to submit.)
[E11.6] · specification reading · tier 3 · core
§11.7 provides wfExample : { m : AList Nat Nat // (m.map Prod.fst).Nodup } — a
well-formed dictionary carrying a machine-checked proof of its representation invariant.
Read it (do not author any proof) and explain, in prose:
(a) what the invariant (m.map Prod.fst).Nodup asserts about the representation, and why a
dictionary needs it (relate it to “a key maps to at most one value” from §11.2);
(b) what the by decide inside wfExample actually verified, and why that check is
decidable here;
(c) the abstraction function — which partial function from keys to values does
wfExample denote?
(d) which AList operation from §11.3 can produce a value that violates the invariant,
and confirm the violation with the check below (a correct duplicate-key witness makes the
inequality-to-false check succeed):
#guard decide ((([(1, 10), (1, 20)] : List (Nat × Nat)).map Prod.fst).Nodup) = false
This is a reading task: the graded artifact is your four-part explanation, not a proof.
[E11.7] · specification writing · tier 1 · stretch · target Queue
Define a Queue type class in the style of §11.4’s Stack — empty, enqueue,
dequeue : q α → Option (α × q α), and size — together with a LawfulQueue extension
stating its laws. The decisive law is FIFO: dequeuing returns the oldest element, not
the most recently enqueued. State the laws; do not prove them. Give the List-backed
instance and confirm the laws on instances:
#guard (Queue.dequeue (Queue.enqueue 2 (Queue.enqueue (1 : Nat) (Queue.empty : List Nat)))).map Prod.fst = some 1
#guard (Queue.dequeue (Queue.empty : List Nat)).isNone
#guard Queue.size (Queue.enqueue 2 (Queue.enqueue (1 : Nat) (Queue.empty : List Nat))) = 2
Compare with LawfulStack (§11.4): exactly one law changes. Which one, and why does that
single change reverse the order of every observation?
[E11.8] · type-directed derivation · tier 2 · stretch · target TwoListStack
Give a second Stack implementation with a different representation:
structure TwoListStack (α : Type) where
front : List α
back : List α
push conses onto back; pop takes from front, and when front is empty it first
rebalances by reversing back into it. Produce a derivation trace for pop — the trace is
the graded artifact — then the defs, and write the LawfulStack law statements for this
representation (statements only). First-step hint: pop eliminates front first
([] vs y :: ys); only the [] branch touches back. Effort: ~5 trace steps, ~8 lines.
#guard (TwoListStack.push 2 (TwoListStack.push (1 : Nat) TwoListStack.empty)).size = 2
#guard ((TwoListStack.push 2 (TwoListStack.push (1 : Nat) TwoListStack.empty)).pop).map Prod.fst = some 1
#guard (TwoListStack.pop (TwoListStack.empty : TwoListStack Nat)).isNone
[E11.9] · specification writing · tier 1 (+ tier-3 reading) · core
State the representation-independence theorem for Stack: for any two LawfulStack
instances S1 and S2, any program built only from push, pop, size, and empty
produces the same observable results in both. Write it as precisely as you can in Lean —
you must first decide what “program” and “observable result” are, as types, before the
statement can be written at all; that decision is the exercise. Do not prove it. Then,
using E11.8’s TwoListStack alongside the List instance of §11.4, confirm the claim on one
concrete program by checking that both representations yield the same observation.
Say in two sentences which of the LawfulStack laws your statement actually depends on.
This is the exercise the unit points at: a client may rely on the laws, never on the
representation.
end W11