33
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PHPの緩やかな比較の実態

Last updated at Posted at 2021-11-02

概要

PHPには等価演算子が2つあります。「==」と「===」です。
前者は緩やかな比較、後者は厳密な比較が行われます。
PHPerの方ならよくご存知だと思います。
今回は前者の緩やかな比較を実態がどうなっているのかを追って見たいと思います。

緩やかな比較のカオスさ

例:

<?php
var_dump(0 == '00');
var_dump('00' == '0000');
var_dump('01' == '001');

出力:

bool(true)
bool(true)
bool(true)

はい、意味わかりません。
1, 2行目は理解できないこともありませんが
3行目が何故trueになるのか、厳密に説明するにはどうしたらいいでしょうか。
この記事を読んで理解し説明できるようになって貰えれば嬉しいです。

緩やかな比較の実体はどこにあるのか

言語仕様に関する説明は ドキュメント を読めば良いのですが、
最も正確なドキュメントは何でしょう。
それは ソースコード です。
ソースコードとそれを説明しているドキュメントに差異があっても、そのソースコードで動いている以上はソースコードが正確なドキュメントになります。
PHPの緩やかな比較を行っているソースコード本体を見てみましょう。
今回はPHP8.0.9のソースコードを読んでいこうと思います。

ソースコードの落とし方

まずは普通にgit cloneします

git clone https://github.com/php/php-src

今のphp-srcの最新はPHP8.1.4のRCになっているので、8.0.9にアップデートされたコミットを特定してチェックアウトしてあげます。

git checkout a5368c55be0a42af9fdc2c76fb0c2a06a42ddf36

PHPの緩やかな比較は動作を厳密に説明できる人しか使ってはいけません。

該当ソースに辿り着く方法

ノーヒントでPHPのソースコードを読んでも、目的の場所にたどり着けなければ意味がないです。

