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');
}
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()
{
$this->exec('must be compatible', 'inheritance_error_param_covariant_class.php');

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

@ -542,6 +542,9 @@ class Preprocessor extends CompilerBase
}
$functionDef->exported = !($this->classDef?->exported === false || $this->hasNoExportAttribute($v));
$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
// the consuming class when a trait method is flattened into a class.
$functionDef->returnTypeKeyword = $returnTypeKeyword;
@ -560,7 +563,6 @@ class Preprocessor extends CompilerBase
$typeInfo = $this->buildTypeCheckFromNode($v->returnType);
if (!empty($typeInfo['check'])) {
$functionDef->returnTypeCheck = $typeInfo['check'];
$functionDef->returnTypeStr = $typeInfo['typeStr'];
$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);
}
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) {
return true;
}
if ($childFuncDef->returnTypeUndeclared) {
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);
$childTypes = $this->getReturnAcceptedTypes($childFuncDef);
$parentTypes = $this->getReturnAcceptedTypes($parentFuncDef, $parentClass);
$childTypes = $this->getReturnAcceptedTypes($childFuncDef, $childClass);
// Return type covariance: every value the child can return must also be
// acceptable under the parent's declared return type. This allows a
// child to narrow a nullable/union return type (e.g. `?Base` -> `?Child`
// or `int|string` -> `int`) while still satisfying the parent contract.
return $this->isReturnTypeSubtype($childTypes, $parentTypes);
// 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;
}
}
return true;
}
private function getReturnAcceptedTypes(FunctionDef $functionDef): array
private function getReturnAcceptedTypes(FunctionDef $functionDef, string $declaringClass): array
{
if (!empty($functionDef->returnTypeCheck)) {
return $functionDef->returnTypeCheck;
}
$type = $functionDef->returnType;
if ($type === Type::VAR) {
return [['kind' => 'isMixed']];
}
if ($type === Type::OBJECT) {
return $functionDef->returnClass
? [['kind' => 'instanceof', 'class' => $functionDef->returnClass]]
: [['kind' => 'isObject']];
return array_map(
fn (array $type): array => $this->normalizeReturnTypeEntry($type, $declaringClass),
$functionDef->returnTypeCheck,
);
}
return match ($type) {
Type::INT => [['kind' => 'isInt']],
Type::FLOAT => [['kind' => 'isFloat']],
Type::BOOL => [['kind' => 'isBool']],
Type::STR => [['kind' => 'isString']],
Type::ARRAY => [['kind' => 'isArray']],
Type::RESOURCE => [['kind' => 'isResource']],
default => [['kind' => 'isMixed']],
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 isReturnTypeSubtype(array $childTypes, array $parentTypes): bool
private function normalizeReturnTypeEntry(array $type, string $declaringClass): array
{
foreach ($childTypes as $childType) {
if (!$this->isReturnTypeCoveredBy($childType, $parentTypes)) {
return false;
}
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 true;
return $type;
}
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
// is individually a subtype of the parent type.
if ($childKind === 'allOf') {
foreach ($childType['types'] as $member) {
if (!$this->isReturnTypeCoveredBy($member, $parentTypes)) {
return false;
}
foreach ($parentTypes as $parentType) {
$parentClause = ($parentType['kind'] ?? null) === 'allOf'
? $parentType['types']
: [$parentType];
if ($this->isReturnTypeClauseSubtype($childClause, $parentClause)) {
return true;
}
return true;
}
return false;
}
foreach ($parentTypes as $parentType) {
$parentKind = $parentType['kind'] ?? null;
// Parent is an intersection (A&B): the child must be a subtype of
// every member of the intersection.
if ($parentKind === 'allOf') {
$ok = true;
foreach ($parentType['types'] as $member) {
if (!$this->isReturnTypeCoveredBy($childType, [$member])) {
$ok = false;
break;
}
}
if ($ok) {
return true;
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;
}
continue;
}
if ($this->isReturnTypeEntryCompatible($childKind, $childType, $parentKind, $parentType)) {
return true;
if (!$covered) {
return false;
}
}
return false;
return true;
}
private function isReturnTypeEntryCompatible(
?string $childKind,
array $childType,
?string $parentKind,
array $parentType
): bool {
if ($childKind === 'isNull') {
// A null value is only compatible with a nullable (isNull) parent.
return $parentKind === 'isNull';
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 === 'isObject') {
// Any object is compatible with a parent that accepts any object.
return $parentKind === 'isObject';
if (($childKind === 'isTrue' || $childKind === 'isFalse') && $parentKind === 'isBool') {
return true;
}
if ($childKind === 'isMixed') {
return $parentKind === 'isMixed';
if ($childKind === 'isArray' && $parentKind === 'iterable') {
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 ($parentKind === 'isObject') {
return true;
}
$childClass = $childType['class'] ?? '';
if ($parentKind === 'iterable') {
return $childClass !== '' && $this->isInheritedFrom($childClass, 'Traversable');
}
if ($parentKind === 'instanceof') {
$childClass = $childType['class'] ?? '';
$parentClass = $parentType['class'] ?? '';
if ($childClass === '' || $parentClass === '' || $childClass === 'static' || $parentClass === 'static') {
return false;
}
if ($childClass === $parentClass) {
return true;
}
return $this->isInheritedFrom($childClass, $parentClass);
return $childClass !== ''
&& $parentClass !== ''
&& $this->isInheritedFrom($childClass, $parentClass);
}
return false;
}
// Scalar kinds must match exactly.
return $childKind === $parentKind;
return $childKind !== null && $childKind === $parentKind;
}
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()
{
$impl = new UnionReturnImpl();
@ -43,9 +98,23 @@ function main()
$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