Immutable Python ~ NamedTuple で書く副作用のないプログラム

f:id:nsmr_jx:20191220091445j:plain

この記事はJX通信社 Advent Calendar 2019 の23日めの記事です。
昨日は Yosk さんの 名刺作成をデザイナーの業務から外して、効率化させた話 でした。

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。 今回は Python の Immutable を最大限活用してみる話を書いてみます。


【2019/12/23 訂正 1 】 : NamedTupleを使う際、同値であればオブジェクト自体も同一であると書いてましたがこちらは誤りでした。 個々の id を調べてみると別々のオブジェクトが割り当てられていたため、記事の表現を一部修正しました。

【2019/12/24 訂正 2 】: コメントにて、Java の String オブジェクトも不変であるというご指摘をいただきました。 不変であることとオブジェクトが同一であることの性質を混同してしまっていたため、記事を修正させていただきました。


Immutable ってなに

Immutable (イミュータブル) は「不変」であることを指す英単語です。 日常では出てくることはまずないですが、プログラミングの用語としては時々耳にする名前かもしれません。

プログラミングにおいて「不変」、つまり変わらないというのはどういうことかというと、一度生成したら値を変えることができない もののことを指します。 一方生成後も変更が可能なオブジェクトのことを Mutable (ミュータブル) といいます。

Python でいうと list は可変ですが、str や tuple は不変のオブジェクトになっています。 比較しやすいので list と tuple で比べてみてみましょう。

list

>>> l = [1, 2, 3, 4, 5]  # list

>>> l[1]  # index を指定して読み取り
2
>>> l[:3]  # 範囲を指定して取り出し
[1, 2, 3]
>>> len(l)  # 長さを取得
3

tuple

>>> t = (1, 2, 3, 4, 5)  # tuple

>>> t[1]  # index を指定して読み取り
2
>>> t[:3]  # 範囲を指定して取り出し
[1, 2, 3]
>>> len(t)  # 長さを取得
3

list と tuple、どちらもシーケンス型とよばれるタイプの組み込み型なので、同じような読み出し操作を行うことができます。list や tuple の中身を変更していないので当然といえば当然ですね。

ではこれら2つの変更操作をそれぞれみてみましょう。

list

# 
>>> l[0] = 6   # index を指定して書き換え
>>> l
[6, 2, 3, 4, 5]
>>> l.append(7)  # 末尾に追加
>>> l
[6, 2, 3, 4, 5, 7]
>>> l = [8, 9, 10]  # 変数の使い回し
>>> l
[8, 9, 10]

tuple

>>> t[0] = 6  # index を指定して書き換え
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

>>> t.append(6)  # 末尾に追加
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'tuple' object has no attribute 'append'

>>> t = [8, 9, 10]  # 変数の使い回し
>>> t
[8, 9, 10]

今度は tuple 側がエラーになってしまいました。特定の index に代入しようとすると TypeError が出てます。また末尾に追加する append というメソッドは tuple では用意されていません。

このように 一度生成したら変更する手段がない のが Immutable なオブジェクトの特性となります。 なお3つめの例の変数の使い回しについては成功してます。不変なのはあくまで tuple の実体に対してだけなので、実体を指し示す変数については制約がかかりません。

なぜ Immutable をつかうのか

一見不便に見える Immutable ですが、Immutableだからこその利点がいくつかあります。

同値比較ができる

Immutable であるということは、同じ値を持っていれば同じもののようにふるまうことを示すことができます。 中身が変化しないのでいつどこで参照しても同一のものとして扱うことができます。

代表的な例でいうと文字列ですね。Python では文字列も Immutable なオブジェクトとなっています。 なので

>>> "hoge" == "hoge"
True

というのが常に成立します。

Python を使っているユーザーにとっては当たり前と思うかもしれませんが、Java のような文字列が可変として扱われる言語だと、こういった等式による比較が常に成立するとは限りません。文字列に同値であるかを判定するメソッド equals が用意されており、こちらを使う必要があります。 (Java においても文字列は不変オブジェクトとのことでした。同値であってもオブジェクトが異なるため等式による比較が常に成立するわけではないようです。)

Python でもクラスは可変なオブジェクトになります。 なので

>>> class Hoge:
...     def __init__(self):
...         self.fuga = 'fuga'
...
>>> h1 = Hoge()
>>> h2 = Hoge()
>>> h1 == h2
False

のようにインスタンスが同じ値を持っていたとしても、等式による比較は失敗してしまいます。

