Python OOP Cheat Sheet

Python OOP Cheat Sheet

Welcome, Python enthusiast! Whether you're just starting your journey into object-oriented programming (OOP) with Python or you're looking for a quick reference guide, you're in the right place. OOP is a programming paradigm that uses objects and classes to structure software, and Python makes it both powerful and intuitive. Let’s dive into the essentials with clear explanations and practical examples.

Classes and Objects

At the heart of OOP are classes and objects. A class is like a blueprint for creating objects. It defines the attributes (data) and methods (functions) that the objects created from the class will have. An object is an instance of a class—a concrete entity built from that blueprint.

To define a class in Python, use the class keyword. Here's a simple example:

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        return "Woof!"

In this example, Dog is a class. The __init__ method is a special method called a constructor. It initializes the object's attributes. self refers to the instance of the class. To create an object (an instance of the class), you call the class as if it were a function:

my_dog = Dog("Rex", 3)
print(my_dog.name)  # Output: Rex
print(my_dog.bark())  # Output: Woof!

Remember: self is always the first parameter in instance methods and refers to the instance calling the method. You don't pass it explicitly when calling the method.

Class Component Purpose Example
Class Definition Blueprint for objects class Dog:
Constructor (__init__) Initializes object attributes def __init__(self, name, age):
Instance Method Function defined in a class def bark(self):
Object Creation Creating an instance my_dog = Dog("Rex", 3)

Here’s a quick list of key points to keep in mind when working with classes and objects: - Classes are defined using the class keyword. - The __init__ method is called automatically when a new object is created. - Attributes are variables that belong to an object. - Methods are functions that belong to an object. - You can create as many objects (instances) as you need from a single class.

Inheritance

Inheritance allows you to define a class that inherits all the methods and properties from another class. The class being inherited from is called the parent or base class, and the class that inherits is called the child or derived class. This promotes code reusability.

Let’s extend our Dog class:

class Animal:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"

Here, Animal is the base class, and Dog and Cat are derived classes. They inherit the __init__ method from Animal but override the speak method.

my_dog = Dog("Rex", 3)
my_cat = Cat("Whiskers", 2)
print(my_dog.speak())  # Output: Woof!
print(my_cat.speak())  # Output: Meow!

Inheritance enables you to create a general class first and then more specialized classes. This avoids repetition and keeps your code DRY (Don’t Repeat Yourself).

Inheritance Type Description Syntax
Single Inheritance Child class inherits from one parent class Child(Parent):
Multiple Inheritance Child inherits from multiple parents class Child(Parent1, Parent2):
Multilevel Inheritance Chain of inheritance class Child(Parent):, class GrandChild(Child):
Hierarchical Inheritance Multiple children from one parent class Child1(Parent):, class Child2(Parent):

Key aspects of inheritance in Python: - A child class can override methods of the parent class. - You can use the super() function to call methods from the parent class. - Multiple inheritance is supported, but it should be used carefully to avoid complexity. - Inheritance represents an "is-a" relationship (e.g., a Dog is an Animal).

Encapsulation

Encapsulation is the concept of restricting direct access to some of an object's components. This is achieved by using private and protected attributes and methods. It helps in preventing accidental modification of data and in bundling the data with the methods that operate on that data.

In Python, we use naming conventions to denote the access level: - Public: Accessible from anywhere. No underscores. - Protected: Accessible within the class and its subclasses. Prefix with a single underscore _. - Private: Accessible only within the class. Prefix with double underscores __.

Here’s an example:

class BankAccount:
    def __init__(self, balance):
        self._balance = balance  # Protected attribute

    def deposit(self, amount):
        self._balance += amount

    def _display_balance(self):  # Protected method
        return f"Balance: {self._balance}"

    def get_balance(self):
        return self._display_balance()

account = BankAccount(1000)
account.deposit(500)
print(account.get_balance())  # Output: Balance: 1500
# print(account._balance)  # Possible but not recommended

Although Python doesn’t enforce strict encapsulation (it’s more about convention), it’s good practice to respect these conventions to write maintainable code.

Access Modifier Prefix Accessibility
Public None Anywhere
Protected _ Within class and subclasses
Private __ Only within the class

Important notes on encapsulation: - Use protected members when you intend for them to be used only within the class and its subclasses. - Use private members to prevent accidental access from outside the class. - Name mangling is applied to private members: __attribute becomes _Classname__attribute. - Encapsulation helps in achieving data hiding and abstraction.

Polymorphism

Polymorphism allows methods to do different things based on the object it is acting upon. It means "many forms". In Python, polymorphism is achieved through method overriding and duck typing.

Method overriding occurs when a child class provides a specific implementation of a method that is already defined in its parent class. We saw this in the inheritance example with the speak method.

Duck typing is a concept where the type or class of an object is less important than the methods it defines. If an object has the method you're calling, it can be used, regardless of its class.

