Assalam o Alaikum! I’m Dr. Zeeshan Bhatti — and today we’re going to master Python OOP in a fast, focused, and slightly nerdy ride. Yes — the Tutorial is themed “200 seconds,” but this script is the extended, classroom-style version: we’ll explain every concept deeply so you can use these lines as subtitles, study notes, or a reference while coding.
What you’ll learn: classes and objects, constructors, methods, the meaning of self, inheritance, super(), polymorphism, encapsulation, property decorators, class vs instance variables, static and class methods, abstract base classes and interfaces, operator overloading, __str__, multiple inheritance and Method Resolution Order, inner classes, composition, and handling exceptions in OOP. That’s a lot — and we’ll make each piece clear, line by line, with conceptual explanation and quick questions to test your thinking.
Quick heads-up: I’ll point out important keywords like class, def, self, @property, @staticmethod, @classmethod, abstractmethod, and special methods like __init__ and __str__. Whenever I say “Can you guess…?” try to pause and answer mentally — that little pause locks learning in.
Ready? Let’s jump into Topic 1 and break this down like a curious engineer.
✅ Topic 1: Main Concept — Class
Let’s start at the foundation: a class. In Python, class Car: defines a blueprint. You can think of a class as a recipe or blueprint — it doesn’t create a cake; it describes how to bake one. The line class Car: tells Python “I’m defining a new type called Car.” Inside that block we can put attributes and methods.
In the sample code we had:
class Car:
pass
c = Car()
print(c) # Output: <__main__.Car object>
Line by line: class Car: — declares the type. pass — is a placeholder; it tells Python “this block is intentionally empty.” We often use pass while building or to create minimal examples.
c = Car() — this is the instantiation step: we call the class as if it were a function, and Python returns a new instance, an object. This is where the abstract blueprint becomes an actual object in memory.
print(c) — prints the default representation of the object. You’ll see something like <__main__.Car object at 0x…> which is Python’s way of telling you “this is an object of type Car, living in module __main__ at memory address …”. Not human-friendly, but useful for debugging.
Why is this important? Because everything in OOP builds from this: objects carry state and behavior defined by classes. Ask yourself: if class is a blueprint, what would be the real-world attributes of a Car blueprint? Brand, model, color — we’ll add those next.
Quick question to the viewer: Can you guess what would happen if we replaced pass with def drive(self): print(“Driving”)? Try it after the video and tell me in the comments.
✅ Topic 2: Constructor
Constructors initialize an object’s state. In Python the constructor is the __init__ method. Example:
class Car:
def __init__(self, brand):
self.brand = brand
c = Car(“Tesla”)
print(c.brand) # Output: Tesla
What’s happening: def __init__(self, brand): — when you call Car(“Tesla”), Python creates a new object and then calls __init__ with that new object bound to self. Inside __init__ we set self.brand = brand — this attaches a brand attribute to that particular instance.
Important: self is not a keyword enforced by Python — it’s just a convention. You could name it anything, but everyone uses self. The first parameter of an instance method always refers to the instance itself.
Why use constructors? They ensure objects start with the right state. Without __init__, you would have to set attributes manually after creating the object.
A practical note: If your constructor has default values, you can create flexible APIs: def __init__(self, brand=”Unknown”): lets you call Car() or Car(“Toyota”).
Question for the viewer: Why do we prefer self.brand = brand over creating a local variable brand inside __init__? Think about object lifetime — local variables vanish after the function returns, while self.brand stays attached to the object.
✅ Topic 3: Methods
Methods are functions defined inside a class that describe the behavior of instances. Example:
class Car:
def drive(self):
print(“Driving”)
Car().drive() # Output: Driving
Here def drive(self): defines an instance method. When we call Car().drive(), we create an object and immediately call its drive method. Note that even though we didn’t set any state, an instance method still receives the instance as self. That means inside drive you can access self.brand if it exists, or other attributes.
Key point: methods encapsulate behavior tied to data. Instead of writing a standalone function drive(car), object-oriented style keeps code organized: car.drive() reads more naturally, especially when you have many different objects each with their own data.
Also note method binding: Car.drive (unbound) is a function, while Car().drive is a bound method where self is fixed to that instance.
Question: Try adding self.speed = 0 in __init__ and then inside drive increment self.speed. What would calling Car().drive() multiple times show? Try it and observe how object state changes across method calls.
✅ Topic 4: Self Keyword
self is the linchpin of instance methods. Example used a Dog class:
class Dog:
def bark(self):
print(“Woof!”)
Dog().bark() # Output: Woof!
Even though bark doesn’t use any attributes, it still needs self as the first parameter because Python binds the instance to the method call. Think of self as the pointer to the current object — inside methods it’s how you access or modify instance attributes, e.g., self.name, self.age.
Common mistakes: forgetting to include self in the method signature — def bark(): will raise a TypeError because Python will pass the instance automatically and the method doesn’t accept it. Another mistake is forgetting to use self when assigning attributes: writing name = name inside __init__ creates a local variable rather than an instance attribute. Always use self.name = name.
Important nuance: self has no special powers — it’s just a parameter name that refers to the instance. But because the convention is universal, reading code becomes easy: self.x clearly means “this object’s x.”
A tiny challenge: try renaming self to me everywhere and run the code — it works. But don’t do it in real projects; readability matters.
✅ Topic 5: Inheritance
Inheritance lets a class (child) reuse or override behavior from another class (parent). Example:
class Animal:
def sound(self): print(“Generic”)
class Cat(Animal):
def sound(self): print(“Meow”)
Cat().sound() # Output: Meow
Here Cat(Animal) means Cat inherits from Animal. If Cat didn’t define sound, calling Cat().sound() would use Animal.sound. But since Cat provided sound, it overrides the parent behavior.
Conceptually, inheritance models “is-a” relationships: a Cat is an Animal. That relationship helps you reuse common code — put shared logic in Animal, specialized logic in Cat or other subclasses.
Design tip: Favor composition when inheritance gets messy. For simple hierarchical relationships (e.g., Animal -> Mammal -> Dog), inheritance is natural. But don’t force inheritance just to reuse code — use helper classes or composition where appropriate.
Question: What change would happen if Animal.sound took a volume argument and Cat.sound didn’t accept it? Think about method signatures and polymorphism — we’ll discuss polymorphism next.
✅ Topic 6: Super()
super() lets the child class call methods of the parent class — useful for extending behavior rather than replacing it. Example:
class Parent:
def show(self): print(“Parent”)
class Child(Parent):
def show(self):
super().show()
print(“Child”)
Child().show()
# Output: Parent Child
Here Child.show first calls super().show() which invokes Parent.show, then executes print(“Child”). The result is both messages in sequence.
When to use super()? In constructors especially — when your subclass needs to initialize parent fields, call super().__init__(…). Also use it in overridden methods when you want to augment, not entirely replace, parent behavior.
Subtlety: In multiple inheritance, super() follows the method resolution order (MRO), not simply the immediate parent. That is powerful and sometimes surprising — super() can call a sibling’s method if MRO dictates.
Exercise: Make a class Base with __init__ setting x, then subclass and call super().__init__ to ensure x is set for the child. This pattern avoids duplicating initialization code.
✅ Topic 7: Polymorphism
Polymorphism is the ability to treat different objects through a common interface. Example:
def animal_sound(a): a.sound()
animal_sound(Cat()) # Output: Meow
animal_sound doesn’t care what exact class a is — as long as a implements sound(), the function works. That’s polymorphism: different classes implementing the same method name can be used interchangeably.
This supports writing generic code: functions and algorithms operate on interfaces or expected behaviors rather than concrete types. In dynamic languages like Python, this is often called “duck typing”: “If it quacks like a duck, treat it like a duck.” So if an object has sound(), you can call it.
Advantage: flexible and extensible systems. New classes implementing sound() automatically work with animal_sound.
Caveat: duck typing is powerful but requires runtime checks in some cases. If you call a.sound() and the object doesn’t provide it, you’ll get an AttributeError. Defensive code may use hasattr(a, “sound”) or try/except.
Question: How would you design animal_sound to handle objects that don’t implement sound() gracefully? Think about try/except or default behaviors.
✅ Topic 8: Encapsulation
Encapsulation is about hiding internal details and exposing a clean interface. Python doesn’t have strict private variables, but it uses naming conventions and name-mangling: attributes prefixed with double underscores become “mangled” to discourage external access.
Example:
class Bank:
def __init__(self): self.__balance = 100
def get_balance(self): return self.__balance
b = Bank()
print(b.get_balance()) # Output: 100
Here __balance is name-mangled to _Bank__balance internally. The idea is to prevent casual external access, not to make absolute privacy. Use getter methods like get_balance() to provide controlled access. You can also provide setter methods with validation, ensuring that any change to sensitive state is checked.
Why not enforce strict privacy? Python’s philosophy trusts the programmer — “we are all consenting adults.” Encapsulation in Python is therefore about intent and discipline, not ironclad enforcement.
Design tip: prefer properties (@property) and setter validation to manage state rather than exposing raw attributes. That gives you a stable API and control over invariants.
Question: How would you allow external code to deposit money safely into Bank? Think about deposit(self, amount) with validation for negative numbers.
✅ Topic 9: Property Decorator
The @property decorator allows methods to be accessed like attributes, creating a neat, Pythonic API. Example:
class Person:
def __init__(self, age): self._age = age
@property
def age(self): return self._age
p = Person(30)
print(p.age) # Output: 30
Here, age is a method decorated with @property. Consumers of Person use p.age like a simple attribute, but under the hood you can run logic, validation, or compute values.
Why @property? It allows you to start with a public attribute and later switch to a method without changing the public interface — this preserves backward compatibility. You can also pair @property with a setter: @age.setter to validate assignments, e.g., reject negative ages.
Best practice: prefix internal storage with a single underscore _age to indicate internal use, then expose a clean API age via a property. This keeps your class robust and change-resistant.
Small exercise: Add @age.setter that raises ValueError if age is negative. Try p.age = -5 and observe the error.
✅ Topic 10: Class vs Instance Variable
Understanding the difference between class (shared) variables and instance (per-object) variables is crucial.
Example:
class Test:
x = 10
print(Test.x) # Output: 10
print(Test().x) # Output: 10
x is a class variable attached to the class object Test. Accessing Test.x or Test().x will find the value on the class if the instance has no attribute x. If you assign Test.x = 20, all instances that don’t override x see the new value. If you do t = Test(); t.x = 30, you create an instance attribute x for t only; this shadows the class variable.
Rule of thumb: Use class variables for shared, constant-like data (e.g., species = “Human” for a Person class), and instance variables for per-object state (self.name, self.age). Be careful with mutable class variables like lists or dicts — modifying them affects all instances.
Test exercises: Create a mutable class variable items = [], append in one instance and observe effect on another — this demonstrates the shared nature clearly.
✅ Topic 11: Static Method
Static methods are functions grouped inside a class that don’t access instance (self) or class (cls) state. They’re a namespacing convenience.
Example:
class Math:
@staticmethod
def add(a,b): return a+b
print(Math.add(3,4)) # Output: 7
@staticmethod means add is a plain function inside the class namespace. You call it on the class or instance: Math.add(3,4) or Math().add(3,4) both work. Use static methods for utilities logically related to the class but not depending on its state.
Why not make them module-level functions? Sometimes grouping them inside a class clarifies intent or keeps related code organized. It’s largely a design choice.
Practice idea: Write a StringUtils class with @staticmethod def is_palindrome(s): and test it.
✅ Topic 12: Class Method
Class methods receive the class itself as the first parameter (cls) and operate on class-level data.
Example:
class A:
count = 0
@classmethod
def inc(cls): cls.count += 1
A.inc(); print(A.count) # Output: 1
inc changes A.count. Class methods are handy for factory methods or logic that should affect the class rather than a single instance. For example, you can implement from_timestamp constructors that build objects differently, or maintain a registry of instances.
Difference from @staticmethod: class methods receive the class (useful when class might be subclassed), static methods don’t receive anything special.
Example use: If A is subclassed into B, calling B.inc() will update B.count if you designed it that way, because cls will be B. That makes class methods polymorphic at the class level.
Question: How can you use @classmethod to implement alternative constructors? Try @classmethod def from_list(cls, items): and return cls(items).
✅ Topic 13: Abstract Class (Begin: Topic 13: “Master Python OOP in 200 Seconds”)
Abstract classes define an interface but can’t be instantiated. In Python you use abc.ABC and @abstractmethod.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self): pass
Shape is abstract; it declares area as an abstract method. Any concrete subclass must implement area or Python will refuse to instantiate it (you’ll get a TypeError).
Why abstract classes? They formalize an interface: “Every shape must implement area.” This helps large codebases by making expectations explicit at the language level.
Difference from duck-typing: abstract base classes give structural guarantees — if a class inherits from Shape, it promises to implement the abstract methods. ABCs can also be used to register virtual subclasses.
Practical tip: Use ABCs when designing frameworks or libraries where you want to force implementors to provide certain methods.
Exercise: Try s = Shape() — observe the error. Then implement class Square(Shape): def area(self): return side*side and instantiate Square.
✅ Topic 14: Interface via ABC
Abstract base classes serve as interfaces. Example continues:
class Circle(Shape):
def area(self): return 3.14*5*5
print(Circle().area()) # Output: 78.5
Circle implements area, hence it’s instantiable. Using ABCs as interfaces makes expectations explicit and enables polymorphism: any Shape instance can be used where a Shape is expected.
Note: Python lacks a formal interface keyword, but ABCs effectively play that role. You can also use protocols (from typing) to achieve structural typing in modern Python — both are useful patterns.
Design consideration: prefer ABCs if you want to explicitly register or require methods; prefer protocols when you want looser, structural contracts without inheritance.
Mini-challenge: implement Rectangle(Shape) with parameters for width and height and return width * height in area.
✅ Topic 15: Operator Overloading
Python allows classes to implement special “dunder” methods to customize behavior of operators. Example:
class Point:
def __init__(self,x): self.x = x
def __add__(self,other): return self.x + other.x
print(Point(2) + Point(3)) # Output: 5
__add__ defines the behavior for +. When you write Point(2) + Point(3), Python calls Point.__add__ on the left operand passing the right operand as other.
Operator overloading makes user-defined types behave like built-ins and enables expressive code. For complex types, you might return a new Point instance rather than a raw number: e.g., return Point(self.x + other.x). Also consider symmetric operations (__radd__) and other dunder methods like __sub__, __mul__, __eq__ for equality checks.
Design note: use operator overloading when it’s intuitive — don’t overload + to do something surprising. Aim for clear, consistent semantics.
Try: Change __add__ to return Point(self.x + other.x) and update printing by adding __str__ so you get readable output.
✅ Topic 16: Dunder __str__
__str__ defines a human-readable string representation for objects. Example:
class Car:
def __str__(self): return “Car Object”
print(Car()) # Output: Car Object
Without __str__, Python prints the default object representation, which is not user-friendly. Implementing __str__ provides a friendly message when printing objects, helpful for debugging and logging.
Difference between __repr__ and __str__: __repr__ aims for an unambiguous representation (often suitable for debugging and ideally evaluable), while __str__ focuses on readability for end-users. It’s common to implement both; __repr__ can fall back to __str__ or vice versa depending on the use case.
Practical tip: Make __repr__ informative and __str__ clean. For a Point, use def __repr__(self): return f”Point({self.x})”.
Small exercise: Add __repr__ to Car and include brand information.
✅ Topic 17: Multiple Inheritance
Python supports multiple inheritance: a class can inherit from more than one base class.
class A: pass
class B: pass
class C(A,B): pass
print(C.__mro__)
# Output: MRO of class
__mro__ shows the Method Resolution Order: the order Python searches base classes for attributes or methods. MRO is important to understand because it determines which method gets called when multiple bases define the same method.
Python uses C3 linearization for MRO — a deterministic algorithm that preserves local precedence and monotonicity. In practice, MRO ensures predictable behavior for method lookup in complex hierarchies.
Caution: multiple inheritance can lead to complexity and diamond problems. Use it when it models the domain well (e.g., mixins for small, orthogonal behavior), and prefer composition for more complex relationships.
Try: create two base classes with a method of the same name, inherit both in different orders, and inspect __mro__ to see how resolution changes.
✅ Topic 18: Inner Class
Nested classes are classes defined within other classes. Example:
class Outer:
class Inner:
def show(self): print(“Inner”)
Outer.Inner().show() # Output: Inner
Inner classes can be a way to group related types and indicate scoping — Inner is conceptually part of Outer. Use nested classes when the inner type is strongly tied to the outer and not used elsewhere.
Practical occurrence: sometimes inner classes are used for helpers, small value objects, or grouping constants. Remember that inner classes are still full-fledged classes and can be instantiated, subclassed, or given methods.
Design note: don’t overuse nested classes — they can reduce discoverability. If Inner is widely used, prefer a top-level class.
Quick thought: nested classes are about organization and intent, not about special runtime magic. They help signal to other developers “this class belongs to this context.”
✅ Topic 19: Composition
Composition models “has-a” relationships: objects containing other objects. Example:
class Engine: pass
class Car:
def __init__(self): self.engine = Engine()
print(isinstance(Car().engine, Engine)) # Output: True
Car has an Engine. Composition is often preferred over inheritance for code reuse because it keeps responsibilities separate and avoids fragile parent-child coupling. Use composition when a class should own or manage another object’s lifecycle or when behavior is better represented as a contained component.
Benefits: loose coupling, clearer separation of concerns, easier testing (you can swap the engine for a mock).
Design pattern note: many design patterns rely on composition — decorators, adapters, and strategy pattern are all composition-friendly.
Exercise: Make Engine have a start() method and call car.engine.start() from Car to expose a start_car() method that delegates to the engine.
✅ Topic 20: Exception in OOP
Exceptions and custom exception types help signal errors cleanly in OOP. Example:
class InvalidAge(Exception): pass
try: raise InvalidAge(“Too young”)
except InvalidAge as e: print(e)
# Output: Too young
By defining InvalidAge(Exception) you create a semantic error type: code catching InvalidAge knows it’s handling a specific domain error. In OOP, custom exceptions improve readability and error handling because you can catch specific issues without catching all exceptions.
Design tips: create exception hierarchies for related error types. For validation errors, raise specific exceptions with clear messages. In library code, prefer your own exception types so callers can catch them explicitly without interfering with unrelated exceptions.
Example usage in classes: a Person constructor might raise InvalidAge if age is negative. Consumers then try/except InvalidAge to show user-friendly messages.
Practice: Implement InvalidAge and in Person.__init__ do if age < 0: raise InvalidAge(“Age cannot be negative”) then test try/except around Person(-1).
You made it! We’ve walked through twenty core OOP concepts in Python, line by line and conceptually: classes, constructors, methods, self, inheritance, super(), polymorphism, encapsulation, properties, class vs instance variables, static and class methods, abstract classes and interfaces, operator overloading, __str__, multiple inheritance and MRO, inner classes, composition, and custom exceptions.
Small checklist to practice:
• Turn examples into small scripts and run them.
• Modify constructors and observe instance state changes.
• Implement setters and property validation.
• Create a small class hierarchy (Animal -> Dog/Cat) to test polymorphism.
• Experiment with __add__, __str__, and MRO using print(C.__mro__).
• Try composition vs inheritance for a small project.
If you found this helpful, like the video, leave a comment with one concept you’ll implement today, and subscribe to Zeeshan Academy for more bite-sized yet deep programming guides. Share the video with a friend who needs to learn Python OOP fast.
Thank you — keep experimenting, break things, fix them, and ask hard questions. See you in the next video.