ところが後述する NamedTuple というものを使うと、値が一致していれば等しいと見なすオブジェクトを作成することが可能です。

>>> from typing import NamedTuple
>>> class Hoge(NamedTuple):
...     fuga: str = 'fuga'
...
>>> h1 = Hoge()
>>> h2 = Hoge()
>>> h1 == h2
True

NamedTuple は tuple の一種で、クラス初期化のタイミングでしか値をセットすることが出来ません。

Immutable なオブジェクトの場合、中身の値が一致していればオブジェクトも同値であることを保証できます。 (__eq__ というメソッドをオーバーライドすることで普通のクラスでも同値による比較が可能になりますが、本筋ではないので割愛します)

変化を気にしなくていい

Immutable 最大のメリットとなるのがこの性質です。一度生成したオブジェクトの変化を気にしなくていいので安全なプログラミングを書くことが可能になります。

def print_last(l: list):
    """ Listの一番後ろを表示する関数 """
    last = l.pop()
    print(last)

l = [1, 2, 3]
print_last(l)  # => 3

print(l)  # => [1, 2] 
# l 自体が変化してしまっている

(いい例が思いつかなくて微妙なサンプルになってしまってますが、)プログラムを書いていて上記みたいなパターンではまったことはないでしょうか。 print_last という画面表示を行うための関数を作っていますが、この関数は値を読み取るだけではなく、渡した配列を変化させてしまっています。 そのため呼び出し前と呼び出し後で予期せず配列の中身が変わってしまっているわけですね。 こういった関数で多かれ少なかれ苦い経験をしたことはあるのではないでしょうか。

受け取った引数を変更する関数は破壊的メソッドと呼ばれており、取り扱う際は注意して使う必要があります。 (この例でいうと pop というリスト用のメソッドが破壊的な操作になっています。)

こういった副作用の問題は リストが変更可能であるからこそ起こっている ともいえます。 変更不可能な Immutable オブジェクトを引数に利用していれば、呼び出し元を変化させるような手段が用意されていないため安全に利用することができます。

def print_last(t : tuple):
    # t.pop() のような破壊的メソッドはそもそも存在しない
    t = t[-1]   # また、仮に t に再代入しても呼び出し元は変化しないので安全  (これはリストでも同じ)
    print(t)

t = (1, 2, 3)
print_last(t)  # => 3

print(t)  # => (1, 2, 3) 
# t は変化していない

ハッシュ化が可能

同値比較ができるということに起因するのですが、Immutable なオブジェクトであればハッシュ化して扱うことができます。 ハッシュ化とは何か、説明するよりコード見てもらった方が早いかもしれません。

>>> l = [1, 2, 3]  # list
>>> t = (1, 2, 3)  # tuple

>>> d = {}

>>> d[l] = "これは list です"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

>>> d[t] = "これは tuple です"
>>> d
{(1, 2, 3): 'tuple'}

見慣れてないと不思議に思うかもしれませんが、Immutable であるタプルは辞書(dict)のキーとして利用することが出来ます。 可変である List のほうは dict のキーとして使おうとすると TypeError が発生してますね。 定義後に値が変わらないので、内部的にオブジェクトのハッシュ値を割り当てれば、その値から一意のオブジェクトが復元できるため、こういったことが可能になります。 ちょうど git のコミットハッシュから一意の変更が取り出せるのも似た理屈です。

これで何がうれしいかというと、オブジェクトのまま重複チェックなどが利用できるようになります。 クラスのインスタンスのまま重複チェックをしようとすると、重複確認用のキーを生成して比較することになりますが、Immutable なオブジェクトであれば同値のものをそのまま重複と見なして扱うことが出来ます。

Hello Immutable Python

Immutable について紹介したのでより具体的に Python で扱っていく方法を紹介していきます。

Python で使える Immutable 要素

Python で主に使える Immutable な要素は以下になります

  • 真偽値(bool)
  • 数値(int, float, Decimal)
  • 文字列(str)
  • タプル(tuple, NamedTuple)

あとは bytes だったり、Frozen(=変更不可な)属性を持った特別なオブジェクトfrozenset, Dataclass(frozen=True) なんかもあります。

これらを活用していくことで Immutable で副作用のないプログラムを書いていくことが可能です。 なお、tuple や NamedTuple を使う場合でも中に入れるオブジェクトを Mutable にしてしまうと、完全な Immutable とはいえないので注意が必要です。

具体例をみていきましょう。

