Chapter 12

Classes

Every value encountered so far — integers, strings, arrays, maps — has a type, and that type determines what operations are available on the value. "hello".length works because strings have a length method. [1, 2, 3].sort works because arrays have a sort method. The type is not just a label; it is a bundle of data and behaviour.

Classes let you define your own types with the same structure: a bundle of data (fields or attributes) and behaviour (methods). Once a class is defined, you can create as many instances of it as you need, each carrying its own data, all sharing the same methods.

Defining a Class

A class definition in Nex has two blocks: a create block containing constructors, and a feature block containing attributes and methods:

nex> class Point
     create
       make(px, py: Real) do
         x := px
         y := py
       end
     feature
       x: Real
       y: Real
       distance_from_origin(): Real do
         result := ((x * x) + (y * y)) ^ 0.5
       end
     end

This defines a class Point with a constructor named make, two attributes x and y, and one method distance_from_origin.

Attributes are declared inside feature as name: Type — no keyword needed. Methods are declared as name(params): ReturnType do ... end, also inside feature. The distinction between attributes and methods is structural: attributes have no parameter list or body; methods do.

A plain feature section introduces public features. Nex also supports private feature for members that should only be used inside the class itself.

Creating Objects

Objects are created with create, naming both the class and the constructor:

nex> let p := create Point.make(3.0, 4.0)
nex> p.x
3.0

nex> p.y
4.0

nex> p.distance_from_origin
5.0

create Point.make(3.0, 4.0) runs the make constructor with the given arguments, initialising both attributes. The resulting object is assigned to p.

The create keyword appeared in Chapter 2 as let con := create Console. Now the mechanism is fully visible: create allocates a new instance and runs the named constructor.

Constructors

A constructor is a named entry in the create block. Its body initialises the object’s attributes. A class may have more than one constructor with different names:

nex> class Point
     create
       origin() do
         x := 0.0
         y := 0.0
       end
       make(px, py: Real) do
         x := px
         y := py
       end
     feature
       x: Real
       y: Real
       distance_from_origin(): Real do
         result := ((x * x) + (y * y)) ^ 0.5
       end
     end

nex> let p1 := create Point.origin
nex> p1.x
0.0

nex> let p2 := create Point.make(3.0, 4.0)
nex> p2.distance_from_origin
5.0

Named constructors communicate intent. create Point.origin clearly creates a point at the origin; create Point.make(3.0, 4.0) creates a point at specific coordinates. The name is part of the interface.

When several constructors share setup logic, one can delegate to another with this.<constructor-name>(...), rather than repeating the field assignments. origin is really just make called with zero for both coordinates:

nex> class Point
     create
       make(px, py: Real) do
         x := px
         y := py
       end
       origin() do
         this.make(0.0, 0.0)
       end
     feature
       x: Real
       y: Real
     end

nex> let p1 := create Point.origin
nex> p1.x
0.0

Attributes

Attributes are the data a class carries. Each instance gets its own independent copy of every field:

nex> let p1 := create Point.make(1.0, 2.0)
nex> let p2 := create Point.make(4.0, 6.0)

nex> p1.x
1.0

nex> p2.x
4.0

Attributes in a public feature section are read using dot notation: obj.attr_name. Assigning to an attribute from outside the class is not permitted. If an attribute needs to change, the class provides a method:

nex> class Point
     create
       make(px, py: Real) do
         x := px
         y := py
       end
     feature
       x: Real
       y: Real
       move(dx, dy: Real) do
         x := x + dx
         y := y + dy
       end
       distance_from_origin(): Real do
         result := ((x * x) + (y * y)) ^ 0.5
       end
     end

nex> let p := create Point.make(1.0, 2.0)
nex> p.move(2.0, 2.0)
nex> p.x
3.0

If an attribute or helper method is an implementation detail, put it in a private feature section instead:

nex> class Counter
     create
       make(start: Integer) do
         count := start
       end
     feature
       increment() do
         count := count + 1
       end
       current(): Integer do
         result := count
       end
     private feature
       count: Integer
     end

nex> let c := create Counter.make(10)
nex> c.increment
nex> c.current
11

Here increment and current are public operations, but count is hidden from code outside Counter. Private helper methods are declared the same way: place them under private feature.

Classes can also define class-level constants directly in the feature block:

nex> class Layout
     feature
       MAX_WIDTH = 450
       widened(): Integer do
         result := MAX_WIDTH + 10
       end
     end

nex> let layout := create Layout
nex> Layout.MAX_WIDTH
450
nex> layout.widened
460

