安全性の高いコードを書くために

スコープを意識する

意図しない不具合を埋め込んでしまわないように、変数のスコープの範囲を理解しておくことが望ましい。

クラスや関数内で定義された変数はそのクラスや関数の中でしか利用できない。

def func():
    val = "LOCAL"
    print(f"関数内:{val}")

func()
print(f"関数外:{val}")


# 関数内:LOCAL
# Traceback (most recent call last):
#   File "C:\Users\yuki\main.py", line 7, in <module>  
#     print(f"関数外:{val}")
#                  ^^^
# NameError: name 'val' is not defined. Did you mean: 'eval'?
class Dog():
    val = "LOCAL"
    print(f"クラス内:{val}")

Dog()
print(f"クラス外:{val}")


# クラス内:LOCAL
# Traceback (most recent call last):
#   File "C:\Users\yuki\main.py", line 7, in <module>
#     print(f"クラス外:{val}")
#                   ^^^
# NameError: name 'val' is not defined. Did you mean: 'eval'?

一方で、グローバル変数として関数外で定義された変数は関数の中からも利用できる。

val= "GLOBAL"

def func():
    print(val)

func()


# GlOBAL

グローバル変数と同じ名前のローカル変数を定義した場合は、関数内ではローカル変数が優先される。

val = "GLOBAL"

def func():
    val = "LOCAL"
    print(f"関数内:{val}")

func()
print(f"関数外:{val}")


# 関数内: LOCAL
# 関数外: GLOBAL

意図せずグローバル変数にアクセスして、不具合が発生するということもあるので注意が必要。関数内で使う変数はローカル変数に限定し、関数外の情報が必要な場合は引数として渡すようにすると良い。

なお、for文、if文、while文、with文、try文、match文などの中で定義された変数は文の外でも利用できる。

for val in [1, 2, 3, 4, 5]:
    print(f"for文内:{val}")

print(f"for文外:{val}")


# for文内:1
# for文内:2
# for文内:3
# for文内:4
# for文内:5
# for文外:5

意図せずfor文内で変数の値を変更してしまい、不具合が発生するということもあるので注意が必要。同じ名前の変数を異なる目的で利用しないようにし、処理が長くなる場合は関数として切り出すと良い。

また、リスト内包表記の中で定義された変数は、リスト内包表記の中でしか利用できないため、より安全なコードを書くためにリスト内包表記への書き換えを検討しても良い。

[val*2 for val in [1, 2, 3, 4, 5]]
print(f"リスト内包表記の外:{val}")


# Traceback (most recent call last):
#   File "C:\Users\yuki\main.py", line 2, in <module>
#     print(f"リスト内包表記の外:{val}")
#                        ^^^
# NameError: name 'val' is not defined. Did you mean: 'eval'?

ミュータブルな値の扱いに注意する

