PHPの奇妙なincrement/decrementの挙動

こちらの記事はPHP Advent Calendar10日目の記事です。

# この記事について

今年の11月23日に公式リリースされたPHP8.3で導入されたRFCの中にこんなRFCがありました。

https://wiki.php.net/rfc/saner-inc-dec-operators

**Path to Saner Increment/Decrement operators**というタイトルにもあるようにこのRFCはIncrement/Decrement演算子の挙動についてもの申したいことがあるそう。そしてイントロダクションでこのRFCはこんなことを言っています。

> PHP's increment and decrement operators can have some surprising behaviours when used with types other than int and float. Various previous attempts have been made to improve the behaviour of these operators, but none have been implemented. The goal of this RFC is to normalize the behaviour of $v++ and $v-- to be the same as $v += 1 and $v -= 1, respectively.

要約すると、PHPのIncrement/Decrement演算子はintとfloat以外の型での挙動がおかしい（**some surprising behaviours**）ため、このRFCではこれを正常にすること（`$v++`や`$v--`の挙動を`$v += 1`や`$v -= 1`と同じようにすること）が目的である、と述べています。

そこでこの記事では、このRFCが指摘すること（**some surprising behaviours**）は何であって、それがPHP8.3でどのようになるのかについて紹介していこうと思います。

# 挙動Quiz
PHP8.2までのIncrement/Decrement演算子の挙動について説明する前に、まず読者の皆さんにこれに関するクイズに答えていただきます。このクイズはPHPのコードから出力結果を答える形式となっています。
クイズで出すコードはとても簡単なので、コードを実行せずに答えてみてください。
実行環境：8.2.4 (cli) (built: Mar 16 2023 21:22:17) (NTS)

## Q1 int/float
```php
$int = '12';
var_dump($int+1);
$int = '12';
var_dump(++$int);

$float = '0.1';
var_dump($float+1);
$float = '0.1';
var_dump(++$float);
```
<details><summary>答え</summary>

```terminal
int(13)
int(13)

float(1.1)
float(1.1)
```
</details>


## Q2 null
```php
$null = null;
var_dump($null+1);
$null = null;
var_dump($null-1);

$null = null;
var_dump(++$null);
$null = null;
var_dump(--$null);
```

<details><summary>答え</summary>

```terminal
int(1)
int(-1)

int(1)
NULL
```
</details>

## Q3 boolean(bool)

```php
$true = true;
var_dump($true+1);
$true = true;
var_dump($true-1);

$false = false;
var_dump($false+1);
$false = false;
var_dump($false-1);

$true = true;
var_dump(++$true);
$true = true;
var_dump(--$true);

$false = false;
var_dump(++$false);
$false = false;
var_dump(--$false);
```

<details><summary>答え</summary>

```terminal
int(2)
int(0)

int(1)
int(-1)

bool(true)
bool(true)

bool(false)
bool(false)
```

</details>

## Q4 string

```php
$qiita = "qiita";
var_dump(++$qiita);
$qiita = "qiita";
var_dump(--$qiita);

$qiita = "qiita";
var_dump($qiita+1);
$qiita = "qiita";
var_dump($qiita-1);

$quiz = "quiz";
var_dump(++$quiz);
$quiz = "quiz";
var_dump(--$quiz);

$empty = "";
var_dump(++$empty);
$empty = "";
var_dump(--$empty);

$qiita9 = "qiita9";
var_dump(++$qiita9);
$qiita9 = "qiita9";
var_dump(--$qiita9);

$aBeforeSpace = "A ";
var_dump(++$aBeforeSpace);
$aAfterSpace = " A";
var_dump(++$aAfterSpace);

$dot = "quiz.9";
var_dump(++$dot);
$japanese = "クイズ";
var_dump(++$japanese);
```

<details><summary>答え</summary>

```terminal
string(5) "qiitb"
string(5) "qiita"

TypeError
TypeError

string(4) "quja"
string(4) "quiz"

string(1) "1"
int(-1)

string(6) "qiitb0"
string(6) "qiita9"

string(2) "A "
string(2) " B"

string(6) "quiz.0"
string(9) "クイズ"
```

</details>

# 挙動Quizの解説
ここからはクイズの解説を行いますが、大したことは書いてないので「挙動まとめ」まで読み飛ばしても問題ありません。


## Q1 int/float
文字列型であっても数値として表せる値のIncrement/Decrement演算子の結果は数値型の値の結果と同じになります。

