JEPA4Japan · チュートリアル

関数設計、スコープ、呼び出し方

1,891文字 6分で読めます #Python

関数を値として扱い、既定値、キーワード引数、スコープ、クロージャー、副作用を理解して、役割の絞られた関数を設計します。

コース進捗 コース目次 24レッスン中 24件を公開中

助手には、一つの仕事

第8章では学習レポートを作りました。長いレシピも動きますが、小さな助手に分けると理解もテストも簡単です。関数には、はっきりした仕事を一つと、小さな契約を与えます。何を受け取り、何を返し、何を変更するかという約束です。

def session_label(topic, minutes=30, *, excited=False):
    label = f"{topic}:{minutes}分"
    if excited:
        return label.upper() + "!"
    return label


print(session_label("Python"))
print(session_label("Git", minutes=45, excited=True))

出力:

Python:30分
GIT:45分!

topicは必須、minutesには既定値があり、*より後ろは名前を付けて渡します。意味の分からない二つ目のTrueより、excited=Trueの方が親切です。

この契約は「テーマと時間を受け取り、文字列を返し、関数の外を変えない」です。データを返す計算と、目に見える副作用を作るprint()を分けられます。

関数も値です。第8章ではminutes_inをsorted()のkeyへ渡しました。()を付けるとその場で呼び出してしまうので、関数そのものを渡すときは付けません。

変更できる既定値は、共有のリュック

Pythonが既定値を作るのは、呼び出すたびではなくdefを実行したときです。そのためリストには、前の呼び出しで入れた物が残ります。

  1. リュックは一つ 既定のリストを作る
  2. Pythonを入れる リュックに残る
  3. Gitを入れる Pythonもまだある
変更できる既定値は、後の呼び出しでも同じ物が再利用されます。

次は、わざと間違えた例です。

def collect(topic, topics=[]):
    topics.append(topic)
    return topics


print(collect("Python"))
print(collect("Git"))

出力:

['Python']
['Python', 'Git']

Noneを「新しいリストを作って」という合図にします。

def collect(topic, topics=None):
    if topics is None:
        topics = []
    topics.append(topic)
    return topics

これで、省略した二回の呼び出しは別々に始まります。端の注意:呼び出し側がリストを渡した場合、そのリストはまだ変更されます。変更しない契約なら、先にtopics = list(topics)でコピーします。

名前は内側から探す

Pythonが名前を見ると、LEGBの順で探します。Local、Enclosing、Global、Built-inです。

  1. Local 今の関数呼び出し
  2. Enclosing 外側の関数
  3. Global このファイルの一番外
  4. Built-in lenなどPythonの名前
同じ名前が見つかると、Pythonはそこで探すのを止めます。
course = "Python"                 # Global


def make_reader():
    prefix = "学習"               # Enclosing

    def label(minutes):
        suffix = "分"             # Local
        return f"{prefix} {course}:{minutes}{suffix}"

    return label


reader = make_reader()
print(reader(40))                  # printはBuilt-in

出力:

学習 Python:40分

関数内で代入すると、通常はlocalな名前になります。合計を更新するためだけにglobalを使うのは避けましょう。古い合計を受け取り、新しい合計を返す方が明確です。隠れた共有変更があると、テスト結果が呼び出し順に左右されます。

クロージャーは、小さなことを覚えられる

先ほど返したreaderは、まだprefixを覚えています。外側の名前を覚えた関数をクロージャーと呼びます。内側の関数から外側の値を入れ替えるときだけ、nonlocalを使います。

def make_counter(start=0):
    total = start

    def add(minutes):
        nonlocal total
        total += minutes
        return total

    return add


counter = make_counter(10)
print(counter(20))
print(counter(15))

出力:

30
45

nonlocalがなければ、total += minutesは、まだ値のない新しいlocalのtotalを使おうとします。クロージャーは小さな非公開メモには便利ですが、プログラム全体を隠す箱にはしません。

純粋な変換は、入力を使って結果を返し、外側を変えません。表示、ファイル書き込み、呼び出し側のリスト変更は副作用です。できるだけ、中央に変換、端に副作用を置きます。

小さな分析パイプラインを作る

  1. 整える きれいな新規レコード
  2. 分析する 合計して順位付け
  3. 文章にする レポート文字列を返す
  4. 表示する 目に見える副作用は一つ
小さな契約が、分かりやすいパイプラインでデータを渡します。

次の完全なプログラムをstudy_pipeline.pyとして保存します。

def normalize_session(session):
    return {
        "topic": session["topic"].strip(),
        "minutes": session["minutes"],
    }


def minutes_by_topic(sessions):
    totals = {}
    for session in sessions:
        topic = session["topic"]
        totals[topic] = totals.get(topic, 0) + session["minutes"]
    return totals


def minutes_in(item):
    return item[1]


def analyze(sessions, target=60):
    clean = [normalize_session(session) for session in sessions]
    totals = minutes_by_topic(clean)
    ranking = sorted(totals.items(), key=minutes_in, reverse=True)
    total = 0
    for session in clean:
        total += session["minutes"]
    return {"total": total, "met": total >= target, "ranking": ranking}


def format_report(analysis, *, heading="学習レポート"):
    status = "達成" if analysis["met"] else "未達成"
    lines = [heading, f"合計:{analysis['total']}分", f"目標:{status}"]
    for number, (topic, minutes) in enumerate(analysis["ranking"], start=1):
        lines.append(f"{number}. {topic}:{minutes}分")
    return "\n".join(lines)


def run_pipeline(sessions, formatter=format_report, *, target=60):
    return formatter(analyze(sessions, target=target))


sessions = [
    {"topic": " Python ", "minutes": 45},
    {"topic": "Git", "minutes": 40},
    {"topic": "Python", "minutes": 30},
]
before = [session.copy() for session in sessions]
report = run_pipeline(sessions, target=100)

assert sessions == before
assert "Python:75分" in report

print("確認に合格しました。")
print(report)

python3 study_pipeline.pyで実行します。

確認に合格しました。
学習レポート
合計:115分
目標:達成
1. Python:75分
2. Git:40分

formatterは関数値を受け取ります。計算はすべてデータを返し、目に見える副作用を作るのは最後の二つのprint()だけです。

3つの小さなチャレンジ

  1. format_duration(minutes, *, compact=False)を書き、45分または45mを返す。
  2. 安全なcollect()をリストなしで二回呼び、結果が独立していることを確かめる。
  3. 100から始まるクロージャーへ20と5を足し、実行前に二つの答えを予想する。

第10章へ進めるかな?

  • 関数の入力、戻り値、副作用を説明できる
  • 通常はリストを既定値にしない理由が分かる
  • LEGBの順で名前を探せる
  • クロージャーが覚える物と、nonlocalが変える物を説明できる
  • 計算と表示を分けられる
  • パイプラインを実行し、確認に合格した

次章では、正確な例外と検証で不正な入力を拒否し、これらの関数の契約を守ります。