🧊

Cleaner, more elegant, and ... の、例外とエラーの話

という、似たようなタイトルの記事が2つありまして。
どちらもマイクロソフト社のレイモンド・チェン氏が書いたもの。

氏は、「キーボードが動かない!(本当は刺さってないから)」っていうクレームに対して、「ちゃんとつながってます?」って聞くのではなく、「接点にゴミがついて接続が弱くなることがあるので、一回抜き差ししてもらえます?」って聞くと、結果どうあれうまくいくよ〜っていう逸話のあの人です。

少し古い記事やけど、そうそう自分が最近考えてたのもこういうことなんですよ・・・って思ったので、その備忘としても要点をメモっておく。

Cleaner, more elegant, and wrong

Cleaner, more elegant, and wrong - The Old New Thing

とあるC#のプログラミング本で紹介されてたスニペットが、クリーンでエレガント・・・かもしれないが、間違ってる!っていう話。

その疑惑のコードがこちら。

try {
  AccessDatabase accessDb = new AccessDatabase();
  accessDb.GenerateDatabase();
} catch (Exception e) {
  // ここで例外を処理
}

public void GenerateDatabase()
{
  CreatePhysicalDatabase();
  CreateTables();
  CreateIndexes();
}

本では、こうすればなにかあってもいい感じに例外を処理できる!例外ってすばらしい!って紹介されてるが、実際は、そんなことないじゃろ、と。

というのも、`GenerateDatabase()`がやってる3つの処理のうち、2つ目とか3つ目で例外が起きたらどうするの?
DBつくって、テーブルつくったけど、インデックスを張ろうとして失敗したらどうなるの?

このコードの通りに呼び出し元で例外をキャッチしたとしても、どの時点までロールバックが必要なのかもわからない。

そういうわけで、"いつどこで起きるかわからない"例外を、エラーハンドリングに使うのは、難易度が高いことだと割り切ったほうがよいという話。

これも良くないコードの例。

Guy AddNewGuy(string name)
{
  Guy guy = new Guy(name);
  AddToLeague(guy);
  guy.Team = ChooseRandomTeam();
  return guy;
}

このコードにもさっきの考え方は流用できて、まだこうするほうがマシ。

Guy AddNewGuy(string name)
{
  Guy guy = new Guy(name);
  guy.Team = ChooseRandomTeam();
  AddToLeague(guy);
  return guy;
}

つまり、「コードは副作用がない順に実行していって、状態の確定は最後にまとめてやれ」ということ。
そうすれば最初のほうで例外があっても、ロールバックは必要なくて良くなる(と見なせる)。

ただまあ実際はそんな単純ではなく、さっきのコードにもいつの間にか修正が入って、こうなってしまったりする・・・。

Guy AddNewGuy(string name)
{
  Guy guy = new Guy(name);
  guy.Team = ChooseRandomTeam();
  guy.Team.Add(guy);
  AddToLeague(guy);
  return guy;
}

言いたいことはわかりますね?

Cleaner, more elegant, and harder to recognize

Cleaner, more elegant, and harder to recognize - The Old New Thing

前回の記事を、例外への批難のようなメッセージだと解釈した人たちがいる。
けどそうは言ってないのである。

例外を正しく扱った良いコードを書くことは、可能だけれども難しいことだと言っただけ。
そして難易度が高いからといって、やってはいけない・やれないことではない。(そういうことは世の中にもいっぱいあるので)

そして、例外を使わず、エラーを返してコードをチェックする方式にしたとしても、良いコードになる保証はない。

ただ例外を使う場合は、ほんとに全ての行で例外が発生しないかどうか、そしてどんな対応が必要かを常に意識しながらコードを書く必要があって、それが難しいと思っている。

エラー(とコード)を、それが生まれる可能性がある場所でだけチェックするほうがまだ簡単。

エラー(とコード)を使ったコードの良し悪しを見分けることに比べると、例外を使ったコードの良し悪しを見分けることは、もっと難しい。

例えばこれは、エラー(とコード)を使った悪いコードの例。

BOOL ComputeChecksum(LPCTSTR pszFile, DWORD* pdwResult)
{
  HANDLE h = CreateFile(
    pszFile, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL
  );
  HANDLE hfm = CreateFileMapping(h, NULL, PAGE_READ, 0, 0, NULL);
  void *pv = MapViewOfFile(hfm, FILE_MAP_READ, 0, 0, 0);
  DWORD dwHeaderSum;
  CheckSumMappedFile(pvBase, GetFileSize(h, NULL), &dwHeaderSum, pdwResult);
  UnmapViewOfFile(pv);
  CloseHandle(hfm);
  CloseHandle(h);
  return TRUE;
}

