Chapter 15

Generic Classes

Exercise 5 in Chapter 13 asked you to define Integer_Stack, String_Stack, and Real_Stack alongside each other. If you did it, you noticed something uncomfortable: the three classes are identical except for the element type. Every method has the same structure; only the type annotations differ. Any bug fixed in one must be fixed in all three. Any new method added to one should be added to all three.

This is exactly the problem that generic classes solve. A generic class is parameterised by a type: you write the class once, and the type is supplied when the class is used. Stack[Integer], Stack[String], and Stack[Real] are all the same class, instantiated with different type arguments.

This is also how Nex’s standard collections work. Array[T] and Set[T] each take one type argument, and Map[K, V] takes two. Once you understand Stack[G], you understand the core idea behind the standard collection library as well.

A Generic Class

The type parameter is declared in square brackets after the class name:

nex> class Stack [G]
     create
       make() do
         items := []
       end
     feature
       items: Array[G]
       push(value: G) do
         items.add(value)
       end
       pop(): G do
         result := items.get(items.length - 1)
         items.remove(items.length - 1)
       end
       peek(): G do
         result := items.get(items.length - 1)
       end
       is_empty(): Boolean do
         result := items.is_empty
       end
       size(): Integer do
         result := items.length
       end
     end

G is the type parameter — a placeholder for whatever type will be used when the class is instantiated. items is an Array[G]; push takes a G; pop and peek return a G. Everything that was Integer in the original Stack is now G.

The type parameter name is a convention. Single uppercase letters are common: G for a generic element, T for a type, K and V for key and value. The name does not matter — what matters is that it is used consistently throughout the class.

Using a Generic Class

When creating an instance, supply the concrete type in square brackets:

nex> let int_stack := create Stack[Integer].make
nex> int_stack.push(10)
nex> int_stack.push(20)
nex> int_stack.push(30)
nex> int_stack.pop
30

nex> let str_stack := create Stack[String].make
nex> str_stack.push("hello")
nex> str_stack.push("world")
nex> str_stack.peek
"world"

Stack[Integer] is a stack whose element type is Integer while Stack[String] has String as its element type. Both are produced by the same class definition — only the type argument differs.

Nex enforces type safety: pushing an Integer onto a Stack[String] is a type error caught before the program runs. The generic mechanism provides both reuse and safety.

Multiple Type Parameters

A class can have more than one type parameter:

nex> class Pair [F, S]
     create
       make(h: F, t: S) do
         head := h
         tail := t
       end
     feature
       head: F
       tail: S
       describe(): String do
         result := "(" + head + ", " + tail + ")"
       end
     end
nex> let p1 := create Pair[String, Integer].make("age", 30)
nex> p1.head
"age"

nex> p1.tail
30

nex> let p2 := create Pair[Real, Boolean].make(3.14, true)
nex> p2.describe
"(3.14, true)"

Pair[F, S] holds a value of type F and a value of type S. The two types are independent — Pair[String, Integer], Pair[Real, Boolean], and Pair[String, String] are all valid instantiations.

Type Constraints

Sometimes a generic class needs to call methods on its type parameter — and not all types support all methods. If Stack needed to sort its elements, G would need to support comparison. You cannot sort arbitrary types; you can only sort types that implement Comparable.

Type constraints restrict which types can be used as a type argument. The constraint is written with ->:

nex> class Sorted_List [G -> Comparable]
     create
       make() do
         items := []
       end
     feature
       items: Array[G]
       insert(value: G) do
         items.add(value)
         items := items.sort
       end
       max(): G do
         result := items.get(items.length - 1)
       end
       min(): G do
         result := items.get(0)
       end
       size(): Integer do
         result := items.length
       end
     end

[G -> Comparable] means: G can be any type that implements Comparable. Inside the class, Nex knows that G values can be compared, so items.sort — which requires Comparable elements — is valid.

