fix(compiler): preserve runtime class dispatch and introspection for polymorphic objects (#110)

* fix(compiler): preserve runtime class dispatch and introspection for polymorphic objects

When an object variable has an upper-bound declared type (e.g. assigned from
a function or method return), SSA marks it as a stable object. However,
stableObjects does not guarantee the concrete runtime class: the instance
may be a subclass at runtime, or null if nullable.

1. FuncCallOptimizer::genGetClassOptimized checked isStableObject($obj->name)
   and folded get_class($obj) to the declared type literal string. For
   $a = getAnimal() returning a Dog, get_class($a) was incorrectly folded
   to "Animal" at compile time.
2. MethodCallTrait::parseStaticCall checked isStableObject($class) and
   jumped to _do_call with the declared base class. For $a::who(), this
   statically invoked Base::who() instead of dispatching to Dog::who() on
   the runtime object.

Require proven exact object instances (exactObjects, populated from direct
new ConcreteClass() definitions) before folding get_class() or devirtualizing
static calls on object variables. Non-exact variables fall back to runtime
class resolution (php::fn::get_class() and php::callStaticMethod()),
matching PHP semantics.

Add unit test coverage verifying exact objects continue to fold/devirtualize
while polymorphic variables retain dynamic runtime dispatch.

* fix(compiler): preserve lexical scope in dynamic static calls and runtime type checks for get_class

- Use php::callScoped with getCallableScopeExpr() for dynamic static calls inside class methods to preserve caller visibility on protected methods
- Fall back to runtime php::call for get_class on non-exact values so nullable objects throw TypeError instead of dereferencing null
- Add behavioral PHPT tests for protected polymorphic static calls and nullable get_class
- Update unit tests for scoped dispatch and runtime introspection

* test(compiler): remove stray top-level main call from PHPT tests

In TypePHP AOT compilation, function main(): void serves as the binary
entrypoint. Top-level function calls outside a function are rejected by
the preprocessor as stray code.
master
Pratik Bhujel 4 weeks ago committed by GitHub
parent ce4c712b0c
commit 13889e9a30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 63
      phpunit/code/polymorphic-dispatch.php
  2. 51
      phpunit/src/PolymorphicClassDispatchTest.php
  3. 6
      src/Optimizer/FuncCallOptimizer.php
  4. 18
      src/Parser/MethodCallTrait.php
  5. 78
      tests/compiler/static/polymorphic-static-call-scope.phpt
  6. 67
      tests/compiler/stdlib/get_class_nullable.phpt

@ -0,0 +1,63 @@
<?php
class BaseAnimal
{
public static function identify(): string
{
return 'base';
}
}
class DogAnimal extends BaseAnimal
{
public static function identify(): string
{
return 'dog';
}
}
function makeAnimal(): BaseAnimal
{
return new DogAnimal();
}
function getExactClass(): string
{
$dog = new DogAnimal();
return get_class($dog);
}
function getPolymorphicClass(): string
{
$animal = makeAnimal();
return get_class($animal);
}
function callExactStatic(): string
{
$dog = new DogAnimal();
return $dog::identify();
}
function callPolymorphicStatic(): string
{
$animal = makeAnimal();
return $animal::identify();
}
class BaseScopedCaller
{
protected static function identifyProtected(): string
{
return static::class;
}
public static function exercise(BaseScopedCaller $obj): string
{
return $obj::identifyProtected();
}
}
class ChildScopedCaller extends BaseScopedCaller
{
}

@ -0,0 +1,51 @@
<?php
use TypePhp\CompilerTest;
final class PolymorphicClassDispatchTest extends BaseTest
{
public function testPolymorphicClassIntrospectionAndStaticDispatch(): void
{
global $translator;
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/polymorphic-dispatch.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$code = file_get_contents($compiler->convertFile($source));
self::assertIsString($code);
// Exact new object definition get_class() folds to compile-time literal string
self::assertMatchesRegularExpression(
'/php_getexactclass\(\) \{.*?tmp_var_\d+ = \(get_str\(\d+\)\);/s',
$code,
);
// Polymorphic get_class() must NOT fold or blindly invoke unchecked C++ helper;
// it must use runtime php::call to enforce PHP argument semantics (TypeError on null)
self::assertMatchesRegularExpression(
"/php_getpolymorphicclass\(\) \{.*?tmp_var_\d+ = \(php::call\(get_persistent_func\(PersistentFuncId\{0\}, get_str\(3\)\), php::VarList\{animal\}\)\);/s",
$code,
);
// Exact new object definition static call devirtualizes to cached call
self::assertMatchesRegularExpression(
'/php_callexactstatic\(\) \{.*?typephp_call_cached\(get_str\(\d+\)/s',
$code,
);
// Polymorphic static call from global function dispatches dynamically via callStaticMethod
self::assertMatchesRegularExpression(
'/php_callpolymorphicstatic\(\) \{.*?php::callStaticMethod\([^)]+\)/s',
$code,
);
// Polymorphic static call from within a class method preserves lexical callable scope
self::assertMatchesRegularExpression(
"/php_basescopedcaller__exercise\(.*?php::CallableScope (tmp_var_\d+) = php::getCallableScope\(.*?php::callScoped\(php::concat\(.*?, \\1\)/s",
$code,
);
}
}

@ -1118,10 +1118,10 @@ trait FuncCallOptimizer
'Native classes do not support runtime class introspection; use `NativeClass::class`',
);
}
if ($this->isVarExpr($obj) && $this->isStableObject($obj->name)) {
return $this->getLiteralString($this->getObjectType($obj->name));
if ($this->isVarExpr($obj) && isset($this->context->exactObjects[$obj->name])) {
return $this->getLiteralString($this->context->exactObjects[$obj->name]);
}
return 'php::fn::get_class(' . $this->parseIdentifier($obj) . ')';
return false;
}
protected function genGetParentClass(string $n, Node\Expr\FuncCall $e, array $c): string

