skip to content

How does a custom class's __getitem__ tell an int index from a slice object?

level: seniorimportance: nice to knowfreq 22%

answer

  1. The colons build an object
  2. Omitted bounds arrive as None
  3. Branch on the key's type
  4. One method call clamps everything
  5. key.indices(len(self)) feeds range()

basics

~10 s

Python packages the bracket syntax into a slice object and passes it to getitem, so the method branches on isinstance(key, slice). Calling key.indices(len(self)) turns it into a clamped start, stop and step triple.

solid answer

~40 s

`x[1:4]` does not call `__getitem__` four times or hand over a tuple of numbers — it builds `slice(1, 4)` and passes that single object. Omitted bounds arrive as `None`, unnormalised: `x[::2]` gives `slice(None, None, 2)`. So the method branches with `isinstance(key, slice)` and, in that branch, calls `key.indices(len(self))`, which resolves negatives, clamps out-of-range bounds and fills in the direction-sensitive defaults, returning a `(start, stop, step)` triple you can feed straight to `range()`. The integer branch is your responsibility too: a custom sequence must add its own length to a negative index, since nothing does that for you. Convention says the slice branch returns the same container type and the integer branch returns an element; `__setitem__` and `__delitem__` receive slice objects the same way.

code

python · 19 lines
python
class Frames:
    def __init__(self, data):
        self._data = list(data)

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

    def __getitem__(self, key):
        if isinstance(key, slice):
            start, stop, step = key.indices(len(self._data))
            return Frames(self._data[i] for i in range(start, stop, step))
        return self._data[key]

    def __repr__(self):
        return f"Frames({self._data!r})"


f = Frames(range(6))
print(f[1:4], f[::-1], f[-2])

go deeper

for a junior

Know that the colons in brackets create a slice object and that a class can receive it. Being able to say x[1:4] passes one object rather than two numbers is enough at this level.

for a middle

Explain the branch and the normalisation: isinstance(key, slice), then key.indices(len(self)) to resolve None bounds, negatives and clamping into a triple you can drive a range with.

for a senior

Show the design judgement: return the same container type from the slice branch, decide and document copy versus lazy view, handle negative integer keys and raise IndexError and TypeError where the built-ins do.

for a principal

Own the API contract. A sequence façade over remote or lazily-loaded data makes slicing look free while it is not, so decide deliberately whether to expose slicing at all, and set the team's convention for what a slice of a domain type returns.

### What the syntax actually produces The subscription `x[1:4]` is compiled to `type(x).__getitem__(x, slice(1, 4))`. The colons are syntax for constructing a `slice` object, nothing more, and that object is a perfectly ordinary value you can build yourself and pass around. Its three read-only attributes are `start`, `stop` and `step`, and crucially they hold **exactly what was written**: omitted bounds are `None`, not zero and not the length. `x[::2]` produces `slice(None, None, 2)`; `x[:-1]` produces `slice(None, -1, None)`. No normalisation has happened yet, because normalisation needs a length and the slice object does not know one. ### The branch A class that wants to support both forms therefore writes: ```python def __getitem__(self, key): if isinstance(key, slice): start, stop, step = key.indices(len(self._data)) return type(self)(self._data[i] for i in range(start, stop, step)) return self._data[key] ``` `slice.indices(length)` is the piece worth remembering by name. It applies precisely the algorithm the built-in sequences use — add the length to negative bounds, clamp into range, supply the direction-sensitive defaults for a negative step — and returns a `(start, stop, step)` triple guaranteed to be valid for `range()`. Writing that logic by hand is a reliable source of bugs, particularly the negative-step case where an omitted `stop` must mean "past the front" rather than 0. ### The integer branch is not free either A subtlety that catches people: for a custom class, **nothing translates a negative integer index for you**. If `__getitem__` stores its data in a list and forwards the key, the list does the translation and `x[-1]` works. If the class computes an offset itself, it must add `len(self)` when the index is negative and raise `IndexError` when the result is still out of range — raising `IndexError` matters, because that is the exception the iteration protocol relies on to stop a `for` loop over an object that has no `__iter__`. Integral keys also need not be `int`. Anything implementing `__index__` is an acceptable index in Python, so a strict `isinstance(key, int)` check rejects integer-like values the language would otherwise accept. Whatever falls through both branches should raise `TypeError`, which is what the built-in sequences do for a key they cannot interpret. ### Return-type convention Built-in sequences establish an expectation callers rely on: indexing returns an **element**, slicing returns a **new container of the same type**. A class whose slice branch returns a bare list when the class is not a list will surprise every caller who chains another operation onto the result. Whether the slice branch copies or returns a lazy view is a genuine design choice — a view avoids O(k) work but keeps the source alive and can expose later mutations — and it should be documented either way, because callers cannot see it from the call site. ### The mutating siblings `__setitem__` and `__delitem__` receive slice objects through exactly the same mechanism: `x[1:4] = value` calls `__setitem__(slice(1, 4), value)` and `del x[1:4]` calls `__delitem__(slice(1, 4))`. A mutable custom sequence must branch in all three methods, and if it supports a step it inherits the same question the built-in list answers with a `ValueError`: whether an extended-slice assignment may change the length. The usual answer is no. ### Multi-axis subscripts One more shape shows up in libraries with multi-dimensional data: `x[1:4, ::2]` builds a **tuple** whose members are two slice objects and passes that single tuple to `__getitem__`. That is the entire language-level mechanism behind multi-axis indexing in array libraries — there is no special syntax for it, just a tuple of slices. A plain sequence should reject a tuple key with `TypeError`, and the built-in list does exactly that. ### Where this comes up Inheriting from `collections.abc.Sequence` does not implement slicing for you: `__getitem__` remains abstract and the mixin methods it provides are built on top of whatever you write. So any class exposing a sequence-like façade — a lazily-loaded record set, a windowed buffer, a domain type wrapping a list — hits this the moment a caller writes a colon inside its brackets. Answering with `isinstance(key, slice)` plus `key.indices(len(self))`, the negative-index responsibility, and the same-type return convention covers everything an interviewer is looking for.

  • What does x[1:4, ::2] pass to __getitem__?
    A single tuple containing two slice objects — `(slice(1, 4), slice(None, None, 2))`. Comma-separated subscripts are packed into one tuple key, which is the whole language-level mechanism behind multi-axis indexing in array libraries; there is no special syntax for it. A one-dimensional sequence should reject a tuple key with `TypeError`, which is what the built-in list does.
  • Why call key.indices(length) rather than reading key.start and key.stop directly?
    Because the attributes hold exactly what was written, including `None` for omitted bounds and unresolved negatives. `indices(length)` applies the same normalisation the built-in sequences use — adding the length to negatives, clamping into range, and supplying the direction-sensitive defaults a negative step needs — and returns a triple valid for `range()`. Hand-rolling that logic reliably gets the negative-step case wrong.
  • What must a custom __getitem__ do about negative integer indices?
    Handle them itself. Nothing translates a negative index on the way in, so a class that computes offsets must add its own length when the key is negative and raise `IndexError` when the result is still out of range. Raising `IndexError` specifically matters: it is the signal the iteration protocol uses to stop a `for` loop over a class that defines `__getitem__` but no `__iter__`.

saying these in an interview costs you the question

  • Thinks __getitem__ receives start and stop as separate arguments
  • Expects omitted bounds to arrive as 0 and the length
  • Reads key.start directly without normalising it
  • Assumes negative indices are translated automatically for a custom class
  • Returns a plain list from a custom class's slice branch
  • Believes multi-axis subscripts need special syntax support

context