1
1

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文法講義~ユーザー定義関数~

Posted at

まえがき

今回からPythonの文法事項について解説していく講義形式の記事を書く。どちらかというと、初心者向けテキスト扱われることが少ないテーマを中心に扱うことを目的としているが、一方でこの記事だけでも、その理解に前提となる内容も含めて理解が完結するように工夫も行う。
ただ、しっかりとした文章を書くというよりも、本を読みながら肉付けした読書メモみたいな体裁になることについてはご了承いただきたい。

ユーザー定義関数の基本構文

シンプルなコードでも、何度も書くのは面倒である。コード全体が冗長になるし、例外処理など様々な機能を追加するとなると、該当するすべてのコードに施すのは途方に暮れる作業となる。大規模なアプリとなれば、修正対象を洗い出すだけで手間になる。

ユーザー定義関数:重複したコードを一か所に集めるための仕組み

base = 8
height = 10
area = base * height / 2

print(f'三角形の面積は{area}です。')
実行結果
三角形の面積は40.0です

ユーザー定義関数にて上記を記述すると以下。

# get_triangle(8, 10)
print(f'三角形の面積は{area}です。)
実行結果
三角形の面積は40.0です

名前空間とスコープ

名前空間:変数や関数などの実態であるオブジェクトと、そのオブジェクトを参照する名前(変数名、関数名などの)対応付けのようなもの。
スコープ:ある名前がそのまま通用する(参照/束縛できる)ような、スクリプトプログラム上の範囲。

LEGB原則

Local → Enclosing(外側関数)→ Global(モジュール)→ Builtins(組み込み)の順で検索される。

典型的な入出力 備考
Builtins len, print, Exception 読み出し中心 import builtins で参照可能
Global(モジュール) モジュール直下の x 読み書き可 globals() は実体辞書
Enclosing(外側関数) 関数 outer のローカルを inner が参照 読み取り・nonlocal で再束縛 クロージャの自由変数
Local(関数ローカル) 関数内の通常の変数 読み書き可 代入が出現すると「ローカル扱い」になる

代入が出たらローカル扱い(UnboundLocalError)

関数内で代入(=, +=, := など) が出現する同名の名前は、原則ローカルと見なされる。そのため右辺で未初期化参照になれば UnboundLocalError

x = 10  # グローバル

def f():
    # print(x)       # ← ここで参照すると UnboundLocalError(下で x に代入があるため)
    x = x + 1         # 右辺評価時点でローカル x は未束縛
    return x

回避:外側を読むだけなら、その名前に対して関数内で代入を書かない or 再束縛したいなら global x(モジュールへ)または nonlocal x(外側関数へ)を明示。

globalnonlocal の使い分け

global name:モジュール名前空間の name を再束縛
nonlocal name:最近傍の外側変数スコープの name を再束縛(モジュールは対象外)。

# モジュール変数
counter_global = 0

def outer():
    # 外側関数ローカル
    counter_nonlocal = 0

    def inner_normal():
        # 何も宣言しない → ローカル変数を新しく作る
        counter_local = 0
        counter_local += 1
        return f"[inner_normal] counter_local = {counter_local}"

    def inner_nonlocal():
        # 外側関数の変数を再束縛
        nonlocal counter_nonlocal
        counter_nonlocal += 1
        return f"[inner_nonlocal] counter_nonlocal = {counter_nonlocal}"

    def inner_global():
        # モジュール変数を再束縛
        global counter_global
        counter_global += 1
        return f"[inner_global] counter_global = {counter_global}"

    # それぞれの関数を順に呼び出してみる
    print(inner_normal())
    print(inner_nonlocal())
    print(inner_global())

    return f"[outer return] counter_nonlocal={counter_nonlocal}, counter_global={counter_global}"

print(outer())
print(outer())
print(f"[module level] counter_global = {counter_global}")
実行結果
[inner_normal] counter_local = 1
[inner_nonlocal] counter_nonlocal = 1
[inner_global] counter_global = 1
[outer return] counter_nonlocal=1, counter_global=1

[inner_normal] counter_local = 1
[inner_nonlocal] counter_nonlocal = 1
[inner_global] counter_global = 2
[outer return] counter_nonlocal=1, counter_global=2

[module level] counter_global = 2

inner_normal:関数内で新しいローカル変数を定義。outer を呼び出すたびにリセットされる。
inner_nonlocal:outer のローカル変数 counter_nonlocal を再束縛。1回の outer 呼び出しの間だけ蓄積する。しかし outer を呼び直すとまたリセットされる。
inner_global:モジュール変数 counter_global を再束縛。outer を何度呼び出しても、グローバルは累積していく。

クロージャ―と遅延束縛

Python のクロージャは静的スコープだが、自由変数の値は呼び出し時に見る(late binding)ため、ループ変数を閉じ込めると誤動作につながる。

funcs = []
for i in range(3):
    funcs.append(lambda: i)   # i は自由変数。呼ぶ時点では 2 に落ち着く
print([f() for f in funcs])   # [2, 2, 2]

対策
・デフォルト引数で先取りしてしまう

funcs = [(lambda i=i: i) for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2]

・小ファクトリ関数で値を引き込む

def make_fn(v): return lambda: v
funcs = [make_fn(i) for i in range(3)]

引数と戻り値

引数:関数の中で参照可能な変数のこと。関数を呼び出す際に、呼び出し側から関数に引き渡すために利用。
実引数:呼び出し元から渡される値のこと
仮引数:受け取り側の変数のこと
戻り値:関数が処理した結果を表す

戻り値の基本

関数に渡るのはオブジェクトの参照。引数名は、その参照を再束縛したローカル名に過ぎない。
→ミュータブルなオブジェクト(list, dict, set, numpy配列など)を関数内で破壊的に変更すると、呼び出し元にも変更すると、呼び出し元にも影響する。
→ ただし、「x = ...」のように再代入しても、呼び出し元の参照は書き変わらない(ローカル名が別オブジェクトを指すだけ)

戻り値は常に一つだが、タプルで複数値をまとめて返すのが一般的(立ち戻りに見えるのは、タプルのアンパック)

def mutate_and_rebind(xs):
    xs.append(99)         # 破壊的変更(呼び出し元に影響)
    xs = [1, 2, 3]        # ローカル名の再代入(呼び出し元には影響しない)
    return xs

a = [0]
b = mutate_and_rebind(a)
print(a)  # [0, 99]  ← append の影響あり
print(b)  # [1, 2, 3]

パラメータの種類と並び順

種類 記号 役割
位置専用 / def f(a, b, /): /より左は位置引数のみで指定可(キーワードで呼べない)
位置/キーワード なし def f(a, b): 位置でもキーワードでも呼べる
可変長位置 *args def f(*args): 余った位置引数をタプルで受ける
キーワード専用 * def f(*, x, y): *より右はキーワードでのみ指定可
可変長キーワード **kwargs def f(**kwargs): 余ったキーワード引数を辞書で受ける
def api(a, b=0, /, c=1, *args, d, e=2, **kwargs):
    """
    a, b: 位置専用(/ の左)
    c: 位置/キーワード可
    *args: 余剰の位置引数
    d, e: キーワード専用(* の右側)
    **kwargs: 余剰のキーワード引数
    """
    return a, b, c, args, d, e, kwargs

print(api(10, 20, 30, 40, 50, d=60, e=70, flag=True))
# -> (10, 20, 30, (40, 50), 60, 70, {'flag': True})

これだとわかりにくいと思うので、以下に要点をまとめてみる。

デフォルト引数

「引数 = 値」の形式で、仮引数に設定できる。引数を省略した場合は既定でセットされる。

デフォルト引数は定義時点で一度だけ評価されるのが基本。そのため、以下のケースには気を付ける(有名)

(1) 既定値として変数を受け取る場合

msg = 'before'

def my_func(param: str = msg) -> None:
    print(param)

msg = 'after'
my_func()    # before

呼び出しのタイミングでは、変数msgは「after」であるが、既定値の評価は定義のタイミングで行われるため、(呼び出しのタイミングに関わらず)「before」を返す。

これに関連して、以下も自然に理解可能

(2) 既定値がミュータブルなオブジェクトである場合

def bad(x, acc=[]):   # 定義時に acc が1個だけ作られる
    acc.append(x)
    return acc

print(bad(1))  # [1]
print(bad(2))  # [1, 2] ← 想定外に共有

想定外とあるが、これも最初に評価された既定値がそのまま維持されるという仕組みを理解していれば自明である。
ミュータブル型を既定値とする場合は、既定値を仮にNoneとして置き、実際の既定値は関数の配下で決定する。

キーワード引数

キーワード引数:呼び出し時に、値だけでなく名前を伴う引数のこと。

・引数が多くなっても意味を把握しやすい
・必要な引数だけを表現できる(デフォルトがあれば、どれを省略してもよい)
・引数の順序を自由に変更可能

特に、「引数の数が多い」「省略可能な引数が多く、省略パターンにも様々な組み合わせがある」ような場合に有効

可変長引数

可変長引数:0個以上の値を要求する引数
可変長引数においては、引数の個数があらかじめ決まっていない

def total_products(*args):
    result = 1
    for value in args:
        result *= value
    return result

print(total_products(12, 15, -1))
print(total_products(5, 7, 8, 2, 1, 15))

可変長引数については、以下の注意点を要する。
(1) 可変長引数は1関数に1つだけ
→ 可変長引数が複数あると、それぞれの引数がどこまで受け取るかが曖昧になるため

(2) 可変長引数後方には、キーワード引数のみ指定できる。

def bar(*args, x):
    print(args, x)

bar(10, 11, 12, 'Python')

'Python'は引数xに渡すことを想定しているが、キーワード引数ではないため、可変長引数argsに吸収されてしまい、エラーを吐く。

def bar(*args, x = 'Python'):
    print(args, x)

bar(10, 11, 12, 'Python')    # (10, 11, 12) Python

(3) 想定される引数まで可変長引数にまとめない
以下の例をもとに考える指定された文字列を「・」で連結し前後に接頭辞と接尾辞を付与するconcatenate関数
```python
def concatenate(prefix, suffix, *args):
    result = prefix
    result += ''.join(args)
    return result + suffix

print(concatenate('[', ']', '鈴木', 'エルメシア', '富士子'))
    # [鈴木・エルメシア・富士子]

これを以下のように定義するのは、可読性の観点で避けるべきである。

def concatnate(*args):

関数のヘッダーだけでは、concatenate関数が要求する引数が把握できず、関数としての使い勝手が低下するため。
→ 通常の引数がまず基本であり、可変長引数には、定義時には個数を特定できないものだけをまとめるのが原則

(4) 可変長引数で「1個以上の引数」を表す
関数の構造的に、引数を一つ受け取ることを確定的にしたい場合、1つ目の引数は可変長でない通常引数を宣言し、2個目以降尾の変数を可変長引数として宣言するようにすればよい。

可変長キーワード引数

「*」の代わりに「**」を用いることで、不特定数のキーワード引数を受け取ることも出来る。

def create_dict(**kwargs):
    result = dict()
    for key, value in kwargs.items():
        result[key] = value
    return result

d = create_dict(name = '山田太郎', age = 18, sex = 'male')
print(d)    # {'name': '山田太郎', 'age': 18, 'sex': 'male'}

引数についてまとめると以下

```python
def api(a, b=0, /, c=1, *args, d, e=2, **kwargs):
    """
    a, b: 位置専用(/ の左)
    c: 位置/キーワード可
    *args: 余剰の位置引数
    d, e: キーワード専用(* の右側)
    **kwargs: 余剰のキーワード引数
    """
    return a, b, c, args, d, e, kwargs

print(api(10, 20, 30, 40, 50, d=60, e=70, flag=True))
# -> (10, 20, 30, (40, 50), 60, 70, {'flag': True})

関数呼び出しと戻り値

関数を呼び出すための様々な方法と戻り値について扱う。

複数の戻り値

関数から複数の値を返したい場合は、戻り値をタプルとして束ねて返すのが一般的。

def get_max_min(*args):
    return (max(args), min(args))

max_v, min_v = get_max_min(15, 7.5, 108, -10)
print(max_v)    # 108
print(min_v)    # -10

タプルとして受け取った値を個別の変数に振り分けるには、アンパック代入を利用する。

名前付きタプルを生成する

タプルはイミュータブルなリストなので、個々の要素にもインデックスでしかアクセスできない。戻り値としてタプルを返すような例では、個々の要素の意味が名前で把握できた方が便利。
→ typingモジュールのNamedTuple関数で実現可能

from typing import NamedTuple

# MaxMin型の名前付きタプルを準備
MaxMin = NamedTuple('MaxMin', [('max', float), (min, float)])

def get_max_min(*args: float) -> MaxMin:
    return MaxMin(max(args), min(args))

result = get_max_min(15, 7.5, 108, -10)
print(result.max)    # 108
print(result.min)    # -10
print(result[0])     # 108

NamedTuple関数では、まだ名前付きタプル(型)を定義しただけなので、実際に利用する際には、return MaxMin(max(args), min(args)) のようにインスタンス化して利用する。
戻り値を受け取った後は、タプル.名前やインデックス番号でアクセスすることが出来るようになっている。

高階関数

Pythonにおいては、関数もオブジェクト。即ち、他の関数型や文字列型と同様に、関数の引数として引き渡したり、戻り値として返すことも出来る。
高階関数:「関数を引数/戻り値」として扱う関数のこと

以下が簡単な例

from collection.abc import Collable
from typing import Any

# 高階関数walk_list関数を定義
def walk_list(data: list[Any], func: Callable[[Any, int], None]) -> None:
    # リストの内容を順に処理
    for key, value in enumerate(data):
        # func経由で指定の関数を呼び出し
        funcvalue, key)

