Sitemap
Jennifer Udeh

Jennifer Udeh

Object-Oriented Python: A Beginner’s Journey Through Classes, Inheritance, Iterators, Polymorphism, and Scope

6 min readSep 19, 2026

--

When I started learning Python’s object-oriented side, it felt like a pile of disconnected vocabulary: classes, self, super(), iter, nonlocal. It wasn’t until I built a small project that used all of it together that the pieces actually clicked. Here’s the tour I wish I’d had at the start.

It starts with a blueprint

The whole idea of OOP boils down to one distinction: a class is a blueprint, and an object is a real thing built from that blueprint.

class Person:  
def __init__(self, name, age):
self.name = name
self.age = age
p1 = Person("John", 36)  
print(p1.name) # John

init() is the setup step that runs automatically every time you create a new object. self just means “this particular object,” a way for the object to keep track of its own data instead of mixing it up with every other object made from the same class.

By default, printing an object gives you something unhelpful like <main.Person object at 0x…>. Adding a str() method fixes that:

class Person:  
def __init__(self, name, age):
self.name = name
self.age = age
    def __str__(self):  
return f"{self.name}({self.age})"
print(Person("John", 36)) # John(36)

Small detail, but it’s a good early lesson: Python has a whole family of these “dunder” (double-underscore) methods that let your objects hook into built-in behavior, things like printing, iteration, and comparisons.

Inheritance: don’t repeat yourself

Once you have one class, the next question is usually: what if I want a slightly specialized version of it? That’s inheritance.

class Person:  
def __init__(self, fname, lname):
self.firstname = fname
self.lastname = lname
    def printname(self):  
print(self.firstname, self.lastname)
class Student(Person):
def __init__(self, fname, lname, year):
super().__init__(fname, lname)
self.graduationyear = year

Student gets everything Person has for free, plus its own graduationyear. The super().init(…) call is doing the heavy lifting here: it reuses the parent’s setup logic instead of copy-pasting it. Skip that call, and the child class quietly loses everything the parent’s init was doing.

This scales further than you’d expect. Multi-level inheritance chains a grandparent, parent, and child together, and multiple inheritance lets a class inherit from more than one parent at once. That’s where Python’s Method Resolution Order (MRO) comes in. If two parent classes both define a method with the same name, Python decides which one wins by walking through the classes in a fixed, predictable left-to-right order:

class Flyer:  
def move(self):
return "Flying"
class Swimmer:  
def move(self):
return "Swimming"
class Duck(Flyer, Swimmer):
def move(self):
return super().move()
print(Duck().move()) # Flying
print(Duck.__mro__) # Duck -> Flyer -> Swimmer -> object

Swap the inheritance order to Duck(Swimmer, Flyer), and super().move() returns “Swimming” instead. Nothing mysterious about it, just following the order you wrote.

Polymorphism: same name, different behavior

Polymorphism is a fancy word for something you’ve already been using without noticing: len() works on strings, lists, and dictionaries, and does something sensible for each one.

print(len("hello"))            # 5  
print(len([1, 2, 3])) # 3
print(len({"a": 1, "b": 2})) # 2

The same idea applies to your own classes. Different classes can define a method with the same name, and you can call it without caring what type of object you’re actually holding:

class Cat:  
def sound(self):
return "Meow"
class Dog:  
def sound(self):
return "Woof"
def animal_sound(animal):
print(animal.sound())
animal_sound(Cat()) # Meow
animal_sound(Dog()) # Woof

animal_sound() never checks whether it received a Cat or a Dog. It just trusts that whatever it’s handed has a .sound() method, a style Python developers often call “duck typing.” Combine this with inheritance, and you get one of the most useful patterns in OOP: a shared interface, like move() or area(), that every subclass implements its own way.

Iterators: how for loops actually work

Every time you write for item in some_list:, Python is quietly doing more than it looks like. It calls iter() on the list to get an iterator, then repeatedly calls next() on that iterator until it runs out of values.

