428
307

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

namedtupleで美しいpythonを書く!(翻訳)

Last updated at Posted at 2019-02-06

namedtupleの解説記事です。

この記事の想定読者

  • namedtupleの基本を知りたい人
  • namedtupleの存在意義がよく分からない人
  • namedtupleの活用場面を知りたい人
  • そこそこ多量のデータを上手に管理する方法を探している人

【2019年10月4日追記】
結構な数のいいねをいただいたため、きちんとした記事にしなければと改めて思っております。
見直しますと、typing.NamedTupleの利用方法など、重要な情報が不足していると思いましたので(執筆当時の知識不足によります。申し訳ありません)、本日追記して記述を補いました。

はじめに

あまりメジャーではないかもしれませんが、pythonにはnamedtupleというデータ型があります。標準ライブラリのcollectionsモジュールからインポートできます。

私自身はnamedtupleに関しては、

  • dictionaryのようにキーバリュー的に値を格納できる
  • ドットアクセスできる。あの面倒なdic["key"] を書かないでいい!!
  • 基本的にtupleである。なのでイミュータブルである
  • tupleなので軽量だったり早かったりするのではないか?

というくらいの認識でした。

私はついついパフォーマンスが気になるし、イミュータブルと言われると安心を感じるほうなので、namedtupleはなんだか気になる存在でした。ただ、ネットで検索してみてもあまりすっきりした解説に出会うことはなく、自己流でもやもやしていました。

そんなある日、namedtupleはとても「安全」なんじゃないかと思い、**"namedtuple safe"**というキーワードでググってみました。そこで以下の記事を発見しました。

Namedtupleでクリーンなpythonを書く
Writing Clean Python With Namedtuples

読んでみたところ、namedtupleの入門に必要な内容がきちんと書かれており、更に長所や使いどころも整理されており、私的にはスッキリな記事でした。
namedtupleに関しては、日本語の解説記事でほどよいものを見た記憶がなかったので、この際、記事を和訳して投稿してみようと思い立ちました。

なお、翻訳については著者の了解を得ています。
それではお楽しみください。

namedtupleでクリーンなpythonを書く(翻訳)

pythonには namedtupleという特別なコンテナがありますが、その力が十分に評価されていないようです。pythonが持つ素晴らしい機能なのに、みんなに見過ごされているものの1つです。

クラスを自分で定義するかわりにnamedtupleを使うことが非常に有効です。そしてnamedtupleには他にも面白い特徴があります。この記事でそれを紹介したいと思います。

さて、namedtupleとはなんでしょうか。また、何が特別なのでしょうか。
よい方法があります。組み込みのデータ型であるtupleの拡張だと考えてみるのです。

pythontupleはシンプルなデータ型で、任意のオブジェクトをグループにします。また、tupleはイミュータブルです。一旦作成されると変更できません。


>>> tup = ('hello', object(), 42)
>>> tup
('hello', <object object at 0x105e76b70>, 42)
>>> tup[2]
42
>>> tup[2] = 23
TypeError: "'tuple' object does not support item assignment"

プレーンなtupleには弱点があります。格納したデータに、整数のインデックスでしかアクセスできないことです。tupleに格納された個々のプロパティに名前を付けることはできません。これはコードの可読性を損ないます。

また、tupleの構造は毎回変わります。2つのtupleが、同じ数のフィールドと、同じプロパティを持っている保証はありません。フィールドの順番をうっかり取り違えてバグを起こしがちです。

namedtupleが解決!

namedtupleの狙いは、この2つの問題を解決することです。
何よりも、namedtupleは通常のtupleと同じようにイミュータブルです。一旦何かを格納すると、もう変更することはできません。

それと同時にnamedtupleは・・・、そう、namedなtupleなのです。
namedtupleに格納されたオブジェクトには、ユニークな(人間に読みやすい)名前でアクセスすることができます。数値のインデックスを覚える必要はありません。インデックスで使うために数値の入った定数を定義するような、その場しのぎも必要ありません。

namedtupleはこんな風に使います。

>>> from collections import namedtuple
>>> Car = namedtuple('Car' , 'color mileage')

namedtupleを使うためには、collectionsモジュールをインポートします。Python2.6で標準ライブラリに追加されました。上の例では、Carというシンプルなデータ型を定義しました。Carにはcolormileageの2つのフィールドがあります。

書き方が少し妙に見えるかもしれません。なぜフィールドを"color mileage"という文字列で渡すのでしょう。
それはnamedtupleのファクトリ関数が、この文字列に対して、split()を呼び出すからです。つまり、以下の書き方の略記になっています。

