📑

Writing Python like it’s Rustの紹介・邦訳

2023/08/04に公開

いきなりですが,「これ,なーんだ!?」のお時間です.
top




















正解は,くまモンって眉毛あるんだ
じゃなくて,私が研究室内に設置したトラップです.
見た人は当該ブログを読まなくてはなりません.
それがこちらです.
https://kobzol.github.io/rust/python/2023/05/20/writing-python-like-its-rust.html

Pythonで疲弊しているそこのあなたも,読みましょう読みましょう.
ただ,英語が母国語ではない人間に英語の記事を投げつけて「オラ読めぇ!!!」というのは横暴だなぁ,という気がしてきたので,ここで和訳を提供しようと思い至りました.

私の翻訳が不正確な場合もあると思いますので,是非Kobzolさんによる原文も読んでいただければ.

では,失礼して,

邦訳「RustのようにPythonを書く」

私がRustによるプログラミングを始めたのは数年前のことだが,このことが他の言語(特にPython)での私のプログラム設計指針を徐々に変えていった.
Rustを始める前は型ヒントも使わず,いつでも関数の引数や返り値に辞書(dict)を使い,時たま"文字列型指向"に頼るという,非常に動的で型の制約が緩いコードを書いていた.しかし,Rustの型システムの厳格さを経験し,それが"構造的に"防止している全ての問題に気付いた時,Pythonに戻った時に同様の保証がされないことに,私は突如とても不安になるようになった.

誤解のないように言うと,ここで言う"保証"とは,メモリ安全性のことではなく(Pythonはもともとメモリ安全),"健全性" - APIの誤用が非常に困難あるいは不可能になるように設計し,ひいては,未定義の挙動や各種のバグを防ぐという概念を指している.Rustではインターフェースの使用が不正確であると,通常はコンパイルエラーが発生する.Pythonではこの類の不正確なプログラムも実行できてしまうが,(pyrightのような)型チェッカーや,(PyCharmのような)型解析器付きのIDEを使用すると,起こりうる問題について同様のレベルの迅速なフィードバックを得ることができる.

最終的には,私はPythonのプログラムでRustの概念を一部採用するようになった.それは基本的には2つの原則に要約できる

私はこれを一定期間保守されるようなプログラムだけではなく,一度きり使うようなスクリプトに対しても試みる.なぜなら,経験上後者はかなりの頻度で前者に変化するためである.私の経験からすると,このアプローチはより理解しやすく変更しやすいプログラムにつながっている.

本稿ではPythonのプログラムに適用したそのようなパターンのうち,いくつかを例示する.これらは高度なテクニック (rocket science) ではないものの,文書化しておくことが有用な部類だと感じている.

型ヒント

まず最初に重要なことは,可能な箇所(特に関数のシグネチャとクラスの属性)では型ヒントを使用することである.例えば,私は以下のような関数のシグネチャを読む時,

def find_item(records, check):

これ自体からは何が起こるかわからない.recordは配列か?辞書か?はたまたデータベースの接続か?checkは真偽値か?それとも関数か?そもそもこの関数は何を返すのだろう?処理に失敗したら例外を吐くのか?それともNoneが返るのだろうか?こうした疑問の答えを見つけるには,関数の中身を見る(しばしばその中で呼ばれている他の関数の本体も再帰的に読む必要がある - これはかなり厄介)か,(もしあれば)ドキュメンテーションをあたる必要がある.ドキュメンテーションにはその関数が何をするかについての有用な情報が含まれているかもしれないが,それを前述の疑問に答えるためのドキュメンテーションとして使用する意義はない.前述した疑問の多くは,組み込みのメカニズム - 型ヒントによって答えることができる.

def find_item(
  records: List[Item],
  check: Callable[[Item], bool]
) -> Optional[Item]:

シグネチャを書くのに時間がかかった?それはそうですね.でもそれが問題になりますか?ならないでしょう.コーディングにかかる時間のボトルネックが1分あたりにかける文字数であるような場合を除いて,そんなことは実際には起こらない.型を明示的に書くことで,関数が提供する実際のインターフェースが何であるか,そしてそれを可能な限り厳格にするにはどうすればいいか,即ち,その呼び出し元がそれを間違った方法で使用するのを難しくするにはどうすればいいかを考えることを強制される.上記のシグネチャを使えば,関数の使用方法,引数として何を渡すべきか,そして何が返されることが期待されるかについて,かなりよい見通しを得ることができる.さらに,コードが変更されたときに簡単に期限切れになるドキュメントコメントとは異なり,型を変更した上で関数の呼び出し元を更新しなければ,型チェッカーが私に警告してくれる.そして,私がItemが何であるかに興味がある場合,go to definitionを使用してその型がどのように見えるかをすぐに確認することができる.