HELLO and MAX_WIDTH are not per-object attributes. They belong to the class itself. Their meaning is: these features are always equal to those values.

This is the Nex equivalent of a Java static final member.

The form is:

NAME: Type = expression
NAME = expression

If the type is omitted, Nex infers it from the value. MAX_WIDTH = 450 is therefore an Integer.

Class constants are accessed from outside the class with the class name:

print(Layout.MAX_WIDTH)

Inside the class, they can be used directly by name:

widened(): Integer do
  result := MAX_WIDTH + 10
end

Because constants are not object state, they are not initialised by constructors and cannot be assigned to later.

Detachable Attributes

Nex requires that every attribute holding a non-basic type — any class, array, map, or other composite — must be initialised in the constructor. The basic types (Integer, Real, Boolean, String, Char) have well-defined defaults and can be left uninitialised. Everything else must be explicitly set.

Sometimes an attribute genuinely might not have a value at construction time. For these cases, use a detachable type, written with a leading ?:

nex> class Person
     create
       make(n: String) do
         name := n
         email := nil
       end
     feature
       name: String
       email: ?String
       set_email(addr: String) do
         email := addr
       end
       describe(): String do
         if email /= nil then
           result := name + " <" + email + ">"
         else
           result := name + " (no email)"
         end
       end
     end

nex> let p := create Person.make("Ada")
nex> p.describe
"Ada (no email)"

nex> p.set_email("ada@example.com")
nex> p.describe
"Ada <ada@example.com>"

email is declared as ?String — a detachable string that may hold a value or nil. The constructor initialises it to nil explicitly. The describe method checks for nil to choose what to print; joining a ?String onto a string is allowed either way. Calling a method on a detachable field is different: a /= nil check does not permit it, because any call could set the field back to nil in between. For that, bind the field to a local first with the object test ?email as e, introduced in Chapter 15.

The rule to remember is: use a plain type when the field must always have a value; use ?Type when absence is a meaningful state for this field.

Methods

Methods are features that compute or act. They are declared inside feature with a parameter list and body:

name(params): ReturnType do
     
end

For methods with no return value, the return type and colon are omitted:

name(params) do
     
end

Methods access the object’s own attributes directly by name. Here is a Bank_Account class:

nex> class Bank_Account
     create
       make(name: String, initial: Real) do
         owner := name
         balance := initial
       end
     feature
       owner: String
       balance: Real
       deposit(amount: Real) do
         balance := balance + amount
       end
       withdraw(amount: Real) do
         balance := balance - amount
       end
       describe(): String do
         result := owner + ": " + balance.to_string
       end
     end

nex> let account := create Bank_Account.make("Alice", 1000.0)
nex> account.deposit(500.0)
nex> account.withdraw(200.0)
nex> account.describe
"Alice: 1300.0"

Optional Arguments and Method Overloading

Sometimes you want a method that callers can use with or without an extra piece of information. Many languages solve this with default argument values. Nex does not have them: a call must always pass exactly as many arguments as the method declares.

Instead, a class may define several methods that share a name but take different numbers of parameters. This is called overloading. When the method is called, Nex selects the version whose parameter count matches the number of arguments you supplied.

A common pattern is to write the shorter version so that it forwards to the longer one, filling in a sensible default:

nex> class Greeter
     feature
       greet(name: String): String do
         result := greet(name, "!")
       end
       greet(name: String, punct: String): String do
         result := "Hello, " + name + punct
       end
     end

nex> let g := create Greeter
nex> g.greet("Ann")
"Hello, Ann!"
nex> g.greet("Bob", ".")
"Hello, Bob."

The one-argument greet repeats no logic; it simply calls the two-argument version with a default punctuation mark. Callers get the convenience of an optional argument, and the real work lives in a single place.

Two limitations are worth remembering. First, overloads are distinguished only by the number of parameters, not by their types — you cannot define two methods with the same name and the same number of parameters that differ only in parameter type. Second, this applies to methods only. Plain top-level functions cannot be overloaded: each function name must be unique, so if you need function variants, give them distinct names.

The this Reference

Inside a method, this refers to the object on which the method was called. Most of the time you do not need it — attributes and methods are accessible directly by name. this is needed when a parameter name shadows a field name:

nex> class Point
     create
       make(x, y: Real) do
         this.x := x
         this.y := y
       end
     feature
       x: Real
       y: Real
     end

Here the constructor parameters are also named x and y. Inside the constructor, bare x refers to the parameter; this.x refers to the field. Without this, the assignment x := x would assign the parameter to itself and leave the field uninitialised.

