0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

演習形式で学ぶPythonプログラミング vol.18 ~関数の設計と記述スタイル~

Posted at

まえがき

Python3.13環境を想定した演習問題を扱っています。変数からオブジェクト指向まできちんと扱います。続編として、データサイエンティストのためのPython(NumPyやpandasのような定番ライブラリ編)や統計学・統計解析・機械学習・深層学習・SQLをはじめCS全般の内容も扱う予定です。

対象者は、[1]を一通り読み終えた人、またそれに準ずる知識を持つ人とします。ただし初心者の方も、文法学習と同時並行で進めることで、文法やPythonそれ自体への理解が深まるような問題を選びました。前者については、答えを見るために考えてみることをお勧めしますが、後者については、自身と手元にある文法書を読みながら答えを考えるというスタイルも良いと思います。章分けについては[1]および[2]を参考にしました。

ストックしている問題のみすべて公開するのもありかなと考えたのですが、あくまで記事として読んでもらうことを主眼に置き、1記事1トピック上限5問に解答解説を付与するという方針で進めます。すべての問題を提供しない分、自分のストックの中から、特に読むに値するものを選んだつもりです。
また内容は、ただ文法をそのままコーディング問題にするのではなく、文法を俯瞰してなおかつ実務でも応用できるものを目指します。前者については世の中にあふれていますし、それらはすでに完成されたものです(例えばPython Vtuberサプーさんの動画[3]など)。これらを解くことによって得られるものも多いですし、そのような学習もとても有効ですが、私が選んだものからも得られるものは多いと考えます。
私自身がPython初心者ですので、誤りや改善点などがあればご指摘いただけると幸いです。
➡ 最近複数の方から、誤りのご指摘、編集リクエストによる修正/改良をいただいております。この場を借りてですが、感謝申し上げます。

今回は「関数の設計と記述スタイル」について扱います。それでは始めましょう!

Q.18-1

あなた:
Pythonにおいて「1関数1責務」という原則が守られないときに、保守・デバッグ・再利用にどのような支障が出るかを説明せよ。

問題背景

【1】「1関数1責務」とは何か

「1関数1責務(Single Responsibility Principle)」とは、1つの関数が1つの明確な目的だけを担うべきであるという原則である。これはソフトウェア設計の基本であり、次のような利点をもたらす:

・関数が短く明快になり、理解しやすくなる
・テストが容易になる
・再利用性が高くなる
・不具合発生時の影響範囲が局所的になる

【2】守られない場合に起きる典型的な問題

【2.1】保守性の低下
・複数の異なる責務を一つの関数に押し込めると、小さな変更でも別の機能を壊す可能性がある
・関数の長さが肥大化し、コードの読みやすさと理解コストが悪化する

【2.2】デバッグの困難
・エラーがどの処理由来かを特定しづらくなる
・ログや例外が混在し、スタックトレースが把握困難になる

【2.3】再利用性の喪失
・一部の機能だけ再利用したい場合でも、巨大な関数全体を呼び出さなければならなくなる
・再利用できるように切り出すには、副作用の分離が難しくなる

解答例とコードによる実践

「ファイル読み込み+データ解析+表示」を1つの関数で行っている設計と、それを1関数1責務に分解した改善版を比較

【悪い例】:複数責務を1関数に押し込めた設計

# 【1】関数を定義する(ファイルパスを引数に受け取り、その中の数値データを処理・表示する)
def process_and_display_data(file_path: str):
    try:
        # 【2】指定されたファイルを読み込みモード("r")で開く(自動的にクローズされる with 文を使用)
        with open(file_path, "r") as f:
            # 【3】ファイルの全行をリストとして読み込む(1行ずつ文字列として格納される)
            lines = f.readlines()

        # 【4】各行を前処理して、数字だけを int 型に変換し、リストに格納する(空行や非数値は除外される)
        values = [int(line.strip()) for line in lines if line.strip().isdigit()]

        # 【5】数値の合計を計算する
        total = sum(values)

        # 【6】リストが空でない場合に平均を計算。空なら 0(ゼロ除算対策)
        avg = total / len(values) if values else 0

        # 【7】合計値を表示する
        print(f"合計: {total}")

        # 【8】平均値を表示する
        print(f"平均: {avg}")

    # 【9】ファイルが存在しない・読み取りエラー・数値変換エラーなど、あらゆる例外を捕捉して表示する
    except Exception as e:
        print(f"エラー: {e}")

上のコードには次のような問題点がある。
・読み込み・変換・集計・表示・エラーハンドリングがすべて1つの関数に混在している
・エラー発生時にどの処理が失敗したのか不明確
・平均値の再利用がしたくても関数内に閉じていて取り出せない
・単体テストが困難である(表示処理の副作用がテストを阻害)

**【改善例】:1関数1責務に分解

# 【1】ファイルの中身を1行ずつ読み込み、文字列のリストとして返す関数を定義する
def read_file(file_path: str) -> list[str]:
    with open(file_path, "r") as f:  # 【2】ファイルを読み込みモードで開く(自動的に閉じられる)
        return f.readlines()  # 【3】各行を文字列のリストとして返す(改行を含む)

# 【4】読み込んだ各行を前処理し、整数のリストに変換する関数を定義する
def parse_values(lines: list[str]) -> list[int]:
    return [int(line.strip()) for line in lines if line.strip().isdigit()]  # 【5】空行や非数値を除き、文字列を整数に変換する

# 【6】合計値と平均値を計算し、タプルで返す関数を定義する
def compute_stats(values: list[int]) -> tuple[int, float]:
    total = sum(values)  # 【7】数値の合計を求める
    avg = total / len(values) if values else 0  # 【8】空のときはゼロ除算を回避して平均値を0にする
    return total, avg  # 【9】合計と平均をまとめて返す(tuple)

# 【10】合計値と平均値を画面に表示する関数を定義する
def display_stats(total: int, avg: float):
    print(f"合計: {total}")  # 【11】合計を表示する
    print(f"平均: {avg}")    # 【12】平均を表示する

# 【13】処理全体を統合する main 関数を定義する(例外処理の責務も一括で管理)
def main(file_path: str):
    try:
        lines = read_file(file_path)  # 【14】ファイルから全行を読み込む
        values = parse_values(lines)  # 【15】前処理と数値変換を行う
        total, avg = compute_stats(values)  # 【16】合計と平均を計算する
        display_stats(total, avg)  # 【17】計算結果を表示する
    except Exception as e:
        print(f"処理失敗: {e}")  # 【18】例外が発生した場合のエラーメッセージを出力する

# 【19】main 関数を実行して、sample_data.txt の内容を処理する
main("sample_data.txt")

実行結果
合計: 60
平均: 20.0

本問のまとめ

・「1関数1責務」の原則が守られないと、以下のような支障が生じる:

観点 支障内容
保守性 小さな変更が他の機能に波及しやすく、変更箇所の特定が困難になる
デバッグ エラー発生位置や原因が関数内に混在し、調査負荷が増す
再利用性 再利用可能な処理を個別に切り出せず、関数ごと再定義が必要になる
テスト性 出力やファイル処理などの副作用が多く、ユニットテストが行いにくくなる

・逆に、責務を分離することで関数のテスト・理解・拡張・再利用が容易になる。
・Pythonでは柔軟に関数を組み合わせられるため、小さく分離された関数を積み上げる設計が極めて有効である。

補足

【1】関数をオブジェクトにカプセル化して責務ごとにクラスに分離する設計

【設計方針】

・各責務(入出力、処理、表示など)をメソッドとして分離したクラスに持たせる
・状態(ファイルパスや中間結果)はインスタンス属性として保持する
呼び出し側は「調整ロジック」だけに集中できる

構成図は以下