# リストを処理するためのユーザー定義関数
def show_item(value: Any, key: int) -> None:
    print(key, ':', value)

data = [105, 53, 27, 87, 33]
walk_list(data, show_item)
実行結果
0 : 105
1 : 53
2 : 27
3 : 87
4 : 33

ユーザー定義関数funcは差し替え可能。例えば以下。

from collection.abc import Collable
from typing import Any

# 高階関数walk_list関数を定義
def walk_list(data: list[Any], func: Callable[[Any, int], None]) -> None:
    # リストの内容を順に処理
    for key, value in enumerate(data):
        # func経由で指定の関数を呼び出し
        funcvalue, key)

result = 0
def calc_sum(value: float, key: int) -> None:
    global result      # グローバル変数の利用を宣言
    result += value    # リストの値をresultに加算

data = [105, 53, 27, 87, 33]
walk_list(data, calc_sum)
print(result)    # 305

無名関数(lambda式)

基本知識

lambda式:無名関数をその場で作るための式
lambda <パラメータ列>: <単一の式> で記述でき、単一の式の評価結果が戻り値になる
→ 文(statement)は書けません(if/for/while/try/with/return などは不可)。ただし 代入式 := は式なので lambda 内で使用可

def との使い分け

短い一発変換・キー関数・その場限りのコールバック → lambda
数行以上の処理・再利用・テスト/ドキュメント化 → def を使う

