🐍

Pythonチョットデキルになるためのテクニック集

2024/12/09に公開

Pythonは比較的自由な文法で記述できる言語で、様々な分野で利用されています。
一方で、その自由さ故、どうやって書くのが良いか分かりにくい側面もあります。そこで、本記事では、Python使いに布教したいテクニックをまとめてみました。

TLDR;

Pydantic ClassをModelとしてImmutableに運用しつつ、Controller, Service, etc にTyping.Protocol使うとだいぶ体験が良い。

開発環境は、uvTaskfileの組み合わせがおすすめ。

リポジトリ

参照しやすいように、GitHubで動くfizzbuzzのコードを用意しました。必要に応じて、ご利用ください。

https://github.com/shunsock/fizzbuzz

環境構築

Pythonでは、メジャーな環境構築方法がいくつかあります。

  • virtual env
  • Docker (この後に登場するツールとの併用を含む)
  • PyEnv + Poetry
  • uv

著者はuvを推奨しています。

uvを用いるといくつか恩恵がありますが、Pythonのインタープリタとパッケージ、開発者ツールをひとまとめに管理できるので環境構築が非常に楽な点が個人的なおすすめポイントです。

開発者ツール

開発者ツールを用いることで、開発やレビューの負荷を抑えることができます。下記は著者がよく使用しているツールです。

特に静的解析ツールは、動的型付け言語であるPythonを「実行前」に評価できるので、最も重要なツールの1つと言っても過言ではないでしょう。Typingと合わせて使うことが多いです。

事例を公式より引用します。mypyを用いることで型情報などが間違っているプログラムを実行前に検知できます。

number = input("What is your favourite number?")
print("It is", number + 1)  # error: Unsupported operand types for + ("str" and "int")

Coverageは聞いたことのない方もいるかもしれません。これはテストの網羅性をチェックしてくれるツールです。

task: [validate] uv run coverage report -m
Name                                                 Stmts   Miss  Cover   Missing
----------------------------------------------------------------------------------
app/__init__.py                                          0      0   100%
app/service/runner/fizzbuzz_presenter/buzz.py            5      0   100%
app/service/runner/fizzbuzz_presenter/fizz.py            5      0   100%
app/service/runner/fizzbuzz_presenter/fizz_buzz.py       5      0   100%
app/service/runner/fizzbuzz_presenter/otherwise.py       5      0   100%
app/service/runner/runner_config.py                     11      0   100%
app/service/runner/selector.py                          17      0   100%
----------------------------------------------------------------------------------
TOTAL                                                   48      0   100%

こんなに開発者ツールを使うと、実行が面倒と思った方もいるかもしれません。
そのような時に活躍するのが、Taskfile などのタスクランナーです。Task では、YAMLという書式でワークフローを記載します。

version: 3

tasks:
  validate:
    cmds:
      - uv run ruff check --fix app
      - ...

実行してみます。

$task validate
task: [validate] uv run ruff check --fix app
...
task: [validate] uv run ruff check --select I --fix app
...
task: [validate] uv run black .
...
21 files left unchanged.
task: [validate] uv run mypy ./app --strict
...
task: [validate] uv run coverage run -m pytest .
...
task: [validate] uv run coverage report -m
...

一撃必殺!全てのコマンドをコマンド1つで実行することができました。

コードのフォーマットやアノテーションなどをレビューで指摘するのは不毛です。CIやgitのhookツールなどで事前に検知するのが望ましいでしょう。著者はGitHub Actionsで検知する方法をよく用いています。

commitする前にチェックするツールとしては下記が有名です。

補足

  • isortも便利なツールですが、ruff check --select I --fix で代用できるのでここでは挙げませんでした。
  • GNU Makeも強力なツールで、大体のOSにインストールされているツールです。Makefile警察と関わりたくないので、ここでは挙げませんでした。Makefile警察の解説
  • リポジトリにおけるTask Runnerへのリンク: Taskfile.yml

コーディング: Syntax

コーディング編では、FizzBuzzのコードを使いながら、紹介をします

Typing.Final

