fix(trait): preserve late-bound abstract type semantics

master
韩天峰 2 days ago
parent 3a916a9aa1
commit 7d698987d7
  1. 14
      phpunit/code/trait_abstract_explicit_trait_parameter.php
  2. 17
      phpunit/code/trait_abstract_explicit_trait_return.php
  3. 17
      phpunit/code/trait_abstract_explicit_trait_union_return.php
  4. 27
      phpunit/code/trait_abstract_nested_parent_unavailable.php
  5. 19
      phpunit/code/trait_abstract_parent_return.php
  6. 16
      phpunit/code/trait_abstract_parent_union_param.php
  7. 48
      phpunit/src/TraitAbstractRequirementTest.php
  8. 3
      src/Entity/ArgInfo.php
  9. 47
      src/Generator/TypeCheckGenerator.php
  10. 2
      src/Preprocessor.php
  11. 281
      src/Translator.php
  12. 112
      tests/compiler/trait/trait-abstract-late-bound-types.phpt

@ -0,0 +1,14 @@
<?php
trait ExplicitTraitParameterRequirement
{
abstract public function accept(ExplicitTraitParameterRequirement $value): void;
}
class InvalidExplicitTraitParameter
{
use ExplicitTraitParameterRequirement;
public function accept(self $value): void {}
}
function main() {}

@ -0,0 +1,17 @@
<?php
trait ExplicitTraitReturnRequirement
{
abstract public function make(): ExplicitTraitReturnRequirement;
}
class InvalidExplicitTraitReturn
{
use ExplicitTraitReturnRequirement;
public function make(): self
{
return $this;
}
}
function main() {}

@ -0,0 +1,17 @@
<?php
trait ExplicitTraitUnionRequirement
{
abstract public function make(): ExplicitTraitUnionRequirement|null;
}
class InvalidExplicitTraitUnionReturn
{
use ExplicitTraitUnionRequirement;
public function make(): self|null
{
return $this;
}
}
function main() {}

@ -0,0 +1,27 @@
<?php
class NestedParentBase {}
trait NestedParentRequirement
{
abstract public function copy(parent $value): parent;
}
trait NestedParentImplementation
{
public function copy(NestedParentBase $value): NestedParentBase
{
return $value;
}
}
trait InvalidNestedParentComposition
{
use NestedParentRequirement, NestedParentImplementation;
}
class NestedParentConsumer extends NestedParentBase
{
use InvalidNestedParentComposition;
}
function main() {}

@ -0,0 +1,19 @@
<?php
class ParentReturnBase {}
trait ParentReturnRequirement
{
abstract public function make(): parent;
}
class InvalidParentReturnImplementation extends ParentReturnBase
{
use ParentReturnRequirement;
public function make(): stdClass
{
return new stdClass();
}
}
function main() {}

@ -0,0 +1,16 @@
<?php
class ParentParamBase {}
trait ParentParamRequirement
{
abstract public function accept(parent|null $value): void;
}
class InvalidParentParamImplementation extends ParentParamBase
{
use ParentParamRequirement;
public function accept(stdClass|null $value): void {}
}
function main() {}

@ -72,4 +72,52 @@ class TraitAbstractRequirementTest extends BaseTest
// an abstract trait requirement.
$this->compile('trait_abstract_variance_ok.php');
}
public function testExplicitTraitReturnIsNotTreatedAsSelf(): void
{
$this->exec(
'Declaration of `InvalidExplicitTraitReturn::make()` must be compatible with `ExplicitTraitReturnRequirement::make()`',
'trait_abstract_explicit_trait_return.php'
);
}
public function testExplicitTraitTypeInUnionIsNotTreatedAsSelf(): void
{
$this->exec(
'Declaration of `InvalidExplicitTraitUnionReturn::make()` must be compatible with `ExplicitTraitUnionRequirement::make()`',
'trait_abstract_explicit_trait_union_return.php'
);
}
public function testExplicitTraitParameterIsNotTreatedAsSelf(): void
{
$this->exec(
'Declaration of `InvalidExplicitTraitParameter::accept()` must be compatible with `ExplicitTraitParameterRequirement::accept()`',
'trait_abstract_explicit_trait_parameter.php'
);
}
public function testParentReturnUsesTheConsumingClassParent(): void
{
$this->exec(
'Declaration of `InvalidParentReturnImplementation::make()` must be compatible with `ParentReturnRequirement::make()`',
'trait_abstract_parent_return.php'
);
}
public function testParentUnionParameterUsesTheConsumingClassParent(): void
{
$this->exec(
'Declaration of `InvalidParentParamImplementation::accept()` must be compatible with `ParentParamRequirement::accept()`',
'trait_abstract_parent_union_param.php'
);
}
public function testNestedTraitCannotResolveParentFromAnEventualConsumer(): void
{
$this->exec(
'Cannot use "parent" when current class scope has no parent',
'trait_abstract_nested_parent_unavailable.php'
);
}
}