nex> let nums := create Sorted_List[Integer].make
nex> nums.insert(5)
nex> nums.insert(2)
nex> nums.insert(8)
nex> nums.insert(1)
nex> nums.min
1

nex> nums.max
8

Attempting create Sorted_List[Array[Integer]].make would be a type error at instantiation, because Array[Integer] does not implement Comparable.

The built-in constraints available in Nex include: - Comparable — supports ordering (<, <=, >, >=) - Hashable — can be used as a map key.

Constrained Multiple Parameters

Type constraints and multiple parameters combine naturally. Here's an implementation of a class that captures a mapping function fn(K) = V, but caches the result for each K so that the same computation never runs twice.

nex> class Cache[K -> Hashable, V]
     feature
       cached_results: Map[K, V]
       callback: Function(K): V

     create
       make(f: Function(K): V)
       do
        callback := f
       end

     feature
       lookup(k: K): V
       do
        if cached_results.contains_key(k) then
          result := cached_results.get(k)
        else
          result := callback(k)
          cached_results.set(k, result)
        end
      end
   end

K must be Hashable because map keys require hashing. V is unconstrained — values can be any type. This mirrors the design of the built-in Map type, which is itself a generic class with exactly these constraints.

nex> let cache := create Cache[String, Integer].make(
                    fn(s: String): Integer do
                      result := s.to_integer * 10
                    end)

cache.lookup("1089") -- 10890
cache.lookup("8900") -- 89000
cache.lookup("1089") -- 10890 looked up from cache

Generic Classes and Inheritance

A generic class can inherit from another class, and a concrete class can inherit from an instantiated generic:

nex> class Bounded_Stack [G] inherit Stack[G]
     create
       make(max: Integer) do
         super.make
         max_size := max
       end
     feature
       max_size: Integer
       is_full(): Boolean do
         result := size = max_size
       end
       push(value: G) do
         if not is_full then
           super.push(value)
         end
       end
     end

Bounded_Stack[G] inherits from Stack[G] and adds a max_size field and an is_full check. The push override silently ignores pushes when the stack is full (a real implementation might signal this — we will see how with contracts in Part V).

super.make and super.push(value) are the super keyword from Chapter 14: because Bounded_Stack[G] inherits from exactly one class, super unambiguously means Stack[G]. super.make in the constructor runs Stack[G]’s own constructor first, so items is initialised before max_size is set. super.push(value), inside the if not is_full guard, is what actually appends the value onto the underlying array — the override’s only job is deciding whether that call happens at all.

nex> let s := create Bounded_Stack[Integer].make(3)
nex> s.push(1)
nex> s.push(2)
nex> s.push(3)
nex> s.push(4)     -- ignored: stack is full
nex> s.size
3

The Standard Collections as Generic Classes

By now you have used Array[T] and Map[K, V] throughout. They are generic classes built into the language: Array[Integer], Array[String], and Array[Real] are all instances of the same Array class with different type arguments, and Map[String, Integer] and Map[Integer, String] are both instances of Map with different key and value types.

Nex also provides a built-in Set[T] class. A set stores unique values of one element type. The literal syntax is #{...}:

nex> let seen: Set[Integer] := #{1, 2, 3}
nex> seen.contains(2)
true