Pythonのオブジェクトは、オブジェクト作成後に状態を変更できる「ミュータブルなオブジェクト」とオブジェクト作成後に状態を変更できない「イミュータブルなオブジェクト」に分けられる。

  • ミュータブルなオブジェクト リスト、辞書、集合
  • イミュータブルなオブジェクト 数値、文字列、タプル、ブール値
  • 実際、リスト(ミュータブルなオブジェクト)ではオブジェクトのidを変更することなく、値を変更できる。

    mutable_val = [1, 2, 3]
    print(id(mutable_val))
    mutable_val.append(4)
    print(id(mutable_val))
    
    
    # 136050907684096
    # 136050907684096

    一方で、数値(イミュータブルなオブジェクト)では値の変更に伴って、オブジェクトのidが変わる。つまり、変更前と変更後では別のオブジェクトになっている。

    immutable_val = 1
    print(id(immutable_val))
    immutable_val = 2
    print(id(immutable_val))
    
    
    # 136051815710960
    # 136051815710992

    Pythonでのイコールによる代入では、値をコピーして渡しているのではなく、オブジェクトへの参照をコピーして渡している。

    そのため、下のコードのように安易にイコールによる代入を行うと、代入したリストと代入されたリストが同じオブジェクトを共有する形になり、意図せず元のリストを書き換えてしまうということがよくある。

    mutable_val = [1, 2 ,3 ,4 ,5]
    new_mutable_val = mutable_val
    new_mutable_val[2] = 999
    print(id(mutable_val))
    print(id(new_mutable_val))
    print(mutable_val)
    print(new_mutable_val)
    
    
    # 136050909019712
    # 136050909019712
    # [1, 2, 999, 4, 5]
    # [1, 2, 999, 4, 5]

    copyメソッドを使うと、新しいオブジェクトが生成されるため、この問題を回避できる。

    mutable_val = [1, 2 ,3 ,4 ,5]
    new_mutable_val = mutable_val.copy()
    new_mutable_val[2] = 999
    print(id(mutable_val))
    print(id(new_mutable_val))
    print(mutable_val)
    print(new_mutable_val)
    
    
    # 136051385386304
    # 136051400756928
    # [1, 2, 3, 4, 5]
    # [1, 2, 999, 4, 5]

    なお、多次元リストのように、ミュータブルなオブジェクトの要素がミュータブルなオブジェクトになっている場合にはdeepcopyメソッドを使う必要がある。

    import copy
    
    mutable_val = [[1, 1], [2, 2] ,[3, 3]]
    new_mutable_val = copy.deepcopy(mutable_val)
    new_mutable_val[0][1] = 999
    print(id(mutable_val[0]))
    print(id(new_mutable_val[0]))
    print(mutable_val)
    print(new_mutable_val)
    
    
    # 136050908189056
    # 136050908198720
    # [[1, 1], [2, 2], [3, 3]]
    # [[1, 999], [2, 2], [3, 3]]

    また、Pythonでは引数は代入によって渡されるため、引数によって関数にミュータブルなオブジェクトを渡すときも、上と同様に注意が必要になる。

    def func(val):
        val[2] = 999
        return val
    
    mutable_val = [1, 2 ,3 ,4 ,5]
    new_mutable_val = func(mutable_val)
    print(id(mutable_val))
    print(id(new_mutable_val))
    print(mutable_val)
    print(new_mutable_val)
    
    
    # 136050493483712
    # 136050493483712
    # [1, 2, 999, 4, 5]
    # [1, 2, 999, 4, 5]

    copyあるいはdeepcopyメソッドを使うと、新しいオブジェクトが生成されるため、この問題を回避できる。

    def func(val):
        val = val.copy()
        val[2] = 999
        return val
    
    mutable_val = [1, 2 ,3 ,4 ,5]
    new_mutable_val = func(mutable_val)
    print(id(mutable_val))
    print(id(new_mutable_val))
    print(mutable_val)
    print(new_mutable_val)
    
    
    # 136050487037120
    # 136050487236096
    # [1, 2, 3, 4, 5]
    # [1, 2, 999, 4, 5]
    import copy
    
    def func(val):
        val = copy.deepcopy(val)
        val[0][1] = 999
        return val
    
    mutable_val = [[1, 1], [2, 2] ,[3, 3]]
    new_mutable_val = func(mutable_val)
    print(id(mutable_val[0]))
    print(id(new_mutable_val[0]))
    print(mutable_val)
    print(new_mutable_val)
    
    
    # 136050487039232
    # 136050907828928
    # [[1, 1], [2, 2], [3, 3]]
    # [[1, 999], [2, 2], [3, 3]]

    さらに、ミュータブルなオブジェクトの引数にデフォルト値を設定する時にも注意が必要である。Pythonの仕様で引数のデフォルト値は関数が定義される時に生成され、それ以降は同じオブジェクトが使い回される。

    def func(x, l=[]):
        l.append(x)
        print(l)
    
    func(1)
    func(2)
    func(3)
    
    
    # [1]
    # [1, 2]
    # [1, 2, 3]

    ミュータブルなオブジェクトの引数にデフォルト値はNoneとしておき、関数内で空のリストをセットするようにすると意図した挙動が実現できる。

    def func(x, l=None):
        if l is None:
            l=[]
        l.append(x)
        print(l)
    
    func(1)
    func(2)
    func(3)
    
    
    # [1]
    # [2]
    # [3]

    副作用がないコードを書く

    関数を呼び出した時に外部状態を変更してしまう副作用があると、その関数の影響範囲がわからず、使いまわしたり変更したりといったことが困難になる。

    例えば、以下の関数は呼び出されるたびに変数xの値を書き換えてしまっている。これに気づかずにこの関数を使いまわしていると不具合の原因となるかもしれない。

    def func(x):
        x.append(4) # appendメソッドで値を書き換えている(副作用)
        return x
    
    x = [1, 2, 3]
    print(func(x))
    print(func(x))
    print(func(x))
    
    
    # [1, 2, 3, 4]
    # [1, 2, 3, 4, 4]
    # [1, 2, 3, 4, 4, 4]

    副作用が起こらないようにしておけば、意図せずに不具合を埋め込む可能性を減らすことができる。

    def func(x):
        x = x.copy()
        x.append(4)
        return x
    
    x = [1, 2, 3]
    print(func(x))
    print(func(x))
    print(func(x))
    
    
    # [1, 2, 3, 4]
    # [1, 2, 3, 4]
    # [1, 2, 3, 4]

    インプレースな処理の扱いに注意する

    Pythonでは対象のオブジェクト自体を変更する「インプレースな処理」と、対象のオブジェクト自体は変更せずに別途処理済みのオブジェクトを返す「インプレースでない処理」がある。

    例えば、sortメソッドはインプレースな処理であり、対象のオブジェクト自体を変更する。

    numbers = [2, 3, 1, 5, 4]
    numbers.sort()
    print(numbers)
    
    
    # [1, 2, 3, 4, 5]

    一方、sortedメソッドはインプレースでない処理であり、対象のオブジェクト自体を変更せずに別途処理済みのオブジェクトを返す。

    numbers = [2, 3, 1, 5, 4]
    sorted_numbers = sorted(numbers)
    print(numbers)
    print(sorted_numbers)
    
    
    # [2, 3, 1, 5, 4]
    # [1, 2, 3, 4, 5]

    安全性を重視するなら、インプレースでない処理を用いて、戻り値に明示的に異なる名前を付け、変数の状態が場所に依存して変化しないようにすることが望ましい。

    インプレースな処理が用いられていて変数の状態が場所によって異なる場合、これまでのコードの流れをすべて追わなければ変数に何が入っているのかわからない。

    numbers = [2, 3, 1, 5, 4]
    numbers.sort()
    print(numbers) # ←変数の状態が場所によって異なる
    numbers.sort(reverse=True)
    print(numbers) # ←変数の状態が場所によって異なる
    numbers.sort()
    print(numbers) # ←変数の状態が場所によって異なる
    numbers.sort(reverse=True)
    print(numbers) # ←変数の状態が場所によって異なる
    
    
    # [1, 2, 3, 4, 5]
    # [5, 4, 3, 2, 1]
    # [1, 2, 3, 4, 5]
    # [5, 4, 3, 2, 1]

    一方、インプレースでない処理を用いられていて変数の状態が明示的にわかるように名前をつけておけば、名前を見ただけでその変数に何が入っているのかを理解できる。

    numbers = [2, 3, 1, 5, 4]
    sorted_numbers = sorted(numbers)
    print(sorted_numbers) # ←名前を見ると変数の状態がわかる
    reversed_numbers = sorted(sorted_numbers, reverse=True)
    print(reversed_numbers) # ←名前を見ると変数の状態がわかる
    sorted_numbers = sorted(reversed_numbers)
    print(sorted_numbers) # ←名前を見ると変数の状態がわかる
    reversed_numbers = sorted(sorted_numbers, reverse=True)
    print(reversed_numbers) # ←名前を見ると変数の状態がわかる
    
    
    # [1, 2, 3, 4, 5]
    # [5, 4, 3, 2, 1]
    # [1, 2, 3, 4, 5]
    # [5, 4, 3, 2, 1]

    ただし、インプレースでない処理はインプレースな処理に比べてリソースの消費が大きく、処理速度が遅くなる傾向があることは考慮に入れておく必要がある。

    型ヒントを書く

    型ヒントを用いると変数の型を明示的に宣言できる。

    int_val: int = 1
    str_val: str = "Hello"
    float_val: float = 3.14
    bool_val: bool = True
    list_val: list[int] = [1, 2, 3]
    set_val: set[str] = {'a', 'b', 'c'}
    dict_val: dict[str, float] = {"alice": 5700, "bob":4800}
    tuple_val: tuple[str, int, bool] = ("a", 1, True)

    特に、関数の引数と戻り値の型の指定によく用いられる。->の右側には戻り値の型を記入する。

    def add_tax(price: int, tax_rate: float) -> int:
        return int(price * (1+tax_rate))

    戻り値がない場合はNoneを指定し、戻りが複数ある場合はタプルで指定する。

    def func() -> None:
        print("Hello")
    def func() -> tuple[int, str]:
        _id = 10001
        name = "alice"
        return _id, name

    また、以下のように「 | 」を使うと複数の型を連結できる。

    def func(name: int | str) -> None:
        print(f"{name}さん、こんにちは。")
    def func(name: str | None) -> None:
        if name is None:
            return
        print(f"{name}さん、こんにちは。")
        
    func(None)

    自作したクラスも型として指定することができる。

    class Animal:
        pass
    
    def func(animal: Animal) -> Animal:
        return Animal
    
    animal = Animal()
    func(animal)

    なお、引数のデフォルト値設定も通常通り可能である。

    def add_tax(price: int=100, tax_rate: float=0.1) -> int:
        return int(price * (1+tax_rate))

    ただ、残念なことにPythonでは明示的に型宣言をしても強制力がなく、違う型の値を入れてもエラーを出してくれない。

    str_val: str = 1
    print(type(str_val))
    
    
    # int

    強制力がないため型ヒントには意味がないように感じるかもしれないが、静的型チェッカーと組み合わせると静的に(コードを実行せずに)論理的な型の不整合がないかチェックすることができ、安全なコードを書くための強力なツールとなる。

    Mypyという静的型チェッカーの導入方法を以下に記載しているので参照されたい。

    例外処理

    例外は「戻り値とは異なる形でメソッドからエラーを返す仕組み」である。例外処理(try-except)を利用すると、処理の途中で例外(エラー)が発生したときに、その処理を中断して別の処理に切り替えることによって発生した例外(エラー)にうまく対処できるようになる。Pythonでの例外処理の基本的な記述方法については以下を参照されたい。

    例外処理をうまく利用すると、エラーの発生原因の明確にして、次のアクションを促すことができる。例えば、Pythonでは以下のようにゼロで割ると組み込み例外「ZeroDivisionError」が発生し、例外が発生した箇所で処理が中断してしまう。

    numbers = []
    average = sum(numbers) / len(numbers) # ←ここで例外が発生!
    print('この部分は処理されない')
    
    
    # Traceback (most recent call last):
    #   File "C:\Users\yuki\main.py", line 2, in <module>
    #     average = sum(numbers) / len(numbers) # ←ここで例外が発生!
    #               ~~~~~~~~~~~~~^~~~~~~~~~~~~~
    # ZeroDivisionError: division by zero

    エラー文を読むとゼロ除算が発生したことはわかるが、なぜこのようなバグが混入したのかまではわからない。

    一方で、よくエラーが発生する場所に以下のように例外処理を記述しておくと、どこでバグが混入したのか、次に何をすればよいのかがより簡単にわかるようになる。

    try:
        numbers = []
        average = sum(numbers) / len(numbers)
        print('この部分は処理されない')
    except ZeroDivisionError as e:
        print(
            "ゼロ除算が発生しました。",
            "numbersが空リストになっていないか確認してください。",
            f"Error: {e}",
        )
    
    
    # ゼロ除算が発生しました。
    # numbersが空リストになっていないか確認してください。
    # Error: division by zero

    また、コードの安全性を保つために、想定していた値と異なる値を受け取ったときに、自ら例外を発生させることもある。

    def validation(email):
        if '@' not in email:
            raise Exception('無効なEメールアドレスです。')
    
    try:
        validation('email-address')
    except Exception as e:
        print(f"Error: {e}")
    
    
    # Error: 無効なEメールアドレスです。

    例外は呼び出し元にエスカレーションしていくため、以下のように任意の場所に例外処理を記述できる。

    def func1():
        try:
            func2()
        except Exception as e:
            print("func1関数内で例外をキャッチ!")
            print(f"Error: {e}",)
    
    def func2():
        func3()
    
    def func3():
        func4()
    
    def func4():
        raise Exception("例外が発生しました。")
    
    func1()
    
    
    # func1関数内で例外をキャッチ!
    # Error: 例外が発生しました。
    def func1():
        func2()
    
    def func2():
        try:
            func3()
        except Exception as e:
            print("func2関数内で例外をキャッチ!")
            print(f"Error: {e}",)
    
    def func3():
        func4()
    
    def func4():
        raise Exception("例外が発生しました。")
    
    func1()
    
    
    # func2関数内で例外をキャッチ!
    # Error: 例外が発生しました。
    def func1():
        func2()
    
    def func2():
        func3()
    
    def func3():
        try:
            func4()
        except Exception as e:
            print("func3関数内で例外をキャッチ!")
            print(f"Error: {e}",)
    
    def func4():
        raise Exception("例外が発生しました。")
    
    func1()
    
    
    # func3関数内で例外をキャッチ!
    # Error: 例外が発生しました。

    そのため、例外処理は適切な対処ができる場所に記述すると良い。

    一般に、ファイルの読み書き、データベースの読み書き、APIなどを利用したデータのやり取りのような外部との境界ではエラーが発生しやすいため、例外処理を記述しておくとコードの安全性を高められる。

    例外処理を記述した箇所で同時にログを出力しておくようにすると、後から見た時に不具合に対処しやすくなる。

    assert文を使う

    assert文を使うと条件を満たしていない場合に例外を出力することができ、主にテストコードで用いられる。

    assert 1 == 2, "1 == 2ではありません!"
    
    
    # Traceback (most recent call last):
    #   File "C:\Users\yuki\main.py", line 1, in <module>
    #     assert 1 == 2, "1 == 2ではありません!"
    #            ^^^^^^
    # AssertionError: 1 == 2ではありません!

    assert文の用途はテストコードに限られず、関数の先頭に記載して値を検証することもできる。

    def func(email: str):
        assert isinstance(email, str), "emailはstr型である必要があります。"
        assert '@' in email, "emailは@を含む必要があります。"
        # 何らかの処理
    
    func('abcd')
    
    
    # Traceback (most recent call last):
    #   File "C:\Users\yuki\main.py", line 6, in <module>
    #     func('abcd')
    #   File "C:\Users\yuki\main.py", line 3, in func
    #     assert '@' in email, "emailは@を含む必要があります。"
    #            ^^^^^^^^^^^^
    # AssertionError: emailは@を含む必要があります。

    ログを出力する

    ログは、起動しているプログラムを監視し、どこまで処理が終わっているのか、どこでエラーが発生したのか、異常なデータが含まれていないか、不正な操作が行われていないかなどを確認し、いち早く不具合を修正したり、分析したりするために出力される。Pythonでログを出力する際の基本的な記述方法については以下を参照されたい。

    一般に、処理の開始地点や終了地点、重要な処理の周辺、例外が発生する地点、外部との境界部分でログを出力するようにしておくとコードの安全性を高められる。

    例えば、次に示すコードのように処理の開始地点と終了地点でログを出力するようにしておけば、ログを見ればどこまで処理が完了しているのかが明確になる。

    from logging import getLogger, StreamHandler, INFO, Formatter
    import time
    
    # ロガーを設定する
    logger = getLogger(__name__)
    logger.setLevel(INFO)
    formatter = Formatter("%(asctime)s - %(levelname)s : %(message)s")
    stream_handler = StreamHandler()
    stream_handler.setLevel(INFO)
    stream_handler.setFormatter(formatter)
    logger.addHandler(stream_handler)
    
    def main():
        logger.info("main()を開始")
        # ↓↓↓ ここに処理を記載 ↓↓↓
        func()
        # ↑↑↑ ここに処理を記載 ↑↑↑
        logger.info("main()を終了")
    
    def func():
        logger.info("func()を開始")
        # ↓↓↓ ここに処理を記載 ↓↓↓
        time.sleep(5)
        # ↑↑↑ ここに処理を記載 ↑↑↑
        logger.info("func()を終了")
    
    if __name__ == "__main__":
        main()
    
    
    # 2024-10-25 13:39:37,785 - INFO : main()を開始
    # 2024-10-25 13:39:37,787 - INFO : func()を開始
    # 2024-10-25 13:39:42,789 - INFO : func()を終了
    # 2024-10-25 13:39:42,789 - INFO : main()を終了

    また、例外が発生する地点で一緒にログを出力する場合、exceptionメソッドを使うと、発生したエラーメッセージやトレースバックをログと同時に出力することができる。

    from logging import getLogger, StreamHandler, INFO, Formatter
    import time
    
    # ロガーを設定する
    logger = getLogger(__name__)
    logger.setLevel(INFO)
    formatter = Formatter("%(asctime)s - %(levelname)s : %(message)s")
    stream_handler = StreamHandler()
    stream_handler.setLevel(INFO)
    stream_handler.setFormatter(formatter)
    logger.addHandler(stream_handler)
    
    def main():
        try:
            1 / 0
        except ZeroDivisionError:
            logger.exception("ZeroDivisionErrorが発生しました。")
    
    if __name__ == "__main__":
        main()
    
    
    # 2024-10-27 19:02:04,837 - ERROR : ZeroDivisionErrorが発生しました。
    # Traceback (most recent call last):
    #   File "C:\Users\yuki\main.py", line 17, in main
    #     1 / 0
    #     ~~^~~
    # ZeroDivisionError: division by zero

    テストコードを書く

    コードをメンテナンス、管理していく上で、テストコードは威力を発揮する。テストコードを一度書いておくと、機能の追加、修正をした際に他の機能に影響がないかをテストコードを実行するだけで簡単に確認できる。また、シンプルに書かれたテストコードは生きたドキュメント(仕様書)として、読み手の理解を助けてくれる。

    Pythonでテストコードの基本的な記述方法については以下を参照されたい。

    テストコードを書くことに慣れてきたら、テスト駆動開発を始めよう。テストを先に書くことで、テストしやすい設計(責務が明確、結合度が低い、凝集度が高い、決定的な動作など)を意識するようになり、設計の改善にもつながる。

    テスト駆動開発の詳細については以下を参照されたい。


    参考資料





    著者画像

    ゆうき

    2018/04からITエンジニアとして活動、2021/11から独立。主な使用言語はPython, TypeScript, SAS, etc.