We’re sorry we missed you at re:Invent, but we can still meet!

例外はバグだった

MomentoがJVM言語の例外についてどのような哲学を採用しているかを学びましょう。

Kenny Chamberlin headshot
Kenny Chamberlin
Author

Share

RustやKotlinの開発者の方は、うなずきすぎによる首の疲れに注意してください。冗長性を高め(適切な位置で、私は提案します)、正しさを促進するパターンを見ていきます。

いくつかの裏話

私はプロとしてC#とJavaからスタートしました。当時、ジェネリックスは新しく、広く採用されていませんでした。当時よく議論されていたのは、”例外:チェックされるのか、チェックされないのか?”ということでした。情熱的で尊敬できる人々がこの点をめぐって大きく対立し、少なくともJavaでは、その結果は一様に混乱しました。誰かが関連する例外をキャッチして処理するのを忘れたり、消費される呼び出しサイトやスタックをすべて注意深く検査することなく新しい例外タイプが追加されたりしたために、私が解決したバグの数は数えきれません。初期の貢献として、私はひどいlogAndThrow()関数の削除を何年もかけて熱心に主張しました。

その後、私は数年間C++を深く学びました。C++の例外は問題が多く、私たちはそれを完全に無効にしました。考え抜かれた包括的なソフトウェアに没頭するこのセラピーは、「デフォルトの例外」(セグメンテーション・フォールトの遍在するギロチン)と共に、MomentoでJVMに戻るための物思いにふける舞台となりました。

The Momento story

MomentoではgRPCを使用しており、多くの社内サービスではKotlinを使用しています。ご存じないかもしれませんが、KotlinはJVMバイトコードにコンパイルするプログラミング言語で、TypeScriptとJavaScriptの関係を彷彿とさせるものがあります。

グリーンフィールド・サービスの方向性を決めるにあたって、(未来の私のような)良識ある開発者がつまずくようなありふれたバグではなく、時間の経過とともに「興味深い」バグになるようにする方法が必要でした。コールインしたものは必ずキャッチしなければならない」とか、「何をキャッチする必要があるか知るために、コールしたすべての行を読みなさい」というような義務を課すことは、みんなの時間を無駄にする素晴らしい方法のように思えたのです。結局のところ、開発者はコードを書くのが得意だし、書かれたコードは価値とそれなりに相関しています。私は価値を奨励したいので、開発者があちこちを読んだりつま先立ちしたりする代わりに、より良いコードをより速く書けるようにすることは、私にとって当然のことでした。

The direction

(少なくとも)2つの重要な技術的原則に合意する必要がある:

ソフトウエアは、「違法な」コードを排除するためにコンパイラに依存すべきである。
よくあるバグは “違法 “に書くべきである(関数型プログラマーは団結せよ!)。

その最も単純な例のひとつが、初期化されていない変数をコンパイラーが使用できないようにする場合です。これはJVM、dotnet、Rust、その他多くのエコシステムでよく見られることです。代わりに、初期化されていない変数の使用に遭遇したときに例外をスローするコードを生成するコンパイラーに頼ることもできるが(C++では笑い)、初期化されていない変数がアプリケーションのどこにも読み込まれないという保証を享受することができます(安全でないブロックや創造的なリフレクションなどを除けば、ガードレールやルールについて話しているのであって、The Universal Model Of Everythingについて話しているのではない)。この保証はとても価値があり直感的なので、開発者はそれに従わないコードを修正するでしょう。修正したくなるような例を挙げてみます:

var message: String? = null
if (condition) {
  message = "the condition was true"
} else {
  message = "the condition was false"
}

現在、Kotlinの型はnullの可能性も認識しているため、これを繰り返し単純化することで、読者の認知的負担を軽減し、このコードを静的合法性検証のためにコンパイラにますます依存するようにすることができます:

val message: String
if (condition) {
  message = "the condition was true"
} else {
  message = "the condition was false"
}

messageをreadonlyとして宣言できる(Kotlinではval vs var)ので、この条件式の後にmessageを使いたい場合(そう、ここで次のアップデートの伏線を張っている)、コンパイラーは私が使う前に変数が確実に初期化されていたことを保証できます!

完全を期すために、ここではKotlinを使っているので、命令的で時間的に脆弱な式列ではなく、クリーンでエラーに強い宣言的な式にするために代入を解除すべきです:

val message = if (condition) {
  "the condition was true"
} else {
  "the condition was false"
}

On exceptions

そうそう、これはこういうことなんです。目を細めれば(正直なところ、目を細める必要はないと思うが)、別の名前のgotoは例外です。

gotoをサポートしている言語でgotoを否定するチームが使っているおかしなトリックのひとつは、do {} while (false);パターンを使うことです。これにより、breakラベルとcontinueラベルの両方が得られ、自由にgotoすることができます!これはとんでもないことです。将来の読者は混乱し、スコープや本来の意図について推論しなければならなくなるだろうし、もし原作者が連続した折り返しラベルループから抜け出すための動的ブレークカウンターを実装していたら、天罰が下るでしょう。

