What is Python Class?
class Dog:
def __init__(self, name, age): # Constructor
self.name = name # Instance attribute
self.age = age # Instance attribute
def bark(self): # Instance method
print(f"{self.name} says Woof!")
def get_older(self, years):
self.age += years
- class Dog: defines a new class named Dog.
- __init__ is a special method called the constructor. It runs when you create a new object.
- self is a reference to the current instance of the class (required as the first parameter in instance methods).
- You create an object (instance) like this as shown below:
my_dog = Dog("Buddy", 3)
my_dog.bark() # Output: Buddy says Woof!
print(my_dog.age) # Output: 3
my_dog.get_older(2)
print(my_dog.age) # Output: 5
MethodsMethods are functions defined inside a class. There are three main types:
- Instance methods (most common)
- Take self as the first parameter.
- Can access and modify instance attributes.
- Class methods
- Take cls as the first parameter.
- Decorated with @classmethod.
- Operate on the class itself, not instances.
class Dog: species = "Canis familiaris" # Class attribute @classmethod def get_species(cls): return cls.species - Static methods
- Don’t take self or cls.
- Decorated with @staticmethod.
- Behave like regular functions but belong to the class’s namespace.
class Dog: @staticmethod def is_adult(age): return age >= 2
class Car:
def __init__(self):
self.speed = 0
self.color = "white"
def set_speed(self, speed):
self.speed = speed
def paint(self, color):
self.color = color
my_car = Car()
my_car.set_speed(100)
my_car.paint("red")
print(my_car.speed, my_car.color) # 100 red
Example with method chaining:
class Car:
def __init__(self):
self.speed = 0
self.color = "white"
def set_speed(self, speed):
self.speed = speed
return self # ? Important for chaining
def paint(self, color):
self.color = color
return self # ? Important for chaining
my_car = Car()
my_car.set_speed(100).paint("red") # Chain the calls
print(my_car.speed, my_car.color) # 100 red
You can chain as many methods as you want:
my_car.paint("blue").set_speed(200).paint("green")
Real-world examples of method chaining in Python:
- Strings (immutable, so they return a new string):
" hello world ".strip().upper().replace("WORLD", "PYTHON") # ? "HELLO PYTHON" - pandas DataFrames (very common):
df.query("age > 30").sort_values("name").drop_duplicates() - Requests library (some methods return self):
session.headers.update({...}).timeout(10).verify(False)
Key points for enabling chaining:
- The method should modify the object in place.
- The method must return self at the end.
- Avoid methods that naturally return something else (like len() or computations) unless you intentionally want to break the chain.
Summary
- Classes define objects with attributes and behavior.
- Methods are functions inside classes; instance methods use self.
- Method chaining makes code fluent and readable by having each method return self.
This pattern is widely used in Python libraries to create clean, expressive APIs.
Explain inheritance and polymorphism
- The class that is inherited from is called the parent class (superclass or base class).
- The class that inherits is called the child class (subclass or derived class).
Basic Syntax
class Animal: # Parent class
def __init__(self, name):
self.name = name
def speak(self):
pass # To be overridden
class Dog(Animal): # Child class inheriting from Animal
def speak(self):
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says Meow!"
# Usage
dog = Dog("Buddy")
cat = Cat("Whiskers")
print(dog.speak()) # Buddy says Woof!
print(cat.speak()) # Whiskers says Meow!
Key points:
- The child class automatically gets all attributes and methods from the parent.
- You can override methods in the child class (as shown with speak()).
- Use super() to call the parent’s method from the child.
Example with super()
class Animal:
def __init__(self, name, species):
self.name = name
self.species = species
def info(self):
return f"{self.name} is a {self.species}"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, species="Dog") # Call parent's __init__
self.breed = breed
def info(self):
parent_info = super().info() # Reuse parent's info
return f"{parent_info}, breed: {self.breed}"
dog = Dog("Buddy", "Golden Retriever")
print(dog.info())
# Output: Buddy is a Dog, breed: Golden Retriever
Types of Inheritance
- Single inheritance: One child, one parent (most common).
- Multiple inheritance: A child inherits from multiple parents.
class Flyer: def fly(self): return "Flying!" class Swimmer: def swim(self): return "Swimming!" class Duck(Animal, Flyer, Swimmer): # Multiple inheritance pass duck = Duck("Donald") print(duck.fly()) # Flying! print(duck.swim()) # Swimming! - Multilevel inheritance: Chain like Grandparent ? Parent ? Child.
- Hierarchical inheritance: Multiple children from one parent.
Polymorphism in Python
Polymorphism means “many forms.” It allows objects of different classes to be treated as objects of a common parent class. The same method name can behave differently depending on the object calling it.The key idea: Same interface, different implementation.Example of PolymorphismUsing the earlier Animal, Dog, and Cat classes:
animals = [Dog("Buddy"), Cat("Whiskers"), Dog("Max")]
for animal in animals:
print(animal.speak()) # Calls the appropriate speak() method
Output:
Buddy says Woof!
Whiskers says Meow!
Max says Woof!
Even though the loop uses the generic variable animal, Python calls the correct overridden speak() method based on the actual object type. This is runtime polymorphism (also called method overriding).Duck Typing (Pythonic Polymorphism)Python is dynamically typed and follows “duck typing”:
“If it walks like a duck and quacks like a duck, it’s a duck.”You don’t need inheritance for polymorphism — just having the same method name is enough.
class Duck:
def speak(self):
return "Quack!"
class Person:
def speak(self):
return "Hello!"
def make_it_speak(thing): # Accepts any object with a speak() method
print(thing.speak())
make_it_speak(Duck()) # Quack!
make_it_speak(Person()) # Hello!
No inheritance needed — both classes just implement speak().Polymorphism with Built-in FunctionsMany built-in functions are polymorphic:
print(len("hello"))`
Object-oriented programming principles
- Encapsulation
- Abstraction
- Inheritance
- Polymorphism
Let’s explore each in detail with Python examples.
1. EncapsulationDefinition: Bundling data (attributes) and the methods that operate on that data within a single unit (class), while restricting direct access to some components. This protects the internal state and allows controlled interaction.
- Achieved using private/protected attributes (by convention) and getter/setter methods (or properties).
Example:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance # Private attribute (name mangling)
def deposit(self, amount):
if amount > 0:
self.__balance += amount
print(f"Deposited {amount}. New balance: {self.__balance}")
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
print(f"Withdrew {amount}. New balance: {self.__balance}")
else:
print("Insufficient funds or invalid amount")
def get_balance(self):
return self.__balance # Controlled access
account = BankAccount("Alice", 100)
account.deposit(50)
account.withdraw(30)
print(account.get_balance()) # 120
# Direct access fails (or is discouraged)
# print(account.__balance) # AttributeError
- __balance is private (Python uses name mangling: _BankAccount__balance).
- Only methods inside the class can modify it directly.
2. AbstractionDefinition: Hiding complex implementation details and exposing only the essential features of an object. Users interact with a simplified interface without needing to know “how” it works.
- Achieved through abstract classes and interfaces (in Python, using abc module).
Example:
from abc import ABC, abstractmethod
class Vehicle(ABC): # Abstract base class
@abstractmethod
def start_engine(self):
pass
@abstractmethod
def stop_engine(self):
pass
def honk(self): # Concrete method
print("Beep beep!")
class Car(Vehicle):
def start_engine(self):
print("Car engine started with key")
def stop_engine(self):
print("Car engine stopped")
class ElectricCar(Car):
def start_engine(self):
print("Electric car silently powers on") # Different implementation
# vehicle = Vehicle() # Can't instantiate abstract class
my_car = ElectricCar()
my_car.start_engine() # Electric car silently powers on
my_car.honk() # Beep beep! (inherited)
- Users know a vehicle can start_engine(), but not how each type does it.
3. InheritanceDefinition: A mechanism where a new class (child/subclass) derives properties and behaviors from an existing class (parent/superclass). Promotes code reuse and hierarchical organization.Example:
class Animal:
def __init__(self, name):
self.name = name
def eat(self):
print(f"{self.name} is eating")
def sleep(self):
print(f"{self.name} is sleeping")
class Dog(Animal): # Inherits from Animal
def bark(self):
print(f"{self.name} says Woof!")
class Cat(Animal):
def meow(self):
print(f"{self.name} says Meow!")
dog = Dog("Buddy")
dog.eat() # Inherited from Animal
dog.bark() # Defined in Dog
- Supports single, multiple, and multilevel inheritance.
4. PolymorphismDefinition: The ability of different objects to respond to the same method call in different ways. Literally means “many forms.”
- Achieved through method overriding and duck typing.
Example:
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
class Cow:
def speak(self):
return "Moo!"
# Polymorphic function
def animal_sound(animal):
print(animal.speak()) # Same method, different results
animals = [Dog(), Cat(), Cow()]
for animal in animals:
animal_sound(animal)
# Output:
# Woof!
# Meow!
# Moo!
- The same speak() method behaves differently based on the object type.
- Python also supports operator overloading (e.g., __add__ for +).
Summary Table
|
Principle
|
Purpose
|
Key Technique in Python
|
Example Benefit
|
|---|---|---|---|
|
Encapsulation
|
Data protection & bundling
|
Private attributes (__var), properties
|
Secure, maintainable code
|
|
Abstraction
|
Hide complexity
|
Abstract classes (ABC, @abstractmethod)
|
Simpler interface for users
|
|
Inheritance
|
Code reuse & hierarchy
|
Class inheritance (class Child(Parent))
|
Avoid duplication
|
|
Polymorphism
|
Flexibility & uniformity
|
Method overriding, duck typing
|
Write generic, reusable functions
|
These principles together make code more modular, reusable, maintainable, and easier to understand—especially in large projects.Many modern libraries and frameworks (like Django, Flask, pandas) heavily rely on OOP principles.
Python Decorators
- The decorator function takes the target function as an argument.
- It returns a new function (often a wrapper) that “decorates” the original.
- When the decorated function is called, the wrapper executes instead, typically calling the original function inside it.
Simple Example (Function Decorator):
def my_decorator(func): # Decorator function
def wrapper(): # Inner wrapper function
print("Something before the function")
func() # Call the original function
print("Something after the function")
return wrapper # Return the wrapper
@my_decorator # Apply the decorator
def say_hello():
print("Hello!")
say_hello()
# Output:
# Something before the function
# Hello!
# Something after the function
Without the decorator syntax, it’s equivalent to:
say_hello = my_decorator(say_hello)
Decorators with Arguments (in the Decorated Function)If the decorated function takes arguments, the wrapper must accept *args and **kwargs to pass them through.Example:
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before")
result = func(*args, **kwargs) # Call with args
print("After")
return result # Return the result
return wrapper
@my_decorator
def add(a, b):
return a + b
print(add(3, 5)) # Output: Before\n8\nAfter (with 8 on a new line)
Decorators with Their Own ArgumentsTo make a decorator that accepts arguments itself, you need a third layer: a decorator factory.Example (Repeat Decorator):
def repeat(times): # Decorator factory
def decorator(func): # Actual decorator
def wrapper(*args, **kwargs):
for _ in range(times):
func(*args, **kwargs)
return wrapper
return decorator
@repeat(3) # Decorator with argument
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
Class DecoratorsDecorators can also modify classes. They take the class as an argument and return a modified class (or the same one with additions).Example (Adding a Method to a Class):
def add_method(cls): # Class decorator
def new_method(self):
print("This is a new method added by the decorator")
cls.new_method = new_method # Add the method to the class
return cls # Return the modified class
@add_method
class MyClass:
def existing_method(self):
print("Existing method")
obj = MyClass()
obj.existing_method() # Existing method
obj.new_method() # This is a new method added by the decorator
Method DecoratorsThese are applied to methods inside classes. Common built-in ones include @staticmethod, @classmethod, and @property.Example (@property for Getter/Setter):
class Circle:
def __init__(self, radius):
self._radius = radius
@property # Getter decorator
def radius(self):
return self._radius
@radius.setter # Setter decorator
def radius(self, value):
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
c = Circle(5)
print(c.radius) # 5 (calls getter)
c.radius = 10 # Calls setter
# c.radius = -1 # Raises ValueError
Built-in DecoratorsPython has several useful built-ins:
- @staticmethod: Defines a method that doesn’t take self or cls.
- @classmethod: Takes cls as the first argument; useful for factory methods.
- @property: Turns a method into a read-only attribute.
- @functools.wraps: Preserves the original function’s metadata (name, docstring) in the wrapper—highly recommended for custom decorators.
Using @wraps (to avoid metadata loss):
from functools import wraps
def my_decorator(func):
@wraps(func) # Preserve func's info
def wrapper(*args, **kwargs):
print("Before")
return func(*args, **kwargs)
return wrapper
@my_decorator
def say_hello():
"""Docstring for hello"""
print("Hello!")
print(say_hello.__name__) # say_hello (without @wraps, it would be 'wrapper')
print(say_hello.__doc__) # Docstring for hello
Common Use Cases
- Logging: Log function calls and arguments.
- Timing/Performance: Measure execution time (e.g., using time.time()).
- Memoization/Caching: Store results of expensive functions (e.g., @functools.lru_cache).
- Authorization: Check permissions before executing.
- Flask/Django Routes: Decorators like @app.route(‘/’) in web frameworks.
Decorators make code cleaner and more modular by separating concerns. They’re powerful but can make debugging trickier if overused—always keep them simple!
Are self and cls the same?
- Purpose: self is a conventional name (you could technically use any name, but self is standard) that refers to the current instance (object) of the class. It’s used in instance methods, which operate on individual objects created from the class.
- When it’s used: Automatically passed as the first argument when you call a method on an instance.
- Key characteristics:
- Allows access to instance-specific attributes and methods.
- Each instance has its own self, so changes affect only that object.
Example:
class Dog:
def __init__(self, name): # self refers to the new instance being created
self.name = name # Instance attribute
def bark(self): # Instance method
print(f"{self.name} says Woof!") # Accesses instance attribute
buddy = Dog("Buddy") # Create instance
buddy.bark() # Output: Buddy says Woof!
# Here, self is buddy when bark() is called
cls (Class Methods)
- Purpose: cls is a conventional name (again, could be any, but cls is standard) that refers to the class itself, not an instance. It’s used in class methods, which are decorated with @classmethod and operate on the class level.
- When it’s used: Automatically passed as the first argument in class methods. You can call these methods on the class directly (without creating an instance) or on instances.
- Key characteristics:
- Allows access to class-level attributes and methods (shared across all instances).
- Useful for factory methods, alternative constructors, or operations that don’t need instance data.
Example:
class Dog:
species = "Canis familiaris" # Class attribute (shared by all instances)
@classmethod
def get_species(cls): # Class method
return cls.species # Accesses class attribute
@classmethod
def create_puppy(cls, name): # Factory method example
return cls(name + " Jr.") # Uses cls to create a new instance
print(Dog.get_species()) # Output: Canis familiaris (called on class)
puppy = Dog.create_puppy("Buddy") # Creates Dog("Buddy Jr.")
print(puppy.name) # Output: Buddy Jr.
# Here, cls is the Dog class
Key Differences
|
Aspect
|
self (Instance Methods)
|
cls (Class Methods)
|
|---|---|---|
|
Refers to
|
The specific instance (object)
|
The class itself
|
|
Decorator
|
None (default for methods)
|
@classmethod
|
|
Access
|
Instance attributes/methods
|
Class attributes/methods
|
|
Calling
|
On instances (e.g., obj.method())
|
On class (e.g., Class.method()) or instances
|
|
Use case
|
Object-specific behavior (e.g., bark)
|
Class-wide operations (e.g., factories)
|
- Why the distinction? Instance methods need to know “which object” they’re working on (self), while class methods need to know “which class” (useful in inheritance, where cls could refer to a subclass).
- In inheritance, cls ensures the correct class is referenced (e.g., if a subclass calls a class method, cls will be the subclass, not the parent).
- Note: There’s also @staticmethod, which uses neither self nor cls—it’s like a regular function but namespaced in the class.
If you confuse them (e.g., using self in a class method), you’ll get errors like TypeError: method() takes 0 positional arguments but 1 was given. Always match the parameter to the method type!
Why need @classmethod decorator?
- In a class, if you define a method without any decorator, Python assumes it’s an instance method.
- Instance methods automatically receive the instance (self) as the first argument when called on an object.
- This is great for working with object-specific data, but not for class-level operations.
Example Without Decorator (Instance Method):
class Dog:
species = "Canis familiaris" # Class attribute
def get_species(self): # Treated as instance method
return self.species # Would work, but uses 'self' unnecessarily
print(Dog.get_species()) # TypeError: get_species() missing 1 required positional argument: 'self'
- Here, calling Dog.get_species() directly on the class fails because Python expects an instance (self), but none is provided.
- You’d have to create an instance first: dog = Dog(); print(dog.get_species()), which works but is inefficient for class-level data.
2. What @classmethod Does
- The decorator transforms the method so that:
- It’s bound to the class (not the instance).
- The first parameter is automatically the class itself (conventionally named cls), instead of self.
- You can call it on the class directly (ClassName.method()) or on an instance (instance.method()), and cls will always refer to the class.
- This is handled by Python’s descriptor protocol under the hood—the decorator returns a classmethod object that manages how the method is called.
Example With @classmethod:
class Dog:
species = "Canis familiaris"
@classmethod
def get_species(cls): # Now a class method
return cls.species # 'cls' is the class Dog
print(Dog.get_species()) # Output: Canis familiaris (works directly on class)
dog = Dog()
print(dog.get_species()) # Also works, 'cls' is still Dog
- No errors, and it efficiently accesses shared class data without needing an instance.
3. Why It’s Needed: Key Reasons
- To Avoid Errors in Parameter Passing:
- Without @classmethod, if you name the first parameter cls but don’t decorate, Python still treats it as an instance method expecting self. Calling it on the class would require manually passing the class: Dog.get_species(Dog), which is awkward and error-prone.
- The decorator automates this, making code cleaner and less buggy.
- For Class-Level Operations:
- Class methods are ideal for tasks that don’t depend on instance state, like accessing/modifying class attributes (shared across all instances).
- Example: Updating a class variable for all future instances.
- Alternative Constructors/Factory Methods:
- A common use case is creating objects in custom ways without using __init__ directly.
- Without the decorator, you couldn’t easily call these from the class.
Factory Example:
class Dog: def __init__(self, name, age): self.name = name self.age = age @classmethod def from_birth_year(cls, name, birth_year): age = 2025 - birth_year # Assuming current year is 2025 return cls(name, age) # 'cls' creates a new instance of Dog puppy = Dog.from_birth_year("Buddy", 2023) # Creates Dog("Buddy", 2)- This wouldn’t work properly without @classmethod because cls wouldn’t be passed automatically.
- Inheritance and Polymorphism:
- In subclasses, cls refers to the subclass, not the parent. This allows polymorphic behavior.
- Example: If GoldenRetriever inherits from Dog, GoldenRetriever.from_birth_year() returns a GoldenRetriever instance, not Dog.
- Without the decorator, this subclass-aware behavior wouldn’t happen.
- To Distinguish from Static Methods:
- @staticmethod is similar but doesn’t pass cls (or self). Use @classmethod when you need the class reference (e.g., for cls.__name__ or creating instances).
- Needing cls is a key reason for @classmethod over @staticmethod.
4. What Happens Without It?
- Your method becomes an instance method, requiring an instance to call it.
- You lose the ability to call it directly on the class without hacks.
- Code becomes less flexible in inheritance hierarchies.
- Potential runtime errors if you forget to pass arguments manually.
5. When to Use It
- For methods that should work without an instance (e.g., utility functions tied to the class).
- In metaprogramming or when building frameworks/libraries (e.g., SQLAlchemy uses class methods for queries).
- Always when the first parameter should be the class.
In summary, @classmethod is necessary to explicitly signal to Python that a method should operate on the class level, enabling cleaner, more robust, and inheritance-friendly code. It’s not just syntactic sugar—it’s crucial for correct method binding and to prevent common pitfalls in OOP design. If you’re coming from languages like Java, think of it as Python’s way to define static methods that still get the class context.
