Open12

MLエンジニア4

かーなびかーなび

pull requestの見方

  1. 自分がreviewerにアサインされている理由を立場を踏まえて掴む(一緒に運用していけるレベルで見て欲しいのか、プロダクトの内容まではよいがアーキテクトレベルで見て欲しいのかなど)
  2. PR本文の概要を読む
  3. 変更差分のテストを見てやりたいことの内容を掴み、このテストが通っていれば問題ないだろうという要所を理解する
  4. 変更差分の本文を見てテストの通し方に違和感がないかを確認(テストは通っているが意図しないロジックで通っていないか / 書き方に違和感がないか)
かーなびかーなび

cloud run

Cloud Runの仕組みを、特にスケールアップウォームアップの重要性に焦点を当てて、分かりやすく解説します。


Cloud Runの基本コンセプト:必要な時にだけ働く「オンデマンド・タクシー」🚕

Cloud Runの仕組みを理解する最も簡単な方法は、**「超高性能なタクシー配車センター」**をイメージすることです。

  • コンテナ 📦: あなたが開発したアプリケーション(Webサーバーなど)です。これは「タクシー車両」に相当します。
  • リクエスト 🙋: サービスを利用したいユーザーからのアクセスです。これは「タクシーに乗りたいお客さん」です。
  • Cloud Run 🤖: 配車センターです。お客さんの数に応じて、タクシーをガレージから出動させます。

このモデルの最大の特徴は、**「お客さん(リクエスト)がいない時は、一台もタクシー(インスタンス)を動かさず、料金(コスト)が一切かからない」**という点です。


スケールの仕組み:お客さんの数に合わせて自動で配車

Cloud Runの真価は、この自動配車(スケーリング)の仕組みにあります。

1. ゼロへのスケール (Scale to Zero) 💤

リクエストが全くない状態が続くと、Cloud Runは全てのインスタンスを停止します。これにより、サーバー維持費がゼロになります。

  • タクシーの例え: 深夜でお客さんが誰もいないので、全タクシーをガレージに戻してエンジンを停止させます。燃料代はかかりません。

2. コールドスタート (Cold Start) 🏃

サービスに最初のリクエストが来た時、または全てのリクエストを処理中のインスタンスが手一杯になった時に、新しいインスタンスを起動する必要があります。この「ゼロからインスタンスを起動するプロセス」をコールドスタートと呼びます。

コールドスタートには以下のステップが含まれ、数秒から時にはそれ以上の時間がかかります。

  1. インスタンス確保: Googleのインフラ上でサーバーリソースを確保します。
  2. イメージ取得: あなたが作成したコンテナイメージをダウンロードします。
  3. コンテナ起動: コンテナを実行します。
  4. アプリ初期化: アプリケーションがリクエストを受け付けられるように、内部の準備(データベース接続、設定読み込みなど)をします。
  • タクシーの例え: 最初のお客さんから電話がかかってきました。運転手はガレージに行き、車のエンジンをかけ、お客さんの場所まで移動する必要があります。この**「準備と移動の時間」**がコールドスタートの遅延です。

3. スケールアップ (Scale Up) 🚗🚗🚗

リクエストが殺到し、稼働中のインスタンスだけでは処理しきれなくなると(※)、Cloud Runは自動で新しいインスタンスを次々と起動(コールドスタート)して対応します。

  • タクシーの例え: イベント終了後、駅にお客さんが100人殺到しました。1台目のタクシーが満員になると、配車センターは2台目、3台目...と次々にガレージからタクシーを出動させ、行列をさばきます。

※ 各インスタンスが同時に処理できるリクエスト数には上限(concurrency設定)があります。


エラー

"The request was aborted because there was no available instance." というエラーは、リクエストが来た時点で処理可能なCloud Runインスタンスが存在しない場合に発生します。これは通常、インスタンス数が0からのスケールアップ(コールドスタート)や、急激なリクエスト増にスケールが追いつかない場合に起こります。

このエラーは、Cloud Runの前に立つGoogleのフロントエンドがクライアントに 503 Service Unavailable を返すことで現れます。したがって、この503エラーを検知して、指数関数的バックオフ(Exponential Backoff)でリトライすることで、Cloud Runがスケールアップする時間を稼ぐのが効果的です。

また、大量のリクエストを送信する前に少数のリクエストで「ウォームアップ」を行い、意図的にインスタンスをスケールさせておくことで、本番のリクエストが失敗する可能性を大幅に減らすことができます。


ウォームアップの重要性:なぜ「準備運動」が必要なのか?

ここからが本題です。先ほどの"no available instance"エラーは、このコールドスタートの遅延が直接の原因です。