しかし,私はこの点については専制主義者ではない.1つのパラメータを説明するために5つのネストされた型ヒントが必要な場合,大抵はあきらめて,それをよりシンプルで,ただし正確ではない型にする.私の経験上,このような状況はそれほど頻繁には起こらない.そして,もしそうなった場合,それは実際にはコードに問題があることを示すシグナルであることがある - あなたの関数のパラメータが数値,文字列のタプル,あるいは文字列を整数にマッピングする辞書のいずれかである場合,それはあなたがリファクタリングしてそれを単純化することを検討するべきかもしれないという兆候である.

タプルや辞書型の代わりにデータクラスを使う

型ヒントを用いることは一つの手だが,それは単に関数のインターフェースが何であるかを記述するだけに過ぎない.次のステップは,それらのインターフェースをできるだけ厳密で"ロックダウン"されたものにすることだ.典型的な例は,関数から複数の値(または単一の複雑な値)を返すことである.怠け者の早道はタプルを返すことだ.

def find_person(...) -> Tuple[str, str, int]:

素晴らしい,ここからは3つの値が返ることがわかる. ...こいつらはなんだ?最初の文字列はファーストネームか?次の文字列がサーネーム?この整数はなんだ?年齢?何らかの配列の位置を示すのか?社会保障番号?この種の型指定は不透明であり,関数本体を見ない限り,ここで本質的に何が起こるのかはわからない.

次のステップとしてこれを"改善"してみよう.辞書を返すように実装できる.

def find_person(...) -> Dict[str, Any]:
    ...
    return {
        "name": ...,
        "city": ...,
        "age": ...
    }

これで返り値のそれぞれの属性が何を指すのかがわかるようになった.が,属性の数も,それぞれの型もわからないのでまた関数本体を調べなければならない.ある意味で,型はさらに悪化してしまった.さらに言えば,この関数が変更されて返される辞書のキーが改名されたり削除された場合,その変更を型チェッカーを使って簡単に調べる方法はない.したがってその呼び出し元は通常,非常に面倒な run-crash-modify code cycle (実行しては壊れていたところを直してを繰り返す) を通して手動で変更しなければならない.

正当な解決策は,型付けされたパラメータを持つ強く型付けされたオブジェクトを返すことである.Pythonでは,これはクラスを作成する必要があることを意味する.私はタプルや辞書がこれらの状況で頻繁に使われるのは,クラスを定義する(そしてその名前を考える),パラメータ付きのコンストラクタを作成する,パラメータをフィールドに格納するなどよりもずっと簡単だからだと思う.ただし,Python 3.7以降(そして早ければパッケージのpolyfillで)は,もっと早い方法であるデータクラスがある.

@dataclasses.dataclass
class City:
    name: str
    zip_code: int


@dataclasses.dataclass
class Person:
    name: str
    city: City
    age: int


def find_person(...) -> Person:

作成されたクラスの名前は考えなければならないが,それ以外は非常に簡潔かつ,すべての属性に型アノテーションが付けられる.

このデータクラスを使えば,関数が何を返すかの明示的な記述を持つことになる.この関数を呼び出して返された値を操作するとき,IDEの自動補完はその属性の名前と型を表示する.これは些細なことに聞こえるかもしれないが,私にとっては大きな生産性の向上だ.さらに,コードがリファクタリングされて属性が変更されると,IDEと型チェッカーは私に警告を投げ,プログラムを全く実行せずとも変更しなければならないすべての場所を表示してくれる.一部の単純なリファクタリング(例えば,属性の改名)では,IDEはこれらの変更を自動的に行える.さらに,(Person,Cityなどの)明確に名前が付けられた型を使用すれば,用語の語彙を作成し,それを他の関数やクラスと共有することができる.

TypedDictNamedTupleといった別の解決策もある.

