fix(compiler): 修复返回类型协变检查 #30

Merged
韩天峰 merged 2 commits from compiler-fix-return-type-covariance into master 1 month ago
  1. 23
      phpunit/code/inheritance_error_return_intersection_missing.php
  2. 13
      phpunit/code/inheritance_error_return_never_widened.php
  3. 17
      phpunit/code/inheritance_error_return_static_widened.php
  4. 14
      phpunit/code/inheritance_error_return_union_widened.php
  5. 39
      phpunit/code/return_type_covariance_intersection.php
  6. 25
      phpunit/src/InheritanceErrorTest.php
  7. 2
      src/Generator/TypeCheckGenerator.php
  8. 4
      src/Preprocessor.php
  9. 161
      src/Translator.php
  10. 47
      tests/compiler/class/interface-return-covariance.phpt
  11. 120
      tests/compiler/type_decl/return-type-covariance.phpt

@ -0,0 +1,23 @@
<?php
interface IntersectionLeft
{
}
interface IntersectionRight
{
}
interface IntersectionReturnParent
{
public function value(): IntersectionLeft&IntersectionRight;
}
class IntersectionReturnChild implements IntersectionReturnParent
{
public function value(): IntersectionLeft
{
return new class implements IntersectionLeft {
};
}
}

@ -0,0 +1,13 @@
<?php
abstract class NeverReturnParent
{
abstract public function stop(): never;
}
abstract class NeverReturnChild extends NeverReturnParent
{
public function stop(): void
{
}
}

@ -0,0 +1,17 @@
<?php
class StaticReturnParent
{
public function value(): static
{
return $this;
}
}
class StaticReturnChild extends StaticReturnParent
{
public function value(): self
{
return $this;
}
}

@ -0,0 +1,14 @@
<?php
interface UnionReturnParent
{
public function value(): int|string;
}
class UnionReturnChild implements UnionReturnParent
{
public function value(): bool
{
return true;
}
}

@ -0,0 +1,39 @@
<?php
interface CovarianceLeft
{
}
interface CovarianceRight
{
}
class CovarianceBoth implements CovarianceLeft, CovarianceRight
{
}
interface IntersectionNarrowingContract
{
public function intersection(): CovarianceLeft;
}
class IntersectionNarrowingImpl implements IntersectionNarrowingContract
{
public function intersection(): CovarianceLeft&CovarianceRight
{
return new CovarianceBoth();
}
}
interface IntersectionContract
{
public function concrete(): CovarianceLeft&CovarianceRight;
}
class IntersectionImpl implements IntersectionContract
{
public function concrete(): CovarianceBoth
{
return new CovarianceBoth();
}
}