問題の発生シナリオ

  1. あなたのシステムが、一度に30件の大量のリクエストをCloud Runに送信しました。
  2. その時、Cloud Runのインスタンスは0台(または1台で手一杯)でした。
  3. Cloud Runは慌てて新しいインスタンスを起動(コールドスタート)し始めます。
  4. しかし、インスタンスの準備が完了するに、待たされていたリクエストの一部がタイムアウトしてしまいました。Googleのシステムが「これ以上待てないので、リクエストを中止します」と判断した結果が、あのお馴染みのエラーメッセージです。
  • タクシーの例え: 100人のお客さんが一瞬でタクシー乗り場に現れました。しかし、ガレージからタクシーが到着するのに数分かかります。待っている間に痺れを切らしたお客さんの一部が「もういいや!」と諦めて帰ってしまいました。これがリクエストの中止(aborted request)です。

解決策としてのウォームアップ

ウォームアップとは、この問題を避けるために、本格的なリクエストが来る前に、意図的に少数の「準備運動」リクエストを送り、あらかじめインスタンスを起動させておく戦略です。

  • タクシーの例え: イベントが終わる5分前に、配車センターが「そろそろお客さんが来るぞ」と予測し、あらかじめ10台のタクシーを会場前に待機させておきます。これにより、お客さんは到着後すぐに乗車でき、待ち時間が発生しません。

あなたのコードにwarmup_serviceを追加したのは、まさにこの「タクシーを事前に待機させる」ためです。これにより、本番の30件のリクエストが到着した際には、すでに複数のインスタンスが起動・待機しており、即座にリクエストを処理できるのです。


まとめ:賢い付き合い方

Cloud Runは非常に強力でコスト効率の良いサービスですが、その「オンデマンド」な性質を理解することが重要です。

戦略 目的 タクシーの例え
クライアント側
ウォームアップ 本番リクエストの前にインスタンスを起動させておく イベント前にタクシーを会場に待機させる
リトライ (503対応) スケール中の待ち時間に対応し、諦めずに再試行する タクシーが来るまで、少し待ってからもう一度配車を頼む
サーバー側
最小インスタンス (min-instances) 常に一定数のインスタンスを稼働させ、コールドスタートを完全になくす どんな時でも最低1台のタクシーは常にエンジンをかけて待機させておく(燃料代はかかる)

ウォームアップリトライは、コストを抑えつつ(min-instances=0の恩恵を受けつつ)、突発的な負荷にも強くするための賢いクライアント側のテクニックと言えます。

かーなびかーなび

all

all は、Python モジュールで**「そのモジュールが外部に公開したい名前(関数、クラス、変数など)」をリストで定義するための特別な変数**です。

主な用途

from module import * でインポートされる名前の制御
all を定義すると、

from module import *

と書いたときに、all に含まれている名前だけがインポートされます。含まれていない名前はインポートされません。

__all__ を記載しなかった場合でも、これらのクラスは外部から「使えなくなる」わけではありません

詳細な解説

  • __all__ は、そのモジュールで from <module> import * を使ったときに、どの名前を公開するかを制御するためのリストです。
  • これが未定義の場合、from <module> import * では、アンダースコア(_)で始まらない全ての名前が公開されます(ただし、Pythonの公式仕様ではそうですが、IDEや検査ツールによって多少挙動が異なることもあります)。
  • つまり、__all__ がなくても、通常の importfrom <module> import <class名>直接クラス名を指定すればインポート可能です。

from hoge.fuga.mypackage import MyClass

これは __all__ がなくても動作します。

まとめ

  • __all__ が無い場合でも、明示的にクラス名を指定してインポートすれば使えます
  • __all__ が影響するのは「from ... import *」の挙動だけです。

APIの明示的な公開範囲の設定

他の開発者に「このモジュールでは、これらの名前を使ってください」という意図を示すことができます。内部実装の関数やクラスを隠し、必要なものだけを公開するための手段です。

ドキュメント生成ツールや型チェックの補助

どの要素が公開 API かを明確にすることで、ドキュメント生成ツールや型チェックツールが正しく扱いやすくなります。

__all__ = ["foo", "bar"]

def foo(): pass
def bar(): pass
def baz(): pass

# from module import * では foo, bar だけがインポートされる。baz はインポートされない。

まとめ

  • all はモジュールの公開インターフェースを定義するためのもの
  • from module import * の時にインポートされる名前を制御
  • 外部に公開するAPIを明示する役割
かーなびかーなび

init.py

1. init.py の仕組み

  • Pythonパッケージディレクトリを認識させるためのファイル
    ディレクトリ内に __init__.py があると、そのディレクトリは「パッケージ」として認識され、import できるようになります。
  • 内容が空でもOK
    空ファイルでもパッケージ認識はされます。

具体例

my_package/
│
├── __init__.py
├── module_a.py
└── module_b.py

この場合、my_packageimport my_package などでインポート可能。