nex> seen.union(#{3, 4})
#{1, 2, 3, 4}

nex> let empty_names: Set[String] := #{}
nex> empty_names.is_empty
true

The # prefix is important. {} is an empty map; #{} is an empty set.

This is the same generic pattern you have seen all chapter:

  • Array[T] has one element type parameter
  • Set[T] has one element type parameter
  • Map[K, V] has two type parameters: one for keys and one for values

The type arguments explain why the typechecker knows what operations are valid. sort works on Array[Integer] because Integer is Comparable. It would not work on an Array[Map[String, Integer]], because maps are not comparable. Similarly, map keys require K -> Hashable, which is why Map[K, V] constrains its key type.

The generic mechanism also explains why across can infer loop variable types automatically. If numbers has type Array[Integer], the loop variable is inferred as Integer. If seen has type Set[Integer], the loop variable is also inferred as Integer:

nex> across seen as n do
       print(n + 10)
     end
11
12
13

Understanding that the standard collections are generic classes clarifies the whole type system. Array[Integer], Set[String], and Map[String, Real] are not magical special cases. They are ordinary instances of generic classes, following exactly the same ideas as Stack[Integer], Pair[String, Integer], or Sorted_List[Integer].

Iterating Over Your Own Types

Earlier in this book we saw that across works with any object that knows how to produce a cursor, a small helper that visits the elements one at a time. By giving a class a cursor method you make your own type iterable, so client code can loop over it with the same across it uses for an array.

A cursor is an object that implements four features — the Cursor protocol:

  • start — position the cursor at the first element
  • item — return the element at the current position
  • next — advance to the following element
  • at_end — report whether the traversal is finished

When you write across some_object as x do … end, Nex calls some_object.cursor to obtain a cursor, then drives that cursor: it calls start once, and repeats item / next until at_end becomes true. The built-in collections work exactly this way — an array hands back an Array_Cursor, a map a Map_Cursor — which is why one loop form works uniformly across all of them.

Consider an Interval — an inclusive range of integers — that does not store its values in an array at all, but generates them on demand. First the cursor that walks the range:

nex> class Interval_Cursor
     create
       make(lo, hi: Integer) do current := lo  last := hi end
     feature
       current: Integer
       last: Integer
       start()           do end
       item(): Integer   do result := current end
       next()            do current := current + 1 end
       at_end(): Boolean do result := current > last end
     end

The cursor holds its own position in current. It starts already on the first element, so start has nothing to do; item reads the current value; next moves forward; and at_end is true once it has stepped past last. Now the Interval itself, whose only job is to hand out a fresh cursor:

nex> class Interval
     create
       make(lo, hi: Integer) do low := lo  high := hi end
     feature
       low: Integer
       high: Integer
       cursor(): Interval_Cursor
       do
        result := create Interval_Cursor.make(low, high)
       end
     end

That is all it takes. An Interval is now a first-class iterable:

nex> let week := create Interval.make(1, 7)
nex> across week as day do
       print(day)
     end
1
2
3
4
5
6
7

Each cursor call returns a new, independent cursor, so the same Interval can be iterated more than once, and even nested inside another loop over itself, without the traversals interfering.

The lesson generalises beyond ranges. Any class that models a collection — a ring buffer, say, or a tree with a chosen traversal order — becomes usable with across the moment it can produce a cursor. The iteration protocol is the meeting point: clients write one familiar loop, while each type decides privately how its elements are stored and produced. It is the same bargain generics offer, struck at the level of behaviour rather than types: Stack[G] lets one class serve every element type, and the Cursor protocol lets one loop serve every collection.

A Worked Example: A Generic Result Type

A common pattern in robust code is a result type that holds either a successful value or an error description — without raising an exception. Here the success value varies, while the error is always a String, so a one-parameter generic is enough. Its value and error are detachable fields, and that needs one new construct. A /= nil check narrows a local variable or a parameter, but not a field: any call, or another task, could set the field back to nil between the check and the use. To use a detachable field, bind it to a local with the object test ?expr as name. It is true when expr is not nil, and in the then branch name holds that value with the attached type:

if ?value as v then
  print(v.to_string)   -- v is a V, not a ?V
end

Nothing but this branch can assign v, so it stays attached even if the field is reset in the meantime. Here is the whole class:

nex> class Result [V]
     create
       success(val: V) do
         value := val
         error := nil
         ok := true
       end
       failure(msg: String) do
         value := nil
         error := msg
         ok := false
       end
     feature
       value: ?V
       error: ?String
       ok: Boolean
       is_ok(): Boolean do
         result := ok
       end
       to_string(): String do
         if ok then
           if ?value as v then
             result := "Success: " + v.to_string
           else
             result := "Error"
           end
         elseif ?error as e then
           result := "Error: " + e
         else
           result := "Error"
         end
       end
     end
nex> function safe_divide(a, b: Real): Result[Real] 
     do
       if b = 0.0 then
         result := create Result[Real].failure("division by zero")
       else
         result := create Result[Real].success(a / b)
       end
     end

nex> safe_divide(10.0, 2.0)
Success: 5.0

nex> safe_divide(10.0, 0.0)
Error: division by zero

Result[V] has two named constructors — success and failure — making the two cases explicit. The caller can check is_ok and handle each case without catching an exception. This pattern — sometimes called a result type or either type — appears in many modern languages and libraries. Nex ships one too: see data/Result. But writing it yourself as a generic class in Nex is a good exercise in combining what this chapter has covered.

Summary

  • A generic class is parameterised by one or more type parameters declared in square brackets: class Name [T]
  • Type parameters are placeholders; concrete types are supplied at instantiation: create Stack[Integer].make
  • Multiple type parameters are separated by commas: class Pair [F, S]
  • Type constraints restrict which types can fill a parameter: [G -> Comparable] requires G to implement Comparable; [K -> Hashable] requires hashability for use as a map key
  • A generic class can inherit from another generic class using the same type parameter: class Bounded_Stack [G] inherit Stack[G]
  • The built-in Array[T], Set[T], and Map[K, V] are generic classes; Set literals use #{...}, and understanding these collection types explains why element types are inferred and why operations work uniformly across types
  • Any class becomes usable with across by giving it a cursor method that returns an object implementing the Cursor protocol — start, item, next, at_end — which is how the built-in collections work too; narrow the Any loop variable with convert when you need its concrete type
  • Generic classes provide reuse without duplication and type safety without losing flexibility

Exercises

1. The Stack[G] class has implicit preconditions: pop and peek require the stack to be non-empty. Add a require comment to each method stating the precondition. Then test what happens when you call pop on an empty stack.

2. Define a generic class Box [T] with a single field value: T, a constructor make(v: T), and methods get(): T and set(v: T). Then define a Logged_Box [T] inherit Box[T] that also keeps a change_count: Integer field, incrementing it each time set is called. Add a changes(): Integer method.

3. Define a generic class Range [G -> Comparable] with fields low: G and high: G, a constructor make(l, h: G), and methods contains(value: G): Boolean (returns true if low <= value <= high) and overlaps(other: Range[G]): Boolean. Test with integer and real ranges.

4. The Result[V] class in Section 15.9 has value: ?V as a detachable field. Why is ?V needed rather than V? What would happen in the failure constructor if value were not detachable?

5.* Define a generic Queue [G] class backed by an Array[G], with methods enqueue(value: G), dequeue(): G, front(): G, is_empty(): Boolean, and size(): Integer. Then define a Priority_Queue [G -> Comparable] that inherits Queue[G] and overrides enqueue so that elements are always inserted in sorted order (smallest at the front). Verify that dequeuing from a Priority_Queue[Integer] after inserting [5, 2, 8, 1, 9] produces the elements in ascending order.

6. Following the Interval example from Section 15.8, write a class Countdown whose constructor takes a positive integer n and that, when iterated with across, yields n, n-1, …, 1. Give it a cursor implementing start, item, next, and at_end, and a cursor method that returns a fresh one. Verify that across (create Countdown.make(5)) as k do print(k) end prints 5 down to 1, and that iterating the same object twice produces the full sequence both times.

7.* Make the Stack[G] class from Section 15.1 iterable from top to bottom by adding a cursor method, without exposing its underlying items array. Then write a loop that uses convert to total the elements of a Stack[Integer], and explain why the loop variable arrives as Any.