言語特有の演算子を文字列でgrepやIDEのファイル検索をしてみる(<=> とか

結果:

php-src/Zend/zend_language_parser.y
%token T_SPACESHIP "'<=>'"

なんかそれっぽいファイルが見つかりました。
PHPの演算子を文字列として定義している根本はZend/zend_language_parser.yにありそうです。
※.yの拡張子のファイルは構文解析機を生成するために、Yaccと言うツールのファイルの演算子です。
Zend/zend_language_parser.yを開いてみます。

Zend/zend_language_parser.y
%token T_IS_EQUAL     "'=='"
%token T_IS_NOT_EQUAL "'!='"
%token T_IS_IDENTICAL "'==='"
%token T_IS_NOT_IDENTICAL "'!=='"

宇宙船演算子のすぐ上に怪しい行がありました。
目的の「==」はT_IS_EQUALという名前が付いているみたいです。
Zend/zend_language_parser.y内でT_IS_EQUALを検索すると

php-src/Zend/zend_language_parser.y
|	expr T_IS_EQUAL expr
			{ $$ = zend_ast_create_binary_op(ZEND_IS_EQUAL, $1, $3); }

こんなソースが見つかります。
どうやらZEND_IS_EQUALという名前で中間コードに変換されるみたいです。

ZEND_IS_EQUALと言うワードを複数ファイル検索すると、
Zend/zend_opcode.cにこんな行がありました。

Zend/zend_opcode.c
case ZEND_IS_EQUAL:

Zend/zend_opcode.cの中身は以下の通りです。

php-src/Zend/zend_opcode.c
END_API binary_op_type get_binary_op(int opcode)
{
	switch (opcode) {
		case ZEND_ADD:
			return (binary_op_type) add_function;
		case ZEND_SUB:
			return (binary_op_type) sub_function;
		case ZEND_MUL:
			return (binary_op_type) mul_function;
		case ZEND_POW:
			return (binary_op_type) pow_function;
		case ZEND_DIV:
			return (binary_op_type) div_function;
		case ZEND_MOD:
			return (binary_op_type) mod_function;
		case ZEND_SL:
			return (binary_op_type) shift_left_function;
		case ZEND_SR:
			return (binary_op_type) shift_right_function;
		case ZEND_FAST_CONCAT:
		case ZEND_CONCAT:
			return (binary_op_type) concat_function;
		case ZEND_IS_IDENTICAL:
		case ZEND_CASE_STRICT:
			return (binary_op_type) is_identical_function;
		case ZEND_IS_NOT_IDENTICAL:
			return (binary_op_type) is_not_identical_function;
		case ZEND_IS_EQUAL:
		case ZEND_CASE:
			return (binary_op_type) is_equal_function;
		case ZEND_IS_NOT_EQUAL:
			return (binary_op_type) is_not_equal_function;
		case ZEND_IS_SMALLER:
			return (binary_op_type) is_smaller_function;
		case ZEND_IS_SMALLER_OR_EQUAL:
			return (binary_op_type) is_smaller_or_equal_function;
		case ZEND_SPACESHIP:
			return (binary_op_type) compare_function;
		case ZEND_BW_OR:
			return (binary_op_type) bitwise_or_function;
		case ZEND_BW_AND:
			return (binary_op_type) bitwise_and_function;
		case ZEND_BW_XOR:
			return (binary_op_type) bitwise_xor_function;
		case ZEND_BOOL_XOR:
			return (binary_op_type) boolean_xor_function;
		default:
			ZEND_UNREACHABLE();
			return (binary_op_type) NULL;
	}
}

こんなのがいました。
ZEND_IS_EQUALはis_equal_functionという関数に対応しているようですね。
is_equal_functionで検索をかけてみます。

php-src/Zend/zend_operators.c
ZEND_API int ZEND_FASTCALL is_equal_function(zval *result, zval *op1, zval *op2)

Zend/zend_operators.cに辿り着きました。
ノーヒントでも、IDEのファイル検索で大した時間をかけずにほしい情報まで辿り着けます。
ライブラリの中身を調べたいとき等にも使えるので参考にしてみてください。

緩やかな比較の正体見たり!

まず、型違いでfalseになります。
型が一致した上で、それぞれの型でシンプルな比較をしています。

php-src/Zend/zend_operators.c
/* == の処理 */
ZEND_API zend_result ZEND_FASTCALL is_equal_function(zval *result, zval *op1, zval *op2) /* {{{ */
{
	ZVAL_BOOL(result, zend_compare(op1, op2) == 0);
	return SUCCESS;
}
/* }}} */
php-src/Zend/zend_operators.c
ZEND_API int ZEND_FASTCALL zend_compare(zval *op1, zval *op2) /* {{{ */
{
	int converted = 0;
	zval op1_copy, op2_copy;

	while (1) {
		switch (TYPE_PAIR(Z_TYPE_P(op1), Z_TYPE_P(op2))) {
			case TYPE_PAIR(IS_LONG, IS_LONG):
				return Z_LVAL_P(op1)>Z_LVAL_P(op2)?1:(Z_LVAL_P(op1)<Z_LVAL_P(op2)?-1:0);

			case TYPE_PAIR(IS_DOUBLE, IS_LONG):
				return ZEND_NORMALIZE_BOOL(Z_DVAL_P(op1) - (double)Z_LVAL_P(op2));

			case TYPE_PAIR(IS_LONG, IS_DOUBLE):
				return ZEND_NORMALIZE_BOOL((double)Z_LVAL_P(op1) - Z_DVAL_P(op2));

			case TYPE_PAIR(IS_DOUBLE, IS_DOUBLE):
				if (Z_DVAL_P(op1) == Z_DVAL_P(op2)) {
					return 0;
				} else {
					return ZEND_NORMALIZE_BOOL(Z_DVAL_P(op1) - Z_DVAL_P(op2));
				}

			case TYPE_PAIR(IS_ARRAY, IS_ARRAY):
				return zend_compare_arrays(op1, op2);

			case TYPE_PAIR(IS_NULL, IS_NULL):
			case TYPE_PAIR(IS_NULL, IS_FALSE):
			case TYPE_PAIR(IS_FALSE, IS_NULL):
			case TYPE_PAIR(IS_FALSE, IS_FALSE):
			case TYPE_PAIR(IS_TRUE, IS_TRUE):
				return 0;

			case TYPE_PAIR(IS_NULL, IS_TRUE):
				return -1;

			case TYPE_PAIR(IS_TRUE, IS_NULL):
				return 1;

			case TYPE_PAIR(IS_STRING, IS_STRING):
				if (Z_STR_P(op1) == Z_STR_P(op2)) {
					return 0;
				}
				return zendi_smart_strcmp(Z_STR_P(op1), Z_STR_P(op2));

			case TYPE_PAIR(IS_NULL, IS_STRING):
				return Z_STRLEN_P(op2) == 0 ? 0 : -1;

			case TYPE_PAIR(IS_STRING, IS_NULL):
				return Z_STRLEN_P(op1) == 0 ? 0 : 1;

			case TYPE_PAIR(IS_LONG, IS_STRING):
				return compare_long_to_string(Z_LVAL_P(op1), Z_STR_P(op2));

			case TYPE_PAIR(IS_STRING, IS_LONG):
				return -compare_long_to_string(Z_LVAL_P(op2), Z_STR_P(op1));

			case TYPE_PAIR(IS_DOUBLE, IS_STRING):
				if (zend_isnan(Z_DVAL_P(op1))) {
					return 1;
				}

				return compare_double_to_string(Z_DVAL_P(op1), Z_STR_P(op2));

			case TYPE_PAIR(IS_STRING, IS_DOUBLE):
				if (zend_isnan(Z_DVAL_P(op2))) {
					return 1;
				}

				return -compare_double_to_string(Z_DVAL_P(op2), Z_STR_P(op1));

			case TYPE_PAIR(IS_OBJECT, IS_NULL):
				return 1;

			case TYPE_PAIR(IS_NULL, IS_OBJECT):
				return -1;

			default:
				if (Z_ISREF_P(op1)) {
					op1 = Z_REFVAL_P(op1);
					continue;
				} else if (Z_ISREF_P(op2)) {
					op2 = Z_REFVAL_P(op2);
					continue;
				}

				if (Z_TYPE_P(op1) == IS_OBJECT
				 && Z_TYPE_P(op2) == IS_OBJECT
				 && Z_OBJ_P(op1) == Z_OBJ_P(op2)) {
					return 0;
				} else if (Z_TYPE_P(op1) == IS_OBJECT) {
					return Z_OBJ_HANDLER_P(op1, compare)(op1, op2);
				} else if (Z_TYPE_P(op2) == IS_OBJECT) {
					return Z_OBJ_HANDLER_P(op2, compare)(op1, op2);
				}

				if (!converted) {
					if (Z_TYPE_P(op1) < IS_TRUE) {
						return zval_is_true(op2) ? -1 : 0;
					} else if (Z_TYPE_P(op1) == IS_TRUE) {
						return zval_is_true(op2) ? 0 : 1;
					} else if (Z_TYPE_P(op2) < IS_TRUE) {
						return zval_is_true(op1) ? 1 : 0;
					} else if (Z_TYPE_P(op2) == IS_TRUE) {
						return zval_is_true(op1) ? 0 : -1;
					} else {
						op1 = _zendi_convert_scalar_to_number_silent(op1, &op1_copy);
						op2 = _zendi_convert_scalar_to_number_silent(op2, &op2_copy);
						if (EG(exception)) {
							return 1; /* to stop comparison of arrays */
						}
						converted = 1;
					}
				} else if (Z_TYPE_P(op1)==IS_ARRAY) {
					return 1;
				} else if (Z_TYPE_P(op2)==IS_ARRAY) {
					return -1;
				} else {
					ZEND_UNREACHABLE();
					zend_throw_error(NULL, "Unsupported operand types");
					return 1;
				}
		}
	}
}
/* }}} */

長い...

もうなんかイヤな予感がしますね。
中身を要約していくと、演算子の左右のオペランドの型の組み合わせに応じて、良い感じに処理を分岐して返しているみたいです。

数値同士の比較は普通に行われていますが、文字列の比較はなんか関数が呼ばれてます。

php-src/Zend/zend_operators.c
			case TYPE_PAIR(IS_STRING, IS_STRING):
				if (Z_STR_P(op1) == Z_STR_P(op2)) {
					return 0;
				}
				return zendi_smart_strcmp(Z_STR_P(op1), Z_STR_P(op2));

zendi_smart_strcmp と言う怪しい関数が呼ばれています。
どんな奴か見てみましょう。

php-src/Zend/zend_operators.c
ZEND_API int ZEND_FASTCALL zendi_smart_strcmp(zend_string *s1, zend_string *s2) /* {{{ */
{
	zend_uchar ret1, ret2;
	int oflow1, oflow2;
	zend_long lval1 = 0, lval2 = 0;
	double dval1 = 0.0, dval2 = 0.0;

	if ((ret1 = is_numeric_string_ex(s1->val, s1->len, &lval1, &dval1, false, &oflow1, NULL)) &&
		(ret2 = is_numeric_string_ex(s2->val, s2->len, &lval2, &dval2, false, &oflow2, NULL))) {
#if ZEND_ULONG_MAX == 0xFFFFFFFF
		if (oflow1 != 0 && oflow1 == oflow2 && dval1 - dval2 == 0. &&
			((oflow1 == 1 && dval1 > 9007199254740991. /*0x1FFFFFFFFFFFFF*/)
			|| (oflow1 == -1 && dval1 < -9007199254740991.))) {
#else
		if (oflow1 != 0 && oflow1 == oflow2 && dval1 - dval2 == 0.) {
#endif
			/* both values are integers overflown to the same side, and the
			 * double comparison may have resulted in crucial accuracy lost */
			goto string_cmp;
		}
		if ((ret1 == IS_DOUBLE) || (ret2 == IS_DOUBLE)) {
			if (ret1 != IS_DOUBLE) {
				if (oflow2) {
					/* 2nd operand is integer > LONG_MAX (oflow2==1) or < LONG_MIN (-1) */
					return -1 * oflow2;
				}
				dval1 = (double) lval1;
			} else if (ret2 != IS_DOUBLE) {
				if (oflow1) {
					return oflow1;
				}
				dval2 = (double) lval2;
			} else if (dval1 == dval2 && !zend_finite(dval1)) {
				/* Both values overflowed and have the same sign,
				 * so a numeric comparison would be inaccurate */
				goto string_cmp;
			}
			dval1 = dval1 - dval2;
			return ZEND_NORMALIZE_BOOL(dval1);
		} else { /* they both have to be long's */
			return lval1 > lval2 ? 1 : (lval1 < lval2 ? -1 : 0);
		}
	} else {
		int strcmp_ret;
string_cmp:
		strcmp_ret = zend_binary_strcmp(s1->val, s1->len, s2->val, s2->len);
		return ZEND_NORMALIZE_BOOL(strcmp_ret);
	}
}
/* }}} */

うえ〜
処理の内容は、

  • 入力された文字列が「数値として評価できる場合は数値として扱い、比較する」
  • 数値として評価出来ない場合は文字列同士の比較を行う

strcmpって命名はなんなんだ...

つまりstrcmpとは名ばかりで、文字列として異なっていても数値として合っていればtrue です。

誰だこんなクソみたいな仕様思いついたやつ

これを踏まえてもう一度コードを見てみましょう

ソースコード:

<?php
var_dump(0 == '00');
var_dump('00' == '0000');
var_dump('01' == '001');

出力:

bool(true)
bool(true)
bool(true)

これで冒頭のコードの実行結果の謎が溶けたと思います。

厳密な比較のソースはどこ?

Zend/zend_operators.cにいます。

php-src/Zend/zend_operators.c
ZEND_API zend_bool ZEND_FASTCALL zend_is_identical(zval *op1, zval *op2) /* {{{ */
{
	if (Z_TYPE_P(op1) != Z_TYPE_P(op2)) {
		return 0;
	}
	switch (Z_TYPE_P(op1)) {
		case IS_NULL:
		case IS_FALSE:
		case IS_TRUE:
			return 1;
		case IS_LONG:
			return (Z_LVAL_P(op1) == Z_LVAL_P(op2));
		case IS_RESOURCE:
			return (Z_RES_P(op1) == Z_RES_P(op2));
		case IS_DOUBLE:
			return (Z_DVAL_P(op1) == Z_DVAL_P(op2));
		case IS_STRING:
			return zend_string_equals(Z_STR_P(op1), Z_STR_P(op2));
		case IS_ARRAY:
			return (Z_ARRVAL_P(op1) == Z_ARRVAL_P(op2) ||
				zend_hash_compare(Z_ARRVAL_P(op1), Z_ARRVAL_P(op2), (compare_func_t) hash_zval_identical_function, 1) == 0);
		case IS_OBJECT:
			return (Z_OBJ_P(op1) == Z_OBJ_P(op2));
		default:
			return 0;
	}
}
/* }}} */

まず最初に型違いでfalseになります。
型が一致した上で、それぞれの型でシンプルな比較をしています。
「==」 のソースコードより遥かにシンプルです。

まとめ

自分も今までなんとなくでしか、理解してなかったですが、今回の調査でどれだけ危険な代物かよく理解できました。
皆さんPHPの比較演算子は黙って厳格な比較を使いましょう

33
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?