+-------------------+       +--------------------+       +-----------------+
| FileReader        |       | DataAnalyzer       |       | ConsoleReporter |
|-------------------|       |--------------------|       |-----------------|
| - path            |       | - values           |       |                 |
| + read_lines()    | --->  | + parse_values()   | --->  | + display()     |
+-------------------+       | + compute_stats()  |       +-----------------+   
                            +--------------------+

【コード】:責務を分離したクラス設計

# 【1】ファイル読み取り専用クラスを定義する(FileReader)
class FileReader:
    def __init__(self, path: str):  # 【2】初期化時にファイルパスを受け取って保持する
        self.path = path  # 【3】インスタンス変数にパスを保存する

    def read_lines(self) -> list[str]:  # 【4】ファイルを読み込み、各行を文字列として返すメソッド
        with open(self.path, "r") as f:  # 【5】ファイルを読み込みモードで開く(自動で閉じられる)
            return f.readlines()  # 【6】全行をリストとして返す(改行付き)

# 【7】データ解析専用クラスを定義する(DataAnalyzer)
class DataAnalyzer:
    def __init__(self, lines: list[str]):  # 【8】初期化時にファイルの各行を受け取る
        self.lines = lines  # 【9】行データをインスタンス変数として保持
        self.values = self.parse_values()  # 【10】初期化時に数値データへ変換して保持しておく

    def parse_values(self) -> list[int]:  # 【11】前処理:数値行のみ抽出して int に変換
        return [int(line.strip()) for line in self.lines if line.strip().isdigit()]  # 【12】stripで空白除去、isdigitで数値判定

    def compute_stats(self) -> tuple[int, float]:  # 【13】合計と平均を計算する
        total = sum(self.values)  # 【14】合計を計算
        avg = total / len(self.values) if self.values else 0  # 【15】リストが空なら平均は0にする
        return total, avg  # 【16】合計と平均をタプルで返す

# 【17】出力専用クラスを定義する(ConsoleReporter)
class ConsoleReporter:
    def display(self, total: int, avg: float):  # 【18】合計と平均を受け取って表示する
        print(f"合計: {total}")  # 【19】合計を出力
        print(f"平均: {avg}")    # 【20】平均を出力

# 【21】統合された処理を行う関数 run_pipeline を定義する(全体の流れを管理)
def run_pipeline(path: str):  # 【22】ファイルパスを引数に取り、パイプライン処理を開始
    try:
        reader = FileReader(path)  # 【23】FileReader のインスタンスを生成
        lines = reader.read_lines()  # 【24】ファイルから全行を読み込む

        analyzer = DataAnalyzer(lines)  # 【25】行データを解析用クラスに渡す
        total, avg = analyzer.compute_stats()  # 【26】合計と平均を計算する

        reporter = ConsoleReporter()  # 【27】出力用クラスのインスタンスを生成
        reporter.display(total, avg)  # 【28】合計と平均を表示する
    except Exception as e:  # 【29】ファイルが存在しないなどの例外を捕捉
        print(f"[ERROR] {e}")  # 【30】エラーメッセージを表示

# 【31】実行部分:sample_data.txt を対象としてパイプライン処理を開始
run_pipeline("sample_data.txt")  # 【32】main 関数のような立ち位置。全処理がこの1行から始まる

【この設計の利点】

・各クラスが「単一の責務」しか持たない
・テストが非常に容易になる(クラス単体で検証可能)
・クラスを入れ替えれば他の出力先(GUI・CSV)への拡張も容易である

【2】関数合成による関心分離(functional composition)

【設計方針】

・関数を「小さく・独立に」定義し、データを流すパイプラインのように組み合わせる
・Pythonの functoolstoolz、関数合成ライブラリを使って合成することも可能

# 【1】ファイルを読み込み、1行ずつ文字列のリストとして返す関数
def read(path: str) -> list[str]:
    with open(path) as f:  # 【2】ファイルを開く(自動で閉じられる with 構文)
        return f.readlines()  # 【3】全行をリストとして返す(1行ずつ文字列)

# 【4】行ごとの文字列を数値に変換する関数(空行や非数値は除外)
def parse(lines: list[str]) -> list[int]:
    return [int(x.strip()) for x in lines if x.strip().isdigit()]  # 【5】前処理と数値変換のワンライナー

# 【6】合計と平均を計算する関数
def stats(values: list[int]) -> tuple[int, float]:
    total = sum(values)  # 【7】合計を計算
    avg = total / len(values) if values else 0  # 【8】ゼロ除算対策として、空リストなら平均は 0
    return total, avg  # 【9】合計と平均をタプルで返す

# 【10】合計と平均を受け取り、画面に出力する関数
def report(result: tuple[int, float]) -> None:
    total, avg = result  # 【11】タプルを展開して total, avg を取り出す
    print(f"合計: {total}\n平均: {avg}")  # 【12】出力処理

# 【13】関数合成に必要な reduce をインポート(関数を1つずつ適用して値を流す)
from functools import reduce  # 【14】関数を連鎖的に適用するためのツール

# 【15】パイプラインを構成し、関数を順に適用して処理を行う関数を定義する
def run(path: str):
    pipeline = [read, parse, stats, report]  # 【16】関数のリストを定義(実行順に並べる)
    reduce(lambda acc, fn: fn(acc), pipeline, path)  # 【17】初期値 path を出発点に、各関数を順番に適用していく

# 【18】実行:sample_data.txt を対象にデータ処理パイプラインを実行する
run("sample_data.txt")  # 【19】ファイル → 前処理 → 統計計算 → 出力の流れが一気に流れる
処理の流れ(データの流れ)
path (str)
↓ read(path)
list[str]
↓ parse(list[str])
list[int]
↓ stats(list[int])
tuple[int, float]
↓ report(tuple[int, float])
None(出力のみ)

【この設計の利点】

・データの流れが視覚的・構造的に明確
・各処理の切り離し・テスト・差し替えが容易
map, filter, reduce などと相性が良い

補足のまとめ

設計手法 特徴
クラスによる責務分離 各責任をオブジェクト化し状態を保持しやすい。OOP的拡張に強い
関数合成によるパイプライン設計 処理の構造が明快。関数指向プログラミングとの親和性が高い
共通点 「1関数/1クラス = 1責務」の設計原則に基づく。保守・拡張・再利用に強い

Q.18-2

関数の中で同じ処理(例:正規化計算)が複数箇所に書かれているとき、関数抽出の対象とすべき理由を述べよ。

問題背景

【1】「関数抽出(Extract Function)」とは何か

関数抽出とは、プログラム中の繰り返し現れる処理や意味のある処理単位を、新たな関数として切り出すリファクタリング手法である。

【2】なぜ関数抽出が重要か(理由と効果)

抽出すべき理由 説明
重複削減(DRY原則) 同じロジックを複数箇所に書くと修正時に漏れが生じやすい
意味の明確化 何をしているかを「関数名」で説明できるようになる
保守性の向上 ロジックを1か所に集約することで、修正・改良の影響範囲を局所化できる
再利用性の向上 他の関数・スクリプトでも簡単に再利用できる
テストしやすい単位に切り出せる 特定機能をユニットテストで個別に検証できる

解答例とコードによる実践

悪い例:同じ処理が2箇所に重複している

# 【1】2つの数値リスト x_vals, y_vals を比較分析する関数を定義する
def analyze_and_compare(x_vals, y_vals):

    # 【2】x_vals の最小値を取得する(正規化のため)
    x_min = min(x_vals)

    # 【3】x_vals の最大値を取得する(正規化のため)
    x_max = max(x_vals)

    # 【4】x_vals を [0, 1] の範囲にスケーリング(正規化)する
    x_norm = [(x - x_min) / (x_max - x_min) for x in x_vals]

    # 【5】y_vals の最小値を取得する(正規化のため)
    y_min = min(y_vals)

    # 【6】y_vals の最大値を取得する(正規化のため)
    y_max = max(y_vals)

    # 【7】y_vals を [0, 1] の範囲にスケーリング(正規化)する
    y_norm = [(y - y_min) / (y_max - y_min) for y in y_vals]

    # 【8】正規化された x_norm と y_norm の各要素差(絶対値)を求めてリストにする
    diff = [abs(a - b) for a, b in zip(x_norm, y_norm)]

    # 【9】差の合計値を計算して表示する(類似度の指標として使える)
    print(f"差の合計: {sum(diff)}")