this is also used when an object needs to pass itself as an argument:

nex> class Point_2
     create
       make(px, py: Real) do
         x := px
         y := py
       end
     feature
       x: Real
       y: Real
       distance_to(other: Point_2): Real do
         let dx := this.x - other.x
         let dy := this.y - other.y
         result := ((dx * dx) + (dy * dy)) ^ 0.5
       end
     end

nex> let p1 := create Point_2.make(0.0, 0.0)
nex> let p2 := create Point_2.make(3.0, 4.0)
nex> p1.distance_to(p2)
5.0

In distance_to, this.x and this.y refer to the attributes of the object the method was called on (p1), while other.x and other.y refer to the argument (p2).

Uniform Access

Field reads and method calls use identical syntax:

nex> p.x                      -- reads a field
3.0

nex> p.distance_from_origin   -- calls a method
5.0

Both use obj.name notation. The caller cannot tell — and does not need to tell — whether name is a stored field or a computed method. This is uniform access.

It matters because it means a class can change its internal representation without breaking calling code. Consider Circle:

nex> class Circle
     create
       make(r: Real) do
         radius := r
       end
     feature
       radius: Real
       diameter(): Real do
         result := radius * 2.0
       end
       area(): Real do
         result := 3.14159 * radius * radius
       end
     end

nex> let c := create Circle.make(5.0)
nex> c.radius
5.0

nex> c.diameter
10.0

c.radius reads a stored field. c.diameter calls a computation. Both look identical at the call site. If the implementation later changes — storing diameter directly and computing radius — no call site changes.

A Worked Example: A Simple Stack

A pushdown stack (or simply, a stack) is a structure that imitates a pile of objects. You can think of a stack of documents, where a new document is placed on the top. When you want to read, you pick the top one. In other words, we can say that a stack is based on the last-in-first-out (LIFO) policy. The following class implements a stack using an integer array as the internal representation.

nex> class Stack
     create
       make() do
         items := []
       end
     feature
       items: Array[Integer]
       push(value: Integer) do
         items.add(value)
       end
       pop(): Integer do
         result := items.get(items.length - 1)
         items.remove(items.length - 1)
       end
       peek(): Integer 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

nex> let s := create Stack.make
nex> s.push(10)
nex> s.push(20)
nex> s.push(30)
nex> s.peek
30

nex> s.pop
30
nex> s.pop
20

nex> s.size
1

Users of the Stack class need not know about the Array[Integer] which stores the items. Instead they deal only with the methods push, pop, peek, and size. The array is an implementation detail; the four methods are the interface. This is the essential move that classes make: bundle data with its governing operations and present a clean surface to the outside world.

Summary

  • A class has a create block (constructors) and a feature block (attributes and methods)
  • Constructors are named; create ClassName.constructor_name(args) creates an instance
  • A constructor may delegate to another constructor of the same class with this.constructor_name(args), to share setup logic
  • Attributes are name: Type inside feature; class constants use NAME: Type = value or NAME = value; methods are name(params): ReturnType do ... end
  • feature is public by default; use private feature for attributes and helper methods that should stay inside the class
  • Public attributes may be read with obj.attr, but external code cannot assign to them; changes go through methods
  • Non-basic attributes must be initialised in the constructor; use ?Type for attributes that may legitimately be nil
  • this refers to the current object; needed when a parameter name shadows an attribute, or to pass the object as an argument
  • Uniform access: attribute reads and method calls use identical obj.name syntax

Exercises

1. Define a class Rectangle with attributes width and height (both Real) and a constructor make. Add methods area(): Real, perimeter(): Real, and is_square(): Boolean. Test with a 4.0 x 6.0 rectangle and a 5.0 x 5.0 square.

2. Define a class Temperature with a single field celsius: Real. Add methods fahrenheit(): Real and kelvin(): Real. Add describe(): String returning "freezing", "cold", "mild", or "warm". All derived values should be computed methods, not stored attributes.

3. Define a class String_Stack that behaves like Stack but holds String values. Use it to reverse a string by pushing each character and popping them all off.

4. Define a class Accumulator with attributes total: Real and count: Integer (both initialised to 0). Add add(value: Real), reset(), and average(): Real. State the precondition for average as a comment.

5.* Define a class Queue supporting enqueue(value: Integer), dequeue(): Integer, front(): Integer, is_empty(): Boolean, and size(): Integer, backed by an Array[Integer]. Enqueue 1 through 5, dequeue and print each, and verify first-in-first-out order.