Explore Special Methods in Python Classes

PythonBeginner
Practice Now

Introduction

In this lab, you will explore some of Python's special methods, often called "dunder" methods because of their double-underscore names. You will gain a practical understanding of how these methods allow you to customize the behavior of your classes and objects.

You will learn about the __new__ method for controlling instance creation and the __del__ method for object destruction. You will also see how to use __slots__ to optimize memory usage and restrict attributes, and how to make your class instances callable like functions with the __call__ method. Through hands-on examples, you will learn to write more efficient and expressive Python code.

This is a Guided Lab, which provides step-by-step instructions to help you learn and practice. Follow the instructions carefully to complete each step and gain hands-on experience. Historical data shows that this is a beginner level lab with a 100% completion rate. It has received a 100% positive review rate from learners.

Understand and Use the __new__ Method

In this step, you will explore the __new__ method. While __init__ is commonly used to initialize an object's attributes after it has been created, __new__ is the method that actually creates the instance in the first place. It is called before __init__.

Here are the key differences:

  • __new__ is a static method that takes the class (cls) as its first argument. It is responsible for creating and returning a new instance of the class.
  • __init__ is an instance method that takes the instance (self) as its first argument. It initializes the newly created object and does not return anything.

You typically don't need to override __new__ because the default implementation from the object class is sufficient. However, it is useful for advanced cases like implementing the Singleton pattern or creating instances of immutable types.

Let's see __new__ in action. You will create a Dog class that prints a message during instance creation.

First, open the file dog_cat.py from the file explorer on the left side of the IDE.

Add the following code to the dog_cat.py file. This code defines an Animal base class and a Dog subclass that overrides the __new__ method.

## File Name: dog_cat.py

class Animal:
    def __init__(self, name):
        self._name = name
        print(f'Initializing {self._name} in Animal.')

    def say(self):
        print(self._name + ' is saying something')

class Dog(Animal):
    ## The first parameter is cls, which refers to the class itself.
    ## It must also accept any arguments passed to the constructor.
    def __new__(cls, name, age):
        print('A new Dog instance is being created.')
        ## Call the parent class's __new__ method to create the instance.
        instance = super().__new__(cls)
        return instance

    def __init__(self, name, age):
        print(f'Initializing {name} in Dog.')
        ## The __init__ from the parent class is called to set the name.
        super().__init__(name)
        self.age = age

    def say(self):
        print(self._name + ' is making a sound: wang wang wang...')

## Create a Dog instance
print("Creating a Dog object...")
d = Dog('Buddy', 5)
print("Dog object created.")
print(f"Dog's name: {d._name}, Age: {d.age}")

Save the file (Ctrl+S or Cmd+S).

Now, open a terminal in your IDE (you can use the menu Terminal > New Terminal). Run the script to observe the order of method calls.

python ~/project/dog_cat.py

You will see the following output. Notice that __new__ is called first to create the instance, followed by the __init__ methods to initialize it.

Creating a Dog object...
A new Dog instance is being created.
Initializing Buddy in Dog.
Initializing Buddy in Animal.
Dog object created.
Dog's name: Buddy, Age: 5

This demonstrates that __new__ controls the creation of the object, and __init__ configures it afterward.

Implement and Test the __del__ Method

In this step, you will learn about the __del__ method. This method is called a finalizer or destructor. It is invoked when an object's reference count drops to zero, meaning it is about to be destroyed by Python's garbage collector. It is often used for cleanup tasks, like closing network connections or file handles.

You can remove a reference to an object using the del statement. When the last reference is gone, __del__ is automatically called.

Let's add a __del__ method to our Dog class to see when an object is destroyed.

Open the dog_cat.py file again. Replace the entire content of the file with the following code. This version removes the code that creates a Dog instance (to avoid creating it when the module is imported), and it adds the __del__ method to the Dog class.

## File Name: dog_cat.py

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

    def say(self):
        print(self._name + ' is saying something')

class Dog(Animal):
    def __new__(cls, name, age):
        instance = super().__new__(cls)
        return instance

    def __init__(self, name, age):
        super().__init__(name)
        self.age = age

    def say(self):
        print(self._name + ' is making a sound: wang wang wang...')

    ## Add this method to the Dog class
    def __del__(self):
        print(f'The Dog object {self._name} is being deleted.')

Save the dog_cat.py file.

Now, let's create a separate script to test this behavior. Open the file test_del.py from the file explorer.

Add the following code to test_del.py. This script will create two Dog instances and then delete one of them explicitly.

## File Name: test_del.py

from dog_cat import Dog
import time

print("Creating two Dog objects: d1 and d2.")
d1 = Dog('Tom', 3)
d2 = Dog('John', 5)

print("\nDeleting reference to d1...")
del d1
print("Reference to d1 deleted.")

## The garbage collector may not run immediately.
## We add a small delay to give it time to run.
time.sleep(1)

print("\nScript is about to end. d2 will be deleted automatically.")

Save the file. Now, run the test_del.py script in the terminal.

