9.2. The Four Pillars of OOP#
Object-oriented programming is often organized around four big ideas known as the four pillars of OOP. These pillars are not strict rules that every class must follow in the same way. Instead, they are design principles that help us build programs that are easier to understand, extend, and maintain.
When people say a program is “object-oriented,” they usually mean that classes and objects are used to organize related data together with the operations that act on that data. The four pillars give us a vocabulary for describing how that organization works.
As you read each section, keep three questions in mind:
What problem does this pillar solve?
What does the Python code gain by using it?
What would the code look like without it?
Pillar |
Core idea |
|---|---|
Encapsulation |
Bundle data and behavior together, and protect internal state |
Inheritance |
Build a new class by reusing and extending an existing one |
Polymorphism |
Use a common interface even when different classes behave differently |
Abstraction |
Show what an object can do without exposing every implementation detail |
In practice, these ideas overlap. A class might use encapsulation to protect its data, inheritance to reuse behavior, polymorphism to work with many object types, and abstraction to present a simple public interface. We study them separately here so each one is easier to see.
Encapsulation comes first because it is one of the most immediate and practical ideas in everyday Python class design.
9.2.1. Encapsulation#
Encapsulation means that an object manages its own state (data at the point of time) by keeping related data and behavior together and by controlling how that data is accessed or changed.
This idea has two parts:
A class bundles attributes and methods into one coherent unit (Class).
A class protects its internal state so values are changed in deliberate, valid ways rather than arbitrarily from outside the object.
The idea of encapsulation are connected to some critical concepts in software engineering:
Bundling: Grouping data and methods into one unit (Classes). Python does this perfectly.
Information Hiding: Restricting access to internal state. Python does this via convention and consensus.
Abstraction: the class can expose a simple public interface while hiding implementation details.
Data integrity: the class can validate updates and keep the object in a consistent state.
In Python, encapsulation is based on conventions and interface design rather than strict access control such as the private keyword to block access as in Java. This is achieved by using underscores to signal intent to other developers:
_variable(Protected): A single underscore tells other programmers, “internal use by convention”, meaning “This is internal; use it at your own risk because it might change.” Python won’t stop you from accessing it, but doing so is considered bad practice.__variable(Private): A double underscore triggers Name Mangling. Python renames the attribute to_ClassName__variable, which makes accidental external access less likely and thus prevents accidental overwriting in subclasses. It’s still accessible if you know the new name, but it’s a “keep out” sign for the developer.
The main goal of encapsulation is to make callers depend on the object’s public interface, not on the details of how the object stores or computes its data.
Consider a payroll system. An employee’s name is usually safe to expose directly, but salary is more sensitive and should be accessed through a controlled interface. The example below uses a double-underscore attribute for the stored salary and a read-only @property for safe access.
@property == getter
If you have used Java or C#, you can think of @property as serving a role similar to a getter method. It lets callers access a value through a public interface while the class still controls how that value is computed or exposed.
One important difference is syntax: in Python, a property is accessed like an attribute, so we write emp.salary rather than something like emp.getSalary().
The most common use of @property in practice is actually the read-only computed value pattern with no setter:
@property def bmi(self): return self.weight / (self.height ** 2)
class Employee:
def __init__(self, name, salary):
if salary < 0: ### simple validation check salary is not negative
raise ValueError('Salary cannot be negative')
self.name = name ### public attribute
self.__salary = salary ### double underscore triggers name mangling; this is meant
### for internal use and should not be accessed directly
### from outside the class.
@property ### user property lets us expose salary through a controlled read-only interface.
def salary(self): ###
return self.__salary
emp = Employee('Frederick', 50000)
print(emp.name)
print(emp.salary) ### this calls the salary method, but we access it like an attribute (no parentheses)
# because of the @property decorator.
Frederick
50000
The same encapsulation idea appears in banking systems. A bank account should not allow outside code to change the balance arbitrarily; instead, the class should expose controlled operations such as deposit and withdraw, along with a read-only way to inspect the current balance.
class BankAccount:
def __init__(self, owner, balance=0):
if balance < 0:
raise ValueError('Starting balance cannot be negative')
self.owner = owner
self._balance = balance ### single underscore is a convention indicating that
### this attribute is intended for internal use, but it
### can still be accessed from outside the class.
@property
def balance(self): ### this property allows us to access the balance in a
return self._balance ### controlled way, without allowing direct modification.
def deposit(self, amount): ### business logic for depositing money
if amount <= 0: ### simple validation check to ensure deposit amount is positive
raise ValueError('Deposit must be positive')
self._balance += amount
def withdraw(self, amount): ### business logic for withdrawing money
if amount <= 0: ### simple validation check to ensure withdrawal amount is positive
raise ValueError('Withdrawal must be positive')
if amount > self._balance:
raise ValueError('Insufficient funds')
self._balance -= amount
acct = BankAccount('Alice', 100)
acct.deposit(50)
acct.withdraw(30)
print(acct.balance) # 120
120
9.2.1.1. Access Level Conventions#
Python does not enforce strict access modifiers like Java or C++. Instead, it uses naming patterns to communicate intent:
name: public_name: internal use by convention__name: triggers name mangling to reduce accidental access or accidental override in subclasses
That last point matters: double underscore is more than a social convention. Python actually rewrites the attribute name internally. It is still not true private security, but it does provide stronger protection against accidental misuse than a single underscore.
In the examples above:
Employee.__salaryshows double-underscore name mangling.BankAccount._balanceshows an internal attribute exposed safely through methods and@property.
See the note below for how name mangling works.
Name Mangling in Python
__attribute (double underscore) triggers name mangling, where Python rewrites the name internally to _ClassName__attribute.
This is designed to prevent accidental access or accidental overrides in subclasses, not to provide true security.
Example: self.__salary inside Employee is stored as _Employee__salary.
As for single leading underscore _name, it is a convention meaning “this is internal / don’t touch it from outside the class.” Python does nothing special: it’s just a social contract between developers. The attribute is fully accessible, just signaled as private by convention.
9.2.2. Polymorphism#
Polymorphism means different object types can respond to the same method call in their own way.
In the banking example below, each account type implements the same method, month_end().
The caller does not need if/elif logic for each account type — it can just call one method name on all accounts.
class CheckingAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def month_end(self):
self.balance -= 10 # monthly service fee
class SavingsAccount:
def __init__(self, owner, balance, rate=0.01):
self.owner = owner
self.balance = balance
self.rate = rate
def month_end(self):
self.balance += self.balance * self.rate # monthly interest
class LoanAccount:
def __init__(self, owner, balance, rate=0.015):
self.owner = owner
self.balance = balance
self.rate = rate
def month_end(self):
self.balance += self.balance * self.rate # interest on amount owed
accounts = [ ### suppose we have a list of different types of accounts
CheckingAccount('Alice', 1200), ### we can call month_end on all of them without worrying about the specific type of account.
SavingsAccount('Bob', 5000),
LoanAccount('Charlie', 2000),
]
print('Before month-end:')
for account in accounts:
print(f'{account.owner}: {account.balance:.2f}')
Before month-end:
Alice: 1200.00
Bob: 5000.00
Charlie: 2000.00
All objects in accounts provide month_end(), so we can apply month-end updates with one uniform loop.
No type-specific if/elif branches are needed.
for account in accounts:
account.month_end() # polymorphic call
print('\nAfter month-end:')
for account in accounts:
print(f'{account.owner}: {account.balance:.2f}')
After month-end:
Alice: 1190.00
Bob: 5050.00
Charlie: 2030.00
In this loop, account can be a CheckingAccount, SavingsAccount, or LoanAccount,
but the caller code is identical:
account.month_end()
Python dispatches to the correct class-specific implementation at runtime. That is the core idea of polymorphism: one interface, many behaviors.
### Exercise: Banking Polymorphism
# The classes CheckingAccount, SavingsAccount, and LoanAccount are already defined above.
#
# 1. Define a new class InvestmentAccount with:
# - __init__(self, owner, balance, rate=0.02)
# - month_end(self) that applies: self.balance += self.balance * self.rate
#
# 2. Create one instance of each of the four account types:
# CheckingAccount('Alice', 1200)
# SavingsAccount('Bob', 5000)
# LoanAccount('Charlie', 2000)
# InvestmentAccount('Diana', 3000, 0.02)
#
# 3. Put all four in a list called accounts.
# 4. Loop over accounts and call month_end() on each one.
# 5. Loop again and print each owner's updated balance.
### Your code starts here.
### Your code ends here.
Alice: 1190.00
Bob: 5050.00
Charlie: 2030.00
Diana: 3060.00
9.2.3. Inheritance#
The language feature most often associated with object-oriented programming is inheritance. Inheritance lets us define a new class as a modified or specialized version of an existing class.
When inheritance is used well, it helps us avoid rewriting code that is already correct in the parent class. The child class reuses what it needs and adds or overrides only the parts that make it different.
The examples in this section use bank accounts. That domain is close to the kinds of systems used in business settings, but the main idea is general: a child class can reuse behavior from a parent class and then extend it for a more specific purpose.
class BankAccount:
"""Represents a bank account with an owner, balance, and account number."""
def __init__(self, account_number, owner, balance=0.0):
self.account_number = account_number
self.owner = owner
self.balance = balance
def __str__(self):
return f'{self.account_number}: {self.owner} - ${self.balance:.2f}'
def deposit(self, amount):
if amount <= 0:
raise ValueError('Deposit must be positive')
self.balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError('Withdrawal must be positive')
if amount > self.balance:
raise ValueError('Insufficient funds')
self.balance -= amount
def transfer_to(self, other_account, amount):
if not isinstance(other_account, BankAccount):
raise TypeError('Transfers require another BankAccount')
self.withdraw(amount)
other_account.deposit(amount)
### This base class will be extended by more specialized account types below.
A subclass is defined with the same class statement as any other class. The difference is that the parent class name appears in parentheses after the subclass name.
9.2.3.1. Parents and children#
Inheritance is the ability to define a new class that is a modified version of an existing class.
A useful banking example is a SavingsAccount, which is a specific kind of BankAccount.
A savings account is similar to a general bank account because it still has an owner, an account number, a balance, and common operations such as deposit and withdrawal; which we may want to reuse instead of defining them again.
A savings account is also different because it has behavior that does not apply to every account type. For example, it may earn interest at the end of each month; which we may need to add to the child class.
This relationship between classes, where one is a specialized version of another, lends itself naturally to inheritance. To define a new class that is based on an existing class, the name of the existing class is placed in parentheses.
The basic pattern for Python syntax for inheritance is:
class Parent:
def __init__(self, x):
self.x = x
class Child(Parent):
def __init__(self, x, y):
super().__init__(x) # call Parent.__init__
self.y = y
super()
super() returns a proxy object that makes it possible to call a method from a parent class without naming that parent explicitly. This is especially useful in __init__, where the child class often relies on the parent class to set up shared state before adding child-specific attributes.
Example in constructors:
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
def speak(self):
return f'{self.name} says {self.sound}!'
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, sound='Woof') # call parent __init__ first
self.breed = breed # then add child-specific data
def info(self):
return f'{self.name} is a {self.breed}'
d = Dog('Rex', 'Labrador')
print(d.speak()) # inherited behavior uses parent setup
print(d.info()) # child-specific behavior
There is also a pattern for methods overriding:
class Parent:
def greet(self):
return "Hello"
class Child(Parent): ### inheritance
def greet(self): ### same method
return super().greet() + ", from Child"
In this pattern, ChildClass is the subclass (child), and ParentClass is the superclass (parent). To create an inheritance relation for our example below (BankAccount is the parent class and SavingsAccount is the child class),
class SavingsAccount(BankAccount):
pass ### no code needed but can't leave it blank
Now let’s define SavingsAccount class as a child class of BankAccount.
class SavingsAccount(BankAccount):
"""Represents a savings account."""
def __init__(self, account_number, owner, balance=0.0, rate=0.01):
super().__init__(account_number, owner, balance) ### # call Parent.__init__ to initialize the common attributes
self.rate = rate ### extra attribute specific to SavingsAccount
This definition indicates that SavingsAccount inherits from BankAccount, which means that SavingsAccount objects can access methods defined in BankAccount, like deposit and withdraw.
SavingsAccount also inherits __init__ from BankAccount, but if we define __init__ in the SavingsAccount class, it overrides the one in the BankAccount class.
Note that:
SavingsAccountis a subclass ofBankAccountbecauseBankAccountis in parentheses of the header ofSavingsAccount.there is a
__init__constructor here and it has all the parameters the parent class has and an additional parameterrate=.there is a built-in function call
super()to initialize the constructor of the parentBankAccountclass.we then initialize the specialized attribute:
rate, which does not exist in the parent class.
This version of __init__ takes the same basic account information as the parent class and adds an interest rate.
When a SavingsAccount is created, Python invokes this method, but super().__init__(...) reuses the parent setup so the shared attributes do not have to be written again.
savings = SavingsAccount('S-1001', 'Ava', 1200.00, rate=0.02)
print(savings.rate)
0.02
Because SavingsAccount inherits from BankAccount, it can use methods defined in the parent class without redefining them. The next example calls deposit method, which was written once in BankAccount and then reused by the child class.
savings.deposit(300.00) ### inherited method from BankAccount
print(savings)
print(isinstance(savings, SavingsAccount))
print(isinstance(savings, BankAccount))
S-1001: Ava - $1500.00
True
True
The parent class also defines a method that is shared by every kind of bank account. A transfer operation belongs in the parent class because it is useful for a regular bank account, a savings account, and any future subclass that behaves like a bank account.
This method is defined once in the parent class, but it can be used by any child class that inherits from BankAccount. That is one of the main advantages of inheritance: common behavior lives in one place instead of being duplicated across several related classes.
A child class is often called a subclass, and the parent class is often called a superclass. In this example, SavingsAccount is a subclass of BankAccount, and BankAccount is a superclass of SavingsAccount.
A SavingsAccount should be usable anywhere a BankAccount is expected because it still supports the same core account behaviors. That idea makes inheritance practical: the parent class defines what related objects have in common, and the child class adds what makes it special.
9.2.3.2. Specialization#
Inheritance becomes especially useful when subclasses need to keep the basic behavior of the parent class but also add rules of their own.
A BusinessAccount, for example, is still a bank account, but it may charge a service fee for certain withdrawals. That makes it a good example of specialization.
class BusinessAccount(BankAccount):
"""Represents a business checking account."""
def __init__(self, account_number, owner, balance=0.0, transaction_fee=5.0):
super().__init__(account_number, owner, balance)
self.transaction_fee = transaction_fee
def withdraw_with_fee(self, amount):
total = amount + self.transaction_fee
self.withdraw(total)
return total
The withdraw_with_fee method deducts both the requested withdrawal amount and the transaction fee from the account balance.
biz = BusinessAccount('B-1001', 'Acme Corp', 1000.0) ### 1000 in initial balance
total = biz.withdraw_with_fee(200)
print(f'Withdrawn: $200.00, Fee: ${biz.transaction_fee:.2f}, Total deducted: ${total:.2f}')
print(biz)
Withdrawn: $200.00, Fee: $5.00, Total deducted: $205.00
B-1001: Acme Corp - $795.00
The withdraw_with_fee method belongs in the child class because the extra fee is specific to business accounts, not to every kind of bank account.
On the other hand, the transfer_to() method is in the superclass so that the subclasses can use them.
business = BusinessAccount('B-2001', 'Northwind Supply', 5000.00, transaction_fee=12.50)
personal = SavingsAccount('S-2002', 'Mina', 1800.00, rate=0.03)
business.transfer_to(personal, 400.00)
print(business)
print(personal)
B-2001: Northwind Supply - $4600.00
S-2002: Mina - $2200.00
Now we can use withdraw_with_fee on the same account to apply a fee-based withdrawal.
Now let’s invoke the business.withdraw_with_fee() method again; this time without specifying the transaction fee.
charged = business.withdraw_with_fee(250.00)
print(f'Total charged: ${charged:.2f}')
print(business)
Total charged: $262.50
B-2001: Northwind Supply - $4337.50
withdraw_with_fee subtracts both the withdrawal amount and the fee, while the inherited methods from BankAccount still handle the core balance updates.
business.balance
4337.5
In this example, BusinessAccount inherited general account behavior from BankAccount and then added specialized behavior of its own. The child class did not need to rewrite deposit, basic withdrawal, string display, or transfer logic.
The SavingsAccount and BusinessAccount classes inherited all the methods from BankAccount, such as deposit, withdraw, __str__, and transfer_to, but each subclass also gained attributes or methods that reflect its own role in the banking system.
9.2.3.3. issubclass()#
The issubclass() checks whether one class inherits from another at runtime.
issubclass(Child, Parent) returns True if Child is a subclass of Parent or is Parent itself. It is useful when class relationships need to be checked at runtime.
A practical detail matters here: the first argument to issubclass() must itself be a class. If user input or function arguments are being validated, that point is often worth checking explicitly before calling issubclass().
Function |
Checks |
|---|---|
|
Is |
|
Is class |
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
def speak(self):
return f'{self.name} says {self.sound}!'
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, sound='Woof')
self.breed = breed
def info(self):
return f'{self.name} is a {self.breed}'
print(issubclass(Dog, Animal)) # True
print(issubclass(Animal, Dog)) # False
print(issubclass(Dog, Dog)) # True (a class is a subclass of itself)
# Common use: guarding against wrong arguments
def make_animal(cls, *args, **kwargs):
if not isinstance(cls, type):
raise TypeError('cls must be a class')
if not issubclass(cls, Animal):
raise TypeError(f'{cls} is not an Animal subclass')
return cls(*args, **kwargs)
rex = make_animal(Dog, 'Rex', 'Labrador')
print(rex.speak()) # Rex says Woof!
True
False
True
Rex says Woof!
9.2.3.4. Class Variables#
A class variable is defined in the class body and shared by all instances of that class.
Use a class variable when the value belongs to the class as a whole (for example, a default fee or shared configuration), not to one specific object.
A practical detail: assigning to that name through one instance creates or updates an instance attribute and no longer changes the shared class-level value.
class AccountSettings:
bank_name = 'Phelps County Bank' # shared by all instances
transfer_fee = 2.50 # shared default fee
def __init__(self, owner):
self.owner = owner
a = AccountSettings('Ava')
b = AccountSettings('Ben')
print(a.bank_name, '|', b.bank_name)
print(f'Initial fee -> a: ${a.transfer_fee:.2f}, b: ${b.transfer_fee:.2f}')
# Update class variable: both instances see the new shared value
AccountSettings.transfer_fee = 3.00
print(f'After class update -> a: ${a.transfer_fee:.2f}, b: ${b.transfer_fee:.2f}')
# Assign via one instance: creates an instance attribute (shadows class variable for that object only)
b.transfer_fee = 4.50
print(f'After instance assignment -> a: ${a.transfer_fee:.2f}, b: ${b.transfer_fee:.2f}, class: ${AccountSettings.transfer_fee:.2f}')
Phelps County Bank | Phelps County Bank
Initial fee -> a: $2.50, b: $2.50
After class update -> a: $3.00, b: $3.00
After instance assignment -> a: $3.00, b: $4.50, class: $3.00
9.2.3.5. Method Overriding#
An override happens when a child class defines a method with the same name as a parent method to change or specialize behavior.
A common pattern is:
add child-specific validation or rules
then call
super().method(...)to reuse the parent logic
This keeps shared behavior in one place while still allowing specialization.
class LimitedSavingsAccount(SavingsAccount):
def __init__(self, account_number, owner, balance=0.0, rate=0.01, min_balance=100.0):
super().__init__(account_number, owner, balance, rate)
self.min_balance = min_balance
def withdraw(self, amount):
# Child-specific rule before parent logic
if self.balance - amount < self.min_balance:
raise ValueError(f'Cannot go below minimum balance of ${self.min_balance:.2f}')
super().withdraw(amount) # reuse parent validation and subtraction
lsa = LimitedSavingsAccount('S-9001', 'Nina', 500.0, rate=0.02, min_balance=200.0)
lsa.withdraw(250)
print(lsa)
try:
lsa.withdraw(100)
except ValueError as e:
print(e)
S-9001: Nina - $250.00
Cannot go below minimum balance of $200.00
9.2.4. Abstraction#
Abstraction means exposing what an object can do while hiding how it does it. Callers interact with a clean public interface and never need to know the internal logic. For example, you can call sorted() without knowing Python’s sorting algorithm, use list.append() without knowing how lists are stored in memory, or call acct.deposit() without knowing what happens inside. Good abstraction makes complex systems feel simple.
While inheritance provides a mechanism for one class to reuse another’s code, abstraction is a design goal for hiding complexity behind a simple interface.
Here the BankAccount class is a good example. A caller using deposit, withdraw, or transfer_to does not need to know:
how
withdrawvalidates the amounthow
transfer_tocoordinates two accountshow
_balanceis stored internally
The caller only needs the interface: the method names and what they do.
eve_savings = SavingsAccount('S-3001', 'Eve', 2000.00, rate=0.02)
eve_business = BusinessAccount('B-3001', 'Eve Co', 5000.00, transaction_fee=8.0)
# None of these callers need to know the internal validation or storage logic
eve_savings.deposit(500)
eve_business.withdraw(300)
eve_business.transfer_to(eve_savings, 1000)
print(eve_savings)
print(eve_business)
S-3001: Eve - $3500.00
B-3001: Eve Co - $3700.00
9.2.4.1. Abstract Base Classes (abc)#
Python supports abstraction explicitly through the built-in abc module (Abstract Base Classes). An Abstract Base Class (ABC) formalizes that pattern by declaring a contract: any subclass must implement certain methods, or Python will refuse to instantiate it. In other words, with ABC, an abstract base class defines a required interface, it declares methods that child classes must implement.
To implement ABC, you need to import the ABC class and the abstractmethod decorator.
from abc import ABC, abstractmethod
class Account(ABC):
@abstractmethod
def month_end(self):
"""Apply end-of-month rules for this account type."""
pass
In this design, Account is not meant to be instantiated directly. Instead, concrete subclasses such as SavingsAccount or CheckingAccount provide their own implementation of month_end().
After a class inherits from an ABC, two behavior rules matter:
The subclass must implement every abstract method before it can be instantiated.
Any function that expects the
ABCtype can use different subclasses interchangeably through the same method names.
ABC is useful when you want to enforce a shared interface across related classes, not just rely on convention.
# Use case: unified payment processing interface with ABC
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod ### this decorator marks charge as an abstract method
def charge(self, amount): ### that must be implemented by subclasses
"""Return a message describing how payment is processed."""
pass
class CreditCard(PaymentMethod):
def __init__(self, last4):
self.last4 = last4
def charge(self, amount): ### this method implements the abstract charge method defined in PaymentMethod
return f"Charged ${amount:.2f} to card ending in {self.last4}."
class PayPal(PaymentMethod): ### another concrete implementation of PaymentMethod
def __init__(self, email):
self.email = email
def charge(self, amount):
return f"Charged ${amount:.2f} via PayPal account {self.email}."
def checkout(payment_method, amount):
if not isinstance(payment_method, PaymentMethod):
raise TypeError("checkout requires a PaymentMethod")
return payment_method.charge(amount)
methods = [CreditCard("4242"), PayPal("student@example.com")]
for method in methods:
print(checkout(method, 49.99))
Charged $49.99 to card ending in 4242.
Charged $49.99 via PayPal account student@example.com.
In the example above, both CreditCard and PayPal inherit from PaymentMethod, so each one must provide charge(self, amount).
That gives two practical benefits:
checkout(...)depends only on the abstract interface, not on a specific concrete class.New payment types can be added later (for example,
GiftCardorBankTransfer) without changingcheckout(...), as long as they inherit fromPaymentMethodand implementcharge(...).
This is abstraction in action: one stable contract, many concrete behaviors.
9.2.5. Summary#
The four pillars are easiest to understand separately, but good object-oriented design usually combines them:
Encapsulation protects state and funnels access through a controlled interface.
Inheritance lets a new class reuse and extend the behavior of an existing one.
Polymorphism lets the same caller code work with different object types as long as they support the same interface.
Abstraction defines what an object must provide without exposing every implementation detail.
When you read or design a class, a useful habit is to ask: What data does this object protect? What behavior does it inherit? What interface can be used polymorphically? What details are intentionally hidden?
### Exercise: Four Pillars Review
# Create a small OOP example that demonstrates all four pillars.
# 1. Define class Wallet with owner and _cash.
# 2. Add deposit(amount) and spend(amount) methods.
# If spend amount is greater than _cash, raise ValueError.
# 3. Define class SavingsWallet(Wallet) that adds apply_interest(rate).
# 4. Define abstract class Notifier with abstract method send(amount).
# Implement one subclass EmailNotifier that returns a message string.
# 5. Create one SavingsWallet, call deposit, spend, and apply_interest.
# 6. Create an EmailNotifier object and call send with the wallet cash value.
# 7. Print wallet cash and notifier message.
### Your code starts here.
### Your code ends here.
93.5
Email sent: wallet balance is $93.50