閉じる

Pythonで__len__と__getitem__を実装して自作コンテナを作る方法

Pythonでは__len____getitem__を実装するだけで、組み込みのlen()やインデックスアクセス、forループによる走査まで動く自作コンテナを作れます。

本記事では、初心者の方にも分かるように、役割の整理から実装、動作確認、さらに拡張の方向性までを段階的に解説します。

最後にはベストプラクティスや性能上の注意点も触れます。

独自コンテナの基本

Pythonの特殊メソッドで自作コンテナを作る

Pythonのクラスは__len____getitem__といった特殊メソッドを定義すると、len(obj)obj[i]などの構文に対応できます。

これは「プロトコル」と呼ばれる取り決めで、特定のメソッド名と振る舞いを満たせば、その型が特定の機能を持つとみなされます。

コンテナとして最小限必要なのが__len____getitem__です。

__len__と__getitem__の役割

__len__は要素数(非負の整数)を返します。

__getitem__は添字アクセスの実体で、整数インデックスやスライス(slice)に反応します。

整数が範囲外ならIndexError、対応しない型のインデックスならTypeErrorを送出します。

負のインデックス(例: -1)に対応することもPythonの慣習です。

次の対応関係を把握すると全体像がつかめます。

構文や関数呼ばれる特殊メソッド返す/期待されるもの
len(x)__len__(self) -> int要素数
x[i]__getitem__(self, i: int)i番目の要素(範囲外はIndexError)
x[i:j:k]__getitem__(self, s: slice)部分コンテナ(通常は同種クラス)
for v in x__getitem__とIndexError0から順に取り出し、IndexErrorで停止

シーケンスプロトコルの基礎

シーケンスプロトコルとは、シーケンス型として最低限満たすべき規約です。

__len____getitem__を揃えるだけで、反復(forループ)や会員判定(in)の一部、スライシングなどが自然に動きます

ただし、より豊富なAPI(countindexなど)は後述のcollections.abc.Sequenceを継承するのが近道です。

__len__と__getitem__の実装手順

クラス設計と内部配列の用意

まずは内部データをリストとして保持し、そこに対する薄いラッパーを作るのが簡単です。

コンストラクタ__init__では受け取った反復可能オブジェクトをlist()に変換し、内部に保存します。

Python
# mylist.py
# 基本となる自作コンテナ MyList
class MyList:
    """シンプルな読み取り専用シーケンスの例"""

    def __init__(self, iterable=()):
        # 受け取りデータをリスト化して内部に保持(浅いコピー)
        self._data = list(iterable)

    # 表示用(デバッグしやすく)
    def __repr__(self):
        return f"MyList({self._data!r})"

__len__で要素数を返す

__len__は常に0以上の整数を返す必要があります。

内部配列の長さをそのまま返しましょう。

Python
class MyList:
    def __init__(self, iterable=()):
        self._data = list(iterable)

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

    def __len__(self):
        """要素数を返す"""
        return len(self._data)

__getitem__でインデックスと負の値に対応

整数インデックスでは負の値を許容し、-1を末尾と解釈するなど、Pythonの慣習に合わせます。

Python
class MyList:
    def __init__(self, iterable=()):
        self._data = list(iterable)

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

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

    def __getitem__(self, index):
        """整数インデックスとスライスに対応するゲッター"""
        if isinstance(index, int):
            # 負のインデックスは長さを足して正に直す
            if index < 0:
                index += len(self)
            # 範囲外はIndexError
            if index < 0 or index >= len(self):
                raise IndexError("MyList index out of range")
            return self._data[index]
        # スライスは後段で対応
        raise TypeError(f"indices must be integers or slices, not {type(index).__name__}")

スライス対応と部分コンテナの返し方

スライス(x[i:j:k])にはsliceオブジェクトが渡されます

内部リストのスライス結果を再びMyListに包んで返すと、部分コンテナの型が保たれ、連鎖スライスも自然に機能します。

