Embercode
✕
Object-Oriented ProgrammingLesson 8 of 17

40 min read

Invariants and validation in Python classes

Why this matters

Suppose a program stores a person's age and somewhere in the code a value of minus 40 gets saved. The bug may not show up until a report prints "age: -40" or a discount is calculated from a negative number. By then the wrong value has travelled through many lines, and the code that put it there is hard to find.

An invariant is a rule about an object that is always true. For a person, one invariant is "the age is between 0 and 150". Validation is the code that enforces the rule. If every way of changing the object checks the rule first, the rule can never be broken, and a bug is caught at the line that caused it.

This lesson shows how to check inputs in __init__, which runs when an object is created, and in each property setter, which runs when an attribute is assigned. It also covers rules that involve two attributes, rules about collections, and the mistakes that make checks fail to work. Encapsulation, which keeps attributes internal so that all changes go through methods and properties, is covered in an earlier lesson. Here the focus is on what the methods must check.

What __init__ is and why it matters

__init__ is a special method that Python calls right after it creates a new object. Its job is to set up the object's attributes so that the object starts in a valid state.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

This initializer does nothing wrong, but it does nothing to protect the object either. Person("Asha", 3000) builds a person whose age is 3000. The object is created and the program continues. The initializer is the first and most important place to check, because it is where an object comes into being. If it accepts bad input, every later method has to defend against that input.

Checking in __init__

The first step is to reject bad values before any attribute is set. The initializer below refuses a None name, a blank name and an age outside a sensible range. The message in each exception says what went wrong, so the person reading an error can fix the input.

class Person:
    def __init__(self, name, age):
        if name is None:
            raise TypeError("name is required")
        if name.strip() == "":
            raise ValueError("name must not be blank")
        if age < 0 or age > 150:
            raise ValueError(f"age must be between 0 and 150, got {age}")
        self.name = name.strip()
        self.age = age

Three things in this code are worth noticing.

The raise statement stops the initializer. When a value is bad, Python leaves the method at once, and the call that created the object produces no object. The caller never receives a half-built person.

The checks come before the assignments. A common beginner mistake is to assign the attribute first and check afterwards. The next sections show why the order matters.

name.strip() removes spaces at both ends, so the stored name is clean. This is a normalisation step, a small change made so that every stored value follows the same form. It is a choice you make in one place, and it keeps the rest of the code simpler.

The errors use two built-in types. TypeError means a value has the wrong kind, such as None where a name should be. ValueError means the value has the right kind but is not acceptable, such as an age of 3000. Java has IllegalArgumentException and NullPointerException for these two cases.

Checking in each property setter

A property setter is a method that runs when an attribute is assigned, such as person.age = 40. A rule that __init__ enforces must also be enforced by every setter. Otherwise a valid person can be made invalid one step later.

The clean way to do this is to have __init__ assign through the properties. The rule then lives in one setter per attribute, and both __init__ and the setters use it.

Python · editable

Here __init__ assigns self.name and self.age. Because these names are properties, the assignments run the setters. A setter that rejects a value stops the assignment, so __init__ cannot store a bad value either. The method __str__ controls how print shows the object.

None is handled by an explicit check, if value is None. Java's Objects.requireNonNull does the same job in one line. Python has no equivalent in the standard library, so the explicit check is the usual form.

The next example is a complete program that uses this class. It creates a valid person, tries several bad values, and then finishes with one bad value that is not caught.

Python · editable

The output is:

Asha (30)
rejected: name must not be blank
rejected: age must be between 0 and 150, got 200
still valid: Asha (30)
rejected: name must not be None
Traceback (most recent call last):
  ...
ValueError: age must be between 0 and 150, got -1

The first line shows the trimmed name. The second and third lines show two rejected values. The line still valid: Asha (30) matters most. After asha.age = 200 was refused, the person still had age 30. The refused setter changed nothing, so the object stayed valid.

The last lines are an uncaught exception. Nothing catches it, so Python prints a traceback. A traceback is the list of calls that were active when the error happened. Its last line names the error. The lines above it give the file, line number and call for each step, with the oldest call at the top and the newest at the bottom. Here the setter age raised the error, it was called from __init__, and __init__ was called from the line that created the person. The ... in the example stands for those file lines. Read the traceback from the bottom to find the line in your code that caused it.

Checking before the change, not after

The order inside a setter matters. The check must come before the attribute changes. The example below shows the wrong order. It is a Thermostat that holds a target temperature.

Python · editable

The output is:

rejected: target must be 5 to 35, got 90
target is now 90
public attribute accepted -40