>>> 'color mileage'.split()
['color', 'mileage']
>>> Car = namedtuple('Car', ['color', 'mileage'])

もちろん、そのほうが好みなら、リストでそのまま渡すこともできます。このやり方のメリットは、コードを複数行に分けるときに簡単に書き直せることです。

>>> Car = namedtuple('Car', [
...     'color',
...     'mileage',
... ])

いずれにしても、これでCarファクトリ関数によって、新しいcarオブジェクトを作ることができるようになりました。これは、Carクラスを自分で定義し、colormileageの値を受け付けるコンストラクタを作ったのと同じ振る舞いになります。

(【2019年10月4日追記:訳注】python3.6.1より、typing.NamedTupleを使う読みやすい記法が実装されています(後述します)。)

>>> my_car = Car('red', 3812.4)
>>> my_car.color
'red'
>>> my_car.mileage
3812.4

アンパック代入や、可変長引数(*)も利用できます。

>>> color, mileage = my_car
>>> print(color, mileage)
red 3812.4
>>> print(*my_car)
red 3812.4

namedtupleの値には名前でアクセスできる上に、インデックスでも変わらずアクセスできます。このため、namedtupleはプレーンなtupleをいつでも置き換えることができます(drop-in replacement)。

>>> my_car[0]
'red'
>>> tuple(my_car)
('red', 3812.4)

特別な設定なく、いい具合の文字表示まで使えるようになります。タイプ量も冗長さもおさえられます。

>>> my_car
Car(color='red' , mileage=3812.4)

プレーンなtupleと同様に、namedtupleはイミュータブルです。フィールドを上書きしようとすると、AttributeErrorになります。

>>> my_car.color = 'blue'
AttributeError: "can't set attribute"

namedtupleオブジェクトは、内部的に、pythonの通常のクラスとして実装されます。メモリ効率に関しては、通常のクラスよりも良好であり、プレーンなtupleと同水準です。

こう考えるとよいでしょう。namedtupleはメモリ効率がよいイミュータブルなクラスです。そして、クラス定義のショートカットになります。

namedtupleをサブクラス化する

namedtupleは通常のクラスをベースにしているため、namedtupleのクラスにメソッドを追加することもできます。たとえば、他のクラスと同様に継承することができますし、メソッドやプロパティも同様に追加できます。こんな具合です。

>>> Car = namedtuple('Car', 'color mileage')
>>> class MyCarWithMethods(Car):
...     def hexcolor(self):
...         if self.color == 'red':
...            return '#ff0000'
...         else:
...             return '#000000'

MyCarWithMethodsクラスを作り、hexcolor()メソッドを呼び出しました。期待通りに動作します。

>>> c = MyCarWithMethods('red', 1234)
>>> c.hexcolor()
'#ff0000'

少し簡単すぎたかもしれませんね。イミュータブルなプロパティのあるクラスが欲しいときに、こうするとよいかもしれません。でもずっこけたことをやってしまうものです。
たとえば、新たにイミュータブルなフィールドを追加するのは、namedtupleの実装からすると少し難しいです。namedtupleのクラス階層を作る一番簡単な方法は、基底となるtuple._fieldsプロパティを使うことです。

>>> Car = namedtuple('Car', 'color mileage')
>>> ElectricCar = namedtuple(
...     'ElectricCar', Car._fields + ('charge',))

これで期待通りになります。

>>> ElectricCar('red', 1234, 45.0)
ElectricCar(color='red', mileage=1234, charge=45.0)

組み込みのヘルパーメソッド

_fieldsプロパティのほかにも、namedtupleのインスタンスにはいくつかのヘルパーメソッドがあります。便利に使えるかも知れません。

ヘルパーメソッドの名前は全てアンダースコアから始まります。
アンダースコアは普通はそのメソッドやプロパティがプライベートであることを意味し、クラスやモジュールのパブリックインターフェースを意味しません。
namedtupleでアンダースコアが使われるのは別の意味があります。これらのヘルパーメソッドやプロパティは、namedtupleのパブリックインターフェースになっています。ユーザーが定義するフィールド名との衝突を避けるために、アンダースコアがついています。

namedtupleのヘルパーメソッドが便利に活用できるシナリオをいくつか紹介します。最初は_asdict()です。namedtupleの中身をdictで返します。

>>> my_car._asdict()
OrderedDict([('color', 'red'), ('mileage', 3812.4)])

jsonを出力するときにタイポしないですみますね。たとえばこんな具合です。

>>> json.dumps(my_car._asdict())
'{"color": "red", "mileage": 3812.4}'

