Introduction to Key Concepts in Python Classes

Python for AI, data science and machine learning Day 2

Gianpiero Andrenacci
Data Bistrot
11 min readMar 15, 2024

--

In the journey of learning Python, especially its object-oriented programming (OOP) aspects, we’ve encountered several fundamental concepts that were introduced in the first article of the series without an in-depth explanation.

Understanding these concepts is crucial for mastering Python OOP and applying it effectively in various programming scenarios, including data science and AI projects. This section aims to shed light on three essential aspects:

  1. The __init__ method
  2. The self keyword in Python classes
  3. The concept of decorators in Python

Each of these components plays a vital role in Python’s approach to OOP, affecting how we design and interact with classes and objects. Let’s dive deeper into these topics to uncover their significance and practical applications.

Key Concepts in Python Classes

The __init__ method, often referred to as the initializer or constructor, is a special method in Python classes. It's called automatically when a new instance of a class is created. The purpose of the __init__ method is to initialize the instance's attributes with specific values. Here's a brief overview:

  • Purpose: To set up initial state of an object.
  • Automatic Invocation: It is automatically invoked upon object creation.
  • Customizable Parameters: Allows passing parameters at the time of object creation to initialize attributes.

The self keyword is a reference to the current instance of the class. It is used to access variables and methods associated with the current object, making it possible to work with the individual instance's data and methods. Key points include:

  • Instance Reference: Represents the instance upon which methods are called.
  • Accessing Attributes and Methods: Enables accessing and modifying the object’s attributes and calling other methods from within class methods.

Decorators are a powerful feature in Python that allows you to modify the behavior of functions or methods without permanently modifying their code. They are often used in Python for adding functionality to existing code in a clean and concise manner. Important aspects of decorators include:

  • Functionality Extension: Allows extending or modifying the behavior of functions or methods.
  • Reusable: Can be applied to multiple functions or methods to apply the same behavior.
  • Syntactic Sugar: Provides a readable and convenient syntax for modifying functions or methods.

These concepts are foundational to understanding and effectively using Python’s OOP capabilities. They enable the creation of well-structured, reusable, and maintainable code, which is essential in data science projects, where code quality directly impacts the success and reliability of data analyses and applications. As we progress, we’ll explore each of these topics in detail, providing a solid foundation for advanced Python programming and its application in data-centric projects.

The __init__ method in classes

The __init__ method in Python is a special method that is automatically called when a new object (instance) of a class is created.

__init__ is a special method known as the constructor

It serves as the initializer or constructor for the class, allowing you to set up or initialize the object's initial state by assigning values to its properties or performing any other necessary setup operations.

While giving the definition for an __init__(self) method, a default parameter, named ‘self’ is always passed in its argument. We’ll see the “self” later in more details.

The __init__ method is often referred to as double underscores init or dunder init for it has two underscores on each side of its name. These double underscores on both the sides of init imply that the method is invoked and used internally in Python, without being required to be called explicitly by the object.

__init__ Analogy: Moving Into a New Home

Imagine moving into a new home. Before you start living in it, you need to set it up by furnishing it according to your preferences. You might decide on the furniture, decorations, and layout that make it comfortable and suitable for your needs. This setup process is a one-time activity that makes the house ready for habitation.

In this analogy:

  • The house represents the object.
  • The process of setting up the house (furnishing and decorating) is similar to the __init__ method.
  • The furniture and decorations are like the attributes of the object, which the __init__ method sets up.

Just as every time you move into a new house, you go through the process of setting it up to make it livable, every time a new object is created from a class, the __init__ method is called to initialize the object's state.

class House:
def __init__(self, color, bedrooms):
self.color = color # Setting up the color attribute of the house
self.bedrooms = bedrooms # Setting up the number of bedrooms

# Creating a new House object
my_house = House("blue", 3)

print(f"My house is {my_house.color} and has {my_house.bedrooms} bedrooms.")

In this example, when my_house is created, the __init__ method is automatically called with "blue" and 3 as arguments, setting up the color and bedrooms attributes. Just like furnishing your new home makes it ready for you, the __init__ method initializes new objects to make them ready for use.

Before delving into the concepts of self and decorators, let's briefly discuss alternative constructors.

Class Methods as Alternative Constructors

In Python’s object-oriented programming (OOP), the concept of alternative constructors is a powerful pattern that leverages class methods to provide additional ways to create instances of a class. While a class can only have one __init__ method, defining class methods as alternative constructors allows you to instantiate objects using different sets of data or from different sources.

Understanding Alternative Constructors