@ -48,7 +48,8 @@ class ArgInfo
public bool $immutable = false;
/**
* Each element: ['kind' => 'isInt'|'isFloat'|...|'instanceof', 'class' => '']
* Each element: ['kind' => 'isInt'|'isFloat'|...|'instanceof', 'class' => '',
* optional 'lateBound' => 'self'|'static'|'parent'].
* Null means no runtime type check needed.
*/
public ?array $typeCheck = null;

@ -19,6 +19,31 @@ use PhpParser\NodeAbstract;
trait TypeCheckGenerator
{
protected const string LATE_BOUND_TYPE_ATTRIBUTE = 'typephp_late_bound_type';
protected function markLateBoundTypeNodes(?NodeAbstract $type): void
{
if ($type === null) {
return;
}
if ($type instanceof NullableType) {
$this->markLateBoundTypeNodes($type->type);
return;
}
if ($type instanceof UnionType || $type instanceof IntersectionType) {
foreach ($type->types as $member) {
$this->markLateBoundTypeNodes($member);
}
return;
}
if ($type instanceof Node\Name) {
$name = strtolower($type->toString());
if (in_array($name, ['self', 'static', 'parent'], true)) {
$type->setAttribute(self::LATE_BOUND_TYPE_ATTRIBUTE, $name);
}
}
}
protected function isStrictScalarType(string $type): bool
{
return in_array($type, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR], true);
@ -198,17 +223,31 @@ trait TypeCheckGenerator
return [$entry];
}
if ($name === 'self') {
$lateBound = $typeNode instanceof Node\Name
? $typeNode->getAttribute(self::LATE_BOUND_TYPE_ATTRIBUTE, '')
: '';
$lateBound = is_string($lateBound) ? $lateBound : '';
if ($lateBound === 'self') {
$class = $this->getFullClassLikeName();
} elseif ($name === 'parent') {
} elseif ($lateBound === 'parent') {
$class = $this->classDef->extends ?? '';
} elseif ($name === 'static') {
} elseif ($lateBound === 'static') {
$class = 'static';
} else {
$class = $this->getNamespacedClassName($name);
}
return $class ? [['kind' => 'instanceof', 'class' => $class]] : [];
if ($lateBound !== '') {
$typeNode->setAttribute(self::LATE_BOUND_TYPE_ATTRIBUTE, $lateBound);
}
if ($class === '' && !($lateBound === 'parent' && $this->classDef?->trait !== null)) {
return [];
}
$entry = ['kind' => 'instanceof', 'class' => $class];
if ($lateBound !== '') {
$entry['lateBound'] = $lateBound;
}
return [$entry];
}
protected function typeCheckNodeToString(NodeAbstract $typeNode): string

@ -774,6 +774,7 @@ class Preprocessor extends CompilerBase
protected function parseParameterType(Node\Param $param, ArgInfo $argInfo, string $var): string
{
$this->markLateBoundTypeNodes($param->type);
// Capture the late-bound parameter type keyword *before* resolveTypeDecl
// runs, because resolveTypeDecl mutates the `self`/`static`/`parent` node
// name to the declaring class when the method belongs to a trait.
@ -978,6 +979,7 @@ class Preprocessor extends CompilerBase
}
$fnName = $this->parseIdentifier($v->name);
$this->markLateBoundTypeNodes($v->returnType);
// Capture the late-bound return type keyword *before* resolveTypeDecl runs,
// because resolveTypeDecl mutates the `self`/`static`/`parent` node name to
// the declaring class when the method belongs to a trait.

