From 13889e9a304bf4cd35fc3a54832c0e96bf3558ff Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 14 Sep 2026 15:34:26 +0545 Subject: [PATCH] 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. --- phpunit/code/polymorphic-dispatch.php | 63 +++++++++++++++ phpunit/src/PolymorphicClassDispatchTest.php | 51 ++++++++++++ src/Optimizer/FuncCallOptimizer.php | 6 +- src/Parser/MethodCallTrait.php | 18 ++++- .../static/polymorphic-static-call-scope.phpt | 78 +++++++++++++++++++ tests/compiler/stdlib/get_class_nullable.phpt | 67 ++++++++++++++++ 6 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 phpunit/code/polymorphic-dispatch.php create mode 100644 phpunit/src/PolymorphicClassDispatchTest.php create mode 100644 tests/compiler/static/polymorphic-static-call-scope.phpt create mode 100644 tests/compiler/stdlib/get_class_nullable.phpt diff --git a/phpunit/code/polymorphic-dispatch.php b/phpunit/code/polymorphic-dispatch.php new file mode 100644 index 00000000..c10c20b3 --- /dev/null +++ b/phpunit/code/polymorphic-dispatch.php @@ -0,0 +1,63 @@ +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, + ); + } +} diff --git a/src/Optimizer/FuncCallOptimizer.php b/src/Optimizer/FuncCallOptimizer.php index cba6f0bc..c7a0b925 100644 --- a/src/Optimizer/FuncCallOptimizer.php +++ b/src/Optimizer/FuncCallOptimizer.php @@ -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 diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index aa1c64be..a120c363 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -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) . ')'; diff --git a/tests/compiler/static/polymorphic-static-call-scope.phpt b/tests/compiler/static/polymorphic-static-call-scope.phpt new file mode 100644 index 00000000..63c29e41 --- /dev/null +++ b/tests/compiler/static/polymorphic-static-call-scope.phpt @@ -0,0 +1,78 @@ +--TEST-- +Polymorphic object static call preserves lexical scope for protected methods +--FILE-- +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" diff --git a/tests/compiler/stdlib/get_class_nullable.phpt b/tests/compiler/stdlib/get_class_nullable.phpt new file mode 100644 index 00000000..2b06c0c6 --- /dev/null +++ b/tests/compiler/stdlib/get_class_nullable.phpt @@ -0,0 +1,67 @@ +--TEST-- +get_class on nullable and polymorphic objects behaves identically to PHP +--FILE-- +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