定義する例外を減らしつつソフトウェアを設計する方法を"A Philosophy of Software Design"から学ぶ

会社の読書会で"A Philosophy of Software Design"を読んでいる。

自分の担当で第10章"Define Errors Out Of Existence"をしっかり読む機会があり、けっこう興味深い内容なので読書メモをブログにも書いておく。

章タイトルについて

章タイトル"Define Errors Out Of Existence"は直訳すると「エラーが存在しないものとして定義する」。例外やエラーはプログラム中で定義するものなので”define”の語を使っている様子。

こなれた感じにするには「エラーがもとから存在しないようにする」ぐらいが妥当そう。本文中では”defining away”(”define”を使いつつ消し去るニュアンスを出してる?)みたいな用例も出てくる。

この章の主張

著者のJohn先生による主張の構造は次のとおり。

  • エラーハンドリングのコードは特殊なケースについてのロジックを表している
  • 特殊なケースについてのコードはソフトウェアをより複雑にする
    • 6章の"General-Purpose Modules are Deeper"などでこの主張を支えている
  • よって、次の方法でエラーハンドリングする箇所をできるだけ減らすべきである
    • 例外的な状況をなくして通常のふるまいの一部になるようにセマンティクスを変える
    • 下位層のエラーとして隠す、もしくは上位層でまとめてエラーハンドリングする
    • そのままクラッシュさせる

各節のまとめ

10.1 Why exceptions add complexity

この本での例外 (exception) の定義は次のとおり。

I use the term exception to refer to any uncommon condition that alters the normal flow of control in a program.

プログラムにおける正常系フローを異常系フローに変えてしまう状況を例外と呼んでいる。

例外があると、無視すると別の例外が発生する可能性があるし、例外を受けて処理の中断をするとシステムの整合性を取りつつ、より上位にエラーを上げる必要があるので、コードが複雑になりやすい。また、例外処理のコードはボイラープレートが増えがちで読みにくい。

データ指向の分散システムにおける90%以上の障害がうまくエラーハンドリングできていないことによるものという研究もある。

所感

try-catchな例外処理が増えてくると読みにくいというのは誰しもが感じることがありそう。大域脱出するので処理が追いづらいというのもある。

10.2 Too many exceptions

プログラマはよかれと思って例外を定義しすぎる。

Programmers exacerbate the problems related to exception handling by defining unnecessary exceptions. Most programmers are taught that it’s important to detect and report errors;

著者のJohn先生の失敗談として、Tclunsetが紹介されている。ざっくりまとめると

  • unsetは変数を削除するためのコマンドで、存在しない変数の名前を渡すのは使う側のバグと考えてエラーを投げるようにした
  • 存在するかどうかに関わらず変数名のリストにある名前すべてをunsetに渡したいケースがあるが、存在しない変数が渡る場合に備えてエラーハンドリングを必ず書く必要があり、失敗したと感じている

例外もインタフェースの一部であり、上位層に伝播する性質もあるので、増やしすぎるとシステム全体の複雑度が上がる。

The exceptions thrown by a class are part of its interface; classes with lots of exceptions have complex interfaces, and they are shallower than classes with fewer exceptions.

…It can propagate up through several stack levels before being caught, so it affects not just the method’s caller, but potentially also higher-level callers …

例外を投げて使う側に対応を委譲するのは楽だが、システムの複雑さを軽減するためには例外をハンドリングする場所自体を減らしていく必要がある。

The best way to reduce the complexity damage caused by exception handling is to reduce the number of places where exceptions have to be handled.

所感

防御的プログラミングに対する反論的な章。手元のEffective Java第2版7章「メソッド」の「パラメータの正当性を検査する」には

もし、不正なパラメータ値がメソッドに渡されて、実行する前にメソッドがそのパラメータを検査すれば、適切な例外で持ってメソッドは速やかにきちんと失敗します

とある。こういうプラクティスが主流だった時期の反動はあるのだろうか。

10.3 Define errors out of existence

例外ハンドリングを減らすには、例外をハンドリングしなくて済むように(エラーがもとから存在しないように)APIを定義すればよい。

The best way to eliminate exception handling complexity is to define your APIs so that there are no exceptions to handle: define errors out of existence. This may seem sacrilegious, but it is very effective in practice.

先ほどのunsetの場合、未知の変数を渡したらなにもせずにreturnすればよかった。また、変数を削除するというよりは、削除した結果として変数が存在しなくなっていることを確認するというセマンティクスに変えるとよかった。すると、未知の変数名を渡してもエラーが発生しないというのが自然な挙動になる。

所感

この節は短いが、テストに出るとしたら(?)ここだと思う。APIを設計する時点で、例外を定義せずにすむようなセマンティクスにするのが大事。