代数的データ型

私が主流の言語のほとんどにおいて欠落していると考えるものが,Rustの代数的データ型(ADTs)だ.これは,私のコードが取り扱うデータの形を明示的に記述するための信じられないほどに強力なツールである.例として,Rustでパケットを扱っているとき,私は受信できるさまざまな種類のパケットを明示的に列挙し,それぞれに異なるデータフィールドを割り当てることができる.

enum Packet {
  Header {
    protocol: Protocol,
    size: usize
  },
  Payload {
    data: Vec<u8>
  },
  Trailer {
    data: Vec<u8>,
    checksum: usize
  }
}

そしてパターン・マッチを使えば,個々のバリアントに対応することができるし,コンパイラがどんなケースも見逃さないようチェックしてくれる.

fn handle_packet(packet: Packet) {
  match packet {
    Packet::Header { protocol, size } => ...,
    Packet::Payload { data } |
    Packet::Trailer { data, ...} => println!("{data:?}")
  }
}

これは,無効な状態を表現不可能にする上で非常に価値があり,それによって多くのランタイムエラーを避けることができる.代数的データ型は,共通の名前を持った枠組みで一連の型を統一して扱いたい場合,特に静的型付け言語で有用である.代数的データ型がなければ,こうした操作は通常オブジェクト指向プログラミングのインターフェースか継承を使用して行われる.インターフェースと仮想メソッドは,使用される型の集合が有限ではない場合には役立つが,型の集合が有限であり,可能なすべてのバリアントを処理できることを確認したい場合,代数的データ型とパターンマッチングははるかに適している.

Pythonなどの動的型付け言語は,一連の型のための共通した名前を持つ必要は基本的にない.主にプログラムで使用される型を最初に名前付けする必要がないからだ.だが,ユニオン型を作成することで代数的データ型に似たものを使用することは依然として有用である.

@dataclass
class Header:
  protocol: Protocol
  size: int

@dataclass
class Payload:
  data: str

@dataclass
class Trailer:
  data: str
  checksum: int

Packet = typing.Union[Header, Payload, Trailer]
# or `Packet = Header | Payload | Trailer` since Python 3.10

ここでのPacketは,ヘッダ,ペイロード,またはトレイラパケットのいずれかになる新しい型である.私はこの型(名前)をプログラムの残りの部分で使用することができる.そこではこれら三つのクラスのみが有効になることを確認したい.クラスには明示的な"タグ"が付けられていないため,それらを区別したいときには,例えばinstanceofやパターンマッチングを使用しなければならないことに注意されたい.

def handle_is_instance(packet: Packet):
    if isinstance(packet, Header):
        print("header {packet.protocol} {packet.size}")
    elif isinstance(packet, Payload):
        print("payload {packet.data}")
    elif isinstance(packet, Trailer):
        print("trailer {packet.checksum} {packet.data}")
    else:
        assert False

def handle_pattern_matching(packet: Packet):
    match packet:
        case Header(protocol, size): print(f"header {protocol} {size}")
        case Payload(data): print("payload {data}")
        case Trailer(data, checksum): print(f"trailer {checksum} {data}")
        case _: assert False

残念ながら,ここでは不正なデータを受け取った際に受け取った際にクラッシュするよう,面倒なassert Falseの分岐を設けなければならない(というより,設けるべきだ).Rustではこれはコンパイルの時点でエラーになる.

注:Redditで幾人かが私に思い出させてくれたが,assert Falseは最適化ビルドでは完全に最適化されてしまう(python -O ...).したがって,直接例外を送出する方が安全である.また,Python 3.11からはtyping.assert_neverがあり,これは型チェッカーにこのブランチに落ちるべきは"コンパイル時"エラーであることを明示的に伝える.

ユニオン型の良い特性は,それがユニオン型の一部となるクラスの外部で定義されていることだ.そのため,クラスは自身がユニオン型に含まれていることを知らない,これによりコードの結合度が低下する.そして,同じ型を使用して複数の異なるユニオン型を作成することもできる.

Packet = Header | Payload | Trailer
PacketWithData = Payload | Trailer