さて、公平を期すために言っておくと、問題そのものはException-ではなく、ここでの問題はthrowまたはraise文です。例外自体は素晴らしいものです。スタック・トレースに役立つデバッグ情報がバンドルされています。これを愛してやまないものがあるだろうか?上記の技術的な原則に同意したと仮定すると、次のようになります: ライブラリのコードで、nearest_wrapping_catch_expression_on_the_stack_which_catch_type_contravariant_of_this_exception_typeを実行すると、ユーザーのソフトウェアにバグをインストールしたことになります。このラベルは読めませんが、throwがすることは多かれ少なかれそういうことです!

あなたが(チームメイトや顧客や$future_youに)提供するかもしれないAPIを考えてみましょう。このAPIは、ほとんどの場合乱数を返しますが、非常に大きな値に対する乱数生成器にバグがあるとします:

fun randomNumberUsually(): Int {
  val randomNumber = ThreadLocalRandom.current().nextLong()
  if (randomNumber < Long.MAX_VALUE - 32) {
    return 4 // source: xkcd.com/221
  } else {
    throw AskMeAgainException("this function does not trust random values near 2^63")
  }
}
delay(randomNumberUsually())

これは通常テストではうまくいくだろうし、本番では何ヶ月もキャッチラベルなしでうまくいくかもしれない。しかし、このAPI設計では、ユーザーが明らかなバグを書くことは些細なことです。コードを読まない人は、この関数が常に乱数を返すと期待するでしょう:

悲劇的なことに、このAPIは、nextLong()の大きな値を信用しないという、あなたのAPIにおける “非常に重要な “ファセットを無視しています。その結果、ユーザーをクラッシュさせることになるでしょう。

その代わりに、ユーザーがあなたの特別な関数を正しく呼び出せるように、あなたの言語の機能を使ったらどう見えるかを考えてみてください:

sealed interface RandomNumberUsually {
  data class RandomNumber(val n: Int) : RandomNumberUsually
  object AskMeAgainLater : RandomNumberUsually // You could make this a data class and put an exception (unthrown of course) inside if you want
}

fun randomNumberUsually(): RandomNumberUsually {
  val randomNumber = ThreadLocalRandom.current().nextLong()
  return if (randomNumber < Long.MAX_VALUE - 32) {
    RandomNumberUsually.RandomNumber(4) // source: xkcd.com/221
  } else {
    RandomNumberUsually.AskMeAgainLater
  }
}

これでは書くコードが増えるだけです。さて、自分のことよりもユーザーを大切にする良いAPI開発者として、あなたは今、この関数の有効な戻り値をすべて型システムに公開しています。ユーザーの素朴なdelay(randomNumberUsually())呼び出しはコンパイルされないでしょう。

ユーザーがこの関数を使って行うことは以下の通りです:

delay(
  when (val random = randomNumberUsually()) {
    is RandomNumberUsually.RandomNumber -> random.n
    RandomNumberUsually.AskMeAgainLater -> 16 // oh well, I don't think this is so bad
    // There are no more possible responses. The compiler will remind me if the return codes are updated and another is added.
  }
)

その値を使うためには、両方のケースを考慮しなければなりません。また、ThreadLocalRandom::current()が失敗するような、あなたが考えもしなかったような本当のバグがない限り、不正なgoto dynamic_catch_labelがアプリケーションを破壊することはありません。何度も失敗するようであれば、内部的にキャッチして、代わりにクリーンで型安全な戻り値としてモデル化することを考えるかもしれません。

もし例外バグに遭遇し、後でAskMeAgainExceptionをキャッチする必要があることに気づいたら、こうなるでしょう:

delay(
  try {
     randomNumberUsually()
  } catch (e: AskMeAgainException) { // Are there other exceptions I should catch? Remember to check on every version bump...
    16 // this caused an outage because it crashed the server...
  }
)

The rule

エラーは特別なものではなく、戻り値なのです。コンポーネントの境界を越えて例外を発生させてはいけません。

try{}なしでコンポーネントを裸で起動し、バグがsegfaultのように外部に伝播し、プログラムをクラッシュさせるか、一番上に未処理のバグを記録します。try{}なしで呼び出しても安全なコンポーネントを提供してください。

コンポーネント内では、例外を投げるツール(言語機能やライブラリ)を使うかもしれません。例外をキャッチし、コンポーネントの戻り値をモデル化する必要があります。一般的なケースでは、Ok(value)とError(exception)以上のものは必要ありません。エラーで呼び出された場所に戻る方が、ユーザーが指定したわけでもない未知のラベルに動的なgotoを発行するよりも、はるかにユーザーに親切です。

