Representation Invariants

lecture
python
representation-invariants
assertions
oop

See csse1001 for course logistics — this note covers Lecture 8B’s technical content. See python-representation-invariants for the full reference on writing and enforcing invariants.

Today’s outline

  • What representation invariants are, and why they matter
  • Documenting invariants in docstrings
  • Enforcing invariants with assert and a _check_invariants() helper
  • Encapsulation and avoiding exposed mutable state
  • Worked example: DroneFlight

What are representation invariants?

Representation invariants are conditions or properties that must remain true about the internal state of an object throughout its lifetime (usually enforced during development).

Why do they matter?

  • Discover and prevent bugs and inconsistent behaviour.
  • Simplify the implementation of methods (you can assume a valid starting state).
  • Make code more maintainable and robust.
  • Help detect errors early.
  • Serve as documentation for developers.

Examples

  • A stack’s size must never be negative.
  • A list’s length must equal the number of elements it contains.
  • All elements in a set must be unique.
  • A fraction’s denominator cannot be zero.
  • A date must follow calendar rules (e.g. no 30 Feb).
  • For free/basic X (Twitter) accounts, the standard limit is 280 characters per tweet.

Writing representation invariants

Clearly state invariants in docstrings, underneath a class’s attributes:

class Fraction():
    """Represents a mathematical fraction.

    Representation Invariants:
    - denominator != 0
    - if fraction is zero, it is represented as 0/1
    - if fraction is negative, numerator is negative
    """

Enforcing representation invariants

Assertions

Directly check conditions in the initialiser and setter methods, using an assert statement to raise an AssertionError if an assumption isn’t met:

assert <statement that should be true>, "Error message if not True"
  • Assertions can be disabled in production when running Python with optimization (python -O).
  • Assertions are meant to find bugs (e.g. invalid states) during development.
class Fraction():
    def __init__(self, numer: int, denom: int) -> None:
        assert denom != 0, "Denominator cannot be zero."
        assert isinstance(numer, int), "Numerator must be an integer."
        assert isinstance(denom, int), "Denominator must be an integer."

        # Ensure denominator is positive
        if denom < 0:
            numer, denom = -numer, -denom
        self._numer = numer
        self._denom = denom

        # Special case for zero
        if self._numer == 0:
            assert self._denom == 1, "Zero must be 0/1"

Private attributes and encapsulation

  • Use private (_variable) names to discourage direct access.
  • Provide public methods that maintain the invariants.
  • Never expose mutable objects directly:
class Team():
    def __init__(self):
        self._members = []   # internal mutable state

    # classic getter (dangerous!)
    # BAD!
    def get_members(self):
        return self._members

    # instead, return a copy
    # GOOD!
    # def get_members(self):
    #     return list(self._members)  # caller can't modify internal state directly

t = Team()
members = t.get_members()   # gets the actual list inside the class
members.append("Alice")      # modifies it directly!

print(t.get_members())        # ['Alice'] <-- internal state was changed externally

Helper methods

Define a private method (e.g. _check_invariants()) that validates the object’s state, and call it after any state changes:

\[\text{state change} \to \texttt{\_check\_invariants()} \to \text{valid}\]

When to call _check_invariants():

  • At the end of __init__.
  • At the end of methods that modify object state.
  • At the beginning of methods that rely on invariants being true.
class Fraction():
    def __init__(self, numer: int, denom: int) -> None:
        self._numer = numer
        self._denom = denom
        self._check_invariants()

    def _check_invariants(self) -> None:
        """Verify that representation invariants hold."""
        assert isinstance(self._numer, int), "Numerator must be an integer."
        assert isinstance(self._denom, int), "Denominator must be an integer."
        assert self._denom != 0, "Denominator cannot be zero."
        assert self._denom > 0, "Denominator must be positive."
        if self._numer == 0:
            assert self._denom == 1, "Zero must be 0/1"

Best practices

  • Document invariants in docstrings.
  • Centralise invariant checking in a single method.
  • Do not expose methods that could violate invariants.
  • Create comprehensive test cases focusing on edge cases.
  • Python’s type hints can express some invariants.
  • Balance strictness with practicality.

Exercise: DroneFlight