ユニオン型は自動シリアライズにも非常に便利である.最近,私はRustのシリアライゼーションフレームワークであるserdeをベースにした素晴らしいシリアライゼーションライブラリであるpyserdeを見つけた.他の多くのクールな特性の中でも,このライブラリは型注釈を活用して,追加のコードなしでユニオン型をシリアライズおよびデシリアライズしてくれる.

import serde

...
Packet = Header | Payload | Trailer

@dataclass
class Data:
    packet: Packet

serialized = serde.to_dict(Data(packet=Trailer(data="foo", checksum=42)))
# {'packet': {'Trailer': {'data': 'foo', 'checksum': 42}}}

deserialized = serde.from_dict(Data, serialized)
# Data(packet=Trailer(data='foo', checksum=42))

serde同様,ユニオンタグのシリアライズ方法を選択することさえできる.私は長い間類似の機能を探していた,なぜならユニオン型を(逆)シリアライズすることは非常に便利だからだ.しかし,私が試したほとんどの他のシリアライゼーションライブラリ(例えばdataclasses_jsondacite)でそれを実装することはかなり面倒だった.

例えば,機械学習モデルを扱うとき,私はユニオン型を使用してさまざまな種類のニューラルネットワーク(例えば,分類またはセグメンテーションCNNモデル)を単一の設定ファイル形式内に保存する.また,以下のようにデータ(私の場合は設定ファイル)の異なるフォーマットをバージョン管理するのにも便利だと感じた.

Config = ConfigV1 | ConfigV2 | ConfigV3

Configをデシリアライズすることで,私は設定フォーマットのすべての以前のバージョンを読み込むことができ,したがって後方互換性を保持することができる.

NewTypeを使う

Rustでは,新たな振る舞いを追加せずとも,それ以外の非常に一般的なデータ型(例えば整数型)のドメインや意図された使用法を指定するために,データ型を定義することがよくある.このパターンは"NewType"と呼ばれ,Pythonでも使用できる.以下に,このパターンが活躍する状況の例を挙げる.

class Database:
    def get_car_id(self, brand: str) -> int:
    def get_driver_id(self, name: str) -> int:
    def get_ride_info(self, car_id: int, driver_id: int) -> RideInfo:

db = Database()
car_id = db.get_car_id("Mazda")
driver_id = db.get_driver_id("Stig")
info = db.get_ride_info(driver_id, car_id)

エラーは見つけられただろうか?

...

...

正解はget_ride_infoの引数が入れ替えられていることだ.意味的には関数呼び出しが間違っているのだが,型エラーは起こらない.car IDsもdriver IDsも単に整数型であるため,型としては正しいのである.

この問題は,"NewType"で異なる種類のIDに対して別々の型を定義することで解決できる.

from typing import NewType

# Define a new type called "CarId", which is internally an `int`
CarId = NewType("CarId", int)
# Ditto for "DriverId"
DriverId = NewType("DriverId", int)

class Database:
    def get_car_id(self, brand: str) -> CarId:
    def get_driver_id(self, name: str) -> DriverId:
    def get_ride_info(self, car_id: CarId, driver_id: DriverId) -> RideInfo:


db = Database()
car_id = db.get_car_id("Mazda")
driver_id = db.get_driver_id("Stig")
# Type error here -> DriverId used instead of CarId and vice-versa
info = db.get_ride_info(driver_id, car_id)

これは,他の方法では見つけにくいエラーを捕捉するのに役立つ非常にシンプルなパターンだ.特に,多くの異なる種類のID(CarIdDriverId)や,混合すべきでないいくつかの指標(Speed vs Length vs Temperatureなど)を扱っている場合などに非常に有用である.

コンストラクション関数の使用

私がRustについて特に気に入っている点の一つは,それが本来の意味でのコンストラクタを持っていないことだ.代わりに,通常の関数を使用して(理想的には適切に初期化された)構造体のインスタンスを作成する傾向にある.Pythonには,コンストラクタのオーバーロードがない.そのため,複数の方法でオブジェクトを構築する必要がある場合,異なる方法の初期化のために用意された大量の引数を持つ__init__メソッドにつながる,そして,それらの引数は実際には一緒に使用することはできなかったりする.

私はコンストラクタの代わりに,どのようなデータからどのようにオブジェクトを構築するのかを明確にする名前を持った"コンストラクション"関数を作成するのが好きだ.

