いつの間にかPythonが軟派な言語ではなくなっていた～ロバストPython～

こんにちは NSS江口 です。
最近事業部内の勉強会グループで [ロバストPython](https://www.oreilly.co.jp/books/9784814400171/) の読書会を行っております。

# Pythonの思い出

私がPythonを一番ヘビーに利用していたのは、5年前くらいにサーバレスアプリケーションを構築していた時でした。その時のPythonのバージョンは**3.6**だったと思います。
私は金融システムに携わる人間で、ファーストチョイスは何かとJavaでしたが、当時利用していた AWS Lambda ではJavaを利用するとクラスロードにかかる初回起動のオーバーヘッドがバカにならず、軽量に利用できるということでPythonを選択したとかだったと思います。なお、Javaの記事も書いておりますので、参考までに。

* [Javaのいいところ](https://qiita.com/NSS_FS_ENG/items/4bc1b17080bd82ace104)
* [JITコンパイラ](https://qiita.com/NSS_FS_ENG/items/c9bccab6651c39d3fc4e)
* [ガベージコレクション](https://qiita.com/NSS_FS_ENG/items/570dd211930deba6e882)

Javaに触れている期間が長いと、初めてPythonやRubyに触れた時

> 便利だけど、随分と軟派なプログラミング言語だなあ

という感想を持っており、ツールを手軽に作るための言語だと思っていたのですが、まさかAIやデータ分析、AWS-CLI、Webと広い分野でこんなにも幅を利かせるとは思っておらず、自分の先見の明のなさに呆れております。

世の中にはPythonが本番品質ではないと思っている私のような方もまだまだいらっしゃると思うのですが、前述の`ロバストPython`の読書会を通じて**Pythonでも堅牢なコーディングをすることができる**ということを知りました。
今回はそれを実現することができる**型アノテーション**という機能を紹介したいと思います。
なお、私が利用しているPythonのバージョンは**3.12**となります。

# 型アノテーション

まずは型アノテーションの説明ですが、Pythonにおける引数や戻り値の型のヒントを与えられる機能となります。
Pythonは動的型付け言語であるため、引数にも戻り値にも型を宣言しないで記載することができます。それが開発を軽量にしてくれる利点でもあるのですが、一方で保守をする開発者にとっては意図を組むことを難しくしてしまう欠点にもなってしまいます。

```java
// Javaの場合は必ず引数と戻り値に型を記載
public int calc(int value1, int value2) {
    ...
}
```

```python
# Pythonの場合はこんなんでいけちゃう
def calc(value1, value2):
    ...
```

このメソッドを使おうと思った際に、Javaの場合ですと型という枠組みがあり、あまりヤンチャできませんが、Pythonの場合は`value1`に文字列を渡しても言語仕様上はよいので、時間がたつにつれて、**この関数は何をするものなのか？** というポリシーがブレて、堅牢性は失われていくことになります。

## 型アノテーションの文法

続いて文法です。型アノテーションは以下のように引数と戻り値の型の宣言を行います。

```python
def add(value1: int, value2: int) -> int:
    return value1 + value2
```

## 型チェッカの導入

型アノテーションを利用する場合、型チェッカである`mypy`を利用します。以下の通り`pip/pip3`でインストールします。

```ps1
pip install mypy
```

なお、プロキシの関係で結構躓く方は多いと思うので、その場合は以下のコマンドを打っていただければと思います。

```ps1
# HTTP_PROXY / HTTPS_PROXYの設定 （http://{ユーザID}:{パスワード}@{ホスト名}:{ポート番号}）
set HTTP_PROXY=http://username:password@proxy.xxx.co.jp:8080
set HTTPS_PROXY=http://username:password@proxy.xxx.co.jp:8080
# 信頼済みホストを設定し、pip install
pip --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --trusted-host pipy.org install mypy
```

なお、そのままでは`mypy`コマンドを実行できない場合があるので、環境変数`Path`に`Scripts`ディレクトリへのパスを追加お願いします。
私の場合は**C:\Users\t-eguchi\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0\LocalCache\local-packages\Python312\Scripts**でした。

## 型チェッカの実行

Pythonは本来の軽量さを損なわないために、**型アノテーションに反する型を利用していたとしても**実行することはできます。以下のコードはどちらも動作します。

```python
def add(value1: int, value2: int) -> int:
    return value1 + value2

# これは実行できるし ->　3
result:int = add(1, 2)
print(f"result = {result}")
# これも実行できる -> "AABB"
result = add("AA", "BB")
print(f"result = {result}")
```

なので、型チェッカ(`mypy`)を使って確認することになります。以下がその実行結果です。

```ps1
PS C:\work\git\robust-python\3\src> mypy .\type_anotation.py
type_anotation.py:8: error: Argument 1 to "add" has incompatible type "str"; expected "int"  [arg-type]
type_anotation.py:8: error: Argument 2 to "add" has incompatible type "str"; expected "int"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)
```

はい、こんな感じで`int`のはずなのに、`str`を設定しているよ。って注意してくれます。
ファイルを設定すればそのファイルだけをチェックしてくれますし、ディレクトリを設定すれば再帰的に検索してチェックしてくれます。
開発がひと段落したら、ルートディレクトリを設定して、`mypy`を実行してみるのをお勧めします。
また、型アノテーションを利用しておくと、IDEも親切に教えてくれるので、利用者の生産性を上げる効果も期待できそうです。

![01.IDE.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/ba6dacf1-ed9e-786c-0fb2-916faa6663b0.png)

# 型制約

基本的な型アノテーションについて説明しましたが、型制約機能を利用することにより更なる制限をかけることができます。

|型制約|説明|
|---|---|
|Optional|None参照があることを明記する|
|Union|扱える複数のデータ型を定義する|
|Literal|指定できる値をごく一部に制限する|
|Annotated|扱えるデータ型と値の条件を指定する|
|NewType|特定のコンテキスト内だけで扱えるデータ型を作る|
|Final|変数を束縛し変更できないようにする|

## Optional型

プログラムをしていると困ったときに無効な値として`None`をreturnしてしまいがちだと思います。
`Optional`を使うことで利用者に`None`が予期する戻り値として返ってくることを提示することができます。（引数に利用することもできます。）

```python
def get_user_name(id: int) -> Optional[str]:
    users = {
        1: "User1",
        2: "User2"
    }
    return users.get(id, None)

name = get_user_name(1)
print(f"lower name = {name.lower()}")
```

これに対して、`mypy`を実行すると

```ps1
optional.py:11: error: Item "None" of "str | None" has no attribute "lower"  [union-attr]
```

といった具合に

> Noneが返ってくる可能性があるから、その場合Noneにはlowerメソッドなんて無いよ。

ということを伝えることができ、以下のようにプログラムを誘導することができます。

```python
name = get_user_name(1)
if name is not None:
    print(f"lower name = {name.lower()}")
```

文字列だとあまりピンとこないかもしれませんが、`list`の場合などは

* `None`が返る
* 無効値として`空リスト`が返る
* 検索対象が存在しなかったという意味で`空リスト`が返る

などいろいろと悩むポイントはあるので、意図を正確に伝える意味でも利用していくのが良いと思います。

## Union型

型アノテーションの良いところとして、利用側に制約を付けることができるというものですが、これはPythonの特性である`ダックタイピング`の利点と相反するものになってしまいます。
そんな時に`Union`を利用することで、**これとこれはOKだよ**という制約を付けられるため、堅牢なプログラミングをしつつ、`ダックタイピング`の良さも生かすいいとこどりをすることができます。

```python
class Stock: # 株式
    def __init__(self):
        self.issueCd = "1301"
        self.name = "極洋"
        self.price = 4510

class Bond: # 債券
    def __init__(self):
        self.issueCd = "JGB0172"
        self.name = "利付国庫債券（5年）（第172回）"
        self.price = 100.19
        self.expire_date = date(2029, 6, 20)

class Future: # 先物
    def __init__(self):
        self.issueCd = "NK2252412"
        self.name = "日経225先物24年12月"
        self.price = 37723.91
        self.expire_date = date(2024, 12, 13)

# 終了日を取得します（債券と先物だけ）
def get_end_date(issue: Union[Bond, Future]) -> date:
    return issue.expire_date

# 銘柄ごとの終了日を取得し、表示します
bond = Bond()
end_date = get_end_date(bond)
print(f"end_date = {end_date}")
future = Future()
end_date = get_end_date(future)
print(f"end_date = {end_date}")
stock = Stock()
end_date = get_end_date(stock)
print(f"end_date = {end_date}")
```

これに`mypy`を実行すると

```ps1
union.py:39: error: Argument 1 to "get_end_date" has incompatible type "Stock"; expected "Bond | Future"  [arg-type]
```

といった具合に警告してくれます。使い方がハマれば、かなりの堅牢性向上を期待できます。

## Literal型

`Literal型`を利用することにより、設定される値を制限することができるようになります。オブジェクト指向になれた人間としては、利用するユースケースがあまり想像できませんが、
あまり多様な値を入れたくない場合に利用するとのことでした。
個人的に列挙型でよいのでは？とも思いましたが、列挙型より軽量とのことなので、活躍の場はありそうですね。

```python
from dataclasses import dataclass
from typing import Literal

@dataclass
class Janken:
    name: Literal["グー", "チョキ", "パー"] # 3種類だけに制限

goo = Janken("グー")
print(goo)
press = Janken("プレス機")
print(press)
```

`mypy`の実行結果は

```ps1
literal.py:10: error: Argument 1 to "Janken" has incompatible type "Literal['プレス機']"; expected "Literal['グー', 'チョキ', 'パー']"  [arg-type]
```

となります。リテラル型で利用しないと`mypy`は検知してくれないので、そこが少し不便かもしれません。

## Annotated型

`Literal型`は許可する対象が多くなってくるとやや煩雑になってきますが、これをより柔軟にヒントとして与えることができるようになったものが`Annotated型`です。

```python
from typing import Annotated, Optional
from pydantic import Field

# 値を 1 ~ 3 に限定
def get_janken_name(id: Annotated[int, Field(ge=1, le=3)]) -> Optional[str]:
    jankens = {
        1:"グー", 2: "チョキ", 3:"パー"
    }
    return jankens.get(id)

name = get_janken_name(1)
print(name)
name = get_janken_name(5)
print(name)
```

しかし、これは`Field`の箇所は評価してくれないようで`mypy`を実行してみると

```ps1
Success: no issues found in 1 source file
```

通ってしまいました。正直コメントとの差別化はあまりできていないように思われます。
これからの進化に期待したいと思います。

:::note info
↓↓↓ Annotatedの記載について、一部修正させていただきます ↓↓↓
:::

いただいたコメントを踏まえて色々と試してみたところ、`Annotated`は制約のためというよりは、Javaでいう`Annotation`、C#でいう`Attribute`と一緒でコードに付与できるメタデータでした。
まだ`mypy`はこれを加味するようにはなっていないようですが、`pydantic`で値のバリデーションに活用されています。

```python
from typing import Annotated, Optional
from pydantic import BaseModel, Field

class Foo(BaseModel):
    positive: Annotated[int, Field(gt=0)]
     # エラーが発生しうるため、非推奨とのこと
     # （筆者の環境では問題なく動く）
    non_negative: Optional[int] = Field(ge=0)

foo = Foo(
    positive=1,
    non_negative=-1
)
print(foo)
```

これを実際に実行すると

```
pydantic_core._pydantic_core.ValidationError: 1 validation error for Foo
positive
  Input should be greater than 0 [type=greater_than, input_value=0, input_type=int]
    For further information visit https://errors.pydantic.dev/2.9/v/greater_than
```

このように`positive > 0 でなければならない`というエラーを発生させてくれます。
この記事の静的解析の観点からは少しずれますが、こちらもPythonコードの堅牢性を高めてくれる強力な機能となっていますね。
今はまだ`mypy`では検出できませんが、サポートされるようになればもっと強力になるかもしれません。

## NewType型

既存のクラスから便宜的な新しいクラスを作成することができます。これ自体は型制約というより、便利な機能なのですが、これと型アノテーションを組み合わせることにより、意味付けされたテンポラリなクラスで制約を付けることができるようになります。

```python
from typing import NewType

# 新しいクラスの作成
TrimmedStr = NewType("TrimmedStr", str)

# 新しいクラスに限定
def get_word_count(text: TrimmedStr) -> int:
    return len(text)

text = "  aaaa  "
count = get_word_count(text)
print(count)

trimmed_str = TrimmedStr(text.strip())
count = get_word_count(trimmed_str)
print(count)
```

`mypy`を実行すると

```ps1
new_type.py:9: error: Argument 1 to "get_word_count" has incompatible type "str"; expected "TrimmedStr"  [arg-type]
```

つまり通常の`str`ではダメで、`トリムされたstr`が必要という制約をかけることができます。
トリムしない文字列を渡してしまい、期待する動作ができなかったときに、メソッド側を修正してしまいがちですが、`NewType`を利用することにより、メソッドは`トリムされたstr`を求めているわけだから利用側でトリムしてくれ、ということを明確に伝えることができます。

## Final型

これは地味ですが、個人的には最も待望だったかもしれません。pythonは定数が命名規則のみによって提供されていましたが、この機能により変数の再定義を防ぐことができます。

```python
from typing import Final

class Const:
    PROGRAM: Final = "PYTHON"

Const.PROGRAM = "Java"
print(Const.PROGRAM)
```

`mypy`を実行すると

```ps1
final.py:6: error: Cannot assign to final attribute "PROGRAM"  [misc]
```

今までは**定数といっても結局書き換えられるじゃーん**と思っていたので、私の中では最も衝撃でしたね。

# Github上でのプルリクエスト時のmypyチェック

> githubなどでプルリクエストをマージする際のルールに適用できたりしないの？

というコメントをメンバーよりもらいました。
確かにそこまでやってしまえば、いちいち手元で`mypy`を実行しなくてよいため、
より手軽にチェックを行うことができるなと感じましたので、挑戦してみました。
どうやらプロジェクト直下に`.github/workflows`ディレクトリを作成し、
その中に`*.yml`を置いておけばその内容にしたがってチェックしてくれるようです。（ありがとう`Copilot`！！）
`mypy-check.yml`というファイルを以下のように作成しました。

```yml
name: Mypy Check

on: [pull_request]

jobs:
  mypy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up Python
        uses: actions/setup-python@v2
        with:
          python-version: "3.12" # 使用するPythonのバージョンを指定

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install mypy

      - name: Run mypy
        run: mypy mypy/src # プロジェクトのディレクトリを指定
```

これで`mypy`のエラーがある状態でプルリクエストを作成すると

![02.プルリクチェック（エラー）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/21fb7c70-4c31-6c12-3588-2140a98deecd.png)

こんな感じでエラー判定してくれてマージができません。`Detail`をクリックすると

![03.プルリクチェック（エラー詳細）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/953c225a-2a02-94c4-d905-06aea7b1ef34.png)

明細も表示されます。これを`mypy`エラーが出ない状態に修正し、再びプルリクエストを出すと

![04.プルリクチェック（成功）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/c73b1a42-0703-08d2-d6dd-5b939597d430.png)

はい、これで無事に`mypy`チェックも通り、マージすることができるようになりました！

# まとめ

プログラムに制約をかけるとはいっても、Pythonはやはりスクリプト言語なので、制約に反していたとしても実行はできてしまいます。
これは高々テキストを加工したいだけなのに、いちいち注意されて実行できなかったらまどろっこしくて仕方がないので、しょうがないことだと思います。
ただし、軽量で便利な言語から**堅牢にも作れる言語**に進化していると思いますので、さらなるPythonの飛躍を期待したいと思います。