「\Throwableをcatchしないで」と伝えていく

「Throwbaleをcathcしない方が良いよ、って前に言われたことあるけどアレってソースある?」と言われまして。

f:id:o0h:20201128140525p:plain f:id:o0h:20201128175047p:plain

画像はイメージです

自分としては、どちらかというと「考えていったら自然とそう思えたので」という生き方をしており、確かにソースか〜探したことなかったなぁ。。。となり。即答できず。
「理屈はわかる」のと「毎度説明するのが面倒」は別物ですよね。
(寧ろ、折角PHPの本を書いたのだからそこで言及しても良かったかもな〜)

f:id:o0h:20201128140847p:plain

「話すの面倒くさいと説明のクオリティが毎回変わる」ので、あまり気持ちよくありません。
かといって、「毎回ちゃんと説明する」のは非常に骨も折れるので、それもやっぱり気持ちよくありません。

ということで、「Throwableってなに?Exceptionと何が違うの?調べてみました!」です。

・・・書いていたら思いの外長くなったので、tl;drをつけます😌

tl;dr

これらの文献のうち、特に顕著な表現としてtrowski氏とThrowbaleのRFCの表現がわかりやすいです。これが「何かソースありますか?」に対する回答、ということで良いのではないか?

Error should be used to represent coding issues that require the attention of a programmer. Error objects thrown from the PHP engine fall into this category, as they generally result from coding errors such as providing a parameter of the wrong type to a function or a parse error in a file. Exception should be used for conditions that can be safely handled at runtime where another action can be taken and execution can continue.
Since Error objects should not be handled at runtime, catching Error objects should be uncommon. In general, Error objects should only be caught for logging, performing any necessary cleanup, and display an error message to the user.

https://trowski.com/2015/06/24/throwable-exceptions-and-errors-in-php7

また、ThrowableのRFCでも「キャッチすべきではない」と端的に説明されとります

catch (Error $e) and catch (Throwable $e) may be used to catch respectively Error objects or any Throwable (current or future) object. Users should generally be discouraged from catching Error objects except for logging or cleanup purposes as Error objects represent coding problems that should be fixed rather than runtime conditions that may be handled.

PHP: rfc:throwable-interface

この辺りが、「ちょっと調べてみよ&ブログに残しておこ」と思ったきっかけに対する答えとなっております!

以下本編。

いんとろ

PHP(7以降)には \Throwable というものがありましてん。