Get Jennifer Udeh’s stories in your inbox

Join Medium for free to get updates from this writer.

You can build your own by implementing two methods: iter() (which sets things up and returns the object itself) and next() (which computes and returns the next value):

class Fibonacci:  
def __init__(self, n):
self.n = n
self.a, self.b = 0, 1
self.count = 0
    def __iter__(self):  
return self
def __next__(self):
if self.count >= self.n:
raise StopIteration
current = self.a
self.a, self.b = self.b, self.a + self.b
self.count += 1
return current
for num in Fibonacci(8):
print(num) # 0 1 1 2 3 5 8 13

StopIteration is the signal that tells a for loop to stop asking for more values. Without it, a custom iterator would just run forever.

Scope: who can see what

The last piece is about where a variable lives and who can reach it.

A variable created inside a function is local, it disappears once the function ends. A variable created at the top level of your script is global, visible everywhere. If you reuse the same name inside and outside a function, Python treats them as two completely separate variables:

x = 300
def myfunc():  
x = 200
print(x) # 200, the local one
myfunc()
print(x) # 300, the global one, untouched

If you actually want to modify the global variable from inside a function, you need the global keyword. And if you’re working with a nested function and want to modify a variable from its enclosing function, not all the way up to global, that’s what nonlocal is for:

def outer():  
message = "Hello"
    def inner():  
nonlocal message
message = "Hello, changed!"
inner()
print(message) # Hello, changed!
outer()

Even Python’s built-in functions live in a scope you can accidentally shadow. Write len = 100 inside a function, and every later call to len(…) in that scope will fail with TypeError: ‘int’ object is not callable, because your local variable now hides the real len function.

Bringing it all together: a small library system

The best way any of this actually sinks in is by combining it into something real. Here’s a compact library management system that touches every concept above:

class Book:  
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
self.available = True
    def get_description(self):  
return f"{self.title} by {self.author} ({self.year})"
class EBook(Book):
def __init__(self, title, author, year, file_size, format):
super().__init__(title, author, year)
self.file_size = file_size
self.format = format
def get_description(self):
return f"{super().get_description()} - eBook, {self.format}, {self.file_size}MB"
class Member:
def __init__(self, name, member_id):
self.name = name
self.member_id = member_id
self._borrowed_books = [] # protected, by convention only
self.borrow_limit = 3
def borrow_book(self, book):
if len(self._borrowed_books) >= self.borrow_limit or not book.available:
print(f"Can't borrow '{book.title}' right now.")
return
book.available = False
self._borrowed_books.append(book)
Library.total_borrowed += 1
def __iter__(self):
return iter(self._borrowed_books)
class Library:
total_borrowed = 0 # class-level, shared by every Library instance
def __init__(self, name):
self.__library_name = name # private, name-mangled
self.books = []
def get_library_name(self):
return self.__library_name
def add_book(self, book):
self.books.append(book)
def __iter__(self):
return iter(self.books)

Inheritance: EBook extends Book, and different member types could extend Member the same way. Polymorphism: get_description() looks different depending on the book type, without any calling code needing to know which subclass it’s dealing with. Iterators: both Library and Member implement iter, so you can write for book in library: or for book in member: directly. Scope: _borrowed_books is a soft “protected” signal, while __library_name is name-mangled by Python into something like _Library__library_name, making it much harder to touch by accident from outside the class. Class-level state: total_borrowed lives on the class itself, not on any one instance, so every Member.borrow_book() call updates a single shared counter.

The takeaway

None of these ideas are hard on their own: a class with an init, a for loop, a variable inside a function. What makes OOP feel overwhelming at first is that the real value only shows up once you combine them. Inheritance avoids repeating yourself, polymorphism treats different objects uniformly, iterators make your own objects loop-friendly, and scope keeps your data from leaking where it shouldn’t. Build one small project that uses all four together, and the vocabulary stops being abstract. It just becomes how you think about structuring code.

--

--