Best Practices
Designing for Inheritance and Polymorphism
Polymorphism
Interface
# Withdraw amount from each account in list_of_accounts def batch_withdraw(list_of_accounts, amount): for acct in list_of_accounts: acct.withdraw(amount) b, c, s = BankAccount(1000), CheckingAccount(2000), SavingsAccount(3000) batch_withdraw([b,c,s]) # uses bankaccount.withdraw, checking, Savings
- batch_withdraw() doesn’t need to check the object to know which withdraw() to call
Liskov substitution principle
Base class should be interchangeable with any of its subclasses without altering any properties of the program
- Wherever BankAccout woorks –> CheckingAccount should work as well
- Syntactically
- function signatures are compatible –> arguments, returned values
- Semantically
- the state of the object and the program remains consistent
- subclass method doesn’t strengthen input conditions
- subclass method doesn’t weaken output conditions
- no additional exceptions
- the state of the object and the program remains consistent
Violating LSP
- Syntactic incompatibility
BankAccount.withdraw()
requires 1 parameter butCheckingAccount.withdraw()
requieres 2
- Subclass stengthening input conditions
BankAccount.withdraw()
accepts any amount, butCheckingAccount.withdraw()
assumes that the amount is limited
- Subclass weakening output conditions
BankAccount.withdraw()
can only leave a positive balance or cause an error,CheckingAccount.withdraw()
can leave balance negative
- Chaning additional attributes in subclass’s method
- Throwing additional exceptions in subclass’s method
NO LSP – No Inheritance
Exercise
class Parent: def talk(self): print("Parent talking!") class Child(Parent): def talk(self): print("Child talking!") class TalkativeChild(Parent): def talk(self): print("TalkativeChild talking!") Parent.talk(self) p, c, tc = Parent(), Child(), TalkativeChild() for obj in (p, c, tc): obj.talk() > Parent talking! Child talking! TalkativeChild talking! Parent talking!
# Define a Rectangle class class Rectangle: def __init__(self, h, w): self.h, self.w = h, w # Define a Square class class Square(Rectangle): def __init__(self, w): self.h, self.w = w, w
class Rectangle: def __init__(self, w,h): self.w, self.h = w,h # Define set_h to set h def set_h(self, h): self.h = h # Define set_w to set w def set_w(self, w): self.w = w class Square(Rectangle): def __init__(self, w): self.w, self.h = w, w # Define set_h to set w and h def set_h(self, h): self.h = h self.w = h # Define set_w to set w and h def set_w(self, w): self.w = w self.h = w
Managing Data Access – private attributes
All class data is public
- any method or atttribute from a class can be accessed, by design
Restricting Access
- naming conventions
- use
@property
to customize access - Overriding
__getattr__()
and__setattr__()
Naming convention – internal attributes
obj.att_name
, obj._method_name()
- starts with a single
_
–> “internal” - widely accepted in Python community
- Not a part of the public API
- As a class user –> don’t mess with it
- As a class developer –> use for implementation detail & helper functions, …
df._is_mixed_type
–> indicator if df is of a mixed typedatetime._ymd2ord()
–> method for ordering dates
Naming convention – pseudoprivate attributes
obj.__attr_name, obj.__method_name()
- starts but doesn’t end with __ –> “private’
- not inherited
- Name mangling:
obj.__attr_name
is interpreted asobj._MyClass__attr_name
obj._MyClass__attr_name
= actual name of the attribute
- Use : prevent name clashes in inherited classes
!! Leading and trailing __
are only used for built-in Python methods –> __init__()
, __repr__()
!!
_name
–> helper method that checks validity of an attribute value but isn’t considered a part of the class public interface__name
–> a ‘version’ attribute that stores the actual version of the class and shouldn’t be passed to child classes which have their own versions__name__
–> a method run whenever the object is printed
class BetterDate: _MAX_DAYS = 30 _MAX_MONTHS = 12 def __init__(self, year, month, day): self.year, self.month, self.day = year, month, day @classmethod def from_str(cls, datestr): year, month, day = map(int, datestr.split("-")) return cls(year, month, day) def _is_valid(self): return (self.day <= BetterDate._MAX_DAYS) and \ (self.month <= BetterDate._MAX_MONTHS) bd1 = BetterDate(2020, 4, 30) print(bd1._is_valid()) > True bd2 = BetterDate(2020, 6, 45) print(bd2._is_valid()) > False
Properties
Changing attribute values
class Employee: def set_name(self, name): self.name = name def set_salary(self, salary): self.salary = salary def give_raise(self, amount): self.salary = self.salary + amount def __init__(self, name, salary): self.name, self.salary = name, salary emp = Employee("Anton", 31000) # Use dot syntax and = to alter atributes emp.salary = emp.salary + 2000
Control attribute access ?
- check the value for validity
- make attributes read-only
- modifying
set_salary()
doesn’t preventemp.salary = -100
- dot.syntax –> still access
- modifying
Restricted and read-only attributes
import pandas as pd df = pd.DataFrame({"colA": [1,2], "colB":[3,4]}) df > colA colB 0 1 3 1 2 4 df.columns = ["new_colA", "new_colB"] df > new_colA new_colB 0 1 3 1 2 4 # will cause an error df.columns = ["new_colA", "new_colB", "extra"] df > ValueError: Length mismatch: Expected axis has 2 elements, new values have 3 elements df.shape = (43, 27) df > AttributeError: can't set attribute
@property
class Employer: def __init__(self, name, new_salary): self._salary = new_salary @property def salary(self): return self._salary @salary.setter def salary(self, new_salary): if new_salary < 0: raise ValueError("Invalid salary") self._salary = new_salary
- Use protected attribute with leading
_
to store data - Use
@property
on a method whose name is exactly the name of the restricted attribute – return the internal attribute - Use
@attr.setter
on a methodattr()
that will be called onobj.attr = value
- value to assign passed as argument
emp = Employee("Harry", 5000) # accessing the "property" emp.salary >5000 emp.salary = 6000 # <-- @salary.setter emp.salary = -1000 > ValueError: Invalid salary
Why use @property?
- User-facing : behaving like attributes
- Developer-facing : give control of access
Other possibilities
- Do not add
@attr.setter
- Create a read-only property
- Add
@attr.getter
- Use for method that is called when property’s value is retrieved
- Add
@attr.deleter
- Use for the method that’s called when proporty is deleted using
del
- Use for the method that’s called when proporty is deleted using
class Customer: def __init__(self, name, new_bal): self.name = name if new_bal < 0: raise ValueError("Invalid balance!") self._balance = new_bal @property def balance(self): return self._balance @balance.setter def balance(self, new_bal): if new_bal < 0: raise ValueError("Invalid balance!") self._balance = new_bal print("Setter method called") cust = Customer("Belinda", 2000) cust.balance = 3000 print(cust.balance) > Setter method Called 3000
import pandas as pd from datetime import datetime class LoggedDF(pd.DataFrame): def __init__(self, *args, **kwargs): pd.DataFrame.__init__(self, *args, **kwargs) self._created_at = datetime.today() def to_csv(self, *args, **kwargs): temp = self.copy() temp["created_at"] = self._created_at pd.DataFrame.to_csv(temp, *args, **kwargs) @property def created_at(self): return self._created_at ldf = LoggedDF({"col1": [1,2], "col2":[3,4]}) try: ldf.created_at = '2035-07-13' except AttributeError: print("Could not set attribute") > Could not set attribute