32. Extending Classes with Inheritance and Polymorphism
In Chapter 30, we learned how to create our own classes to model real-world concepts. We built classes like BankAccount and Student that bundled data and behavior together. But what happens when you need to create a new class that's similar to an existing one, but with some differences or additions?
Inheritance is Python's mechanism for creating new classes based on existing ones. Instead of copying and pasting code, you can create a subclass that automatically gets all the attributes and methods from a parent class (also called a base class or superclass), and then add or modify what you need.
This chapter explores how inheritance lets you build hierarchies of related classes, how to customize inherited behavior, and how polymorphism allows different classes to be used interchangeably when they share common interfaces.
32.1) Creating Subclasses from Existing Classes
32.1.1) The Basic Syntax of Inheritance
Inheritance allows you to create a new class (called a subclass or child class) based on an existing class (called a parent class or base class). The subclass automatically inherits all methods and attributes from the parent class.
When you create a subclass, you specify the parent class in parentheses after the class name.
# Parent class
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
# Subclass inheriting from Animal
class Dog(Animal):
pass # No additional code needed yet
# Create a Dog instance
buddy = Dog("Buddy")
print(buddy.speak()) # Output: Buddy makes a sound
print(buddy.name) # Output: BuddyEven though Dog has no code of its own (just pass), it inherits everything from Animal. The Dog class automatically has the __init__ method and the speak method from its parent.
32.1.2) Why Inheritance Matters
Inheritance solves a common programming problem: code duplication. Imagine you're building a system to manage different types of employees:
# Without inheritance - lots of duplication
class FullTimeEmployee:
def __init__(self, name, employee_id, salary):
self.name = name
self.employee_id = employee_id
self.salary = salary
def get_info(self):
return f"{self.name} (ID: {self.employee_id})"
class PartTimeEmployee:
def __init__(self, name, employee_id, hourly_rate):
self.name = name
self.employee_id = employee_id
self.hourly_rate = hourly_rate
def get_info(self):
return f"{self.name} (ID: {self.employee_id})"Notice how name, employee_id, and get_info() are duplicated. With inheritance, we can eliminate this duplication:
# With inheritance - shared code in parent class
class Employee:
def __init__(self, name, employee_id):
self.name = name
self.employee_id = employee_id
def get_info(self):
return f"{self.name} (ID: {self.employee_id})"
class FullTimeEmployee(Employee):
def __init__(self, name, employee_id, salary):
Employee.__init__(self, name, employee_id) # Call parent's __init__
# Note: We'll learn a better way to do this with super() in Section 32.3
self.salary = salary
class PartTimeEmployee(Employee):
def __init__(self, name, employee_id, hourly_rate):
Employee.__init__(self, name, employee_id) # Call parent's __init__
self.hourly_rate = hourly_rate
# Both subclasses inherit get_info()
alice = FullTimeEmployee("Alice", "E001", 75000)
bob = PartTimeEmployee("Bob", "E002", 25)
print(alice.get_info()) # Output: Alice (ID: E001)
print(bob.get_info()) # Output: Bob (ID: E002)Now the common attributes and methods live in Employee, and each subclass only defines what makes it unique.
32.1.3) Adding New Methods to Subclasses
Subclasses can add their own methods that the parent doesn't have:
class Vehicle:
def __init__(self, brand, model):
self.brand = brand
self.model = model
def get_description(self):
return f"{self.brand} {self.model}"
class Car(Vehicle):
def __init__(self, brand, model, num_doors):
Vehicle.__init__(self, brand, model)
self.num_doors = num_doors
def honk(self): # New method specific to Car
return "Beep beep!"
class Motorcycle(Vehicle):
def __init__(self, brand, model, has_sidecar):
Vehicle.__init__(self, brand, model)
self.has_sidecar = has_sidecar
def rev_engine(self): # New method specific to Motorcycle
return "Vroom vroom!"
# Each subclass has its parent's methods plus its own
my_car = Car("Toyota", "Camry", 4)
print(my_car.get_description()) # Output: Toyota Camry
print(my_car.honk()) # Output: Beep beep!
my_bike = Motorcycle("Harley", "Sportster", False)
print(my_bike.get_description()) # Output: Harley Sportster
print(my_bike.rev_engine()) # Output: Vroom vroom!The Car class has both get_description() (inherited) and honk() (its own). The Motorcycle class has get_description() (inherited) and rev_engine() (its own).
32.1.4) Adding New Attributes to Subclasses
Subclasses can also add their own instance attributes. You typically do this in the subclass's __init__ method:
class BankAccount:
def __init__(self, account_number, balance):
self.account_number = account_number
self.balance = balance
def deposit(self, amount):
self.balance += amount
return self.balance
class SavingsAccount(BankAccount):
def __init__(self, account_number, balance, interest_rate):
BankAccount.__init__(self, account_number, balance)
self.interest_rate = interest_rate # New attribute
def apply_interest(self): # New method using new attribute
interest = self.balance * self.interest_rate
self.balance += interest
return interest
# SavingsAccount has all BankAccount attributes plus interest_rate
savings = SavingsAccount("SA001", 1000, 0.03)
savings.deposit(500) # Inherited method
print(f"Balance: ${savings.balance}") # Output: Balance: $1500
interest_earned = savings.apply_interest()
print(f"Interest earned: ${interest_earned:.2f}") # Output: Interest earned: $45.00
print(f"New balance: ${savings.balance:.2f}") # Output: New balance: $1545.00The SavingsAccount has account_number and balance from BankAccount, plus its own interest_rate attribute.
32.1.5) Multiple Levels of Inheritance
Classes can inherit from classes that themselves inherit from other classes, creating an inheritance hierarchy:
class LivingThing:
def __init__(self, name):
self.name = name
def is_alive(self):
return True
class Animal(LivingThing):
def __init__(self, name, species):
LivingThing.__init__(self, name)
self.species = species
def move(self):
return f"{self.name} is moving"
class Dog(Animal):
def __init__(self, name, breed):
Animal.__init__(self, name, "Dog")
self.breed = breed
def bark(self):
return f"{self.name} says: Woof!"
# Dog inherits from Animal, which inherits from LivingThing
max_dog = Dog("Max", "Golden Retriever")
# Methods from all three levels work
print(max_dog.is_alive()) # Output: True (from LivingThing)
print(max_dog.move()) # Output: Max is moving (from Animal)
print(max_dog.bark()) # Output: Max says: Woof! (from Dog)
# Attributes from all three levels exist
print(max_dog.name) # Output: Max (from LivingThing)
print(max_dog.species) # Output: Dog (from Animal)
print(max_dog.breed) # Output: Golden Retriever (from Dog)Dog inherits from Animal, which inherits from LivingThing. This means Dog has access to methods and attributes from both parent classes.
32.2) Overriding Methods in Subclasses
32.2.1) What Method Overriding Means
Sometimes a subclass needs to change how an inherited method works. Method overriding means defining a method in the subclass with the same name as a method in the parent class. When you call that method on a subclass instance, Python uses the subclass's version instead of the parent's.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
class Dog(Animal):
def speak(self): # Override the parent's speak method
return f"{self.name} says: Woof!"
class Cat(Animal):
def speak(self): # Override with different behavior
return f"{self.name} says: Meow!"
# Each class has its own version of speak()
generic_animal = Animal("Generic")
print(generic_animal.speak()) # Output: Generic makes a sound
buddy = Dog("Buddy")
print(buddy.speak()) # Output: Buddy says: Woof!
whiskers = Cat("Whiskers")
print(whiskers.speak()) # Output: Whiskers says: Meow!When you call buddy.speak(), Python looks for the speak method in the Dog class first, since buddy is a Dog instance. Because Dog defines its own speak method, Python uses that version. If Dog didn't have a speak method, Python would then look in the parent class Animal and use that version instead.
This search order—starting with the instance's class, then moving to the parent class—is how method overriding works and how subclasses customize inherited behavior.
32.2.2) Why Override Methods?
Method overriding lets you create specialized versions of general behavior. Consider a shape hierarchy:
class Shape:
def __init__(self, name):
self.name = name
def area(self):
return 0 # Default implementation
def describe(self):
return f"{self.name} with area {self.area()}"
class Rectangle(Shape):
def __init__(self, width, height):
Shape.__init__(self, "Rectangle")
self.width = width
self.height = height
def area(self): # Override with rectangle-specific calculation
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
Shape.__init__(self, "Circle")
self.radius = radius
def area(self): # Override with circle-specific calculation
return 3.14159 * self.radius ** 2
# Each shape calculates area differently
rect = Rectangle(5, 3)
print(rect.describe()) # Output: Rectangle with area 15
circle = Circle(4)
print(circle.describe()) # Output: Circle with area 50.26544The describe() method is inherited by both subclasses and works correctly because each subclass provides its own area() implementation.
When you call rect.describe(), the inherited describe() method executes, but self refers to the Rectangle instance. So when describe() calls self.area(), Python looks for area() in the Rectangle class first and finds the overridden version.
32.2.3) Overriding __init__ and Calling Parent Initialization
When you override __init__, you typically need to call the parent's __init__ to ensure the parent's initialization happens:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def introduce(self):
return f"I'm {self.name}, {self.age} years old"
class Student(Person):
def __init__(self, name, age, student_id, major):
# Call parent's __init__ to set name and age
Person.__init__(self, name, age)
# Then set student-specific attributes
self.student_id = student_id
self.major = major
alice = Student("Alice", 20, "S12345", "Computer Science")
print(alice.name) # Output: Alice
print(alice.student_id) # Output: S12345Notice how Student.__init__ first calls Person.__init__(self, name, age) to initialize the parent class's attributes, then adds its own attributes.
32.3) Using super() to Access Parent Behavior
32.3.1) What super() Does
In the previous section, we called parent methods explicitly: ParentClass.method(self, ...). Python provides a cleaner way: the super() function. super() returns a temporary object that lets you call methods from the parent class without naming the parent explicitly.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # Cleaner than Animal.__init__(self, name)
self.breed = breed
def speak(self):
parent_sound = super().speak() # Call parent's speak()
return f"{parent_sound} - specifically, Woof!"
buddy = Dog("Buddy", "Labrador")
print(buddy.speak())
# Output: Buddy makes a sound - specifically, Woof!Using super() has several advantages:
- You don't need to name the parent class explicitly
- It works correctly with multiple inheritance (covered later)
- It makes code easier to maintain if you change the parent class
32.3.2) Using super() in __init__
The most common use of super() is calling the parent's __init__:
class Employee:
def __init__(self, name, employee_id):
self.name = name
self.employee_id = employee_id
self.is_active = True
def deactivate(self):
self.is_active = False
class Manager(Employee):
def __init__(self, name, employee_id, department):
super().__init__(name, employee_id) # Initialize parent attributes
self.department = department
self.team = [] # Manager-specific attribute
def add_team_member(self, employee):
self.team.append(employee)
# Manager gets all Employee attributes plus its own
sarah = Manager("Sarah", "M001", "Engineering")
print(sarah.name) # Output: Sarah
print(sarah.is_active) # Output: True
print(sarah.department) # Output: EngineeringBy calling super().__init__(name, employee_id), the Manager class ensures that all the initialization logic from Employee runs, including setting is_active to True.
32.3.3) Extending Parent Methods with super()
You can use super() to extend a parent's method rather than completely replacing it:
class BankAccount:
def __init__(self, account_number, balance):
self.account_number = account_number
self.balance = balance
self.transaction_count = 0
def deposit(self, amount):
self.balance += amount
self.transaction_count += 1
return self.balance
class CheckingAccount(BankAccount):
def __init__(self, account_number, balance, overdraft_limit):
super().__init__(account_number, balance)
self.overdraft_limit = overdraft_limit
def deposit(self, amount):
was_overdrawn = self.balance < 0
# Call parent's deposit to handle the basic logic
new_balance = super().deposit(amount)
# Add checking-specific behavior
if was_overdrawn and new_balance >= 0:
print("Account is no longer overdrawn")
return new_balance
checking = CheckingAccount("C001", -50, 100)
checking.deposit(75)
# Output: Account is no longer overdrawn
print(f"Balance: ${checking.balance}") # Output: Balance: $25
print(f"Transactions: {checking.transaction_count}") # Output: Transactions: 1The CheckingAccount.deposit() method calls super().deposit(amount) to handle the basic deposit logic (updating balance and transaction count), then adds its own check for overdraft status.
32.3.4) When to Use super() vs Direct Parent Call
Use super() in most cases:
class Vehicle:
def __init__(self, brand):
self.brand = brand
class Car(Vehicle):
def __init__(self, brand, model):
super().__init__(brand) # Preferred
self.model = modelUse direct parent calls when you need to call a specific parent in a multiple inheritance scenario (covered later) or when you want to be explicit about which parent you're calling:
class Car(Vehicle):
def __init__(self, brand, model):
Vehicle.__init__(self, brand) # Explicit, but less flexible
self.model = modelFor single inheritance (one parent), super() is almost always the better choice.
32.3.5) super() with Other Methods
You can use super() with any method, not just __init__:
class TextProcessor:
def process(self, text):
# Basic processing: strip whitespace
return text.strip()
class UppercaseProcessor(TextProcessor):
def process(self, text):
# First do parent's processing
processed = super().process(text)
# Then add uppercase conversion
return processed.upper()
class PrefixProcessor(UppercaseProcessor):
def __init__(self, prefix):
self.prefix = prefix
def process(self, text):
# First do parent's processing (which calls its parent too)
processed = super().process(text)
# Then add prefix
return f"{self.prefix}: {processed}"
processor = PrefixProcessor("ALERT")
result = processor.process(" system error ")
print(result) # Output: ALERT: SYSTEM ERROR32.4) Polymorphism: Working with Compatible Classes
32.4.1) What Polymorphism Means
Polymorphism (from Greek: "many forms") is the ability to treat objects of different classes the same way if they provide the same methods.
In Python, if multiple classes have methods with the same name, you can call those methods without knowing the exact class of the object.
class Dog:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} says: Woof!"
class Cat:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} says: Meow!"
class Bird:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} says: Tweet!"
# Function that works with any object that has a speak() method
def make_animal_speak(animal):
print(animal.speak())
# Works with different classes
buddy = Dog("Buddy")
whiskers = Cat("Whiskers")
tweety = Bird("Tweety")
make_animal_speak(buddy) # Output: Buddy says: Woof!
make_animal_speak(whiskers) # Output: Whiskers says: Meow!
make_animal_speak(tweety) # Output: Tweety says: Tweet!The make_animal_speak() function doesn't care what class the animal parameter is—it just needs the object to have a speak() method. This is polymorphism in action.
32.4.2) Polymorphism with Inheritance
Polymorphism is especially powerful with inheritance, where subclasses override parent methods:
class PaymentMethod:
def process_payment(self, amount):
return f"Processing ${amount:.2f}"
class CreditCard(PaymentMethod):
def __init__(self, card_number):
self.card_number = card_number
def process_payment(self, amount):
return f"Charging ${amount:.2f} to credit card ending in {self.card_number[-4:]}"
class PayPal(PaymentMethod):
def __init__(self, email):
self.email = email
def process_payment(self, amount):
return f"Sending ${amount:.2f} via PayPal to {self.email}"
class BankTransfer(PaymentMethod):
def __init__(self, account_number):
self.account_number = account_number
def process_payment(self, amount):
return f"Transferring ${amount:.2f} to account {self.account_number}"
# Function that works with any PaymentMethod
def complete_purchase(payment_method, amount):
print(payment_method.process_payment(amount))
print("Purchase complete!")
# All payment methods work with the same function
credit = CreditCard("1234567890123456")
paypal = PayPal("user@example.com")
bank = BankTransfer("9876543210")
complete_purchase(credit, 99.99)
# Output: Charging $99.99 to credit card ending in 3456
# Output: Purchase complete!
complete_purchase(paypal, 49.50)
# Output: Sending $49.50 via PayPal to user@example.com
# Output: Purchase complete!
complete_purchase(bank, 199.00)
# Output: Transferring $199.00 to account 9876543210
# Output: Purchase complete!The complete_purchase() function works with any PaymentMethod subclass. Each subclass provides its own implementation of process_payment(), but the function doesn't need to know which specific class it's working with.
32.4.3) Duck Typing: "If It Walks Like a Duck..."
Python's polymorphism doesn't require classes to be related through inheritance. This is called duck typing: "If it walks like a duck and quacks like a duck, then it is a duck." In other words, Python cares about what an object can do(its methods), not what it is (its class). If an object has the methods you need, you can use it, regardless of its class hierarchy.
class FileWriter:
def __init__(self, filename):
self.filename = filename
def write(self, data):
print(f"Writing to {self.filename}: {data}")
class DatabaseWriter:
def __init__(self, table_name):
self.table_name = table_name
def write(self, data):
print(f"Inserting into {self.table_name}: {data}")
class ConsoleWriter:
def write(self, data):
print(f"Console output: {data}")
# Function that works with any object that has a write() method
def save_data(writer, data):
writer.write(data)
# All three classes work, even though they're unrelated
file_writer = FileWriter("data.txt")
db_writer = DatabaseWriter("users")
console_writer = ConsoleWriter()
save_data(file_writer, "User data")
# Output: Writing to data.txt: User data
save_data(db_writer, "User data")
# Output: Inserting into users: User data
save_data(console_writer, "User data")
# Output: Console output: User dataNone of these classes inherit from a common parent, but they all work with save_data() because they all have a write() method. This is duck typing—the function doesn't care about the class, only about the interface (the methods available).
32.4.4) Practical Example: A Plugin System
Polymorphism enables flexible, extensible systems. Here's a simple plugin system for data processing:
class DataProcessor:
def process(self, data):
return data # Base implementation does nothing
class UppercaseProcessor(DataProcessor):
def process(self, data):
return data.upper()
class ReverseProcessor(DataProcessor):
def process(self, data):
return data[::-1]
class RemoveSpacesProcessor(DataProcessor):
def process(self, data):
return data.replace(" ", "")
class DataPipeline:
def __init__(self):
self.processors = []
def add_processor(self, processor):
self.processors.append(processor)
def run(self, data):
result = data
for processor in self.processors:
result = processor.process(result) # Polymorphic call
return result
# Build a processing pipeline
pipeline = DataPipeline()
pipeline.add_processor(UppercaseProcessor())
pipeline.add_processor(RemoveSpacesProcessor())
pipeline.add_processor(ReverseProcessor())
# Process data through the pipeline
input_data = "Hello World"
output = pipeline.run(input_data)
print(f"Input: {input_data}") # Output: Input: Hello World
print(f"Output: {output}") # Output: Output: DLROWOLLEHThe DataPipeline doesn't need to know what specific processors it contains—it just calls process() on each one. You can easily add new processor types without changing the pipeline code.
32.5) Checking Types and Class Relationships (isinstance, issubclass)
32.5.1) Checking Instance Types with isinstance()
Sometimes you need to check if an object is an instance of a particular class. The isinstance() function does this:
class Animal:
pass
class Dog(Animal):
pass
class Cat(Animal):
pass
buddy = Dog()
whiskers = Cat()
# Check if an object is an instance of a class
print(isinstance(buddy, Dog)) # Output: True
print(isinstance(buddy, Animal)) # Output: True (Dog inherits from Animal)
print(isinstance(buddy, Cat)) # Output: False
print(isinstance(whiskers, Cat)) # Output: True
print(isinstance(whiskers, Animal)) # Output: True
print(isinstance(whiskers, Dog)) # Output: FalseNotice that isinstance(buddy, Animal) returns True even though buddy is a Dog instance. This is because Dog inherits from Animal, so a Dog instance is also considered an Animal instance.
32.5.2) Why isinstance() Respects Inheritance
The isinstance() function checks the entire inheritance chain:
class Vehicle:
pass
class Car(Vehicle):
pass
class ElectricCar(Car):
pass
tesla = ElectricCar()
# Check all levels of inheritance
print(isinstance(tesla, ElectricCar)) # Output: True
print(isinstance(tesla, Car)) # Output: True
print(isinstance(tesla, Vehicle)) # Output: True
print(isinstance(tesla, str)) # Output: FalseThe tesla object is an instance of ElectricCar, but it's also an instance of Car and Vehicle because of inheritance.
32.5.3) Checking Multiple Types at Once
You can check if an object is an instance of any of several classes by passing a tuple:
class Dog:
pass
class Cat:
pass
class Bird:
pass
def is_pet(animal):
return isinstance(animal, (Dog, Cat, Bird))
buddy = Dog()
whiskers = Cat()
tweety = Bird()
rock = "just a rock"
print(is_pet(buddy)) # Output: True
print(is_pet(whiskers)) # Output: True
print(is_pet(tweety)) # Output: True
print(is_pet(rock)) # Output: FalseThis is more concise than writing isinstance(animal, Dog) or isinstance(animal, Cat) or isinstance(animal, Bird).
32.5.4) Checking Class Relationships with issubclass()
The issubclass() function checks if one class is a subclass of another:
class Animal:
pass
class Dog(Animal):
pass
class Cat(Animal):
pass
class Poodle(Dog):
pass
# Check class relationships
print(issubclass(Dog, Animal)) # Output: True
print(issubclass(Cat, Animal)) # Output: True
print(issubclass(Poodle, Dog)) # Output: True
print(issubclass(Poodle, Animal)) # Output: True (indirect inheritance)
print(issubclass(Dog, Cat)) # Output: False
# A class is considered a subclass of itself
print(issubclass(Dog, Dog)) # Output: TrueNote that issubclass() works with classes, not instances. Use isinstance() for instances and issubclass() for classes.
32.5.5) Practical Use Cases for Type Checking
Type checking is useful when you need different behavior for different types:
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
class Manager(Employee):
def __init__(self, name, salary, bonus):
super().__init__(name, salary)
self.bonus = bonus
class Contractor:
def __init__(self, name, hourly_rate, hours):
self.name = name
self.hourly_rate = hourly_rate
self.hours = hours
def calculate_payment(worker):
# When using isinstance() with inheritance, check subclasses before parent classes
if isinstance(worker, Manager): # Check Manager first
return worker.salary + worker.bonus
elif isinstance(worker, Employee): # Then check Employee (parent class)
return worker.salary
elif isinstance(worker, Contractor):
return worker.hourly_rate * worker.hours
else:
return 0
alice = Employee("Alice", 50000)
bob = Manager("Bob", 70000, 10000)
charlie = Contractor("Charlie", 50, 160)
print(f"Alice's payment: ${calculate_payment(alice)}") # Output: Alice's payment: $50000
print(f"Bob's payment: ${calculate_payment(bob)}") # Output: Bob's payment: $80000
print(f"Charlie's payment: ${calculate_payment(charlie)}")# Output: Charlie's payment: $8000However, in many cases, polymorphism (having each class implement a common method) is better than type checking:
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def calculate_payment(self):
return self.salary
class Manager(Employee):
def __init__(self, name, salary, bonus):
super().__init__(name, salary)
self.bonus = bonus
def calculate_payment(self):
return self.salary + self.bonus
class Contractor:
def __init__(self, name, hourly_rate, hours):
self.name = name
self.hourly_rate = hourly_rate
self.hours = hours
def calculate_payment(self):
return self.hourly_rate * self.hours
# No type checking needed - polymorphism handles it
workers = [
Employee("Alice", 50000),
Manager("Bob", 70000, 10000),
Contractor("Charlie", 50, 160)
]
for worker in workers:
payment = worker.calculate_payment() # Polymorphic call
print(f"{worker.name}'s payment: ${payment}")Output:
Alice's payment: $50000
Bob's payment: $80000
Charlie's payment: $8000This polymorphic approach is more flexible and easier to extend with new worker types. You don't need to modify the calling code when you add a new worker class—just make sure it has a calculate_payment() method.
Inheritance and polymorphism are powerful tools for organizing code and creating flexible, extensible systems. By creating subclasses, you can reuse existing code while adding or modifying behavior. By overriding methods, you can customize how subclasses work. And by using polymorphism, you can write code that works with many different classes through a common interface.
The key is to use these features thoughtfully:
- Use inheritance when there's a genuine "is-a" relationship (a
Dogis anAnimal) - Override methods to specialize behavior, not to completely change what a class does
- Use
super()to extend parent behavior rather than replacing it entirely - Prefer polymorphism (common methods) over type checking when possible
As you build larger programs, these object-oriented techniques will help you create code that's easier to understand, maintain, and extend.