@ -53,6 +53,31 @@ class InheritanceErrorTest extends TestCase
$this->exec('must be compatible', 'inheritance_error_return_contravariant_class.php'); $this->exec('must be compatible', 'inheritance_error_return_contravariant_class.php');
} }
public function testUnionReturnTypeCannotBeWidenedToUnrelatedType(): void
{
$this->exec('must be compatible', 'inheritance_error_return_union_widened.php');
}
public function testIntersectionReturnTypeCannotDropAMember(): void
{
$this->exec('must be compatible', 'inheritance_error_return_intersection_missing.php');
}
public function testStaticReturnTypeCannotBeWidenedToSelf(): void
{
$this->exec('must be compatible', 'inheritance_error_return_static_widened.php');
}
public function testNeverReturnTypeCannotBeWidenedToVoid(): void
{
$this->exec('must be compatible', 'inheritance_error_return_never_widened.php');
}
public function testIntersectionReturnTypeCanNarrowToIntersectionOrConcreteSubtype(): void
{
$this->assertCompiles('return_type_covariance_intersection.php');
}
public function testParameterTypeCannotBeCovariant() public function testParameterTypeCannotBeCovariant()
{ {
$this->exec('must be compatible', 'inheritance_error_param_covariant_class.php'); $this->exec('must be compatible', 'inheritance_error_param_covariant_class.php');

@ -116,7 +116,7 @@ trait TypeCheckGenerator
return $class ? [['kind' => 'instanceof', 'class' => $class]] : []; return $class ? [['kind' => 'instanceof', 'class' => $class]] : [];
} }
private function typeCheckNodeToString(NodeAbstract $typeNode): string protected function typeCheckNodeToString(NodeAbstract $typeNode): string
{ {
if ($typeNode instanceof Node\Identifier) { if ($typeNode instanceof Node\Identifier) {
return $typeNode->name; return $typeNode->name;

@ -542,6 +542,9 @@ class Preprocessor extends CompilerBase
} }
$functionDef->exported = !($this->classDef?->exported === false || $this->hasNoExportAttribute($v)); $functionDef->exported = !($this->classDef?->exported === false || $this->hasNoExportAttribute($v));
$functionDef->returnClass = $class; $functionDef->returnClass = $class;
$functionDef->returnTypeStr = $v->returnType === null
? ''
: $this->typeCheckNodeToString($v->returnType);
// Record late-bound return type keywords so they can be re-resolved to // Record late-bound return type keywords so they can be re-resolved to
// the consuming class when a trait method is flattened into a class. // the consuming class when a trait method is flattened into a class.
$functionDef->returnTypeKeyword = $returnTypeKeyword; $functionDef->returnTypeKeyword = $returnTypeKeyword;
@ -560,7 +563,6 @@ class Preprocessor extends CompilerBase
$typeInfo = $this->buildTypeCheckFromNode($v->returnType); $typeInfo = $this->buildTypeCheckFromNode($v->returnType);
if (!empty($typeInfo['check'])) { if (!empty($typeInfo['check'])) {
$functionDef->returnTypeCheck = $typeInfo['check']; $functionDef->returnTypeCheck = $typeInfo['check'];
$functionDef->returnTypeStr = $typeInfo['typeStr'];
$functionDef->returnTypeNode = $v->returnType; $functionDef->returnTypeNode = $v->returnType;
} }
} }