次の便利なヘルパーは_replace()関数です。tupleのシャローコピーを返し、選んだフィールドを置き換えます。

>>> my_car._replace(color='blue')
Car(color='blue', mileage=3812.4)

最後に、_make()クラスメソッドはシーケンスやイテレーターから、新しいインスタンスを作るために使えます。

>>> Car._make(['red', 999])
Car(color='red', mileage=999)

どんなときにnamedtupleを使うか

namedtupleは、コードをきれいに、読みやすくする簡単な方法になります。データにきちんとした型を与えられるからです。

たとえば、定型フォーマットにしたディクショナリ(そのたびに型が変わるものです)を、namedtupleに変えることで、自分の意図がよりはっきり表現できます。このリファクタリングをやってみると、目下の問題に素晴らしい解決策をたちまち考えつくことがしばしばです。

構造の無いタプルやディクショナリでなくnamedtupleを使うことで、同僚たちの仕事も楽になります。データがある程度セルフドキュメンティングな形で渡されるからです。

一方、よりクリーンに、読みやすく、メンテナンスしやすいコードにするために役立たないなら、namedtupleは使わないようにしています。過ぎたるは及ばざるがごとしです。
しかし意識して使えば、namedtupleはまちがなく、あなたのpythonコードをより良く、そして、表現力豊かなものにしてくれます。

覚えておこう!

  • collection.namedtupleは自分でイミュータブルなクラスを定義するショートカットになり、しかも、メモリ効率がよい。
  • namedtupleはデータに理解しやすい型を与えることで、コードをクリーンにしてくれる。
  • namedtupleには便利なヘルパーメソッドがある。アンダースコアから始まるが、パブリックインターフェースだ。これを活用するのもよい。

投稿者より

私自身はnamedtupleであれこれ試している段階です。
これまではちょっとしたデータの格納にはいつもdictを使っていたのですが、それを積極的にnamedtupleに置き換えるようになりました。

データコンテナとして

よく考えてみると、型(キー)が固定しているデータって結構よくあるのですね。固定していることが期待されるというべきでしょうか。
そういうデータは、キーが本質的に不定なdictよりも、namedtupleであるほうが自然だと思います。

それと、イミュータブルという点が魅力です。
ありませんか? 謎のバグが起きて、必死でトレースバックを追ってみると、「うわ、ここでdictの値が書き換わってた・・・」みたいなことが。

ごく短いスコープで使うならばまだしも、クラスや関数間で受け渡すような場合は、どうもミュータブルは不安になります。データ用のクラスもミュータブルだと不安です。
こういうのをnamedtupleで置き換えてみると、すっきり安心になりました。

最近使った例では、

  • ディレクトリのパスを格納するため
  • 8個セットの引数を受け渡すため
  • システム内で変更されることがない基本データの格納のため

といった感じです。

パスの格納はこんなイメージです。ファクトリとセットにしています。

import os
from collections import namedtuple

PathContainer = namedtuple("PathContainer","root document exe")

def generate_PathContainer(root):
    document = os.path.join(root,"document")
    exe = os.path.join(root,"hoge","fuga.exe")
    return PathContainer(root,document,exe)

勝手に書き換わることが決してない!と思うと、すごく安心して寄りかかれます。

5個の関数の間でミュータブルな引数を次々と渡していくとかちょっと怖かったですが、namedtupleにしてから大丈夫だと思えるようになりました。

【2019年10月4日追記】
この記事を書いた後も、筆者はご機嫌でnamedtupleを活用しています。その後の知見として、関数の戻り値として使うというのは有力だと思います。

pythonはこんな風にして、関数の戻り値として、簡単にtupleを返せます。

def analyze_text(text):
    # some_process
    name = "analyzed_name"
    age = processed_number
    return name,age

ただこれが意外と曲者で、考え無しに使っていると、似た関数でnameだけ返していたり、age,nameを返したり、name,addressを返したり、、といった具合に戻り値が混沌としていくことがあります。

こういう場合、戻り値の型をnamedtupleで定義してしまえば、一気にすっきりします(この記法はすぐあとで説明します)。


from typing import NamedTuple

class ResultAnalyze(NamedTuple):
    name: str 
    age: int 

def analyze_text(text):
    # some_process
    name = "analyzed_name"
    age = processed_number
    return ResultAnalyze(name,age)

こうしておけば、戻り値がバラバラになるなんてことは起きなくなります。

本体記事にもあったように、namedtupleは数値インデックスでのアクセスもできるなど、tupleと同じインターフェースを持っています。ですので、既存の関数をこういう風にリファクタしても、既存のコードに影響を与えることなく動きます。
【追記終わり】