class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

def animal_sound(animal):
    return animal.speak()

my_dog = Dog()
my_cat = Cat()
print(animal_sound(my_dog))  # Output: Woof!
print(animal_sound(my_cat))  # Output: Meow!

Here, animal_sound function doesn't care about the class of animal as long as it has a speak method.

Polymorphism Type Description Example
Method Overriding Child class provides specific implementation def speak(self): in child class
Duck Typing Object's methods matter more than its class animal.speak() in function

Key points about polymorphism: - Method overriding is a way to achieve runtime polymorphism. - Duck typing is a philosophy in Python: "If it walks like a duck and quacks like a duck, it must be a duck." - Polymorphism allows for writing more generic and reusable code. - It often goes hand-in-hand with inheritance.

Special Methods

Special methods in Python are predefined methods that you can override to add special functionality to your classes. They are always surrounded by double underscores, like __init__. These methods are called automatically in response to specific operations.

For example, the __str__ method is called when you use the print() function on an object, and __len__ is called when you use the len() function.

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    def __str__(self):
        return f"{self.title} by {self.author}"

    def __len__(self):
        return self.pages

my_book = Book("Python Basics", "John Doe", 200)
print(my_book)  # Output: Python Basics by John Doe
print(len(my_book))  # Output: 200

Here’s a table of some commonly used special methods:

Special Method Purpose Trigger
__init__ Constructor Object creation
__str__ Informal string representation print(obj), str(obj)
__repr__ Formal string representation repr(obj)
__len__ Length of object len(obj)
__add__ Addition obj1 + obj2
__eq__ Equality check obj1 == obj2

By implementing these methods, you can make your objects behave more like built-in types.

Important special methods to know: - __init__: Initialize new objects. - __str__: Return a human-readable string representation. - __repr__: Return an unambiguous string representation, often used for debugging. - __add__, __sub__, etc.: Define behavior for arithmetic operations. - Overloading these methods allows for intuitive object interactions.

Properties and Decorators

Properties allow you to define methods that can be accessed like attributes. This is useful when you want to add logic to attribute access without changing the interface. Decorators like @property, @<attribute>.setter, and @<attribute>.deleter are used to define properties.

Consider a class where we want to ensure that an attribute is always non-negative:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self):
        return 3.14 * self._radius ** 2

my_circle = Circle(5)
print(my_circle.radius)  # Output: 5
print(my_circle.area)    # Output: 78.5
my_circle.radius = 10
print(my_circle.area)    # Output: 314.0
# my_circle.radius = -1  # Raises ValueError

Here, radius is a property with a getter and setter. The area is a read-only property.

Decorator Purpose Example
@property Defines a getter method def radius(self):
@<property>.setter Defines a setter method def radius(self, value):
@<property>.deleter Defines a deleter method def radius(self):

Benefits of using properties: - You can add validation logic when setting an attribute. - You can compute values on the fly (like area). - The interface remains clean—users access it like an attribute. - Encapsulation is maintained without sacrificing convenience.

Class and Static Methods

So far, we've dealt with instance methods, which operate on instances. Python also supports class methods and static methods.

  • Class methods are bound to the class and not the instance. They can modify class state that applies across all instances. They are defined using the @classmethod decorator and take cls as the first parameter.
  • Static methods are also bound to the class, but they don't modify class or instance state. They are defined using the @staticmethod decorator and don't take self or cls.
class MyClass:
    class_attribute = 0

    def __init__(self, value):
        self.instance_attribute = value

    @classmethod
    def class_method(cls):
        cls.class_attribute += 1
        return cls.class_attribute

    @staticmethod
    def static_method():
        return "This is a static method."

obj = MyClass(10)
print(MyClass.class_method())  # Output: 1
print(obj.class_method())      # Output: 2
print(MyClass.static_method()) # Output: This is a static method.
Method Type Decorator First Parameter Can Modify
Instance Method None self Instance state
Class Method @classmethod cls Class state
Static Method @staticmethod None Nothing

When to use each: - Use instance methods when you need to access or modify instance attributes. - Use class methods for factory methods or when you need to work with class-level data. - Use static methods for utility functions that are related to the class but don't need access to instance or class data.

Composition Over Inheritance

While inheritance is powerful, it's not always the best solution. Composition is an alternative where you build complex objects by combining simpler ones. This often leads to more flexible and maintainable code.

Inheritance represents an "is-a" relationship, while composition represents a "has-a" relationship.

For example, instead of making Engine a subclass of Car, you can compose a Car with an Engine:

class Engine:
    def start(self):
        return "Engine started"

class Car:
    def __init__(self):
        self.engine = Engine()

    def start(self):
        return self.engine.start()

my_car = Car()
print(my_car.start())  # Output: Engine started

