LoginSignup
1
0

【Effective Python】仮引数の定義方法と、実引数の受け取り方、型アノテーションまとめ

Posted at

前書き

Pythonでは、関数で様々な引数を設定することが可能です。ここでは復習を兼ねて、Effective Pythonの内容を絡めつつまとめていきます。

なお、以下では仮引数と実引数という用語を説明なしに利用しています。見覚えが無い方は以下の折りたたみをご確認下さい。

仮引数と実引数について

仮引数は、関数を定義するときの引数です。計算をするにしろ、文字列を処理するにしろ、どんな値が与えられるかは、実際に利用されるまでわかりません。そのため、仮に設定された引数、仮引数と呼ばれています。

tentative_parameter.py
def greet(user_name):
    return f"こんにちは。{user_name}さん。"

user_name、つまりユーザ名は実際に与えられるまで本当のところはわかりません。そのため、この定義部分のuser_nameは仮引数です。

一方で、これを利用するときには、実際に名前を文字列として与えます。

actual_parameter.py
print(greet("uncle sam"))
こんにちは。uncle samさん。

ここで与えたuncle samという文字列が実用する際の引数ということで、実引数になります。

------------------------折りたたみここまで------------------------

引数一覧

1.)位置またはキーワードにより判別される引数

その名の通り、与えられた実引数を位置か、もしくはキーワードにより判別するやり方です。前者を位置引数、後者をキーワード引数と呼ぶのが一般的です。

初めにまとめておきますと、この位置またはキーワードにより判別される引数には、以下のような特徴があります。

強み: 引数が位置またはキーワードのどちらでも与えられるため、柔軟性や汎用性に富む
弱み: 制限が緩やかであるため、誤った利用を招いてしまう恐れがある
活用法: 両方の引数タイプを併用して、利点を最大限に活用する。また、デフォルト値を設定することで、拡張性と可読性を向上させることが可能になる

1_1.) 位置により判別される引数

まずは一番基本となる位置により判別される引数、通称位置引数です。定義したときの仮引数の位置と、利用される時の実引数の位置が同じだという考え方で実引数を受け取ることになります。

ここでは割り算を行う関数を例として考えてみましょう。1番目がdivided_number(割られる数)で、2番目がdividing_number(割る数)として実引数を受け取ります。

1_1_1.py
def simple_division(divided_number, dividing_number):
    return divided_number / dividing_number

# 左からdivided_number, divinding_number
assert simple_division(3, 2) == 1.5

最もシンプルな設定方法です。特に理由がなければこれでOKという代物ですね。実際に一番使われているのではないでしょうか。

ですが一方で、位置だけによる引数の判別に頼りすぎるのも考えものです。というのも、定義の順番はあくまで作った側が、作ったときに考えたものにすぎないため、誤った使い方をしてしまう可能性があります。

たとえば下の例だと、割る数と割られる数を逆にとってしまっているので、想定された解答が得られなくなります。

1_1_2.py
# 誤った使用法
assert simple_division(2, 3) == 1.5
AssertionError:

また、関数の機能を拡張したい場合、呼び出し部分を全て書きかえる必要が出てくるのもデメリットのひとつです。

ここでは例として、0で割ったときの処理を、エラーを挙上させるパターンと、無限大で出力させるパターンとに分けてみましょう。ちなみに、あまり見覚えが無い人も居るかもしれないtry,except,else、そしてraiseのセットについては以下に折り畳んでおきます。

try,except,else文とraise

通常であれば、Python側で許されていない挙動を設定した場合、即座に例外がraiseされてプログラムが停止します。
ここではユーザーに自分の年齢を入力してもらうケースを考えてみます。

exception1.py
user_input = input('年齢を数字で入力して下さい。')
age = int(user_input)
print(f"年齢は{age}歳でお間違い無いですか?")

この入力プログラム、数字で入力されている場合はよいのですが、たとえば「24歳」のように歳をつけられた場合や、「二十四」のように漢数字を使われた場合には、

ValueError: invalid literal for int() with base 10: '24歳'

というエラーを吐きます。動作を確認したいのであればこれが望ましいですが、実際に運用する際には、ユーザーに再入力を促したいです。このようなときに用いるのがtry,except,else文です。

まずtry文でエラーが挙上する可能性がある処理を書きます。そして、エラーが挙上した場合の処理をexcept文の中に、挙上しなかった場合はelse文の中にそれぞれ処理を書きます。

exception2.py
try:
    user_input = input("年齢を数字で入力してください。")
    age = int(user_input)  # エラーが起こる可能性がある処理
except ValueError:  # ValueErrorが起きたときの処理
    print(f"{user_input}は有効な入力ではありません。数を入力してください。")
else:  # ValueErrorが起きなかったときの処理
    print(f"年齢は{age}歳ですね。")

こうすることで、プログラムを終了させることなく、ユーザーに再入力を促すことができます。ぱっと考えつくのはwhile Trueでループさせるというやり方でしょうか。

exception3.py
while True:
    try:
        user_input = input("年齢を数字で入力してください。")
        age = int(user_input)
    except ValueError:
        print(f"{user_input}は有効な入力ではありません。数を入力してください。")
    else:
        print(f"年齢は{age}歳ですね。")
        break
print(f"ようこそ{age}歳のゲストさん。")

ここにさらにraiseを組み合わせたのがこの後登場する関数です。raiseを単体で用いるのは少し珍しいですが、これは使用されているexceptブロックの例外を再び挙上させるという動作になります。

今回の例ですが、開発途中であることを想定すると、そのままループに戻るのではなく、どのような値が入力されたときにエラーが吐かれているのかを詳しく知りたいこともあるでしょう。

ここではdebug_modeというbool型の値を新しく作って、これがTrueの時はエラーを引き起こした値と型を出力し、その後raiseしてもらうようにしてみます。あくまでraiseだけで利用するのが重要であり、raiseの後にValueErrorまで書いてしまうと、二重に挙上させてしまうので要注意です。

exception4.py
debug_mode = True

while True:
    try:
        user_input = input("年齢を数字で入力してください。")
        age = int(user_input)
    except ValueError:
        if debug_mode:
            print(f"user_input: {user_input}, type: {type(user_input)}")
            # ValueErrorを挙上
            raise
        else:
            print(f"{user_input}は有効な入力ではありません。数を入力してください。")
    else:
        print(f"年齢は{age}歳ですね。")
        break
print(f"ようこそ{age}歳のゲストさん。")
出力
年齢を数字で入力してください。a
user_input: a, type: <class 'str'>
# 中略
ValueError: invalid literal for int() with base 10: 'a'

このように利用することで、エラーの時の挙動を細かく設定できるのがtry,except,else、そしてraise文の働きです。

なお、これは本筋とは関係ありませんが、Pythonではexceptを複数並べることができます。複数並べた場合は上から順に判定されていきますので、予期せぬ例外を補足するために、以下のような例外全体をキャッチしてくれるexceptブロックをおいておくのも一つの手段です。

exception5.py
import sys


while True:
    try:
        user_input = input("年齢を数字で入力してください。")
        age = int(user_input)
    except ValueError:
        print(f"{user_input}は有効な入力ではありません。数を入力してください。")
    except Exception as e:
        print("想定されていないエラーが発生しました。以下のメッセージを管理者にお伝え下さい。")
        print(type(e).__name__)
        sys.exit(1)
    else:
        print(f"年齢は{age}歳ですね。")
        break
print(f"ようこそ{age}歳のゲストさん。")

ここではValueError以外の全てのエラーをexcept Exception as eとしてキャッチすることで、想定していないエラーにも対応できるようにしてあります。
------------------------折りたたみここまで------------------------

