16 Generic Classes
Exercise 5 in Chapter 13 asked you to define IntegerStack, StringStack, and RealStack 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.
16.1 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.
16.2 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.
16.3 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
get_first(): F do
result := first
end
get_second(): 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.get_first
age
nex> p1.get_second
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.
16.4 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
16.5 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]
put(key: K, value: V) do
entries.put(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.put("apples", 5)
nex> dict.put("oranges", 3)
nex> dict.get("apples")
5
nex> dict.try_get("bananas", 0)
0
16.6 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).
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
16.7 The Standard Collections as Generic Classes
The built-in Array[T], Set[T], and Map[K, V] that you have been using throughout the book are generic classes. Array[Integer], Array[String], and Array[Real] are all instances of the same Array class with different type arguments. Set[Integer] and Set[String] are instances of Set with different element types. Map[String, Integer] and Map[Integer, String] are instances of Map with different key and value types.
This is why the methods work uniformly across element types: add, get, remove, contains, sort are defined once on Array[T], and work for any T. Similarly, contains, union, intersection, and difference are defined once on Set[T], and work for any element type T. The sort method requires T -> Comparable, which is why sorting an Array[Integer] works but sorting an Array[Map[String, Integer]] would not.
The generic mechanism also explains why across infers element types automatically: an Array[Integer] knows its element type is Integer, so the loop variable is inferred as Integer without annotation.
Understanding that the standard collections are generic classes clarifies the entire type system: Array[Integer] is not a special built-in type. It is an instance of a generic class, following exactly the same rules as Stack[Integer] or Sorted_List[Integer].
16.8 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. This is naturally a two-parameter generic:
nex> class Result [V]
create
success(val: V) do
value := val
error := nil
ok := true
end
failure(msg: String) do
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
result := "Success: " + value.to_string
else
result := "Error: " + 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.
16.9 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]requiresGto implementComparable;[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], andMap[K, V]are generic classes; understanding this explains why element types are inferred and why collection operations work uniformly across types - Generic classes provide reuse without duplication and type safety without losing flexibility
16.10 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.8 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.