Core OOP Principles
Object-Oriented Programming is built on four fundamental principles: Encapsulation, Abstraction, Inheritance, and Polymorphism. This chapter covers these principles as they apply to Python, along with related concepts like composition and advanced features.
Encapsulation
Encapsulation is the bundling of data and methods that operate on that data within a single unit (class). It also involves restricting access to certain components to prevent unauthorized modification.
Private Attributes (Name Mangling)
Python doesn’t have true private attributes, but it uses name mangling to make attributes harder to access from outside the class. Prefix an attribute name with double underscores __:
class BankAccount: def __init__(self, balance): self.__balance = balance # Private attribute (name mangling)
def deposit(self, amount): if amount > 0: self.__balance += amount
def withdraw(self, amount): if 0 < amount <= self.__balance: self.__balance -= amount return True return False
def get_balance(self): return self.__balance
account = BankAccount(1000)account.deposit(500)print(account.get_balance()) # 1500
# Direct access attempts# print(account.__balance) # AttributeError: 'BankAccount' object has no attribute '__balance'# But name mangling makes it accessible as:print(account._BankAccount__balance) # 1500 (not recommended, but possible)Protected Attributes
By convention, a single underscore _ prefix indicates a protected attribute (should not be accessed from outside, but Python doesn’t enforce this):
class Person: def __init__(self, name, age): self.name = name # Public self._age = age # Protected (convention) self.__salary = 0 # Private (name mangling)
def get_age(self): return self._age
person = Person("Alice", 25)print(person.name) # Alice (public, accessible)print(person._age) # 25 (protected, accessible but not recommended)# print(person.__salary) # AttributeErrorGetters and Setters
Use properties to create getters and setters for controlled access:
class Temperature: def __init__(self, celsius): self._celsius = celsius
@property def celsius(self): return self._celsius
@celsius.setter def celsius(self, value): if value < -273.15: raise ValueError("Temperature below absolute zero") self._celsius = value
@property def fahrenheit(self): return (self._celsius * 9/5) + 32
@fahrenheit.setter def fahrenheit(self, value): self.celsius = (value - 32) * 5/9
temp = Temperature(25)print(temp.celsius) # 25print(temp.fahrenheit) # 77.0
temp.fahrenheit = 100print(temp.celsius) # 37.777...Polymorphism
Polymorphism means “many forms” - the ability of different classes to be used through the same interface. Python supports polymorphism through duck typing and method overriding.
Duck Typing
“If it walks like a duck and quacks like a duck, it’s a duck.” Python uses duck typing - if an object has the required methods, it can be used regardless of its type:
class Dog: def speak(self): return "Woof!"
class Cat: def speak(self): return "Meow!"
class Robot: def speak(self): return "Beep boop!"
def make_sound(animal): print(animal.speak())
dog = Dog()cat = Cat()robot = Robot()
make_sound(dog) # Woof!make_sound(cat) # Meow!make_sound(robot) # Beep boop!Polymorphism with Inheritance
class Shape: def area(self): raise NotImplementedError("Subclass must implement area()")
class Rectangle(Shape): def __init__(self, length, width): self.length = length self.width = width
def area(self): return self.length * self.width
class Circle(Shape): def __init__(self, radius): self.radius = radius
def area(self): return 3.14159 * self.radius ** 2
def print_area(shape): print(f"Area: {shape.area()}")
rect = Rectangle(5, 3)circle = Circle(4)
print_area(rect) # Area: 15print_area(circle) # Area: 50.26544Operator Overloading (Polymorphism)
Polymorphism also applies to operators through special methods:
class Vector: def __init__(self, x, y): self.x = x self.y = y
def __add__(self, other): return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar): return Vector(self.x * scalar, self.y * scalar)
def __str__(self): return f"Vector({self.x}, {self.y})"
v1 = Vector(2, 3)v2 = Vector(1, 4)v3 = v1 + v2v4 = v1 * 3
print(v3) # Vector(3, 7)print(v4) # Vector(6, 9)Abstraction
Abstraction hides complex implementation details and shows only essential features. In Python, abstraction is achieved through abstract base classes.
Abstract Base Classes (ABC)
Use the abc module to create abstract classes:
from abc import ABC, abstractmethod
class Animal(ABC): @abstractmethod def make_sound(self): pass
@abstractmethod def move(self): pass
def sleep(self): return "Sleeping..."
class Dog(Animal): def make_sound(self): return "Woof!"
def move(self): return "Running on four legs"
class Bird(Animal): def make_sound(self): return "Chirp!"
def move(self): return "Flying"
# animal = Animal() # TypeError: Can't instantiate abstract classdog = Dog()bird = Bird()
print(dog.make_sound()) # Woof!print(bird.move()) # FlyingAbstract Properties
You can also create abstract properties:
from abc import ABC, abstractmethod
class Shape(ABC): @property @abstractmethod def area(self): pass
@property @abstractmethod def perimeter(self): pass
class Rectangle(Shape): def __init__(self, length, width): self.length = length self.width = width
@property def area(self): return self.length * self.width
@property def perimeter(self): return 2 * (self.length + self.width)
rect = Rectangle(5, 3)print(rect.area) # 15print(rect.perimeter) # 16Composition vs Inheritance
Composition is a design pattern where a class contains objects of other classes, rather than inheriting from them. “Has-a” relationship vs “Is-a” relationship.
Inheritance (Is-a)
class Animal: def move(self): return "Moving"
class Dog(Animal): # Dog IS-A Animal def bark(self): return "Woof!"
dog = Dog()print(dog.move()) # Moving (inherited)print(dog.bark()) # Woof!Composition (Has-a)
class Engine: def start(self): return "Engine started"
class Wheel: def rotate(self): return "Wheel rotating"
class Car: def __init__(self): self.engine = Engine() # Car HAS-A Engine self.wheels = [Wheel() for _ in range(4)] # Car HAS-A Wheels
def start(self): return self.engine.start()
def drive(self): return ", ".join([wheel.rotate() for wheel in self.wheels])
car = Car()print(car.start()) # Engine startedprint(car.drive()) # Wheel rotating, Wheel rotating, Wheel rotating, Wheel rotatingWhen to Use Composition vs Inheritance
- Use Inheritance when there’s a clear “is-a” relationship and you want to reuse code
- Use Composition when there’s a “has-a” relationship or you want more flexibility
# Inheritance exampleclass Employee: def work(self): return "Working"
class Manager(Employee): # Manager IS-A Employee def manage(self): return "Managing"
# Composition exampleclass Salary: def __init__(self, amount): self.amount = amount
class Employee: def __init__(self, salary): self.salary = salary # Employee HAS-A Salary
def get_salary(self): return self.salary.amount
salary = Salary(50000)employee = Employee(salary)print(employee.get_salary()) # 50000Data Classes (Python 3.7+)
Data classes automatically generate special methods like __init__, __repr__, and __eq__ for classes that primarily store data:
from dataclasses import dataclass
@dataclassclass Point: x: float y: float
def distance_from_origin(self): return (self.x ** 2 + self.y ** 2) ** 0.5
@dataclassclass Person: name: str age: int email: str = "" # Default value
p1 = Point(3, 4)p2 = Point(3, 4)p3 = Point(5, 6)
print(p1) # Point(x=3, y=4)print(p1 == p2) # True (automatic __eq__)print(p1 == p3) # Falseprint(p1.distance_from_origin()) # 5.0
person = Person("Alice", 25, "alice@example.com")print(person) # Person(name='Alice', age=25, email='alice@example.com')Data Class Features
from dataclasses import dataclass, field
@dataclassclass Student: name: str age: int grades: list = field(default_factory=list) # Mutable default
def add_grade(self, grade): self.grades.append(grade)
def average_grade(self): return sum(self.grades) / len(self.grades) if self.grades else 0
student = Student("Alice", 20)student.add_grade(85)student.add_grade(90)student.add_grade(88)
print(student) # Student(name='Alice', age=20, grades=[85, 90, 88])print(student.average_grade()) # 87.666...Frozen Data Classes
Make data classes immutable:
from dataclasses import dataclass
@dataclass(frozen=True)class Point: x: float y: float
p = Point(3, 4)# p.x = 5 # FrozenInstanceError: cannot assign to field 'x'__slots__ for Memory Optimization
Using __slots__ restricts the attributes an instance can have, saving memory:
class RegularClass: def __init__(self, x, y): self.x = x self.y = y
class SlotsClass: __slots__ = ['x', 'y']
def __init__(self, x, y): self.x = x self.y = y
regular = RegularClass(1, 2)slots_obj = SlotsClass(1, 2)
# regular.z = 3 # Works (can add new attributes)# slots_obj.z = 3 # AttributeError: 'SlotsClass' object has no attribute 'z'
import sysprint(sys.getsizeof(regular)) # Larger sizeprint(sys.getsizeof(slots_obj)) # Smaller sizeNote
__slots__prevents the creation of__dict__, which saves memory but prevents adding new attributes dynamically.
Descriptors
Descriptors are objects that define how attribute access works. Properties are a type of descriptor:
class PositiveNumber: def __init__(self, name): self.name = name
def __get__(self, obj, objtype=None): return obj.__dict__.get(self.name, 0)
def __set__(self, obj, value): if value < 0: raise ValueError("Value must be positive") obj.__dict__[self.name] = value
class Rectangle: width = PositiveNumber('width') height = PositiveNumber('height')
def __init__(self, width, height): self.width = width self.height = height
def area(self): return self.width * self.height
rect = Rectangle(5, 3)print(rect.area()) # 15
# rect.width = -5 # ValueError: Value must be positiveComplete Example: Library System
Here’s a comprehensive example combining multiple OOP principles:
from abc import ABC, abstractmethodfrom dataclasses import dataclassfrom typing import List
# Abstractionclass Item(ABC): def __init__(self, title, author): self.title = title self.author = author self._is_checked_out = False
@abstractmethod def get_info(self): pass
def check_out(self): if not self._is_checked_out: self._is_checked_out = True return True return False
def return_item(self): if self._is_checked_out: self._is_checked_out = False return True return False
# Inheritance and Polymorphismclass Book(Item): def __init__(self, title, author, isbn): super().__init__(title, author) self.isbn = isbn
def get_info(self): status = "Checked out" if self._is_checked_out else "Available" return f"Book: {self.title} by {self.author} (ISBN: {self.isbn}) - {status}"
class Magazine(Item): def __init__(self, title, author, issue_number): super().__init__(title, author) self.issue_number = issue_number
def get_info(self): status = "Checked out" if self._is_checked_out else "Available" return f"Magazine: {self.title} by {self.author} (Issue: {self.issue_number}) - {status}"
# Composition@dataclassclass Address: street: str city: str zip_code: str
def __str__(self): return f"{self.street}, {self.city} {self.zip_code}"
class Member: def __init__(self, name, member_id, address): self.name = name self.member_id = member_id self.address = address # Composition: Member HAS-A Address self.checked_out_items = [] # Composition: Member HAS-A list of Items
def check_out_item(self, item): if item.check_out(): self.checked_out_items.append(item) return True return False
def return_item(self, item): if item in self.checked_out_items: item.return_item() self.checked_out_items.remove(item) return True return False
# Polymorphism in actiondef display_item_info(item): print(item.get_info())
# Usagebook1 = Book("Python Basics", "John Doe", "123-456")magazine1 = Magazine("Tech Weekly", "Jane Smith", 42)
address = Address("123 Main St", "Springfield", "12345")member = Member("Alice", "M001", address)
member.check_out_item(book1)member.check_out_item(magazine1)
# Polymorphism: same function works with different typesdisplay_item_info(book1) # Book: Python Basics by John Doe (ISBN: 123-456) - Checked outdisplay_item_info(magazine1) # Magazine: Tech Weekly by Jane Smith (Issue: 42) - Checked out
print(f"Member: {member.name}, Address: {member.address}")# Member: Alice, Address: 123 Main St, Springfield 12345Exercises
Exercise 1: Encapsulation with Properties
Create a class BankAccount with a private _balance attribute. Use properties to get and set the balance, ensuring it cannot be set to a negative value. Read initial balance, then a new balance, and display both.
Encapsulation with Properties
class BankAccount: def __init__(self, balance): self._balance = balance
@property def balance(self): return self._balance
@balance.setter def balance(self, value): if value >= 0: self._balance = value
initial = float(input())new_balance = float(input())
account = BankAccount(initial)print(f"Initial balance: {account.balance}")account.balance = new_balanceprint(f"New balance: {account.balance}")Exercise 2: Polymorphism with Duck Typing
Create three classes Dog, Cat, and Bird, each with a speak() method. Create a function make_animal_speak() that takes any object with a speak() method and calls it. Test with all three classes.
Polymorphism with Duck Typing
class Dog: def speak(self): return "Woof!"
class Cat: def speak(self): return "Meow!"
class Bird: def speak(self): return "Chirp!"
def make_animal_speak(animal): return animal.speak()
dog = Dog()cat = Cat()bird = Bird()
print(make_animal_speak(dog))print(make_animal_speak(cat))print(make_animal_speak(bird))Exercise 3: Abstract Base Class
Create an abstract base class Shape with abstract methods area() and perimeter(). Create concrete classes Rectangle and Circle that implement these methods. Read values and display areas and perimeters.
Abstract Base Class
from abc import ABC, abstractmethodimport math
class Shape(ABC): @abstractmethod def area(self): pass
@abstractmethod def perimeter(self): pass
class Rectangle(Shape): def __init__(self, length, width): self.length = length self.width = width
def area(self): return self.length * self.width
def perimeter(self): return 2 * (self.length + self.width)
class Circle(Shape): def __init__(self, radius): self.radius = radius
def area(self): return math.pi * self.radius ** 2
def perimeter(self): return 2 * math.pi * self.radius
length = float(input())width = float(input())radius = float(input())
rect = Rectangle(length, width)circle = Circle(radius)
print(f"Rectangle - Area: {rect.area()}, Perimeter: {rect.perimeter()}")print(f"Circle - Area: {circle.area():.2f}, Perimeter: {circle.perimeter():.2f}")Exercise 4: Composition
Create a Engine class with a start() method and a Car class that has an Engine (composition). Create a Car and start its engine.
Composition
class Engine: def start(self): return "Engine started"
class Car: def __init__(self): self.engine = Engine() # Composition: Car HAS-A Engine
def start_car(self): return self.engine.start()
def drive(self): return "Car is ready to drive!"
car = Car()print(car.start_car())print(car.drive())Exercise 5: Data Class
Create a data class Student with fields name, age, and grade. Create two Student objects with the same values and one with different values. Test equality comparison.
Data Class
from dataclasses import dataclass
@dataclassclass Student: name: str age: int grade: int
name1 = input()age1 = int(input())grade1 = int(input())name2 = input()age2 = int(input())grade2 = int(input())
student1 = Student(name1, age1, grade1)student2 = Student(name1, age1, grade1) # Same valuesstudent3 = Student(name2, age2, grade2) # Different values
print(student1 == student2) # Trueprint(student1 == student3) # False