@ -3624,7 +3624,12 @@ CODE;
)); ));
} }
if (!$this->isReturnTypeOverrideCompatible($childFuncDef, $parentFuncDef)) { if (!$this->isReturnTypeOverrideCompatible(
$childFuncDef,
$parentFuncDef,
$className,
$parentClass,
)) {
$this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass);
} }
if ($childFuncDef->returnsByRef !== $parentFuncDef->returnsByRef) { if ($childFuncDef->returnsByRef !== $parentFuncDef->returnsByRef) {
@ -3709,33 +3714,163 @@ CODE;
)); ));
} }
private function isReturnTypeOverrideCompatible(FunctionDef $childFuncDef, FunctionDef $parentFuncDef): bool private function isReturnTypeOverrideCompatible(
{ FunctionDef $childFuncDef,
FunctionDef $parentFuncDef,
string $childClass,
string $parentClass,
): bool {
if ($parentFuncDef->returnTypeUndeclared) { if ($parentFuncDef->returnTypeUndeclared) {
return true; return true;
} }
if ($childFuncDef->returnTypeUndeclared) { if ($childFuncDef->returnTypeUndeclared) {
return false; return false;
} }
if ($parentFuncDef->returnTypeCheck || $childFuncDef->returnTypeCheck) {
return $parentFuncDef->returnTypeStr === $childFuncDef->returnTypeStr; $parentTypes = $this->getReturnAcceptedTypes($parentFuncDef, $parentClass);
$childTypes = $this->getReturnAcceptedTypes($childFuncDef, $childClass);
// Type checks are stored in disjunctive normal form: the outer list is
// a union, while an allOf entry is an intersection. Every child union
// branch must imply at least one complete parent branch.
foreach ($childTypes as $childType) {
if (!$this->isReturnTypeCoveredBy($childType, $parentTypes)) {
return false;
}
} }
if ($parentFuncDef->returnType === Type::VAR) { return true;
return true; }
private function getReturnAcceptedTypes(FunctionDef $functionDef, string $declaringClass): array
{
if (!empty($functionDef->returnTypeCheck)) {
return array_map(
fn (array $type): array => $this->normalizeReturnTypeEntry($type, $declaringClass),
$functionDef->returnTypeCheck,
);
} }
if ($childFuncDef->returnType !== $parentFuncDef->returnType) {
return false; if ($functionDef->returnTypeKeyword === 'static') {
return [['kind' => 'isStatic', 'class' => $declaringClass]];
}
if ($functionDef->returnType === Type::OBJECT && $functionDef->returnClass !== '') {
return [['kind' => 'instanceof', 'class' => $functionDef->returnClass]];
}
$declaredType = strtolower($functionDef->returnTypeStr);
return match ($declaredType) {
'mixed' => [['kind' => 'isMixed']],
'never' => [['kind' => 'isNever']],
'void' => [['kind' => 'isVoid']],
'null' => [['kind' => 'isNull']],
'true' => [['kind' => 'isTrue']],
'false' => [['kind' => 'isFalse']],
'callable' => [['kind' => 'callable']],
'iterable' => [['kind' => 'iterable']],
'object' => [['kind' => 'isObject']],
default => match ($functionDef->returnType) {
Type::INT => [['kind' => 'isInt']],
Type::FLOAT => [['kind' => 'isFloat']],
Type::BOOL => [['kind' => 'isBool']],
Type::STR => [['kind' => 'isString']],
Type::ARRAY => [['kind' => 'isArray']],
Type::RESOURCE => [['kind' => 'isResource']],
Type::OBJECT => [['kind' => 'isObject']],
default => [['kind' => 'isMixed']],
},
};
}
private function normalizeReturnTypeEntry(array $type, string $declaringClass): array
{
if (($type['kind'] ?? null) === 'allOf') {
$type['types'] = array_map(
fn (array $member): array => $this->normalizeReturnTypeEntry($member, $declaringClass),
$type['types'],
);
} elseif (($type['kind'] ?? null) === 'instanceof' && ($type['class'] ?? null) === 'static') {
$type = ['kind' => 'isStatic', 'class' => $declaringClass];
}
return $type;
}
private function isReturnTypeCoveredBy(array $childType, array $parentTypes): bool
{
$childClause = ($childType['kind'] ?? null) === 'allOf'
? $childType['types']
: [$childType];
foreach ($parentTypes as $parentType) {
$parentClause = ($parentType['kind'] ?? null) === 'allOf'
? $parentType['types']
: [$parentType];
if ($this->isReturnTypeClauseSubtype($childClause, $parentClause)) {
return true;
}
} }
if ($parentFuncDef->returnType !== Type::OBJECT) { return false;
}
private function isReturnTypeClauseSubtype(array $childClause, array $parentClause): bool
{
foreach ($parentClause as $parentType) {
$covered = false;
foreach ($childClause as $childType) {
if ($this->isReturnTypeEntryCompatible($childType, $parentType)) {
$covered = true;
break;
}
}
if (!$covered) {
return false;
}
}
return true;
}
private function isReturnTypeEntryCompatible(array $childType, array $parentType): bool
{
$childKind = $childType['kind'] ?? null;
$parentKind = $parentType['kind'] ?? null;
if ($childKind === 'isNever' || $parentKind === 'isMixed') {
return true;
}
if (($childKind === 'isTrue' || $childKind === 'isFalse') && $parentKind === 'isBool') {
return true; return true;
} }
if ($childFuncDef->returnClass === $parentFuncDef->returnClass) { if ($childKind === 'isArray' && $parentKind === 'iterable') {
return true; return true;
} }
if (!$childFuncDef->returnClass || !$parentFuncDef->returnClass) { if ($childKind === 'isStatic') {
if ($parentKind === 'isObject' || $parentKind === 'isStatic') {
return true;
}
if ($parentKind === 'instanceof') {
return $this->isInheritedFrom(
$childType['class'] ?? '',
$parentType['class'] ?? '',
);
}
return false;
}
if ($childKind === 'instanceof') {
if ($parentKind === 'isObject') {
return true;
}
$childClass = $childType['class'] ?? '';
if ($parentKind === 'iterable') {
return $childClass !== '' && $this->isInheritedFrom($childClass, 'Traversable');
}
if ($parentKind === 'instanceof') {
$parentClass = $parentType['class'] ?? '';
return $childClass !== ''
&& $parentClass !== ''
&& $this->isInheritedFrom($childClass, $parentClass);
}
return false; return false;
} }
return $this->isInheritedFrom($childFuncDef->returnClass, $parentFuncDef->returnClass); return $childKind !== null && $childKind === $parentKind;
} }
private function isParameterTypeOverrideCompatible(ArgInfo $childArg, ArgInfo $parentArg): bool private function isParameterTypeOverrideCompatible(ArgInfo $childArg, ArgInfo $parentArg): bool

