Python collectionsの7つの型の使い分け|実測データで効率化を判断

プログラミング

先週、自分の書いたPythonスクリプトを読み返していたら if key not in d: d[key] = [] という行が3か所出てきて、さすがに何か間に合ってないなと思いました。collections は名前だけ知っていて Counterdefaultdict しか使っていなかったので、この機会に一通り触って自分用に整理しています。

この記事でわかること

  • defaultdict で初期化チェックを消す方法と落とし穴
  • Counter で集計だけでなく差分や和を計算する使い方
  • deque で先頭操作を高速化(リストの80倍以上)
  • namedtupledataclass の実測サイズ比較
  • ChainMap で設定の優先順位を管理する方法
  • UserDictOrderedDict の使いどころ
  • Python 3.14系での collections 関連の変更

defaultdict:キーの存在チェックを書かなくなる

いちばん使用頻度が高いのはこれでした。defaultdict(list) にしておくと、存在しないキーにアクセスした時点でデフォルト値が作られるので、初期化のif文が消えます。

from collections import defaultdict

logs = [
    ("2026-09-01", "lambda-a", "ERROR"),
    ("2026-09-01", "lambda-a", "INFO"),
    ("2026-09-01", "lambda-b", "ERROR"),
    ("2026-09-02", "lambda-a", "ERROR"),
]

by_func = defaultdict(list)
for date, func, level in logs:
    by_func[func].append((date, level))

print(dict(by_func))

nested = defaultdict(lambda: defaultdict(int))
for date, func, level in logs:
    nested2026/09/21[level] += 1

print(nested["2026-09-01"]["ERROR"], nested["2026-09-03"]["ERROR"])
print(len(nested))  # ここ注意

実際に実行した結果
▲実際にこのブログの裏側で実行してみた結果です

ネストさせたいときは defaultdict(lambda: defaultdict(int)) で書けます。defaultdict(defaultdict(int)) だと動かないので、あくまで「呼ばれたら値を返すもの」を渡す、という理解でいます。

そして最後の len(nested) がハマりポイントで、存在しない "2026-09-03" を読んだだけなのにキーが増えて3になります。読み取りが副作用を持つのは、慣れるまでちょっと気持ち悪い挙動でした。集計後にキーを数えるようなコードだと静かにズレるので、参照だけしたいときは nested.get(...) を使うか、集計が終わったら dict(nested) に変換して固めてしまうのが安全かなと。

Counter:数えるだけじゃなく引き算ができる

Counter は「数える」用途しか知らなかったんですが、カウンタ同士の演算のほうが面白かったです。

from collections import Counter

words = "cat dog cat bird cat dog fish".split()
c = Counter(words)

print(c.most_common(2))
print(c.total())
print(c["hamster"])

yesterday = Counter({"cat": 2, "dog": 3, "bird": 1})
print(c - yesterday)
print(c & yesterday)

実際に実行した結果
▲実際にこのブログの裏側で実行してみた結果です

実行すると most_common(2)[('cat', 3), ('dog', 2)]total() は7、存在しないキーは KeyError ではなく0が返ります。この0返しがあるおかげで、素朴に c[k] += 1 と書けるわけですね。

差分の c - yesterdayCounter({'cat': 1, 'fish': 1}) になります。dog は昨日3で今日2なので -1 のはずですが、減算は0以下を捨てるので消えます。つまり「増えたものだけ」が残る挙動で、日次のアクセスログ比較みたいな用途にはそのまま使えます。逆に増減を両方見たいなら subtract() のほうで、こちらはマイナスを保持してくれます。&(積集合=小さい方を採用)も地味に便利です。

ちなみに total() はPython 3.10で入ったメソッドなので、それ以前は sum(c.values()) でした。自分は最近まで後者を書いていました。

deque:先頭の追加・削除が桁違いに速い

listの pop(0) が遅いというのは知識としては知っていたけど、どのくらい遅いのか測ったことがなかったので測ってみました。

import timeit
from collections import deque

N = 50_000

def with_list():
    q = list(range(N))
    while q:
        q.pop(0)

def with_deque():
    q = deque(range(N))
    while q:
        q.popleft()

print("list.pop(0)  :", round(timeit.timeit(with_list, number=3) / 3, 4), "sec")
print("deque.popleft:", round(timeit.timeit(with_deque, number=3) / 3, 4), "sec")

手元の環境では list が約0.24秒、deque が約0.003秒でした。80倍くらい違います。listは先頭を抜くたびに残り全要素をずらすのでO(n)、それを5万回やるのでO(n²)になる、という話が素直に数字に出ています。要素数が100くらいなら体感差はないでしょうが、キュー的な使い方をするなら最初からdequeにしておくのが良さそうです。

maxlenで「直近N件」が勝手に維持される

from collections import deque

recent = deque(maxlen=3)
for i in range(6):
    recent.append(i)
    print(list(recent))