The message says the target was rejected, but the next line shows the target is 90. The exception was raised, but the attribute had already been set to 90 on the line before the check. Any code that catches the exception and carries on now holds an object in an invalid state. The rule is broken even though the program reported an error.

The fix is to move the if above the assignment:

def set_target(self, target):
    if target < 5 or target > 35:
        raise ValueError(f"target must be 5 to 35, got {target}")
    self._target = target

With this order, a refused value never reaches the attribute.

The second class in the program, NoCheckThermostat, shows the other way to break the rule. It has no property, so raw.target = -40 is allowed. Nothing in the class runs at all. This is why attributes that have rules should be internal, with a property that checks. If the attribute is internal, the only way to change it is through the property, and the setter can check. The output line public attribute accepted -40 shows what happens without this protection. The earlier lesson on encapsulation covers this in full.

Rules that involve two attributes

Some rules cannot be checked by looking at one attribute alone. A meeting has a start time and an end time, and the rule is that the start is not after the end. If you change the start, you must check it against the current end. If you change the end, you must check it against the current start. Each setter needs to read the other attribute.

The example below is a Meeting with two times and a list of people who were invited. The times are datetime.time values from Python's standard datetime module. A time is a time of day, such as 09:00. Two times can be compared with >.

Python · editable

The output is:

rejected: start 09:00:00 is after end 08:30:00
09:00:00 to 10:00:00 ['Asha', 'Ravi']
copy is read-only: AttributeError
after leak: 09:00:00 to 10:00:00 ['Asha', 'Ravi', 'Asha']
duplicate Asha in list? True

The method _require_order is written once and used in three places: __init__ and both setters. This is a common pattern. A shared check method keeps the rule in one place, so the three uses cannot drift apart. It is marked as a static method because it needs no object, only the two times.

The output shows the setter working. Setting the end to 08:30 would put it before the start of 09:00, so it was refused, and the meeting stayed at 09:00 to 10:00.

The last three lines show a second kind of invariant, one about a list. The rule is that nobody is invited twice. add_attendee checks that rule. But the class has attendees_leaky, which returns the internal list itself. A caller who appends "Asha" to that list skips the check, and the meeting now lists Asha twice. This is called a leak: the object gives out a reference to its own private data, and the rule is no longer enforced.

The method attendees returns a tuple, which is a read-only copy. The first try block shows that appending to it raises an AttributeError, because a tuple has no append method. A caller who wants to change the invite list must use add_attendee, which checks the rule.

Rules about collections

A class that holds a collection often has a rule about its size or its contents. A course might allow at most 2 students, and no student may be enrolled twice. These rules must be checked in the method that adds to the collection, and also in any method that changes the limit, since a new limit can make the current contents invalid.

Python · editable

The output is:

Java basics: 2/2
rejected: Ravi is already enrolled in Java basics
rejected: Java basics is full (2 seats)
rejected: capacity 1 is below the 2 students already enrolled
Java basics: 3/3 ('Asha', 'Ravi', 'Meera')

Look at the capacity setter. It has its own rule: the new capacity cannot be below the number of students already enrolled. Without this, a course with 2 students could be given a capacity of 1, and the class would hold more students than its limit. The rule is about the relationship between two pieces of data, the limit and the list, so it has to be checked where either one changes.

The _title attribute is never changed after __init__. That is the simplest form of invariant. Python does not enforce it by itself, so the convention is to give it no setter. A property with no setter gives the same protection: outside code that tries to assign it gets an AttributeError.

The errors in this class are not the same kind. enrol raises RuntimeError for a full course and a duplicate student. The input is well formed. The problem is that the object is not in a state where the action is allowed right now. Python has no exception built for that case, so RuntimeError is the usual general choice. The capacity setter and the initializer raise ValueError, because the value passed in is wrong by itself. The next section describes how to pick between them.

Choosing the right exception

Python has several built-in exceptions used for validation. Picking the right one makes the code easier to read and the errors easier to handle.

  • ValueError means the value passed in is not acceptable. Use it for an age of minus 1, a blank name or a capacity of zero.
  • TypeError means the value has the wrong kind, such as None where a name is needed. It is also what Python raises when you pass the wrong number of arguments.
  • RuntimeError is a general error for an action that the object cannot take right now, such as enrolling into a full course. A project that needs more detail defines its own exception class, such as class CourseFullError(RuntimeError): pass.

All three are ordinary exceptions. The code that calls them can catch them, or let them stop the program. The usual response to a bad value is to fix the call, not to recover from it. Catching them is useful at the boundary of a program, such as a loop that shows the message to the user, as the examples above do.

Common mistakes