class Rectangle:
    @staticmethod
    def from_x1x2y1y2(x1: float, ...) -> "Rectangle":
    
    @staticmethod
    def from_tl_and_size(top: float, left: float, width: float, height: float) -> "Rectangle":

こうすればオブジェクトの構築がよりきれいになり,クラスを使う側がオブジェクトを構築する際に無効なデータを渡すこと(例えば,y1とwidthを組み合わせる誤用)を防げる.

型を利用して不変性をエンコードする

型システム自体を使って,それ以外ではランタイムでのみ追跡される不変性をエンコードするというのは,非常に一般的で強力な概念だ.Python(しかし,他の主流の言語でも)では,私はしばしば,大きな複雑な状態の塊を持つクラスを見かける.この混乱の一つの源は,オブジェクトの不変性をランタイムで追跡しようとするコードである.型システムによって不可能にされていないために理論的に起こり得る多くの状況を考慮しなければならない(例:"クライアントが切断を求められて,今,誰かがそれにメッセージを送ろうとしているが,ソケットはまだ接続されている").

Clientの例

以下が典型的な例である.

class Client:
    """
    Rules:
    - Do not call `send_message` before calling `connect` and then `authenticate`.
    - Do not call `connect` or `authenticate` multiple times.
    - Do not call `close` without calling `connect`.
    - Do not call any method after calling `close`.
    """
    def __init__(self, address: str):

    def connect(self):
    def authenticate(self, password: str):
    def send_message(self, msg: str):
    def close(self):

…簡単だろう?ただ,注意深くドキュメンテーションを読んで,上記のルールを決して破らないようにすればいいだけだ(失敗すれば,未定義の挙動やクラッシュを引き起こすことになるけど).代替案は,上記のすべてのルールをランタイムでチェックする様々なアサートでクラスを満たすことだ.ただしそれによってコードが混乱し,エッジケースが見逃され,何かが間違っているときにフィードバックが遅くなる(compile-time vs run-time).問題の核心は,クライアントがさまざまな(相互に排他的な)状態に存在できるにも関わらず,これらの状態を個別にモデリングせずに,それらすべてが単一の型に統合されていることである.

さまざまな状態を個別の型に分割して,これを改善することができるかどうかを見てみよう.

  • まず最初に,何も接続されていないClientを持っていることに意味はあるだろうか?そうは思えない.そんな接続されていないクライアントは,何かを接続するまで何もできない.なにゆえこの状態を存在させることを許しているのだろうか.対処法として,我々は接続されたクライアントを返すconnectという名前のコンストラクション関数を作成することができる.

    def connect(address: str) -> Optional[ConnectedClient]:
        ...
    
    class ConnectedClient:
        def authenticate(...):
        def send_message(...):
        def close(...):
    

    この関数が成功すると,"接続されている"という不変性を保持するクライアントを返し,再度接続しても問題を引き起こさないようにする.接続が失敗した場合,関数は例外を発生させるか,Noneを返すか,明示的なエラーを返す.

  • authenticatedの状態についても同様のアプローチを使用できる.クライアントが接続され,認証されているという不変性を保持する別の型を導入することができる.

    class ConnectedClient:
        def authenticate(...) -> Optional["AuthenticatedClient"]:
    
    class AuthenticatedClient:
        def send_message(...):
        def close(...):
    

    こうすれば我々はAuthenticatedClientのインスタンスを実際に持って初めて,メッセージを送ることができる.

  • 最後の問題は,closeメソッドに関連している.Rustでは(破壊的なムーブセマンティクスのおかげで),closeメソッドが呼ばれたときには,もうクライアントを使用できないという事実を表現することができる.これはPythonでは実現できないので,何らかの回避策を使用しなければならない.一つの解決策は,ランタイム追跡にフォールバックし,クライアントにブール型の属性を導入し,closesend_messageでまだクローズされていないことを確認することである.もう一つのアプローチは,closeメソッドを完全に削除し,クライアントをコンテキストマネージャとして使用することである:

    with connect(...) as client:
        client.send_message("foo")
    # Here the client is closed
    

    これでもう誤ってクライアントを二度クローズすることはなくなる.

強い型付けが為されたbounding boxesの例

