diff --git a/phpunit/code/property-default-array-for-int.php b/phpunit/code/property-default-array-for-int.php new file mode 100644 index 00000000..6a5d56fe --- /dev/null +++ b/phpunit/code/property-default-array-for-int.php @@ -0,0 +1,16 @@ +a); + } +} + +function property_default_array_for_int(): void +{ + $test = new PropertyDefaultArrayForInt(); +} diff --git a/phpunit/code/property-default-array-for-object.php b/phpunit/code/property-default-array-for-object.php new file mode 100644 index 00000000..89d348b2 --- /dev/null +++ b/phpunit/code/property-default-array-for-object.php @@ -0,0 +1,15 @@ +i); +} diff --git a/phpunit/src/ClassTest.php b/phpunit/src/ClassTest.php index a2c5de87..4be791c3 100644 --- a/phpunit/src/ClassTest.php +++ b/phpunit/src/ClassTest.php @@ -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'); + } } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 6cdd3707..1c6f02d6 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.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|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; diff --git a/src/Translator.php b/src/Translator.php index 75da8a1d..e89d42fa 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -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_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;