1_1_3.py
def division(divided_number, dividing_number, using_inf):
    try:
        answer = divided_number / dividing_number
    except ZeroDivisionError:
        if using_inf:
            return float('inf')
        else:
            raise
    else:
        return answer


assert division(3, 2, True) == 1.5
assert division(1, 0, True) == float('inf')
assert division(3, 2) == 1.5
TypeError: division() missing 1 required positional argument: 'using_inf'

最後の行で、using_infの指定がないせいで、TypeErrorを吐いています。つまり、位置引数だけを利用して関数の機能を拡張しようとした場合、利用されている箇所を全てチェックしなければならないことになります。

個人単位で開発していても結構なダルさなので、これが複数人、しかも大規模な開発になると、面倒臭さも問題の大きさも桁違いであることは容易に想像できます。

また、これ以外にも、位置引数には増えすぎると扱いが面倒になるという欠点があります。ここでは関数の方向性を少し変えて、割り算の商と余りを個別に出力もできるようにしてみましょう。

1_1_4.py
# 余りも出力できるように
def division_with_remainder(divided_number, dividing_number, using_inf, with_remainder):
    try:
        if with_remainder:
            quotient, remainder = divmod(divided_number, dividing_number)
            return quotient, remainder
        else:
            answer = divided_number / dividing_number
            return answer
    except ZeroDivisionError:
        if using_inf:
            return float('inf')
        else:
            raise


assert division_with_remainder(3, 2, True, True) == (1, 1)
assert division_with_remainder(1, 0, True, True) == float('inf')
assert division_with_remainder(3, 2, True, False) == 1.5

これ自体があまり良くない拡張の方向である(呼び出し側でかなり気を使わなければならない・型ヒントの動作が怪しくなるなど)のはさておいて、それを差し引いた上でもこの拡張はよくない拡張になります。というのも、シンプルにusing_infとwith_remainderが分かりづらいからです。呼び出すたびに気を使っておかないと、たとえば逆の順に取ってしまったりするでしょう。

1_1_5.py
# 商を取らないタイプの計算をしているつもり
assert division_with_remainder(3, 2, False, True) == 1.5
AssertionError: 

具体的な数は個人の信条やプロジェクトの方針などにより異なるでしょうが、私の場合は位置引数だけの指定は2個までに留めるようにしています。結構な痛い目にあったことがあったせいで、慎重になりすぎている感がありますが、それを差し引いても3個、ギリギリで4個程度が限界ではないでしょうか。

1_2.) キーワードにより判別される引数

上述の問題をある程度解決してくれるのが、引数名で判別される引数、通称キーワード引数です。

関数の定義部分はそのままですが、呼び出し部分で引数名を利用する点が変わっています。

1_2_1.py
def division(divided_number, dividing_number, using_inf):
    answer = divided_number / dividing_number
    return answer

# 位置ではなく、名前で指定
assert division(divided_number=3, dividing_number=2) == 1.5

位置だけで与えていたときに比べて、どの値が何を意味しているのかが定義部分を読まなくても理解しやすくなっています。

また、複数の実引数を与える場合でも、値同士を混同してしまう可能性は低くなります。

1_2_2.py
assert division_with_remainder(divided_number=3, dividing_number=2, using_inf=True, with_remainder=True) == (1, 1)
assert division_with_remainder(divided_number=3, dividing_number=2, using_inf=True, with_remainder=False) == 1.5

順番も自由です。

1_2_3.py
assert division_with_remainder(with_remainder=True, dividing_number=2, divided_number=3, using_inf=True) == (1, 1)
assert division_with_remainder(using_inf=True, divided_number=3, dividing_number=2, with_remainder=False) == 1.5

このように、キーワード引数は誤った使用を抑える効果があります。呼び出し側でしっかりと指定することで、混同を防げるということですね。

一方で、字面が少々くどくなるという弱点もあります。これはまぁ仕方のないことなのですが、全てをキーワード引数で指定すると、PEP8で規定された1行79、または80文字という原則の制限を超えてしまいます。いちいちスクロールさせるのも面倒ですしね。

1_3.) 位置とキーワードの併用

ここまでで、それぞれ位置引数とキーワード引数を見てきましたが、この2つは併用することが可能です。

1_3_1.py
# divided_numberとdividing_numberは位置で、残り2つはキーワードで
assert division_with_remainder(3, 2, using_inf=True, with_remainder=True) == (1, 1)

どこまでを位置で与え、どこからをキーワードで与えるかは自由に決めてOKです。ただし、キーワードで与えた始めた後に、再び位置で与えることはできません。

1_3_2.py
assert division_with_remainder(3, 2, True, True) == (1, 1)
assert division_with_remainder(3, 2, True, with_remainder=True) == (1, 1)
assert division_with_remainder(3, 2, using_inf=True, with_remainder=True) == (1, 1)
assert division_with_remainder(3, dividing_number=2, using_inf=True, with_remainder=True) == (1, 1)
assert division_with_remainder(3, dividing_number=2, True, with_remainder=True) == (1, 1)
SyntaxError: positional argument follows keyword argument

実用を考えると、やはり併用するのが望ましいかなと思われます。混同しやすいと思われるところからキーワード引数に切り替えるという考え方ですね。

1_4.) デフォルト値の設定

ここからは少し趣が変わって、定義側のお話になります。関数の定義時には、デフォルト値を設定することが可能です。つまり、通常想定された振る舞いがある場合は、いちいちそれを与えずに済むように、予めデフォルトの値をこちらで与えておくということができます。

ここでは無限大を出力したり、余りを出力したりするのはあくまで特別な場合だと考えて、通常は普通の割り算として振る舞うようにしてもらいましょう。設定の方法はカンタンで、仮引数の定義時にデフォルトの値をイコールで結んで書いておくだけです。

1_4_1.py
def division_with_remainder(divided_number, dividing_number, using_inf=False, with_remainder=False):
    try:
        if with_remainder:
            quotient, remainder = divmod(divided_number, dividing_number)
            return quotient, remainder
        else:
            answer = divided_number / dividing_number
            return answer
    except ZeroDivisionError:
        if using_inf:
            return float('inf')
        else:
            raise

デフォルト値を与えることで、既存の利用部分を邪魔せずに機能を拡張することができます。これは大きな利点です。

1_4_2.py
# 既存の部分
assert division_with_remainder(3, 2) == 1.5
# 新しい機能を利用している部分
assert division_with_remainder(3, 2, using_inf=True, with_remainder=False) == 1.5
assert division_with_remainder(3, 2, using_inf=False, with_remainder=True) == (1, 1)

位置またはキーワードにより判別される引数のまとめ

ここでいったん、位置またはキーワードにより判別される引数をまとめておきます。

強みは、実引数を、位置でもキーワードでも受け入れることで、柔軟に利用できることです。一方で、弱みは、厳しく制限を設けていないことで、それを原因とした誤った利用法や、エラーなどが発生してしまう可能性があることです。これらはトレードオフの関係になっていると言えるでしょう。

これらの特徴のうち、良い部分のみを享受するためには、併用するのがベターです。位置引数は誤用しづらい個数までに留めます。また、誤用しやすい要素(型が同じなど)を含む場合は、キーワード引数を積極的に活用します。

さらに、予め想定される挙動がある場合は、デフォルト値の設定も重要です。デフォルト値を設定することで、既存の利用部分を邪魔せず機能を拡張することが可能です。また、実引数を与えている部分がスッキリすることで、可読性を向上させる効果も期待できるでしょう。