# 悪い例:長い処理をlambdaで無理やり書く
# key=lambda s: (s.strip().lower().replace('-', ' ').split()[0] if ... else ...)

# 良い例:読みやすさ重視で def
def norm_key(s: str) -> tuple[str, int]:
    s2 = s.strip().lower().replace('-', ' ')
    head = s2.split()[0] if s2 else ''
    return (head, len(s2))
sorted_list = sorted(["Z-foo", "a bar"], key=norm_key)

どのような場面で用いるか?

  1. sorted / min / maxkey 関数
# 大文字小文字を無視してソート
words = ["banana", "Apple", "cherry"]
print(sorted(words, key=lambda s: s.casefold()))  # ['Apple', 'banana', 'cherry']
  1. map / filter と組み合わせ
nums = [1, 2, 3, 4, 5]
print(list(map(lambda x: x * x, nums)))              # [1, 4, 9, 16, 25]
print(list(filter(lambda x: x % 2 == 0, nums)))      # [2, 4]
  1. ディスパッチ(条件分岐の簡潔化)
# 操作名→処理の対応表を作る
ops = {
    "add":  lambda a, b: a + b,
    "sub":  lambda a, b: a - b,
    "mul":  lambda a, b: a * b,
    "div":  lambda a, b: a / b if b != 0 else float("inf"),
}
print(ops["mul"](6, 7))  # 42

