fix(compiler): preserve signatures for typed property method calls (#104)

* fix(compiler): preserve signatures for typed property method calls

* fix(compiler): separate property signatures from dispatch
master
yavon007 4 weeks ago committed by GitHub
parent 13889e9a30
commit a1efda0f7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 24
      benchmark/property-method-call/README.md
  2. 22
      benchmark/property-method-call/main.php
  3. 5
      benchmark/property-method-call/project.yml
  4. 248
      phpunit/code/typed-property-method-call.php
  5. 143
      phpunit/src/TypedPropertyMethodCallTest.php
  6. 145
      src/Parser/MethodCallTrait.php
  7. 44
      tests/compiler/devirtualize/typed-property-interface-reference.phpt
  8. 44
      tests/compiler/devirtualize/typed-property-private-override.phpt
  9. 88
      tests/compiler/devirtualize/typed-property-receiver.phpt
  10. 34
      tests/compiler/devirtualize/typed-static-property-reference.phpt

@ -0,0 +1,24 @@
# Typed property method calls
This benchmark calls a final class method through a declared object property
1,000,000 times, returning checksum `7000000`. It isolates receiver/property and
method-call overhead; it is not a whole-application performance estimate.
Build from the repository root with a matching PHPX runtime:
```sh
php bin/tpc.php benchmark/property-method-call/project.yml --no-progress \
--build-dir /tmp/typephp-property-method-build -o /tmp/property-method-call
/tmp/property-method-call
```
For a before/after comparison, use the same PHPX, compiler flags and workload in
both checkouts, separate build directories, and alternate the two binaries after
a warmup. Verify equal output before comparing elapsed time.
An initial Linux ARM64/PHP 8.5.10 ZTS/PHPX `4b3a472` O2 comparison against TypePHP
`a1782233` measured five-run median process times of 29.97 ms before and 22.63 ms
after (24.5% lower elapsed time). Timings include process startup. The normal
22-command dungeon scenario improved only about 2.4%, and its exception-heavy
28-command scenario showed no reliable improvement. These results demonstrate a
local call-path improvement, not a claim that all applications become 24.5% faster.

@ -0,0 +1,22 @@
<?php
final class PropertyCounter
{
public function value(): int { return 7; }
}
final class PropertyCounterHolder
{
public PropertyCounter $counter;
public function __construct() { $this->counter = new PropertyCounter(); }
public function run(int $count): int
{
$sum = 0;
for ($i = 0; $i < $count; ++$i) {
$sum += $this->counter->value();
}
return $sum;
}
}
function main(int $argc, array $argv): void
{
echo (new PropertyCounterHolder())->run(1000000), "\n";
}

@ -0,0 +1,5 @@
name: property_method_call
build-mode: bin
optimize: 2
sources:
- main.php

@ -0,0 +1,248 @@
<?php
final class PropertyCallTarget
{
public function __construct(public int $id = 1) {}
public function record(array &$events, int $value = 0): int
{
$events[] = $this->id + $value;
return $this->id;
}
}
class PropertyCallBase
{
public function value(): int { return 1; }
}
final class PropertyCallChild extends PropertyCallBase
{
public function value(): int { return 2; }
}
final class PropertyCallHolder
{
public PropertyCallTarget $target;
public PropertyCallBase $polymorphic;
public ?PropertyCallTarget $nullable = null;
public PropertyCallTarget $uninitialized;
public int $reads = 0;
public PropertyCallTarget $hooked {
get {
++$this->reads;
return $this->target;
}
}
public function __construct()
{
$this->target = new PropertyCallTarget();
$this->polymorphic = new PropertyCallChild();
}
public function replace(): int
{
$this->target = new PropertyCallTarget(9);
return 3;
}
public function checkHook(array &$events): array
{
$id = $this->hooked->record($events);
return [$id, $this->reads];
}
public function checkUninitialized(array &$events): string
{
try {
$this->uninitialized->record($events);
} catch (Error $error) {
return 'uninitialized';
}
return 'unexpected';
}
public function run(array &$events): array
{
$first = $this->target->record($events, $this->replace());
$second = $this->target->record(value: 2, events: $events);
return [$first, $second, $this->polymorphic->value(), $this->nullable?->record($events)];
}
}
class PrivateMethodPropertyBase
{
private function value(): string
{
return 'base';
}
}
final class PrivateMethodPropertyChild extends PrivateMethodPropertyBase
{
public function value(): string
{
return 'child';
}
}
final class PrivateMethodPropertyHolder
{
public PrivateMethodPropertyBase $target;
public function run(): string
{
return $this->target->value();
}
}
interface InterfacePropertyRecorder
{
public function record(array &$events): void;
}
final class InterfacePropertyRecorderImpl implements InterfacePropertyRecorder
{
public function record(array &$events): void
{
$events[] = 'recorded';
}
}
final class InterfacePropertyHolder
{
public InterfacePropertyRecorder $target;
public function run(array &$events): void
{
$this->target->record($events);
}
}
final class StaticPropertyCallTarget
{
public function record(array &$events): void
{
$events[] = 'recorded';
}
}
final class StaticPropertyCallHolder
{
public static StaticPropertyCallTarget $target;
public static function run(array &$events): void
{
self::$target->record($events);
}
}
function unrelatedRecordSignature(array &$events): void
{
}
function dynamicReceiverRecordCall(mixed $target, array $events): void
{
$target->unrelatedRecordSignature($events);
}
final class MissingSignaturePropertyTarget
{
}
function missingSignaturePropertyMethod(array &$events): void
{
}
final class MissingSignaturePropertyHolder
{
public MissingSignaturePropertyTarget $target;
public function run(array $events): void
{
$this->target->missingSignaturePropertyMethod($events);
}
}
class LexicalPrivatePropertyBase
{
public LexicalPrivateFinalPropertyChild $finalTarget;
public LexicalPrivateOpenPropertyChild $openTarget;
private function record(array &$events): void
{
$events[] = 'base';
}
public function runFinal(array &$events): void
{
$this->finalTarget->record($events);
}
public function runOpen(array &$events): void
{
$this->openTarget->record($events);
}
}
final class LexicalPrivateFinalPropertyChild extends LexicalPrivatePropertyBase
{
public function record(array $events): void
{
}
}
class LexicalPrivateOpenPropertyChild extends LexicalPrivatePropertyBase
{
public function record(array $events): void
{
}
}
final class MagicPrivatePropertyTarget
{
private function hidden(array &$events): void
{
}
public function __call(string $name, array $arguments): void
{
}
}
final class MagicPrivatePropertyHolder
{
public MagicPrivatePropertyTarget $target;
public function run(array $events): void
{
$this->target->hidden($events);
}
}
final class MagicMissingPropertyTarget
{
public function __call(string $name, array $arguments): void
{
}
}
final class MagicMissingPropertyHolder
{
public MagicMissingPropertyTarget $target;
public function run(array $events): void
{
$this->target->missing($events);
}
}
function main(): void
{
$holder = new PropertyCallHolder();
$events = [];
$result = $holder->run($events);
$hook = $holder->checkHook($events);
echo json_encode([$result, $events, $holder->checkUninitialized($events), $hook], JSON_THROW_ON_ERROR), "\n";
}

@ -0,0 +1,143 @@
<?php
use TypePhp\CompilerTest;
final class TypedPropertyMethodCallTest extends BaseTest
{
public function testKnownPropertyReceiverUsesDirectCallWithReferenceArguments(): void
{
global $translator;
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/typed-property-method-call.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$generated = $compiler->convertFile($source);
$code = file_get_contents($generated);
self::assertIsString($code);
self::assertSame(1, preg_match(
'/php::Array php_propertycallholder__run\(.*?\) \{(?<body>.*?)\n\}/s',
$code,
$matches,
));
self::assertSame(2, substr_count($matches['body'], 'php_propertycalltarget__record('));
self::assertStringNotContainsString('php_propertycallbase__value(', $matches['body']);
}
public function testNonFinalPropertyReceiverUsesRuntimeDispatch(): void
{
$code = $this->compileFixture();
self::assertSame(1, preg_match(
'/php::\w+ php_privatemethodpropertyholder__run\(.*?\) \{(?<body>.*?)\n\}/s',
$code,
$matches,
));
self::assertStringNotContainsString('php_privatemethodpropertybase__value(', $matches['body']);
self::assertMatchesRegularExpression('/(?:typephp_call_method(?:_scoped)?_cached|\.call)\(/', $matches['body']);
}
public function testInterfacePropertyReceiverUsesInterfaceReferenceSignature(): void
{
$code = $this->compileFixture();
self::assertSame(1, preg_match(
'/void php_interfacepropertyholder__run\(.*?\) \{(?<body>.*?)\n\}/s',
$code,
$matches,
));
self::assertMatchesRegularExpression('/(?:RefWrap|toReference|\.ref\()/i', $matches['body']);
self::assertMatchesRegularExpression('/(?:typephp_call_method_cached|\.call)\(/', $matches['body']);
}
public function testStaticPropertyReceiverUsesDirectCallWithReferenceArguments(): void
{
$code = $this->compileFixture();
self::assertSame(1, preg_match(
'/void php_staticpropertycallholder__run\(.*?\) \{(?<body>.*?)\n\}/s',
$code,
$matches,
));
self::assertStringContainsString('php_staticpropertycalltarget__record(', $matches['body']);
}
public function testUnknownReceiverDoesNotUseSameNamedGlobalFunctionSignature(): void
{
$code = $this->compileFixture();
self::assertSame(1, preg_match(
'/void php_dynamicreceiverrecordcall\(.*?\) \{(?<body>.*?)\n\}/s',
$code,
$matches,
));
self::assertStringNotContainsString('RefWrap', $matches['body']);
self::assertStringNotContainsString('.ref()', $matches['body']);
}
public function testMissingTypedPropertySignatureDoesNotUseSameNamedGlobalFunction(): void
{
$code = $this->compileFixture();
self::assertSame(1, preg_match(
'/void php_missingsignaturepropertyholder__run\(.*?\) \{(?<body>.*?)\n\}/s',
$code,
$matches,
));
self::assertStringNotContainsString('RefWrap', $matches['body']);
self::assertStringNotContainsString('.ref()', $matches['body']);
}
public function testLexicalPrivateMethodWinsOverPropertyClassMethod(): void
{
$code = $this->compileFixture();
foreach (['runfinal', 'runopen'] as $method) {
self::assertSame(1, preg_match(
'/void php_lexicalprivatepropertybase__' . $method . '\\(.*?\\) \\{(?<body>.*?)\\n\\}/s',
$code,
$matches,
));
self::assertStringNotContainsString('php_lexicalprivatepropertybase__record(', $matches['body']);
self::assertStringNotContainsString('php_lexicalprivatefinalpropertychild__record(', $matches['body']);
self::assertStringNotContainsString('php_lexicalprivateopenpropertychild__record(', $matches['body']);
self::assertStringContainsString('typephp_call_method_scoped_cached(', $matches['body']);
self::assertMatchesRegularExpression('/(?:RefWrap|toReference|\.ref\()/i', $matches['body']);
}
}
public function testFinalPropertyPrivateMethodUsesMagicRuntimeDispatch(): void
{
$code = $this->compileFixture();
self::assertSame(1, preg_match(
'/void php_magicprivatepropertyholder__run\(.*?\) \{(?<body>.*?)\n\}/s',
$code,
$matches,
));
self::assertStringNotContainsString('php_magicprivatepropertytarget__hidden(', $matches['body']);
self::assertMatchesRegularExpression('/(?:typephp_call_method(?:_scoped)?_cached|\.call)\(/', $matches['body']);
self::assertStringNotContainsString('RefWrap', $matches['body']);
self::assertStringNotContainsString('.ref()', $matches['body']);
}
public function testFinalPropertyMissingMethodRetainsDirectMagicOptimization(): void
{
$code = $this->compileFixture();
self::assertSame(1, preg_match(
'/void php_magicmissingpropertyholder__run\(.*?\) \{(?<body>.*?)\n\}/s',
$code,
$matches,
));
self::assertStringContainsString('php_magicmissingpropertytarget____call(', $matches['body']);
self::assertStringNotContainsString('typephp_call_method', $matches['body']);
self::assertStringNotContainsString('RefWrap', $matches['body']);
}
private function compileFixture(): string
{
global $translator;
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/typed-property-method-call.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$generated = $compiler->convertFile($source);
$code = file_get_contents($generated);
self::assertIsString($code);
return $code;
}
}

@ -166,6 +166,64 @@ trait MethodCallTrait
return $this->hasClass($class) && ($this->getClass($class)->flags & Modifiers::FINAL) !== 0;
}
/**
* A typed property declaration can describe call arguments without proving
* the receiver's concrete runtime class. Private methods are the one
* exception: a child may declare an unrelated public method with the same
* name and a different signature, so an inaccessible private declaration
* cannot be used as the runtime call's signature contract.
*/
protected function getTypedPropertyMethodSignatureClass(string $class, string $method): string
{
if ($class === '' || $method === '') {
return '';
}
if ($this->hasInterface($class)) {
return $this->findAotMethodFunctionDef($class, $method) !== null ? $class : '';
}
if (!$this->hasClass($class)) {
return '';
}
$classDef = $this->getClass($class);
while (true) {
if ($classDef->hasMethod($method) || $classDef->hasAbstractMethod($method)) {
return $this->checkAccessible($classDef, $classDef->getMethodFlags($method)) ? $class : '';
}
if (!$classDef->extends || !$this->hasClass($classDef->extends)) {
break;
}
$classDef = $this->getClass($classDef->extends);
}
// A class may inherit its declaration from an implemented interface.
return $this->findAotMethodFunctionDef($class, $method) !== null ? $class : '';
}
/**
* A final property class is exact enough for a native call only when its
* resolved method is accessible from the current lexical scope. An
* inaccessible method may instead dispatch to __call() at runtime.
*/
protected function hasInaccessibleTypedPropertyMethod(string $class, string $method): bool
{
if ($class === '' || $method === '' || !$this->hasClass($class)) {
return false;
}
$classDef = $this->getClass($class);
while (true) {
if ($classDef->hasMethod($method) || $classDef->hasAbstractMethod($method)) {
return !$this->checkAccessible($classDef, $classDef->getMethodFlags($method));
}
if (!$classDef->extends || !$this->hasClass($classDef->extends)) {
return false;
}
$classDef = $this->getClass($classDef->extends);
}
}
protected function getMethodFlags(string $class, string $method): int
{
if (!$this->hasClass($class)) {
@ -548,6 +606,9 @@ trait MethodCallTrait
$class = '';
$materializedNativeReceiver = false;
$materializedTypedPropertyReceiver = false;
$typedPropertyReceiver = false;
$typedPropertyFinalClass = '';
// C++17 sequences a member-call receiver before its arguments, but
// lowering an argument may hoist captured beforeStmtLines ahead of the
// whole call. Materialize an effectful receiver before parsing args.
@ -556,6 +617,42 @@ trait MethodCallTrait
$object = $this->materializeNativeObjectReceiver($expr->var, $receiverClass);
$class = $receiverClass;
$materializedNativeReceiver = true;
} elseif (($expr->var instanceof Expr\PropertyFetch || $expr->var instanceof Expr\StaticPropertyFetch)
&& $this->isIdExpr($expr->var->name)) {
// A non-nullable declared object property supplies a method
// signature, but only a final class proves the concrete receiver
// needed for a direct native call. Materialize either kind of
// property once before arguments (including hook getters).
$resolvedStaticProperty = false;
if ($expr->var instanceof Expr\PropertyFetch) {
$this->getPropertyIdentifier($expr->var, $expr->var->var, $expr->var->name);
} else {
$resolution = $this->resolveNativeStaticPropertyFetch($expr->var);
$resolvedStaticProperty = $resolution !== null && $resolution->class !== null;
}
$property = $this->getNativePropertyDef($expr->var);
if ($property !== null
&& $property->type === Type::OBJECT
&& !$property->nullable
&& $property->class !== ''
&& ($this->hasClass($property->class) || $this->hasInterface($property->class))
&& !$this->isNativeObjectClass($property->class)
&& ($expr->var instanceof Expr\PropertyFetch || $resolvedStaticProperty)
) {
$object = $this->parseOrderedOperand($expr->var, false, true);
$class = $property->class;
$typedPropertyReceiver = true;
if ($this->isFinalClass($property->class)) {
$typedPropertyFinalClass = $property->class;
}
} else {
$object = empty($expr->args)
? $this->parseIdentifier($expr->var)
: $this->parseOrderedOperand($expr->var, false);
if (empty($expr->args)) {
$object = '(' . $object . ')';
}
}
} else {
$object = empty($expr->args)
? $this->parseIdentifier($expr->var)
@ -629,6 +726,38 @@ trait MethodCallTrait
// Re-checking isNamedMethod() does not prove the earlier assignment to
// static analyzers and previously left the object path uninitialized.
$methodName = $this->isNamedMethod($expr->name) ? $expr->name->toString() : '';
$typedPropertyLexicalPrivateClass = '';
if ($typedPropertyReceiver
&& $methodName !== ''
&& $this->classDef !== null
&& $this->methodDef !== null
) {
$scopeClass = $this->classDef->getNamespacedName(false);
// Private methods are lexically scoped. Check only this class's
// own declaration: an inherited private method has a different
// lexical scope and must not affect this call.
if ($this->classDef->hasMethod($methodName)
&& ($this->classDef->getMethodFlags($methodName) & Modifiers::PRIVATE)
&& $this->isSameOrSubclassOf($class, $scopeClass)
) {
$typedPropertyLexicalPrivateClass = $scopeClass;
}
}
$typedPropertyHasInaccessibleMethod = $typedPropertyReceiver
&& $typedPropertyLexicalPrivateClass === ''
&& $this->hasInaccessibleTypedPropertyMethod($class, $methodName);
$typedPropertySignatureClass = $typedPropertyReceiver
? ($typedPropertyLexicalPrivateClass !== ''
? $typedPropertyLexicalPrivateClass
: $this->getTypedPropertyMethodSignatureClass($class, $methodName))
: $class;
if ($typedPropertyFinalClass !== ''
&& $typedPropertyLexicalPrivateClass === ''
&& !$typedPropertyHasInaccessibleMethod
) {
$this->addObject($object, $typedPropertyFinalClass);
$materializedTypedPropertyReceiver = true;
}
$pythonFacadeCall = $this->parsePythonNativeFacadeMethodCall($expr, $object);
if ($pythonFacadeCall !== null) {
@ -702,7 +831,8 @@ trait MethodCallTrait
}
// Method calls that can be lowered to a native call
if (($this->isVarExpr($expr->var) || $materializedNativeReceiver) and $this->isNamedMethod($expr->name)) {
if (($this->isVarExpr($expr->var) || $materializedNativeReceiver || $materializedTypedPropertyReceiver)
and $this->isNamedMethod($expr->name)) {
$type = $this->getVarType($object);
if ($class !== '' && $this->isNativeObjectClass($class)) {
// Native objects have their own C++ virtual thunk for an
@ -874,7 +1004,7 @@ trait MethodCallTrait
$funcName,
$magicMethod,
$this->isVarExpr($expr->var) && $this->parseIdentifier($expr->var) === 'this_',
);
) || $typedPropertyLexicalPrivateClass !== '';
$resolvedMethodPtr = false;
if ($class && $funcName && !$magicMethod) {
if ($this->isInternalClass($class)) {
@ -914,7 +1044,16 @@ trait MethodCallTrait
try {
$class = empty($class) ? self::DYNAMIC_CALLED_CLASS : $class;
if (!$resolvedMethodPtr) {
$callArgs = $this->parseCallArgs($expr->args, $funcName, $class);
$callArgClass = $typedPropertyReceiver
? ($typedPropertySignatureClass === ''
? self::DYNAMIC_CALLED_CLASS
: $typedPropertySignatureClass)
: $class;
$callArgs = $this->parseCallArgs(
$expr->args,
$funcName,
$callArgClass,
);
if ($requiresDynamicScope && $this->methodDef) {
if (!$cacheMethod) {
return 'php::callScoped(' . $object . ', ' . $methodPtr . ', '

@ -0,0 +1,44 @@
--TEST--
Interface typed property receiver preserves by-reference arguments
--FILE--
<?php
interface TypedPropertyEventRecorder
{
public function record(array &$events): void;
}
final class TypedPropertyEventRecorderImpl implements TypedPropertyEventRecorder
{
public function record(array &$events): void
{
$events[] = 'recorded';
}
}
final class TypedPropertyInterfaceHolder
{
public TypedPropertyEventRecorder $target;
public function __construct()
{
$this->target = new TypedPropertyEventRecorderImpl();
}
public function run(array &$events): void
{
$this->target->record($events);
}
}
function main(): void
{
$events = [];
$holder = new TypedPropertyInterfaceHolder();
$holder->run($events);
echo json_encode($events, JSON_THROW_ON_ERROR), "\n";
}
?>
--EXPECT--
["recorded"]

@ -0,0 +1,44 @@
--TEST--
Typed property receiver dispatches a child public method over a parent private method
--FILE--
<?php
class TypedPropertyPrivateBase
{
private function value(): string
{
return 'base';
}
}
final class TypedPropertyPrivateChild extends TypedPropertyPrivateBase
{
public function value(): string
{
return 'child';
}
}
final class TypedPropertyPrivateHolder
{
public TypedPropertyPrivateBase $target;
public function __construct()
{
$this->target = new TypedPropertyPrivateChild();
}
public function run(): string
{
return $this->target->value();
}
}
function main(): void
{
echo (new TypedPropertyPrivateHolder())->run(), "\n";
}
?>
--EXPECT--
child

@ -0,0 +1,88 @@
--TEST--
Typed property method calls preserve references, receiver order, overrides, hooks and uninitialized access
--FILE--
<?php
final class PropertyCallTarget
{
public function __construct(public int $id = 1) {}
public function record(array &$events, int $value = 0): int
{
$events[] = $this->id + $value;
return $this->id;
}
}
class PropertyCallBase
{
public function value(): int { return 1; }
}
final class PropertyCallChild extends PropertyCallBase
{
public function value(): int { return 2; }
}
final class PropertyCallHolder
{
public PropertyCallTarget $target;
public PropertyCallBase $polymorphic;
public ?PropertyCallTarget $nullable = null;
public PropertyCallTarget $uninitialized;
public int $reads = 0;
public PropertyCallTarget $hooked {
get {
++$this->reads;
return $this->target;
}
}
public function __construct()
{
$this->target = new PropertyCallTarget();
$this->polymorphic = new PropertyCallChild();
}
public function replace(): int
{
$this->target = new PropertyCallTarget(9);
return 3;
}
public function checkHook(array &$events): array
{
$id = $this->hooked->record($events);
return [$id, $this->reads];
}
public function checkUninitialized(array &$events): string
{
try {
$this->uninitialized->record($events);
} catch (Error $error) {
return 'uninitialized';
}
return 'unexpected';
}
public function run(array &$events): array
{
$first = $this->target->record($events, $this->replace());
$second = $this->target->record(value: 2, events: $events);
return [$first, $second, $this->polymorphic->value(), $this->nullable?->record($events)];
}
}
function main(): void
{
$holder = new PropertyCallHolder();
$events = [];
$result = $holder->run($events);
$hook = $holder->checkHook($events);
echo json_encode([$result, $events, $holder->checkUninitialized($events), $hook], JSON_THROW_ON_ERROR), "\n";
}
?>
--EXPECT--
[[1,9,2,null],[4,11,9],"uninitialized",[9,1]]

@ -0,0 +1,34 @@
--TEST--
Non-nullable typed static property receiver preserves by-reference arguments
--FILE--
<?php
final class TypedStaticPropertyTarget
{
public function record(array &$events): void
{
$events[] = 'recorded';
}
}
final class TypedStaticPropertyHolder
{
public static TypedStaticPropertyTarget $target;
public static function run(array &$events): void
{
self::$target->record($events);
}
}
function main(): void
{
TypedStaticPropertyHolder::$target = new TypedStaticPropertyTarget();
$events = [];
TypedStaticPropertyHolder::run($events);
echo json_encode($events, JSON_THROW_ON_ERROR), "\n";
}
?>
--EXPECT--
["recorded"]
Loading…
Cancel
Save