病みつきエンジニアブログ

機械学習、Python、Scala、JavaScript、などなど

Python の型システムの上で Immutable な Python プログラムを作る

まえがき

今年の PyCon JP 2020 にて「Python 3.9 時代の型安全な Python の極め方」というタイトルで登壇させていただきます。本稿は、発表の補足となる「型ヒントを使って Immutable な Python を実現する方法」について紹介したものです。

Python の型ヒント

Python には「型ヒント」という機能があり、型をプログラム内に宣言することができます。

age: int = 28
name: str = 'Bruce Wayne'

Python は動的型付き言語であるため、この情報はランタイム(実行時)にはあまり意味がないのですが、 mypy などの型チェックツールをつかうと、型の誤りをチェックすることができます。

def check_batman(name: str) -> bool:
    return name == 'Bruce Wayne'

age = 28
check_batman(28)  # NG: 文字列型の引数に int を入れているため

さて、この機能をつかうと、 list や set のような mutable (要素が書き換え可能) なデータ構造であっても、型システム上においては imutable なものとして扱うことができます。ランタイム時は基本的なデータ構造である list や set などを使い続けながらも、型のチェック時だけ書き換えを防ぐことができるのです。

Immutable Python とは

例えば、 list を immutable にしたいというのは、こういうことを防ぐのが目的です。

def check_list(li: list):
    li.append('fuga')  # ← 勝手に書き換えないでえええええ

users = []
if check_list(users):  # ← 意図せず書き換わってしまう
    ...

さすがにこんなにことはしないと思いますが、例えば numpy の shuffle や JavaScript の sort など、配列に変更を加えて嫌な気持ちになるケースは結構紛れています。

これをランタイム(実行時)時に Immutable にする方法もあります。同僚の書いた記事をご覧ください。

tech.jxpress.net

しかし、あくまでデータ構造としては一般的なものを使い続けたり、ランタイムの挙動を変えずに Immutable にしたいこともあると思います。今回は クラスやリスト等のデータ構造が不変な状態を保つこと を目的とし、変数の書き換え防止や副作用全般のコントロール、ランタイム時の Immutability などは目的としていません。

Immutable な list とは

例えば、list に対する破壊的な変更は、次のようなものがあります。

name_list.append('New User')
name_list[1] = 'Renamed User'

つまり、変更できるメソッドが生えているから、list が変更できてしまうのです。逆に、変更できるメソッドが生えていない list を型宣言すれば、その list は型システムの世界においては実質 immutable です。

Protocol による型宣言

Python では 3.8 から Protocol という「継承によらない部分型」を宣言できるようになりました。つまり「何を継承してるか(名前的部分型)じゃなくて、何を持っているか(構造的部分型)で自分を語れよ!」ということです。

from typing import Protocol
class Vehicle(Protocol):
    def run(self):
        ...

class BatMobile:
    def run(self):
        ...

def run_vehicle(v: Vehicle):
    v.run()

run_vehicle(BatMobile())  # 継承してないけどOK

ここで、BatMobile と Vehicle の間には継承関係はありません(名前的部分型でない)。しかし、 run という共通のメソッドを持っていて、関数内ではそれを呼び出しています。したがって、構造的部分型としては OK なのです。

では「Immutable な list となるような構造的部分型」を定義してみましょう。「Immutable な list」は、「'a' in hoge」とか「for x in hoge」とかはできるのに、「 hoge[0] = fuga 」 はできないような list です。これらは Python では __contains__ や __iter__ や __setitem__ といったメソッドを class に定義いしてあげることで実現できます。

from typing import Protocol

class ImmutableList(Protocol):
    def __contains__(self, x):
        ...
    def __iter__(self):
        ...

def check_list(li: ImmutableList):
    print('a' in li)  # OK
    for x in li:  # OK
        ...
    li.append('fuga')  # NG

users: ImutableList = []
if check_list(users):
    ...

今回は ImmutableList というのを作ってみましたが、 Python 公式で用意されている ので、これを使いましょう。

from typing import Sequence

def check_list(li: Sequence):
    print('a' in li)  # OK
    for x in li:  # OK
        ...
    li.append('fuga')  # NG

users: ImutableList = []
if check_list(users):
    ...

同様に、Mapping (immutable な dict 相当)やSet(immutable な set 相当)なども用意されています。

Immutable な class

dataclass(frozen=True) や NamedTuple をつかうと、mypy はチェックしてくれます。もちろん、ランタイム上でも immutable になります。

from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    name: str

batman = User(name='Bruce Wayne')
batman.name = 'Dick Grayson'  # NG

この方式の微妙な方法

__contains__ を持っているかどうかでしか宣言できないので、「真に set や dict がほしい、immutable で」というケースには使えません。例えば、 x in [] と x in {} では、計算量が違うので、contains を持っていても list は困る、という場合には使えません。

型の上で Immutable なデータ構造のメリット

と、ここまで書いて「Immutable な Python って意味あるの?」という疑問を持たれたかもしれません。このメリットは3つあります。

一つめは、意図しない副作用を発生させない(安全なプログラムを作る)という観点です。

2つめの理由は、list などのジェネリクスは、共変ではないということです。例えば、「犬リスト」は「動物リスト」として扱えるでしょうか?

from typing import List

class Animal:
    ...

class Dog(Animal):  # 継承関係にある
    ...

def check_animal(animal: List[Animal]):
    ...

dogs = [Dog()]
check_animal(dogs)  # NG: List[Dog]はList[Animal]として扱えない!

実は、「犬リスト」は「動物リスト」として扱うことはできません。なぜかというと、 check_animal の中で勝手に「うさぎリスト」等に書き換えてしまことができるからです。逆に、書き換えられないようにしてあげれば、「犬リスト」を「動物リスト」として扱うことができます。

from typing import Sequence

class Animal:
    ...

class Dog(Animal):  # 継承関係にある
    ...

def check_animal(animal: Sequence[Animal]):  # List から Sequence に変えただけ
    ...

dogs = [Dog()]
check_animal(dogs)

ある程度 Python で型ヒントをちゃんと書いていくと、ジェネリクスのこのあたりの挙動に引っかかることがあるかと思います。

(追記) 3つめのメリットとして、「デフォルト引数にミュータブルなものを入れてバグる」ということがなくなります。デフォルト引数には [] などは入れず、None などのイミュータブルなものだけ入れるべきというプラクティスがありますが、型チェックをするならばこのプラクティスは意味がなくなります。

宣伝

...みたいな Python の型の話を、PyCon JP 2020 にて「Python 3.9 時代の型安全な Python の極め方」というタイトルで 8/28(金) 11:50〜 発表します。チケットを買ってない方も、YouTube にて生配信を見れるとのことです。

pycon.jp

同僚の id:shinyorke も同日 16:50〜 「スポーツデータを用いた特徴量エンジニアリングと野球選手の成績予測 - PythonとRを行ったり来たり」というタイトルで発表がありますので、こちらも是非ご覧ください!