Skip to content

Inconsistency between (bool)$foo and $foo==true for enums and other "uncomparable" objects #16954

Closed
@IMSoP

Description

@IMSoP

Description

Problem Description

For enums and several internal object types, casting to boolean yields true, but comparing against true (with ==) does not.

This leads to some very confusing inconsistencies, as user "Crapulam" pointed out on Reddit:

enum TestEnum
{
    case A;
}

$truthy = true;

var_dump(TestEnum::A == true); // bool(true)
var_dump(TestEnum::A == $truthy); // bool(false)

Demo: https://3v4l.org/OOTcA

What's happening is that the compiler has a special case to treat $foo == true as (bool)$foo, so ends up running the object's cast_object handler instead of the compare handler. The enum implementation has inconsistent handler definitions, leading to the wonky result.

Note that this is not unique to enums, but happens for various other objects, e.g. XMLParser: https://3v4l.org/ZqWRK

Analysis

The base class entry for enums sets the compare handler to zend_objects_not_comparable, causing all comparisons to be false; but it leaves other handlers, including cast_object at default:

php-src/Zend/zend_enum.c

Lines 167 to 169 in be27cbd

memcpy(&zend_enum_object_handlers, &std_object_handlers, sizeof(zend_object_handlers));
zend_enum_object_handlers.clone_obj = NULL;
zend_enum_object_handlers.compare = zend_objects_not_comparable;

That leaves the cast handler as zend_std_cast_object_tostring, which handles checking for __toString, but also treats all objects as true when cast or coerced to boolean:

case _IS_BOOL:
ZVAL_TRUE(writeobj);
return SUCCESS;

The zend_objects_not_comparable handler doesn't have any logic at all:

ZEND_API int zend_objects_not_comparable(zval *o1, zval *o2)
{
return ZEND_UNCOMPARABLE;
}

That means it skips the normal logic in zend_std_compare_objects which would call the cast handler when comparing to a non-object:

if (Z_TYPE_P(o1) != Z_TYPE_P(o2)) {
/* Object and non-object */
zval casted;

As a final piece of the puzzle, this special case was first added by Nikita in 8.0.3 to fix a regression caused by resource-to-object conversion on sockets: cb9785a It's now used for a bunch of different "opaque" objects.

The specific requirement was that two different sockets should not compare equal with ==

Suggested Fix

Theoretically, we could keep the current comparison, and fix the cast behaviour to match. This would be straight-forward for enums, because they explicitly forbid __toString, so could just error for all casts. However, this would be a rather visible BC break, because if ( $foo ) is frequently used as a lazy emptiness test.

Probably the safer way around is to fix the comparison, by making the compare handler for some or all "non-comparable" objects still use the standard casting logic when comparing to non-objects.

My gut feel is that we could just expand the zend_objects_not_comparable callback to share the "object and non-object" block from the top of zend_std_compare_objects.

That's still a small compatibility break if someone is relying on the current behaviour for any of the affected objects. Probably safe for 8.5, but not 8.4.x

PHP Version

PHP 8.0.3 onwards

Operating System

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions