【PHP7.4】__toString()が例外を吐けるようになる


プロパティの設定を必須にしたかったとしましょう。

```php
class HOGE{
    public $var;
    public function __toString(){
        if(!$this->var){
            throw new \Exception('$var must set.');
        }
        return sprintf('$var is %1$s', $this->var);
    }
}

try{
    echo new HOGE();
}catch(\Exception $e){
    var_dump($e);
}
```

何の変哲も無いように見えるコードですが、これ動きません。
`Fatal error: Method HOGE::__toString() must not throw an exception, caught Exception`というFatalエラーを吐いて死にます。

実は`__toString()`メソッド内では[例外を出すことができない](https://www.php.net/manual/ja/language.oop5.magic.php#object.tostring)のです。
他のあらゆるマジックメソッドにはこんな制限はないのに、`__toString()`だけそういうことになっています。
よくわかりませんね。

しかしまあ、そんなよくわからない制限なら要らないよね、ということで[Allow throwing exceptions from __toString](https://wiki.php.net/rfc/tostring_exceptions)のRFCが提出されました。


# PHP RFC: Allow throwing exceptions from __toString()

## Introduction

`__toString()`からの例外スローは禁止されており、致命的エラーになります。
これは`__toString()`から任意のコードを呼び出すことを困難にし、一般的なAPIとして使用することが難しくなります。
このRFCは、この制限を取り除くことを目的としています。

現在のような動作になっている理由は、PHPエンジンと標準ライブラリのあらゆるところで文字列変換が行われており、そして全ての箇所が例外を正しく処理できるようになっているわけではないからです。

技術的観点からすると、この制限は無駄でしかありません。
なぜならば、文字列変換中の例外は、回復可能なエラーを例外に変換するエラーハンドラによって引き起こされる可能性があるからです。


```php
set_error_handler(function() {
    throw new Exception();
});
 
try {
    (string) new stdClass;
} catch (Exception $e) {
    echo "(string) threw an exception...\n";
}
```

実際、Symfonyは現在の制限を回避するために[この抜け穴](https://github.com/symfony/symfony/blob/1c110fa1f7e3e9f5daba73ad52d9f7e843a7b3ff/src/Symfony/Component/Debug/ErrorHandler.php#L457-L489)を使用しています。
残念ながら、これはPHP8で削除予定の[$errcontext](https://wiki.php.net/rfc/deprecations_php_7_2#errcontext_argument_of_error_handler)パラメータに依存しています。


## Proposal

`__toString()`からの例外スローを許可します。
もう致命的なエラーは発生しません。

さらにPHP7のエラーポリシーに基づいて、`could not be converted to string`および`__toString() must return a string value`というrecoverable fatal errorを、適切なError exceptionに変更します。


## Extension Guidelines

エクステンション開発者は、文字列変換からの例外を適切に処理するため、以下のガイドラインを考慮に入れてください。

・`zval_get_string()`、`convert_to_string()`および類似の関数は、例外を生成した場合でも文字列を生成します。この文字列を含めてください。必ずしも従う必要はありませんが、そうすることができます。
・文字列変換がエラーハンドラによって例外になった場合の結果は、オブジェクトから文字列への変換の場合は空文字列、配列の場合は"Array"という文字列になります。これは以前と同じ動作です。
・通常は`(EG(exception))`で例外がスローされたかどうかを確認すれば十分です。

```cpp
zend_string *str = zval_get_string(val);
if (EG(exception)) {
    // リソース解放など
    return;
}
```

・失敗する可能性のある操作を行うヘルパーAPIが多数追加されます。

```cpp
// zval_get_string()に似ているが、変換に失敗したらNULLを返す
zend_string *str = zval_try_get_string(val);
if (!str) {
    // リソース解放とか
    return;
}
// Main code.
zend_string_release(str);
 
// zval_get_tmp_string()に似ているが、変換に失敗したらNULLを返す
zend_string *tmp, *str = zval_try_get_tmp_string(val, &tmp);
if (!str) {
    // リソース解放とか
    return;
}
// Main code.
zend_tmp_string_release(tmp);

// convert_to_string()に似ているが、変換に成功したか否かのbooleanを返す
if (!try_convert_to_string(val)) {
    // リソース解放とか
    return;
}
// Main code.
```

・`try_convert_to_string()`は、変換失敗時には元の値を返しません。そのため、これを使った方が、`convert_to_string()`と例外チェックを使うより安全です。
・あらゆる文字列変換操作にチェックを行うことができますが、チェックを省略しても通常は余剰な警告が発生するだけです。気をつけて実装しなければならないのは、主にデータベースのような、永続的な構造を変更する操作です。

## Backward Incompatible Changes

`recoverable fatal error`から`Error exception`への変更は、破壊的変更です。

## Vote

投票開始は2019/05/22、終了が2019/06/05、投票数の2/3+1の賛成票で受理されます。

2019/05/27現在、賛成32反対0で、ほぼ確実に導入決定です。


# 感想

これによって、あらゆるメソッドから例外を出すことができるようになりました。


さて、なんでこんな簡単そうな変更が今まで残っていたの？
かというと、変更が簡単ではないからです。

最初に報告があったのはなんと[2011年1月](https://bugs.php.net/bug.php?id=53648)であり、2012年にはあのNikitaが[実装が大変なんじゃよー](https://bugs.php.net/bug.php?id=53648#1354639310)と言っています。
しかし結局[全てのcommit](https://github.com/php/php-src/pull/3887/commits)がNikita自身の手によって行われました。
最終的な修正は[111ファイル1000行以上](https://github.com/php/php-src/pull/3887/files)に及びます。
どんだけ働いてんだ[この人](https://github.com/php/php-src/commits?author=nikic)。

PHPフォーラムは基本的に[保守的な人が多く](https://qiita.com/rana_kualu/items/644def9847b116c8f108)、破壊的な変更はあまり受理されない傾向にあるのですが、Nikitaについては既に、Nikitaが言うなら仕方ないというレベルに達しているようです。
[三項演算子のDeprecate](https://qiita.com/rana_kualu/items/0de1e406f72c0ef86432)とか、他の人が提案してたら絶対通らなかったよね。


しかしこれ、てっきり[uninitialized](https://qiita.com/rana_kualu/items/9b4502d229d19635ef34#%E5%88%9D%E6%9C%9F%E5%80%A4unset)を`__toString()`したときのために作ったものなのかと思ったのですが、特に関係なかったみたいですね。


そして修正規模のわりに、あまり重要な使いどころが思いつかないというか、そもそも個人的には`__toString()`自体まず使うことがないですね。
デバッグには`var_dump()`という超絶便利関数がありますし、文字列化したいなら適当にメソッド生やします。