オブジェクト検出は,私が時折取り組むコンピュータビジョンのタスクで,プログラムは画像内の一連のバウンディングボックスを検出する必要がある.バウンディングボックスは基本的に付随するデータを持った見かけ上の矩形で,オブジェクト検出を実装するときにはあちこちに現れる.それらについて面倒なことの一つは,時に抱えるバウンディングボックスが正規化されている(矩形の座標とサイズが区間[0.0, 1.0]内にある)場合と,非正規化されている(座標とサイズがそれが付けられている画像の次元によって規定されている)場合があることだ.データの前処理や後処理などを扱う多くの関数を通してバウンディングボックスを送ると,これを混乱させてしまい,例えばバウンディングボックスを二度正規化するなど,デバッグが非常に面倒なエラーが発生する.

これは私が何度か経験したことで,ある時,これら二つのタイプのbboxesを二つの別々の型に分割することで,これを一度で解決することにした.

@dataclass
class NormalizedBBox:
    left: float
    top: float
    width: float
    height: float


@dataclass
class DenormalizedBBox:
    left: float
    top: float
    width: float
    height: float

この分離によって,正規化されたバウンディングボックスと非正規化されたバウンディングボックスを簡単に混同することはできなくなり,問題をほとんど解決できている.しかし,コードをより使いやすくするため,いくつかの改良を加えることができる.

  • コンポジションまたは継承によって重複を減らす.

    @dataclass
    class BBoxBase:
        left: float
        top: float
        width: float
        height: float
    
    # Composition
    class NormalizedBBox:
        bbox: BBoxBase
    
    class DenormalizedBBox:
        bbox: BBoxBase
    
    Bbox = Union[NormalizedBBox, DenormalizedBBox]
    
    # Inheritance
    class NormalizedBBox(BBoxBase):
    class DenormalizedBBox(BBoxBase):
    
    
  • 実行時にチェックを追加して,正規化されたバウンディングボックスが実際に正規化されていることを確認する.

    class NormalizedBBox(BboxBase):
        def __post_init__(self):
            assert 0.0 <= self.left <= 1.0
            ...
    
  • 二つの表現間での変換方法を追加する.一部の場所では,明示的な表現を知りたいかもしれないが,他の場所では,一般的なインターフェース("任意の型のBBox")で作業したい.その場合,"任意のBBox"を二つの表現のいずれかに変換できるべきだ.

    class BBoxBase:
        def as_normalized(self, size: Size) -> "NormalizeBBox":
        def as_denormalized(self, size: Size) -> "DenormalizedBBox":
    
    class NormalizedBBox(BBoxBase):
        def as_normalized(self, size: Size) -> "NormalizedBBox":
            return self
        def as_denormalized(self, size: Size) -> "DenormalizedBBox":
            return self.denormalize(size)
    
    class DenormalizedBBox(BBoxBase):
        def as_normalized(self, size: Size) -> "NormalizedBBox":
            return self.normalize(size)
        def as_denormalized(self, size: Size) -> "DenormalizedBBox":
            return self
    

注:親クラスに対応するクラスのインスタンスを返す共有メソッドを追加したい場合は,Python 3.11のtyping.Selfを使用できる.

class BBoxBase:
    def move(self, x: float, y: float) -> typing.Self: ...

class NormalizedBBox(BBoxBase):
    ...

bbox = NormalizedBBox(...)
# The type of `bbox2` is `NormalizedBBox`, not just `BBoxBase`
bbox2 = bbox.move(1, 2)

より安全なミューテックス

Rustでは,ミューテックスとロックは通常,2つの利点を持つ非常に素晴らしいインターフェースを通じて提供される:

  • ミューテックスをロックすると,ガードオブジェクトが返され,このガードオブジェクトは破棄されるときに自動的にミューテックスのロックを解除する.これは,長く尊敬されてきたRAIIメカニズムを活用したものだ.

    {
        let guard = mutex.lock(); // locked here
        ...
    } // automatically unlocked here
    

    これは,ミューテックスのロックを解除し忘れるというミスを起こすことがないということを意味する.C++でも非常に類似したメカニズムが一般的に使用されているものの,ガードオブジェクトなしの明示的なlock/unlockインターフェースもstd::mutexで利用可能であり,誤用の余地がある.

  • ミューテックスによって保護されるデータは,ミューテックス(構造体)内部に直接格納される.この設計により,ミューテックスを実際にロックせずに保護されたデータにアクセスすることは不可能になっている.データにアクセスするには,まずミューテックスをロックしてガードを取得し,次にガード自体を使用してアクセスする必要がある.

    let lock = Mutex::new(41); // Create a mutex that stores the data inside
    let guard = lock.lock().unwrap(); // Acquire guard
    *guard += 1; // Modify the data using the guard
    