The primary constructor of a class is defined by the __init__ method. However, you might encounter situations where you need to initialize objects in various ways that the single __init__ method cannot accommodate directly. This is where alternative constructors come into play, offering the flexibility to create instances from different data formats or sources without cluttering the __init__ method with multiple conditional statements.

How to Implement Alternative Constructors

Alternative constructors are implemented using class methods. These methods typically start with from_ in their names to indicate their purpose as constructors that instantiate objects from different data types or structures. The @classmethod decorator is used to define these methods, and they return an instance of the class.

Example: Implementing an Alternative Constructor

Consider a Date class that by default takes year, month, and day as separate arguments. You might want to also allow creating a Date instance from a string such as "YYYY-MM-DD".

class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day

@classmethod
def from_string(cls, date_as_string):
year, month, day = map(int, date_as_string.split('-'))
return cls(year, month, day) # Creating a new instance using the class method

# Default constructor
date1 = Date(2024, 3, 2)

# Alternative constructor
date2 = Date.from_string("2024-03-02")

print(f"Date1: {date1.year}-{date1.month}-{date1.day}")
print(f"Date2: {date2.year}-{date2.month}-{date2.day}")

In this example, from_string is an alternative constructor that allows creating a Date instance from a string. This approach enhances the class's usability and flexibility without modifying the original constructor.

Benefits of Using Alternative Constructors

  • Flexibility: Allows initializing objects in different ways without overloading the __init__ method with complex logic.
  • Readability: Enhances code readability by providing clearly named methods that describe their purpose, such as from_string, from_file, etc.
  • Maintainability: Keeps the class interface clean and maintainable by separating different construction mechanisms into distinct methods.

Practical Application in Data Projects

In data science and engineering projects, alternative constructors can be particularly useful for creating instances from various data sources. For example, you might have a class representing a dataset that can be initialized from a file, a database query, or an API response. By implementing alternative constructors, you can provide clear and concise pathways to instantiate objects from these diverse sources, improving code organization and facilitating data manipulation tasks.

Using class methods as alternative constructors embodies the principles of clear code organization and flexibility, making it easier to manage complex object creation scenarios in Python projects.

The “self “ in python classes

In Python, self is a reference to the current instance of the class, and it's used to access variables and methods associated with the current object. It's the first parameter of any method in a class, and Python passes it to the method when it's called.

However, self is not a keyword in Python; it's just a convention that everyone follows. You could technically name it anything, but sticking to self is considered best practice for readability and consistency.

Self Analogy: Self-Consciousness

The self keyword in Python can be likened to a person's self-consciousness. Just as self-consciousness allows a person to be aware of their own identity, thoughts, and feelings, distinguishing them from others,

the self in Python allows an object to identify and access its own attributes and methods, distinguishing itself from other objects.

Imagine you’re in a room full of mirrors, each reflecting a different aspect of yourself. Your self-consciousness helps you recognize which reflection is yours, despite the multitude of images. It allows you to understand your reflection’s actions, differentiate your movements from others, and interact with your environment based on this self-awareness.

In programming terms:

  • You are an object created from a class (a blueprint).
  • Your self-consciousness is the self keyword, enabling you to be aware of and control your actions and attributes.
  • The mirrors represent different instances (objects) or contexts in which you (the object) operate.
  • Recognizing your reflection and distinguishing it from others is like how self allows each object to access and modify its own data, ensuring that an object's actions only affect its own state, not that of other objects.
class Person:
def __init__(self, name, age):
self.name = name # `self` makes the object aware of its 'name' attribute
self.age = age # and its 'age' attribute

def introduce_self(self):
# `self` is used to access the object's own attributes
print(f"Hello, my name is {self.name} and I am {self.age} years old.")

# Creating a new Person object
john = Person("John", 30)

# John uses his self-awareness ('self') to introduce himself
john.introduce_self()

In this example, the Person class uses self to refer to its own attributes (name and age). When john.introduce_self() is called, self ensures that John is talking about his own attributes, not someone else's, much like how self-consciousness ensures that you are aware of and can refer to your own thoughts and feelings.

Decorators in Python

Decorators in Python are a very powerful and useful tool in both simple and advanced programming, providing a simple syntax for calling higher-order functions. A decorator is essentially a function that takes another function and extends its behavior without explicitly modifying it. They are represented by the @ symbol and are placed above the definition of a function or method.

Understanding Decorators

The core idea behind decorators is to allow for the addition of functionality to an existing function or method at definition time. This is achieved by defining a wrapper function that takes the original function as an argument, extends its behavior, and returns the modified function.

