Contracts, invariants, and exceptions
State what a function requires and guarantees, and fail fast with clear errors.
class InsufficientFunds(Exception):
pass
class Account:
def __init__(self, balance):
if balance < 0:
raise ValueError("opening balance cannot be negative")
self._balance = balance
@property
def balance(self):
return self._balance
def withdraw(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
if amount > self._balance:
raise InsufficientFunds(f"balance {self._balance} is less than {amount}")
self._balance -= amount
assert self._balance >= 0
return self._balance
account = Account(100)
print(account.withdraw(30))
for amount in (0, 500):
try:
account.withdraw(amount)
except InsufficientFunds as error:
print("rejected:", error)
except ValueError as error:
print("invalid:", error)
print(account.balance)70 invalid: amount must be positive rejected: balance 70 is less than 500 70
Explanation
Design by contract describes a function by three things: preconditions that callers must satisfy, postconditions that the function guarantees, and class invariants that stay true whenever no method is running. When a precondition is violated, the caller has a bug. When a postcondition or invariant fails, the function itself has a bug.
In Python, validate inputs that come from outside the function with explicit checks that raise exceptions: a ValueError for a bad value, and a custom exception for a meaningful domain failure such as insufficient funds. Callers can then handle each case differently. Use assert only for internal assumptions that should never be false: asserts are removed with python -O, so they cannot be the only validation of user input.
Fail fast means detecting a problem as close to its cause as possible and reporting it clearly. The Account class rejects a negative opening balance, a non-positive withdrawal, and an overdraft before changing any state, so a failed call leaves the object exactly as it was. The error messages say what was wrong and with which values, which makes problems far quicker to diagnose.
Explore the dry run
class InsufficientFunds(Exception): pass class Account: def __init__(self, balance): if balance < 0: raise ValueError("opening balance cannot be negative") self._balance = balance @property def balance(self): return self._balance def withdraw(self, amount): if amount <= 0: raise ValueError("amount must be positive") if amount > self._balance: raise InsufficientFunds(f"balance {self._balance} is less than {amount}") self._balance -= amount assert self._balance >= 0 return self._balance account = Account(100)print(account.withdraw(30))for amount in (0, 500): try: account.withdraw(amount) except InsufficientFunds as error: print("rejected:", error) except ValueError as error: print("invalid:", error)print(account.balance)Follow the example
Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.
View the complete dry-run table
| Step / line | What happens | State after step | Output so far |
|---|---|---|---|
| Precondition check: a withdrawal must be a positive amount, otherwise the caller has made a mistake. | — | — | |
| A domain-specific exception with a message that includes the actual values. | — | — | |
| Class invariant: the balance is never negative. It documents an assumption that must always hold. | — | — | |
| A valid withdrawal succeeds and returns the new balance. | balance = 70 | 70 | |
| Both failed calls left the balance unchanged, because they checked before modifying anything. | balance = 70 | 70 invalid: amount must be positive rejected: balance 70 is less than 500 70 |
Cost and common mistakes
Each withdrawal performs O(1) checks and one update.
- Do not use assert to validate user input. python -O removes assert statements, and the check would silently disappear.
- Catching a broad Exception and ignoring it hides real bugs. Catch the specific errors you can handle.
- Change state only after every check has passed, so a rejected call has no side effects.
Try it yourself
Which exception does Account(-5) raise, and why is it raised in __init__?
Reveal answer and reasoning
ValueError. The constructor must establish the invariant that the balance is never negative; refusing to create an invalid object is better than letting it exist.