Here, Car has an Engine, rather than being an Engine.

Approach Relationship Flexibility
Inheritance Is-a Less flexible
Composition Has-a More flexible

Advantages of composition: - Reduces coupling between classes. - Easier to change behavior at runtime by replacing components. - Avoids the diamond problem in multiple inheritance. - Promotes code reuse without the constraints of inheritance hierarchies.

Abstract Base Classes

Abstract Base Classes (ABCs) are classes that cannot be instantiated and are meant to be subclassed. They define a set of methods that must be implemented by their subclasses. This is useful for defining interfaces.

To define an ABC, use the abc module:

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        return "Woof!"

# animal = Animal()  # This would raise an error
my_dog = Dog()
print(my_dog.speak())  # Output: Woof!

Here, Animal is an abstract class with an abstract method speak. Any concrete subclass must implement speak.

ABC Component Purpose Example
ABC class Base class for ABCs class Animal(ABC):
@abstractmethod Decorator for abstract methods @abstractmethod def speak(self):

Key points about ABCs: - Cannot instantiate an abstract class. - Subclasses must implement all abstract methods. - Useful for enforcing a consistent interface across multiple classes. - Helps in large projects to ensure that certain methods are always present.

Magic Methods for Comparisons

Python allows you to define how objects behave with comparison operators by overriding magic methods like __eq__, __lt__, etc. This is useful for comparing custom objects.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __eq__(self, other):
        return self.age == other.age

    def __lt__(self, other):
        return self.age < other.age

person1 = Person("Alice", 30)
person2 = Person("Bob", 25)
print(person1 == person2)  # Output: False
print(person1 < person2)   # Output: False
print(person1 > person2)   # Output: True (uses __lt__ in reverse)

Here, we've defined __eq__ for equality and __lt__ for less-than. Python can infer other comparisons from these.

Magic Method Operator Purpose
__eq__ == Equality
__ne__ != Inequality
__lt__ < Less than
__le__ <= Less than or equal
__gt__ > Greater than
__ge__ >= Greater than or equal

By implementing these, you can use comparison operators directly on your objects.

Slots for Memory Optimization

By default, Python objects store their attributes in a dictionary, which allows dynamic addition of attributes but consumes more memory. If you have a class with a fixed set of attributes, you can use __slots__ to save memory by preventing the creation of __dict__.

class Point:
    __slots__ = ('x', 'y')

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
# p.z = 3  # This would raise an AttributeError

With __slots__, you cannot add new attributes dynamically, but memory usage is reduced.

Aspect With __slots__ Without __slots__
Memory Usage Lower Higher
Dynamic Attributes Not allowed Allowed
Access Speed Slightly faster Slightly slower

Use __slots__ when: - You have a large number of instances. - The attributes are fixed and known in advance. - Memory efficiency is a concern.

Metaclasses

Metaclasses are advanced topic often referred to as "classes of classes". They define how classes behave. The default metaclass is type. You can create custom metaclasses by subclassing type.

This is powerful but complex, so use it sparingly. Here’s a simple example:

class Meta(type):
    def __new__(cls, name, bases, dct):
        dct['class_id'] = 123
        return super().__new__(cls, name, bases, dct)

class MyClass(metaclass=Meta):
    pass

print(MyClass.class_id)  # Output: 123

This metaclass adds a class_id attribute to any class that uses it.

Metaclass Component Purpose Example
__new__ Controls class creation def __new__(cls, name, bases, dct):
type Default metaclass class MyClass(metaclass=Meta):

Metaclasses are used for: - API design where you want to enforce patterns. - Registration of classes. - Adding class-level attributes or methods automatically. - They are deep magic—use only if necessary.

Best Practices

To write clean and maintainable OOP code in Python, follow these best practices:

  • Use meaningful names for classes, attributes, and methods.
  • Prefer composition over inheritance when possible.
  • Keep classes focused on a single responsibility.
  • Use properties to encapsulate attribute access.
  • Document your classes and methods with docstrings.
  • Write unit tests for your classes.
class Employee:
    """A class to represent an employee."""
    def __init__(self, name, salary):
        self.name = name
        self._salary = salary

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, value):
        if value < 0:
            raise ValueError("Salary cannot be negative")
        self._salary = value

    def __str__(self):
        return f"Employee: {self.name}, Salary: {self.salary}"

This class follows good practices: it has a docstring, uses properties for validation, and provides a clear string representation.

Practice Benefit Example
Single Responsibility Easier to maintain One class, one purpose
Encapsulation Prevents accidental misuse Use properties with validation
Documentation Better understanding Docstrings
Testing Reliable code Unit tests for methods

By adhering to these, you'll write robust and professional OOP code.

I hope this cheat sheet serves as a valuable resource in your Python OOP journey. Practice these concepts with small projects, and soon they'll become second nature. Happy coding!