Constructors that keep objects valid
If __init__ converts and checks its arguments and raises ValueError for bad ones, an invalid object can never exist, and the rest of the program can trust every object it gets.
- Convert and tidy arguments in
__init__(strip, upper, float) before storing them - Raise
ValueErrorfrom__init__so an invalid object is never created - Give parameters defaults, and avoid the shared mutable default trap
Data arrives messy: SKUs in lower case with spaces, prices as text, negative stock from a typo. If every function that uses a product has to check all of that, the checks get forgotten somewhere. Put them in one place instead: the constructor. __init__ can tidy each argument, convert it, and raise ValueError(...) (see Exceptions & Validation) when it can't be made valid. Because the error stops the object from being created, every Product that *does* exist is valid, and the rest of the program can rely on it.
A parameter can have a default (stock=0, the Functions module), so callers can leave it out.
Run each example to see what it prints. Running examples doesn't count toward progress.
import re class Product: def __init__(self, sku, name, price, stock=0): sku = sku.strip().upper() if not re.fullmatch(r"[A-Z]{3}-\d{5}", sku): raise ValueError(f"bad SKU: {sku!r}") price = float(price) if price < 0: raise ValueError(f"negative price for {sku}: {price}") self.sku = sku self.name = name.strip() self.price = price self.stock = int(stock) mat = Product(" spo-11681", "Yoga mat ", "31.00", "12") print(mat.sku, repr(mat.name), mat.price, mat.stock) lamp = Product("HOM-22847", "Desk lamp", 48) print(lamp.sku, lamp.stock) for args in [("SPO-1168", "Mat", 5), ("ELE-24781", "Speaker", -1)]: try: Product(*args) except ValueError as err: print("refused:", err)
Defaults need one warning. A default value is created once, when def runs, not each time the function is called. For a number or a text that doesn't matter. For a list, every object that uses the default shares that one list.
class Basket: def __init__(self, customer_id, items=[]): self.customer_id = customer_id self.items = items nora = Basket("C00038") nora.items.append("SPO-11681") jamal = Basket("C00077") assert jamal.items == [], f"Jamal's new basket already holds {jamal.items}"
The fix is a default of None, replaced inside __init__ by a new list. While at it, make a copy of a list the caller passes in, so the object doesn't share the caller's list either.
class Basket: def __init__(self, customer_id, items=None): if not customer_id: raise ValueError("a basket needs a customer") self.customer_id = customer_id if items is None: items = [] self.items = list(items) nora = Basket("C00038") nora.items.append("SPO-11681") jamal = Basket("C00077") saved = ["HOM-22847", "ELE-24781"] grace = Basket("C01338", saved) grace.items.append("TOY-54564") print(nora.items, jamal.items) print(grace.items, saved)
When to use it: for any class whose objects must obey rules (prices not negative, ids in a format, quantities whole and positive). Validate where the object is created, not in every function that uses it.
Mistakes to avoid: storing first and checking later (an exception halfway leaves no object, but checks after self.x = … read badly); assert instead of raise (asserts can be switched off, the Testing & Code Quality module); mutable defaults such as items=[]; and error messages that don't say which value was wrong.
__init__can tidy, convert and check its arguments before storing them.- Raising ValueError there means an invalid object is never created.
- Defaults are made once: use
Noneand create a new list inside, and copy lists the caller passes in. - Next: methods, the functions that belong to a class.