これは主流の言語(Pythonを含む)にみられる通常のミューテックスAPIとは対照的だ.そうした通常のミューテックスAPIでは,ミューテックスとそれが保護するデータが分離されているため,データにアクセスする前に実際にミューテックスをロックすることを忘れてしまうことが容易に起こる.

mutex = Lock()

def thread_fn(data):
    # Acquire mutex. There is no link to the protected variable.
    mutex.acquire()
    data.append(1)
    mutex.release()

data = []
t = Thread(target=thread_fn, args=(data,))
t.start()

# Here we can access the data without locking the mutex.
data.append(2)  # Oops

Rustと同じ利点をPythonで得ることはできないが,全て再現できないわけではない.Pythonのロックはコンテキストマネージャインターフェースを実装しているので,スコープの終了時に自動的にロックが解除されるように,それらをwithブロック内で使用することができる.そして,少し頑張れば,さらにRustに近づけられる.

import contextlib
from threading import Lock
from typing import ContextManager, Generic, TypeVar

T = TypeVar("T")

# Make the Mutex generic over the value it stores.
# In this way we can get proper typing from the `lock` method.
class Mutex(Generic[T]):
    # Store the protected value inside the mutex 
    def __init__(self, value: T):
        # Name it with two underscores to make it a bit harder to accidentally
        # access the value from the outside.
        self.__value = value
        self.__lock = Lock()

    # Provide a context manager `lock` method, which locks the mutex,
    # provides the protected value, and then unlocks the mutex when the
    # context manager ends.
    @contextlib.contextmanager
    def lock(self) -> ContextManager[T]:
        self.__lock.acquire()
        try:
            yield self.__value
        finally:
            self.__lock.release()

# Create a mutex wrapping the data
mutex = Mutex([])

# Lock the mutex for the scope of the `with` block
with mutex.lock() as value:
    # value is typed as `list` here
    value.append(1)

この設計では,ミューテックスを実際にロックした後でしか保護されたデータにアクセスすることができない.もちろんこれはまだPythonなので,ミューテックスの外部に保護されたデータへの別のポインタを保存するなど,不変性を壊すことはできてしまう.ただし,わざと壊そうと振る舞っていない限り,これによりPythonのミューテックスインターフェースをより安全に使用することができる.

ともかく,私がPythonのコードで使っている"健全性パターン"はもっとあると思いますが,今のところ思いつくのはこれだけです.同様のアイデアや他のコメントがある場合は,Redditで教えてください.

邦訳「RustのようにPythonを書く」 - 完



結び

紹介されているものは割とすぐ取り組めるものが多いですね.
特に(型ヒントを少しも使ってない人はまぁいないものとして),Unionを使って代数的データ型を再現(複数のconfigやmodelを同列で扱ったり,その上でパターンマッチングで処理を書いたり)したり,あるいは型に別名をつける"NewType"の考え方もすぐ導入できると思います(私はtorch.nn.TensorTensorBxLxDみたいに形状のヒントをつけた名称に変更するのが割と可読性を上げるんじゃないかと思って実践していたりするんですが,皆様もいろいろ試してみてはどうでしょう).
「あとでいろいろ要素足したいからdictで実装します」とか言っている人を見たら,dataclassならIDE-friendlyかつ同等以上のことができるかもしれないですよ?と提案してみてください.

「Rustは書き手より読み手に優しい」という発言をどこかで聞いたのですが,その要素こそPythonに疲弊する我々に必要なものではないでしょうか.

皆様方におかれましては,


是非これを1週間に一度読んでいただき,



読み手に優しいコードを書いて





型ヒントすらなくてシグネチャから何も読み取れない...なにも...なにもわからない...とにゃーにゃー言いながら外部ライブラリの中で10回関数を潜ったりしてコードを読まないといけなかったりする私の苦しみを少しでも多く取り除いてくれ。



p.s.
私も,気をつけます...


GitHubで編集を提案

Discussion