【Python】「is_valid」に潜む副作用の危険性:関数は「問い合わせ」と「命令」に分離しよう

関数を設計する上で、その関数が「何をするか」を明確に定義することは、コードの品質を左右する重要な要素です。多くの関数は、値を計算して返すだけのものと、システムの状態(DBのデータ、ファイル、オブジェクトの属性など)を変更するものの2種類に大別できます。

問題は、これら2つの役割が一つの関数に混在している場合に発生します。特に、何かを**問い合わせる(Query)ように見える関数が、裏でシステムの状態を命令(Command)**して変更してしまう「副作用(Side Effect)」を持つと、コードは非常に予測しにくく、危険なものになります。

目次

副作用を持つ関数の具体例

あるユーザープロフィールの情報(名前、メールアドレス)を更新する処理を考えます。更新前に、ユーザー名が禁止ワードリストに含まれていないかを検証するis_profile_validという関数を作成しました。

# 悪い例:問い合わせを行う関数が、副作用としてデータの更新・保存を行っている

# ユーザーを表すシンプルなクラス
class User:
    def __init__(self, name: str, email: str, status: str = "PENDING"):
        self.name = name
        self.email = email
        self.status = status

    def save(self):
        # 本来はデータベースに保存する処理
        print(f"--- {self.name}の状態を保存しました (Status: {self.status}) ---")

# 禁止されているユーザー名のリスト
BANNED_USERNAMES = {"admin", "root", "superuser"}

def is_profile_valid(user: User) -> bool:
    """ユーザープロフィールの有効性を検証する"""
    if user.name in BANNED_USERNAMES:
        return False

    # ★問題点:検証関数の中で、状態の変更と保存(副作用)を行っている
    user.status = "APPROVED"
    user.save()
    return True

def update_user_profile(user: User, new_name: str, new_email: str):
    """ユーザーのプロフィールを更新する"""
    user.name = new_name
    user.email = new_email

    # この関数を呼び出すだけで、userの状態が変更・保存されてしまう
    if not is_profile_valid(user):
        print("エラー: プロフィールが有効ではありません。")
        return None

    # 本来はこちらで保存処理をしたいが、is_profile_valid内で実行済み
    # user.save() #
    return user

# 実行例
user_a = User(name="guest", email="guest@example.com")
update_user_profile(user_a, "taro", "taro@example.com")

なぜこれが問題なのか?

is_profile_valid という名前は、プロフィールが有効かどうかを問い合わせるだけの関数に見えます。しかし、その内部では user オブジェクトの status を変更し、save() メソッドを呼び出して永続化するという、重大な命令を実行しています。

もし、別の開発者が単に「このユーザーは有効かな?」と状態を確認したいだけ(更新はしたくない)のつもりで is_profile_valid を呼び出した場合、意図せずユーザーのステータスが APPROVED に更新され、データベースに保存されてしまいます。

このように、関数の名前に反した副作用は、発見が困難なバグの温床となります。


解決策:問い合わせと命令を分離する (CQS原則)

この問題を解決するのが、**コマンド・クエリ分離(Command-Query Separation, CQS)**という設計原則です。

  1. 問い合わせ (Query): システムの状態を返すだけで、副作用を一切持たない。
  2. 命令 (Command): システムの状態を変更するだけで、値を返さない(または自分自身を返す程度)。

この原則に従って、先ほどのコードを修正します。

1. 副作用のない「問い合わせ」関数を定義する

まず、ユーザー名が有効かどうかを検証するだけの、純粋な「問い合わせ」関数を作成します。この関数は引数として渡された文字列を評価し、真偽値を返すことだけに責任を持ちます。

# 良い例①:副作用のない、純粋な問い合わせ関数
def is_username_allowed(username: str) -> bool:
    """ユーザー名が許可されているかを検証し、真偽値を返す。"""
    return username not in BANNED_USERNAMES

この関数は、引数で渡された username 以外、外部の状態に一切影響を与えません。そのため、いつ、どこで、何度呼び出しても安全です。

2. 状態の変更を「命令」関数に集約する

次に、ユーザープロフィールの更新という「命令」の責任をすべて負う関数を定義します。この関数の中で、先ほど作成した「問い合わせ」関数を呼び出します。

# 良い例②:状態の変更という責務を集約した命令関数
def update_user_profile(user: User, new_name: str, new_email: str):
    """ユーザーのプロフィールを更新し、状態を保存する。"""

    # 1. 副作用のない関数で問い合わせる
    if not is_username_allowed(new_name):
        print(f"エラー: ユーザー名 '{new_name}' は許可されていません。")
        return None

    # 2. この関数内で、状態の変更と保存という命令をすべて行う
    user.name = new_name
    user.email = new_email
    user.status = "APPROVED"
    user.save() # 状態の変更がすべて終わってから、最後に保存する
    
    return user

修正後の update_user_profile は、ロジックの流れが非常に明確です。

  1. ユーザー名が許可されているか問い合わせる
  2. 許可されていなければ、処理を中断する。
  3. 許可されていれば、プロフィールの更新とステータスの承認という命令を実行し、最後に保存する。

すべての状態変更が一つの関数にまとまっているため、コードの振る舞いが予測しやすくなりました。


まとめ

関数の副作用は、それ自体が悪なのではありません。データベースの更新やファイルの書き込みなど、副作用は多くのアプリケーションで必要不可欠です。問題なのは、副作用が関数の名前に反して、隠れた場所で実行されることです。

  • 問い合わせ (Query) の関数: is_..., has_..., get_... といった名前の関数は、副作用を持たないように設計する。
  • 命令 (Command) の関数: update_..., create_..., save_... といった名前の関数に、状態を変更する処理を集約する。

この「問い合わせ」と「命令」を意識的に分離することで、コードは各段にテストしやすく、再利用性が高く、そして何より安心して読むことができるようになります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次