fix(compiler): enforce complete return covariance rules

pull/30/head
韩天峰 1 month ago
parent c3b24fdd32
commit 71caa933d6
  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. 213
      src/Translator.php
  10. 69
      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,143 +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;
} }
// A parent that accepts everything (mixed/var) is compatible with any
// child return type.
if ($parentFuncDef->returnType === Type::VAR) {
return true;
}
$parentTypes = $this->getReturnAcceptedTypes($parentFuncDef); $parentTypes = $this->getReturnAcceptedTypes($parentFuncDef, $parentClass);
$childTypes = $this->getReturnAcceptedTypes($childFuncDef); $childTypes = $this->getReturnAcceptedTypes($childFuncDef, $childClass);
// Return type covariance: every value the child can return must also be // Type checks are stored in disjunctive normal form: the outer list is
// acceptable under the parent's declared return type. This allows a // a union, while an allOf entry is an intersection. Every child union
// child to narrow a nullable/union return type (e.g. `?Base` -> `?Child` // branch must imply at least one complete parent branch.
// or `int|string` -> `int`) while still satisfying the parent contract. foreach ($childTypes as $childType) {
return $this->isReturnTypeSubtype($childTypes, $parentTypes); if (!$this->isReturnTypeCoveredBy($childType, $parentTypes)) {
return false;
}
}
return true;
} }
private function getReturnAcceptedTypes(FunctionDef $functionDef): array private function getReturnAcceptedTypes(FunctionDef $functionDef, string $declaringClass): array
{ {
if (!empty($functionDef->returnTypeCheck)) { if (!empty($functionDef->returnTypeCheck)) {
return $functionDef->returnTypeCheck; return array_map(
} fn (array $type): array => $this->normalizeReturnTypeEntry($type, $declaringClass),
$type = $functionDef->returnType; $functionDef->returnTypeCheck,
if ($type === Type::VAR) { );
return [['kind' => 'isMixed']];
}
if ($type === Type::OBJECT) {
return $functionDef->returnClass
? [['kind' => 'instanceof', 'class' => $functionDef->returnClass]]
: [['kind' => 'isObject']];
} }
return match ($type) {
Type::INT => [['kind' => 'isInt']], if ($functionDef->returnTypeKeyword === 'static') {
Type::FLOAT => [['kind' => 'isFloat']], return [['kind' => 'isStatic', 'class' => $declaringClass]];
Type::BOOL => [['kind' => 'isBool']], }
Type::STR => [['kind' => 'isString']], if ($functionDef->returnType === Type::OBJECT && $functionDef->returnClass !== '') {
Type::ARRAY => [['kind' => 'isArray']], return [['kind' => 'instanceof', 'class' => $functionDef->returnClass]];
Type::RESOURCE => [['kind' => 'isResource']], }
default => [['kind' => 'isMixed']],
$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 isReturnTypeSubtype(array $childTypes, array $parentTypes): bool private function normalizeReturnTypeEntry(array $type, string $declaringClass): array
{ {
foreach ($childTypes as $childType) { if (($type['kind'] ?? null) === 'allOf') {
if (!$this->isReturnTypeCoveredBy($childType, $parentTypes)) { $type['types'] = array_map(
return false; 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 true; return $type;
} }
private function isReturnTypeCoveredBy(array $childType, array $parentTypes): bool private function isReturnTypeCoveredBy(array $childType, array $parentTypes): bool
{ {
$childKind = $childType['kind'] ?? null; $childClause = ($childType['kind'] ?? null) === 'allOf'
? $childType['types']
: [$childType];
// Child is an intersection (A&B): it is a subtype only if every member foreach ($parentTypes as $parentType) {
// is individually a subtype of the parent type. $parentClause = ($parentType['kind'] ?? null) === 'allOf'
if ($childKind === 'allOf') { ? $parentType['types']
foreach ($childType['types'] as $member) { : [$parentType];
if (!$this->isReturnTypeCoveredBy($member, $parentTypes)) { if ($this->isReturnTypeClauseSubtype($childClause, $parentClause)) {
return false; return true;
}
} }
return true;
} }
return false;
}
foreach ($parentTypes as $parentType) { private function isReturnTypeClauseSubtype(array $childClause, array $parentClause): bool
$parentKind = $parentType['kind'] ?? null; {
foreach ($parentClause as $parentType) {
// Parent is an intersection (A&B): the child must be a subtype of $covered = false;
// every member of the intersection. foreach ($childClause as $childType) {
if ($parentKind === 'allOf') { if ($this->isReturnTypeEntryCompatible($childType, $parentType)) {
$ok = true; $covered = true;
foreach ($parentType['types'] as $member) { break;
if (!$this->isReturnTypeCoveredBy($childType, [$member])) {
$ok = false;
break;
}
}
if ($ok) {
return true;
} }
continue;
} }
if (!$covered) {
if ($this->isReturnTypeEntryCompatible($childKind, $childType, $parentKind, $parentType)) { return false;
return true;
} }
} }
return true;
return false;
} }
private function isReturnTypeEntryCompatible( private function isReturnTypeEntryCompatible(array $childType, array $parentType): bool
?string $childKind, {
array $childType, $childKind = $childType['kind'] ?? null;
?string $parentKind, $parentKind = $parentType['kind'] ?? null;
array $parentType
): bool { if ($childKind === 'isNever' || $parentKind === 'isMixed') {
if ($childKind === 'isNull') { return true;
// A null value is only compatible with a nullable (isNull) parent.
return $parentKind === 'isNull';
} }
if ($childKind === 'isObject') { if (($childKind === 'isTrue' || $childKind === 'isFalse') && $parentKind === 'isBool') {
// Any object is compatible with a parent that accepts any object. return true;
return $parentKind === 'isObject';
} }
if ($childKind === 'isMixed') { if ($childKind === 'isArray' && $parentKind === 'iterable') {
return $parentKind === 'isMixed'; return true;
}
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 ($childKind === 'instanceof') {
if ($parentKind === 'isObject') { if ($parentKind === 'isObject') {
return true; return true;
} }
$childClass = $childType['class'] ?? '';
if ($parentKind === 'iterable') {
return $childClass !== '' && $this->isInheritedFrom($childClass, 'Traversable');
}
if ($parentKind === 'instanceof') { if ($parentKind === 'instanceof') {
$childClass = $childType['class'] ?? '';
$parentClass = $parentType['class'] ?? ''; $parentClass = $parentType['class'] ?? '';
if ($childClass === '' || $parentClass === '' || $childClass === 'static' || $parentClass === 'static') { return $childClass !== ''
return false; && $parentClass !== ''
} && $this->isInheritedFrom($childClass, $parentClass);
if ($childClass === $parentClass) {
return true;
}
return $this->isInheritedFrom($childClass, $parentClass);
} }
return false; return false;
} }
// Scalar kinds must match exactly. return $childKind !== null && $childKind === $parentKind;
return $childKind === $parentKind;
} }
private function isParameterTypeOverrideCompatible(ArgInfo $childArg, ArgInfo $parentArg): bool private function isParameterTypeOverrideCompatible(ArgInfo $childArg, ArgInfo $parentArg): bool

@ -34,6 +34,61 @@ class ObjectReturnImpl implements ObjectReturnContract
} }
} }
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() function main()
{ {
$impl = new UnionReturnImpl(); $impl = new UnionReturnImpl();
@ -43,9 +98,23 @@ function main()
$built = $obj->build(); $built = $obj->build();
var_dump($built instanceof BaseType); var_dump($built instanceof BaseType);
var_dump($built instanceof ChildType); 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-- --EXPECT--
int(42) int(42)
bool(true) bool(true)
bool(true) bool(true)
bool(true)
array(2) {
[0]=>
int(1)
[1]=>
int(2)
}
bool(true)

Loading…
Cancel
Save