2. init.py に書く意図

  • パッケージとして公開したいAPI(関数・クラスなど)の整理
    よく使うサブモジュールやクラス・関数をまとめて import し、パッケージの「玄関口」にする。
  • 複数モジュールの名前空間をまとめる
    例:
    from .module_a import Foo
    from .module_b import Bar
    
    と書くと、my_package.Foomy_package.Bar でアクセスできる。
  • 初期化処理や設定
    パッケージ import 時に必要な初期化コードを書くこともできる。

# __init__.py
from .module_a import Foo
from .module_b import Bar

__all__ = ["Foo", "Bar"]

詳しく説明

  • __init__.py の内容は、そのパッケージ(ディレクトリ)を import したときに実行されます。
  • 例えば、以下のような構成の場合:
my_package/
  ├── __init__.py
  ├── module_a.py
  • Pythonで
    import my_package
    
    とすると、my_package/__init__.py の中身が実行されます。

どんな処理を書いておく?

  • サブモジュールやクラスの import(公開APIの整理)
  • 初期化処理(グローバル変数のセットやロギングの設定など)
  • 設定ファイルの読み込み
  • 何らかの副作用やセットアップ

注意点

  • from my_package import * の場合も、まず __init__.py の内容が実行されます。
  • import するたびに実行されるので、重い処理や副作用は注意が必要です。

__init__.py に書いた処理は、そのパッケージを import するときに実行される、という理解で正しいです。


まとめ

  • __init__.py は「このディレクトリはPythonパッケージですよ」と示すファイル
  • パッケージの公開APIを整理したり、パッケージ初期化処理を書いたりする
  • importの経路をシンプルにしたり、使いやすくするために工夫する場所
かーなびかーなび

関数が受け取ったリスト(引数)を直接変更してしまうと、予期せぬバグ(副作用)の原因になりやすい

コーディングにおいて非常に重要な、意識すべき原則です。

レビューアが指摘している「引数listを破壊するのは設計上厄介」とは、関数が受け取ったリスト(引数)を直接変更してしまうと、予期せぬバグ(副作用)の原因になりやすい、という意味です。


🤔 「副作用」とは何か?

ここで言う副作用とは、関数がそのスコープ(範囲)の外にあるデータ、つまり今回の場合引数として渡された元のリストを書き換えてしまうことです。

関数は一般的に、何かを入力(引数)として受け取り、結果を出力(返り値)として返す、独立した処理単位であることが期待されます。しかし、その過程で入力された元のデータを勝手に変更してしまうと、その関数を呼び出した側は「まさか元のデータが書き換わっているとは思わず」に処理を続けてしまい、混乱やバグを生む原因となります。


💣 なぜ設計上「厄介」なのか?【エビデンス】

引数を変更することが、具体的にどのような問題を引き起こすのかをコードで見てみましょう。

1. 呼び出し元の変数が意図せず変更される

ショッピングカートから特定の商品を削除し、割引対象リストを作る関数を考えてみましょう。

def create_discount_list(cart_items: list[str]) -> list[str]:
    """カートから不要なアイテムを取り除き、割引リストを作成する"""
    # この remove 操作が元のリストを「破壊」している
    cart_items.remove("高額商品") 
    return cart_items

# Aさんのカート
cart_a = ["リンゴ", "ジュース", "高額商品"]

# Aさんのカートから割引リストを作成
discount_list_a = create_discount_list(cart_a)

print(f"割引リスト: {discount_list_a}") # -> 割引リスト: ['リンゴ', 'ジュース']
print(f"元のカート: {cart_a}")        # -> 元のカート: ['リンゴ', 'ジュース']

create_discount_list を呼び出しただけなのに、元の cart_a の中身まで変わってしまいました。もしこの後、別の処理で cart_a の全アイテムを使って合計金額を計算しようとしたら、「高額商品」が消えているため金額が合わなくなります。これが「厄介」なバグです。

2. Python特有の「ミュータブルなデフォルト引数」の罠

これはPythonで特に有名な問題です。関数を定義する際に、デフォルト引数としてリスト [] を使うと、そのリストは一度しか生成されません。そのため、関数を呼び出すたびに同じリストが使い回され、変更が蓄積していきます。

def add_log(message: str, logs: list[str] = []):
    """ログを追加する(悪い例)"""
    logs.append(message)
    return logs

# 1回目の呼び出し
print(add_log("システム起動")) # -> ['システム起動']

# 2回目の呼び出し(新しい空のリストが使われることを期待するが...)
print(add_log("ユーザーログイン")) # -> ['システム起動', 'ユーザーログイン']

# 3回目の呼び出し
print(add_log("ファイル書き込み")) # -> ['システム起動', 'ユーザーログイン', 'ファイル書き込み']

このように、毎回空のリストにログが追加されることを期待していても、実際にはどんどんログが溜まっていきます。これは非常に追跡しにくいバグの原因となります。


✅ どうすれば良かったのか?

原則として、関数内で引数を変更せず、新しいオブジェクトを生成して返すのが安全な設計です。

良い設計: 新しいリストをコピーして操作する

