nikkie-ftnextの日記

イベントレポートや読書メモを発信

Python標準ライブラリ ソースコードリーディング | csv.DictReaderはcsv.readerを継承じゃなくて委譲している!

はじめに

ヨーソロー!1 nikkieです。

ここ数日はcsvモジュールで知ったことをアウトプットしています。
今回はcsv.DictReaderソースコードリーディングで知ったことです。

目次

csv.DictReaderとは

CSV形式のファイルをPythonで読み書きするときに使うのが、標準ライブラリのcsv
以下のようなコードで読み込めます(この記事の対話例は Python 3.10.9 です)。

some.csv

name,price
apple,1000
banana,500
>>> import csv
>>> with open("some.csv", newline="", encoding="utf8") as f:
...   reader = csv.reader(f)
...   for row in reader:
...     print(row)
...
['name', 'price']
['apple', '1000']
['banana', '500']

csv.readerでは各行がリストになります。
整数インデックスで各列の値にアクセスします。

整数インデックスだとマジックナンバーみがありますよね。
csvモジュールにはcsv.DictReaderというクラスもあります!
https://docs.python.org/ja/3/library/csv.html#csv.DictReader

このクラスを使うと、CSV各行を辞書で扱えます。
整数インデックスからヘッダーの項目名に変わる2ので、各列の値へのアクセスがより分かりやすいと思います!

>>> with open("some.csv", newline="", encoding="utf8") as f:
...   reader = csv.DictReader(f)
...   for row in reader:
...     print(row)
...
{'name': 'apple', 'price': '1000'}
{'name': 'banana', 'price': '500'}

DictReaderにもreader同様、dialect引数があります3

csv.DictReaderソースコードリーディング

ソースコードはそんなに長くありません4
https://github.com/python/cpython/blob/v3.11.3/Lib/csv.py#L80-L127

csv.DictReaderインスタンスイテレータ

上で示した対話例ではfor文で使用しました。
for文で使えるのでイテラブルです

実装を見ると、特殊メソッド__iter____next__を持つので、イテレータと分かります。

class DictReader:
    # 省略

    def __iter__(self):
        return self

    def __next__(self):
        # 省略

イテレータはイテラブルです

csv.DictReadercsv.readerに委譲している

「継承なのかな」と想像していたため、実装を見て驚きがありました。
読んでいくと、この委譲、うまくできていると思うんです!

class DictReader:
    def __init__(self, f, fieldnames=None, restkey=None, restval=None,
                 dialect="excel", *args, **kwds):
        # 一部抜粋
        self._fieldnames = fieldnames   # list of keys for the dict
        self.reader = reader(f, dialect, *args, **kwds)
        self.line_num = 0

__next__の実装です

class DictReader:
    def __next__(self):
        # 一部抜粋
        row = next(self.reader)

        d = dict(zip(self.fieldnames, row))
        lf = len(self.fieldnames)
        lr = len(row)
        if lf < lr:
            d[self.restkey] = row[lf:]
        elif lf > lr:
            for key in self.fieldnames[lr:]:
                d[key] = self.restval
        return d
  • next(self.reader)csv.readerに1行処理させる
  • csv.readerが処理した1行とfieldnamesから辞書を作る
  • fieldnamesと1行の長さが合わない場合の処理
    • ドキュメントより「列が fieldnames より多くのフィールドを持っていた場合、残りのデータはリストに入れられて、 restkey により指定されたフィールド名 (デフォルトでは None) で保存されます。
    • ドキュメントより「非ブランクの列が fieldnames よりも少ないフィールドしか持たない場合、不明の値は restval の値 (デフォルトは None ) によって埋められます。

ドキュメントで記載された動きがコードで表現されていて、うまくできているな〜と舌を巻きました

fieldnames属性

https://github.com/python/cpython/blob/v3.11.3/Lib/csv.py#L93-L101

class DictReader:
    @property
    def fieldnames(self):
        if self._fieldnames is None:
            try:
                self._fieldnames = next(self.reader)
            except StopIteration:
                pass
        self.line_num = self.reader.line_num
        return self._fieldnames
  • @propertyを使った実装です
    • 省略しましたが、setterも定義されています
  • _fieldnames属性がすでに設定されていたらそれを返します
    • __init__で渡されたらすでに設定されます
    • fieldnames属性にすでにアクセスがあったら設定されています
  • _fieldnames属性が設定されていない(値がNone)のとき
    • next(self.reader)で返した行を設定します
    • 1行目について呼ばれるように実装しています(後述)
      • 1行目がヘッダー行ならは、ヘッダーがfieldnamesとなりますね

「1行目について呼ばれる」という点ですが、__next__の中に1行も処理していなかった(line_num属性の値が05だった)ら、self.fieldnamesと呼び出しています。
https://github.com/python/cpython/blob/v3.11.3/Lib/csv.py#L108-L110
返ってきたfieldnamesを使うわけではなく、_fieldnames属性の設定が目的の呼び出しと理解しました(副作用のために使われる というコメントもあります)。

    def __next__(self):
        if self.line_num == 0:
            # Used only for its side effect.
            self.fieldnames
        row = next(self.reader)
        # 省略

ミノ駆動本より 継承より委譲

8章「密結合」の「8.2 密結合の各種事例と対処方法」で、良い構造にするために「継承より委譲」が紹介されていたのを思い出しました(8.2.1)。

  • csv.readerを継承してcsv.DictReaderクラスを定義するのではなく、csv.readerインスタンスを属性に持つ
  • csv.DictReaderクラスは、__iter____next__メソッドでイテレータにし、csv.readerクラスと振る舞いを揃える
    • 揃っているから私は継承による実装なのかなと思ったわけですね
  • __next__の実装は、csv.readerが返した行(リスト)を別のリストfieldnamesと組合せて辞書を作るというものでした
    • 実装としてきれいだなーと思います(委譲の一例なのかなと認識しました)

終わりに

csv.DictReaderソースコードリーディングで知ったことをアウトプットしました。

  • csv.DictReaderクラスはcsv.readerインスタンスを持つ(委譲)
    • CSVファイルの1行を辞書として返すのにcsv.readerの処理結果を加工する
  • fieldnames属性、CSVファイルの1行目(ヘッダー想定)が設定されるように__next__など工夫している

比較的短いクラスでしたし、実装に込み入ったところはなく、すっと読めていくつか気づきもありました🙌

csv.readerクラスの中も覗いてみたいところですが、_csvに実装があるようで、Python実装ではない実装がどこかにありそうだな〜くらいの認識です(探すところからですね。C実装だと思うので、いまはまだ私は読めないでしょう)


  1. 渡辺曜さん、お誕生日おめでとうございます!
  2. 辞書のキー名は与えることもできます。ドキュメントより「fieldnames が省略された場合、ファイル f の最初の列の値が fieldnames として使われます。
  3. 詳しくは
  4. ここ数日のcsvモジュール関連のアウトプットでソースコードに入り浸ったのですが、DictReaderはそんなに長いコードでないことに気づき、ちょっと読んでみました
  5. __init__でline_numを0に初期化しています