Custom Sequence Types in Python Using Dunder Methods

Python implements sequences through the sequence protocol, a set of standard conventions powered by container special methods, commonly known as dunder (double underscore) methods. By defining specific methods like __len__, __getitem__, __setitem__, and __delitem__ on a class, developers can create custom objects that behave identically to built-in types like lists and tuples. This guide explains the core dunder methods required to build both immutable and mutable custom sequences, how Python processes indexing and slicing, and how to leverage standard library abstractions to simplify implementation.

The Sequence Protocol

In Python, duck typing governs how collections behave. Python does not require a class to inherit from a specific interface to act as a sequence; instead, it checks whether the object implements the sequence protocol.

The sequence protocol is split into two primary categories:

Building Immutable Sequences

An immutable sequence allows elements to be counted, retrieved by index, sliced, and iterated over, but does not allow elements to be modified or deleted.

__len__(self)

The __len__ method is invoked by the built-in len() function. It must return a non-negative integer representing the number of items in the container.

def __len__(self):
    return len(self._data)

__getitem__(self, index)

The __getitem__ method handles element retrieval via bracket notation (obj[index]). When a user accesses an index, Python passes that value directly to __getitem__.

To create a robust sequence, __getitem__ must handle both integer indices and slice objects:

  1. Integer Indexing: Handles standard zero-based access and negative indices (e.g., -1 for the last item).
  2. Slicing: When syntax like obj[1:4:2] is used, Python passes a slice instance to __getitem__. The method should check if index is an instance of slice and return a new instance of the custom sequence containing the sliced elements.
def __getitem__(self, index):
    if isinstance(index, slice):
        return self.__class__(self._data[index])
    return self._data[index]

Implicit Iteration and Membership

If a class defines __len__ and __getitem__, Python provides iteration (for item in obj) and membership testing (item in obj) automatically:

Building Mutable Sequences

To make a custom sequence mutable, you must implement methods that allow assigning and removing elements.

__setitem__(self, index, value)

Invoked when assigning to an indexed position (e.g., obj[0] = "new" or obj[1:3] = [1, 2]). It receives the key/index and the value being assigned.

def __setitem__(self, index, value):
    self._data[index] = value

__delitem__(self, index)

Invoked when using the del statement on an element (e.g., del obj[0] or del obj[1:3]).

def __delitem__(self, index):
    del self._data[index]

Complete Example: A Custom List Wrapper

The following example demonstrates a custom mutable sequence that wraps a standard Python list:

class CustomList:
    def __init__(self, iterable=None):
        self._items = list(iterable) if iterable is not None else []

    def __len__(self):
        return len(self._items)

    def __getitem__(self, index):
        if isinstance(index, slice):
            return self.__class__(self._items[index])
        return self._items[index]

    def __setitem__(self, index, value):
        self._items[index] = value

    def __delitem__(self, index):
        del self._items[index]

    def __repr__(self):
        return f"{self.__class__.__name__}({self._items})"

With only these four methods implemented, CustomList supports:

Using collections.abc for Full Sequence Features

While implementing dunder methods manually satisfies basic requirements, production code typically inherits from Python's abstract base classes in the collections.abc module: Sequence and MutableSequence.

Subclassing collections.abc.Sequence requires defining only __len__ and __getitem__. In return, the base class automatically provides concrete implementations for:

Subclassing collections.abc.MutableSequence requires __len__, __getitem__, __setitem__, __delitem__, and insert(). The base class automatically supplies:

from collections.abc import MutableSequence

class SmartSequence(MutableSequence):
    def __init__(self, initial=None):
        self._data = list(initial) if initial is not None else []

    def __len__(self):
        return len(self._data)

    def __getitem__(self, index):
        return self._data[index]

    def __setitem__(self, index, value):
        self._data[index] = value

    def __delitem__(self, index):
        del self._data[index]

    def insert(self, index, value):
        self._data.insert(index, value)

By defining the container dunder methods directly or subclassing abstract base classes, Python seamlessly integrates custom data structures into the broader language ecosystem.