【問題点】

・同じ「min-max 正規化」処理が2回繰り返されており、再利用できない上に保守も煩雑である。
・どちらも「正規化している」とはコードから直ちにわからず、読みづらい。
・もし正規化方法を変更したい場合(例:Z-score法)、2か所書き換えなければならない。

改善例:関数抽出によって重複を排除し、可読性・保守性を向上

# 【1】min-max 正規化を行う関数を定義(入力リストの値を [0,1] にスケーリングする)
def normalize(values: list[float]) -> list[float]:
    min_val = min(values)  # 【2】リスト内の最小値を取得
    max_val = max(values)  # 【3】リスト内の最大値を取得

    if max_val == min_val:  # 【4】全要素が同じ場合、ゼロ除算を避けるため条件分岐
        return [0.0 for _ in values]  # 【5】全て同じ値のときは 0.0 に正規化して返す

    # 【6】各値を (値 - 最小値)/(最大値 - 最小値) により 0〜1 に変換して返す
    return [(v - min_val) / (max_val - min_val) for v in values]

# 【7】正規化した2つのデータを比較し、差の合計を表示する分析関数を定義
def analyze_and_compare(x_vals: list[float], y_vals: list[float]) -> None:
    x_norm = normalize(x_vals)  # 【8】x_vals を正規化する(0〜1に変換)
    y_norm = normalize(y_vals)  # 【9】y_vals を正規化する

    diff = [abs(a - b) for a, b in zip(x_norm, y_norm)]  # 【10】正規化後の差分(絶対値)を計算してリストに格納
    print(f"差の合計: {sum(diff)}")  # 【11】差の合計(L1距離)を出力する

# 【12】テスト用の x データを定義(整数のリスト)
x = [10, 20, 30, 40]

# 【13】テスト用の y データを定義(x より5だけ大きい値のリスト)
y = [15, 25, 35, 45]

# 【14】分析関数を実行して、xとyの正規化後の差の合計を確認
analyze_and_compare(x, y)

実行結果
差の合計: 0.5

本問のまとめ

