# 他言語での例外への対応
例外というのは、長短色々あるようです。 他の言語では、どのように解決しているのでしょうか? 簡単に見て見たいと思います。
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
この記事は、いま書いています
if も for もだいたい同じような考え方で対処できますが、 例外に対する考え方は、言語ごとに最も差が出る箇所でもあり、 ある意味面白い箇所でもあるかなと思います。
逆に言えば私たちが使っている if 文や for 文は 当たり前ですが先人たちが長い歴史の中で出した答えです。
if 文や for 文のない世界を考えられますか? if 文や for 文を使ったプログラミングを構造化プログラミングと言います。
if 文や for 文は水や空気のようなもので 「構造化プログラミング」という言葉を意識することさえありません。
Go も Rust も try 文は存在しません。
# 1. C 言語
"C 言語のエラー処理に対するアプローチ" とは、 おそらく素直に return 文で返り値に例外を返してしまうことを指していると思われます。
C 言語の場合エラー処理といえば、 「関数引数あるいは戻値にエラーコードを定義し、 処理結果を if 文で判定、上位ルーチンに戻す。」となります。
C 言語開発での例外処理 (opens new window)
処理を中断するということができないので goto 文を使用します。 C には例外がないので間違えていた箇所を特定するのが困難ですね。
# 2. Go
# 2.1. Go の型の制限
Python では例外を raise するのに対して Go では例外を return します。
try:
file = open(src_name):
except FileExistsError:
// エラー処理
src, err := os.Open(srcName)
if err != nil {
// エラー処理
}
# 2.2. Go の大域脱出
Go には Python の try 文のようなちょっと変わった機能はないのかというと、 そういうことでもなく panic が呼び出されると関数が巻き戻しされます。
panic 文が実行されると巻き戻しされます。 巻き戻しされるときに実行されるのは defer 文だけ。 最終的に recover 関数で panic が回収されます。 なんのこっちゃって感じですが、早速、ブログを見ていきたいと思います。
例外(exception)がない理由は? (opens new window)
我々は、処理構造を制御するためのtry-catch-finally形式の例外処理機構によって、 コードが入り組んでしまうと考えています。 しかも、ファイルを開けないといった、 ごく一般的なエラーをさも特別なエラーであるかのように扱わせる傾向があります。
Go言語では、異なるアプローチを取りました。 Go言語では戻り値として複数の値が返せるので、 一般的なエラーハンドリングの場合、戻り値といっしょにエラー情報を返すことができます。 エラー型は標準化されており、 Goの他の機能と相まってエラーハンドリングがすっきりしたものとなります。 これは、他の言語と大きく異なる点です。
それとは別に、Go言語にはエラーシグナルの発行機構と、 本当に例外的な状況から回復する機構があります。エラーの発生によって、 関数のコールスタックの巻き戻りが開始する中でのみ、 このエラー回復メカニズムは実行可能です。 このメカニズムは、大きな障害をハンドリングするのにも充分な上に、 処理構造に特別な制御を行う必要もありません。 また上手に使えば、エラーハンドリングのコードが読みやすくもなります。
詳細は、Defer, Panic, and Recover(英語)の記事を参照ください。
# 2.3. 例外を別の変数に代入することについて
場合によっては省略することもできるので、例外を握りつぶしてしまうことができます。 Go で書く場合は、Python などのスクリプト言語などよりも 大規模で堅牢なものを作るというイメージなので、 多少煩雑さを回避するために、握りつぶしてしまうことができるのは、 あんまり良くないんじゃないかなって気がします。
個人的には次に紹介する Rust が取る Union, 直和型が良いように感じます。 検査例外的で多少煩雑にはなりますが、なんかこれがいいような気がします。
# 3. Rust
Go は多値で解決しましたが、それに対して Rust は型で解決します。
例外というのは、通常の処理に比べれば重たい処理になります。 Python では関数の呼び出しが重すぎてそうでもないですが。
Rust は CPU の性能を最大限発揮するための言語だと思っています。 例外によって生じるオーバーヘッドを強要しません。 そのため大域脱出という機能を完全に切ってしまっています。
zero-cost を狙う Rust では慎重に議論がなされています。
また Optional な型を使うことは、拡散してしまう危険ではないでしょうか? Rust は根本的に静的型付けの言語なので、そういった恐れはないと考えます。
むしろ try 文を使ってネスト刺せないのが最高です。 処理後の if 文の場合分けなんて try 文のネストに比べれば大したことはありません。
Rust におけるエラー処理 - Hacker News (opens new window)
Error handling in Rust - Hacker News例外を使うことが許されない状況下で Rust を使わなければならない人がいる。 (なぜなら unwind table と cleanup code が大きすぎるからである)。 そのような人々は、実質的に全てのブラウザベンダやゲーム開発者が含まれる。
Some people need to use Rust in places where exceptions aren't allowed (because the unwind tables and cleanup code are too big). Those people include virtually all browser vendors and game developers.さらには、例外は、この厄介な codegen tradeoff があります。 ソフトウェアを zero-cost にするか(例えば典型的に C++, Obj-C そして Swift コンパイラがそのように動作します)、 zero-cost の場合、例外を投げることは重たい処理になります。 さもなくばソフトウェアを non-zero-cost にします(例えば Java HotSpot や Go 6g/8g がそのように動作します)、 non-zero-cost の場合、たとえ例外が投げられなかったとしても、 各 try ブロックに対してパフォーマンスペナルティを喰らいます(Go の defer においては)。 RAII (opens new window) を持つ言語に対しては、デストラクタを伴う各 single stack object は 暗黙的な try ブロックを生成します、そのため例外は実質的に実用的ではありません。
Furthermore, exceptions have this nasty codegen tradeoff. Either you make them zero-cost (as C++, Obj-C, and Swift compilers typically do), in which case throwing an exception is very expensive at runtime, or you make them non-zero-cost (as Java HotSpot and Go 6g/8g do), in which case you eat a performance penalty for every single try block (in Go, defer) even if no exception is thrown. For a language with RAII, every single stack object with a destructor forms an implicit try block, so this is impractical in practice.zero-cost 例外によるパフォーマンスへのオーバーヘッドは、理論的な問題というわけではありません。 私は、起動時に数千に及ぶ例外を投げていたために、 zero-cost 例外が使われた GCJ でコンパイルされた Eclipse が起動に 30 秒も要したのを覚えています。
The performance overhead of zero-cost exceptions is not a theoretical issue. I remember stories of Eclipse taking 30 seconds to start up when compiled with GCJ (which used zero-cost exceptions) because it throws thousands of exceptions while starting.エラーが生じたときの経路と成功したときの経路の両方について考えたとき、 C 言語のエラー処理に対するアプローチは、例外に比べて素晴らしいパフォーマンスと code size story を持っています、 このことは C 言語のエラー処理に対するアプローチがシステムコードに圧倒的に適していることを示しています。 しかしながら C 言語のエラー処理に対するアプローチは人間工学に反し かつ安全ではありません、Rust は Result でこれに対応します。 Rust のアプーロチは C のエラーハンドリングのパフォーマンスを獲得しつつ、 C 言語のエラーハンドリングが持つ煩雑さを取り除いています。
The C approach to error handling has a great performance and code size story relative to exceptions when you consider both the error and success paths, which is why systems code overwhelmingly prefers it. It has poor ergonomics and safety, however, which Rust addresses with Result. Rust's approach forms a hybrid that's designed to achieve the performance of C error handling while eliminating its gotchas.
公式ドキュメントが鬼のようにしっかりしている。一切理解できていないけど。
Go, Rust の対応を見ていて思ったのは Go は例外が握りつぶされる可能性やあるいは忘れらる可能性があること。 これは Effective Python にも書かれていた。
Rust は例外の対応を型でするならば、 なるべく早期に Result<T> が伝搬してしまう恐れがあるかなと思いました。 ただ Python などの動的言語とは異なり、 静的な型検査がついているからコードを書く時点で既に気づけるかなと思ったりもしました。
反面 tuple だと握り潰せてしまう。
try による例外は実行速度の複雑さ、処理の遅さ。
「しかしながら C 言語のエラー処理に対するアプローチは人間工学に反しかつ安全ではありません」というのは、 C 言語は基本的に例外が無いので戻り値にエラーを示す値を代入して呼び出し元に戻します。
Python の例外はすぐにドカンと爆発してくれるので、見逃すことはないのですが、 C 言語の場合、変数にひっそりと代入して忘れてしまうと、あとあとになって爆発して、 この子は、どこかから来たんや?みたいなことになりデバックが大変になります。 「例外を握り潰す」という表現のされ方をよく見かけます。
C言語の場合エラー処理といえば、 「関数引数あるいは戻値にエラーコードを定義し、 処理結果をif文で判定、上位ルーチンに戻す。」となります。
C言語開発での例外処理 (opens new window)
でも結局 Rust も変数に Result 型の変数をいれてるから人間工学に反してるんじゃね? C と何が違うの?という話ですが、コンパイラで静的型チェックをして実行前に判定しているということかなと思います。
Python の標準ライブラリ re の search メソッドもパターンにマッチする文字列がなければ、 しれっと例外的な None を返して来ます。 これは上の記事の文章を借りれば「人間工学に反している」と言えます。
しかし、型アノテーションと mypy を組み合わせれば静的な型検査ができます。 実行する前に、型について間違っているところだけは、エラーを見つけることができます。
ただ C 言語もいちおうコンパイル言語で "弱い型付け" ではありますが、 静的型検査しているはずなので、C はダメで Rust は大丈夫の感覚がまだ正直あまり掴めていません...
Go には、例外はありません。Rust が1つの変数に「正常な値」か「例外」を詰め込むのに対して、 Go は2つの変数、「正常な値のための変数」と「例外のための変数」を用意して対応します。
Go ではこれに加えて、例外的なことが起こった時に、defer, panic, recover が実質的に例外と同等の機能をになっています。 上記引用した記事によると、これがオーバーヘッドになっているそうです。
Python の例外が「ズドンと爆発」して try 文で捕まえるイメージなのに対して、 Go の 「panic を起こすと巻き戻」って、それを recover で回収するイメージです。 The Go Blog を翻訳しました。
あまり関係ないですが JavaScript は undefined が多用されてて、辛いです。 Python の None は JavaScript の null です。null は有り無しの「無し」を表現しているのに対して、 undefined は「未定義」を表現しているオブジェクトです。
Python では re.search みたいに、未定義を表すために None を返すのはあまり多くないんじゃないかなと感じています。 例外を返されると try 文で包めなくなって、リスト内包表記, ジェネレータ式, lambda 式を引数に取る高階関数(例えば map, filter など)で使えなくなるので、 一概にどちらがいいとは言えないと思うのですが...
Python は None を返すくらいなら例外が返されることが多いような気がします(書籍『Effective Python』Item 14: Noneを返すよりも例外を発生させよう)。 ざっくり言えば変数に格納されるオブジェクトの型が2種類になるくらいなら、 Optional になるくらいなら、例外が返って来ます。
なぜこのようなことになっているかというと、Python は基本は、静的型検査をしないことが前提になっているからだと思っています。 個人的には型検査をしない Python の場合は、例外を投げるか None 返すか、どっちがいいか迷ったら、 例外を投げる方がベターかなと、思ったり思わなかったりします。
# 4. Haskell
Haskell はモナドを使って例外を取り扱っているようです。 これが一体なにかはまだ自分も読んでいないのでわかりません。 関数型言語で特殊な例だったので、ご紹介させていただきました。
Java に関するスライドで、正直理解し綺麗ていないのですが、 下記のスライドがとてもとても良いので、ぜひご一読ください。 答えはあるのか→契約による設計、この流れは本当に感動しました。
静的な言語にできるなら個人的には Rust のアプローチが一番良いなと思いました。 Go のように多値だと握りつぶしたり忘れてしまうし、 try 文はネストが本当に嫌です。
# 5. まとめ
ここまで以下のような流れで例外についてご紹介させていただきました。
この try 文のカテゴリを書くにあたり以下のスライドを参考にさせていただきました。 このスライドがなかったら、僕は一生、このカテゴリを書くことはできませんでした。 本当にありがとうございます。
if 文では bool 型, for 文では Iterator, Iterable 型, try 文では Optional 型について見てきました。 これで第二部の「型」に関する記事は終わりになります。 ありがとうございました!