0%

Protecting Valid Object State · practice

Validate Construction

Writing problems(question) put the rules in one place, but it left a gap: something has to remember to call it. A question that nobody checks is exactly as broken as before, and the program keeps running until the damage surfaces somewhere unrelated.

There is a moment when every ordinary construction call passes through the same piece of code: __init__ runs before the call returns the new to its caller. That makes it the natural place to refuse a bad one.

Refuse instead of returning

In Fundamentals I you raised an error for invalid data with raise:

if points <= 0:
    raise ValueError("points must be greater than zero")

The same statement works inside __init__:

Try it

The valid question is built as usual. Now try an invalid one:

Question("", "3", 2)

Python raises , the constructor call does not return a Question, and the never happens. The caller receives either a valid object or an exception.

Constructor validation rejects invalid input before the caller receives a Question object.

Check first, assign second

That ordering is deliberate, and it is worth stating on its own: validate before you store.

class Question:
    def __init__(self, prompt, answer, points):
        self.prompt = prompt        # stored
        if not prompt:              # checked afterwards
            raise ValueError("a question needs a prompt")

This version raises the same error, so a quick test might call it correct. It still stores state before deciding whether that state is valid. In a larger initializer, a later helper or collaborator could observe that partial setup before the error. Checking every proposed first gives the class one dependable rule: stored state is valid state.

What does Question("", "3", 2) leave behind when __init__ raises ValueError?

An error the caller can act on

ValueError is the right choice here because the caller passed a value of the correct type with an unusable value. Fundamentals I introduced it for exactly that situation, and the same reasoning applies inside a class.

The message matters as much as the exception type. Compare:

raise ValueError("invalid")
raise ValueError("points must be greater than zero")

The first tells whoever reads the that something was wrong. The second tells them what to change. You are writing that message for a person who cannot see this code, possibly yourself in six months.

Reading the message back

Fundamentals I caught exceptions by type. A handler can also give the caught exception a name, and then read it:

Try it

as error binds the exception object to a name inside the handler. Printing it prints the message you wrote when you raised it, which is how you check that a message is worth reading.

That is all as does for now. Chapter 12 comes back to what else an exception carries.

Errors are not always the answer

One honest warning before you write your own. Raising from __init__ is right when the object could never be useful, which is true of a question with no answer.

It is not right for every unwanted value. If a program reads questions from a file where a few rows are known to be incomplete, stopping the whole program on the first bad row may be the wrong behavior. The program might prefer to skip those rows and report a count at the end. In that case the caller catches the error and decides, which is a Chapter 12 subject.

Refusing to build a broken object is still the correct default. It puts the failure where the mistake was made, rather than three away.

Task

Complete __init__ so a Question refuses to exist unless all three rules hold: a non-empty prompt, a non-empty answer, and points greater than zero.

Raise with a message that names the rule that was broken. Check every rule before assigning any attribute.

The program at the bottom builds one good question and then tries three bad ones, reporting the message from each. Run it and read what each failure says.