2.) 専用引数

次は専用引数についてです。ここでいう専用引数は、位置とキーワード、いずれかでしか受け取れない引数を指します。前者は位置専用引数、後者はキーワード専用引数と呼ばれることが多いです。

上述した位置またはキーワードにより判別される引数は、呼び出し側に与え方を委ねていましたが、こちらは一転して、定義側が呼び出し側に指定した与え方を強制する定義方法になります。

強みと弱みは以下のようになります。

強み: 実引数の与え方を限定させることにより明確性が生まれ、誤った利用やエラーが発生する可能性を低くすることができる
弱み: 位置またはキーワードにより判別される引数より柔軟性や汎用性に乏しい
活用法: それぞれの性質を理解した上で、使うべき部分を注意深く見極めて利用する

2_1.) 位置でのみ判別される引数

まずは位置でのみ判別される引数、通称位置専用引数です。これはその名の通り、位置引数でのみ呼び出しを許容する定義方法で、Python3.8から利用できるようになりました。

位置専用引数の設定方法はシンプルで、仮引数を記述する際に、/の手前に置いたものが位置専用引数となります。

ここでは、先ほどまで使っていた割り算の関数を引き続き利用して動作を確認していきましょう。divided_numberとdividing_numberを位置専用引数に変更します。

2_1_1.py
def division(divided_number, dividing_number, /, using_inf=True):
    try:
        answer = divided_number / dividing_number
    except ZeroDivisionError:
        if using_inf:
            return float('inf')
        else:
            raise
    else:
        return answer

/の手前にあるdivided_numberとdividing_numberは位置でのみ指定が可能です。キーワードで指定しようとするとTypeErrorを吐きます。

2_1_2.py
assert division(3, 2) == 1.5
assert division(1, 0, using_inf=True) == float('inf')
assert division(divided_number=3, dividing_number=2) == 1.5
TypeError: division() got some positional-only arguments passed as keyword arguments: 'divided_number, dividing_number'

この位置専用引数の利点は、やはり内部の実装変更が外部に影響を及ぼさないという点でしょうか。

例えば、ここまで割られる数はdivided_number, 割る数はdividing_numberとしていましたが、割られる数はdividend、割る数はdivisorとした方が自然な表記のようです。そこで、より正確に関数の機能を表現するために、仮引数の名前を変えましょう。位置専用引数は利用しないこととします。

2_1_3.py
# divided_numberをdividend, dividing_numberをdivisorへ
def division(dividend, divisor, using_inf=True):
    try:
        answer = dividend / divisor
    except ZeroDivisionError:
        if using_inf:
            return float('inf')
        else:
            raise
    else:
        return answer

この方法で内部の実装を変更しても、位置での呼び出しであれば特に問題は発生しません。一方で、キーワードでの呼び出しを利用している箇所はエラーを吐きます。

2_1_4.py
assert division(3, 2) == 1.5
assert division(divided_number=3, dividing_number=2) == 1.5
TypeError: division() got an unexpected keyword argument 'divided_number'

こうなると、利用者はわざわざ定義部分まで遡って確認しないと、関数を利用することができなくなってしまいます。

一方で、初めから位置専用引数にしておけば、そもそもキーワードにより判別される引数を与えることが無くなるため、エラーを吐くこともなくなります。

2_1_5.py
def division(dividend, divisor, /, using_inf=True):
    try:
        answer = dividend / divisor
    except ZeroDivisionError:
        if using_inf:
            return float('inf')
        else:
            raise
    else:
        return answer

assert division(3, 2) == 1.5
assert division(1, 0, using_inf=True) == float('inf')

このやり方はPythonの組み込み関数にも利用されています。例えばrange関数がこれに当たります。

2_1_6.py
for i in range(start=1, stop=5, step=2):
    print(i)
TypeError: range() takes no keyword arguments

他にも標準ライブラリのmathモジュールにある平方根を求めるsqrtがこれに当てはまります。

2_1_7.py
import math

s = math.sqrt(x=2)
TypeError: math.sqrt() takes no keyword arguments

普通の位置引数にあった柔軟性こそ失われているものの、外部から常に一定の方式で利用されるように強制出来るのは大きなメリットです。特に外部から頻繁にアクセスされるインタフェースにおいては、非常に有用だと言えるでしょう。

ただし一方で、位置専用引数を増やしすぎると、普通の位置引数のように、混乱を招く恐れがあります。どこまで増やすかはやはり個人やプロジェクトの方針によるでしょうが、個人的には2つ、多くても3つまでに留めるようにしています。

2_2.) キーワードでのみ判別される引数

キーワード引数には位置引数でも利用できてしまうという欠点がありました。これを解消するために利用できるのがキーワード専用引数です。その名の通り、キーワードでのみ実引数を与えることができる引数で、Python3.0から利用可能になりました。

キーワード専用引数は定義部分において、*を手前に置くことで定義可能です。*以降はキーワード専用引数となります。

2_2_1.py
def division(*, divided_number, dividing_number, using_inf=False):
    try:
        answer = divided_number / dividing_number
    except ZeroDivisionError:
        if using_inf:
            return float('inf')
        else:
            raise
    else:
        return answer

この定義方法であれば、キーワード指定のみを受け付けて、位置による指定は受け付けなくなります。位置で指定しようとすると、TypeErrorを吐きます。

2_2_2.py
assert division(divided_number=3, dividing_number=2, using_inf=True) == 1.5
assert division(3, 2, using_inf=True) == 1.5
TypeError: division() takes 0 positional arguments but 2 positional arguments (and 1 keyword-only argument) were given

ここでは実際の利用例として、いかにも混同されそうな、無限大と余りの指定はいずれもキーワード専用となるようにコードを変更してみましょう。

2_2_3.py
def division_with_remainder(dividend, divisor, *, using_inf=False, with_remainder=False):
    try:
        if with_remainder:
            quotient, remainder = divmod(dividend, divisor)
            return quotient, remainder
        else:
            answer = dividend / divisor
            return answer
    except ZeroDivisionError:
        if using_inf:
            return float('inf')
        else:
            raise

assert division_with_remainder(3, 0, using_inf=True) == float('inf')
assert division_with_remainder(3, 2, with_remainder=True) == (1, 1)

このようにキーワードでの指定を強制することで、混同されやすい利用方法を明確にするというキーワード引数の利点をさらに強化することができます。

一方、位置またはキーワードで判別される引数にあった利点の一部は失われています。利用方法が明確になった代わりに、呼び出し部分は複雑になります。また、濫用してしまうと、どのキーワード引数が重要なのかがわかりづらくなってしまう可能性もあります。このあたりは、やはりトレードオフになりますね。

専用引数まとめ

ここで、専用引数についてまとめていきます。

強みは、利用方法を制限することで関数の扱いを明確にできることです。誤用を防ぎ、エラーが発生する可能性を下げることが出来ます。一方で弱みは、制限を厳しくしているせいで、利用の利便性は失われてしまうことです。

これらの特徴のうち、良い部分のみを享受するためには、他のタイプの引数定義法と併用することが望ましいです。やたらと専用引数を定義してしまうと、利用が面倒な割に拡張性にも乏しい関数が出来上がってしまいます。

そのため、柔軟性を重視したい場所には位置またはキーワードにより判別される引数を、厳密に定義して使い方を明確に定めたい場合は専用引数を用いるというのが良いでしょう。さらにデフォルト値の設定も併用すると、よりよい関数になるでしょう。

3.) 可変長引数

ここから少し話を変えて、可変長、つまり任意の個数を受け取ることができる引数についてお話していきます。

