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__とIndexError | 0から順に取り出し、IndexErrorで停止 |
シーケンスプロトコルの基礎
シーケンスプロトコルとは、シーケンス型として最低限満たすべき規約です。
__len__
と__getitem__
を揃えるだけで、反復(forループ)や会員判定(in
)の一部、スライシングなどが自然に動きます。
ただし、より豊富なAPI(count
やindex
など)は後述のcollections.abc.Sequence
を継承するのが近道です。
__len__と__getitem__の実装手順
クラス設計と内部配列の用意
まずは内部データをリストとして保持し、そこに対する薄いラッパーを作るのが簡単です。
コンストラクタ__init__
では受け取った反復可能オブジェクトをlist()
に変換し、内部に保存します。
# 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以上の整数を返す必要があります。
内部配列の長さをそのまま返しましょう。
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の慣習に合わせます。
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
に包んで返すと、部分コンテナの型が保たれ、連鎖スライスも自然に機能します。
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つにまとめた完成版です。
# 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()とインデックスアクセスをテスト
正のインデックス、負のインデックス、範囲外アクセスを順に確認します。
# 先程の完成したコードを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ループは動作します。
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
を返します。
境界をはみ出す指定や負方向のステップにも対処できています。
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__
、count
、index
などの便利メソッドが自動提供されます。
型引数で要素型を表すと読みやすくなります。
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]
として受ければ、リストやタプル、そして自作コンテナまで幅広いシーケンスを受け入れられます。
# 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
はスライスで新しいリストを生成します。
大きなデータではコピーコストが効いてきます。
回避策の一つはビュー(参照)の実装です。
以下は概念例で、元データとインデックス範囲だけを持ち、コピーを避けます。
# コピーを避ける「ビュー」コンテナの概念例
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の自作クラスは「シーケンス」として自然に振る舞うようになります。
負のインデックスやスライスへの対応、適切なIndexErrorとTypeErrorの送出を押さえれば、len、インデックスアクセス、forループ、そして部分コンテナまで一通りの機能が手に入ります。
さらにcollections.abc.Sequence
を継承すれば周辺APIも自動で整い、typing.Sequence
を使った汎用的な関数設計も可能です。
最後に、スライスのコピーは便利な反面コストがあるため、必要に応じてビュー方式などの最適化も検討すると良いでしょう。