Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 80 additions & 7 deletions Zend/Optimizer/dfa_pass.c
Original file line number Diff line number Diff line change
Expand Up @@ -252,15 +252,68 @@ static void zend_ssa_remove_nops(zend_op_array *op_array, zend_ssa *ssa, zend_op
free_alloca(shiftlist, use_heap);
}

static bool safe_instanceof(const zend_class_entry *ce1, const zend_class_entry *ce2) {
if (ce1 == ce2) {
/* Returns true if every instance of ce1 (or a subclass) is provably an instance
* of the target class. target_ce is the resolved class entry for the target
* when available; it may be NULL for a class declared in the same compilation
* unit that is not yet linked, in which case we fall back to matching
* target_lc against ce1's own declared parent and interfaces. */
static bool safe_instanceof(
const zend_class_entry *ce1,
const zend_class_entry *target_ce,
const zend_string *target_lc,
const zend_script *script,
const zend_op_array *op_array
) {
if (target_ce && ce1 == target_ce) {
return true;
}
if (!(ce1->ce_flags & ZEND_ACC_LINKED)) {
/* This case could be generalized, similarly to unlinked_instanceof */
return false;
if (zend_string_equals_ci(ce1->name, target_lc)) {
return true;
}
if (ce1->ce_flags & ZEND_ACC_LINKED) {
return target_ce && instanceof_function(ce1, target_ce);
}
return instanceof_function(ce1, ce2);

for (uint32_t i = 0; i < ce1->num_interfaces; i++) {
const zend_class_entry *iface;
if (ce1->ce_flags & ZEND_ACC_RESOLVED_INTERFACES) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status note for reviewers, carrying over @Girgias's "not sure how to test we hit
this case" and @arnaud-lb's MAY_BE_THIS suggestion from #21948.

This generalized safe_instanceof() is currently not reached for return $this.

can_elide_return_type_check() only continues when use_info->ce is set, and inference assigns no ce to $this (there is no MAY_BE_THIS), so for return $this we bail before ever calling can_elide_list_type() / safe_instanceof(). That's why the inherited_interface* tests still show VERIFY_RETURN_TYPE after the optimizer.

I also couldn't reach the unlinked branches via a non-$this value: when inference knows the operand's class it's already ZEND_ACC_LINKED (so the instanceof_function() fast path is taken), and when the class is unlinked/conditional use_info->ce is unset and we bail first. So this RESOLVED_INTERFACES recursion in particular is currently unexercised.

Making the optimizer side actually elide the inherited-interface $this cases needs the MAY_BE_THIS inference @arnaud-lb suggested, so the $this SSA var carries the scope ce.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, MAY_BE_THIS is actually already there, the problem seems to be class resolution, not inference.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, FETCH_THIS already sets use_info->ce = op_array->scope (with is_instanceof), so we do reach can_elide_return_type_check() with the scope class for return $this. Nothing was elided because safe_instanceof() only worked when the target interface/parent resolved via zend_optimizer_get_class_entry(), and for a not-yet-linked class declared in the same file that returns NULL, so the recursion had nothing to walk.

The fix: for an unlinked ce1, match the target against the class's own name / interface_names / parent_name directly (no resolution needed, same idea as the compile-time helper), and walk the parent chain, recursing through any link that does resolve (internal / preloaded / linked classes). That makes the unlinked path reachable, and lets the optimizer prove transitive interface cases the compile-time pass can't.

That also answers the "how to test this": the new tests use a conditionally-declared class (kept unlinked) whose parent/interface is an internal class (so it resolves), class C extends ArrayObject returning Traversable, and class D implements IteratorAggregate returning Traversable. Both show VERIFY_RETURN_TYPE before the optimizer and elided after.

Two latent bugs turned up once the parent walk reached real classes:

  • ce1->parent / ce1->parent_name are a union; for an unlinked class the field holds the name string, so reading ce1->parent passed a zend_string* to safe_instanceof() as a zend_class_entry*, causing a segfault. Resolved via parent_name instead (this path is unlinked-only).
  • ce1->interfaces[i] can be NULL in the RESOLVED_INTERFACES-but-unlinked state (the existing comment warns about it); guarded it.

Soundness is unchanged: a $this that doesn't satisfy the return type is still not elided and TypeErrors at runtime. Full Zend + opcache + reflection pass, including under tracing JIT.

/* Unlike the normal instanceof_function(), we have to perform a recursive
* check here, as the parent interfaces might not have been fully copied yet. */
iface = ce1->interfaces[i];
} else {
if (zend_string_equals_ci(ce1->interface_names[i].lc_name, target_lc)) {
return true;
}

iface = zend_optimizer_get_class_entry(script, op_array, ce1->interface_names[i].lc_name);
}

/* Skip if unresolvable/not-yet-copied, or the class implements itself. */
if (!iface || iface == ce1) {
continue;
}

if (safe_instanceof(iface, target_ce, target_lc, script, op_array)) {
return true;
}
}

/* ce1 is unlinked here (the linked case returned above), so the
* parent/parent_name union holds the as-yet-unresolved parent name. */
if (ce1->parent_name) {
if (zend_string_equals_ci(ce1->parent_name, target_lc)) {
return true;
}

zend_string *parent_lc = zend_string_tolower(ce1->parent_name);
const zend_class_entry *parent = zend_optimizer_get_class_entry(script, op_array, parent_lc);
zend_string_release(parent_lc);
if (parent && parent != ce1 && safe_instanceof(parent, target_ce, target_lc, script, op_array)) {
return true;
}
}

return false;
}

static inline bool can_elide_list_type(
Expand All @@ -279,8 +332,8 @@ static inline bool can_elide_list_type(
if (ZEND_TYPE_HAS_NAME(*single_type)) {
zend_string *lcname = zend_string_tolower(ZEND_TYPE_NAME(*single_type));
const zend_class_entry *ce = zend_optimizer_get_class_entry(script, op_array, lcname);
bool result = use_info->ce && safe_instanceof(use_info->ce, ce, lcname, script, op_array);
zend_string_release(lcname);
bool result = ce && safe_instanceof(use_info->ce, ce);
if (result == !is_intersection) {
return result;
}
Expand All @@ -289,6 +342,16 @@ static inline bool can_elide_list_type(
return is_intersection;
}

/* Whether the SSA variable is the result of a ZEND_FETCH_THIS, i.e. is $this. */
static bool zend_ssa_var_is_this(const zend_op_array *op_array, const zend_ssa *ssa, int var) {
if (var < 0) {
return false;
}

int def = ssa->vars[var].definition;
return def >= 0 && op_array->opcodes[def].opcode == ZEND_FETCH_THIS;
}

static inline bool can_elide_return_type_check(
const zend_script *script, zend_op_array *op_array, zend_ssa *ssa, zend_ssa_op *ssa_op) {
zend_arg_info *arg_info = &op_array->arg_info[-1];
Expand All @@ -309,6 +372,16 @@ static inline bool can_elide_return_type_check(
return true;
}

/* A `static` return type only accepts the late-static-bound class. Returning
* $this always satisfies it, since $this is by definition an instance of
* `static`. Closures are excluded as they may be rebound to another scope. */
if (disallowed_types == MAY_BE_OBJECT
&& (ZEND_TYPE_PURE_MASK(arg_info->type) & MAY_BE_STATIC)
&& !(op_array->fn_flags & ZEND_ACC_CLOSURE)
&& zend_ssa_var_is_this(op_array, ssa, ssa_op->op1_use)) {
return true;
}

if (disallowed_types == MAY_BE_OBJECT && use_info->ce && ZEND_TYPE_IS_COMPLEX(arg_info->type)) {
return can_elide_list_type(script, op_array, use_info, arg_info->type);
}
Expand Down
14 changes: 14 additions & 0 deletions Zend/tests/return_types/025_2.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
--TEST--
Return type of self is allowed in closure but $this return value must be checked as closure might not be bound to a class
--FILE--
<?php

$c = function(): self { return $this; };
try {
var_dump($c());
} catch (Throwable $e) {
echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}
?>
--EXPECT--
Error: Using $this when not in object context
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
--TEST--
Return type check elision for direct interface return type and $this in class method when interface extends another one
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.optimization_level=-1
opcache.opt_debug_level=0x30000
--EXTENSIONS--
opcache
--FILE--
<?php

interface I1 {}
interface I2 extends I1 {}

class C implements I2 {
public function foo(): I2 {
return $this;
}
}

?>
--EXPECTF--
$_main:
; (lines=3, args=0, vars=0, tmps=0)
; (before optimizer)
; %s:1-13
; return [] RANGE[0..0]
0000 DECLARE_CLASS string("i2")
0001 DECLARE_CLASS string("c")
0002 RETURN int(1)

C::foo:
; (lines=5, args=0, vars=0, tmps=1)
; (before optimizer)
; %s:7-9
; return [] RANGE[0..0]
0000 T0 = FETCH_THIS
0001 VERIFY_RETURN_TYPE T0
0002 RETURN T0
0003 VERIFY_RETURN_TYPE
0004 RETURN null
LIVE RANGES:
0: 0001 - 0002 (tmp/var)

$_main:
; (lines=3, args=0, vars=0, tmps=0)
; (after optimizer)
; %s:1-13
0000 DECLARE_CLASS string("i2")
0001 DECLARE_CLASS string("c")
0002 RETURN int(1)

C::foo:
; (lines=2, args=0, vars=0, tmps=1)
; (after optimizer)
; %s:7-9
0000 T0 = FETCH_THIS
0001 RETURN T0
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
--TEST--
Return type check elision for direct interface return type and $this in class method
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.optimization_level=-1
opcache.opt_debug_level=0x30000
--EXTENSIONS--
opcache
--FILE--
<?php

interface I1 {}

class C implements I1 {
public function foo(): I1 {
return $this;
}
}

?>
--EXPECTF--
$_main:
; (lines=2, args=0, vars=0, tmps=0)
; (before optimizer)
; %s:1-12
; return [] RANGE[0..0]
0000 DECLARE_CLASS string("c")
0001 RETURN int(1)

C::foo:
; (lines=5, args=0, vars=0, tmps=1)
; (before optimizer)
; %s:6-8
; return [] RANGE[0..0]
0000 T0 = FETCH_THIS
0001 VERIFY_RETURN_TYPE T0
0002 RETURN T0
0003 VERIFY_RETURN_TYPE
0004 RETURN null
LIVE RANGES:
0: 0001 - 0002 (tmp/var)

$_main:
; (lines=2, args=0, vars=0, tmps=0)
; (after optimizer)
; %s:1-12
0000 DECLARE_CLASS string("c")
0001 RETURN int(1)

C::foo:
; (lines=2, args=0, vars=0, tmps=1)
; (after optimizer)
; %s:6-8
0000 T0 = FETCH_THIS
0001 RETURN T0
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
--TEST--
Return type check elision for inherited interface return type and $this in class method
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.optimization_level=-1
opcache.opt_debug_level=0x30000
--EXTENSIONS--
opcache
--FILE--
<?php
interface I1 {}
interface I2 extends I1 {}

class C implements I2 {
public function foo(): I1 {
return $this;
}
}

?>
--EXPECTF--
$_main:
; (lines=3, args=0, vars=0, tmps=0)
; (before optimizer)
; %s:1-12
; return [] RANGE[0..0]
0000 DECLARE_CLASS string("i2")
0001 DECLARE_CLASS string("c")
0002 RETURN int(1)

C::foo:
; (lines=5, args=0, vars=0, tmps=1)
; (before optimizer)
; %s:6-8
; return [] RANGE[0..0]
0000 T0 = FETCH_THIS
0001 VERIFY_RETURN_TYPE T0
0002 RETURN T0
0003 VERIFY_RETURN_TYPE
0004 RETURN null
LIVE RANGES:
0: 0001 - 0002 (tmp/var)

$_main:
; (lines=3, args=0, vars=0, tmps=0)
; (after optimizer)
; %s:1-12
0000 DECLARE_CLASS string("i2")
0001 DECLARE_CLASS string("c")
0002 RETURN int(1)

C::foo:
; (lines=3, args=0, vars=0, tmps=1)
; (after optimizer)
; %s:6-8
0000 T0 = FETCH_THIS
0001 VERIFY_RETURN_TYPE T0
0002 RETURN T0
LIVE RANGES:
0: 0001 - 0002 (tmp/var)
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
--TEST--
Return type check elision for inherited interface via class extension return type and $this in class method
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.optimization_level=-1
opcache.opt_debug_level=0x30000
--EXTENSIONS--
opcache
--FILE--
<?php
interface I1 {}
interface I2 extends I1 {}

class C1 implements I2 {}

class C2 extends C1 {
public function foo(): I2 {
return $this;
}
}

?>
--EXPECTF--
$_main:
; (lines=4, args=0, vars=0, tmps=0)
; (before optimizer)
; %s:1-14
; return [] RANGE[0..0]
0000 DECLARE_CLASS string("i2")
0001 DECLARE_CLASS string("c1")
0002 DECLARE_CLASS_DELAYED string("c2") string("c1")
0003 RETURN int(1)

C2::foo:
; (lines=5, args=0, vars=0, tmps=1)
; (before optimizer)
; %s:8-10
; return [] RANGE[0..0]
0000 T0 = FETCH_THIS
0001 VERIFY_RETURN_TYPE T0
0002 RETURN T0
0003 VERIFY_RETURN_TYPE
0004 RETURN null
LIVE RANGES:
0: 0001 - 0002 (tmp/var)

$_main:
; (lines=4, args=0, vars=0, tmps=0)
; (after optimizer)
; %s:1-14
0000 DECLARE_CLASS string("i2")
0001 DECLARE_CLASS string("c1")
0002 DECLARE_CLASS_DELAYED string("c2") string("c1")
0003 RETURN int(1)

C2::foo:
; (lines=3, args=0, vars=0, tmps=1)
; (after optimizer)
; %s:8-10
0000 T0 = FETCH_THIS
0001 VERIFY_RETURN_TYPE T0
0002 RETURN T0
LIVE RANGES:
0: 0001 - 0002 (tmp/var)
Loading
Loading