@ -3067,8 +3067,9 @@ CODE;
$traitMethods = [];
$traitConstants = [];
$traitProperties = [];
$consumingClass = $className->toString();
$classDef = $this->getClass($consumingClass);
$classDef = $this->getClass($className->toString());
$usingClassDef = $classDef;
$compositionOwner = $classDef->getNamespacedName(false);
foreach ($stmt->stmts as $classStmt) {
if ($classStmt instanceof Node\Stmt\ClassMethod) {
@ -3130,7 +3131,11 @@ CODE;
// arginfo reflects the consuming class (PHP trait semantics) and
// passes ZendVM's runtime signature-compatibility checks. The
// alias clones below inherit this rewrite.
$this->reresolveTraitMethodAstLateBoundTypes($classDef, $traitFullName, $traitStmt);
$traitOrigin = (string) $traitStmt->getAttribute(
self::TRAIT_ORIGIN_ATTRIBUTE,
$traitFullName,
);
$this->reresolveTraitMethodAstLateBoundTypes($usingClassDef, $traitOrigin, $traitStmt);
// Every adaptation derives its flags from the original
// method's flags: a same-name visibility change must not
// leak into aliases of the same method that are processed
@ -3153,9 +3158,9 @@ CODE;
$this->validateTraitAbstractImplementation(
$classStmt,
$traitStmt, $requirementSource, $requirementDef,
$methods[$aliasName], $consumingClass,
$methods[$aliasName], $compositionOwner,
$classDef->methods[$aliasName] ?? $classDef->abstractMethodDefs[$aliasName] ?? null,
$consumingClass,
$usingClassDef,
);
}
} elseif (!isset($traitMethods[$aliasName])) {
@ -3183,9 +3188,9 @@ CODE;
$this->validateTraitAbstractImplementation(
$classStmt,
$traitStmt, $requirementSource, $requirementDef,
$methods[$methodName], $consumingClass,
$methods[$methodName], $compositionOwner,
$classDef->methods[$methodName] ?? $classDef->abstractMethodDefs[$methodName] ?? null,
$consumingClass,
$usingClassDef,
);
}
unset($traitStmts[$k1]);
@ -3216,7 +3221,7 @@ CODE;
$classStmt,
$traitStmt, $requirementSource, $requirementDef,
$existingStmt, $implementationSource, $implementationDef,
$consumingClass,
$usingClassDef,
);
unset($traitStmts[$k1]);
continue;
@ -3233,7 +3238,7 @@ CODE;
$classStmt,
$existingStmt, $requirementSource, $requirementDef,
$traitStmt, $implementationSource, $implementationDef,
$consumingClass,
$usingClassDef,
);
foreach ($stmt->stmts as $k3 => $mergedStmt) {
if ($mergedStmt === $existingStmt) {
@ -3374,10 +3379,11 @@ CODE;
Node\Stmt\ClassMethod $implementation,
string $implementationSource,
?MethodDef $implementationDef,
string $consumingClass,
ClassDef $usingClassDef,
): void {
$requirementName = $requirement->name->toString();
$implementationName = $implementation->name->toString();
$consumingClass = $usingClassDef->getNamespacedName(false);
if ($requirement->isStatic() !== $implementation->isStatic()) {
$this->fatalError($errorNode, $requirement->isStatic()
@ -3436,13 +3442,12 @@ CODE;
if ($implArg === null) {
continue;
}
// Late-bound keywords (`self`, `static`, `parent`) were resolved
// against different declaring types but denote the same type once
// both methods are flattened into the consuming class.
if ($requiredArg->typeKeyword !== '' && $requiredArg->typeKeyword === $implArg->typeKeyword) {
continue;
}
if (!$this->isParameterTypeOverrideCompatible($implArg, $requiredArg)) {
if (!$this->isTraitParameterTypeCompatible(
$implArg,
$requiredArg,
$usingClassDef,
$errorNode,
)) {
$incompatible();
}
}
@ -3453,20 +3458,15 @@ CODE;
if ($implementationFunc->returnTypeUndeclared) {
$incompatible();
}
if ($requirementFunc->returnTypeKeyword !== ''
&& $requirementFunc->returnTypeKeyword === $implementationFunc->returnTypeKeyword
) {
return;
}
$requirementTypes = $this->remapInstanceofClass(
$this->getReturnAcceptedTypes($requirementFunc, $consumingClass),
$requirementSource,
$consumingClass,
$requirementTypes = $this->getTraitReturnAcceptedTypes(
$requirementFunc,
$usingClassDef,
$errorNode,
);
$implementationTypes = $this->remapInstanceofClass(
$this->getReturnAcceptedTypes($implementationFunc, $consumingClass),
$implementationSource,
$consumingClass,
$implementationTypes = $this->getTraitReturnAcceptedTypes(
$implementationFunc,
$usingClassDef,
$errorNode,
);
foreach ($implementationTypes as $implementationType) {
if (!$this->isReturnTypeCoveredBy($implementationType, $requirementTypes)) {
@ -3489,24 +3489,105 @@ CODE;
return $required;
}
/**
* Replace `instanceof` references to a trait in a DNF type-check list with
* the consuming class. `self` (and `parent`) inside a trait were resolved
* to the trait itself during preprocessing, but once the method is
* flattened they denote the consuming class, and no object can ever be an
* instance of a trait.
*/
private function remapInstanceofClass(array $types, string $from, string $to): array
{
if ($from === $to) {
return $types;
private function isTraitParameterTypeCompatible(
ArgInfo $implementation,
ArgInfo $requirement,
ClassDef $usingClassDef,
Node $errorNode,
): bool {
if ($this->isTopParameterType($implementation)) {
return true;
}
if ($this->isTopParameterType($requirement)) {
return false;
}
$requirementTypes = $this->getTraitParameterAcceptedTypes(
$requirement,
$usingClassDef,
$errorNode,
);
$implementationTypes = $this->getTraitParameterAcceptedTypes(
$implementation,
$usingClassDef,
$errorNode,
);
if ($requirementTypes === null || $implementationTypes === null) {
return $this->isParameterTypeOverrideCompatible($implementation, $requirement);
}
return $this->isAcceptedTypeSubset($requirementTypes, $implementationTypes);
}
private function getTraitParameterAcceptedTypes(
ArgInfo $argument,
ClassDef $usingClassDef,
Node $errorNode,
): ?array {
if ($argument->typeKeyword !== '') {
return $this->getLateBoundTraitAcceptedType($argument->typeKeyword, $usingClassDef, $errorNode);
}
return $this->resolveLateBoundAcceptedTypes(
$this->getParameterAcceptedTypes($argument),
$usingClassDef,
$errorNode,
);
}
private function getTraitReturnAcceptedTypes(
FunctionDef $function,
ClassDef $usingClassDef,
Node $errorNode,
): array {
if ($function->returnTypeKeyword !== '') {
return $this->getLateBoundTraitAcceptedType($function->returnTypeKeyword, $usingClassDef, $errorNode);
}
return $this->resolveLateBoundAcceptedTypes(
$this->getReturnAcceptedTypes($function, $usingClassDef->getNamespacedName(false)),
$usingClassDef,
$errorNode,
) ?? [];
}
private function getLateBoundTraitAcceptedType(
string $keyword,
ClassDef $usingClassDef,
Node $errorNode,
): array {
if ($keyword === 'static') {
return [['kind' => 'isStatic', 'class' => $usingClassDef->getNamespacedName(false)]];
}
$class = $this->resolveLateBoundClass($usingClassDef, $keyword);
if ($class === null) {
$this->fatalError($errorNode, 'Cannot use "parent" when current class scope has no parent');
}
return [['kind' => 'instanceof', 'class' => $class]];
}
private function resolveLateBoundAcceptedTypes(
?array $types,
ClassDef $usingClassDef,
Node $errorNode,
): ?array {
if ($types === null) {
return null;
}
foreach ($types as &$type) {
if (($type['kind'] ?? null) === 'allOf') {
$type['types'] = $this->remapInstanceofClass($type['types'], $from, $to);
} elseif (($type['kind'] ?? null) === 'instanceof' && ($type['class'] ?? null) === $from) {
$type['class'] = $to;
$type['types'] = $this->resolveLateBoundAcceptedTypes(
$type['types'],
$usingClassDef,
$errorNode,
);
continue;
}
$lateBound = $type['lateBound'] ?? '';
if (!is_string($lateBound) || $lateBound === '') {
continue;
}
$resolved = $this->getLateBoundTraitAcceptedType($lateBound, $usingClassDef, $errorNode)[0];
$type['kind'] = $resolved['kind'];
$type['class'] = $resolved['class'];
unset($type['lateBound']);
}
return $types;
}
@ -3538,21 +3619,26 @@ CODE;
return;
}
$traitDef = $this->getClass($traitFullName);
if (!$traitDef->hasMethod($methodStmt->name->toString())) {
$methodName = strtolower($methodStmt->name->toString());
$methodDef = $traitDef->methods[$methodName]
?? $traitDef->abstractMethodDefs[$methodName]
?? null;
if ($methodDef === null) {
return;
}
$fn = $traitDef->getMethod($methodStmt->name->toString())->functionDef;
$fn = $methodDef->functionDef;
if ($fn->returnTypeKeyword !== '' && $methodStmt->returnType instanceof Node\Name) {
if ($fn->returnTypeKeyword === 'static') {
// `static` remains late-bound in the composed method signature.
$methodStmt->returnType = new Node\Name('static');
} else {
$resolved = $this->resolveLateBoundClass($usingClassDef, $fn->returnTypeKeyword);
if ($resolved !== null) {
$methodStmt->returnType = new Node\Name($resolved);
}
}
$methodStmt->returnType = $this->resolveTraitTypeNode(
$methodStmt->returnType,
$fn->returnTypeKeyword,
$usingClassDef,
);
} elseif ($methodStmt->returnType !== null) {
$methodStmt->returnType = $this->reresolveCompositeTraitTypeNode(
$methodStmt->returnType,
$usingClassDef,
);
}
foreach ($fn->argInfoList as $i => $arg) {
@ -3561,16 +3647,89 @@ CODE;
&& isset($methodStmt->params[$i])
&& $methodStmt->params[$i]->type instanceof Node\Name
) {
if ($arg->typeKeyword === 'static') {
$methodStmt->params[$i]->type = new Node\Name('static');
} else {
$resolved = $this->resolveLateBoundClass($usingClassDef, $arg->typeKeyword);
if ($resolved !== null) {
$methodStmt->params[$i]->type = new Node\Name($resolved);
$methodStmt->params[$i]->type = $this->resolveTraitTypeNode(
$methodStmt->params[$i]->type,
$arg->typeKeyword,
$usingClassDef,
);
} elseif (isset($methodStmt->params[$i]) && $methodStmt->params[$i]->type !== null) {
$methodStmt->params[$i]->type = $this->reresolveCompositeTraitTypeNode(
$methodStmt->params[$i]->type,
$usingClassDef,
);
}
}
}
private function reresolveCompositeTraitTypeNode(
Node\ComplexType|Node\Identifier|Node\Name $type,
ClassDef $usingClassDef,
): Node\ComplexType|Node\Identifier|Node\Name {
if ($type instanceof Node\NullableType) {
$type->type = $this->reresolveTraitTypeMember($type->type, $usingClassDef);
return $type;
}
if ($type instanceof Node\IntersectionType) {
foreach ($type->types as $i => $member) {
$type->types[$i] = $this->reresolveTraitTypeMember($member, $usingClassDef);
}
return $type;
}
if ($type instanceof Node\UnionType) {
foreach ($type->types as $i => $member) {
if ($member instanceof Node\IntersectionType) {
foreach ($member->types as $j => $intersectionMember) {
$member->types[$j] = $this->reresolveTraitTypeMember(
$intersectionMember,
$usingClassDef,
);
}
} else {
$type->types[$i] = $this->reresolveTraitTypeMember($member, $usingClassDef);
}
}
return $type;
}
return $this->reresolveTraitTypeMember($type, $usingClassDef);
}
private function reresolveTraitTypeMember(
Node\Identifier|Node\Name $type,
ClassDef $usingClassDef,
): Node\Identifier|Node\Name {
if (!$type instanceof Node\Name) {
return $type;
}
$keyword = $type->getAttribute(self::LATE_BOUND_TYPE_ATTRIBUTE);
if (!is_string($keyword) || $keyword === '') {
return $type;
}
return $this->resolveTraitTypeNode($type, $keyword, $usingClassDef);
}
private function resolveTraitTypeNode(
Node\Name $type,
string $keyword,
ClassDef $usingClassDef,
): Node\Name {
if ($keyword === 'static') {
return new Node\Name('static', $type->getAttributes());
}
$resolved = $this->resolveLateBoundClass($usingClassDef, $keyword);
if ($resolved === null) {
// A trait can import another trait whose signature contains
// `parent` before any class consumes either one. PHP keeps that
// type late-bound until the outer trait is flattened into a
// class. Signature compatibility checks performed while traits
// are composed still reject `parent` in this scope, because
// there is no parent against which the two methods can be
// compared.
if ($keyword === 'parent' && $usingClassDef->trait !== null) {
return new Node\Name('parent', $type->getAttributes());
}
$this->fatalError($type, 'Cannot use "parent" when current class scope has no parent');
}
return new Node\Name\FullyQualified($resolved, $type->getAttributes());
}
/**

@ -0,0 +1,112 @@
--TEST--
Trait abstract requirements resolve self and parent in the composition scope
--FILE--
<?php
class LateBoundBase {}
trait LateBoundRequirement {
abstract public function selfValue(self|null $value): self|null;
abstract public function parentValue(parent|null $value): parent|null;
}
class LateBoundConsumer extends LateBoundBase {
use LateBoundRequirement;
public function selfValue(LateBoundConsumer|null $value): LateBoundConsumer|null {
return $value;
}
public function parentValue(LateBoundBase|null $value): LateBoundBase|null {
return $value;
}
}
trait NestedSelfRequirement {
abstract public function copy(self $value): self;
}
trait NestedSelfImplementation {
public function copy(self $value): self {
return $value;
}
}
trait NestedSelfComposition {
use NestedSelfRequirement, NestedSelfImplementation;
}
class NestedSelfConsumer {
use NestedSelfComposition;
}
trait NestedParentMethod {
public function parentFromNestedTrait(): parent {
return new LateBoundBase();
}
}
trait NestedParentMethodComposition {
use NestedParentMethod;
}
class NestedParentMethodConsumer extends LateBoundBase {
use NestedParentMethodComposition;
}
trait NestedParentRequirement {
abstract public function nestedParent(parent $value): parent;
}
trait NestedParentRequirementComposition {
use NestedParentRequirement;
}
class NestedParentRequirementConsumer extends LateBoundBase {
use NestedParentRequirementComposition;
public function nestedParent(LateBoundBase $value): LateBoundBase {
return $value;
}
}
trait DeferredRequirement {
abstract public function deferred(self|null $value): self|null;
}
abstract class DeferredBase {
use DeferredRequirement;
}
class DeferredImplementation extends DeferredBase {
public function deferred(DeferredBase|null $value): DeferredImplementation|null {
return $this;
}
}
function main(): void {
$consumer = new LateBoundConsumer();
var_dump($consumer->selfValue($consumer) === $consumer);
$base = new LateBoundBase();
var_dump($consumer->parentValue($base) === $base);
$nested = new NestedSelfConsumer();
var_dump($nested->copy($nested) === $nested);
$nestedParentMethod = new NestedParentMethodConsumer();
var_dump($nestedParentMethod->parentFromNestedTrait() instanceof LateBoundBase);
$nestedParentRequirement = new NestedParentRequirementConsumer();
var_dump($nestedParentRequirement->nestedParent($base) === $base);
$deferred = new DeferredImplementation();
var_dump($deferred->deferred(null) === $deferred);
}
?>
--EXPECT--
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
Loading…
Cancel
Save