10.4 Example: file deletion in Windows / 10.5 Example: Java substring method

これらの節では、Windows/Unixのファイル削除の対比や、JavaのStringクラスのsubstringメソッドを例に、エラーをなくす例を紹介している。

たとえば、Unixではファイル削除を遅延することで次の2種類のエラーをないものとしている。

  • ファイルが利用中でも削除操作がエラーを返さなくなっている
  • ファイルを削除するとき、すでにファイルをオープンしているプロセスで例外が起きなくなっている

また、Javaのsubstringは、指定したインデックスが文字列のインデックスのレンジからはみ出るとIndexOutOfBouondsExceptionが発生するので使いづらい(John先生の感想)。次のようなAPIにすればよい(John先生の提案)。

// Javaは次のコードでIndexOutOfBouondsExceptionを投げるが
// John先生は "ware" を返せるようにすればいいのではと言っている
  "software".substring(4, 8) // => "ware"
// ^^^^^^^^
// 01234567

// その他のerror-freeな例
"software".substring(-1, 3) // => "soft"
"software".substring(8, 9) // => ""
"software".substring(5, 3) // => ""

結果、APIがシンプルかつ多くの機能を持つ = 深いモジュールになる。

例外があるほうがバグをすぐに発見できるのではないかという反論がある。それによってバグを発見できると判断してJavaのsubstringは以上の機能になったかもしれないが、エラーを回避したり無視するコードを書く(もしくは書くのを忘れる)過程でソフトウェアが複雑になり、新たなバグを誘発する。

Overall, the best way to reduce bugs is to make software simpler.

所感

最近の言語だと、たとえばRustのstr::getは上記のJohn先生の提案と同じようなAPIであり、Optionを返す。また、スライスを使って部分文字列を得ることもできるが、こちらはメモリを意識しておりインデックスがはみ出るとpanicになる。

10.6 Mask exceptions

エラーをなくすのではなく隠すテクニックについて。例外的な状況に対してシステムの下位層で対処することで、上位層からは気にする必要がなくなる。

例としてはTCPのパケットロスのときの再送機構、NFSでサーバ内部にエラーを隠してアプリケーションはNFSサーバが復旧するまでハングする、といった話がある。

所感

手法自体は納得感あるものの、例として使っているNFSサーバの話の納得感があまりない…

10.7 Exception aggregation

エラーを単一の箇所でハンドリングする例外の集約について。個別の箇所でエラーハンドリングするのではなく、1つのハンドラを使って1箇所ですべてのエラーに対応する。

Webサーバで、URLに基づいてディスパッチャからハンドラにリクエストを割り当てるケースを考える。URLからパラメータを抽出するメソッド(ここではgetParameterという例)を使い、必要なパラメータがなければ例外を投げるとする。ふつうにディスパッチャから呼び出される各ハンドラでこの例外を対処するとコードが重複する。そこで、ディスパッチャに例外を集めて対処することで、エラー処理が1箇所にまとまる。

getParameterはパラメータ解析方法と人間が読みやすい形式のエラーについてだけ知っており、ディスパッチャはエラーをHTTPレスポンスにする方法だけ知っている、という責務の分担もできる。

所感

Webサーバの例があったが、このようなエラーハンドリングはWebアプリケーションフレームワークの機能で実現するのが現代という気がする。

10.8 Just crash?

例外処理の複雑さを減らすために、アプリケーションをクラッシュさせてしまうというテクニック。

例えば、Cでmallocを使うときにout of memory (OOM)はチェックしきれないし、メモリ不足は現代では起こりにくい。起こるとしたらバグの可能性が高いので、OOMをハンドリングする意義は薄く、そのままクラッシュさせるほうがよい。やるとしたらOOMのときプログラムをエラーメッセージとともに中断するckallocを定義して、それだけ使うようにするほうがまだよい。

所感

『「悪い方が良い」原則と僕の体験談』の3「プログラムの頑強さ」と近い話だと思った。とはいえ、Webサービスの会社としては、信頼できない入力が事故につながるWebアプリケーションではまた別の考え方が必要そうではあった。

10.9 Taking it too far

例外を消し去ったりモジュール内に隠したりするほうがいいといってもやり過ぎに注意。インタフェースが複雑になるが、重要な例外はちゃんとクライアント側に見せる必要がある。

全体の所感

エラーをエラーとして扱わずに済ませるような、特殊ケースをより汎用的な正常系に吸収させるセマンティクスを持つようにAPI定義するという考えかたは、これまで無意識下でやっていたこともあるものの、あらためて意識しておく価値があると思った。この章で紹介されている観点を持って各言語のライブラリのAPIがどのように定義されているかも見てみるとおもしろそう。