先のショッピングカートの例を修正してみましょう。

def create_discount_list_safe(cart_items: list[str]) -> list[str]:
    """元のリストを変更せず、新しい割引リストを返す(良い例)"""
    # 元のリストをコピーしてから処理を行う
    new_list = cart_items.copy() 
    new_list.remove("高額商品")
    return new_list

# Aさんのカート
cart_a = ["リンゴ", "ジュース", "高額商品"]

# 安全な関数で割引リストを作成
discount_list_a = create_discount_list_safe(cart_a)

print(f"割引リスト: {discount_list_a}") # -> 割引リスト: ['リンゴ', 'ジュース']
print(f"元のカート: {cart_a}")        # -> 元のカート: ['リンゴ', 'ジュース', '高額商品']

このように、元の cart_a は一切変更されていません。これなら、関数の呼び出し元は安心して元のデータを使い続けることができます。

まとめ

  • 「引数listを破壊する」 とは、関数が受け取ったリストを直接変更し、副作用を引き起こすこと。
  • なぜ厄介か?
    1. 呼び出し元が予期しないデータ変更に繋がり、追跡困難なバグを生む。
    2. コードの再利用性や予測可能性が著しく低下する。
  • どうすべきか?
    • 基本は、引数を変更せずに新しいリストを生成して返す。 (new = old.copy()new = old + [item] など)
    • パフォーマンス上の理由などで意図的に引数を変更する場合は、sort_in_place のように関数名でその動作を明示したり、Docstring(関数の説明文)に「この関数は引数のリストを直接変更します」と注意書きをしたりするのが親切です。

この考え方は、**不変性(Immutability)**を重視する現代的なプログラミングにおいて非常に基本的な作法とされており、常に意識することをお勧めします。

かーなびかーなび

関数内の変数は 設計の安全性 >> メモリ効率

メモリ効率だけを考えれば、新しいオブジェクトを作らずに元のリストを上書きする方が優れています。

結論から言うと、ほとんどの場合、関数内で作られた新しいオブジェクトのメモリは気にする必要はありません。 設計の安全性や分かりやすさを優先する方が、はるかにメリットが大きいです。


なぜ通常は気にしなくて良いのか

1. ガベージコレクションの存在

Pythonにはガベージコレクション (Garbage Collection, GC) という仕組みがあり、不要になったオブジェクトが占有しているメモリを自動的に解放してくれます。

  • 関数内で new_list = old_list.copy() のように新しいリストを作ったとします。
  • その new_list を関数の外に return しなければ、その関数を抜けた時点で new_list を指し示すものがなくなり、ガベージコレクションの回収対象になります。
  • return して関数の呼び出し元で変数に代入された場合は、その変数が使われなくなった時点で回収対象となります。

このようにPythonが賢くメモリ管理をしてくれるため、開発者は細かなメモリ解放を常に意識する必要はありません。

2. 設計の堅牢性 >>> ごくわずかなメモリ効率

前の話題の通り、副作用によるバグは発見が非常に難しく、修正に多大な時間と労力を要することがあります。

  • メモリコスト: 数マイクロ秒のコピー時間と、数十〜数百キロバイトの追加メモリ。
  • バグ修正コスト: 数時間〜数日のデバッグ時間、そして予期せぬシステムの誤作動のリスク。

この2つを天秤にかけると、ほとんどの状況で、バグのリスクを回避できる安全な設計を選ぶべきです。


例外:メモリを意識すべきケース

もちろん、常にメモリを無視して良いわけではありません。扱うデータが非常に巨大な場合は例外です。

例えば、以下のようなケースでは、新しいオブジェクトの作成がパフォーマンスのボトルネックになる可能性があります。

  • 数百万〜数億件の要素を持つリストを扱う場合
  • 画像処理や機械学習で、巨大な行列データ(numpy.ndarrayなど)を何度も変換する場合
  • メモリが極端に制限された環境(一部の組み込み機器など)で動作させる場合

このような状況では、メモリを節約するために意図的に**破壊的な変更(in-placeな操作)**を選択することが正当化されます。

# 例: 巨大なデータセットを正規化する場合
# メモリを節約するため、意図的に元のデータを書き換える
def normalize_large_dataset_in_place(dataset: list[float]):
    """
    注意: この関数は引数のdatasetを直接変更します。
    巨大なリストのコピーを防ぐために破壊的な操作を行います。
    """
    max_val = max(dataset)
    for i in range(len(dataset)):
        dataset[i] = dataset[i] / max_val

# このような関数を使う側は、元のデータが変更されることを理解している必要がある
large_data = [1.0, 5.0, 2.0, 10.0] # ※本当はもっと巨大なデータ
normalize_large_dataset_in_place(large_data)
print(large_data) # -> [0.1, 0.5, 0.2, 1.0]

この場合でも、関数名に _in_place を付けたり、Docstringで注意喚起したりして、副作用があることを明確に伝えるのが良い設計です。