listを捨ててtupleへ

list は mutable なので immutable な tuple へと置き換えます。

mutable = [1, 2, 3]
immutable = (1, 2, 3)

シンタックスも似てるので簡単ですね。immutable = tuple(mutable) のように tuple 関数を利用して変換することも可能です。 ちなみに要素が一つしかない tuple は (1) ではなく (1, ) と書かなくてはならない罠があります。

要素追加

immutable = immutable + (4, 5)

immutable なタプルには要素を追加するためのメソッドがありません。 なので、要素を追加したいときには新しいタプルを生成してあげる必要があります。 繰り返しになりますが、再代入は Immutable の制約ではないので問題ありません。

Typeヒント

from typing import Tuple

def hoge(t: Tuple[int, ...]):
     ...

Tuple の型ヒントを書いてあげるときはこんな感じになります。List の場合 List[int] と書けば整数のリストであることを表現できますが、Tuple の場合は , ... という表記で同じ型が続くことを明記してあげる必要があります。

dict を捨てて NamedTuple へ

dict もmutable なのでこちらも immutable にしたいですね。frozendict のようなものは標準パッケージに存在しないので、Immutable に辞書を扱うには別のアプローチをとる必要があります。

個人的におすすめしたいのが NamedTuple です。

NamedTuple とは

Python には NamedTuple と呼ばれる、順序でなく名前でアクセスできる tuple が用意されています。 これだけだと使い勝手そんなによくないのですが、Python 3.6.1 から typing モジュールにクラス風にかける NamedTuple が追加されました。 こちらを使うと簡単に構造化した Tuple を生成することが出来ます。 また型ヒントもしっかり活用することができるのでより安全なプログラムを意識して作れるようになります。

from typing import NamedTuple

class Hoge(NamedTuple):
    fuga: str
    piyo: int

h1 = Hoge(fuga='fuga', piyo=1)
h2 = Hoge(fuga='fugafuga', piyo=2)

見た目は普通のクラスに近いですね。実際、クラスと同じようにメソッドを生やしたりできます。 NamedTuple を継承してつくることと、init を使わずクラス変数のように型付きのフィールドを列挙してあげれば作成が出来ます。

>>> h1.fuga  # 値の取得
'fuga'

>>> h1.fuga = 3  # 値の変更
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: cant set attribute

>>> h1._replace(fuga=3)  # 値を変更した新しい tuple をつくる
Hoge(fuga=3, piyo=1)

tuple の要素にドット付きでアクセスすることができますが、値を変更しようとすると Immutable なので怒られます。一部の要素を変えた tuple を手に入れたい時は _replace というメソッドが定義されています。こちらを呼ぶとと元の tuple を破壊せず新しい tuple を生成出来ます。

from typing import Tuple, NamedTuple

class User(NamedTuple):
    name: str

class Group(NamedTuple):
    users: Tuple[User, ...]

u1 = User(name='taro')
u2 = User(name='hanako')
g = Group(users=(u1, u2))
# => Group(users=(User(name='taro'), User(name='hanako')))

構造化されたデータもこの通り。

動的にキーが変わるようなものは dict でないと対応が厳しいですが、API のレスポンスなど事前に形式が決まっているものであれば NamedTuple を活用して immutable なコードを書くことが出来ます。

実践 Immutable

Immutable に書くためのパーツがそろったので、実際に Immutable でどんなコードが書けるのかを紹介したいと思います。

本当はもっと Immutable が生かせるお題見つけたかったのですが、全然思いつかなかったので Qiita の新着アイテム API を叩いて、タイトルの長い順にソートして出力する というようなことをやってみます。

Qiita の API についてはこちら。 このエンドポイントは認証なしで叩くことができます。

NamedTupleを定義する

from typing import NamedTuple

class User(NamedTuple):
    id: str

    @classmethod
    def from_dict(cls, d: dict) -> 'User':
        return cls(id=d['id'])


class Item(NamedTuple):
    title: str
    url: str
    user: User
    created_at: str

    @classmethod
    def from_dict(cls, d: dict) -> 'Item':
        return cls(
            title=d['title'],
            url=d['url'],
            user=User.from_dict(d['user']),
            created_at=d['created_at']
        )

Qiita のレスポンスに従って、使う要素をまず NamedTuple で構造化します。 扱いやすいように from_dict() というクラスメソッドを各 NamedTuple に定義してあげて、dict から NamedTuple に変換できるようにしています。 from_dict 自体は NamedTuple の機能でもなんでもないので、好きなやり方で作ってもらって大丈夫です。

