feat: 添加属性默认值类型兼容性编译期检查

pull/24/head
Yurun 1 month ago
parent 46ff43ba62
commit 38beb7ea29
  1. 16
      phpunit/code/property-default-array-for-int.php
  2. 15
      phpunit/code/property-default-array-for-object.php
  3. 11
      phpunit/code/property-default-null-for-int.php
  4. 11
      phpunit/code/property-default-string-for-int.php
  5. 23
      phpunit/code/property-default-valid.php
  6. 38
      phpunit/src/ClassTest.php
  7. 146
      src/Preprocessor.php
  8. 118
      src/Translator.php

@ -0,0 +1,16 @@
<?php
class PropertyDefaultArrayForInt
{
private int $a = [];
public function __construct()
{
var_dump($this->a);
}
}
function property_default_array_for_int(): void
{
$test = new PropertyDefaultArrayForInt();
}

@ -0,0 +1,15 @@
<?php
class PropertyDefaultArrayForObjectDep
{
}
class PropertyDefaultArrayForObject
{
public PropertyDefaultArrayForObjectDep $dep = [];
}
function property_default_array_for_object(): void
{
$test = new PropertyDefaultArrayForObject();
}

@ -0,0 +1,11 @@
<?php
class PropertyDefaultNullForInt
{
public int $a = null;
}
function property_default_null_for_int(): void
{
$test = new PropertyDefaultNullForInt();
}

@ -0,0 +1,11 @@
<?php
class PropertyDefaultStringForInt
{
public int $a = 'hello';
}
function property_default_string_for_int(): void
{
$test = new PropertyDefaultStringForInt();
}

@ -0,0 +1,23 @@
<?php
class PropertyDefaultValid
{
public int $i = 123;
public float $f = 1.5;
public float $fromInt = 3;
public string $s = 'hello';
public bool $b = true;
public array $arr = [];
public ?int $ni = null;
public int|array $ia = [];
public mixed $m = [];
public $untyped = [];
public const NUM = 7;
public int $fromConst = self::NUM;
}
function property_default_valid(): void
{
$test = new PropertyDefaultValid();
var_dump($test->i);
}

@ -172,4 +172,42 @@ class ClassTest extends \BaseTest
// 作为函数/方法默认参数值应当能够在编译期正确解析。
$this->compile('class-const-default-value.php');
}
public function testPropertyDefaultArrayForIntTypeFailsAtCompileTime()
{
$this->exec(
'Cannot use array as default value for property PropertyDefaultArrayForInt::$a of type int',
'property-default-array-for-int.php'
);
}
public function testPropertyDefaultStringForIntTypeFailsAtCompileTime()
{
$this->exec(
'Cannot use string as default value for property PropertyDefaultStringForInt::$a of type int',
'property-default-string-for-int.php'
);
}
public function testPropertyDefaultNullForNonNullableIntFailsAtCompileTime()
{
$this->exec(
'Cannot use null as default value for property PropertyDefaultNullForInt::$a of type int',
'property-default-null-for-int.php'
);
}
public function testPropertyDefaultArrayForObjectTypeFailsAtCompileTime()
{
$this->exec(
'Cannot use array as default value for property PropertyDefaultArrayForObject::$dep of type PropertyDefaultArrayForObjectDep',
'property-default-array-for-object.php'
);
}
public function testValidPropertyDefaultsCompile()
{
// 合法的默认值(含 int→float 协变、nullable、联合类型、mixed、常量)应通过检查。
$this->compile('property-default-valid.php');
}
}