How Decorators Work

To grasp how decorators work, let’s break down the process into simpler steps:

  1. Decorator Function: This is a function that accepts another function as an argument and returns a function. This function will wrap or “decorate” the input function with additional functionality.
  2. The Wrapped Function: This is the original function that is being decorated. It retains its original functionality but gains additional behavior from the decorator.
  3. Applying the Decorator: The decorator is applied to a function by prefixing its definition with the @ symbol followed by the decorator function name.

Basic Example of a Decorator

def my_decorator(func):
def wrapper():
print("Something is happening before the function is called.")
func()
print("Something is happening after the function is called.")
return wrapper

@my_decorator
def say_hello():
print("Hello!")

say_hello()

In this example, my_decorator is a decorator that adds functionality to print messages before and after the say_hello function runs. The say_hello function is wrapped by the wrapper function defined inside my_decorator.

Sat hello will output:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

Decorators with Parameters

If the function you’re decorating takes arguments, the wrapper function must also take those arguments. Here’s a modified version of the previous example to accommodate functions with parameters:

def my_decorator(func):
def wrapper(name):
print("Something is happening before the function is called.")
func(name)
print("Something is happening after the function is called.")
return wrapper

@my_decorator
def say_hello(name):
print(f"Hello, {name}!")

say_hello("Alice")

Output:

Something is happening before the function is called.
Hello, Alice!
Something is happening after the function is called.

Benefits of Using Decorators

  • Enhanced Readability: Decorators offer a clear and concise way to modify the functionality of functions or methods.
  • Code Reusability: They allow for the reuse of code, reducing repetition and improving maintainability.
  • Separation of Concerns: Decorators help to separate functionality and keep the code clean by adding behavior without changing the original function’s code.

Practical Applications of Decorators

In data science and engineering, decorators can be used for a variety of tasks, including:

  • Timing Functions: Decorators can measure the execution time of functions, which is useful for performance testing.
  • Caching/Memoization: They can store the results of expensive function calls and return the cached result when the same inputs occur again.
  • Data Validation: Decorators can be used to validate inputs to functions, ensuring that data passed into a function meets certain criteria.

Decorators are a flexible and powerful feature in Python, enabling programmers to enhance the functionality of their code in a clean and readable manner.

Decorators Usage Example in Data Science: Caching Function Results

One practical use of decorators in data science is to implement caching, also known as memoization. Memoization is an optimization technique that stores the results of expensive function calls and returns the cached result when the same inputs occur again. This is particularly useful in data science when dealing with heavy computational tasks like data processing, simulations, or model predictions that are repeated with the same parameters.

The Memoization Decorator

We’ll create a simple decorator that caches the results of any function it decorates. This can significantly reduce execution time for functions whose computations are deterministic based on their inputs and are expensive to repeat.

def memoize(func):
cache = {}
def memoized_func(*args):
if args in cache:
print(f"Fetching cached result for {args}")
return cache[args]
result = func(*args)
cache[args] = result
print(f"Caching result for {args}")
return result
return memoized_func

@memoize
def expensive_computation(x, y):
# Simulating an expensive computation, e.g., a complex data transformation
from time import sleep
sleep(2) # Simulating time-consuming computation
return x * y + x - y

# Using the decorated function
print(expensive_computation(5, 10))
print(expensive_computation(5, 10)) # This will fetch the result from the cache

Explanation

  • memoize Function: This is our decorator. It creates a cache (a dictionary) where it stores the results of the decorated function for any set of parameters.
  • memoized_func Function: This is the wrapper function that checks if the function has been called with the given arguments (*args) before. If yes, it retrieves the result from the cache. Otherwise, it calls the function, caches the result, and then returns it.
  • expensive_computation Function: This represents a data processing function that takes a significant amount of time to compute its result. We simulate this with sleep(2).

*We’ll delve into the details of the *args parameter in an upcoming article.

Benefits in Data Science

Using this caching decorator can greatly improve the efficiency of data science workflows, especially when:

  • Reusing Computed Results: Frequently accessing results from previous computations without the need to recompute them.
  • Speeding Up Iterative Processes: Accelerating processes that require repeated calls to the same function with identical parameters, such as parameter tuning or cross-validation in machine learning models.

This example demonstrates how decorators can be a powerful tool in optimizing data science tasks, making code more efficient and reducing computational costs.

--

--

Gianpiero Andrenacci
Data Bistrot

AI & Data Science Solution Manager. Avid reader. Passionate about ML, philosophy, and writing. Ex-BJJ master competitor, national & international titleholder.