あれ、dict つかうの?と思うかもしれませんが、Python の場合、json.loads() の結果が dict で返ってくるので、NamedTuple に変換するまでは mutable な dict で受け渡しする必要があります。

API を叩いて Immutable なオブジェクトを手に入れる

from typing import Item

import requests

QIITA_API_ENDPOINT = 'https://qiita.com/api/v2/items'


def fetch_qiita_items(page: int = 1) -> Tuple[Item, ...]:
    res = requests.get(QIITA_API_ENDPOINT, {'page': page})
    res.raise_for_status()
    return tuple(
        Item.from_dict(item) for item in res.json()
    )

requests という外部ライブラリを使って JSON をとってきます。 とってきた JSON から先ほどの Item.from_dict() に1つづつ渡してオブジェクト化していきます。 最後に Item の配列(厳密には generator)を tuple に変換してあげれば Immutable な Qiita 記事一覧の完成です。

文字数順にソートして出力する

ここまでくればあとは煮るなり焼くなりするだけですね。 PyCharm などの IDE を使っていれば型ヒントによる補完機能が効くのでいちいち JSON の構造を覚えてなくてもサクサクコーディングできるようになります。

def sort_by_title_length(items: Tuple[Item]) -> Tuple[Item, ...]:
    """ タイトルの長さでソートした Item の配列を返す """
    return tuple(
        sorted(items, key=lambda i: len(i.title), reverse=True)
    )


def format_for_display(item: Item) -> str:
    """ 出力用に整形する """
    return f'''{item.title}
        URL: {item.url}
        Author: {item.user.id}
        CreatedAt: {item.created_at}'''


def main():
    items = fetch_qiita_items()
    items = sort_by_title_length(items)
    for item in items[:5]:
        print(format_for_display(item))

sort_by_title_length は tuple を並び替える関数ですが、引数自身を並び替えるのではなく、並び替え済みの新しい tuple を生成して返しているので副作用がなく安全です。 なお、Python の sorted を通すと Generator 型になってしまうので明示的に tuple に変換しています。

これでプログラムが完成したので実行してみましょう。

f:id:nsmr_jx:20191218190627p:plain

ちゃんと表示されてますね。お疲れさまでした。

全体のソースはこちらに置いておきます。

Immutable を意識して書く Python はいかがでしたか。 値の変更が発生しないことで、バグが起こりにくい安全なシステムを作ることが出来ます。

おまけ

実際 Immutable ってどうなの

イントロダクションの記事なので意図して Immutable 化して使ってみましたが、実際のコーディングでここまで頑張って Immutable 化する必要はないと思ってます。 実際 Python の関数通すと簡単に dict とか generator に戻ってしまうので、都度 Immutable 化するのは結構手間です。 関数内で可変な引数をいじらない くらいのルールに留めておくのが現実的なラインではないでしょうか。 Immutable なオブジェクトを利用すると言語レベルで制約をかけれますが、可変なオブジェクトでも扱いを気をつけるだけで安全性が変わってきます。

どちらかというと NamedTuple で構造化したり、関数に型ヒントを記述することで得られるメリットのほうが大きいです。 最近のエディタは賢いので構造化されたデータや型ヒントを活用して安全かつ楽にコーディングが出来るようになります。 型ヒント書きましょう!

NamedTuple VS DataClass

好みの問題で NamedTuple を紹介しましたが、Python3.7 からは Dataclass というちょっと便利なクラスを使うことが出来ます。オプションで frozen=True とすれば変更不可になるのでこちらも Immutable なオブジェクトとして扱うことができます。

便利なメソッドが用意されていたり、若干読み取り速度が速かったりするらしいので Python3.7 以降を使うのであれば積極的に使うのがよさそうです。

ただ Immutable として使うには frozen=True が冗長だったりするので自分は NamedTuple 派です。Mutable として値を書き換えて使うときは Dataclass を使わないてはないでしょう。

from_dict みたいにいちいち変換するの面倒くさい

そんなこともあろうかと、dict から NamedTuple にマッピングするライブラリを作ってみました。 階層化した NamedTuple も一発で変換できます。 (プロダクションに投入した実績はないのでご利用の際は自己責任でお願いします。)

本文中のスクリプト

説明で使ったスクリプトの ipynb 張っておきます。


明日のアドベントカレンダー@shinyorke さんの K8s デビューまわりのお話です。