何が悪いかというと、一切エラーをチェックしてないから。一目瞭然である。

チェックするようにしたら、こうなる。

BOOL ComputeChecksum(LPCTSTR pszFile, DWORD* pdwResult)
{
  BOOL fRc = FALSE;
  HANDLE h = CreateFile(
    pszFile, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL
  );
  if (h != INVALID_HANDLE_VALUE) {
    HANDLE hfm = CreateFileMapping(h, NULL, PAGE_READ, 0, 0, NULL);
    if (hfm) {
      void *pv = MapViewOfFile(hfm, FILE_MAP_READ, 0, 0, 0);
      if (pv) {
        DWORD dwHeaderSum;
        if (
          CheckSumMappedFile(pvBase, GetFileSize(h, NULL), &dwHeaderSum, pdwResult)
        ) {
          fRc = TRUE;
        }
        UnmapViewOfFile(pv);
      }
      CloseHandle(hfm);
    }
    CloseHandle(h);
  }
  return fRc;
}

もちろん改善の余地はあるけど、悪くないコードだとわかる。

それに対して、この(急いで書いたであろう)例外ベースのコードに、前回の記事の考え方を適用した状態のコードがある。

NotifyIcon CreateNotifyIcon()
{
 NotifyIcon icon = new NotifyIcon();
 icon.Text = "Blah blah blah";
 icon.Icon = new Icon(GetType(), "cool.ico");
 icon.Visible = true;
 return icon;
}

さて、これは悪いコードか、悪くないコードかわかりますか?見分けるのはとても困難のはず。(この見える範囲でキャッチされてないだけで、どこか上層でキャッチされてるかもしれないけど、それはここではわからない)

なので例外を使ってコードを書く場合、あとでリファクタしようと思ってはいけない。

例外を投げるか、エラー(とコード)を返すか

という感じで、言いたいことはわかるけど、正解は教えてくれないんですね〜は〜んって感じの2記事だったw

まぁこの2つの記事で、氏が言いたかったことはたぶんこういうことかなー。

  • (往々にしてロールバックが必要になるような)エラーは、そのスコープで即座に処理されているべき
    • 見えるところにそのコードが書かれているべき
  • エラー(とコード)を返す場合、その場で処理してる or NOTは明確にわかる
    • この場合は型としても不定にできるはず
  • 例外を投げる場合は、一見してそれが処理されてるのかはわからない
    • トップレベルのコードならまだしもだいたいは下層のコードのはず

`await`を見たら`catch`してるか確認する癖をつけろ!みたいな話でもありつつ、どの行からも例外が投げられる可能性があるがゆえに、すべてのコードパスを把握する必要があり、それが人類には難しいことである、と。

それはすごいわかるなーと思ったし、例外がないGoみたいな言語がわかりやすいって言われる側面の一つでもありそう。もちろん反対意見があるのも承知の上で、でも個人的には割と好感を持ってるところ。

こと例外が言語仕様として存在してしまってるJavaScript界隈に身をおいてる身としては、例外を禁止することができない(すべてのローレベルなオペレーションをラップする気概はないので)以上、難しいことであるのは受け入れつつ、がんばって注意して扱うしかないのかなーと。

ちなみに最近の個人的なポリシーは、

  • 自分で生成した`Error`は、`throw`せずに`return`する
  • `catch`されるのは、本当に例外的に発生してしまったものだけ
    • つまりネットワークが不通であるとか、事前に保証できない+どうしようもない異常系だけ
    • それ以外の準正常系は`return`して対処する
  • ローレベルな処理では引き続き`throw`することもある
    • この線引が難しいってことやね・・

という感じでやってることが多いかも。
少なくとも自分で`throw`を書いてない以上、どこかで`catch`することがあれば、それは完全にバグであると断定できるようになるはずなので。

言ってしまえば例外(というか`throw`)って、ラベルのないGOTO文みたいなもんやし、扱いが難しいのはまあ当然というか・・。
ならエラーを返すように徹したほうが、脳内メモリは少なくて済むってのもごもっともで。

まぁ昨今のJavaScript界隈は、`Promise`を`throw`するとかいう事態になってるけどな・・・!