Python
class MyList:
    def __init__(self, iterable=()):
        self._data = list(iterable)

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

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

    def __getitem__(self, index):
        if isinstance(index, int):
            if index < 0:
                index += len(self)
            if index < 0 or index >= len(self):
                raise IndexError("MyList index out of range")
            return self._data[index]
        elif isinstance(index, slice):
            # 内部リストのスライスはPythonが面倒を見てくれる
            return MyList(self._data[index])
        else:
            raise TypeError(f"indices must be integers or slices, not {type(index).__name__}")

IndexErrorとTypeErrorの扱い

エラーの種類を正しく使い分けると、利用者が原因を特定しやすくなります。

範囲外はIndexError、インデックスの型が不正ならTypeErrorが基本です。

メッセージには簡潔に原因が分かる文言を入れるとよいです。

完成したコード

ここまでの要素を1つにまとめた完成版です。

Pythonmylist.py
# mylist.py (完成)
class MyList:
    """__len__ と __getitem__ だけで読み取り専用シーケンスとして振る舞う例"""

    def __init__(self, iterable=()):
        # 内部にリストで保持(浅いコピー)。元の配列とは独立に安全に扱える
        self._data = list(iterable)

    def __repr__(self):
        # デバッグ用表現
        return f"MyList({self._data!r})"

    def __len__(self):
        """要素数を返す。非負の整数でなければならない。"""
        return len(self._data)

    def __getitem__(self, index):
        """整数インデックスとスライスをサポートする。
        - int: 負の値にも対応。範囲外はIndexError
        - slice: 同種のMyListを返す
        - その他: TypeError
        """
        if isinstance(index, int):
            if index < 0:
                index += len(self)
            if index < 0 or index >= len(self):
                raise IndexError("MyList index out of range")
            return self._data[index]

        if isinstance(index, slice):
            # スライス結果(list)を再ラップして部分コンテナを返す
            return MyList(self._data[index])

        raise TypeError(f"indices must be integers or slices, not {type(index).__name__}")

使い方と動作確認

len()とインデックスアクセスをテスト

正のインデックス、負のインデックス、範囲外アクセスを順に確認します。

Python
# 先程の完成したコードをmylist.pyに定義済みとします
from mylist import MyList

nums = MyList([10, 20, 30, 40])

print("len:", len(nums))          # 要素数
print("nums[0]:", nums[0])        # 先頭
print("nums[-1]:", nums[-1])      # 末尾(負のインデックス)

# 範囲外アクセスは IndexError
try:
    print(nums[100])
except Exception as e:
    print(type(e).__name__, "->", e)
実行結果
len: 4
nums[0]: 10
nums[-1]: 40
IndexError -> MyList index out of range

forループでのイテレーション確認

__iter__を定義していなくても__getitem__が0から順番に取り出せてIndexErrorで止まる振る舞いを満たせば、forループは動作します。

Python
from mylist import MyList

nums = MyList([10, 20, 30, 40])

for i, v in enumerate(nums):
    print(f"{i}: {v}")
実行結果
0: 10
1: 20
2: 30
3: 40

スライス結果と境界値のチェック

スライスは部分コンテナMyListを返します。

境界をはみ出す指定や負方向のステップにも対処できています。

Python
from mylist import MyList

nums = MyList([10, 20, 30, 40])

print("nums[1:3]   ->", nums[1:3])    # 中間のスライス
print("nums[::-1]  ->", nums[::-1])   # 逆順
print("nums[:10]   ->", nums[:10])    # 上限越え(安全)
print("nums[-10:2] ->", nums[-10:2])  # 下限越え(安全)
print("nums[::2]   ->", nums[::2])    # ステップ指定
実行結果
nums[1:3]   -> MyList([20, 30])
nums[::-1]  -> MyList([40, 30, 20, 10])
nums[:10]   -> MyList([10, 20, 30, 40])
nums[-10:2] -> MyList([10, 20])
nums[::2]   -> MyList([10, 30])

応用とベストプラクティス

collections.abc Sequenceで機能を拡張

collections.abc.Sequenceを継承すると、__len____getitem__を提供するだけで__contains__countindexなどの便利メソッドが自動提供されます。

型引数で要素型を表すと読みやすくなります。

Python
from collections.abc import Sequence
from typing import Generic, Iterable, TypeVar, Union

T = TypeVar("T")