ドットアクセス

それと、ドットアクセスはやはり魅力です。見やすい、書きやすいですよね。

path_container["exe"]

VS

path_container.exe

更にドットアクセスならば、エディターによる入力補完がききやすいです。

ちなみに引数を動的にしたいときには、getattrを使えばOKです。

path_container = generate_PathContainer("ROOTPATH")
key = "exe"
getattr(path_container, key)

# ROOTPATH\hoge\fuga.exe

デフォルト値を設定する

namedtupleはデフォルト値を設定することもできます。
__new__.__defaults__を使えばOKです。これで便利に活用できますね。

Point = namedtuple("Point","name x y")
Point.__new__.__defaults__ = ("name",0,0)

# tupleを作れば良いので、こうやってNoneで埋める手も
Point.__new__.__defaults__ = tuple([None]*3)

typing.NamedTupleによる記述法【2019年10月4日追記】

この記事を書いた当時は把握できていなかったのですが、python3.6.1より、typing.NamedTupleを用いた記法が実装されています。以下のような書き方です。

from typing import NamedTuple

class PathContainer(NamedTuple):
    root: str
    document: str
    exe: str

path_container = PathContainer("root_path","document_path","exe_path")

root: strなどと書いてあるのは、型ヒントです(pythonで型を指定するための仕様です)。この記法では、型ヒントが必須となっています。

行数は少し伸びますが、PathContainer = namedtuple("PathContainer","root document exe")よりもわかりやすいと感じる方もおられるのではないでしょうか。

更に以下の方法で、簡単にデフォルト値が設定できます。

from typing import NamedTuple

class PathContainer(NamedTuple):
    root: str = "root_path"
    document: str = "document_path"
    exe: str = "exe_path"

path_container = PathContainer()

print(path_container)
# output => PathContainer(root='root_path', document='document_path', exe='exe_path')

筆者自身は、この記法を把握してからは、namedtupleは全てこの書き方で書くようになりました。それと、筆者はPyCharmを使っているのですが、型方式で書くとPyCharmがnamedtupleをきちんとclassとして認識してくれるので、入力補完がばっちりききます。この点も大きな魅力です。

公式の説明はこちらです。

【追記終わり】

パフォーマンス(速度)

速度面のパフォーマンスですが、sqliteと併用すると、初期化が早いというメリットがあります(後記)。
ただ、それ以外の場面でははっきりしたメリットは見つかりませんでした。
計測してみたところ、キーによるデータアクセスは、だいたいdictの2分の1倍速(=2倍の時間がかかる)です。通常のクラスと比較しても低速でした。
インスタンス化の速度もdictより何割か低速です。
実用上問題のあるレベルではないですが、ヘビーなループがあるときなどは、dictを選ぶかもしれません。

sqliteのデータコンテナに活用

sqliteからデータを読み出して、リストに格納することがありますよね。
ここでnamedtupleを使うのはおすすめできそうです。
せっかくですので、少し丁寧に解説します。

書き方

まず結論です。こういう書き方になります。

import sqlite3
from contextlib import closing
from collections import namedtuple

DBPATH = r"C:\xxxxx\yyyyy\dbfile.sqlite"
with closing(sqlite3.connect(DBPATH)) as conn:
    cursor = conn.cursor()
    SQL = "select * from table1"
    cursor.execute(SQL)
    fieldname_list = [field[0] for field in cursor.description]
    RowNamedtuple = namedtuple("RowNamedtuple", fieldname_list)
    rows = [RowNamedtuple._make(row) for row in cursor]    #Rownamedtuple(row)はダメ!

cursor.execute(SQL)でデータを読み出します。その結果から、フィールド名を取得し、それをもとにnamedtupleのファクトリを作成します。そして、ファクトリの._makeを使って、各レコードを初期化する、という流れです。

[field[0] for field in c.description]がちょっと分かりにくいかもしれません。
この部分は、select結果に含まれるフィールド名をリストにして取り出しています。そして、namedtupleの第2引数にそれを渡して、テーブルと同じフィールドを持つnamedtupleを作っています。

c.descriptionは、selectされたフィールド名を格納しています。
じゃあ、field[0][0]はなんだということになるのですが、実はc.descriptionの中身は
( ("filedname1",None,None,None,None,None,None), ("filedname2",None,None,None,None,None,None),... )
という形式のタプルになっているのです。これはPython DB APIとの互換性を保つためだそうです。field[0]でこの0番だけを読み取っています。

仕様についてはドキュメントに解説がありますので参考にしてください。
sqlite3 cursor.description