まとめ

  • 原則: 日常的なサイズのリスト(数千〜数万件程度まで)では、メモリ効率より設計の安全性を優先し、新しいオブジェクトを返しましょう。
  • 例外: 明らかに巨大なデータを扱い、メモリや処理速度がボトルネックになることが分かっている場合に限り、破壊的な変更を検討しましょう。

「早すぎる最適化は諸悪の根源」という言葉の通り、まずは安全で分かりやすいコードを書き、パフォーマンスが問題になってから初めて、このような最適化を考えるのが定石です。

かーなびかーなび

ダックタイピングとtyping.Protocol

Pythonのダックタイピングは、「もしアヒルのように歩き、アヒルのように鳴くなら、それはアヒルである」という考え方に基づいた、柔軟なオブジェクトの扱い方を実現する仕組みです。

ダックタイピングの仕組みと利点

仕組み 🦆

ダックタイピングの核心は、オブジェクトの型(クラス)そのものではなく、そのオブジェクトが持つメソッドや属性(振る舞い)に注目する点にあります。ある処理を実行する際に、オブジェクトが特定のクラスのインスタンスであるかどうかをチェックする代わりに、「必要なメソッドを持っているか?」だけを問題にします。

例えば、組み込み関数の len() は、引数に与えられたオブジェクトが __len__() という特殊メソッドを持っていれば、その種類を問わず長さを返すことができます。

# 様々な型のオブジェクト
my_list = [1, 2, 3]
my_string = "hello"
my_dict = {"a": 1, "b": 2}

# いずれも __len__() メソッドを持つため、len() で扱える
print(len(my_list))    # 3
print(len(my_string))  # 5
print(len(my_dict))    # 2

この例では、liststrdict は全く異なるクラスですが、len() 関数から見ればすべて「長さを返す能力(__len__())」を持っているため、同じように扱うことができます。これがダックタイピングの仕組みです。


利点 ✨

  • 柔軟性の向上: 異なるクラス階層に属するオブジェクトでも、共通のインターフェース(メソッド名など)さえ持っていれば、同じ関数やロジックで処理できます。これにより、コードの再利用性が格段に向上します。
  • 疎結合な設計: クラス間の具体的な継承関係に依存しないため、コンポーネント同士の結びつきが弱くなります(疎結合)。これにより、システムの変更や拡張が容易になります。
  • コードの簡潔化: isinstance() などを使った明示的な型チェックを多用する必要がなくなり、コードがシンプルで読みやすくなります。

typing.Protocol と基底クラスの違い

ダックタイピングは非常に強力ですが、コードが大規模になると「この関数はどんなメソッドを持つオブジェクトを期待しているのか?」が分かりにくくなるという欠点がありました。この問題を静的型付けの文脈で解決するのが typing.Protocol です。

typing.Protocol とは?

Protocol は、ダックタイピングにおける非公式なインターフェース(規約)を、静的型チェッカー(Mypyなど)が解釈できる形で明示的に定義するための仕組みです。

Protocol を使うことで、「このオブジェクトは、特定のメソッドや属性を持っているべきだ」という”期待”を型ヒントとして表現できます。


基底クラスとの決定的な違い

Protocol とABC(抽象基底クラス)のような通常の基底クラスの最も大きな違いは、明示的な継承を必要とするか否かです。

  • 基底クラス (e.g., abc.ABC)

    • 明示的な継承が必須: あるクラスが特定の基底クラスのサブタイプであるとみなされるためには、そのクラスを明示的に継承する必要があります。
    • 「is-a」関係を強制: 継承を通じて、クラス間に明確な「is-a(〜は〜の一種である)」という関係を構築します。
    • 実行時チェック: isinstance() を使って、実行時にオブジェクトがそのクラスのインスタンスかを確認できます。

    from abc import ABC, abstractmethod
    
    # 基底クラスを定義
    class SoundMaker(ABC):
        @abstractmethod
        def make_sound(self):
            pass
    
    # 明示的に継承する必要がある
    class Dog(SoundMaker):
        def make_sound(self):
            print("わん!")
    
    # 継承していないのでエラーになる
    # class Cat:
    #     def make_sound(self):
    #         print("にゃー")
    
    dog = Dog()
    # cat = Cat()
    
    isinstance(dog, SoundMaker) # True
    # isinstance(cat, SoundMaker) # False
    
  • typing.Protocol

    • 明示的な継承は不要: クラスがプロトコルで定義されたメソッドや属性を実装しているだけで、静的型チェッカーはそのクラスがプロトコルに適合していると判断します(構造的部分型)。
    • 振る舞いを定義: 「has-a(〜は〜という能力を持つ)」という関係性、つまりオブジェクトの構造や振る舞いに注目します。
    • 静的型チェックが主目的: 主に開発時の静的解析で利用され、実行時の動作には(通常)影響を与えません。

    from typing import Protocol
    
    # プロトコルを定義
    class CanSound(Protocol):
        def make_sound(self) -> None:
            ... # 実装は不要
    
    # 継承は不要! make_sound() を持っているだけでOK
    class Duck:
        def make_sound(self) -> None:
            print("ガー!")
    
    class Car:
        def make_sound(self) -> None:
            print("ブッブー!")
    
    # 型チェッカーは Duck も Car も CanSound として扱えることを理解する
    def play_sound(obj: CanSound) -> None:
        obj.make_sound()
    
    play_sound(Duck()) # ガー!
    play_sound(Car())  # ブッブー!
    

