Open13

Pythonで良いコードを書くために大事な知識

shimakaze_softshimakaze_soft

単一責任の原則

SOLID原則の一つで、クラスには単一責任の原則というルールがある。

そのクラスがやるべきことは一つまでというルール。

いろんなことをするクラスは決して作ってはいけない。

単一責任の原則を守れば、そのクラスがやるべきことが明確になる。
クラスがやるべきことが明確になると、バグ修正や仕様追加の時、どこを変更すれば良いかがすぐ分かるようになる。

1000行あるクラスで一つの関数を変更した時、知らない間に他の場所に影響してバグが発生する可能性があります。(私はこれで酷い目に遭いました)

ですが、150行程度の行数であれば、ある程度制御可能でどこを変更するとどう影響するのかわかりやすくなります。

そのためにも以下を意識する。

一つのクラスは150行以内に収める

一つのクラス(ソースファイル)は150行以内に収める。

もし行数が多いコードを書けば、様々な関数や変数が相互作用して、読むのが大変になる。
スクロールを上にしたり、下にしたりとかなり大変になる。

これにより、いろんな事やなんでも行う神クラス的なものが発生しやすくなり、単一責任の原則が守られづらくなる。

単一責任の原則を守るためには、「そのクラスがやること」を適切に分割することが重要。
しかし、「適切」という単語は曖昧で、どこまで分割すれば適切なのかわかりづらい。

シンプルな解決策には、150行以内という制約をつける。150以内でなくとも、何かしら少ない行数を心がける。
150行以内であれば、たくさんの責務を持つクラスは書きにくくなる。

これにより、必ずしもある単一責任の原則を守れるわけではなくても、処理が分割されたコードを書けるようになる。


150行以内にする目的は、あくまでも責任を分割することであること。
読みやすさを優先して150行を超える場合は仕方ない。

可能であれば長くても200行ぐらいまで。
それ以上になりそうであれば、別のクラスに分けるなどした方が良い。

shimakaze_softshimakaze_soft

循環参照はしない

循環参照とは、それぞれお互いのクラスが参照しあっているという状態のこと

以下の問題が発生する

  • 処理が追いづらくなる
  • 再利用性が失われ、拡張性が失われる
  • 特別な仕様に対応しづらくなる

循環参照は楽に仕様を実現できるため、初心者はやりがちな実装方法。
しかし、循環参照を許せばその箇所が非常に追いづらくなる。

なぜなら、処理があっち行ったりこっち行ったりで、どこで何がどう処理されているのかわからなくなってしまい、全体を把握することが困難になる。

まるで絡まった紐を追うかのような面倒臭さを発生させてしまう。

また、循環参照をすると特別な仕様に対してif文で分岐させるなど、非常に泥臭いコードを書かざるを得なくなってしまう。

循環参照をしないことは、全体の処理の追いやすさにおいて重要な要素。

shimakaze_softshimakaze_soft

一つの例を見てみる。

よくある循環参照の例として、プレイヤーとプレイヤーマネージャーの関係性が挙げられる。

class Player
    def __init__(self):
        self.player_manager = PlayerManager()

    def Init():
        # 初期化処理

    def death():
        # プレイヤーマネージャーに依存
        self.player_manager.on_player_death()

class PlayerManager
    def __init__(self):
        self.player = Player()

    def spawn_player(self):
        # プレイヤーに依存
        self.player.Init()

    def on_player_death(self):
        # プレイヤーが死んだ時の処理

上記の例だと、PlayerManagerはプレイヤーの初期化処理をするためにPlayerに依存している。

PlayerはPlayerManagerに死んだことを伝えるためにPlayerManagerに依存している。
これが循環参照である状態。

shimakaze_softshimakaze_soft

循環参照をしてはいけない理由

クラス同士の依存性が極端に増して、クラスを分ける意味がなくなるから。

shimakaze_softshimakaze_soft

継承はしない

継承は最初このように教えられるかと思います。
「継承を使うことで共通部分をまとめ、効率的にコーディングすることができる」と

これは嘘ではありません。
短期的に見れば効率的にコーディングできるでしょう。
しかし、長期的に見ると多大な技術的負債を生み出していしまいます

shimakaze_softshimakaze_soft

pytestの便利なツール

テストコードを作成しても毎回pytestを実行するのはめんどくさい。

pytest-watchという便利なツールがある。pytestをwatchモードで実行してくれるため、コードの変更を検知して自動で再テストをしてくれる。

$ pip install pytest-watch

watchモードでの実行