ここまで「位置またはキーワードにより判別される引数」、「位置専用引数」、「キーワード専用引数」の3種類の引数を確認してきましたが、いずれも許容する実引数の与え方こそ違えど、指定された個数の引数を与えなければいけないという点は共通していました。

それに対して可変長引数はその名の通り、任意の長さの位置引数を受け取ることができる引数です。そのため、ここまでの3種類の変数のどれとも異なる性質を持つ引数と言えるでしょう。

特徴は以下のようになっています。

強み: 引数の個数に囚われない処理が出来る
弱み: 型や個数を限定できないため、意図しないエラーが起きる可能性が高い
活用法: ラッパー関数として利用。あるいは、想定されていない個数や値が与えられた時のために予めエラー処理を実装しておく

3_1.) 可変長位置引数

まずは可変長位置引数、つまり任意の個数の位置引数を受け取るための仮引数の定義方法からです。可変長位置引数は、変数の前に直接*(アスタリスク)を付けることで定義することができます

ここでは利用例として、与えられた数字がfloat型であることをチェックしつつ、和を求めてくれる関数を作ってみましょう。

3_1_1.py
def sum_of_float_with_check(*float_numbers):
    result = 0
    for number in float_numbers:
        if not isinstance(number, float):
            raise ValueError(f"'sum_of_float_with_check' function can only accept float number.")
        result += number
    return result

answer = sum_of_float_with_check(1.0, 2.0, 3.0, 4.0, 5.0)
assert answer == 15.0

ここでは5個のfloat型を与えていますが、可変長であるため、10個でも100個でも好きなだけ計算することが可能になっています。これが可変長位置引数の利点であり、実際に組み込み関数のprintには可変長位置引数が採用されています。

3_1_2.py
print("a")
print("a", "b")
print("a", "b", "c")
出力
a
a b
a b c

他にもmax関数やmin関数に可変長位置引数が利用されています。

3_1_3.py
max1 = max(1, 2, 3)
max2 = max(10, 11, 12, 13)
min1 = min(1, 2, 3)
min2 = min(10, 11, 12, 13)

assert max1 == 3
assert max2 == 13
assert min1 == 1
assert min2 == 10

ここで他の引数と同じく、似たような用法との比較に入りますが、前述した通り可変長引数はこれまでのいずれの引数とも異なる異質な引数です。そのため、比較対象となるのはリストやタプルなどの、いわゆる可変長データ構造を実引数として扱う場合になってくるでしょう。

先ほどまでの関数を、可変長位置引数ではなく、リストを用いる形で少し改変してみましょう。

3_1_4.py
def sum_of_float_with_check(float_numbers_list):
    result = 0
    for number in float_numbers_list:
        if not isinstance(number, float):
            raise ValueError(f"'sum_of_float_with_check' function can only accept float number.")
        result += number
    return result

numbers = [1.0, 2.0, 3.0, 4.0, 5.0]
answer = sum_of_float_with_check(numbers)
assert answer == 15.0

内部の動作は全く同じです。numbersというリスト型に予め値を格納している点だけが異なります。

この2つを比較した場合、可変長位置引数の強みはシンプルであることです。今回のように単純な和が欲しいだけの場合は可変長位置引数の方が優れているでしょう。一方で、可変長位置引数に与えられた位置引数は、一律で1つのタプルとして扱われます。

3_1_5.py
def sum_of_float_with_check(*float_numbers):
    print(f"float_numbers: {float_numbers}, type: {type(float_numbers)}")
    result = 0
    for number in float_numbers:
        if not isinstance(number, float):
            raise ValueError(f"'sum_of_float_with_check' function can only accept float number.")
        result += number
    return result

ans = sum_of_float_with_check(1.0, 2.0, 3.0, 4.0, 5.0)
出力
float_numbers: (1.0, 2.0, 3.0, 4.0, 5.0), type: <class 'tuple'>

そのため、複数の異なるグループを内部で扱いたかったり、リスト自体に何らかの操作を加えたい場合は元となるリストを用意するやり方を用いるべきです。

ここでは先ほどの関数をさらに改造し、float型とint型の数を分けて受け取り、それぞれの和を返すようにしてあげましょう。

3_1_6.py
def sum_of_float_and_int_with_check(*, float_numbers, int_numbers):
    float_sum = 0
    for number in float_numbers:
        if not isinstance(number, float):
            raise ValueError(f"'float_numbers' arg can only contain float numbers.")
        float_sum += number
    int_sum = 0
    for number in int_numbers:
        if not isinstance(number, int):
            raise ValueError(f"'int_numbers' arg can only contain int numbers.")
        int_sum += number
    return {"float_sum": float_sum, "int_sum": int_sum}

float_numbers = [1.0, 2.0, 3.0, 4.0, 5.0]
int_numbers = [1, 2, 3, 4, 5]
answer = sum_of_float_and_int_with_check(float_numbers=float_numbers, int_numbers=int_numbers)
print(answer)

さらに別方面の改造を施して、与えられたリストの最後に、リスト内の型が指定された型に統一されていたか否かをBool型で追加してくれる関数を作ります。

3_1_7.py
def check_of_float_and_int(*, float_numbers, int_numbers):
    all_float = True
    for number in float_numbers:
        if not isinstance(number, float):
            all_float = False
            break
    all_int = True
    for number in int_numbers:
        if not isinstance(number, int):
            all_int = False
            break
    float_numbers.append(all_float)
    int_numbers.append(all_int)
    
float_numbers = [1.0, 2.0, 3]
int_numbers = [1, 2, 3]
check_of_float_and_int(float_numbers=float_numbers, int_numbers=int_numbers)
print(float_numbers)
print(int_numbers)
出力
[1.0, 2.0, 3, False]
[1, 2, 3, True]

いずれの用法も可変長位置引数には向いていないです。単一のグループで良ければ可変長位置引数を利用し、グループごとに異なる処理を行いたいのであれば、可変長データ構造を利用するといった具合で目的に応じて使い分けていきたいですね。

ちなみに本筋とは関係ありませんが、可変長位置引数で扱いたいけれど、全てをカッコの中に記述するのは見づらいと感じる場合はアンパックを利用することも可能です。アンパックについては例のごとく折り畳んでおきますので、見覚えが無い方は参考にしてみて下さい。

実引数のアンパックについて

リストやタプルなどを展開した上で実引数として与えてくれる機能です。位置引数として与える場合と、キーワード引数として与える場合の2つがあります。

まずは位置引数として与えたい時。この場合は、実引数の前に*を置くことで位置引数として展開してくれます。

たとえば、与えられた実引数3つを区切り線つきで出力してくれる関数を考えてみましょう。

unpack1.py
def three_print_with_partition(arg1, arg2, arg3):
    print(arg1)
    print("-------")
    print(arg2)
    print("-------")
    print(arg3)
    print("-------")

この関数は、位置引数またはキーワードで判別される引数が3つ与えられることを想定しています。そのため、リストの中にひとまとめにして実引数として利用するだけだと、引数の数が足りないよ、とエラーを吐きます。

unpack2.py
lst = ["a", "b", "c"]
three_print_with_partition(lst)
出力
TypeError: three_print_with_partition() missing 2 required positional arguments: 'arg2' and 'arg3'

ですが、*を実引数の手前に置くだけで、位置引数として展開した上で関数に渡してくれるようになります。

unpack3.py
# three_print_with_partition(lst)
three_print_with_partition(*lst)
出力
a
-------
b
-------
c
-------

この機能はキーワードで判別される場合でも利用可能です。辞書の手前に**を置くと、キーを引数名、中身を値とするキーワード引数として渡してくれます。