lambda式についての総まとめ的なコード

# 目的:レコードの前処理 → フィルタ → 並べ替え

records = [
    {"name": " alice ", "age": 31, "dept": "A"},
    {"name": "",        "age": 40, "dept": "C"},
    {"name": "Bob",     "age": 25, "dept": "B"},
]

# A) lambda を最小限で使う(短い変換/抽出に限定)
norm = lambda r: {**r, "name": (r["name"].strip().casefold() or None)}
stage1 = map(norm, records)
stage2 = filter(lambda r: r["name"] is not None and r["age"] >= 30, stage1)
result = sorted(stage2, key=lambda r: (r["dept"], -r["age"], r["name"]))
print(result)

# B) 読みやすさ重視(def + itemgetter)
from operator import itemgetter
def normalize(r: dict) -> dict:
    name = r["name"].strip().casefold()
    return {**r, "name": name or None}

stage1b = map(normalize, records)
stage2b = filter(lambda r: r["name"] is not None and r["age"] >= 30, stage1b)
result_b = sorted(stage2b, key=lambda r: (r["dept"], -r["age"], r["name"]))
print(result_b)

デコレーター

デコレーター(関数デコレーター):既存の関数に機能を追加するための仕組み
→ デコレーターを利用することで、関数を呼び出した際に実行ログを出力したり、呼び出し結果をキャッシュしたりといったことが可能。