Finalを使うと比較的簡単に変数の上書きから守ることができます。

hello: Final[str] = "Hello World"
hello = "Over Written!" # Error with Mypy

ちなみに定数関数は関数は上書きできてしまうので注意です。

def hello() -> str:
    return "Hello, World!"


if __name__ == "__main__":
    print(hello())  # Hello, World!

    h = hello()  # Hello, World!
    h = lambda: print("Overwritten!")

    h()  # Overwritten!

詳細は下記をご参照ください。
https://zenn.dev/shundeveloper/articles/ede53caa9632f5

Typing.Protocol

Python 3.8以降では、typingモジュールにProtocolを使うことができます。Protocolは、静的解析ツールがダックタイピングを認識するための仕組みを提供します。Protocolを用いれば、クラスが特定のメソッドやプロパティを持つことを静的に宣言できます。

FizzBuzzのコードで考えてみましょう。

from app.service.runner.runner_config import RunnerConfig
from app.service.runner.selector import Selector


class Runner:
    def __init__(self, config: RunnerConfig):
        self.config = config

    def run(self) -> None:
        """Run the fizzbuzz algorithm"""
        for i in range(self.config.start, self.config.end + 1):
            presenter = Selector.select(i)  # 👈 ここで出力するClassを決める
            presenter.present() # 👈 presenterのClassはpresent()を実装している

このコードを静的解析するなら、presenterに入るClassがpresent()を実装しているか確認したいと思うはずです。それでは検証していきます。

まず、Protocolを決めます。今回はpresentを実装して欲しいので次のProtocolを作成しました。

from typing import Protocol


class FizzBuzzPresenter(Protocol):
    def present(self) -> None:
        pass

次に実装クラスを作成します。

class Fizz:
    def __str__(self) -> str:
        return "Fizz"

    def present(self) -> None:
        print(self.__str__())

最後にSelector.select()Protocolを返すようにします。

class Selector:
    @staticmethod
    def select(count: int) -> FizzBuzzPresenter:

これでProtocolを実装しているクラスのみを返すことを保証できました。試しにFizzの実装を変更してエラーを出してみましょう。

class Fizz:
    def __str__(self) -> str:
        return "Fizz"

    def print(self) -> None:  # 👈 presentを実装しない!
        print(self.__str__())

静的解析に書けてみると、エラーがでます!

task: [validate] uv run mypy ./app --strict
app/service/runner/selector.py:25: error: Incompatible return value type (got "Fizz", expected "FizzBuzzPresenter")  [return-value]
app/service/runner/selector.py:25: note: "Fizz" is missing following "FizzBuzzPresenter" protocol member:
app/service/runner/selector.py:25: note:     present
Found 1 error in 1 file (checked 15 source files)
task: Failed to run task "validate": exit status 1

補足:
PythonにはJavaのようなInterfaceがありません。ABCという抽象化用のパッケージもありますが、静的解析との相性が悪いという課題があります。

要約変数

Syntaxから外れるかもしれませんが、強力なテクニックなので紹介。例えばFizzBuzzにおける条件分岐を考えてみましょう。次のコードが来たときにどのぐらいレビューに時間を書けますか...?

class Selector:
    @staticmethod
    def select(count: int) -> FizzBuzzPresenter:
        if count % 3 == 0 and count % 5 == 0:
            return FizzBuzz()
        elif count % 3 == 0:
            return Fizz()
        elif count % 5 == 0:
            return Buzz()
        else:
            return Otherwise(count)

上記のようなコードの場合、if文における条件式全てに目を通さないとコードが正しく書かれているか検討できなさそうです。一方で次のようなコードではどうでしょうか?

class Selector:
    @staticmethod
    def select(count: int) -> FizzBuzzPresenter:
        divisible_by_3 = count % 3 == 0
        divisible_by_5 = count % 5 == 0

        if divisible_by_3 and divisible_by_5:
            return FizzBuzz()
        elif divisible_by_3:
            return Fizz()
        elif divisible_by_5:
            return Buzz()
        else:
            return Otherwise(count)