$ pytest-watch # or ptw

ソースコードを修正するたびに自動で再テストが実行される。

オプションをつけてwatchモードで実行したい場合は、以下のように実行する。

$ pytest-watch --runner "python -m pytest test_sample.py"
shimakaze_softshimakaze_soft

Good Code, Bad Codeを参考にする

Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考」という書籍があり、そちらの書籍の内容をPythonで書くとどうなるかを検証していく。

「高品質なコードを書く」ために、以下の4つのゴールを設定している。

  1. 正しく動くこと
  2. 正しく動作し続けること
  3. 要件の変更に対応しやすいこと
  4. 車輪の再発明をしないこと

上記の4つのゴールを達成するための戦略として、以下の「コード品質の6つの柱」というのがあります。

  1. コードを読みやすくする
  2. 想定外の事態をなくす
  3. 誤用しにくいコードを書く
  4. コードをモジュール化する
  5. コードを再利用、汎用化しやすくする
  6. テストしやすいコードを書き、適切にテストする

エラー処理

まずエラーには大きく分けて、回復可能と不可能の2つに分類することができる。

  • 回復可能なエラー
  • 回復不可能なエラー

処理の失敗の大半は回復可能であり、エラー理由さえわかれば、それに応じて対応できる。
回復を試みるのか、不可能なのかは、その関数を呼び出す側のみが知っている。つまり、例外が起こっている関数の中でそれを判断することはできない。

どのように例外が処理されるかは、呼び出し元の個々の状況に応じて判断したほうが良い。
そのため、呼び出される側の関数のコードを書いているときは、その関数を使う呼び出し元がエラーからの回復を試みたりする処理を書くほうが安全である。呼び出される側の関数は例外を例外とわかる形で渡すのが良い。

回復可能なエラー

  • ユーザーからの正しくない入力(不正なEメールアドレスや電話番号の入力など)
  • ネットワークエラー
  • 深刻ではないタスクエラー(統計ログのエラーなど)

外部のサービスが原因である場合、回復すべきエラーとなる場合が多い。
回復を試みる場所は下位層よりも、呼び出し元となる上位層が適切となる。
よって、より上位の層で回復を試みてもらうために、下位層はコードを利用する側にエラーが起こる可能性があることを伝えなければならない。

回復不可能なエラー

  • コードと一緒にあるべきリソースがない
  • コードの誤用(無効な入力、必須の初期化がなされていない、など)

回復が不能であるため、開発陣に修正してもらえるようにするための通知が必要。
回復が不可能であるため、プログラムをクラッシュさせるしかない。(ログだけ残して握りつぶしたりすることは基本的に不可能)


意図せぬことが起きたとき、エラーを通知するべき or 握りつぶすべきのどちらか

エラーが起きたとき、次の二つの選択肢からどちらかを選ぶ必要がある。

  • エラーを解りやすい形で上位層に通知して、エラーハンドリング処理を上位層に任せる、もしくはプログラム全体をクラッシュさせる
  • エラーを処理を握りつぶして動作を続ける

多数のリクエストのうち、1つがネットワークエラーとなったときに、すべてのプログラムをクラッシュさせてしまうのはプログラムの堅牢性を著しく低下させる。

しかし、エラーがどこで発生したのか、なぜエラーが起きたのかを確実に通知することは、バグの発見を早期に抑え込み、その悪影響を最小限にとどめるために必要である。

そのため、以下の二つの対立が生じている。

  • 堅牢性
  • エラーを確実に通知する

この対立に対する1つの答えは、上位の層にエラーを通知する代わりに、エラーログを残し、エラーの発生をモニタリングすること。

エラー頻度が高い場合には警告を出すこともできる。しかし、この解決策は多用すべきではない。
採用してよい例としては、コードの上位のエントリーポイントや、あまり重要でない条件分岐などである。上位の層にエラーを伝えないことは、たとえログを残していたとしてもエラーを見つけにくくすることになり、別の場所で予期せぬ問題を引き起こすことになる。

結論としては、以下の二つを第一に考えるべきである。

  • エラーは上位層に伝える
  • クラッシュさせる

何か想定外のことが起こっていることを適切に(エラーが起きた場所で、エラーが起きたときに)他の開発陣に伝えることが他のバグの発生や意図せぬ挙動を防いでコード全体の信頼性を高める。

shimakaze_softshimakaze_soft

悪いエラー処理の例(Bad Code)と良いエラー処理の例(Good Code)を以下に示す。

悪いエラー処理の例