unpack4.py
def three_print_with_partition(arg1, arg2, arg3):
    print(arg1)
    print("-------")
    print(arg2)
    print("-------")
    print(arg3)
    print("-------")

dct = {"arg1": "a", "arg2": "b", "arg3": "c"}
three_print_with_partition(**dct)
出力
a
-------
b
-------
c
-------

つまるところ、

unpack5.py
three_print_with_partition(*["a", "b", "c"])
three_print_with_partition(a, b, c)

は同じですし、

unpack6.py
three_print_with_partition(**{"arg1": "a", "arg2": "b", "arg3": "c"})
three_print_with_partition(arg1="a", arg2="b", arg3="c")

も同じです。

ちなみに、キーワード引数のアンパックは辞書型のみですが、位置引数のアンパックはタプルやセット型でも機能します。

unpack7.py
three_print_with_partition(*("a", "b", "c"))
three_print_with_partition(*{"a", "b", "c"})

ただし、セット型は順番を保証しませんので、順番を気にするときは使わないのが無難でしょう。

----------------折り畳みここまで----------------

3_1_8.py
def sum_of_float_with_check(*float_numbers):
    result = 0
    for number in float_numbers:
        if not isinstance(number, float):
            raise ValueError(f"'sum_of_float_with_check' function can only accept float number.")
        result += number
    return result

numbers = [1.0, 2.0, 3.0, 4.0, 5.0]
# アンパックを利用
answer = sum_of_float_with_check(*numbers)
assert answer == 15.0

アンパックを利用することで、より円滑に可変長位置引数を扱えるようになるでしょう。

ちなみにですが、この可変長位置引数の*は、キーワード専用引数で用いていたものと同じになります。あちらでは*を単独で用いていましたが、そこに変数名を与えると可変長位置引数として扱われるということですね。

ここまで見てきたように、可変長位置引数は全ての位置引数を吸収する働きを持っています。そのため、以降も追加で引数を与えたい場合はキーワードで与えざるを得なくなります。これをキーワード専用引数と呼んでいるわけですね。

3_1_9.py
def sample_func(*args, k_only1, k_only2):
    print(args)
    print(k_only1)
    print(k_only2)

sample_func(1, 2, 3, 4, 5, k_only1="a", k_only2="b")
出力
(1, 2, 3, 4, 5)
a
b

可変長位置引数とキーワード専用引数、これらは表裏一体ものだと言えるでしょう。

3_2.) 可変長キーワード引数

次は可変長キーワード引数です。名前の通り、任意の個数のキーワード引数を受け取ることができる引数となります。変数の前に**を直接付けることで定義することができます。

ここでは、学生の成績を受け取ってそのログを出力し、最高点と最低点、および平均点を出力する関数を例に上げましょう。可変長キーワード引数に与えた実引数は、関数内部では引数名をキーとした辞書となるため、items()を使っています。

python 3_2_1.py
from statistics import mean


def check_grade(**grades):
    for subject, score in grades.items():
        print(f"subject: {subject}, score: {score}")
    scores = grades.values()
    max_score = max(scores)
    min_score = min(scores)
    average_score = mean(scores)
    grade_book = {"max_score": max_score, "min_score": min_score, "average_score": average_score}
    return grade_book

print(check_grade(math=60, japanese=80))
print("-------------")
print(check_grade(math=50, english=90, physics=70))
出力
subject: math, score: 60
subject: japanese, score: 80
{'max_score': 80, 'min_score': 60, 'average_score': 70}
-------------
subject: math, score: 50
subject: english, score: 90
subject: physic, score: 70
{'max_score': 90, 'min_score': 50, 'average_score': 70}

小学生や中学生、高校生の間では当然テストの科目は異なります。また、学年や、高校生であれば文系か理系かで変わってくることもあるでしょう。このような集団を一括で処理する場合は、可変長キーワード引数が活躍できます。

ただし、これは可変長位置引数でも同じだったのですが、任意の個数のデータを緩く受け取ると、エラーが起こる可能性は高くなります。そのため、例えば「いずれのテストも点数は0以上100以下の整数である」等の共通条件がある場合は、それを予めコードに組み込んでおくべきでしょう。

3_2_2.py
from statistics import mean


def check_grade(**grades):
    for subject, score in grades.items():
        print(f"subject: {subject}, score: {score}")
        # 0点以上100点以下、かつ整数型でない場合にValueErrorを挙上させる
        if not((0 <= score <= 100) and isinstance(score, int)):
            raise ValueError(f"'score' must be 0 or more and 100 or less integer value.")
    scores = grades.values()
    max_score = max(scores)
    min_score = min(scores)
    average_score = mean(scores)
    grade_book = {"max_score": max_score, "min_score": min_score, "average_score": average_score}
    return grade_book


print(check_grade(math=60, japanese=80, civics=100, chemistry=120))
出力
subject: math, score: 60
subject: japanese, score: 80
subject: civics, score: 100
subject: chemistry, score: 120
# 中略
ValueError: 'score' must be 0 or more and 100 or less integer value.

ここからさらに実用に寄せて考えると、生徒情報と教科名を紐づけるためのデータコンテナなどを利用して、統一された教科名が使われるように気を使ったりすべきでしょうが、ここでは一旦置いておきます。

それで次にPythonの組み込み関数や標準モジュールの関数で可変長キーワード引数が利用されている例なのですが、自分には見つけることが出来ませんでした。もしご存知の方はコメント欄によろしくお願いします。

さらに、似たようなものとの比較になりますが、ここは可変長位置引数同様、単一のグループとして扱うのか、異なるグループとして扱うのかによって使い分けが生じます。今回のパターンでは、辞書でそのまま与えるパターンとの比較が妥当でしょう。

ここでは文系科目と理系科目で評価を分ける場合を想定してみます。

3_2_3.py
from statistics import mean


def check_grade(humanities_grade, science_grades):
    print("-------humanities-------")
    for subject, score in humanities_grades.items():
        print(f"subject: {subject}, score: {score}")
        if not((0 <= score <= 100) and isinstance(score, int)):
            raise ValueError(f"'score' must be 0 or more and 100 or less integer value.'")
    scores = humanities_grades.values()
    max_score = max(scores)
    min_score = min(scores)
    average_score = mean(scores)
    humanities_grade_book = {"max_score": max_score, "min_score": min_score, "average_score": average_score}
    print("-------science-------")
    for subject, score in science_grades.items():
        print(f"subject: {subject}, score: {score}")
        if not((0 <= score <= 100) and isinstance(score, int)):
            raise ValueError(f"'score' must be 0 or more and 100 or less integer value.'")
    scores = science_grades.values()
    max_score = max(scores)
    min_score = min(scores)
    average_score = mean(scores)
    science_grade_book = {"max_score": max_score, "min_score": min_score, "average_score": average_score}
    return humanities_grade_book, science_grade_book

humanities_grades = {"japanese": 40, "english": 60, "civics": 50}
science_grades = {"math": 70, "physics": 50, "chemistry": 90}
print(check_grade(humanities_grades, science_grades))
出力
-------humanities-------
subject: japanese, score: 40
subject: english, score: 60
subject: civics, score: 50
-------science-------
subject: math, score: 70
subject: physics, score: 50
subject: chemistry, score: 90
({'max_score': 60, 'min_score': 40, 'average_score': 50}, {'max_score': 90, 'min_score': 50, 'average_score': 70})