前に働いていた会社で(コードレビューの時とか)、「} catch (\Throwable $t) { するのは好ましくないよね〜」という話をしたものでした。
理由としては、

  • \Throwable を捕捉するというのは 、\Error を捕捉することになる
    • この \Error というのは、開発時点で潰されるべき問題であって、(mainブランチに)取り込まれる≒PRを出す前に潰されているべき内容を示すもの
      • 「ロジック」や「アプリケーション」の事情による異常ではなくて、「プログラミング」の失敗によるもの
      • 例えば、サブクラスを見ると含まれているのは 「TypeError(指定された型との不一致)」「AssertionError (expectationを満たしていない)」「ParseError (構文エラー)」といったもの
        • これらを見ると「プログラムが完成した後に考慮しないといけないもの」ではない、というニュアンスが伝わってこないか?
  • そもそもの話として「例外の拾い方が広い(曖昧)なほどコードの意図が読み取りづらくなる(ポケモンキャッチ)」という理由もあり、基底クラスである \Exception\RuntimeException を補足するのは不吉
    • その観点でも 「\Errorを拾うと「何を拾いたいのか」が分かりづらくなる」というもの
    • 「バグってるのに処理ができちゃう」という風に、リスクを握りつぶすのが問題

みたいなのを挙げていた気がします。
で、一緒に働いていた人たちはきっと同意してくれていたはず・・・と信じてる・・・・

もし「あるとする」なら、例えばFWを実装したり、FWの中でも「複層化されたMiddleware(s)の1番外側の部分、エラーハンドラの部分」とかでしょうか。

  • = 「普段のアプリケーション開発」においては、めったに触れないであろう部分。Errorrは意識せずに開発される
  • それが求められるとしたら、どうしても「文脈が特定しにくい」かつ「強固な意思で対応しなきゃ行けない」て場面にのみ限定される
  • 積極的な考え方としては、こういった場面では「どんな状況(具体クラス)を想定しているか」という姿勢を見せるよりも「ありとあらゆる状況について関心がある」という事をコード上で表現できた方が適切

って感じで、コードに現れることになるかな?と思います。

また、同様の理由で「自分たちが \Error をthrowしたり、継承したクラスを実装することは原則的にはないもの」とも考えています。

で。
最近、同じような話をしていた時に「それって根拠に出来る文献とか共有できる資料ってあるんだっけ?」と質問を受け、 ムムム・・・自分の中では結論が出てたつもりだったけど、確かに何かのドキュメントとかを見た訳でもないかもな・・・・ となりまして、改めて論点を整理して探してみるか!!と思ったのでした。

そしていくつか「これなら納得できるでしょ!」という資料を見つけたので「うわぁ〜良かったなぁ」と思うと同時に、 何度も似た話をするのは面倒くせ〜し共有するのにコスト掛かっちゃうよな! というのもあるので、ブログに書いて見るです。

前提: ポケモンキャッチ

「なぜThrowableをキャッチしないほうが良いのか」は「\Error をキャッチしたくないから」「拾う範囲が広すぎるから」の二本柱によって説得を試みたいのですが、まずは「範囲」の問題です。
が、これは殊更この記事で自分の言葉で説明したい・・・という意欲がないので、いんたーねっとに頼ります。

Pythonの人たちはこんな風に言っています。

最後の except 節では例外名を省いて、ワイルドカード (wildcard、総称記号) にすることができます。ワイルドカードの except 節は非常に注意して使ってください。というのは、ワイルドカードは通常のプログラムエラーをたやすく隠してしまうからです!

8. エラーと例外 — Python 3.9.0 ドキュメント

もしくは、2017年のHirakuさんの発表資料「PHPのエラーと例外再入門」の第5部を参照してください。

PHPのエラーと例外再入門 / php-error-and-exception - Speaker Deckの105枚目からです。

改めて \Error とは

ここからが、もう一方の問題である \Error の話です。

\Error ってなんなの、については 「fatal error や recoverable fatal error の多くが、PHP 7 では例外に変換されるようにな」ったものと説明されています。
PHP: 下位互換性のない変更点 - Manual

この中身はと言うと、およそ「文法エラー」「契約違反」といった内容です。
これらについては「発生する可能性を考慮してプログラミングを行う」ようにする事はなく、「発生してはならないものとしてプログラミング中にクリアされる」べきものです。
前者が \Exception、後者が \Error に属するもの・・・という理解をしています。*1

具体的な内容は以下の様になっています

f:id:o0h:20201119185941p:plain
PHP: PHP 7 でのエラー - Manual *2

なぜ Error(throwable) が必要だったのか?

「重大なエラー」の中には、「処理自体が不能になるもの(= "エンジンが不安定な状態になるほど"の内容)」と「処理自体は可能だが、辻褄があわなくなるようなもの」があります。
前者については、例えば「メモリ容量が足りなくなるもの」があります。後者については「ゼロ除算」「型の不一致」といったものです。後者が、今回 \Error に変更されたものとなります*3

RFCを見ながら、「そうした方が(Errorクラスがあった方が)良い」と考えられた理由を見ていきます。

PHP: rfc:engine_exceptions_for_php7

捕捉・復帰ができない

「重大なエラーが落ちた時に処理が続行できない」・・・というのは、一見「そりゃそうじゃね??」とも感じられますが、例えばデーモンやサーバーのようなプログラムを組んでいた場合は「処理をシャットダウンしないで欲しい」という需要が出てきます。
もしくは、反復的に大量の処理をするバッチとか?

例えば、以下のコードはFatal Errorになります。

<?php

function doWalk($obj)
{
    $obj->walk();
}

// $dogはDogクラスのインスタンスが来ると想定している
// しかし、何らかの理由で$dogはnullだった・・・
doWalk($dog); 

Call to a member function walk() on null です。
PHP5時代は、これによってデーモンそのものが落ちてしまう状態でした。(register_shutdown_function() の処理が呼ばれて終了です。)

エラーハンドラとの兼ね合い

PHP5のFatal Error(致命的エラー)には不便な点がある。 それは、set_error_handler やその他の方法でエラーハンドリングできず、必ず終了してしまう点である。

PHPのエラーハンドリングとロギング - fortkle blog

PHPのエラーハンドリングは、

  • キャッチされない例外が検出された場合の処理: set_exception_handler
  • (処理続行可能な)エラーが発生した場合の処理: set_error_handler

があります。
E_WARNINGやE_RECOVERABLE_ERRORについては、 set_error_handler の処理を通ります。(後者については、"ユーザー定義のハンドラでエラーがキャッチされなかった場合は、 E_ERROR として異常終了する。"です。)
しかし、E_ERRORに関しては「次はもうshutdownだけ」となります。画面表示やロギング、エラー通知といった処理も難しくなってしまいます・・・

finally, __destructとの兼ね合い

「正しく後処理をする」といった場面で利用したいfinallyや__destructですが、これらも旧来の重大なエラーでは実行されませんでした。
そうすると、 shutdown時の処理として後処理を入れざるを得ないことになります。
・・・それは「グローバルなスコープを相手取って良い感じに後処理を入れる」となるので、汎用的な内容だけで済むならともかく、何か具体的な処理を入れないと〜となった時に大変そうです。

RECOVERABLEであってもRECOVERするには扱いづらい

「復旧可能」なのに「実際に捕まるのは終了(shutdown)直前」・・・ということで、通常は復帰処理が難しいものです。
先に述べたように、E_RECOVERABLE_ERRORは set_error_handler の処理を通ります。
公式ドキュメントに

キャッチできる致命的なエラー。危険なエラーが発生したが、 エンジンが不安定な状態になるほどではないことを表す。 ユーザー定義のハンドラでエラーがキャッチされなかった場合 (set_error_handler() も参照ください) は、 E_ERROR として異常終了する。

PHP: 定義済み定数 - Manual

とあるように、E_RECOVERABLE_ERRORはerror handler内の処理によっては「復帰」が可能です。

set_error_handler の説明を見ると、以下のような記述が見られます。

コールバック関数が FALSE を返さない限り、error_types で指定した型のエラーでは PHP 標準のエラーハンドラが完全にバイパスされることに注意してください。

PHP: set_error_handler - Manual

また、そのために

ユーザーハンドラ関数は、必要に応じて die() を コールする責任があることにも注意しましょう。エラーハンドラ関数が リターンした場合、スクリプトの実行は、エラーを発生した命令の次の命令に 継続されます。

(同)

とのことです。

例として、次のコードを見てみましょう。

<?php
try {
    
    $func = function(Hoge $hoge) {
        echo '★★func called!★★', PHP_EOL;
    };
    
    set_error_handler (function ($errno, $errstr, $errfile, $errline) {
        printf('★★error handler at line(%d) called!★★' . PHP_EOL, __LINE__);
        echo $errstr, PHP_EOL;
        return;
    });
    $func();
    
    echo '-----', PHP_EOL;
    
    set_error_handler (function ($errno, $errstr, $errfile, $errline) {
        printf('★★error handler at line(%d) called!★★' . PHP_EOL, __LINE__);
        echo $errstr, PHP_EOL;
        return false;
    });
    $func();
    
} catch (\Error $e) {
    echo $e->getMessage();
}

結果は以下のようになります。 f:id:o0h:20201128163709p:plain 3v4l.org

^5.3.0のバージョンでは次のようになります。

★★error handler at line(9) called! errno = 4096★★
Argument 1 passed to {closure}() must be an instance of Hoge, none given, called in /in/8svWG on line 13 and defined
★★func called!★★
-----
★★error handler at line(18) called! errno = 4096★★
Argument 1 passed to {closure}() must be an instance of Hoge, none given, called in /in/8svWG on line 22 and defined

Catchable fatal error: Argument 1 passed to {closure}() must be an instance of Hoge, none given, called in /in/8svWG on line 22 and defined in /in/8svWG on line 4

Process exited with code 255.

2つめの $func() まで到達していることに注目してください。
このように、「(falseを返さないと)元の処理に届いた」という状況です。すなわち「recover」しました。

・・しかしながら、グローバルに影響する set_error_handler に作用して、「この場面で局所的に必要な処理」を作っていくのは苦労しそうです。
実際に、RFCを見ると「Hard to catch」という問題を指摘するために、以下のようなスニペットを示しています。

<?php
set_error_handler(function($errno, $errstr, $errfile, $errline) {
    if ($errno === E_RECOVERABLE_ERROR) {
        throw new ErrorException($errstr, $errno, 0, $errfile, $errline);
    }
    return false;
});
 
try {
    new Closure;
} catch (Exception $e) {
    echo "Caught: {$e->getMessage()}\n";
}
 
restore_error_handler();

これが「E_RECOVERABE_ERRORは "Catchable Fatal Error" なのに扱いにくい」とされる問題の1つです。

また、もう1つ、先に示したように「そのまま同じコンテキストで 2つめの $func() まで到達してしまう」というのも問題です。RFC中で "Execution is continued in same context"として言及されています。
これによって、「致命的なエラー」なのに「処理が継続される可能性があるもの」として実装を強いられる・・・"in the same way as a warning" な実装をしなければならない、という事になります。

それでは開発がしづらく、バグの温床となりかねません。

こうした問題を解決するために、「Errorをthrowする」というアイディアに至ります。

Error が何か?を理解すると、catchしたくなくなる(はず)

例外にもRuntimeとLogicの2系統のExceptionがありますが、これらは「どの段階で発生するものか」という分類です。
逆に言えば「どの段階までに対処せよ」「どこまでなら許容できるか」というメッセージを開発者に伝えます。
PHPには「検査例外」といった概念は導入されていませんが(IDEとかで頑張れる)、「コードで対処する」ものを区別するような思想になっています。
(実際、公式ドキュメントの「LogicException クラス ¶」の項目を見ると プログラムのロジック内でのエラーを表す例外です。 この類の例外が出た場合は、自分が書いたコードを修正すべきです。 と書かれています。)

こちらの記事が参考になります。
PHP でどのように Exception/RuntimeException/LogicException を使い分けるか - Qiita (・・・というか、ここに Error はアプリケーションのロジック中で発生させたり、捕捉したりしてはいけません。 って書いてあるなぁ。昔から読んでいた記事なのに、パッと出てなかった。。)

個人的には、「実行時の問題RuntimeException」と「実装時のミス問題LogicException」のような雰囲気で捉えています。
それに対して、Errorは「プログラミングの問題」と先にも書きました。
LogicExceptionが「(名前の通り)ロジックの問題」なのに対して、Errorはそれよりも「単純」な問題になります。
(ちなみに、SPLに含まれるExceptionについてはAirbreakのブログが詳しいです The PHP Exception Class Hierarchy · Airbrake)

文法エラーや型違いなど・・・これらのある意味「動かさなくてもわかる」ようなものを含め、その程度の問題を報告するための表現が \Error ということになります。

そうした問題を抱え込んだままのコードを、本番環境にデリバリーしたいか・・?あるいは他人にレビューを依頼したいか?というと、「開発段階で解決可能な問題についての関心を、本番環境を想定した時に残したままにしておきたくない」という事になるのではないでしょうか。

明確に「存在理由が違う」からこそ、Exception(例えばErrorException)を継承したものではなく、兄弟関係にある別物として \Error が誕生します。(もちろん、PHP5までのバージョンとの互換性という歴史的な経緯もあります)

「Throwable Exceptions and Errors in PHP 7」の記事を見ると、まさにその事が書かれています。

Since Error objects should not be handled at runtime, catching Error objects should be uncommon.

https://trowski.com/2015/06/24/throwable-exceptions-and-errors-in-php7/

ということで、「\Throwable をキャッチするコードは不吉」「例外的にフレームワーク作ってる時のエラーハンドリングやロガーなどには、 \Throwable が出てくるかも知れない」「もっと言えば、 \Error に限定してキャッチしたい・・・というシチュエーションは、更に生じなさそう」と考えています。

で、この辺りの話は「Errorとは何か」を考えるのが最も良いはずなので、

を読んでみてほしいな〜!という気持ちです。

まとめ

「何かを正しく使う」のには「それがどうして生まれてきたのか、何を解決するのか」を知るのが近道なので、RFCやPRを見ちゃうのが早いよね〜って思います!!

*1:LogicExceptionとAssertionErrorの使い分けをしっかり言語化して分かりやすく説明するのが難しい・・・とは良く思います

*2:ちなみに、PHP8でErrorに関連する良い感じな変更が入ってますよ! Internal function warnings now throw `TypeError` and `ValueError` exceptions - PHP 8.0 • PHP.Watch

*3:「エンジンが不安定な状態になる」というのは、PHPマニュアルのエラーに関する定義済み定数の頁からとってきたものです https://www.php.net/manual/ja/errorfunc.constants.php