最後はRowNamedtuple._make(row)となることに注意してください。RowNamedtuple(row)では初期化できません。私はここで30分くらいハマったことがあります。
(デフォルトでは、cursorをイテレートすると、tupleが返ってくるんですね。namedtupleは、引数を1個ずつ渡す必要があるので(可変長引数スタイル)、tupleをそのまま渡しても正しく初期化できないです。
こういう風に、「そのまま渡したい」ときが、まさに_makeの出番になります。)

なお、RowNamedtuple(*row)と書くこともできますが、パフォーマンスは少し低下します。

メリット

同じ場面で、sqlite3.Rowのまま使ったり、更にdictに変換することもできますが、namedtupleには以下のような特徴があります。

  • ドットアクセスができる
  • dictよりも初期化が早い
  • イミュータブルである

1番目、2番目は、はっきりうれしい点です。3番目は場面によってありがたいです。

パフォーマンス

サンプルコードを兼ねて、初期化速度の計測結果を掲げておきます。
計測に用いたsqliteファイルは15000レコードのものです。

コードはこれです。


def read_by_plain():
    with closing(sqlite3.connect(DB)) as conn:
        cursor = conn.cursor()
        cursor.execute(SQL)
        return [row for row in cursor]    #プレーンタプルが返る

def read_by_Row():
    with closing(sqlite3.connect(DB)) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute(SQL)
        return [row for row in cursor]     #sqlite3.Row型が返る

def read_by_Row_to_dict():
    with closing(sqlite3.connect(DB)) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute(SQL)
        return [dict(row) for row in cursor]    #dictが返る

def read_by_plain_to_ntp():
    with closing(sqlite3.connect(DB)) as conn:
        cursor = conn.cursor()
        cursor.execute(SQL)
        RowNamedtuple = namedtuple("RowNamedtuple", [field[0] for field in cursor.description])
        return [RowNamedtuple._make(row) for row in cursor]    #namedtupleが返る

それぞれ、

  1. 特に設定せずに、プレーンなtupleを取得する
  2. sqlite3.Row型を取得する
  3. dictにして取得する
  4. namedtupleにして取得する

という場面を想定しています。
sqlite3.Rowについてはあとで説明します。

計測結果は以下の通りです。%timeitで10回ループしました。

%timeit -r 10 read_by_plain()
1 loop, best of 10: 96.7 ms per loop

%timeit -r 10 read_by_Row()
1 loop, best of 10: 100 ms per loop

%timeit -r 10 read_by_Row_to_dict()
1 loop, best of 10: 150 ms per loop

%timeit -r 10 read_by_plain_to_ntp()
1 loop, best of 10: 106 ms per loop

Rowはほとんどオーバーヘッドが無いようです。そして、namedtupleも大差無い速度になっています。
対して、dictはかなり差が付きました。

ただし、前記のようにキーアクセスの速度はdictのほうが早いですので、パフォーマンスについては一長一短があります。
個人的には、格納にウェートがある場合は、namedtupleのほうが扱いやすいです。たびたび編集するような用途ならdictのほうがもちろん便利です。

補足:sqlite3.Rowについて

sqlite3.Rowというのはsqlite専用のデータ型です。これをconn.row_factoryに渡しておくと、読み出し結果がsqlite3.Rowで返ってきます。
sqlite3.Rowは高度に最適化されているとのことで、パフォーマンスは確かに良好です。更に、キーでもインデックスでもアクセスできますの、namedtupleと似たところがあります。
単に読めればよいという場合、sqlite3.Rowだけでもよいかもしれません。
ただし、機能は限定されていますし、ドットアクセスもできません。私の感覚では、少し物足りない感じはあります。
たとえば、namedtuple_replaceができるあたりなど嬉しいですね。

詳しくは
ドキュメント row_factory
ドキュメント sqlite3.Row
を参照してください。

最後に

namedtupleにはなんだか魅力を感じます。もう少し使い方を研究してみたいです。

なお、投稿者は完全独学プログラマーのため、内容とか、訳とか、言葉使いとか色々間違っているところがあるかもしれません。遠慮なくご指摘いただければ幸いです。

参考情報

いくつかnamedtupleの関連リンクを挙げておきます。

Pythonのdictをnamedtupleに変換してベンチ取ってみた
※ベンチマークがあります。

namedtupleのドキュメント

namedtuple(名前付きタプル)で簡易クラスを作る

おっぱいそん! namedtuple

スタックオーバーフロー

Pythonメモ-07 (collections.namedtuple, 名前付きのフィールドを持つタプル)

428
307
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
428
307

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?