OOP: Encapsulation and Polymorphism
Controlling Access and Creating Flexible Interfaces
In the previous unit, we explored inheritance. Now let's look at two more OOP concepts: encapsulation and polymorphism. Encapsulation controls access to an object's data. Polymorphism lets different classes share a common interface.

Encapsulation
Encapsulation means bundling data with the methods that operate on it, while restricting direct access to some of that data. This prevents code outside the class from accidentally breaking the object's internal state.
class BankAccount:
def __init__(self, balance):
self.__balance = balance # Private attribute
def deposit(self, amount):
self.__balance += amount
def get_balance(self):
return self.__balance
The double underscore before balance makes it private. Code outside the class can't access __balance directly. Instead, it must use deposit() to add money and get_balance() to check the amount. This way the class controls how its data gets modified.
Private vs Protected
Python uses underscores to signal access levels:
A single underscore (like _age) marks an attribute as protected. It's a convention saying "this is internal, don't touch it from outside," but Python doesn't enforce it.
class Person:
def __init__(self):
self._age = 18 # Protected
A double underscore (like __age) marks an attribute as private. Python "mangles" the name, making it harder to access from outside the class.
class Person:
def __init__(self):
self.__age = 18 # Private
Neither is truly enforced, but double underscore provides stronger protection and signals stronger intent.
Polymorphism
Polymorphism means "many forms." It lets you write code that works with objects of different types, as long as they share a common interface.
class Animal:
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
animals = [Dog(), Cat()]
for animal in animals:
print(animal.speak()) # Outputs "Woof!" then "Meow!"
The loop doesn't care whether each animal is a Dog or Cat. It just calls speak() on each one. Each class provides its own implementation, but the calling code treats them all the same way.
Project: Encapsulated Shapes
Let's refactor our Shape class to use private attributes and accessor methods:
import turtle
class Shape:
def __init__(self, t, sides, length):
self.__t = t
self.__sides = sides
self.__length = length
def draw(self):
for _ in range(self.__sides):
self.__t.forward(self.__length)
self.__t.right(360 / self.__sides)
def get_sides(self):
return self.__sides
def get_length(self):
return self.__length
class Square(Shape):
def __init__(self, t, length):
super().__init__(t, 4, length)
class Triangle(Shape):
def __init__(self, t, length):
super().__init__(t, 3, length)
def draw_shapes(t, shapes):
for shape in shapes:
shape.draw()
t.penup()
t.forward(shape.get_length() * 2)
t.pendown()
screen = turtle.Screen()
screen.setup(width=800, height=600)
screen.bgcolor("white")
t = turtle.Turtle()
shapes = [Square(t, 50), Triangle(t, 50), Square(t, 100)]
draw_shapes(t, shapes)
t.hideturtle()
turtle.done()
The Shape class now encapsulates its attributes with double underscores. External code uses get_sides() and get_length() to read the values.
The draw_shapes function demonstrates polymorphism: it accepts any list of shapes and calls draw() on each. Whether it's a Square, Triangle, or any future shape class, the function works the same way.
In the next unit, we'll explore recursion and use it to create fractal patterns.