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. Stack[String] is a stack whose element type is String. 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(first_val: F, second_val: S) do
           first := first_val
           second := second_val
         end
       feature
         first: F
         second: S
         head: F do
           result := first
         end
         tail: S do
           result := second
         end
         describe(): String do
           result := "(" + first.to_string + ", " + second.to_string + ")"
         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:

nex> class Dictionary [K -> Hashable, V]
       create
         make() do
           entries := {}
         end
       feature
         entries: Map[K, V]
         set(key: K, value: V) do
           entries.set(key, value)
         end
         get(key: K): V do
           result := entries.get(key)
         end
         try_get(key: K, default: V): V do
           result := entries.try_get(key, default)
         end
         contains_key(key: K): Boolean do
           result := entries.contains_key(key)
         end
         size(): Integer do
           result := entries.size
         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 dict := create Dictionary[String, Integer].make
nex> dict.set("apples", 5)
nex> dict.set("oranges", 3)
nex> dict.get("apples")
5

nex> dict.try_get("bananas", 0)
0

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 # matters. {} 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

Because these are generic classes, their methods are defined once and then reused for any valid type arguments. add, get, remove, contains, and sort are defined once on Array[T]. contains, union, intersection, and difference are defined once on Set[T]. get, set, try_get, and contains_key are defined once on Map[K, V].

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

If the standard collections are ordinary classes, nothing about across can be reserved for them — and it is not. Chapter 11 left this promise open: 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 ArrayCursor, a map a MapCursor — 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.

One detail carries over from Section 11.2. Because a user-defined cursor can yield elements of any type, the loop variable for a custom iterable comes through as Any. When you only print it, that is fine — print and to_string accept Any. But to use it as a number you narrow it with convert, exactly as you would a value pulled from a map of Any:

nex> let total := 0
nex> across (create Interval.make(1, 100)) as n do
       if convert n to value: Integer then
          total := total + value
       end
     end
nex> total
5050

The lesson generalises beyond ranges. Any class that models a collection — a ring buffer, a linked list, 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:

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
         describe(): String do
           if ok then
             if value /= nil then
               result := "Success: " + value.to_string
             else
               result := "Error"
             end
           elseif error /= nil then
             result := "Error: " + error
           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).describe
"Success: 5.0"

nex> safe_divide(10.0, 0.0).describe
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. Writing it yourself as a generic class in Nex is a good exercise in combining what this chapter has covered.

Nex ships one, so you do not have to: intern data/Result brings in Result[T, E], along with its two variants and a set of combinator functions. Its design differs from the version above in a few ways worth noting. The error type is its own parameter E rather than always String, so Result[Real, String] and, say, Result[Real, Parse_Error] — with a class of your own describing what went wrong — are equally valid instantiations. Success and failure are separate classes, Ok[T, E] and Err[T, E], rather than one class with a flag — matched with match r of when Ok as ok then … when Err as er then … end instead of branching on is_ok. Querying and unwrapping — is_ok(), is_err(), unwrap_or(fallback) — are methods, but transforming a result — result_map, result_and_then, result_map_err — are free functions, since each introduces a type parameter a method has no way to add. Construction infers the type arguments it can from the value passed and leaves the rest as Any: create Ok.make(42) is Ok[Integer, Any], which still assigns to a Result[Integer, String]-typed variable. In real code, reach for this built-in Result rather than the hand-rolled one above, which exists here to practice generics and constructors, not as a pattern to repeat.

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.