python ~/project/test_del.py

Observe the output. The __del__ message for Tom appears after del d1 is called. The message for John appears at the very end, as the d2 object is garbage collected when the script finishes.

Creating two Dog objects: d1 and d2.

Deleting reference to d1...
The Dog object Tom is being deleted.
Reference to d1 deleted.

Script is about to end. d2 will be deleted automatically.
The Dog object John is being deleted.

Note: The exact timing of garbage collection can vary. __del__ is called when the object is collected, not necessarily immediately after del is used.

Control Attributes with __slots__

In this step, you will learn about __slots__. By default, Python stores instance attributes in a special dictionary called __dict__. This allows you to add new attributes to an object at any time. However, this flexibility uses extra memory.

By defining a __slots__ attribute in your class, you can specify a fixed list of attributes that instances can have. This has two main effects:

  1. Memory Savings: Python uses a more compact internal structure instead of a __dict__ for each instance, which can significantly reduce memory usage, especially when creating many objects.
  2. Attribute Restriction: You can no longer add attributes to an instance that are not listed in __slots__. This helps prevent typos and enforce a strict object structure.

Let's create an example to see how __slots__ works. Open the file slots_example.py from the file explorer.

Add the following code to slots_example.py:

## File Name: slots_example.py

class Player:
    ## Define the allowed attributes using __slots__
    __slots__ = ('name', 'level')

    def __init__(self, name, level):
        self.name = name
        self.level = level

## Create an instance of Player
p1 = Player('Hero', 10)

## Access the allowed attributes
print(f"Player name: {p1.name}")
print(f"Player level: {p1.level}")

## Now, try to add a new attribute that is NOT in __slots__
print("\nTrying to add a 'score' attribute...")
try:
    p1.score = 100
    print(f"Player score: {p1.score}")
except AttributeError as e:
    print(f"Caught an error: {e}")

## Also, check if the instance has a __dict__ attribute
print("\nChecking for __dict__...")
try:
    print(p1.__dict__)
except AttributeError as e:
    print(f"Caught an error: {e}")

Save the file. Now, run the slots_example.py script in the terminal.

python ~/project/slots_example.py

The output shows that you can assign to name and level, but attempting to assign a value to score raises an AttributeError. It also confirms that the instance does not have a __dict__.

Player name: Hero
Player level: 10

Trying to add a 'score' attribute...
Caught an error: 'Player' object has no attribute 'score'

Checking for __dict__...
Caught an error: 'Player' object has no attribute '__dict__'

This demonstrates how __slots__ can enforce a fixed set of attributes and optimize memory by eliminating the instance dictionary.

Make Instances Callable with __call__

In this step, you will explore the __call__ method. In Python, objects that can be "called" using parentheses () like functions are known as callable objects. Functions and methods are naturally callable.

By default, class instances are not callable. However, if you define the __call__ special method in a class, its instances become callable. When you call an instance like a function, the code inside its __call__ method is executed. This is useful for creating objects that behave like functions but can also maintain their own internal state.

Let's create a class whose instances can be called. Open the file callable_instance.py from the file explorer.

Add the following code to callable_instance.py:

## File Name: callable_instance.py

class Greeter:
    def __init__(self, greeting):
        ## This state is stored with the instance
        self.greeting = greeting
        print(f'Greeter initialized with "{self.greeting}"')

    ## Define the __call__ method to make instances callable
    def __call__(self, name):
        ## This code runs when the instance is called
        print(f"{self.greeting}, {name}!")

## Create an instance of Greeter
hello_greeter = Greeter("Hello")

## Check if the instance is callable using the built-in callable() function
print(f"Is hello_greeter callable? {callable(hello_greeter)}")

## Now, call the instance as if it were a function
print("\nCalling the instance:")
hello_greeter("Alice")
hello_greeter("Bob")

## Create another instance with a different state
goodbye_greeter = Greeter("Goodbye")
print("\nCalling the second instance:")
goodbye_greeter("Charlie")

Save the file. Now, run the callable_instance.py script in the terminal.

python ~/project/callable_instance.py

The output shows that the hello_greeter instance is indeed callable. Each time you call it, the __call__ method is executed, using the state (self.greeting) that was set during initialization.

Greeter initialized with "Hello"
Is hello_greeter callable? True

Calling the instance:
Hello, Alice!
Hello, Bob!
Greeter initialized with "Goodbye"

Calling the second instance:
Goodbye, Charlie!

This demonstrates how __call__ allows you to create stateful, function-like objects, which is a powerful feature in object-oriented programming.

Summary

In this lab, you have explored several powerful special methods in Python. You learned how to use __new__ to control the instance creation process, giving you a hook before __init__ is called. You implemented the __del__ method to define cleanup logic that runs when an object is garbage collected. You also used __slots__ to optimize memory and enforce a strict attribute model by preventing the creation of an instance __dict__. Finally, you made your objects behave like functions by implementing the __call__ method. By mastering these dunder methods, you can write more flexible, efficient, and Pythonic classes.