@ -1080,6 +1080,7 @@ trait MethodCallTrait
$rtClass = '';
$cacheCallable = false;
$directStaticCall = false;
$scopedStaticCall = false;
$staticCallTarget = '';
$staticCallMethod = '';
$canUseDirectCallScope = $this->isNameExpr($expr->class) && $this->isIdExpr($expr->name);
@ -1107,10 +1108,10 @@ trait MethodCallTrait
if (!$this->isNameExpr($expr->class)) {
if ($this->isVarExpr($expr->class)
&& $this->isStableObject($class)
&& isset($this->context->exactObjects[$class])
&& $this->isIdExpr($expr->name)
) {
$class = $this->getObjectType($class);
$class = $this->context->exactObjects[$class];
goto _do_call;
}
$classTarget = $this->materializeDynamicStaticCallTarget($expr->class);
@ -1129,7 +1130,11 @@ trait MethodCallTrait
}
}
$placeHolder = $fn;
$directStaticCall = true;
if ($this->methodDef !== null) {
$scopedStaticCall = true;
} else {
$directStaticCall = true;
}
} elseif ($this->isVarExpr($expr->name)) {
$staticCallMethod = $this->methodNameToStr($expr->name, literal: true);
if ($class === 'static') {
@ -1240,6 +1245,9 @@ trait MethodCallTrait
}
if (empty($expr->args)) {
if ($scopedStaticCall) {
return 'php::callScoped(' . $fn . ', ' . $this->getCallableScopeExpr() . ')';
}
if ($directStaticCall) {
return 'php::callStaticMethod(' . $staticCallTarget . ', ' . $staticCallMethod . ')';
}
@ -1249,6 +1257,10 @@ trait MethodCallTrait
return 'php::call(' . $fn . ')';
}
try {
if ($scopedStaticCall) {
return 'php::callScoped(' . $fn . ', ' . $this->getCallableScopeExpr() . ', '
. $this->parseCallArgs($expr->args, $rtFunc, $rtClass) . ')';
}
if ($directStaticCall) {
return 'php::callStaticMethod(' . $staticCallTarget . ', ' . $staticCallMethod . ', '
. $this->parseCallArgs($expr->args, $rtFunc, $rtClass) . ')';

@ -0,0 +1,78 @@
--TEST--
Polymorphic object static call preserves lexical scope for protected methods
--FILE--
<?php
class Base
{
protected static function identify(): string
{
return static::class;
}
public static function who(): string
{
return static::class;
}
public static function exerciseStatic(Base $obj): string
{
return $obj::identify();
}
public function exerciseInstance(Base $obj): string
{
return $obj::identify();
}
}
class Child extends Base
{
}
class SubChild extends Child
{
public static function who(): string
{
return 'subchild:' . static::class;
}
}
function makeChild(): Base
{
return new Child();
}
function makeSubChild(): Base
{
return new SubChild();
}
function globalCallPublic(Base $obj): string
{
return $obj::who();
}
function main(): void
{
$child = makeChild();
var_dump(Base::exerciseStatic($child));
$base = new Base();
var_dump($base->exerciseInstance($child));
$sub = makeSubChild();
var_dump(Base::exerciseStatic($sub));
var_dump($base->exerciseInstance($sub));
var_dump(globalCallPublic($child));
var_dump(globalCallPublic($sub));
}
?>
--EXPECT--
string(5) "Child"
string(5) "Child"
string(8) "SubChild"
string(8) "SubChild"
string(5) "Child"
string(17) "subchild:SubChild"

@ -0,0 +1,67 @@
--TEST--
get_class on nullable and polymorphic objects behaves identically to PHP
--FILE--
<?php
class Animal
{
}
class Dog extends Animal
{
}
class Cat extends Animal
{
}
function makeAnimal(): ?Animal
{
return new Dog();
}
function makeNullAnimal(): ?Animal
{
return null;
}
function testPolymorphic(): void
{
$animal = makeAnimal();
var_dump(get_class($animal));
}
function testExact(): void
{
$cat = new Cat();
var_dump(get_class($cat));
}
function testNullable(): void
{
$animal = makeNullAnimal();
try {
var_dump(get_class($animal));
} catch (TypeError $e) {
echo "Caught nullable TypeError: " . $e->getMessage() . "\n";
}
try {
var_dump(get_class(null));
} catch (TypeError $e) {
echo "Caught literal TypeError: " . $e->getMessage() . "\n";
}
}
function main(): void
{
testPolymorphic();
testExact();
testNullable();
}
?>
--EXPECT--
string(3) "Dog"
string(3) "Cat"
Caught nullable TypeError: get_class(): Argument #1 ($object) must be of type object, null given
Caught literal TypeError: get_class(): Argument #1 ($object) must be of type object, null given
Loading…
Cancel
Save