Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Encapsulation and Class Immutability

Private Members

A field or method whose name starts with _ is private. It can be reached through self or parent, and nowhere else:

class Box {
  var _items = []

  add(value) {
    self._items.append(value)
    return self
  }

  count() {
    return self._items.length()
  }
}

echo Box().add(1).add(2).count()
2

Reaching in from outside does not compile:

var b = Box()
echo b._items
SyntaxError: '_items' is private and can only be accessed via 'self' or 'parent'
  --> /path/to/main.zu:2:8
   |
 2 | echo b._items
   |        ^

This is a compile-time check, not a runtime one, so it costs nothing and cannot be worked around by computing the name.

A subclass can reach its parent’s private members, through self for fields and parent for methods:

class Base {
  var _secret = 'base secret'

  _internal() {
    return 'base internal'
  }
}

class Child < Base {
  reveal() {
    return self._secret + '/' + parent._internal()
  }
}

echo Child().reveal()
base secret/base internal

The same underscore convention governs modules: a module member whose name starts with _ cannot be imported by name. Chapter 8 covers that side of it.

Classes Are Sealed

Once a class is declared, its shape is final. You cannot add a field or a method to it, and you cannot add one to an instance:

class Account {
  var balance = 0
}

var a = Account()

catch {
  a.nickname = 'rainy day'
} as e {
  echo e.message
}
undefined field 'nickname' on instance of 'Account'

The practical consequence is that a misspelled field name is an error at the point you write it, rather than a new field that silently shadows the one you meant. Every field a class has is declared in one place, and that place is @new.

Static field values are mutable; the set of static fields is not.

Reflective Access

Four built-in functions read and write fields by name:

var b = Box()

echo hasprop(b, '_items')
echo getprop(b, '_items')
echo delprop(b, '_items')
echo getprop(b, '_items')
true
[]
true
nil

setprop(obj, name, value) writes; delprop resets the slot to nil. Both return false when the field does not exist on the class, because neither can create one.

These bypass the underscore rule, which is deliberate: they exist for serialisers, debuggers and test helpers, where reaching into an object is the entire point. Regular code should not use them.

The zuri module goes further, with a full reflection API over classes, functions and modules. Chapter 21 covers it.

Designing With Sealed Classes

Two habits follow from sealing.

Declare every field on the class, even the ones the constructor fills in. self.x = value inside @new will declare one for you, but a var line at the top of the body is documentation the next reader gets for free, and it is required the moment a helper method does the assigning instead.

Model optional state as a field holding nil, not as an absent field. There is no such thing as an absent field, so a var cached_result that starts nil is the shape you want.

A Worked Example

Privacy earns its place when a class has an invariant to protect. Here is a bounded history buffer: it keeps the last n entries and nothing else, and there is no way for a caller to break that from outside.

class History {
  var _entries = []
  var _limit = 0

  @new(limit: number) {
    if limit < 1 {
      raise ValueError('limit must be at least 1, got ${limit}')
    }

    self._limit = limit
  }

  record(entry) {
    self._entries.append(entry)

    if self._entries.length() > self._limit {
      self._entries.shift()
    }

    return self
  }

  # A copy, so a caller cannot append through the value we hand back.
  entries() {
    return self._entries.clone()
  }

  length() {
    return self._entries.length()
  }
}

var history = History(3)

history.record('a').record('b').record('c').record('d')

echo history.entries()
echo history.length()

var taken = history.entries()
taken.append('e')

echo history.entries()

catch {
  History(0)
} as e {
  echo '${e.type}: ${e.message}'
}
[b, c, d]
3
[b, c, d]
ValueError: limit must be at least 1, got 0

Four decisions are doing the work.

The list is private, so nothing outside can append to it and skip the trimming. history._entries.append('x') does not compile.

entries() returns a clone. Without it, the caller would hold the real list and could grow it past the limit — which is exactly what the fourth output line shows not happening. Handing out a private mutable collection is the most common way encapsulation leaks.

The invariant is established in the constructor. _limit is validated once, so record() never has to wonder whether it is sensible.

The class is sealed, so history.limit = 999 is an error rather than a second, ignored field sitting alongside _limit.