別に動作は問題ないのですが、同じ処理を繰り返しているのが若干可読性を損ねているとも感じられます。ここが気になるのであれば、評価部分は別関数に分けたり、関数内関数で対応するのも手段の一つだと思います。ここではスコア評価はこの関数内でしか行われないと仮定した上で、関数内関数で対応してみます。

3_2_4.py
from statistics import mean


def check_grade(humanities_grades, science_grades):
    def validate_scores(subject_grades):
        print("-------grades-------")
        max_score = max(subject_grades.values())
        min_score = min(subject_grades.values())
        average_score = mean(subject_grades.values())
        for subject, score in subject_grades.items():
            print(f"subject: {subject}, score: {score}")
            if not ((0 <= score <= 100) and isinstance(score, int)):
                raise ValueError("'score' must be 0 or more and 100 or less integer value.")
        return {"max_score": max_score, "min_score": min_score, "average_score": average_score}
        
    humanities_grade_book = validate_scores(humanities_grades)
    science_grade_book = validate_scores(science_grades)
    return humanities_grade_book, science_grade_book

humanities_grades = {"japanese": 40, "english": 60, "civics": 50}
science_grades = {"math": 70, "physics": 50, "chemistry": 90}
print(check_grade(humanities_grades, science_grades))
出力
-------grades-------
subject: japanese, score: 40
subject: english, score: 60
subject: civics, score: 50
-------grades-------
subject: math, score: 70
subject: physics, score: 50
subject: chemistry, score: 90
({'max_score': 60, 'min_score': 40, 'average_score': 50}, {'max_score': 90, 'min_score': 50, 'average_score': 70})

以上が可変長キーワード引数の概要になります。可変長であることだけならば可変長位置引数で対応可能なので、それに加えてキーを何かしらの形で活用したい、かつ単一のグループとして扱いたい場合に活躍できる引数と言えるでしょう。

なお、先述した通り、辞書をアンパックすることでもキーワード引数を与えることが可能です。辞書を作った上で、**を辞書名の前に置いてあげればOKです。

3_2_5.py
from statistics import mean


def check_grade(**grades):
    for subject, score in grades.items():
        print(f"subject: {subject}, score: {score}")
        # 0点以上100点以下、かつ整数型出ない場合にValueErrorを挙上させる
        if not((0 <= score <= 100) and isinstance(score, int)):
            raise ValueError(f"'score' must be 0 or more and 100 or less integer value.'")
    scores = grades.values()
    max_score = max(scores)
    min_score = min(scores)
    average_score = mean(scores)
    grade_book = {"max_score": max_score, "min_score": min_score, "average_score": average_score}
    return grade_book

grades = {"math": 60, "japanese": 80, "civics": 100, "physics": 60}
print(check_grade(**grades))
出力
subject: math, score: 60
subject: japanese, score: 80
subject: civics, score: 100
subject: physics, score: 60
{'max_score': 100, 'min_score': 60, 'average_score': 75}

可変長引数まとめ

ここで、可変長引数についてまとめておきます。

可変長引数はその名の通り、任意の個数の引数を受け取ることが出来る関数です。この点で、与え方こそ違えど、個数はしっかりと指定してきた他の引数とは大きく異なる引数であると言えます。

可変長位置引数はタプル、可変長キーワード引数は辞書として内部で扱われることになります。単一のグループとして扱いたい場合はこれらを活用すると良いでしょう。一方で、異なるグループとして扱いたい場合は、リストやタプル、辞書などの可変長データ構造の活用を検討すべきです。

利用する上での注意点は、エラーを引き起こす可能性があるのを常に意識しておくことです。引数の個数が限定できない以上、想定していないデータが混入してプログラムを破壊してしまう可能性はいつもつきまとってきます。しかし、型のチェックや値の確認などを事前に行っておくことで、これらのリスクは軽減することが可能です。

以上のことから、自分の扱いたいデータが単一のグループなのかをチェックした上で、エラー対策と共に用いることで、その効果を最大限に発揮できるのが可変長引数だと言えるでしょう。

ちなみに、ここまで触れてきませんでしたが、可変長引数にデフォルト値を設定することはできません。

variable_positional_argument_with_default.py
def sample(*pargs=[]):
    print(pargs)

このような関数を定義すると、即座に以下のように怒られてしまいます。

SyntaxError: var-keyword argument cannot have default value

「可変長引数にデフォルト値を設定すんな」ってことですね。可変長キーワード引数でも同様です。

variable_keyword_argument.py
def sample(**kwargs=[]):
    print(kwargs)
SyntaxError: var-keyword argument cannot have default value

ドキュメントと型アノテーションについて

ここまで引数の種類について解説してきましたが、最後に別口から、関数をより便利かつ多様に利用する方法であるドキュメントと型アノテーションについて解説していきます。

ドキュメントはその名の通り、クラスや関数などの具体的な利用方法を記した文章です。例えば関数であれば、以下のようにざっくりとした関数の目的、および引数の型と返り値の型について記載するのが基本となります。

document1.py
def simple_division(dividend, divisor):
    """単純な割り算を実行する
    
    Args:
        dividend (int): 割られる数
        divisor (int): 割る数
    
    Returns:
        answer (float): 割り算の結果
    """
    answer = dividend / divisor
    return answer

これを読むことで、割られる数と割る数はint型を想定していること。また、出力されるのはfloat型であることが理解できます。また仮に、割られる数と割る数にfloat型も想定しているプログラムであれば以下のように書き換えることもできます。

document2.py
def simple_division(dividend, divisor):
    """単純な割り算を実行する
    
    Args:
        dividend (Union[int, float]): 割られる数
        divisor (Union[int, float]): 割る数
    
    Returns:
        answer (float): 割り算の結果
    """
    answer = dividend / divisor
    return answer

Union[a, b]と書くことで、a,bのいずれかが与えられることを想定していることが示せます。

また、これ以外にも、たとえばデフォルト値がNoneで与えられている関数ではOptionalを利用することでこれを示せます。

少々無理やり感がありますが、特に指定がない場合は通常通りfloatで、指定があった場合はintで返すような関数に魔改造してみます。これに伴い、返す値もUnion[float, int]に変更し、さらに注意事項として返り値に関する説明もドキュメントに追加してあります。

document3.py
def simple_division(dividend, divisor, type_specification=None):
    """単純な割り算を実行する
    
    Args:
        dividend (Union[int, float]): 割られる数
        divisor (Union[int, float]): 割る数
        type_specification (Optional[str]): 割り算の結果の型を指定。デフォルト値はNone
    
    Returns:
        answer (Union[float, int]): 割り算の結果
    
    Note:
        type_specificationがNoneの時は通常通りfloat,"int"のときはintを返す
    """
    if type_specification is None:
        answer = dividend / divisor
    elif type_specification == "int":
        answer = int(dividend / divisor)
    return answer

assert simple_division(3, 2) == 1.5
assert simple_division(3, 2, "int") == 1

次はこの関数に、そのまま型アノテーションを追加してみます。型アノテーションはPython3.5から導入された機能で、以下のように、引数に対して具体的な型を示すために利用されます。

type_annotation1.py
from typing import Optional, Union

def simple_division(dividend: Union[int, float], divisor: Union[int, float], type_specification: Optional[str]=None):
    """単純な割り算を実行する
    
    Args:
        dividend (Union[int, float]): 割られる数
        divisor (Union[int, float]): 割る数
        type_specification (Optional[str]): 割り算の結果の型を指定。デフォルト値はNone
    
    Returns:
        answer (Union[float, int]): 割り算の結果
    
    Note:
        type_specificationがNoneの時は通常通りfloat,"int"のときはintを返す
    """
    if type_specification is None:
        answer = dividend / divisor
    elif type_specification == "int":
        answer = int(dividend / divisor)
    return answer