まとめ

特徴 基底クラス (ABC) typing.Protocol
継承 必須 不要
関係性 is-a(〜の一種) has-a(〜の能力を持つ)
主な目的 共通実装の提供、継承関係の明示 ダックタイピングの規約を静的に検査
チェック 実行時 (isinstance()) と静的 主に静的(Mypyなど)

Protocol は、既存のライブラリや変更できないコードに対しても、後から型情報を付与して静的解析の恩恵を受けられるようにする、非常に強力なツールです。Pythonの柔軟なダックタイピングと、静的型付けによる堅牢性を両立させるための仕組みと言えます。

参考:
https://zenn.dev/sousquared/articles/0d31063a7f69ec

かーなびかーなび

Protocol導入の利点

  • ダックタイピングを型として定義できるようになった
    • たとえばcollection.abcにないものも型として定義可能になった
  • 故に柔軟に型定義ができて既存の型にないものに対してもmypyを無視せずに対応できるようになった

「継承」による束縛から「振る舞い」による適合へ

ご認識の通り、Pythonの型安全の世界では、Protocolの登場によって大きなパラダイムシフトが起きました。

以前の世界:collections.abcによる「公称的部分型」

collections.abc.Iterableなどを継承する方法は、**公称的部分型(Nominal Subtyping)**と呼ばれます。

これは「名前(Name)」が重要だ、という考え方です。
「あなたは『Iterable』という名前のグループに属していると**宣言(継承)**していますか?」と問うわけです。

  • やり方: class MyIterator(collections.abc.Iterator): ... のように明示的に継承する。
  • メリット:
    • isinstance()で実行時にチェックできる。
    • 継承関係が明確で、IDEのサポートも受けやすい。
  • デメリット(ご指摘の点):
    • 密結合: クラスを定義する側が、利用されるインターフェース(abc)を知っていて、継承する必要がある。
    • 拡張性の欠如: サードパーティのライブラリなど、自分が変更できないクラスは、たとえ__iter____next__を持っていても Iterator として扱えず、型チェックでエラーになる。

Protocolがもたらした世界:「構造的部分型」

Protocolが導入したのは、**構造的部分型(Structural Subtyping)**という考え方です。

これは「構造(Structure)」、つまり振る舞い(メソッドや属性)が一致しているかが重要だ、という考え方です。
「あなたは『__iter____next__というメソッド
を持っていますか?」と、その構造だけを問います。これはまさに静的型付けにおけるダックタイピングです。

  • やり方:
    1. 期待する振る舞いをProtocolで定義する:
      class CanRead(Protocol):
          def read(self) -> str: ...
      
    2. 呼び出し側が型ヒントとして使う:
      def get_content(source: CanRead) -> None:
          print(source.read())
      
    3. 実装側はProtocolを意識せず、ただ振る舞いを実装する:
      class File: # CanReadを継承しない!
          def read(self) -> str:
              return "Data from file"
      
      class Socket: # CanReadを継承しない!
          def read(self) -> str:
              return "Data from socket"
      
  • メリット(ご指摘の点):
    • 疎結合: FileSocketクラスは、CanReadというプロトコルの存在を一切知る必要がない。呼び出し側(get_content)が要件を定義するだけで、Mypyが両者の「構造」が一致するかをチェックしてくれる
    • 高い拡張性: 既存のどんなクラスでも、必要なメソッドさえ持っていればプロトコルを満たすものとして扱える。

まとめ:比較表

あなたの理解を以下の表にまとめました。

項目 collections.abc(基底クラス) typing.Protocol
考え方 公称的部分型 (Nominal) 構造的部分型 (Structural)
判定基準 「〜の一員である」と宣言しているか (is-a) 「〜の能力を持つ」という構造か (has-a)
継承 必須 不要
結合度 密結合 (実装側がABCを知る必要がある) 疎結合 (実装側はProtocolを意識不要)
キーワード 継承、isinstance()、抽象メソッド ダックタイピング、インターフェース、振る舞い

結論として、あなたの「疎結合でありつつMypyで型安全を保証できるようになった」という理解は、Protocolがもたらした最も重要な価値を的確に捉えています。

かーなびかーなび

Protocolの書き方