・同一処理が関数内に複数回登場する場合、それを関数抽出することは設計上必須である。
・関数抽出により、DRY原則(Don't Repeat Yourself)が守られ、保守・拡張・理解が容易になる。
・Pythonでは関数が「第一級オブジェクト」であるため、関数を抽出・再利用・合成しやすい構造がもともと備わっている。
・特にデータ変換処理やスケーリングなど、意味が明確かつ他でも再利用され得る処理は、関数化することで設計品質が大幅に向上する。

Q.18-3

関数に渡す引数が5つ以上になる場合に設計的に望ましくない理由と、その対処法(例:辞書やクラスによるラップ)を示せ。

問題背景

【1】関数引数が多いと何が問題か

Pythonにおいて、関数に渡す引数が5つを超えるような設計は、以下の観点から望ましくないとされる:

問題点 説明
可読性の低下 引数が多いと、関数の目的と引数の意味が把握しづらくなる
使用時の負担増加 呼び出し側でも順序や意味を間違いやすくなる
保守性の低下 引数の増減や順序変更により、関数呼び出し元のコードが壊れやすい
拡張性の欠如 柔軟に機能を追加しづらくなる

【2】関数引数を整理する主な方法

手法 概要
辞書でラップ dict に必要な情報をまとめ、関数に1つの引数として渡す
クラスでラップ データ構造をクラスにカプセル化し、明示的な型属性名で可読性を高める

解答例とコードによる実践

悪い例:5つ以上の引数を持つ関数

# 【1】register_user 関数を定義する。5つの情報(名前・年齢・メール・電話・住所)を受け取って処理する。
def register_user(name: str, age: int, email: str, phone: str, address: str) -> None:
    # 【2】受け取った引数を文字列として整形し、1行で画面に出力する
    print(f"ユーザー情報: {name}, {age}, {email}, {phone}, {address}")

# 【3】定義した関数を呼び出す。引数にユーザーの情報を指定して実行する
register_user("Alice", 30, "alice@example.com", "090-1234-5678", "Tokyo")

【問題点】
・引数の順序を間違えると誤動作につながる
・呼び出し元で引数の意味が不明瞭である
・今後新たな属性(性別・職業など)が追加された場合、関数定義も呼び出し元もすべて修正が必要になる

改善例1:辞書によるラップ

# 【1】関数定義:辞書で渡す方式
def register_user_dict(user_info: dict[str, str]) -> None:  # 【1】
    print(f"ユーザー情報: {user_info['name']}, {user_info['age']}, "
          f"{user_info['email']}, {user_info['phone']}, {user_info['address']}")  # 【2】

# 【3】辞書を構築して渡す
user = {  # 【3】
    "name": "Bob",  # 【4】
    "age": "28",  # 【5】
    "email": "bob@example.com",  # 【6】
    "phone": "080-5678-4321",  # 【7】
    "address": "Osaka"  # 【8】
}
register_user_dict(user)  # 【9】

改善例2:クラスによるカプセル化(推奨)

# 【1】ユーザー情報をまとめて扱うためのクラス UserProfile を定義する
class UserProfile:  # クラスは複数のデータ(属性)を1つにまとめるための設計図
    def __init__(self, name: str, age: int, email: str, phone: str, address: str):  # 【2】インスタンス生成時に渡すべき情報を引数として受け取る
        self.name = name        # 【3】name をインスタンス変数に保存
        self.age = age          # 【4】age をインスタンス変数に保存
        self.email = email      # 【5】email をインスタンス変数に保存
        self.phone = phone      # 【6】phone をインスタンス変数に保存
        self.address = address  # 【7】address をインスタンス変数に保存

# 【8】UserProfile のインスタンス(オブジェクト)を引数として受け取る関数を定義する
def register_user_obj(user: UserProfile) -> None:
    # 【9】user オブジェクトの属性を取り出して表示する
    print(f"ユーザー情報: {user.name}, {user.age}, {user.email}, {user.phone}, {user.address}")

# 【10】UserProfile クラスのインスタンス(ユーザー情報)を作成する
profile = UserProfile(  # インスタンス化(=オブジェクト生成)
    name="Carol",        # 【11】name 引数に "Carol" を指定
    age=35,              # 【12】age 引数に 35 を指定
    email="carol@example.com",  # 【13】email 引数に メールアドレスを指定
    phone="070-2222-3333",      # 【14】phone 引数に電話番号を指定
    address="Kyoto"             # 【15】address 引数に住所を指定
)

# 【16】生成したインスタンスを関数に渡して情報を出力する
register_user_obj(profile)
実行結果
ユーザー情報: Alice, 30, alice@example.com, 090-1234-5678, Tokyo
ユーザー情報: Bob, 28, bob@example.com, 080-5678-4321, Osaka
ユーザー情報: Carol, 35, carol@example.com, 070-2222-3333, Kyoto

まとめ

・Pythonでは関数の引数が5つ以上になると、可読性・保守性・拡張性・テスト性のすべてが悪化する。
・このような関数は、「意味のまとまりごとに1つの構造」にラップすることで設計が大きく改善する。
・辞書を使えば柔軟に属性を管理でき、クラスを使えば明示的な構造と型付けができる。
・特に実務では、ドメインに即したオブジェクト(例:UserProfile)の導入によって、コードの意図と責任が明確化され、長期的な保守・再利用が容易になる。

Q.18-4

「副作用を持たない関数(純粋関数)」が好まれる理由を、テスト容易性・再利用性の観点から詳細に論ぜよ。

問題背景

【1】副作用とは何か

副作用(side effect)とは、関数の実行が関数外の状態に影響を与えること、または関数外の状態に依存することを指す。
例として以下が挙げられる:
・グローバル変数の変更
・ファイルやネットワークへの書き込み
・標準出力(print)や標準入力の利用
・リストや辞書のインプレース変更

【2】純粋関数とは何か

純粋関数(pure function)は、同じ入力に対して常に同じ出力を返し、副作用を持たない関数である。
具体的には以下の条件を満たす:
・入力のみで出力が決まる(状態に依存しない)
・実行中に外部に影響を与えない(状態を変更しない)

【3】なぜ純粋関数が好まれるか(設計的利点)

観点 理由
テスト容易性 外部依存がないため、入力と出力だけを検証すればよい
再利用性 他のコンポーネントから安心して呼び出せる(副作用による影響を受けない)
デバッグ性 同じ入力からは常に同じ結果が得られるため、バグの再現が容易である
並列処理との相性 状態共有がないためスレッド間競合が起きない

解答例とコードによる実践

純粋関数の例と非純粋関数の比較を通して、テスト・再利用のしやすさを示す。

【例1】非純粋関数(副作用あり):グローバル変数を書き換える

counter = 0  # 【1】グローバル変数 counter を定義する(関数の外で定義された共有の状態)

def increment():  # 【2】counter を1つ増やして返す副作用のある関数を定義する
    global counter  # 【3】関数内からグローバル変数 counter を変更するために global 宣言が必要
    counter += 1    # 【4】counter の値を1増やす(副作用:グローバル変数が更新される)
    return counter  # 【5】更新後の counter の値を返す

print(increment())  # 【6】increment を呼び出す → counter は 1 に増えて、その値を表示
print(increment())  # 【7】再度呼び出す → counter は 2 に増えて、その値を表示(呼び出すたびに状態が変わる)
実行結果
1
2

【例2】純粋関数(副作用なし):入力だけで出力が決まる

# 【1】純粋関数を定義する:引数 value を1だけ増やして結果を返す(外部状態を一切使わない)
def pure_increment(value: int) -> int:
    return value + 1  # 【2】与えられた引数にのみ依存して処理し、結果を返す(副作用なし)

# 【3】関数を呼び出して 0 を渡す → 戻り値は 1
print(pure_increment(0))

# 【4】同じ引数 0 を渡せば、再び同じ結果 1 が返る(状態に依存しない=純粋関数の特性)
print(pure_increment(0))
実行結果
1
1

【例3】副作用のないデータ処理(リストの正規化)

# 【1】数値のリストを 0〜1 の範囲に正規化する関数を定義する(元データは変更しない)
def normalize(values: list[float]) -> list[float]:
    min_val = min(values)  # 【2】リスト内の最小値を取得(正規化の下限)
    max_val = max(values)  # 【3】リスト内の最大値を取得(正規化の上限)

    if max_val == min_val:  # 【4】すべての値が同じなら0除算になるため、それを回避
        return [0.0 for _ in values]  # 【5】同じ値しかない場合は、すべて0.0として返す

    # 【6】(各値 - 最小値) / (最大値 - 最小値) により 0〜1 にスケーリングして返す
    return [(x - min_val) / (max_val - min_val) for x in values]

# 【7】元データを定義(正規化の対象)
data = [10, 20, 30, 40]

# 【8】normalize 関数を呼び出して、正規化された新しいリストを取得
normalized = normalize(data)

# 【9】正規化された結果を表示(0〜1の範囲になっていることを確認)
print("正規化結果:", normalized)

# 【10】元データを表示し、変更されていないことを確認(副作用なし)
print("元データ:", data)
実行結果
正規化結果: [0.0, 0.3333333333333333, 0.6666666666666666, 1.0]
元データ: [10, 20, 30, 40]

【例4】副作用を持つ正規化(インプレース変更)

# 【1】リストを受け取り、その場で(in-place)0〜1の範囲に正規化する関数を定義する(戻り値なし)
def normalize_inplace(values: list[float]) -> None:
    min_val = min(values)  # 【2】リスト内の最小値を取得する(正規化の基準)
    max_val = max(values)  # 【3】リスト内の最大値を取得する(正規化の基準)

    # 【4】リスト内のすべての要素に対してループ処理を行う(インデックスでアクセス)
    for i in range(len(values)):
        if max_val == min_val:  # 【5】すべての要素が同じ場合、ゼロ除算を避けるために 0.0 を代入
            values[i] = 0.0
        else:
            # 【6】通常の min-max 正規化処理を行う(元データを書き換える副作用あり)
            values[i] = (values[i] - min_val) / (max_val - min_val)

# 【7】正規化対象のデータ(元データ)を定義する
data2 = [10, 20, 30, 40]

# 【8】normalize_inplace を呼び出して、data2 の内容をその場で変更(副作用)
normalize_inplace(data2)

# 【9】data2 を表示すると、正規化された値に変更されていることが確認できる
print("変更されたデータ:", data2)
実行結果
変更されたデータ: [0.0, 0.3333333333333333, 0.6666666666666666, 1.0]

それぞれの方法の比較と分析

観点 純粋関数 副作用のある関数
テストのしやすさ 入力に対して出力を検証するだけで良い 状態の変化を含むため、テストが複雑になる
デバッグ性 同じ入力で常に同じ結果、再現性が高い 外部状態により結果が異なり、バグ再現が困難になる
安全な再利用 安心して呼び出せる 呼び出し元や他関数に影響を及ぼす可能性がある
並列処理との相性 スレッドセーフ(共有状態を持たない) 複数スレッドでの状態競合が起こる可能性がある

本問のまとめ

・純粋関数とは「同じ入力に対して常に同じ出力を返し、外部状態に依存も影響もしない関数」である。
・副作用を持たない関数は、テストがしやすく、再利用性が高く、バグの原因を特定しやすく、並列処理にも強いという多くの利点を持つ。
・実務においては、関数を設計する際に純粋性をできる限り維持し、必要な副作用は限定的に分離して管理することが推奨される。
・Pythonでは関数を第一級オブジェクトとして扱えるため、純粋関数を中心とした関数合成やデータパイプライン設計も容易であり、これらのスタイルは可読性と保守性を大きく高める設計指針となる。

Q.18-5

関数の中にネストされた複数の制御構造(ループ・例外処理など)があるとき、可読性と修正性が低下する理由を説明せよ。

問題背景

【1】ネストされた制御構造とは

関数の中に forwhiletryifwith などが階層的に複数入れ子になっている構造のことである:

def example():
    for item in collection:
        try:
            if condition:
                with open(path) as f:
                    ...

【2】可読性が低下する理由

理由 内容
インデントが深くなりすぎる 読み手が「現在の制御構造の深さ」を把握しにくくなる
ロジックが分かりにくい 多重条件や例外分岐があると、何がいつ起こるかを予測しづらくなる
コードの視線移動が激しい 括弧やブロックを上下に探す必要があり、理解が断続的になる

【3】修正性が低下する理由

理由 内容
部分的な修正が困難になる 特定の処理だけ切り離したくても他のブロックに依存している
変更によって意図せずバグが生まれる 例えば breakreturn の位置が上下関係に強く依存しており、変更が危険
例外処理が局所で閉じない try の範囲が不適切だと本来無視すべき例外まで握り潰してしまう

解答例とコードによる実践

【悪い例】ネストが深くなりすぎた非推奨の構造

# 【1】複数のファイルパスを受け取り、それぞれの中身を処理する関数を定義する
def process_files(file_paths: list[str]) -> None:
    # 【2】渡されたファイルパスリストを1つずつ順に処理する
    for path in file_paths:
        try:
            # 【3】ファイルを読み込みモードで開く(自動でクローズされる with 文)
            with open(path, "r") as f:
                # 【4】ファイル全体の行をリストとして読み込む
                lines = f.readlines()

                # 【5】各行を1つずつ取り出して処理する
                for line in lines:
                    # 【6】行が空白でない(何か内容がある)場合のみ処理を行う
                    if line.strip():
                        try:
                            # 【7】行の文字列を整数に変換(stripで前後の空白を除去)
                            num = int(line.strip())

                            # 【8】変換した数が偶数かどうかを判定
                            if num % 2 == 0:
                                # 【9】偶数ならその旨をファイル名とともに表示
                                print(f"{path}: even number {num}")
                            else:
                                # 【10】奇数ならその旨を表示
                                print(f"{path}: odd number {num}")

                        except ValueError:
                            # 【11】数値変換に失敗した場合(非数値)の処理
                            print(f"{path}: not a number: {line.strip()}")

        except OSError as e:
            # 【12】ファイルの読み込みに失敗した場合(存在しない、権限なしなど)の処理
            print(f"ファイルエラー: {e}")

問題点

・最大5階層のネスト(fortrywithforiftryif)がある
・例外処理が2重にネストされており、どのtryが何を守っているか不明瞭
・条件式と出力が密結合しており、部分抽出や再利用が困難

【改善例】責務分離+ネスト解消の設計

# 【1】1行の文字列を受け取り、空行/数値/偶数/奇数を分類して結果を返す関数を定義
def parse_and_classify(line: str) -> str:
    line = line.strip()  # 【2】行の前後の空白や改行を削除する(空行や整形ミスに対応)

    if not line:  # 【3】空行(中身が空の文字列)の場合
        return "空行"

    try:
        num = int(line)  # 【4】文字列を整数に変換を試みる(失敗すれば例外へ)
        return "偶数" if num % 2 == 0 else "奇数"  # 【5】偶数なら "偶数"、そうでなければ "奇数" を返す
    except ValueError:
        return "数値でない"  # 【6】数値に変換できない場合(例:文字列)のエラーメッセージ

# 【7】1つのファイルを開いて、各行に分類処理を適用して出力する関数を定義
def process_file(path: str) -> None:
    try:
        with open(path, "r") as f:  # 【8】ファイルを読み込みモードで開く(自動的に閉じられる)
            for line in f:  # 【9】ファイルを1行ずつ読み込むループ
                result = parse_and_classify(line)  # 【10】各行に対して分類関数を実行して結果を取得
                print(f"{path}: {line.strip()}{result}")  # 【11】元の行の内容と分類結果を表示する
    except OSError as e:
        print(f"{path}: ファイル読み込み失敗 ({e})")  # 【12】ファイルが存在しない・権限がないなどのエラー表示

# 【13】複数ファイルを順に処理する関数を定義(エントリポイント)
def process_files(file_paths: list[str]) -> None:
    for path in file_paths:  # 【14】ファイルパスを1つずつ処理
        process_file(path)  # 【15】それぞれのファイルに対して個別に処理関数を呼び出す

# 【16】テスト用の仮想ファイルパス(存在するかは実行環境次第)
test_paths = ["file1.txt", "file2.txt"]

# 【17】ファイル処理の実行開始(process_files が全体の起点)
process_files(test_paths)
【実行結果例(file1.txt に "10\nabc\n\n5" が入っている場合)】
file1.txt: 10 → 偶数
file1.txt: abc → 数値でない
file1.txt:  → 空行
file1.txt: 5 → 奇数
file2.txt: ファイル読み込み失敗 ([Errno 2] No such file or directory: 'file2.txt')

まとめ

・関数内でネストされた複数の制御構造があると、インデントが深くなり、可読性が大きく損なわれる。
・ネストが深いと、ロジックの流れを追うことが難しくなり、意図しないバグの温床となる。
・ネストされた例外処理や条件分岐は、「どの範囲に適用されるか」が読みづらく、修正時の影響範囲も把握困難となる。
・設計としては、以下のような方針を取ることで改善できる:

改善策 内容
処理単位を小さな関数に分離 各機能を責任ごとに関数化し、呼び出しだけをネスト外で行う
例外処理を局所化 例外を最小範囲で捕捉し、過剰なtryブロックの入れ子を避ける
ループや条件を早期脱出型に整理 continue, return を使ってネストを浅く保つ

補足

深いネスト構造を match 文や関数辞書に置き換える戦略的リファクタリング、または条件分岐を戦略パターンに変換する方法も考えられる。

ネストが深くなる典型例:if-elif-else の連鎖や多重switch 構造

def handle_command(cmd: str):
    if cmd == "start":
        ...
    elif cmd == "stop":
        ...
    elif cmd == "restart":
        ...
    else:
        ...

このような構造では、条件が増えるごとに可読性が悪化し、追加・修正の影響範囲も拡大する。これを構造的に改善する方法として、以下の3例を紹介する:

改善法①:match 文(Python 3.10以降)

【文法背景】
match は Python 3.10 から導入された構造的パターンマッチであり、値に応じた処理を簡潔に記述できる。
match + case の文法で switch-case 構造に近い。

# 【1】文字列コマンド cmd を受け取り、内容に応じて処理を分岐する関数を定義する
def handle_command(cmd: str) -> None:
    # 【2】match文によって cmd の内容に応じたパターンマッチングを行う(Python 3.10以上)
    match cmd:
        case "start":  # 【3】cmd が "start" の場合の処理
            print("処理開始")  # 【4】"start" に一致したときの出力

        case "stop":  # 【5】cmd が "stop" の場合の処理
            print("処理停止")  # 【6】"stop" に一致したときの出力

        case "restart":  # 【7】cmd が "restart" の場合の処理
            print("処理再起動")  # 【8】"restart" に一致したときの出力

        case _:  # 【9】上記のいずれにも一致しなかった場合のデフォルト処理
            print("未知のコマンド")  # 【10】不明なコマンドとして出力

# 【11】handle_command 関数を "restart" という引数で呼び出す(該当の case にマッチ)
handle_command("restart")
実行結果
処理再起動
改善法②:関数辞書による関心の分離(コマンド→関数マッピング)

【文法背景】
Pythonは関数を第一級オブジェクトとして扱えるため、関数名を辞書の値としてマッピングすることで処理を切り替えることができる。
可読性が高く、新しいコマンド追加も関数と辞書の1行追加のみで可能となる。

# 【1】開始コマンドの処理を行う関数を定義する
def start():
    print("開始処理を実行中")

# 【2】停止コマンドの処理を行う関数を定義する
def stop():
    print("停止処理を実行中")

# 【3】再起動コマンドの処理を行う関数を定義する
def restart():
    print("再起動処理を実行中")

# 【4】未知のコマンドに対する処理を行う関数を定義する
def unknown():
    print("未知のコマンドです")

# 【5】コマンド文字列と、それに対応する処理関数とを対応させた辞書を定義する
command_map: dict[str, callable] = {
    "start": start,     # 【6】"start" コマンドが来たら start() を実行
    "stop": stop,       # 【7】"stop" コマンドが来たら stop() を実行
    "restart": restart  # 【8】"restart" コマンドが来たら restart() を実行
}

# 【9】コマンド文字列を受け取り、対応する関数を辞書から呼び出す処理関数を定義する
def handle_command(cmd: str):
    # 【10】辞書から cmd に該当する関数を取り出し、なければ unknown を返し、それを実行する
    command_map.get(cmd, unknown)()

# 【11】handle_command を呼び出し、"start" コマンドに対応する処理を実行する
handle_command("start")
実行結果
開始処理を実行中
改善法③:戦略パターン(Strategy Pattern)によるクラスベース分岐

【設計背景】
戦略パターンとは、アルゴリズムや処理を切り替え可能なオブジェクトとしてカプセル化し、委譲によって切り替える設計パターンである。
条件ごとの if を 各戦略クラスに置き換え、実行時に選択する。

# 【1】すべてのコマンド戦略クラスが継承する共通インターフェース(抽象基底クラス)を定義
class CommandStrategy:
    def execute(self):  # 【2】すべてのサブクラスでこのメソッドを実装することを期待する
        raise NotImplementedError  # 【3】サブクラスで未実装ならエラーにする(抽象メソッド)

# 【4】"start" コマンドに対応する処理クラス(CommandStrategy を継承)
class StartCommand(CommandStrategy):
    def execute(self):  # 【5】execute メソッドをオーバーライドして処理を定義
        print("開始処理を実行")

# 【6】"stop" コマンドに対応する処理クラス
class StopCommand(CommandStrategy):
    def execute(self):
        print("停止処理を実行")

# 【7】"restart" コマンドに対応する処理クラス
class RestartCommand(CommandStrategy):
    def execute(self):
        print("再起動処理を実行")

# 【8】どのコマンドにも一致しなかった場合の処理クラス
class UnknownCommand(CommandStrategy):
    def execute(self):
        print("不明なコマンドです")

# 【9】コマンド文字列を受け取り、それに対応する戦略クラスのインスタンスを返す関数(Factory的役割)
def get_strategy(cmd: str) -> CommandStrategy:
    strategies = {
        "start": StartCommand,     # 【10】"start" → StartCommand クラス
        "stop": StopCommand,       # 【11】"stop" → StopCommand クラス
        "restart": RestartCommand  # 【12】"restart" → RestartCommand クラス
    }
    return strategies.get(cmd, UnknownCommand)()  # 【13】該当するクラスをインスタンス化して返す(なければ Unknown)

# 【14】"stop" というコマンドに対して、該当する戦略インスタンスを取得する
command = get_strategy("stop")

# 【15】取得した戦略オブジェクトの execute メソッドを実行する(ここで処理が動く)
command.execute()
実行結果
停止処理を実行

問題のコードにそれぞれのパターンを適用する

改善法①:match 文によるリファクタリング(Python 3.10 以降対応)

# 【1】1行の文字列を受け取り、空行・非数値・偶数・奇数を分類する関数を定義
def parse_and_classify(line: str) -> str:
    line = line.strip()  # 【2】行頭・行末の空白や改行を除去してから処理を行う

    if not line:  # 【3】空文字列(空行)の場合は "空行" として分類する
        return "空行"

    try:
        num = int(line)  # 【4】文字列を整数に変換(例: "123" → 123)
    except ValueError:
        return "数値でない"  # 【5】int 変換に失敗したら "数値でない" と分類

    # 【6】整数に変換できたら、その数値が偶数か奇数かを match-case 文で判定
    match num % 2:
        case 0:
            return "偶数"  # 【7】0 の場合(割り切れる)→ 偶数
        case 1:
            return "奇数"  # 【8】1 の場合(割り切れない)→ 奇数
        case _:
            return "不明"  # 【9】通常到達しないが、念のため例外ケースを処理

# 【10】指定されたファイルを開いて各行を分類・出力する関数
def process_file(path: str) -> None:
    try:
        with open(path, "r") as f:  # 【11】ファイルを読み込みモードで開く(自動で閉じる)
            for line in f:  # 【12】ファイルを1行ずつ読み込んで処理
                result = parse_and_classify(line)  # 【13】各行の文字列を分類関数に渡す
                print(f"{path}: {line.strip()}{result}")  # 【14】元の値と分類結果を出力
    except OSError as e:
        print(f"{path}: ファイル読み込み失敗 ({e})")  # 【15】ファイルが存在しない・権限がない等の例外を処理

# 【16】複数のファイルを順に処理するエントリポイント関数
def process_files(file_paths: list[str]) -> None:
    for path in file_paths:  # 【17】ファイルパスリストを1つずつ処理
        process_file(path)  # 【18】各ファイルに対して行分類を実行

# 【19】処理対象のファイル名を仮に定義(テスト用)
test_paths = ["file1.txt", "file2.txt"]

# 【20】ファイル処理を開始(すべてのファイルに対して分類を行う)
process_files(test_paths)
実行例
file1.txt: 42 → 偶数
file1.txt: abc → 数値でない
file1.txt:       → 空行
file2.txt: -9 → 奇数

改善法②:関数辞書による分類戦略の切り替え

# 【1】偶数を分類する処理関数を定義する(戻り値として "偶数" を返す)
def classify_even():
    return "偶数"

# 【2】奇数を分類する処理関数を定義する(戻り値として "奇数" を返す)
def classify_odd():
    return "奇数"

# 【3】文字列を解析して、空行/数値でない/偶奇のいずれかに分類する関数を定義
def parse_and_classify(line: str) -> str:
    line = line.strip()  # 【4】行の前後にある空白や改行を取り除く

    if not line:  # 【5】行が空(空白や改行だけ)の場合
        return "空行"

    try:
        num = int(line)  # 【6】文字列を整数に変換
    except ValueError:
        return "数値でない"  # 【7】整数に変換できない場合は非数値として扱う

    classify_map = {  # 【8】偶奇の判定結果(0 or 1)に対応する分類関数の辞書を定義
        0: classify_even,  # 偶数なら classify_even() を使う
        1: classify_odd    # 奇数なら classify_odd() を使う
    }

    return classify_map.get(num % 2, lambda: "不明")()  # 【9】対応する関数を呼び出し、なければ "不明" を返す

# 【10】1つのファイルを読み込み、各行に対して分類処理を実行する関数
def process_file(path: str) -> None:
    try:
        with open(path, "r") as f:  # 【11】ファイルを読み込みモードで開く(自動で閉じる)
            for line in f:  # 【12】ファイルの各行をループ処理
                result = parse_and_classify(line)  # 【13】行の内容を分類
                print(f"{path}: {line.strip()}{result}")  # 【14】元の行と分類結果を表示
    except OSError as e:
        print(f"{path}: ファイル読み込み失敗 ({e})")  # 【15】ファイルが存在しないなどのエラー処理

# 【16】複数ファイルに対して順に分類処理を行う関数
def process_files(file_paths: list[str]) -> None:
    for path in file_paths:  # 【17】リスト内の各ファイルを処理
        process_file(path)  # 【18】各ファイルごとに分類処理を呼び出す

# 【19】テスト用のファイルパスをリストで定義する(仮想)
test_paths = ["file1.txt", "file2.txt"]

# 【20】複数ファイルを処理する関数を実行する(プログラムの実行開始点)
process_files(test_paths)
実行結果
file1.txt: 12 → 偶数  
file1.txt: 15 → 奇数  
file1.txt: abc → 数値でない  
file1.txt: → 空行

改善法③:戦略パターンによる分類戦略の抽象化

# 【1】分類戦略の共通インターフェース(抽象基底クラス)を定義する
class ClassificationStrategy:
    def classify(self) -> str:  # 【2】すべての戦略クラスで必ずこのメソッドを実装する
        raise NotImplementedError  # 【3】実装されていなければエラーを発生させる

# 【4】偶数の分類処理を行う戦略クラス(分類名: "偶数")
class EvenStrategy(ClassificationStrategy):
    def classify(self) -> str:
        return "偶数"

# 【5】奇数の分類処理を行う戦略クラス(分類名: "奇数")
class OddStrategy(ClassificationStrategy):
    def classify(self) -> str:
        return "奇数"

# 【6】不明な場合の処理を行う戦略クラス(分類名: "不明")
class UnknownStrategy(ClassificationStrategy):
    def classify(self) -> str:
        return "不明"

# 【7】文字列を分類し、結果を返すメイン分類関数
def parse_and_classify(line: str) -> str:
    line = line.strip()  # 【8】前後の空白や改行を削除する

    if not line:  # 【9】空行の処理(strip 後に空文字列であれば)
        return "空行"

    try:
        num = int(line)  # 【10】数値変換を試みる
    except ValueError:
        return "数値でない"  # 【11】数値に変換できなかった場合の分類

    strategy_map = {  # 【12】偶奇の剰余(0か1)に対応する戦略クラスを辞書で定義
        0: EvenStrategy,
        1: OddStrategy
    }

    strategy_class = strategy_map.get(num % 2, UnknownStrategy)  # 【13】該当しなければ UnknownStrategy を使用
    return strategy_class().classify()  # 【14】戦略クラスのインスタンスを作成し classify() を呼び出して分類

# 【15】1つのファイルを処理し、各行に対して分類を行って表示する関数
def process_file(path: str) -> None:
    try:
        with open(path, "r") as f:  # 【16】ファイルを読み込みモードで開く(自動クローズ)
            for line in f:  # 【17】ファイルの各行をループ処理
                result = parse_and_classify(line)  # 【18】行を分類
                print(f"{path}: {line.strip()}{result}")  # 【19】行の内容と分類結果を表示
    except OSError as e:
        print(f"{path}: ファイル読み込み失敗 ({e})")  # 【20】ファイル読み込み時の例外処理

# 【21】複数のファイルを処理するエントリポイント関数
def process_files(file_paths: list[str]) -> None:
    for path in file_paths:  # 【22】各ファイルパスに対して
        process_file(path)  # 【23】ファイルを1つずつ処理

# 【24】テスト対象となる仮想的なファイルパスをリストとして定義
test_paths = ["file1.txt", "file2.txt"]

# 【25】複数ファイルの処理を実行(プログラムのエントリポイント)
process_files(test_paths)
file1.txt が以下の内容の場合
10
abc

5
実行結果
file1.txt: 10 → 偶数
file1.txt: abc → 数値でない
file1.txt:  → 空行
file1.txt: 5 → 奇数
file2.txt: ファイル読み込み失敗 ([Errno 2] No such file or directory: 'file2.txt')

3つの改善方法の比較

方法 利点 適用タイミング
match シンプルな値の分岐に最適 Python 3.10以降で条件分岐が少数なとき
関数辞書 処理を動的に分岐、関数型スタイルに好適 動作の切替が頻繁に必要なとき
戦略パターン 処理の内容が複雑 or 拡張が前提のとき クラスごとに状態や属性を持たせたいとき

Q.18-6

関数が他関数を返す(クロージャ)ように設計された場合、スコープと状態保持の観点からその利点と注意点を述べよ。

問題背景

【1】 クロージャー

クロージャ(closure)とは、関数の定義時に参照していた外側の変数(自由変数)を、関数オブジェクトが保持し続ける構造のことである。
Pythonでは関数が第一級オブジェクト(first-class object)であるため、関数の戻り値として別の関数を返すことが可能である。

基本構造は以下の通り

def outer(x):                  # 外側の関数
    def inner(y):              # 内側の関数
        return x + y           # x は outer のスコープにある自由変数
    return inner               # inner を返す

上記のように inner() 関数は x を自由変数として参照し、outer() が終了しても x の値は保持される。これは「スコープを越えて状態を保持している」ことを意味する。

利点と注意点は以下

観点 説明
状態の保持 外側関数のローカル変数を維持できるため、状態付き関数が実装できる
関数の生成工場 引数に応じて動的に関数を構築できる(関数ファクトリ、関数テンプレート)
名前空間の隠蔽 外部からアクセス不能なプライベートな状態として使える
副作用の抑制 グローバル変数を使わずに、安全に状態を関数単位で管理できる
注意点 説明
遅延評価による値の変化 変数の参照が「値の取得時」ではなく「使用時」に起こるため、想定外の値を保持することがある
メモリリークの可能性 長期間参照されると、外部変数がGCされずに残るケースがある
関数名や意図が不明瞭になりやすい クロージャで返された関数が何を保持しているかがブラックボックスになりやすい

解答例とコードによる実践

以下の例では、「加算器(add_by)をクロージャで作成する関数ファクトリ」と、「呼び出し回数を内部で保持するクロージャ」の2例を示す。

【例1】加算器を作るクロージャ(自由変数の保持)

# 【1】整数 base を受け取り、その base を加算する関数を返す関数(高階関数)を定義する
def make_adder(base: int):
    # 【2】内部関数 adder を定義。これは base に x を加えて返す加算器
    def adder(x: int) -> int:
        return base + x  # 【3】adder の中で base を参照している(base は外側のスコープ)

    return adder  # 【4】adder 関数を関数オブジェクトとして返す(ここでクロージャが形成される)

# 【5】make_adder に 10 を渡して、「10を加算する関数」を生成し、add_10 に代入
add_10 = make_adder(10)

# 【6】make_adder に 100 を渡して、「100を加算する関数」を生成し、add_100 に代入
add_100 = make_adder(100)

# 【7】add_10 に 5 を渡して呼び出す → 10 + 5 = 15 が出力される
print(add_10(5))   # 結果: 15

# 【8】add_100 に 5 を渡して呼び出す → 100 + 5 = 105 が出力される
print(add_100(5))  # 結果: 105
実行結果
15
105

【例2】呼び出し回数を保持するクロージャ(状態付き関数)

# 【1】呼び出し回数を記録するクロージャを返す関数を定義
def call_counter():
    count = 0  # 【2】外側スコープの変数。これを内側の関数が保持・更新する(自由変数)

    # 【3】内部関数 counter を定義(呼び出されるたびにカウントアップ)
    def counter():
        nonlocal count  # 【4】外側スコープの count に代入するためには nonlocal が必要
        count += 1      # 【5】count をインクリメント(状態を更新)
        return f"{count}回目の呼び出しです"  # 【6】状態に基づいてメッセージを返す

    return counter  # 【7】関数 counter をそのまま返す(クロージャとして)

# 【8】1つ目のカウンタ関数を作成(独立した count を持つ)
counter1 = call_counter()

# 【9】2つ目のカウンタ関数を作成(別の count を持つ別インスタンス)
counter2 = call_counter()

# 【10】counter1 を1回目呼び出し → count=1
print(counter1())

# 【11】counter1 を2回目呼び出し → count=2(状態が維持されている)
print(counter1())

# 【12】counter2 を1回目呼び出し → 別のスコープなので count=1(counter1 とは独立)
print(counter2())
実行結果
1回目の呼び出しです
2回目の呼び出しです
1回目の呼び出しです

注意:遅延評価によるバグ

# 【1】空のリスト funcs を用意し、ここに関数を追加していく
funcs = []

# 【2】0から2までの範囲でループを実行(i = 0, 1, 2)
for i in range(3):
    # 【3】i を返す関数 f を定義(この時点では i の値は確定していない=遅延評価)
    def f():
        return i  # 【4】ここでの i は「参照」であり、「その場の値」を記録しているわけではない

    # 【5】定義した関数 f をリストに追加する
    funcs.append(f)

# 【6】リスト funcs に入っている関数をすべて呼び出して、その戻り値を表示する
print([fn() for fn in funcs])  
# 【7】期待される結果は [0, 1, 2] だが、実際には [2, 2, 2] が表示される
# 理由:関数 f 内で使われる i は、ループ終了後に最終的に i=2 となっており、
# すべての f がその同じ i(=2)を参照しているため
# 修正:引数のデフォルト値で固定する
# 【1】空のリスト funcs を用意して、ここに関数を格納していく
funcs = []

# 【2】0から2までループ(i = 0, 1, 2)
for i in range(3):
    # 【3】i の値をデフォルト引数 val に束縛して関数を定義(この時点で val に値が固定される)
    def f(val=i):  # ここがポイント:val は f() の定義時に i の値をキャプチャする
        return val  # 【4】val をそのまま返す(val は固定された値)

    # 【5】定義した関数 f をリストに追加する
    funcs.append(f)

# 【6】リスト内の関数をすべて呼び出して、その戻り値(val)をリストとして表示
print([fn() for fn in funcs])  # → [0, 1, 2]

まとめ

・関数が関数を返す設計(クロージャ)は、Pythonにおける状態付き関数設計の重要な技法である。
・クロージャを使うことで、状態を外部に漏らさずに保持・更新できる関数が構築でき、関数合成や関数生成工場のようなパターンにも応用できる。
・一方で、スコープの扱い(自由変数、nonlocal)や評価タイミングには注意を要し、意図しない共有状態やバグを招く設計になりやすい。
・安全に使うためには、以下を設計指針とすべきである:

設計指針 内容
状態の更新には nonlocal を使う 関数内部で自由変数を書き換える場合は明示が必要
値の固定にはデフォルト引数を使う ループ変数のように「時点の値」を固定する場合に効果的
関数の命名と説明を丁寧にする 関数の振る舞いがコードから直感的に分かるようにする

Q.18-7

複数の関数が共通の前処理・後処理を行っているとき、デコレータを用いた共通化にはどのような利点があり、その構造はどうなっているか。

問題背景

【1】 なぜ「前処理・後処理の共通化」が必要か?

関数型・オブジェクト指向を問わず、実務において次のような共通処理(cross-cutting concerns)は、複数の関数・モジュールで繰り返される:
関数開始時のログ出力(print(f"Start: {func.__name__}")
終了時のログ・例外・エラー処理
処理時間の計測(time.time() による差分)
リソースの確保と開放(例:データベース接続、トランザクション)
これらをすべての関数の冒頭と末尾に書き続ける設計は、以下のような問題を生む:

問題点①:重複コード(Don't Repeat Yourself違反)

import time  # 【1】時間計測やスリープを行うために time モジュールをインポート

# 【2】データを読み込む処理(処理時間のログ付き)
def load_data():
    print("[LOG] 開始: load_data")  # 【3】関数の処理開始をログに出力

    start = time.time()  # 【4】現在の時刻(UNIX時間)を取得して開始時刻とする

    # 何らかの処理...(ここでは処理の代わりにスリープで時間をシミュレート)
    time.sleep(1)  # 【5】1秒間処理を停止(=擬似的な処理時間)

    duration = time.time() - start  # 【6】現在時刻との差分で処理時間を計算

    print(f"[LOG] 終了: load_data 実行時間: {duration:.2f}s")  # 【7】終了ログと処理時間を表示

# 【8】データを保存する処理(こちらも処理時間のログ付き)
def save_data():
    print("[LOG] 開始: save_data")  # 【9】関数の処理開始をログに出力

    start = time.time()  # 【10】処理開始時刻を取得

    # 何らかの処理...(ここでは処理の代わりにスリープで時間をシミュレート)
    time.sleep(1.5)  # 【11】1.5秒間停止(擬似的な重い処理)

    duration = time.time() - start  # 【12】処理時間を計算(現在時刻との差)

    print(f"[LOG] 終了: save_data 実行時間: {duration:.2f}s")  # 【13】終了ログと処理時間を表示

上記のように、処理本体とは無関係な「ログ」「時間計測」がコピー&ペーストされている。

■ 問題点②:変更が1箇所では済まない
・ログの出力形式やタイミングを変更したいとき、すべての関数を修正しなければならない。
・バグ修正時の影響範囲が**横断的(cross-cutting)**になり、保守性が著しく低下する。

■ 問題点③:関数本来の意図が見えづらい
・本質であるビジネスロジック(例:データを読み込む、加工する)が、雑音的な前後処理に埋もれてしまう。

【2】 解決策:デコレータでの共通処理分離

Pythonのデコレータは、関数の前後に追加の処理を挿入するための機能であり、上述の共通処理を「関数の外」に安全に切り出すことができる。

以下のような構造が典型である:

# 【1】デコレーター関数 decorator を定義する。func には対象となる関数(target)が渡される
def decorator(func):
    # 【2】ラップ関数 wrapper を定義(func の前後に任意の処理を挟めるようにする)
    def wrapper(*args, **kwargs):  # 引数は *args, **kwargs で汎用的に対応
        print("前処理")  # 【3】target を実行する前に実行される処理(前処理)
        result = func(*args, **kwargs)  # 【4】実際の処理(ここでは target 関数)を呼び出す
        print("後処理")  # 【5】target を実行した後に実行される処理(後処理)
        return result  # 【6】func の戻り値をそのまま返す(必要に応じて使用)

    return wrapper  # 【7】デコレーターとして wrapper を返す(target の代わりになる)

# 【8】@decorator によって、target = decorator(target) と同義になる(target が wrapper に置き換えられる)
@decorator
def target():
    print("本体処理")  # 【9】target 関数の本体(元の処理)

# 【10】関数 target を呼び出す → 実際には wrapper() が呼ばれ、「前処理→本体処理→後処理」が順に実行される
target()

このようにすることで、関数本体は「業務ロジックだけを記述することに専念できる」。

コードによる実践

import time  # 【1】時間測定や待機処理(sleep)を使うために time モジュールをインポート

# 【2】デコレータを定義:任意の関数にログと処理時間測定機能を追加する
def log_and_time(func):
    # 【3】実際の関数を包むラッパー関数を定義(すべての引数を受け取れるように *args, **kwargs を使用)
    def wrapper(*args, **kwargs):
        print(f"[LOG] 開始: {func.__name__}")  # 【4】対象関数名の実行開始ログを出力
        start = time.time()  # 【5】関数実行開始時刻を記録

        result = func(*args, **kwargs)  # 【6】実際の処理関数を呼び出し、その戻り値を保持
        duration = time.time() - start  # 【7】関数終了後の現在時刻と開始時刻の差分で処理時間を計算

        print(f"[LOG] 終了: {func.__name__} 実行時間: {duration:.2f}s")  # 【8】関数名と処理時間を含む終了ログを出力
        return result  # 【9】元の関数の戻り値をそのまま返す
    return wrapper  # 【10】wrapper 関数を返すことで、元の関数が置き換えられる

# 【11】以下の fetch_user_data 関数に log_and_time デコレータを適用する(実行時には wrapper が使われる)
@log_and_time
def fetch_user_data():
    time.sleep(0.5)  # 【13】処理の代わりに 0.5秒の待機を入れて疑似的な処理時間を作る
    return {"id": 1, "name": "Alice"}  # 【14】ダミーのユーザーデータを返す

# 【15】以下の update_database 関数にも同様にデコレータを適用する
@log_and_time
def update_database():
    time.sleep(0.8)  # 【17】0.8秒の処理待機(こちらも疑似的な処理)
    print("データベースを更新しました")  # 【18】本体処理内容を出力

# 【19】fetch_user_data を呼び出す → wrapper を通してログと時間測定が実行される
fetch_user_data()

# 【20】update_database を呼び出す → 同様に wrapper が実行され、ログと時間測定が行われる
update_database()
実行結果
[LOG] 開始: fetch_user_data  
[LOG] 終了: fetch_user_data 実行時間: 0.50s  
[LOG] 開始: update_database  
データベースを更新しました  
[LOG] 終了: update_database 実行時間: 0.80s

本問のまとめ

設計効果 内容
冗長性の排除 同じ前後処理を何度も書かなくてよくなる(DRY原則)
保守性の向上 共通ロジックを1か所で変更・管理できる
意図の明確化 業務ロジックとインフラ処理(ログ・計測)が明確に分離される
再利用性の確保 デコレータ自体を複数関数に適用でき、処理の再利用が可能

あとがき

今回は「関数の設計と記述スタイル」について扱いました。個人的にはもっと扱いたかった問題も多いのですが、盲点となりがちな話や重要そうに感じる話題を中心に選んでみました。次回は、「グローバル変数とローカル変数」について扱う予定です。

参考文献

[1] 独習Python (2020, 山田祥寛, 翔泳社)
[2] Pythonクイックリファレンス 第4版(2024, Alex, O’Reilly Japan)
[3] 【Python 猛特訓】100本ノックで基礎力を向上させよう!プログラミング初心者向けの厳選100問を出題!(Youtube, https://youtu.be/v5lpFzSwKbc?si=PEtaPNdD1TNHhnAG)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?