class MySeq(Sequence[T], Generic[T]):
    """Sequenceミックスインで標準的なAPIを得る例"""
    def __init__(self, iterable: Iterable[T] = ()):
        self._data: list[T] = list(iterable)

    def __repr__(self) -> str:
        return f"MySeq({self._data!r})"

    def __len__(self) -> int:
        return len(self._data)

    def __getitem__(self, index: Union[int, slice]) -> Union[T, "MySeq[T]"]:
        if isinstance(index, int):
            if index < 0:
                index += len(self)
            if index < 0 or index >= len(self):
                raise IndexError("MySeq index out of range")
            return self._data[index]
        if isinstance(index, slice):
            return MySeq(self._data[index])
        raise TypeError(f"indices must be integers or slices, not {type(index).__name__}")

# 使ってみる
s = MySeq([1, 2, 2, 3])
print(2 in s, s.count(2), s.index(3))
実行結果
True 2 3

補足: Sequenceは読み取り専用の抽象基底クラスです。

要素の更新(__setitem__)や追加/削除をしたい場合はMutableSequenceの実装が必要になります。

型ヒントとtyping Sequenceの活用

関数の引数をtyping.Sequence[T]として受ければ、リストやタプル、そして自作コンテナまで幅広いシーケンスを受け入れられます。

Python
# mylist.pyに定義済みとします
from mylist import MyList

from typing import Sequence

def sum_even(seq: Sequence[int]) -> int:
    """偶数だけを合計する。Sequenceなら何でもOK"""
    return sum(x for x in seq if x % 2 == 0)

print(sum_even([1, 2, 3]))             # 組み込みlist
print(sum_even((10, 11, 12)))          # 組み込みtuple
print(sum_even(MyList([2, 4, 5])))     # 自作コンテナ(MyList)
実行結果
2
22
6

インターフェース(振る舞い)で受けることで、コードの再利用性が高まり、テストや差し替えも容易になります。

パフォーマンスとコピーコストの注意

本記事のMyListはスライスで新しいリストを生成します。

大きなデータではコピーコストが効いてきます。

回避策の一つはビュー(参照)の実装です。

以下は概念例で、元データとインデックス範囲だけを持ち、コピーを避けます。

Python
# コピーを避ける「ビュー」コンテナの概念例
class MyListView:
    """元のリスト(data)への参照と索引(range)だけで表すビュー"""
    def __init__(self, data, start=0, stop=None, step=1):
        self._data = data
        n = len(data)
        # slice.indicesでNoneや負値、範囲外を正規化
        start, stop, step = slice(start, stop, step).indices(n)
        # 実体は取り出し位置のrange。要素はコピーしない
        self._indices = range(start, stop, step)

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

    def __getitem__(self, index):
        if isinstance(index, int):
            if index < 0:
                index += len(self)
            if index < 0 or index >= len(self):
                raise IndexError("MyListView index out of range")
            # 実データから対応位置の要素を返す
            return self._data[self._indices[index]]
        if isinstance(index, slice):
            # rangeのスライスは新しいrangeを返すのでそのまま再利用
            new_range = self._indices[index]
            view = MyListView(self._data)  # 仮のインスタンスを作り
            view._indices = new_range      # rangeを差し替える(簡略実装)
            return view
        raise TypeError(f"indices must be integers or slices, not {type(index).__name__}")

ビューはメモリ効率が良い一方で、元データの変更がビューにも即時反映されます。

用途に応じてコピーの安全性ビューの効率を使い分けてください。

まとめ

__len__と__getitem__を実装するだけで、Pythonの自作クラスは「シーケンス」として自然に振る舞うようになります。

負のインデックスやスライスへの対応、適切なIndexErrorTypeErrorの送出を押さえれば、len、インデックスアクセス、forループ、そして部分コンテナまで一通りの機能が手に入ります。

さらにcollections.abc.Sequenceを継承すれば周辺APIも自動で整い、typing.Sequenceを使った汎用的な関数設計も可能です。

最後に、スライスのコピーは便利な反面コストがあるため、必要に応じてビュー方式などの最適化も検討すると良いでしょう。

この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!