デコレーターを利用しない例

以下は例。

from collections.abc import Callable
from typing import Any

def log_func(func: Callable[..., Any]) -> Callable[..., Any]:
    # 関数内関数を定義
    def inner(*args, **kwargs):
        print('---------------')
        print(f'Name: {func.__name__}')
        print(f'Args: {args}')
        print(f'Kwargs: {kwargs}')
        print('---------------')
        return func(*args, **kwargs)
    return inner

def hoge(x: int, y: int, m: str = 'bar', n: str = 'piyo') -> None:
    print(f'hoge: {x} - {y} / {m} - {n}')

# log_func関数の戻り値を実行
log_hoge = log_func(hoge)
log_hoge(15, 37, m = 'ほげ', n = 'ぴよ')
実行結果
---------------
Name: hoge
Args: (15, 37)
Kwargs: {'m': 'ほげ', 'n': 'ぴよ'}
---------------
hoge: 15 - 37 / ほげ - ぴよ

一見すると、log_hoge関数では、「元の関数(引数func)の情報 - 名前や引数情報を出力したうえで、関数funcを実行している」ように見えるが、これらの処理は入れ子の関数innerとして定義されており、log_func関数はinner関数を戻り値として返している。つまり、log_func関数は、関数を受け取って関数を返す高階関数。

log_hogeは、log_func関数の戻り値innerで、inner関数はlog_func関数を呼び出した時に渡されたhoge関数をそのまま維持しているので、ここではhoge関数の情報を表示した後にhoge関数を実行している。

デコレーターの基本

しかし、上のように高階関数を呼び出して、その戻り値の関数をさらに呼び出すというのは直感的でない。
→ Pythonではデコレーターにより、高階関数による拡張をシンプルに表現できる。

from collections.abc import Callable
from typing import Any

def log_func(func: Callable[..., Any]) -> Callable[..., Any]:
    # 関数内関数を定義
    def inner(*args, **kwargs):
        print('---------------')
        print(f'Name: {func.__name__}')
        print(f'Args: {args}')
        print(f'Kwargs: {kwargs}')
        print('---------------')
        return func(*args, **kwargs)
    return inner

@log_func
def hoge(x: int, y: int, m: str = 'bar', n: str = 'piyo') -> None:
    print(f'hoge: {x} - {y} / {m} - {n}')

hoge(15, 37, m = 'ほげ', n = 'ぴよ')
実行結果
---------------
Name: hoge
Args: (15, 37)
Kwargs: {'m': 'ほげ', 'n': 'ぴよ'}
---------------
hoge: 15 - 37 / ほげ - ぴよ