@ -0,0 +1,47 @@
--TEST--
Interface return type covariance with nullable interface and anonymous class
--FILE--
<?php
declare(strict_types=1);
interface TestInterface1
{
}
interface TestInterface2 extends TestInterface1
{
}
interface TestInterface3
{
public function test(): ?TestInterface1;
}
class TestClass implements TestInterface3
{
// Covariant: ?TestInterface2 is a subtype of ?TestInterface1 because
// TestInterface2 extends TestInterface1.
public function test(): ?TestInterface2
{
return new class() implements TestInterface2 {
public function hello(): string {
return "anon";
}
};
}
}
function main()
{
$test = new TestClass;
$result = $test->test();
var_dump($result instanceof TestInterface1);
var_dump($result instanceof TestInterface2);
var_dump($result === null);
}
?>
--EXPECT--
bool(true)
bool(true)
bool(false)

@ -0,0 +1,120 @@
--TEST--
Return type covariance: union narrowing and object subtype
--FILE--
<?php
interface UnionReturnContract
{
public function make(): int|string;
}
class UnionReturnImpl implements UnionReturnContract
{
// Covariant: narrowing a union return type (int|string -> int) is allowed.
public function make(): int
{
return 42;
}
}
class BaseType {}
class ChildType extends BaseType {}
interface ObjectReturnContract
{
public function build(): BaseType;
}
class ObjectReturnImpl implements ObjectReturnContract
{
// Covariant: returning a subtype (ChildType) for a BaseType return is allowed.
public function build(): ChildType
{
return new ChildType();
}
}
class StaticBase
{
public function copy(): ?self
{
return $this;
}
}
class StaticChild extends StaticBase
{
public function copy(): ?static
{
return $this;
}
}
interface IterableContract
{
public function values(): iterable;
}
class IterableImpl implements IterableContract
{
public function values(): array
{
return [1, 2];
}
}
interface BoolContract
{
public function enabled(): bool;
}
class LiteralBoolImpl implements BoolContract
{
public function enabled(): true
{
return true;
}
}
abstract class VoidContract
{
abstract public function stop(): void;
}
abstract class NeverImpl extends VoidContract
{
public function stop(): never
{
throw new RuntimeException('stop');
}
}
function main()
{
$impl = new UnionReturnImpl();
var_dump($impl->make());
$obj = new ObjectReturnImpl();
$built = $obj->build();
var_dump($built instanceof BaseType);
var_dump($built instanceof ChildType);
$static = new StaticChild();
var_dump($static->copy() instanceof StaticChild);
var_dump((new IterableImpl())->values());
var_dump((new LiteralBoolImpl())->enabled());
}
?>
--EXPECT--
int(42)
bool(true)
bool(true)
bool(true)
array(2) {
[0]=>
int(1)
[1]=>
int(2)
}
bool(true)
Loading…
Cancel
Save