Checking only in __init__. This is the most common gap. The initializer runs once. A setter added later without checks lets the object become invalid, and nothing warns the developer. The fix is to make every setter call the same check, or to assign through the properties in __init__, as in the Person example.

Checking after the assignment. As the Thermostat output showed, the value is already stored when the exception is raised. Always check first, then assign.

Leaving an attribute public when it has a rule. A public attribute skips every method and every check. Keep attributes with rules internal, and expose them through properties that check.

Returning the internal collection. A method that returns the list itself lets callers change it. The attendees_leaky example shows the result: a duplicate Asha in the invite list. Return a tuple, or a copy, instead.

Forgetting to re-check when a related value changes. If a course has a capacity and a list of students, changing the capacity must check the list. Any rule that connects two attributes belongs in every method that changes either one.

Assigning to a read-only property. A property with no setter raises AttributeError when it is assigned. The fix is to add a checked setter, or to use a method that says what the change means. The next example shows the message.

Python · editable

The output is:

3
AttributeError: property 'size' of 'Box' object has no setter

The read works, and the assignment is refused, because size has no setter.

Leaving an attribute unset on one path. An initializer must set every attribute on every path. The next example sets _size only when the value is positive. For any other value, the attribute never exists, and reading it fails later, far from the place where the mistake was made.

Python · editable

The output is:

Traceback (most recent call last):
  ...
AttributeError: 'Box' object has no attribute '_size'. Did you mean: 'size'?

The fix is to raise an error in the other path, which is what the earlier checks do, or to set a default. An initializer that finishes without setting an attribute its methods need is a bug, even though Python lets the object exist.

Practice

Exercise 1. Write a class Temperature with an internal _celsius value and a property celsius. The initializer and the setter must reject any value below absolute zero, which is -273.15 degrees, with a ValueError. Test it with 25.0 and -300.0.

Show the answer

Python · editable

The output is:

25.0
rejected: below absolute zero: -300.0

The initializer assigns through the property, so the rule exists in one place.

Exercise 2. Add a Range class with low and high values and the rule low <= high. Write the initializer and both setters so that every path keeps the rule. Then try to set low to a value above high and print the rejection.

Show the answer

Python · editable

This prints rejected: low 15 is above high 10. Both setters check against the other value, which is the point of the exercise.

Exercise 3. A Playlist holds song titles. Its rules are: no song appears twice, and the list never holds more than 3 songs. Write add so that both rules are enforced. Then write a method that is safe to give to other code.

Show the answer

Python · editable

The output is:

rejected: playlist is full
('Song A', 'Song B', 'Song C')

The _songs attribute is internal. add checks both rules before the list changes. songs returns a tuple, so a caller cannot add a fourth song or a duplicate through it.

Exercise 4. A student writes this setter and says it is correct because it checks the value:

class Score:
    def __init__(self):
        self._score = 0
 
    @property
    def score(self):
        return self._score
 
    @score.setter
    def score(self, value):
        self._score = value
        if value < 0:
            raise ValueError("negative")

Explain in plain words what is wrong with it and what happens to a caller who catches the exception.

Show the answer

The check runs after self._score is set. When the value is negative, the exception is raised, but the attribute already holds the negative value. A caller who catches the exception and keeps using the object will read a negative score from score, which breaks the rule. The check must come first, before the assignment:

class Score:
    def __init__(self):
        self._score = 0
 
    @property
    def score(self):
        return self._score
 
    @score.setter
    def score(self, value):
        if value < 0:
            raise ValueError("negative")
        self._score = value

Exercise 5. Explain why a class that sets self._id only in __init__, and has no setter for it, does not need a check for _id in any method, and why a class with a setter for id does.

Show the answer

A value that has no setter can be assigned only in the initializer. The only place an invalid value can enter is the initializer, and the initializer checks it. No method can change the value later, so no method needs a check. A value with a setter can be changed at any time, so each setter must check the rule. If a value is set in the initializer and never changed, the initializer's check is enough for the whole life of the object.

Summary

  • An invariant is a rule that is always true about an object, such as "the age is between 0 and 150".
  • Check every input in __init__, and check again in each setter. Assigning through properties in __init__ keeps the rule in one place.
  • Check before you assign. A value that is stored before the check runs stays stored even when the exception is raised.
  • Rules that connect two attributes, such as start before end, must be checked in each method that changes either attribute.
  • Do not return an internal collection from a method. Return a tuple or a copy, or the rule can be broken from outside.
  • Use ValueError for a bad value, TypeError for a wrong kind of value, and RuntimeError for an action the object cannot take right now.
Check yourself: Invariants (6 questions) →Practice this topic: coding problems for Object-Oriented Programming →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Composition over inheritance in Python