PythonのProtocolは、クラスが特定のメソッドや属性を持っていることを型ヒントとして示すための仕組みです。継承を強制せずに「振る舞い」を定義できるため、ダックタイピングの考え方を静的型チェックで安全に実現できます。


## Protocolの基本的な使い方 📝

使い方はとてもシンプルで、3つのステップで構成されます。

ステップ1: 「振る舞い」をProtocolとして定義する

まず、typingモジュールからProtocolをインポートし、期待するメソッドや属性を持つクラスを定義します。メソッドの中身は ... と記述するのが一般的です。

from typing import Protocol

# 「saveメソッドを持つ」という振る舞いを定義
class Saveable(Protocol):
    def save(self) -> str:
        """データを保存し、保存先のパスを返す"""
        ...

ここでは、「save()というメソッドを持ち、それは文字列(str)を返す」というルール(プロトコル)を定義しました。


ステップ2: 関数がその「振る舞い」を要求することを型ヒントで示す

次に、定義したProtocolを関数の型ヒントとして使います。これにより、この関数は「Saveableプロトコルに適合するオブジェクトなら何でも受け取れる」ことになります。

# Saveableに適合するオブジェクトなら何でも受け付ける関数
def execute_save(item: Saveable):
    print(f"{item.__class__.__name__}を保存します...")
    path = item.save()
    print(f"  -> 保存先: {path}")

execute_save関数は、渡されたitemDocumentクラスかGameクラスかなどを気にしません。ただsave()メソッドを持っていることだけを期待しています。


ステップ3: クラスを実装する(継承は不要!)

最後に、その振る舞いを持つクラスを作成します。ここで最も重要なのは、Saveableを明示的に継承する必要がないことです。

class Document:
    # Saveableを継承しない!
    def save(self) -> str:
        # ドキュメントをファイルに書き込む処理
        return "/path/to/document.txt"

class GameProgress:
    # こちらも継承しない!
    def save(self) -> str:
        # ゲームの進行状況をクラウドに保存する処理
        return "cloud-storage/save-data-123"

class UserSetting:
    # saveメソッドを持たないクラス
    def update(self):
        pass

# --- 実行してみる ---
doc = Document()
game = GameProgress()
setting = UserSetting()

execute_save(doc)      # OK!
execute_save(game)     # OK!

# execute_save(setting) # MyPyがエラーを検出!
# Error: Argument 1 to "execute_save" has incompatible type "UserSetting";
#        expected "Saveable"

静的型チェッカー(Mypyなど)は、DocumentGameProgresssave()メソッドを持っているのでSaveableプロトコルを満たしていると判断します。一方、UserSettingsave()を持っていないため、エラーとして検出してくれます。


## なぜProtocolを使うのか? 🚀

Protocolを使う最大のメリットは、疎結合な設計を実現できることです。

  • 既存のコードを汚さない: サードパーティ製のライブラリなど、あなたが変更できないクラスに対しても、後から「振る舞い」を定義して型チェックの恩恵を受けられます。
  • 関心を分離する: クラスの実装側は、自分がどのようなインターフェースで使われるかを意識する必要がありません。逆に関数側は、具体的なクラス名ではなく、必要な「振る舞い」だけを要求できます。

## 基底クラス(ABC)との使い分け 🔧

typing.Protocol abc.ABC (抽象基底クラス)
目的 振る舞いを共通化したい 実装も共通化・継承関係を明確にしたい
継承 不要 必須
関係 has-a (〜の能力を持つ) is-a (〜の一種である)
適した場面 疎結合にしたい、既存コードに型を付けたい 共通の処理を持たせたい、フレームワークを作りたい

基本的には、「振る舞いだけを定義したい場合はProtocol」、「共通のコードも持たせたい場合はABC」と考えると分かりやすいです。


## (発展) 実行時チェック 💡

通常Protocolは静的型チェックのためのものですが、@runtime_checkableデコレータを付けると、isinstance()による実行時チェックも可能になります。

from typing import Protocol, runtime_checkable

@runtime_checkable
class Saveable(Protocol):
    def save(self) -> str:
        ...

doc = Document()
print(isinstance(doc, Saveable)) # True

これは便利な機能ですが、Protocolの主目的はあくまで開発時の静的な安全性確保にあることを覚えておきましょう。

かーなびかーなび

pull request(PR) reviewを早く行う方法

PRレビューの時間を短縮するのは、レビュアー自身の生産性向上はもちろん、チーム開発の速度を上げる上でも非常に重要ですね。

ここでは、ご要望に合わせて「速攻でできるもの」と、継続的な「改善ベース」の2つに分けて、PRレビューを速くするための具体的な方法をご紹介します。


🚀 速攻でできるもの (今日から試せるアクション)

まずは、意識やツールの使い方を変えるだけで、すぐに効果が出る可能性のある方法です。