assert simple_division(3, 2) == 1.5
assert simple_division(3, 2, "int") == 1

この型アノテーションは人間が読んでさくっと仕様を把握できる以外にも、静的型チェッカーで正しく利用しているかを確かめられるという利点があります。有名所だとmypyやPyrightなどが挙げられるでしょうか。ここではmypyを使って型をチェックしてみましょう。

division_mypy.py
from typing import Optional, Union


def simple_division(dividend: Union[int, float], divisor: Union[int, float], type_specification: Optional[str]=None):
    """単純な割り算を実行する
    
    Args:
        dividend (Union[int, float]): 割られる数
        divisor (Union[int, float]): 割る数
        type_specification (Optional[str]): 割り算の結果の型を指定。デフォルト値はNone
    
    Returns:
        answer (Union[float, int]): 割り算の結果
    
    Note:
        type_specificationがNoneの時は通常通りfloat,"int"のときはintを返す
    """
    if type_specification is None:
        answer = dividend / divisor
    elif type_specification == "int":
        answer = int(dividend / divisor)
    return answer


answer1 = simple_division(3, 2)
answer2 = simple_division("3", 2)

ここに、

mypy division_mypy.py

を通すと、

division_mypy.py:26: error: Argument 1 to "simple_division" has incompatible type "str"; expected "int | float"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

という風に型をチェックした上でエラーを吐いてくれます。「simple_divisionに与えられた1つ目の変数は、不適格な型である'str'を持っています。intあるいはfloatが想定されています。」といった感じですね。

以上のように、ドキュメントは人間用、型アノテーションはシステム用といった形で、両方をあわせて書いていくのが基本となります。これらをきちんと書くことで、より使いやすいプログラムが出来上がるでしょう。

ただし、型アノテーションには少し注意すべきことがあって、それはPythonの歴史の中では比較的新しい機能であることも相まって、バージョンごとの変更が今でもちょこちょこ行われているということです。

バージョンごとの流れをざっと追いかけていくと、まずはPython3.5。ここで関数の型アノテーションが追加されました。今回書いているコードですね。

次にPython3.6。ここで引数だけでなく、変数への型アノテーションが追加されました。

argument_annotation1.py
number: int = 1

mypyでチェックをかけると、関数のアノテーションと同様に型の誤りを指定してくれます。

argument_annotation2.py
number: int = "abc"
type_annotation2.py:1: error: Incompatible types in assignment (expression has type "str", variable has type "int")  [assignment]
Found 1 error in 1 file (checked 1 source file)

3.7ではTypingモジュールの変更の他、遅延評価が実装されました。遅延評価とは、定義時ではなく、呼び出しがかかったタイミングで初めて評価される仕組みです。

ここでは例として、イテレータを繰り返し利用するためにカスタムしたデータコンテナを利用してみましょう。以下のようなファイルから1行1行点数を読み取り、その平均と最大を計算するためのプログラムです。点数読み取りの仮定で、負の点数が出力されないようにチェックもしています。

score1.txt
50
60
70
80
90
type_annotation2.py
from statistics import mean
from typing import Iterator, Tuple, Union


class ReadScore:
    def __init__(self, data_path: str):
        self.data_path = data_path
    
    def __iter__(self) -> Iterator[int]:
        with open(self.data_path) as f:
            for line in f:
                score = int(line)
                if score < 0:
                    raise ValueError(f"Score must be more than zero.")
                yield score


def check_score(score_book: ReadScore) -> Tuple[int, Union[int, float]]:
    sum_of_score = sum(score_book)
    average_of_score = mean(score_book)
    return sum_of_score, average_of_score


score = ReadScore("score1.txt")
sum_of_score, average_of_score = check_score(score)
assert sum_of_score == 350
assert average_of_score == 70

なお、ここでわざわざカスタムデータコンテナを用意している理由は以下に折り畳んでおきます。見覚えが無い方はご確認ください。

外部ファイルからデータを読み込む際に、カスタムデータコンテナを利用する理由

外部ファイルからopenでデータを読み込み、1行1行データを返す簡単な関数を用意します。すると、当然ながらその出力はイテレータになります。これらは、一見、普段扱っているリストなどと同じ使い方ができるように感じられます。実際、以下のように動かすことができます。

iterator1.py
def read_scores(data_path):
    with open(data_path) as f:
        for line in f:
            yield int(line)

            
scores = [50, 60, 70, 80, 90]
assert sum(scores) == 350

scores_from_file = read_scores("score.txt")
assert sum(scores_from_file) == 350

ただし、これは複数の操作を行っていくと話が変わってきます。というのも、イテレータは結果を1回しか出力してくれないからです。その後は空っぽなので、平均を利用するにはデータが足りないよというエラーを吐くわけです。

iterator2.py
from statistics import mean
            
scores = [50, 60, 70, 80, 90]
assert sum(scores) == 350
assert mean(scores) == 70

scores_from_file = read_scores("score.txt")
assert sum(scores_from_file) == 350
assert mean(scores_from_file) == 70
StatisticsError: mean requires at least one data point

これを防ぐためには、例えば初めの段階でリストにしておくという手段が考えられます。一度リストにしてしまえばメモリに格納されるため、問題なくイテレータを消費する操作を複数回行うことが可能です。

iterator3.py
scores_from_file = list(read_scores("score.txt"))
assert sum(scores_from_file) == 350
assert mean(scores_from_file) == 70

ですが、そもそもの話として、ある程度大量、かつ中身が変わっていくデータを読み込むために外部ファイルを利用しています。そのため、データ量によってはメモリを使い切りエラーを吐く可能性があります。

他には、利用の度にファイルを読み込んでイテレータを生成するというやり方も考えられます。しかしこのやり方ではメモリの消費こそ抑えられますが、可読性は下がってしまうでしょう。

iterator3.py
assert sum(read_scores("score.txt")) == 350
assert mean(read_scores("score.txt")) == 70

これらを解決するために行われるのが__iter__メソッドを持つカスタムデータコンテナの実装です。イテレータを消費する関数やメソッドなどを使う度に新しくイテレータを生成してくれますし、見た目もスッキリしています。

iterator4.py
class ReadScore:
    def __init__(self, data_path):
        self.data_path = data_path
    
    def __iter__(self):
        with open(self.data_path) as f:
            for line in f:
                yield int(line)

score = ReadScore("score.txt")
assert sum(score) == 350
assert mean(score) == 70

以上のように、外部ファイルからデータを読み込むとイテレータが生成されます。そして、イテレータは1回利用されると空になってしまいます。そのため、複数回使いたい場合はこのようにカスタムデータコンテナを利用するのが一般的です。

-------------------------折りたたみここまで-----------------------------

さて、ここまで特に問題はなさそうですが、実はこのコード、ReadScoreとcheck_scoreを逆にするだけでエラーを吐くようになります。

type_annotation3.py
from statistics import mean
from typing import Iterator, Tuple, Union


def check_score(score_book: ReadScore) -> Tuple[int, Union[int, float]]:
    sum_of_score = sum(score_book)
    average_of_score = mean(score_book)
    return sum_of_score, average_of_score


class ReadScore:
    def __init__(self, data_path: str):
        self.data_path = data_path
    
    def __iter__(self) -> Iterator[int]:
        with open(self.data_path) as f:
            for line in f:
                score = int(line)
                if score < 0:
                    raise ValueError(f"Score must be more than zero.")
                yield score