以下のようなコードはエラーを隠すことにつながるため、悪い例になる。

何も返さない

# 以下のように何もしないのは基本的にNGになる
def get_value_root(value: int) -> None:
    if value < -1:
        pass

上記のような何も関数から返さない場合は、正常にプログラムが完了しているものと呼び出し元から勘違いさせてしまう。これは想定外の事態を招いてバグを生んでしまうため、基本的にやってはいけない。

デフォルト値を返す

## デフォルトの値を返すのは基本的にNG
def get_value_root(value) -> int:
    if value < -1:
        return 0

上記のようなデフォルト値を返すのもやってしまいがちである。デフォルト値を返してしまうのも、何も返さないのと同様にエラーかどうかわかりにくく、別のところでおかしな形でエラーが現れる可能性が出てくるため、使うべきではない。

基本的にはやってはいけないが、場合によっては選択肢となりうる

## Noneを返すのも基本的にNGだが、場合によっては許容
def get_value_root(value: int): -> None
    if value < -1:
        return None

デフォルト値を返すのと同様に、エラーの結果であるのかどうかがわかりにくい、またどのようなエラーを意味しているのかもわからない例である。それに加えて、この関数の呼び出し元のでNoneかどうかチェックする処理を入れなければならない。

しかし、呼び出し元に値が定義されていないことを伝えたい場合には、Noneを返すことが適切である場合もある。

## ログを残のが適切な場合もある
def get_value_root(value: int) -> None:
    if value < -1:
        print('value should be greater than zero. value: {value}'.format(value))

上記のコードは、開発者がエラーが起きていることに全く気づかない可能性がある。しかし、採用して良い例としては、一番最初の呼び出し元であるエントリーポイントや、あまり重要でない条件分岐などであるとのこと。

良いエラー処理の例

上位層に例外を通知する

上位層である呼び出し元には、エラーが発生した際に回復を行うかどうかを判断するために、発生した例外に合わせて必要な処理を行わせるためにも、エラーは上位の層に通知するべきである。

どのようなエラーが起きるかコードを利用する開発メンバーに適切に知らせ、見逃しを防ぐためスローする例外をdocstringなどで残すことが推奨される。

class NegativeNumberException(Exception):
    def __init__(self, erroneous_number: int):
        self.erroneous_number: int = erroneous_number

def get_value_root(value: int):
    """
    Raises:
        NegativeNumberException: 入力値が負の場合に発生
    """
    if value < -1:
        raise NegativeNumberException(value)

上位層では例外をキャッチして別の例外としてさらに上位層に伝えることも可能であるし、必要なログを残してユーザにリトライを促すなどの必要な通知を出すこともできる。

# 呼び出し元
def display_square_root(value: int):
    try:
        ui.set_output(get_value_root(value))
    except NegativeNumberError as e:
        ui.set_error("NegativeNumberError: " + e.erroneous_number)

なお、注意すべき点として、上位の層で適切なエラー処理が行われない可能性もある。
全ての例外をキャッチするのは非常に困難であることから、except Exceptionで全ての例外をキャッチするようになりがちである。しかし、これでは深刻なエラーも見逃しかねない。

## やりがち悪い呼び出し元の例
def display_square_root(value: int):
    try:
        ui.set_output(get_square_root(value))
    except Exception as e: # except Exceptionにすると、全てのエラーを握りつぶしてしまい、適切に処理が行われなくなる可能性がある
        ui.set_error("次の値の平方根を計算できません" + e.erroneous_number)

例外の代わりにResult型の戻り値を返す

例外を返す代わりの手段として、Result型というのを返すという方法もある。Haskellなどの関数型言語には、実装されていることが多い型です。
これは、簡単に言えば処理に失敗する可能性があることを示す型です。
これにより、エラーが起こる可能性があることを呼び出し元に強制的に意識させ、エラー処理を促すことができる。
エラーが起こる可能性があることが明確になり、エラー処理を忘れる可能性はほとんどないという利点があります。

その代わり冗長なコードが多くなるというデメリットもあります。

shimakaze_softshimakaze_soft

PythonでのResult型

pythonでresult型を実現するには、サードパーティーライブラリが必要になる。

resultreturnsというのが見つかったが、returnsの方が更新頻度が頻繁であるため、returnsを使っていくことにする。

https://github.com/dry-python/returns

$ pip install returns

mypyを入れている場合は、mypy.iniに以下の設定が必要になる。

# In setup.cfg or mypy.ini:
[mypy]
plugins =
  returns.contrib.mypy.returns_plugin