Core OOP Principles

Learn the core principles of object-oriented programming in Python: encapsulation, polymorphism, abstraction, composition, data classes, and advanced OOP concepts.

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 __:

name-mangling.py
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):

protected-attributes.py
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) # AttributeError

Getters and Setters

Use properties to create getters and setters for controlled access:

getters-setters.py
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) # 25
print(temp.fahrenheit) # 77.0
temp.fahrenheit = 100
print(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:

duck-typing.py
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

polymorphism-inheritance.py
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: 15
print_area(circle) # Area: 50.26544

Operator Overloading (Polymorphism)

Polymorphism also applies to operators through special methods:

operator-overloading.py
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 + v2
v4 = 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:

abstract-base-classes.py
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 class
dog = Dog()
bird = Bird()
print(dog.make_sound()) # Woof!
print(bird.move()) # Flying

Abstract Properties

You can also create abstract properties:

abstract-properties.py
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) # 15
print(rect.perimeter) # 16

Composition 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)

inheritance-relationship.py
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)

composition-relationship.py
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 started
print(car.drive()) # Wheel rotating, Wheel rotating, Wheel rotating, Wheel rotating

When 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
composition-vs-inheritance.py
# Inheritance example
class Employee:
def work(self):
return "Working"
class Manager(Employee): # Manager IS-A Employee
def manage(self):
return "Managing"
# Composition example
class 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()) # 50000

Data Classes (Python 3.7+)

Data classes automatically generate special methods like __init__, __repr__, and __eq__ for classes that primarily store data:

data-classes.py
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
def distance_from_origin(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
@dataclass
class 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) # False
print(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

dataclass-features.py
from dataclasses import dataclass, field
@dataclass
class 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:

frozen-dataclass.py
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:

slots.py
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 sys
print(sys.getsizeof(regular)) # Larger size
print(sys.getsizeof(slots_obj)) # Smaller size

Note

__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:

descriptors.py
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 positive

Complete Example: Library System

Here’s a comprehensive example combining multiple OOP principles:

library-system.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List
# Abstraction
class 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 Polymorphism
class 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
@dataclass
class 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 action
def display_item_info(item):
print(item.get_info())
# Usage
book1 = 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 types
display_item_info(book1) # Book: Python Basics by John Doe (ISBN: 123-456) - Checked out
display_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 12345

Exercises

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

Checks: 0 times
Answer:
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_balance
print(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

Checks: 0 times
Answer:
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

Checks: 0 times
Answer:
from abc import ABC, abstractmethod
import 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

Checks: 0 times
Answer:
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

Checks: 0 times
Answer:
from dataclasses import dataclass
@dataclass
class 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 values
student3 = Student(name2, age2, grade2) # Different values
print(student1 == student2) # True
print(student1 == student3) # False

Course Progress

Section 51 of 61

Back to Course