You have joined a start-up that records quick test flights for hobby drones. Write a minimal DroneFlight class that always keeps its state valid by enforcing these representation invariants:

  • Drone ID — exactly six alphanumeric characters (e.g. "A1B2C3").
  • Altitude — must stay within 0 m–120 m, the CASA (Civil Aviation Safety Authority) legal ceiling for recreational drones.
  • Battery — an integer percentage in the range 0–100.

Solution

class DroneFlight:
    """
    Model a quick test-flight for a small hobby drone.

    Attributes:
    _id (str) : 6-character alphanumeric flight identifier.
    _altitude (float): Current altitude in metres above launch point.
    _battery (int): Remaining battery charge as a percentage (0-100).

    Representation invariants:
    - _id is a 6-character alphanumeric string
    - 0 <= _altitude <= 120
    - 0 <= _battery <= 100
    """

    _ID_LEN = 6         # one place to change if the rule changes
    _MAX_ALT = 120.0    # metres (CASA limit)

    def __init__(self, flight_id: str) -> None:
        self._id = flight_id
        self._altitude = 0.0
        self._battery = 100
        self._check_invariants()

    def _check_invariants(self) -> None:
        assert (
            isinstance(self._id, str)
            and self._id.isalnum()
            and len(self._id) == self._ID_LEN
        ), "ID must be a 6-character alphanumeric string."
        assert 0.0 <= self._altitude <= self._MAX_ALT, "Altitude out of range."
        assert 0 <= self._battery <= 100, "Battery out of range."

    # Getter and setter methods
    def get_flight_id(self) -> str:
        """Return the immutable flight identifier."""
        return self._id

    def get_altitude(self) -> float:
        """Return current altitude in metres."""
        return self._altitude

    def get_battery(self) -> int:
        """Return remaining battery charge (percentage)."""
        return self._battery

    def set_altitude(self, value: float) -> None:
        """Set altitude, clamping to legal ceiling."""
        if not isinstance(value, (int, float)):
            raise TypeError("Altitude must be a number.")
        if not 0.0 <= value <= self._MAX_ALT:
            raise ValueError(f"Altitude must be 0-{self._MAX_ALT} m.")
        self._altitude = float(value)
        self._check_invariants()

    def __repr__(self) -> str:
        return (f"DroneFlight({self._id}, "
                f"alt={self._altitude:.1f} m, bat={self._battery} %)")

    def ascend(self, metres: float) -> None:
        """Climb *metres* metres (cannot exceed the legal ceiling)."""
        if metres < 0:
            raise ValueError("ascend() expects a non-negative distance.")
        self.set_altitude(min(self._altitude + metres, self._MAX_ALT))

    def land(self) -> None:
        """Land the drone and consume 5 % battery."""
        self.set_altitude(0.0)
        self._battery = max(self._battery - 5, 0)
        self._check_invariants()
>>> d = DroneFlight("ABC123")
>>> d.ascend(50)
>>> print(d)
DroneFlight(ABC123, alt=50.0 m, bat=100 %)

>>> d.altitude = 200
Caught: Altitude must be 0-120 m.

>>> DroneFlight("BAD!")
Caught: ID must be a 6-character alphanumeric string.

>>> d.land()
DroneFlight(ABC123, alt=0.0 m, bat=95 %)

The d.altitude = 200 line is presented as raising a “Caught” error, but as written this class only defines set_altitude() as an ordinary method — it has no @property/@altitude.setter. Plain attribute assignment like d.altitude = 200 doesn’t call set_altitude() at all; it silently creates a brand-new, unrelated altitude attribute (alongside the real _altitude) and raises nothing. To actually trigger the validation shown, the demo would need d.set_altitude(200) wrapped in a try/except (TypeError, ValueError) as e: print("Caught:", e). The companion drone.py script does contain the bare d.altitude = 200 line with no such wrapper, confirming it wouldn’t actually raise in practice. The second demo line (constructing DroneFlight("BAD!")) is legitimate, though — __init__ really does call _check_invariants(), so an invalid ID genuinely raises an AssertionError there (assuming it’s likewise wrapped in a try/except AssertionError).

Summary

  • Representation invariants define the valid states of an object.
  • They help catch bugs early and document assumptions (mostly during development time).
  • We can use _check_invariants() to verify invariants, and call it after state changes.
  • Good invariants are specific, testable conditions.

Next: composition and inheritance (Week 9).