From 35f611084228084a0b46babe18d111dd123d3cf2 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Tue, 1 Sep 2026 17:40:02 +0800 Subject: [PATCH] devirtualize statically exact magic calls --- phpunit/code/magic-call-codegen.php | 29 ++++++++ phpunit/src/MagicCallCodegenTest.php | 42 +++++++++++ src/Context/FunctionContext.php | 10 +++ src/Optimizer/SsaPropOptimizer.php | 28 ++++++++ src/Parser/MethodCallTrait.php | 61 ++++++++++++++++ .../dynamic_call/call-magic-direct-safe.phpt | 69 +++++++++++++++++++ 6 files changed, 239 insertions(+) create mode 100644 phpunit/code/magic-call-codegen.php create mode 100644 phpunit/src/MagicCallCodegenTest.php create mode 100644 tests/compiler/dynamic_call/call-magic-direct-safe.phpt diff --git a/phpunit/code/magic-call-codegen.php b/phpunit/code/magic-call-codegen.php new file mode 100644 index 00000000..959c5a93 --- /dev/null +++ b/phpunit/code/magic-call-codegen.php @@ -0,0 +1,29 @@ +missing(1, named: 2); +} + +function runtimeMagicCall(ExactMagicHandler $handler): mixed +{ + return $handler->missing(); +} + diff --git a/phpunit/src/MagicCallCodegenTest.php b/phpunit/src/MagicCallCodegenTest.php new file mode 100644 index 00000000..c68b52ef --- /dev/null +++ b/phpunit/src/MagicCallCodegenTest.php @@ -0,0 +1,42 @@ +compileFixture(); + + $exactBody = $this->functionBody($code, 'php_exactmagiccall'); + self::assertStringContainsString('php_exactmagichandler____call(', $exactBody); + self::assertStringNotContainsString('.call(', $exactBody); + + $runtimeBody = $this->functionBody($code, 'php_runtimemagiccall'); + self::assertStringContainsString('.call(', $runtimeBody); + self::assertStringNotContainsString('php_exactmagichandler____call(', $runtimeBody); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/magic-call-codegen.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } + + private function functionBody(string $code, string $function): string + { + $matched = preg_match('/php::Var ' . preg_quote($function, '/') . '\\([^)]*\\) \\{(?.*?)\\n\\}/s', $code, $match); + self::assertSame(1, $matched, "generated body of {$function}() not found"); + return $match['body']; + } +} diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index d07df058..8d13ba29 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -18,6 +18,15 @@ class FunctionContext /** Map of SSA-stable object variable name => class name (SsaPropOptimizer). */ public array $stableObjects = []; + /** + * SSA-stable variables whose sole definition is a concrete `new ClassName()`. + * Unlike a declared/returned object type, these entries prove the exact + * runtime class and may be used for conservative method devirtualization. + * + * @var array + */ + public array $exactObjects = []; + /** Map of hoisted property refs: objName => [propName => true] (SsaPropOptimizer). */ public array $hoistedProps = []; @@ -126,6 +135,7 @@ class FunctionContext $this->objectProps = []; $this->ssaBuilder = null; $this->stableObjects = []; + $this->exactObjects = []; $this->hoistedProps = []; $this->unsafeObjectProps = []; $this->staticPropRefs = []; diff --git a/src/Optimizer/SsaPropOptimizer.php b/src/Optimizer/SsaPropOptimizer.php index 285bad5d..6f09aa58 100644 --- a/src/Optimizer/SsaPropOptimizer.php +++ b/src/Optimizer/SsaPropOptimizer.php @@ -61,7 +61,35 @@ trait SsaPropOptimizer } $this->context->stableObjects[$objName] = $className; + if ($this->isExactNewObjectDefinition($ssa, $objName)) { + $this->context->exactObjects[$objName] = $className; + } + } + } + + /** + * A declared return type is only an upper bound and may contain a subclass. + * Only a sole, concrete `new` definition proves the runtime class exactly. + */ + protected function isExactNewObjectDefinition(SsaBuilder $ssa, string $objName): bool + { + foreach ($ssa->ssaVars as $ssaVar) { + if ($ssaVar->origName !== $objName || $ssaVar->flags & SsaFlags::PHI) { + continue; + } + + $definition = $ssaVar->definition; + if (!$definition instanceof Node\Stmt\Expression + || !$definition->expr instanceof Expr\Assign + || !$definition->expr->expr instanceof Expr\New_ + || !$definition->expr->expr->class instanceof Node\Name) { + return false; + } + + return strtolower($definition->expr->expr->class->toString()) !== 'static'; } + + return false; } /** diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 6697f5c5..525211de 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -283,6 +283,59 @@ trait MethodCallTrait return false; } + /** + * Directly invoke a TypePHP-compiled __call() only when the receiver's + * exact runtime class is statically proven. A declared class is not enough: + * a subclass may provide the requested real method instead of invoking the + * parent's __call(). + */ + protected function parseDirectNativeMagicCall( + Expr\MethodCall $expr, + string $object, + string $class, + string $method, + ): ?string { + if ($class === '' || !$this->hasClass($class)) { + return null; + } + + $exactClass = null; + if ($object === 'this_' && $this->isCurrentClassFinal()) { + $exactClass = $this->getFullClassName(); + } elseif (isset($this->context->exactObjects[$object])) { + $exactClass = $this->context->exactObjects[$object]; + } elseif ($this->isFinalClass($class)) { + $exactClass = $class; + } + + if ($exactClass === null || strcasecmp(ltrim($exactClass, '\\'), ltrim($class, '\\')) !== 0) { + return null; + } + + $nativeFunc = $this->getNativeMethod($expr, $exactClass, '__call', false); + if ($nativeFunc === false || !$this->hasFunction($nativeFunc)) { + return null; + } + $this->checkFunction($nativeFunc); + + if ($this->getVarType($object) !== Type::OBJECT) { + $tmpObject = $this->genTmpVarName(); + $this->context->beforeStmtLines[] = Type::OBJECT . ' ' . $tmpObject . ' = ' . $object . ';'; + $object = $tmpObject; + } + + // __call receives one PHP array containing positional and named + // arguments. Reuse the dynamic call argument builder so evaluation + // order, unpacking and named keys remain identical to the Zend path. + $arguments = $this->parseCallArgs( + $expr->args, + separateNamedArgs: false, + forceArrayArgs: true, + ); + + return self::PREFIX . $nativeFunc . '(' . $object . ', ' . $method . ', ' . $arguments . ')'; + } + protected function parseNativeMethodCall(string $object, string $nativeFunc, array $args): string { if ($this->getVarType($object) != Type::OBJECT) { @@ -695,6 +748,14 @@ trait MethodCallTrait if ($extension !== null) { return $this->parseUniversalMethodCall($expr, $object, $methodName, $extension); } + try { + $directMagicCall = $this->parseDirectNativeMagicCall($expr, $object, $class, $method); + if ($directMagicCall !== null) { + return $directMagicCall; + } + } catch (PlaceHolder) { + return $this->genPlaceHolder($this->genArray([$object, $method])); + } $magicMethod = true; } if (!$nativeFunc) { diff --git a/tests/compiler/dynamic_call/call-magic-direct-safe.phpt b/tests/compiler/dynamic_call/call-magic-direct-safe.phpt new file mode 100644 index 00000000..cc26502c --- /dev/null +++ b/tests/compiler/dynamic_call/call-magic-direct-safe.phpt @@ -0,0 +1,69 @@ +--TEST-- +compiled __call fast path preserves arguments and dynamic subclass dispatch +--FILE-- +existing(); +} + +function main(): void +{ + $direct = new DirectMagic(); + var_dump($direct->missing(1, second: 2)); + + $inherited = new InheritedMagic(); + $values = [3, 4]; + var_dump($inherited->packed(...$values, tail: 5)); + + // A declared base type is not an exact runtime type. The compiler must + // retain Zend dispatch so a subclass's real method wins over __call(). + var_dump(callFromDeclaredBase(new RuntimeMethod())); +} +?> +--EXPECT-- +array(2) { + [0]=> + string(7) "missing" + [1]=> + array(2) { + [0]=> + int(1) + ["second"]=> + int(2) + } +} +array(2) { + [0]=> + string(6) "packed" + [1]=> + array(3) { + [0]=> + int(3) + [1]=> + int(4) + ["tail"]=> + int(5) + } +} +string(14) "runtime-method"