score = ReadScore("score1.txt")
sum_of_score, average_of_score = check_score(score)
assert sum_of_score == 350
assert average_of_score == 70
出力
NameError: name 'ReadScore' is not defined

プログラムは基本的に上から読み込まれるので、当然といえば当然なのですが、これはなかなかに不便な話です。常に順番に定義されているとは限りませんし、プログラムの構造を変えたタイミングで根こそぎエラーを吐かれると面倒です。

そこで利用されるのが遅延評価です。以下のようにimportを行うことで、定義時ではなく必要になったタイミング、ここでいうとインスタンス作成時に評価されるようになり、NameErrorを吐かなくなります。

type_annotation4.py
# 遅延評価のためのimport
from __future__ import annotations
from statistics import mean
from typing import Iterator, Tuple, Union


def check_score(score_book: ReadScore) -> Tuple[int, Union[int, float]]:
    sum_of_score = sum(score_book)
    average_of_score = mean(score_book)
    return sum_of_score, average_of_score


class ReadScore:
    def __init__(self, data_path: str):
        self.data_path = data_path
    
    def __iter__(self) -> Iterator[int]:
        with open(self.data_path) as f:
            for line in f:
                score = int(line)
                if score < 0:
                    raise ValueError(f"Score must be more than zero.")
                yield score


score = ReadScore("score1.txt")
sum_of_score, average_of_score = check_score(score)
assert sum_of_score == 350
assert average_of_score == 70

こうすることで、より柔軟にプログラム構造を変更することができるようになります。また、循環参照の回避にも利用できます。ノード型のデータの構造のように、自分自身を引数に持つようなケースですね。

type_annotation5.py
from __future__ import annotations
from typing import List


class Node:
    def __init__(self, value: int, parent_node: List[Node] = None, child_node: List[Node] = None):
        self.value = value
        self.parent_node = parent_node
        self.child_node = child_node

以上のような遅延評価が実装されたのがPython3.7ですね。

次の変更がPython3.9でのアップデートになります。いくつか変更がありますが、とりわけ書き方に関わるのは組み込みのコンテナ型がジェネリック型として使われるようになった点でしょうか。ここでは例として、注文されたメニューを集計する関数を考えてみます。

type_annotation6.py
def count_order(orders: list[str]) -> dict:
    menu_dict = {}
    for order in orders:
        if order in menu_dict.keys():
            menu_dict[order] += 1
        else:
            menu_dict[order] = 1
    return menu_dict


orders = ["サバ定食", "サバ定食", "味噌煮込みうどん", "きりたんぽ", "きりたんぽ", "サバ定食"]
print(count_order(orders))
出力
{'サバ定食': 3, '味噌煮込みうどん': 1, 'きりたんぽ': 2}

これまではtypingモジュールから個別にList,Tupleなどをimportする必要がありましたが、いつも利用している形(listやdictなど)をそのまま使えるようになりました。

最後にPython3.10での変更。色々な変更がありましたが、型アノテーションに限定するとバーティカルバー、つまり縦棒を使ってUnionを代用できるようになりました。たとえば先ほどの関数では、リストだけではなくタプルを用いることも可能です。これを示す場合には以下のように記述されます。

type_annotation7.py
def count_order(orders: list[str] | tuple[str]) -> dict:
    menu_dict = {}
    for order in orders:
        if order in menu_dict.keys():
            menu_dict[order] += 1
        else:
            menu_dict[order] = 1
    return menu_dict


orders = ["サバ定食", "サバ定食", "味噌煮込みうどん", "きりたんぽ", "きりたんぽ", "サバ定食"]
print(count_order(orders))
orders2 = ("サバ定食", "サバ定食", "味噌煮込みうどん", "きりたんぽ", "きりたんぽ", "サバ定食")
print(count_order(orders2))
出力
{'サバ定食': 3, '味噌煮込みうどん': 1, 'きりたんぽ': 2}
{'サバ定食': 3, '味噌煮込みうどん': 1, 'きりたんぽ': 2}

とまあ、主要なものだけを抜き出してまとめてもこれだけの変更が行われています。バージョンごとに細かい変更がなされていますので、バージョンをまたいで開発が行われる場合は、__future__を利用するなどの配慮が必要です。(それでも完全に互換性があるわけではありませんが)

型アノテーションはとても便利な機能ですが、複数人が関わる開発ではドキュメントを併用したり、あらかじめプロジェクト内でルールを定めておくのが無難だと言えるでしょう。

まとめ

ここで最後に、もう一度話の内容をまとめておきます。

  • 単純に仮引数を定義すると、"位置またはキーワードにより判別される引数"となる。名前の通り、位置でもキーワードでも与えることが出来る引数になっていて、柔軟性に富む一方で、誤った使われ方をする危険性もある
  • /の手前に置くと位置専用、*の後に置くとキーワード専用の引数となる。与え方を強制することで利用方法が明確になる一方で、柔軟性は失われる
  • 変数の前に直接*を付けると、その引数は可変長引数として扱われ、**を付けると可変長キーワード引数となる。任意の個数の引数を受け取ることが出来るが、その分思わぬエラーを引き起こす可能性がある
  • ドキュメントは人間が読むための説明書きで、型アノテーションは機械も読むことが出来る説明書き。これらを併用することで、より可読性と安全性に優れたプログラムを書くことができるが、特に型アノテーションはバージョンごとに細かな違いがある点に要注意

といった感じですね。

あらためて全てを定義部分で記述し、動かしてみると以下のようになります。

all_in_one1.py
def sample_func1(p_only1, p_only2, /, p_or_k1, p_or_k2, *args, k_only1, k_only2, **kwargs):
    print(f"位置専用引数1: {p_only1}")
    print(f"位置専用引数2: {p_only2}")
    print(f"位置またはキーワードにより判別される引数1: {p_or_k1}")
    print(f"位置またはキーワードにより判別される引数2: {p_or_k2}")
    print(f"可変長位置引数: {args}")
    print(f"キーワード専用引数1: {k_only1}")
    print(f"キーワード専用引数2: {k_only2}")
    print(f"可変長キーワード引数: {kwargs}")


sample_func1("a", "b", "c", "d", "e", "f", "g", k_only1=1, k_only2=2, kw1=3, kw2=4)
出力
位置専用引数1: a
位置専用引数2: b
位置またはキーワードにより判別される引数1: c
位置またはキーワードにより判別される引数1: d
可変長位置引数: ('e', 'f', 'g')
キーワード専用引数1: 1
キーワード専用引数2: 2
可変長キーワード引数: {'kw1': 3, 'kw2': 4}

ただ、3_1で説明した通り、キーワードによる指定を用いた後は、再び位置により判別される引数を利用することはできません。そのため、位置またはキーワードにより判別される引数でキーワード指定を利用すると、可変長位置引数は空欄になります。

all_in_one2.py
sample_func1("a", "b", "c", p_or_k2="d", k_only1=1, k_only2=2, kw1=3, kw2=4)
出力
位置専用引数1: a
位置専用引数2: b
位置またはキーワードにより判別される引数1: c
位置またはキーワードにより判別される引数2: d
可変長位置引数: ()
キーワード専用引数1: 1
キーワード専用引数2: 2
可変長キーワード引数: {'kw1': 3, 'kw2': 4}

実際に全てを一度に利用することがあるかどうかはさておき、細かいルールには気をつけて利用していきたいところです。

後書き

いつも当たり前のように利用していたので、それなりに理解していたつもりでしたが、実際に調べて動かしてみると大小様々な知らないことが出てきたので、あまり理解できていないことが理解できました。何かしらの指摘・補足があればコメント欄にお願い致します。

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