満杯になると反対側から自動で捨てられるので、最終的に [3, 4, 5] だけが残ります。公式ドキュメントでもUnixの tail に似た機能と説明されていて、直近のリクエストを保持しておく用途や、移動平均の窓なんかにそのまま使えます。自前でスライスして管理するコードを書いたことがあるので、これを先に知りたかった…。

弱点はランダムアクセス

万能ではなくて、真ん中へのインデックスアクセスはlistに負けます。10万要素の中央を10万回読む比較をしたら、listが0.003秒に対してdequeは0.17秒でした。両端O(1)の代わりに中央付近はO(n)寄りになる構造なので、d[i] をループで回すような使い方をするならlistのままのほうが良い、ということですね。あと rotate() というメソッドがあって、これは何に使うのが正解なのか正直まだピンときてないです。

namedtuple・ChainMap・UserDictの使いどころ

残りは使用頻度が下がるものの、刺さる場面がはっきりしている3つ。

import sys
from collections import namedtuple, ChainMap
from dataclasses import dataclass

Point = namedtuple("Point", ["x", "y"])

@dataclass(slots=True)
class DPoint:
    x: int
    y: int

p = Point(1, 2)
print(p.x, p[0], p._asdict(), p._replace(y=99))
print(sys.getsizeof(p), sys.getsizeof(DPoint(1, 2)), sys.getsizeof({"x": 1, "y": 2}))

defaults = {"region": "ap-northeast-1", "timeout": 30, "debug": False}
env = {"timeout": 10}
cli = {"debug": True}

cfg = ChainMap(cli, env, defaults)
print(cfg["region"], cfg["timeout"], cfg["debug"])

cfg["timeout"] = 5
print(cli, env["timeout"])

namedtuple

_asdict() でdict化、_replace() で一部だけ差し替えた新インスタンスが作れます。サイズは手元で namedtuple 56バイト / @dataclass(slots=True) 48バイト / 素のdict 184バイト でした。dictとの差は明確ですが、dataclass(slots)とはほぼ互角でした。なので「メモリのためにnamedtuple」という理由づけは今はあまり強くなくて、インデックスアクセスやアンパックができる=タプル互換が必要かどうかで選ぶのが実際的かなと思っています。型を効かせたいなら typing.NamedTuple のほうが書き味は良いです。

ChainMap

これが個人的に一番の発見でした。CLI引数 > 環境変数 > デフォルト値、みたいな優先順位付き設定を {**defaults, **env, **cli} でマージしていたんですが、ChainMapなら元のdictを壊さず、先頭から探して最初に見つかったものを返してくれます。上の例では timeout が10(env優先)、debug がTrue、region はdefaultsから来ます。

注意点は書き込みで、cfg["timeout"] = 5 は先頭のマップ(cli)にだけ入ります。env 側の10は書き換わりません。読み取り専用のビューとして使うのが素直そうです。

UserDict

dictを継承して __setitem__ をオーバーライドしても、update()__init__ 経由の代入が拾われないことがあります。UserDict は内部の data 属性に委譲する作りなので、そのあたりが一貫して動きます。HTTPヘッダを大文字小文字を無視して扱うクラスを書いてみたら、コンストラクタに渡したキーもちゃんと小文字化されました。頻繁に使うものではないけど、dictっぽい自作クラスが必要になったときの正解はこれ、と覚えておきたいところ。

あと OrderedDict は、Python 3.7以降のdictが挿入順を保つようになったので出番が減っています。ただ move_to_end()popitem(last=False) があるので、LRUキャッシュを自作するなら今でもこちら、という認識です。

2026年9月現在のバージョン事情

この記事のコードは3.11で動かしましたが、2026年9月現在の最新安定版は Python 3.14系です。3.15は9月1日に最終リリース候補が出た段階で、正式版は10月予定っぽいです。

で、collections自体は何か大きく変わったのかというと、ほぼ変わっていません。3.10でABCのエイリアス(collections.Mapping のような書き方)が削除されて collections.abc に統一されたのが最後の大きめな変更で、あとは Counter.total() の追加くらい。枯れているモジュールなので、今から覚えても知識の寿命が長いのはありがたいです。

むしろ3.14側の変更で関係しそうなのは、ガベージコレクション周りの変更でしょうか。大量のdequeやdefaultdictをメモリに抱える処理では効いてくるかもしれません。これは自分の手元で差を測れていないので、別途試したいです。

結局どう使い分けるか

ざっくり、キーごとに集める→defaultdict、数える・比べる→Counter、両端をいじる/直近N件→deque、軽い値オブジェクト→namedtuple、設定の優先順位→ChainMap、という整理で自分は落ち着きました。どれも標準ライブラリなのでインストール不要、importするだけで済むのが良いですね。

itertoolsheapq も触ってみる予定です。heapq の使いどころが「優先度付きキュー」以外に思いつかないんですが、実務で使っている人はどういう場面で出してるんでしょう。

※この記事にはプロモーションが含まれます

そういえば、Pythonのcollectionsモジュールで効率化したコードをブログにまとめようと思って、レンタルサーバーをいろいろ調べてたらお名前.com レンタルサーバーが目に留まりました。

タイトルとURLをコピーしました