機能拡張したい関数(今回はhoge)の頭に「@名前」の形式で、高階関数を指定すればよい。

引数を受け取るデコレーター

デコレーターは通常の関数と同じく、引数を持たせることも出来る。
上記の@log_funcデコレーターを引数detailsを受け取れるように集令した例が以下。
引数detailsがFalseの場合(既定値はTrue)、@log_funcデコレーターは簡易な関数情報(名前のみ)を表示。

from collections.abc import Callable
from typing import Any

def log_func(details: bool = True) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    # 修飾すべき関数を受け取る
    def outer(func: Callable[..., Any]) -> Callable[..., Any]
        # 本来の関数に渡すべき引数を受け取る       
        def inner(*args, **kwargs):
            print('---------------')
            print(f'Name: {func.__name__}')
            if details:
                print(f'Args: {args}')
                print(f'Kwargs: {kwargs}')
            print('---------------')
            return func(*args, **kwargs)
        return inner
    return outer

@log_func
def hoge(x: int, y: int, m: str = 'bar', n: str = 'piyo') -> None:
    print(f'hoge: {x} - {y} / {m} - {n}')

log_hoge(15, 37, m = 'ほげ', n = 'ぴよ')

@log_func
def hoge(x: int, y: int, m: str = 'bar', n: str = 'piyo') -> None:
    print(f'hoge: {x} - {y} / {m} - {n}')

hoge(15, 37, m = 'ほげ', n = 'ぴよ')
実行結果(Trueの場合)
---------------
Name: hoge
Args: (15, 37)
Kwargs: {'m': 'ほげ', 'n': 'ぴよ'}
---------------
hoge: 15 - 37 / ほげ - ぴよ

引数を受け取るデコレーターでは、関数の入れ子が一段階増える点に注意。外側から。
・デコレーターの引数を受け取る(log_func):最上位の関数
・修飾すべき関数を受け取る(outer):実際にデコレーター―として使われる関数
・修飾すべき関数の引数を受け取る(inner):hogeを実行
が入れ子になっている。トップレベルの関数(log_func)、1段目の関数(outer)はそれぞれ直下の関数を戻り値として返さなければならない。
修正した@log_funcデコレーターは。引数付きで呼び出すことはできない

関数閉方(クロージャ―)

クロージャ―:上位のローカル変数を参照した入れ子の参照
→ スコープの基本は、「ローカル変数は関数を抜けたところで破棄される」というのが基本

上で示した例を再考する。
log_func関数から返されたinner関数は、関数の外でも変数funcを維持している(funcはlog_func関数の引数で、ローカル変数)。
→ これは、グローバル変数log_hogeがinner関数を、inner関数がfuncを参照しているために起こる。参照されているということは、まだ必要なので、ローカル変数inner/funcは維持されている。
デコレーターは、クロージャ―を利用した典型的な仕組みである。

例えば、
・入れ子のinner関数(入れ子のローカルスコープ)
・トップレベルのlog_func関数(ローカルスコープ)
・グローバルスコープ
というスコープチェーンが、クロージャー(inner関数)が有効である間は保持されるということになる。

from collections.abc import Callable

def counter(init: int) -> Callable[[], int]:
    # カウント値
    count = init

    # カウント値をインクリメントする内部関数
    def increment() -> int:
        nonlocal count
        count += 1
        return count
    return increment

c1 = counter(1)
c2 = counter(25)
print(c1())   # 2
print(c1())   # 3
print(c2())   # 26
print(c2())   # 27

ローカルスコープは、関数呼び出しの都度に生成される。そしてそれぞれのスコープチェーンやその中で管理されるローカル変数countも別物。結果、クロージャ―でc1, c2(実態はいずれもincrement関数)が呼び出された場合も、独立したローカル変数がインクリメントされて、上記のような結果となる。

あとがき

本記事では、初心者向け書式ではあまり触れられないクロージャ―やデコレーターという概念を扱うため、その前提となる知識を含めて整理した。デコレーターという概念は、これ以降の記事でも現れるオブジェクト指向プログラミングにおいても必要な概念となる。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?