ほとんどの人は最初の divisible_by_3divisible_by_5 を読み、その後のコードは流し目に読むことができたのではないでしょうか?要約変数を適切に用いれば、条件式の視認性を向上させることが可能です。

Match

※ この章だけ、FizzBuzzに入れられませんでした ><

Pythonのmatch文は、Python 3.10から導入された構文です。match文を使うことで、変数の値や構造に基づいた分岐処理を簡潔に記述できます。

match value:
    case pattern1:
        # pattern1 に一致した場合の処理
    case pattern2:
        # pattern2 に一致した場合の処理
    case _:
        # どのパターンにも一致しない場合の処理

PythonのMatch文は柔軟です。次のようなorやwildcardが使えますし、

value = 3

match value:
    case 1 | 2 | 3:
        print("1, 2, または 3です")
    case _:
        print("その他の値")

caseにガードをつけることもできます。

def evaluator(op, a, b):
    match (op, a, b):
        case ('+', a, b):
            return a + b
        case ('-', a, b):
            return a - b
        case ('*', a, b):
            return a * b
        case ('/', a, b) if b != 0:
            return a / b
        case _:
            return "Invalid operation"

print(evaluator('+', 2, 3))  # 5
print(evaluator('/', 4, 0))  # Invalid operation

詳細を知りたい方は次のチュートリアルを読んでみましょう。
https://peps.python.org/pep-0636/

コーディング: パッケージ

Pydantic

PydanticはValidationライブラリです。
https://docs.pydantic.dev/latest/

import json

from pydantic import BaseModel, PositiveInt, ValidationInfo, ValidationError, field_validator


class RunnerConfig(BaseModel):
    start: PositiveInt
    end: PositiveInt

    @field_validator("end")
    @classmethod
    def check_start_less_than_end(
        cls, end: PositiveInt, info: ValidationInfo
    ) -> PositiveInt:
        start = info.data.get("start")
        if start is not None and start > end:
            raise ValueError("start must not be greater than end")
        return end


class FizzbuzzController:
    def __init__(self, start: int, end: int) -> None:
        self.start = start
        self.end = end

    def run(self) -> None:
        try:
            runner = Runner(RunnerConfig(start=self.start, end=self.end))
            runner.run()
        except ValidationError as e:
            print("UserError: Input Values are invalid")
            print(json.dumps(e.errors(), indent=4))
            exit(1)

上記のControllerに、int型のstartendがあります。これらは、intは満たしますが、FizzBuzzとしては想定しないような値が入りそうです。例えば、0, -1 などが入る可能性があります。

そこで、RunnerConfigで、Validationを行なっています。実際に-1を入力してみます。

$ task run -- --start 10 --end -1
UserError: Input Values are invalid
[
    {
        "type": "greater_than",
        "loc": [
            "end"
        ],
        "msg": "Input should be greater than 0",
        "input": -1,
        "ctx": {
            "gt": 0
        },
        "url": "https://errors.pydantic.dev/2.10/v/greater_than"
    }
]
task: Failed to run task "run": exit status 1

エラーとその理由が書かれたURLが表示されました。他にもjsonへのダンプ用のmethodが用意されていたり、Fieldによる柔軟な型定義が書けたりする機能があったりします。

TypeGuard

どうしても実行時に型の確認をしたい時におすすめなのがTypeGuard。
https://typeguard.readthedocs.io

check_type()TypeCheckErrorでifを使わずに型の検証ができます。

from typeguard import check_type

# Raises TypeCheckError if there's a problem
check_type([1234], List[int])

参考書籍・情報収集

著者が参考にしている本を紹介します

SNS

情報収集は次の場所で行なっています。(もちろん、GitHubでも。)

まとめ

紹介してきたテクニックを使えば上級者の入り口には立てるはずです...!!

まずは小さなところから実践してみてください。これまで真っ白だったあなたのソースコードがキラキラした彩りあるソースコードに変わっていることに気づいたら、上級者の入口です。

参照: https://zenn.dev/yosemat/books/1e021f1745566f/viewer/50516a

みんなのおすすめテクニックあったらコメント欄で教えてください!!

Discussion