1. レビュー時間をブロックする
マルチタスクは集中力を削ぎます。「14時から30分間はレビューに集中する」のようにカレンダーに予定を入れ、その時間は他の通知をオフにしてレビューだけに集中しましょう。細切れの時間でレビューするよりも、一度にまとまった時間を確保する方が結果的に速く終わります。

2. PRの「目的」から理解する
いきなりコードの差分(Diff)を1行目から読むのは非効率です。

  • PRのタイトルと説明を熟読する: このPRが「何を」「なぜ」解決しようとしているのかを最初に把握します。
  • 関連チケットやIssueを確認する: 背景知識や仕様を理解することで、コードの意図が掴みやすくなります。
  • UIの変更ならスクリーンショットや動画を見る: 実際の動作を先に確認すると、コードの役割がイメージしやすくなります。

3. まずは全体像を掴み、次に詳細を見る
森を見てから木を見るアプローチです。

  • ファイル一覧を眺める: どのファイルが変更されているのかを見て、影響範囲の仮説を立てます。
  • 重要なロジックから読む: 最も中心的な変更(ビジネスロジックなど)から読み始め、その後でテストコードや軽微な修正を確認します。

4. 自動化できるレビューはツールに任せる
人間がやるべきでないレビューは、CI(継続的インテグレーション)に任せましょう。

  • LinterやFormatterのエラーを信頼する: コーディングスタイルに関する指摘は、自動チェックをパスしているなら基本的にOKとします。もし気になっても「(修正は任意ですが)次からこうしませんか?」のように、ブロッキングしないコメントを心がけます。
  • カバレッジや静的解析の結果を確認する: テストが不足していないか、潜在的なバグがないかをツールで確認し、人間はロジックの妥当性や設計に集中します。

5. チェックリストを用意する
毎回同じ観点でレビューできるように、自分なりのチェックリストを用意しておくと、抜け漏れがなくなり、判断も速くなります。

  • 目的は達成されているか?
  • テストは書かれているか?
  • パフォーマンスに影響はないか?
  • 変数名やメソッド名は分かりやすいか?
  • エラーハンドリングは適切か?

6. わからないことはすぐに質問する
コードの意図がわからないまま時間をかけて解読しようとするより、PR作成者に直接質問する方が圧倒的に速いです。「ここの実装の意図を教えてください」とコメントしたり、必要なら短いハドル(オンラインミーティング)で画面共有してもらいましょう。


🌱 改善ベース (中長期で取り組むべきこと)

こちらは、チーム全体や自分自身のスキルアップを通じて、根本的にレビュー速度を上げていくための方法です。

1. チームでPRの文化を作る
レビューしやすいPRを提出してもらうことが、レビュアーの速度向上に直結します。

  • PRを小さく保つ文化を醸成する: 1つのPRには1つの関心事だけを含めるようにチームで合意します。「差分が200行を超えたら分割を検討する」などの具体的なルールも有効です。巨大なPRはレビューの質も速度も低下させます。
  • PRのテンプレートを用意する: 「背景」「やったこと」「動作確認方法」などを必ず書いてもらうようにテンプレートを整備すれば、レビュアーは毎回同じフォーマットで情報を得られます。

2. ドメイン知識とコードベースへの理解を深める
結局のところ、これが最も効果的です。

  • 担当領域の仕様を深く理解する: 仕様やビジネスロジックを理解していれば、「なぜこのコードが必要か」がすぐに分かり、レビューがスムーズになります。
  • 積極的にコードを読む: レビュー時以外にも、コードベースの主要な部分を読んでおくことで、変更による影響範囲を素早く予測できるようになります。

3. 効果的なフィードバックの技術を磨く
レビューの往復(やり取り)が減れば、トータルの時間は短縮されます。

  • 修正案を具体的に提示する: 「ここ、もっと良い方法がありそうです」ではなく、「ここのループ処理は、mapfilter を使うとより簡潔に書けますよ」のように、具体的なコードスニペットを示すと、相手の修正が速くなります。
  • 指摘の意図を伝える: 「なぜ」そうした方が良いのか(例: パフォーマンスのため、可読性向上のため)を伝えることで、相手の納得感が高まり、将来的に同様の指摘が不要になります。
  • Nitpick(些細な指摘)にはプレフィックスを付ける: [Nit][nits] といった接頭辞を使い、「これは修正必須ではない、好みの問題レベルの指摘です」と明示することで、相手は修正の優先度を判断しやすくなります。

4. ペアレビューやモブレビューを取り入れる
特に複雑なロジックや設計に関するレビューの場合、複数人で同時にレビューすることで、一人で悩む時間を減らし、多角的な視点から素早くフィードバックができます。

まとめ

レビューの速度を上げることは、「雑にレビューする」ことではありません。「レビューの勘所を押さえ、効率的に質の高いフィードバックを行う」ことです。

まずは速攻でできることから試してみて、自分に合ったスタイルを見つけてください。そして、チーム全体で改善ベースの取り組みを進めることで、より快適で生産的な開発サイクルが実現できるはずです。