@ -800,6 +800,7 @@ class Preprocessor extends CompilerBase
$default = null;
$arrayInitPlan = null;
if ($defaultNode !== null) {
$this->checkPropertyDefaultType($name, $typeNode, $defaultNode, $errorNode);
if ($defaultNode instanceof Node\Expr\Array_) {
$arrayInitPlan = $this->buildLiteralArrayInitPlan($defaultNode);
$default = $arrayInitPlan->expr;
@ -834,6 +835,151 @@ class Preprocessor extends CompilerBase
return $propDef;
}
/**
* Diagnose, during preprocessing, whether a property's default value is
* compatible with its declared type.
*
* TypePHP rejects obvious mismatches such as `int $a = []` at compile time
* instead of silently coercing the declared type or deferring to a runtime
* TypeError, matching the static-compilation principles in CLAUDE.md.
*/
protected function checkPropertyDefaultType(string $name, ?NodeAbstract $typeNode, NodeAbstract $defaultNode, NodeAbstract $errorNode): void
{
if ($typeNode === null) {
// Untyped property accepts any default value.
return;
}
$valueType = $this->detectDefaultValueType($defaultNode);
if ($valueType === null) {
// The value type is not statically decidable (e.g. user or class
// constant references); leave it to later stages.
return;
}
$allowed = $this->collectAllowedDefaultTypes($typeNode);
if ($allowed === null) {
// mixed / callable / otherwise unconstrained type declaration.
return;
}
if (in_array($valueType, $allowed, true)) {
return;
}
$className = $this->getFullClassName();
$typeStr = $this->propertyTypeDeclToString($typeNode);
$this->fatalError(
$errorNode,
"Cannot use {$valueType} as default value for property {$className}::\${$name} of type {$typeStr}"
);
}
/**
* Determine the PHP value type of a constant expression used as a default
* value. Returns one of int/float/string/bool/array/null, or null when the
* type cannot be decided statically.
*/
protected function detectDefaultValueType(NodeAbstract $node): ?string
{
switch ($node->getType()) {
case 'Scalar_Int':
return 'int';
case 'Scalar_Float':
return 'float';
case 'Scalar_String':
case 'Scalar_InterpolatedString':
case 'Expr_BinaryOp_Concat':
return 'string';
case 'Expr_Array':
return 'array';
case 'Expr_UnaryMinus':
case 'Expr_UnaryPlus':
return $this->detectDefaultValueType($node->expr);
case 'Expr_ConstFetch':
return match (strtolower($node->name->toString())) {
'true', 'false' => 'bool',
'null' => 'null',
default => null,
};
default:
return null;
}
}
/**
* Collect the set of value types accepted as a default for a declared type
* node. Returns null when the type imposes no statically-checkable
* constraint (mixed / callable / unknown).
*
* @return array<int, string>|null
*/
protected function collectAllowedDefaultTypes(NodeAbstract $typeNode): ?array
{
if ($typeNode instanceof NullableType) {
$inner = $this->collectAllowedDefaultTypes($typeNode->type);
if ($inner === null) {
return null;
}
return array_values(array_unique(array_merge($inner, ['null'])));
}
if ($typeNode instanceof UnionType) {
$all = [];
foreach ($typeNode->types as $sub) {
$part = $this->collectAllowedDefaultTypes($sub);
if ($part === null) {
// A mixed-like member accepts any default value.
return null;
}
$all = array_merge($all, $part);
}
return array_values(array_unique($all));
}
if ($typeNode instanceof IntersectionType) {
// Intersection types are object-only; no scalar/array default valid.
return [];
}
return match (strtolower($this->parseIdentifier($typeNode))) {
'int' => ['int'],
'float', 'double' => ['float', 'int'], // int coerces to float
'string' => ['string'],
'bool', 'true', 'false' => ['bool'],
'array' => ['array'],
'iterable' => ['array'],
'null' => ['null'],
'object' => [], // no literal object default exists
'self', 'parent', 'static' => [],
'mixed' => null,
'callable' => null, // string/array/closure — not checkable
default => [], // class type: only null via ?Type
};
}
protected function propertyTypeDeclToString(NodeAbstract $typeNode): string
{
if ($typeNode instanceof NullableType) {
return '?' . $this->propertyTypeDeclToString($typeNode->type);
}
if ($typeNode instanceof UnionType) {
$parts = [];
foreach ($typeNode->types as $t) {
$parts[] = $this->propertyTypeDeclToString($t);
}
return implode('|', $parts);
}
if ($typeNode instanceof IntersectionType) {
$parts = [];
foreach ($typeNode->types as $t) {
$parts[] = $this->propertyTypeDeclToString($t);
}
return implode('&', $parts);
}
return $this->parseIdentifier($typeNode);
}
protected function parseClassPropertyDef(Node\Stmt\Property $v): void
{
$oriCtx = $this->context;

@ -1617,34 +1617,60 @@ CODE;
if ($classDef && !$classDef->trait && !$classDef->enum) {
$className = $classDef->getNamespacedName();
$handlers = "property_handlers_{$className}";
$buildCreateBody = function (bool $attachHandlers) use ($classDef, $className, $handlers): string {
$initBlock = '';
foreach ($classDef->properties as $property) {
if ($property->isStatic() || $property->default === null) {
continue;
}
if ($property->arrayInitPlan) {
$init = "auto value = {$property->arrayInitPlan->expr};\n";
$init .= 'zend_update_property(obj->ce, obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n";
$init .= "php::throwErrorIfOccurred();\n";
$initBlock .= $this->wrapArrayInitPlan($property->arrayInitPlan, $init);
} else {
// Scalar / constant / null default value. Wrap it in a
// php::Var so it can be stored as a zval in the object's
// property table via zend_update_property. Each property is
// wrapped in its own block so the local `value` does not
// clash with siblings declared in the same create_object body.
$init = "do {\n";
$init .= "auto value = php::Var({$property->default});\n";
$init .= 'zend_update_property(obj->ce, obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n";
$init .= "php::throwErrorIfOccurred();\n";
$init .= "} while (0);\n";
$initBlock .= $init;
}
}
$buildCreateBody = function (bool $attachHandlers) use ($classDef, $className, $handlers, $ce, $initBlock): string {
$body = $classDef->ctorInit;
$body .= "auto obj = create_object_{$className}(class_type);\n";
if ($attachHandlers) {
// PHP < 8.4: the custom handlers are attached to the object
// AFTER the standard create_object, so object_properties_init
// runs with the standard handlers (no asymmetric check). Our
// explicit default inits run with the custom handlers
// attached, so we set EG(fake_scope) to the object's own class
// to satisfy asymmetric visibility for the class's own
// properties.
$body .= "auto obj = create_object_{$className}(class_type);\n";
$body .= "typephp_attach_property_handlers(obj, &{$handlers});\n";
}
foreach ($classDef->properties as $property) {
if ($property->isStatic() || $property->default === null) {
continue;
}
if ($property->arrayInitPlan) {
$init = "auto value = {$property->arrayInitPlan->expr};\n";
$init .= 'zend_update_property(obj->ce, obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n";
$init .= "php::throwErrorIfOccurred();\n";
$body .= $this->wrapArrayInitPlan($property->arrayInitPlan, $init);
} else {
// Scalar / constant / null default value. Wrap it in a
// php::Var so it can be stored as a zval in the object's
// property table via zend_update_property. Each property is
// wrapped in its own block so the local `value` does not
// clash with siblings declared in the same create_object body.
$init = "do {\n";
$init .= "auto value = php::Var({$property->default});\n";
$init .= 'zend_update_property(obj->ce, obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n";
$init .= "php::throwErrorIfOccurred();\n";
$init .= "} while (0);\n";
$body .= $init;
}
$body .= "zend_class_entry *__typephp_saved_fake_scope = EG(fake_scope);\n";
$body .= "EG(fake_scope) = obj->ce;\n";
$body .= $initBlock;
$body .= "EG(fake_scope) = __typephp_saved_fake_scope;\n";
} else {
// PHP >= 8.4: the custom handlers live in
// default_object_handlers, so the object already carries the
// asymmetric write_property hook at creation time and
// object_properties_init would reject private(set)/protected(set)
// default values (including inherited ones). Create the object
// with the standard handlers, run the default initialization
// (no visibility check), then attach the custom handlers.
$body .= "auto obj = zend_objects_new(class_type);\n";
$body .= "obj->handlers = const_cast<zend_object_handlers *>(zend_get_std_object_handlers());\n";
$body .= "object_properties_init(obj, class_type);\n";
$body .= $initBlock;
$body .= "obj->handlers = &{$handlers};\n";
}
$body .= $classDef->ctorClean;
return $body . "return obj;\n";
@ -1656,7 +1682,7 @@ CODE;
$code .= "{$ce}->create_object = [](zend_class_entry *class_type) -> zend_object* {\n";
$code .= $buildCreateBody(true);
$code .= "};\n";
if ($classDef->requireCtor) {
if ($classDef->requireCtor || $this->classHasAsymmetricOrHookedProperty($classDef)) {
$code .= "#else\n";
$code .= "create_object_{$className} = php_get_create_object_fn({$ce});\n";
$code .= "{$ce}->create_object = [](zend_class_entry *class_type) -> zend_object* {\n";
@ -1669,6 +1695,46 @@ CODE;
return $code;
}
/**
* Whether the given class (or any of its ancestors) declares an asymmetric
* visibility property (private(set)/protected(set)) or a hooked property
* (getter/setter). Such classes install a custom write_property handler, and
* on PHP >= 8.4 that handler lives in the class's default object handlers, so
* the engine's object_properties_init would reject inherited default values
* unless we generate our own create_object that initializes with the standard
* handlers first.
*/
private function classHasAsymmetricOrHookedProperty(ClassDef $classDef): bool
{
$current = $classDef;
$seen = [];
while ($current !== null) {
$key = strtolower(ltrim($current->getNamespacedName(), '\\'));
if (isset($seen[$key])) {
break;
}
$seen[$key] = true;
foreach ($current->properties as $property) {
if ($property->isPrivateSet()
|| $property->isProtectedSet()
|| $property->getter !== null
|| $property->setter !== null
) {
return true;
}
}
if (!$current->extends) {
break;
}
$parent = $this->getClassDef($current->extends);
if ($parent === null) {
break;
}
$current = $parent;
}
return false;
}
protected function getRegisterClassFunction(string $name): string
{
return self::PREFIX . 'register_class_' . $name;

Loading…
Cancel
Save