## Q2 null
PHPにおいてnull型は0として表せる（有名な話ですね）ため、`null型 +=(-=) 1`の結果は数値型の値の結果と同じになります。しかし、`++null型`は`null型 += 1`と同じ値を返すのに対して`--null型`は何も変化しません。

## Q3 boolean(bool)
PHPにおいてtrueは1、falseは0として表せる（これも有名な話ですね）ため、`boolean型 +=(-=) 1`は数値型の値の結果と同じになります。しかし、`++boolean型`および`--boolean型`は何も変化しません。

## Q4 string
同じ文字列のデータであっても、空の文字列とそうでない文字列とでは挙動が変わります。
Increment演算子を空の文字列で実行した場合、文字列型の1を返します。そしてDecrement演算子を空の文字列で実行した場合、int型の-1を返します。んーややこしい...
Increment演算子を空ではない文字列で実行した場合、最後の文字がその文字の[ASCIIコードポイント](https://www.man7.org/linux/man-pages/man7/ascii.7.html)から一つ加算した文字に変換されます。もし加算の対象が`9、z、Z`の場合、前の文字のASCIIコードポイントを加算しながら`0、a、A`に変換されます。しかし加算対象の文字の前に`.(ドット)`がある場合、最後の文字のみ変換されます。またスペース文字は何も変化しません。そしてDecrement演算子の時は何も変化しません。またアルファベット以外の文字はIncrement/Decrement演算子において何も変化しません。

## 挙動まとめ
読者の皆さん、どれぐらい正解できましたか？もちろん全問正解できましたよね？
クイズをしてみて、皆さんはincrement/decrementの挙動についてどのように思いましたか？
increment/decrementができないものがあるばかりか、increment/decrementで型が変わったりするものがあるなど、挙動に統一感がなくて分かりにくいと思いませんでしたか？（私はそう思いました）
冒頭で紹介したRFCにあった"**some surprising behaviours**"とはまさにこの分かりにくさを指しています。
increment/decrementの型ごとの挙動を表にすると以下の通りです。

|type|+1|++|-1|--|
|:--|:--|:--|:--|:--|
|null|1|1|-1|null|
|true|2|true|0|true|
|false|1|false|-1|false|
|""|TypeError|"1"|TypeError|-1|
|"qiita"|TypeError|"qiitb"|TypeError|"qiita"|

# PHP8.3からどうなるのか？
RFCの指摘が分かったところで、次にこのRFCの提案によるPHP8.3のincrement/decrementの挙動の変化について、クイズのコードをもとに紹介していきます。
実行環境：8.3.0 (cli) (built: Nov 27 2023 21:24:30) (NTS)

## null

```php
<?php
$null = null;
var_dump($null+1);
$null = null;
var_dump($null-1);

$null = null;
var_dump(++$null);
$null = null;
var_dump(--$null);
?>
```
出力
```terminal
int(1)
int(-1)

int(1)
Warning: Decrement on type null has no effect, this will change in the next major version of PHP in ...
NULL
```
`var_dump(--$null)`の出力値は`NULL`のままですが、`Warning`が吐かれるようになりました。

## boolean(bool)
```php
<?php
$true = true;
var_dump($true+1);
$true = true;
var_dump($true-1);

$false = false;
var_dump($false+1);
$false = false;
var_dump($false-1);

$true = true;
var_dump(++$true);
$true = true;
var_dump(--$true);

$false = false;
var_dump(++$false);
$false = false;
var_dump(--$false);
?>
```
出力
```terminal
int(2)
int(0)

int(1)
int(-1)

Warning: Increment on type bool has no effect, this will change in the next major version of PHP in ...
bool(true)
Warning: Decrement on type bool has no effect, this will change in the next major version of PHP in ...
bool(true)

Warning: Increment on type bool has no effect, this will change in the next major version of PHP in ...
bool(false)
Warning: Decrement on type bool has no effect, this will change in the next major version of PHP in ...
bool(false)
```
出力値は変わりませんが、変化を与えないものに対しては`Warning`を吐くようになります。

## Q4 string
```php
<?php
$qiita = "qiita";
var_dump(++$qiita);
$qiita = "qiita";
var_dump(--$qiita);

$quiz = "quiz";
var_dump(++$quiz);
$quiz = "quiz";
var_dump(--$quiz);

$empty = "";
var_dump(++$empty);
$empty = "";
var_dump(--$empty);

$qiita9 = "qiita9";
var_dump(++$qiita9);
$qiita9 = "qiita9";
var_dump(--$qiita9);

$aBeforeSpace = "A ";
var_dump(++$aBeforeSpace);
$aAfterSpace = " A";
var_dump(++$aAfterSpace);

$dot = "quiz.9";
var_dump(++$dot);
$japanese = "クイズ";
var_dump(++$japanese);
?>
```
出力
```terminal
string(5) "qiitb"
Deprecated: Decrement on non-numeric string has no effect and is deprecated in ...
string(5) "qiita"

string(4) "quja"
Deprecated: Decrement on non-numeric string has no effect and is deprecated in ...
string(4) "quiz"

Deprecated: Increment on non-alphanumeric string is deprecated in ...
string(1) "1"
Deprecated: Decrement on empty string is deprecated as non-numeric in ...
int(-1)

string(6) "qiitb0"
Deprecated: Decrement on non-numeric string has no effect and is deprecated in ...
string(6) "qiita9"

Deprecated: Increment on non-alphanumeric string is deprecated in ...
string(2) "A "
Deprecated: Increment on non-alphanumeric string is deprecated in ...
string(2) " B"

Deprecated: Increment on non-alphanumeric string is deprecated in ...
string(6) "quiz.0"
Deprecated: Increment on non-alphanumeric string is deprecated in ...
string(9) "クイズ"
```
出力値は変わりませんが、変化を与えないもの、アルファベットまたは数字で扱えないもの（`non-alphanumeric`）、空の文字列の減算に対して`Deprecated(非推奨)`が吐かれるようになりました。

型ごとの出力結果を表にまとめると、
|value|+1|++|-1|--|
|:--|:--|:--|:--|:--|
|null|1|1|-1|null(Warning)|
|true|2|true(Warning)|0|true(Warning)|
|false|1|false(Warning)|-1|false(Warning)|
|""|TypeError|"1"(Deprecated)|TypeError|-1(Deprecated)|
|"qiita"|TypeError|"qiitb"|TypeError|"qiita"(Deprecated)|

出力値は変わらないけど、効果的な使い方をしていないとPHPから様々なことを言われるようになります。

またPHP8.3ではincrement/decrementの出力結果が変わるだけではなく、string型に新たな関数として`str_increment/str_decrement`が追加されます。

```php
<?php
$string = "qiita";
$string = str_increment($string);
var_dump($string);
$string = "qiita";
$string = str_decrement($string);
var_dump($string);

$string = "";
$string = str_increment($string);
var_dump($string);
?>
```
出力
```terminal
string(5) "qiitb"
string(5) "qiisz"

Fatal error: Uncaught ValueError: str_increment(): Argument #1 ($string) cannot be empty
```

`str_increment/str_decrement`は文字列データの加算と減算をします。また、`str_increment/str_decrement`の引数が`non-alphanumeric`の場合、`ValueError`を吐くようになります。
つまり、`str_increment/str_decrement`によって今までできなかった文字列データの減算ができるだけではなく、変化を与えない挙動を防ぐことができます。（`" A"`や`"quiz.9"`といったincrementによって変化する値に対しても`ValueError`を吐いてしまうため、そのような値が入る場合は使わない方が良いかもしれません。）

# 今後はどうなるのか？
ここまでincrement/decrementに関するPHP8.3での変化を紹介しましたが、次のPHPのバージョンアップにおけるincrement/decrementの挙動についても言及があったので、最後に紹介しておきます。

> In a follow-up minor version of PHP the following changes will take place:
・Deprecate using the increment operator with non-numeric strings.

PHPの次のマイナーバージョンアップでは文字列データのincrementが非推奨になります。つまり8.3.0において何の問題もなかった以下のようなコードに対して、`Deprecated`が吐かれます。
```php
$qiita = "qiita";
++$qiita; // Deprecated: Increment on non-numeric string is deprecated
var_dump($qiita); // string(5) "qiitb"
```

>In the next major version of PHP the following changes will take place:
・Values of type bool and null are first cast to integers
・Non-numeric string values throw a TypeError

次のメジャーバージョンアップでは、bool型とnull型はincrement/decrementにおいてInt型として扱われ、数値ではない値(`Non-numeric string values`)では`TypeError`が吐かれるようになります。つまり、Q4 stringの中でPHP8.3において`Deprecated`を吐いていたコードはほとんどエラーとなります。

# 終わりに
この記事ではPHP8.2までのincrement/decrementの挙動とPHP8.3以降の変化について取り上げました。
PHP8.3の新機能については@rana_kualuさんが全て取り上げた記事を書いているので、PHP8.3について気になる方がいればそちらも見てみてください～

https://qiita.com/rana_kualu/items/f03efb9810c3ac5b2ab6