Chapter 16 of 24 · Python for Beginners — Complete Free Tutorial
Classes and Object-Oriented Programming
Dictionaries and lists carry data beautifully, but a program about courses, enrolments and students soon becomes a pile of loose keys with no rules attached. Chapter 16 of the Python for Beginners 24-chapter series introduces classes: a way to bundle data with the functions that act on it, share behaviour through inheritance, and let each object take responsibility for itself.
Classes, objects and __init__
A class is a blueprint; an object (or instance) is one thing built from it. __init__ — pronounced “dunder init” — is the initialiser that runs the moment you call the class, and self is the object being built:
class Course:
def __init__(self, title, lessons):
self.title = title
self.lessons = lessons
python_course = Course("Python Basics", 24)
print(python_course.title, python_course.lessons, type(python_course).__name__)
Expected output: Python Basics 24 Course. Calling Course(...) creates an empty object and hands it to __init__, which attaches the attributes — self.title = title means “store this object’s copy”, and that is where the data lives from then on.
Class attributes vs instance attributes
Attributes come in two flavours. Instance attributes are set inside __init__ and belong to one object; class attributes are declared at class level and shared by every instance.
class Course:
level = "beginner" # class attribute: one shared copy
def __init__(self, title):
self.title = title # instance attribute: one per object
a = Course("Python")
b = Course("SQL")
print(a.level, b.level)
Course.level = "intermediate"
print(a.level)
a.level = "advanced"
print(a.level, b.level, Course.level)
Expected output:
beginner beginner
intermediate
advanced intermediate intermediate
Reading falls back to the class when the instance has no such attribute, but writing a.level quietly creates an instance attribute that shadows the shared one — which is why the last line shows three different values: changing the class changed everyone, changing the instance changed nobody else.
Methods, self, inheritance and super()
Functions inside a class are methods, and the first parameter is always the instance the method was called on. Python spells it self by convention: it is passed for you, but you must declare it. Inheritance then lets a class specialise another, reusing the parent’s initialiser through super() and extending its behaviour — this is the finished model for this chapter:
class Course:
level = "beginner"
def __init__(self, title, lessons):
self.title = title
self.lessons = lessons
self._enrolled = 0 # "protected" by convention
def enroll(self, student):
self._enrolled += 1
return f"{student} joined {self.title}"
def summary(self):
return f"{self.title}: {self.lessons} lessons ({self.level})"
class CertificateCourse(Course):
def __init__(self, title, lessons, exam):
super().__init__(title, lessons)
self.exam = exam
def summary(self):
return f"{super().summary()} + exam: {self.exam}"
course = CertificateCourse("Python Basics", 24, "final quiz")
print(course.summary())
print(course.enroll("Ada"), "->", course._enrolled)
print(isinstance(course, Course))
print([c.__name__ for c in CertificateCourse.__mro__])
Expected output:
Python Basics: 24 lessons (beginner) + exam: final quiz
Ada joined Python Basics -> 1
True
['CertificateCourse', 'Course', 'object']
The last line is the method resolution order: when you call course.summary() Python looks in CertificateCourse, then Course, then object — the base of nearly every class. isinstance() walks the same chain, which is why a subclass is an instance of its parent.
Encapsulation: conventions, name mangling and @property
Python has no private keyword. A single _leading_underscore is a signal to developers (“internal, treat as read-only”), while __double_underscore triggers name mangling: the attribute is stored as _ClassName__name, so a subclass cannot clobber it by accident.
class Secret:
def __init__(self):
self.__token = "abc123"
s = Secret()
print(s._Secret__token)
print(s.__token)
Expected output: abc123 then AttributeError: 'Secret' object has no attribute '__token' — renaming is deterrence, not security. The idiomatic way to expose read-only data is @property, which presents an attribute while keeping the logic underneath:
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
@property
def balance(self):
return self._balance
acct = Account("Ada", 50)
print(acct.balance)
acct.balance = 100
Expected output: 50, then AttributeError: property 'balance' of 'Account' object has no setter. Callers read acct.balance like plain data, but the balance changes only through methods such as deposit() — invariants guarded by methods rather than by visibility.
Polymorphism, isinstance and NotImplementedError
The payoff is polymorphism: code that works with “a shape” rather than any particular shape, asking what a thing can do instead of what it is.
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area()")
class Rectangle(Shape):
def __init__(self, width, height):
self.width, self.height = width, height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def describe(shape):
print(f"{shape.__class__.__name__}: {shape.area():.2f}")
for shape in [Rectangle(4, 5), Circle(2)]:
describe(shape)
print(isinstance(Rectangle(1, 1), Shape))
Shape().area()
Expected output:
Rectangle: 20.00
Circle: 12.57
True
NotImplementedError: subclasses must implement area()
Python has no abstract keyword, so raising NotImplementedError in the base method is the everyday way to say “every subclass must provide this” — nothing stops you instantiating the base, and the error arrives the moment you call the method. If you have met TypeScript’s interfaces in classes and access modifiers, the intent is the same: there the compiler enforces it, here the runtime does.
Common mistakes
- Forgetting
self— callingCourse.enroll("Ada")raisesTypeError: Course.enroll() missing 1 required positional argument: 'student'. - Writing
self.level = ...when you meant the class — it creates a private shadow on that one object; changeCourse.levelwhen every instance should see it. - Treating
_nameas private — the underscore is a courtesy to readers and__namemerely renames the attribute; neither stops determined code. - Skipping
super().__init__(...)— attributes the parent sets are missing, and later methods fail with anAttributeErroryou have to trace back. - Reaching for inheritance too eagerly — deep hierarchies get brittle; prefer small classes that accept collaborators as arguments.
Practice checklist
- Write the
Courseclass, create two instances, and prove the class attribute is shared by changing it on the class. - Call a method before declaring
self, read theTypeError, then fix it. - Reproduce the
Secretoutput: prints.__token(fails) and thens._Secret__token(works), and explain why neither is a security feature. - Give
Accountadeposit()method and awithdraw()method that refuses to overdraw, both feeding the read-onlybalanceproperty. - Add a
Workshop(Course)subclass with its ownsummary(), then print[c.__name__ for c in Workshop.__mro__].
What's next?
Classes work, but __init__, __repr__ and __eq__ add up fast. Let one decorator write that boilerplate for you in Dataclasses and Special Methods.
Frequently Asked Questions
Python is a general-purpose language first released in 1991, designed around one idea: code is read far more often than it is written. English keywords and indentation instead of braces make it readable, the standard library covers files, JSON, dates and testing, and pip adds thousands more packages. The same language handles scripts, web APIs, data work and machine learning.
None at all. Python is widely considered the friendliest first language: no declaration keywords, no braces, no build step, and error messages that name the problem. This series begins at your first print() call. If you already read JavaScript or TypeScript you will adjust quickly, but no previous language is assumed anywhere in these 24 chapters.
Python 3 only, any release from 3.12 onwards. Python 2 reached end of life in 2021 and its print "hi" statements and xrange() no longer run. One historical quirk remains: installers ship the interpreter as python3 on macOS and Linux, while Windows usually offers python or the py launcher, and every command in this series works with that one-word swap.
More than you might expect. Everyday automation such as renaming files, cleaning spreadsheets or scraping pages; web backends with Django, Flask or FastAPI; data analysis and machine learning with pandas, NumPy and PyTorch; DevOps tooling, desktop apps and games. Python is also the classic glue language, calling C, Rust or Fortran libraries from short readable scripts.
Pure Python can run 10 to 100 times slower than C, yet that rarely blocks you: heavy numeric work lives in C extensions such as NumPy, which Python coordinates. Dynamic typing does push errors to runtime, surfacing as a TypeError on the line that finally uses the bad value. Type hints checked by mypy move those mistakes back into your editor.
Only the interpreter. Install CPython from python.org, version 3.12 or newer, confirm it with python3 --version, and open a plain text editor; VS Code with the Python extension is the recommendation here. The early chapters use no third-party packages, so there is nothing else to set up until virtual environments and pip arrive.
A dict is a real runtime mapping: keys may be any hashable value rather than only strings, insertion order has been guaranteed since Python 3.7, and a missing key raises KeyError unless you ask with get(). JavaScript objects coerce every key to a string and carry a prototype chain. A TypeScript interface is erased at compile time and describes shape only; TypedDict is Python's checked equivalent.
Reach for a list when items will be added, removed or reordered: it is ordered, mutable and rich with methods. Pick a tuple for fixed records and function returns, because it is immutable and hashable, so it can be a dictionary key. Choose a set for unique values and fast membership checks; it has no order and de-duplicates on creation.
Yes, gradually. Annotate public function signatures first; the interpreter ignores hints at runtime, so a checker such as mypy is what gives them teeth. Add tests the same way: pytest collects any test_*.py file and plain assert statements, and a suite that finishes in a tenth of a second is one you will run after every change. Both habits cost little now and much more later.
JavaScript and TypeScript keep the browser, so anything rendered to the user stays theirs. Python takes the server: Django for full-featured applications, Flask for small ones, FastAPI for typed async APIs where your annotations become automatic documentation. Your TypeScript habits transfer directly, and asyncio mirrors the event loop you already know, just with different keywords.