(コンポーネントとは何か?それは皆さんの判断にお任せします。メソッド、関数、クラス、あるいは他の何かかもしれないが、一般的に、コンポーネントのスコープがタイトであればあるほど、コンパイラーは静的検証をうまく行うことができる)。

人気言語は何をするのか?

言語によっては、すでにこのためのアフォーダンスが組み込まれているものもあるし、かなり最近になってこのパラダイムのサポートを拡張したものもあります。私がこのアイデアを発明したわけではないです:

Rustでは、Result<>とOption<>をエスケープすることはできません。.unwrap()を呼び出してプログラムをパニックに陥れることもできますが、それは悲しいことです!その代わりに、Rustの?演算子を使って、エラーになる可能性のある関数からResultをカスケードします。演算子は基本的に、エラー処理を明示的に目的としたパターンマッチングの特殊なケースです。この演算子は、失敗をうまくスタック状にアンロールしていき、何かがそれを処理するか、失敗を取り除くまでをサポートします。

Goはエラー処理に関して、新しい言語の立場と同じような考え方を持っており、それはGoのイディオムに現れている。エラータイプを持っており、リンク先の11年前のブログでさえ、自分のフレームワークのエラーセマンティクスを宣伝することがいかに有用であるかを示しています。言語の他の部分はともかく、私はGoのエラー処理哲学が好きです。

C#は、8.0できちんとしたスイッチ・ブランチ・マッチ式を追加した。int/enumの互換性のため、enum値の扱いには不運な点もあるが、努力はしているし、そのおかげで言語も良くなっている!

Javaでさえ、新しい構造化タイプキャスティング・インスタンス演算子で、安全なエラーを戻り値として返すのに適したパターンマッチング構造を採用しています。一般的にあらゆる種類のダウンキャストに適用できるが、これはAPIがユーザーのランタイムで予測可能で、うまく振る舞えることを保証するために特に強力です。

C++コミュニティは現在、パターンマッチングの構築に取り組んでいます。Bjarne StroustrupやHerb Sutterがこのことについて話しています。もうすぐマッチングのための構成体ができるでしょう。バリアント(std::get、std::holds_alternative)のような、現在この言語で利用可能な回避策を使うこともできるし、その間にそうすることをお勧めします。この機能に関する議論は微妙だが、もし忍耐力があるのなら、価値のあるトピックです。お茶でも飲みながら、特に興味深いデモをご覧ください。この講演ではswitchのC#実装にまで言及しており、このブログの全体的なアイデアが何なのかをより深いレベルで理解するのに役立つでしょう。

Pros and cons

Pros:

・ユーザーのバグが減る(多くの言語で)。コードの検証はコンパイラに委ねられ、既知のパスがすべて処理されていることが静的に検証される。これは、例外的なスタイルの “敵対的/ゴチャゴチャした開発 “とは対照的な、ユーザーとの “協力的な開発 “です。
・ユーザーはエッジケースを早期に発見し、考慮する。
・あなたのユーザは、あなたのスローを出し抜こうとして、いたるところでtry/catchをポイ捨てしない。
・ユーザーの呼び出し先(というより、使用先)は明示的で、明確で、安全である。

Cons:

・「例外はバグである」という哲学のもとでは、より多くのタイピングをすることもある(シールされた型を選択し、関数の戻り値ドメインをモデル化しなければならない)。
・あなたのユーザーはより多くタイプするかもしれません。しかし、一般的なIDEは多くの一般的な言語でパターンマッチアームを自動生成しますし、もしユーザーがハッピーケースの結果を必要とするのであれば、悲しいケースをどうするか選択する必要があるでしょう(Rust、Kotlin、Go、C++、Javaを参照)。
・いくつかの言語では、パターン・マッチに対する素晴らしい答えがまだない。
・例外が好きな人もいる。(この哲学は例外を排除するものではない。読者のための練習として、上記のRandomNumberUsuallyにgetOrThrow()を追加する方法を考えてみてほしい)

最後に

あなたがコードを書いているとき、ネットワークエラーや遅いディスクはこの世の終わりのように思えるかもしれない。それは自然なことです。しかし、そのコードが完成し、より高度な問題を解決するためにそのコードを使うようになったとき、あなたはそのような可能性のある問題を気にしなくなるかもしれないし、そのような問題がどれほど重要かについて異なる意見を持っているかもしれません。

Rustでは、NoneかもしれないOptionに対してunwrap()を呼び出すことがあります。ライブラリの中でこのようなことをすると、あなたのライブラリが動作しない場合に備えて回避策があるかもしれないのに、ユーザーにダイナミック・パニックを強制することになります(つまり、エラーはユーザーをクラッシュさせるほど特別なものではありません)。

追加的な定型的コードの代償として、あなたのユーザー(同業者、社外、あるいは将来のあなたであることを忘れないでください)は、可能な限り最大限の、コンパイルすれば動作する「明らかに正しい」コードの恩恵を受けるのです。Rustが懸命にそうしようとしているように。❤️

Share