はじめに
Good Code, Bad Codeを読みました(まだ途中)。著者であるTom LongはGoogleのテックリードで、良いコードを書くために、目の前のシナリオに対する判断力と考慮すべきトレードオフ(メリットデメリット)への理解を身につけるために必要な知識が提供されている。
具体的な本の内容としては、業務でコードを書く上での4つのゴールとして以下の4つを挙げ、
- 正しく動くこと
- 正しく動作し続けること
- 要件の変更に対応しやすいこと
- 車輪の再発明をしないこと
さらにコード品質の柱として以下の6つを掲げ、日々のコーディングにそれら品質の柱を適用するための具体的なGood Code, Bad Codeを示している。
- コードを読みやすくする
- 想定外の事態をなくす
- 誤用しにくいコードを書く
- コードをモジュール化する
- コードを再利用、汎用化しやすくする
- テストしやすいコードを書き、適切にテストする
業務経験が3年以内のエンジニアを主な対象読者として想定しているものの、私にとって馴染みのない(擬似)Javaでサンプルコードが書かれていることもあり、私(エンジニア歴4ヶ月)にはかなり難しい記述も多い。紹介されている考え方をきちんと身につけ、利用できるようになればコードの品質がかなり改善しそうなので頑張って最後まで読んで、自分の中で消化したい。
特に最近、あまり自分のエラー処理判断に自信が持てずにいたため、本の4章で語られているエラー処理に関する記載が非常に参考になった。忘れないように学んだことのまとめをメモする。
エラー処理の目的
エラー処理の目的は、そこがエラーに対処するのに適切な場所であるならばその場で処理すること、適切な場所ではないのであれば、上位レイヤー、もしくは他のエンジニアにエラーが発生したときに早く、確実に対処してもらえるように通知を行うことである。
まず、書籍で述べられているエラー処理のGood Code Bad Codeについて説明する。
エラーの種類
まずエラーは大きく分けて、回復可能なエラーと不可能なエラーに分類することができる。失敗の大半は回復可能であって、エラーの理由がわかればそれぞれに応じて対応できることが多い。
回復を試みたいか、回復不可能なのかは、その関数を利用する側(呼び出す側)のみが知っており、例外が起こる関数の中でそれを判断することはできない。例えば、ユーザの入力のエラーであれば再度入力を促すだろうし、デフォルトの値で返答をしたいかもしれないし、または完全にエラーからの復旧を諦めたいかもしれない。どのように例外が処理されるかは呼び出し元の個々の状況に応じて判断される。そのため、呼び出される側のコードを書いているときは、そのコードを使う人がエラーからの回復を試みると判断してコードを書くほうが安全であり、例外を例外とわかる形で渡すことが好ましい。
回復可能(かつ、回復することが望ましい)
- ユーザーの正しくない入力(不正な電話番号の入力など)
- ネットワークエラー
- 深刻ではないタスクエラー(統計ログのエラーなど)
外部が原因である場合、回復すべきエラーとなる場合が多い。回復を試みる場所は下位レイヤーよりも上位レイヤーが適切となる。よって、より上位のレイヤーで回復を試みてもらうために、下位レイヤーはコードを利用する側にエラーが起こる可能性があることを伝えなければならない。
回復不能
- コードと一緒にあるべきリソースがない
- コードの誤用(無効な入力、必須の初期化がなされていない、など)
回復不能であるため、エンジニアに気づいて修正してもらえる可能性を上げるための通知が必要。回復不能であるため、プログラムをクラッシュさせるしかない。(ログだけ残して握りつぶしたりすることは基本的に不可能)
意図せぬことが起きたとき、エラーを通知するべきか、握りつぶすべきか?
エラーが起きたとき、次の二つの選択肢からどちらかを選ぶ必要がある。
- エラーを解りやすい形で上位レイヤーに通知して、エラー処理を上位レイヤーに任せる、もしくはプログラム全体をクラッシュさせる
- エラーを処理して(握りつぶして)動作を続ける
多数のリクエストのうち、1つがネットワークエラーとなったときに、すべてのプログラムをクラッシュさせてしまうのはプログラムの堅牢性を著しく低下させる。しかし、エラーがどこで発生したのか、なぜエラーが起きたのかを確実に通知することは、バグの発見を早期に抑え込み、その悪影響を最小限にとどめるために必要である。そのため、ここでは堅牢性とエラーを確実に通知すること、二つの対立が生じている。
この対立に対する1つの答えは、上位にエラーを通知する代わりに、エラーのログを残し、エラーの発生をモニタリングすることである。エラー頻度が高い場合には警告を出すこともできる。
しかし、この解決策は多用すべきではない。採用してよい例としては、コードの上位のエントリーポイントや、あまり重要でない条件分岐などである。上位にエラーを伝えないことは、たとえログを残していたとしてもエラーを見つけにくくすることになり、別の場所で予期せぬ問題を引き起こすことになる。
結論として、エラーは上位レイヤーに伝える、もしくはクラッシュさせることを第一に考えるべきであり、何か想定外のことが起こっていることを適切に(エラーが起きた場所で、エラーが起きたときに)他のエンジニアに伝えることが他のバグの発生や意図せぬ挙動を防ぎ、コード全体の信頼性を高める。
Badパターン
以下はエラーを隠すことにつながるため、Badパターンである。しかし場合によっては適切な処理になるため、上から順にやるべきではない処理について述べ、最後の2つは場合によっては選択肢となりうるものを記載している。
何もしない 基本的にNG
def get_square_root(value):
if value < -1:
pass
何も帰ってこない場合には、正常にプログラムが完了しているものと他の人を勘違いさせてしまう。これは想定外の事態を招き、バグを生むため、基本的にやってはならない。
デフォルトの値を返す 基本的にNG
def get_square_root(value):
if value < -1:
return 0
エラーかどうかわかりにくく、別のところでおかしな形でエラーが現れることになる。使うべきではない。
nullを返す 基本的にNG、場合によっては許容
def get_square_root(value):
if value < -1:
return None
デフォルトの値と同様に、エラーの結果であるのかどうかがわかりにくい、またどのようなエラーを意味しているのかもわからない。加えて呼び出し元の全てでnullかどうかチェックしなくてはならない。
値が定義されていないことを伝えたい場合には、nullを返すことが適切である場合もある。
ちなみにDeNAでは開発時になるべくnullを使わないようにしているチームもあるとのこと。
私たちのチームでは、どちらかと言うとnullをできるだけ使わないという方針に寄せています。デフォルトでは変数にnullを入れられないとしていて、nullを入れたい場合は、明示的にアノテーションを付けることを必須としています。
このような方針にした理由はいくつかあります。まず、定義される変数の多くはnullを受け入れる必要がありませんでした。ほとんどの場合、きちんと値が入るような変数でした。
変数を使うたびに「この変数ってnullが入るのだっけ?」みたいなことを考えるよりも、nullを許容しないとしたほうが、コーディング中に考えることが少なくてわかりやすいのではないかと考えました。またこの方針にすると、SpotBugsやIntelliJといったツールを使った静的解析で、このnullの周りのエラーを検出しやすいというメリットも同時にありました。
https://logmi.jp/tech/articles/328396
ログを残す 適切な場合もある
def get_square_root(value):
if value < -1:
print('value should be greater than zero. value: {value}'.format(value))
上二つと同様に、エンジニアがエラーが起きていることに全く気づかない可能性がある。採用してよい例としては、コードの上位のエントリーポイントや、あまり重要でない条件分岐などである。
Goodパターン
上位レイヤーに例外を通知する
上位レイヤー(呼び出し元)はエラーに対して回復したいかどうかを知っているため、発生した例外に合わせて必要な処理を行うことができる。どのようなエラーが起きるかコードを利用するエンジニアに適切に知らせ、見逃しを防ぐためスローする例外をドキュメントに残すことが推奨される。
class NegativeNumberException(Exception):
def __init__(self, erroneous_number):
self.erroneous_number = erroneous_number
def get_square_root(value):
"""
Raises:
NegativeNumberException: 入力値が負の場合に発生
"""
if value < -1:
raise NegativeNumberException(value)
上のレイヤーでは例外をキャッチして別の例外としてさらに上のレイヤーに伝えることも可能であるし、必要なログを残してユーザに(再トライなどの)必要な通知を出すこともできる。
def display_square_root(value):
try:
ui.set_output(get_square_root(value))
except NegativeNumberError as e:
ui.set_error("次の値の平方根を計算できません" + e.erroneous_number)
キャッチしなくてもコンパイルできる。その場合にはプログラムが終了する。キャッチしないことができるので、それによって中間のレイヤーはエラー処理を記述せずにすみ、そのままより上位のレイヤーで処理できることを利点として捉えることもできるが、エラーの見落としが起こりやすくなるというデメリットとしても捉えられる。
なお、注意すべき点として、上位レイヤーで適切なエラー処理が行われない可能性もある。全ての例外をモグラ叩きのようにキャッチするのは非常に困難であるため、catch(Error as e)で全ての例外をキャッチするようになりがちだが、これでは深刻なプログラミングエラーを見逃しかねない。
def display_square_root(value):
try:
ui.set_output(get_square_root(value))
except Error as e: #全てのエラーを握りつぶしてしまい、適切に処理が行われなくなる可能性がある
ui.set_error("次の値の平方根を計算できません" + e.erroneous_number)
Result型の戻り値を返す
-
エラーが起こる可能性を、呼び出し元に強制的に意識させ、処理を促すことができる。
-
例外での通知と同様に、エラーを処理するか、無視するかは呼び出し元に委ねられる。
-
エラーが起こることが明確になっているため、エラー処理を忘れる可能性はほとんどない。
-
エラーを見落とす可能性が非常に低くなった代わりに、必要な処理が多くなり、コードが冗長に感じられることもある。
まとめ
- エラー処理はエラーケースを熟考し、エラーが無視されずに適切に処理されるようにしなければならない
- 回復したいエラーと、適切な回復方法がないものを区別することは堅牢で信頼できるコードを書くことに役立つ
- エラーの通知方法には異なる意見が存在するものの、適切に処理されることを担保で切る方法を選ぶべきである。
- Result型については今後使えるようにRustでの実装なども参考にみてみたい
Pythonでのresult型について、returnsライブラリを使用した記事も書いたので、宜しければ!
参考