From 8118fbd3dc18f9fbc112a4de48eb8d61111ff1c0 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 10 Aug 2026 21:05:16 +0800 Subject: [PATCH] feat(readonly): enforce direct assignment only for readonly properties - Remove initialization window semantics for readonly properties - Restrict readonly property writes to direct assignment only - Add explicit error messages for unsupported readonly operations - Update tests to reflect new readonly property behavior - Remove constructor and clone method invocation restrictions - Add support for closure rebinding rejection at compile time - Modify property write target preparation with readonly checks - Update documentation to remove outdated readonly initialization rules - Add new test cases for readonly property direct assignment enforcement --- docs/INCOMPATIBLE_PHP_FEATURES.md | 1 - docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md | 1 - phpunit/code/clone-direct-method-call.php | 13 --- phpunit/code/clone-direct-static-call.php | 13 --- .../closure/closure-bind-to-unsupported.php | 8 ++ .../code/closure/closure-bind-unsupported.php | 8 ++ .../closure-call-typed-unsupported.php | 6 ++ .../code/closure/closure-call-unsupported.php | 7 ++ .../code/constructor-direct-method-call.php | 10 --- .../code/constructor-direct-static-call.php | 10 --- .../code/readonly-property-no-native-ref.php | 2 - phpunit/code/readonly-write-array-index.php | 7 ++ phpunit/code/readonly-write-concat.php | 7 ++ phpunit/code/readonly-write-decrement.php | 7 ++ phpunit/code/readonly-write-subtract.php | 7 ++ phpunit/src/ClosureTest.php | 22 ++++- phpunit/src/NativePropertyTest.php | 3 +- phpunit/src/ReadonlyPropertyTest.php | 57 ++++--------- src/CompilerBase.php | 8 +- src/Entity/MethodDef.php | 3 - src/Parser/AssignOpTrait.php | 8 +- src/Parser/MethodCallTrait.php | 70 +++++----------- src/Parser/PropertyAccessTrait.php | 42 ++-------- src/Translator.php | 1 - .../readonly-clone-initialization.phpt | 17 ++-- .../readonly-constructor-only.phpt | 42 ---------- .../readonly-dynamic-clone-call.phpt | 35 -------- .../readonly-dynamic-constructor-call.phpt | 30 ------- .../readonly-initialization.phpt | 82 +++++++++++++++++++ 29 files changed, 224 insertions(+), 303 deletions(-) delete mode 100644 phpunit/code/clone-direct-method-call.php delete mode 100644 phpunit/code/clone-direct-static-call.php create mode 100644 phpunit/code/closure/closure-bind-to-unsupported.php create mode 100644 phpunit/code/closure/closure-bind-unsupported.php create mode 100644 phpunit/code/closure/closure-call-typed-unsupported.php create mode 100644 phpunit/code/closure/closure-call-unsupported.php delete mode 100644 phpunit/code/constructor-direct-method-call.php delete mode 100644 phpunit/code/constructor-direct-static-call.php create mode 100644 phpunit/code/readonly-write-array-index.php create mode 100644 phpunit/code/readonly-write-concat.php create mode 100644 phpunit/code/readonly-write-decrement.php create mode 100644 phpunit/code/readonly-write-subtract.php delete mode 100644 tests/compiler/object_property/readonly-constructor-only.phpt delete mode 100644 tests/compiler/object_property/readonly-dynamic-clone-call.phpt delete mode 100644 tests/compiler/object_property/readonly-dynamic-constructor-call.phpt create mode 100644 tests/compiler/object_property/readonly-initialization.phpt diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index 4388a966..80941bdb 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -48,7 +48,6 @@ - 禁止子类用同名 `private` 属性隐藏父类私有属性;`public` / `protected` 同名声明视为同一个继承 property slot,仍须满足类型、可见性和 `readonly` 兼容性要求。 - 为避免 typed property 写入路径引入额外动态检查,native typed property 在右值类型不确定或与属性类型不一致时会退化为 `setProperty()`;部分标量赋值可能遵循 Zend 弱类型转换,而不是 AOT 默认 strict 语义。 - constructor property promotion 的运行时属性可用,但 `ReflectionProperty::isPromoted()` 目前不返回标准 PHP 结果。 -- `readonly` 使用“初始化窗口”语义,而不是 PHP 的“一次赋值”语义:属性只能在声明类自己的 `__construct()` 或 `__clone()` 中通过直接 `$this` 写入,且窗口内允许重复修改;其他方法、子类初始化方法、嵌套闭包、其他对象、引用和 `unset()` 均不可写。`__construct()` / `__clone()` 不能作为普通方法调用,合法的 `parent::__construct()` / `parent::__clone()` 初始化链除外。 ## 表达式与控制流 diff --git a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md index f5e888c5..0eb378bb 100644 --- a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md +++ b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md @@ -76,7 +76,6 @@ These items should be documented with the exact boundary. | Reserved keyword methods such as `toArray()` | Intentional Rule | Conversion keywords are resolved before ordinary object methods to keep conversion lowering static and predictable. | | Zero-initialized fixed typed property slots | Intentional Rule / Partial | Native fixed-layout slots use their type's zero value instead of preserving every Zend uninitialized-property transition. | | Structural mutation of `std` containers during `foreach` | Intentional Rule | Native C++ iterators may be invalidated by append, insertion, erase or whole-container replacement. TypePHP rejects these operations inside the active loop while allowing non-structural element updates. | -| `readonly` initialization window | Intentional Rule | A readonly property is writable only through direct `$this` access in its declaring class' `__construct()` or `__clone()`. Repeated initialization writes are allowed there; after either initialization path it is frozen. Ordinary calls to these lifecycle methods are rejected, while lexical parent initialization chains remain valid. | ## Implementable but Currently Unsupported diff --git a/phpunit/code/clone-direct-method-call.php b/phpunit/code/clone-direct-method-call.php deleted file mode 100644 index 98e85b49..00000000 --- a/phpunit/code/clone-direct-method-call.php +++ /dev/null @@ -1,13 +0,0 @@ -__clone(); - } -} diff --git a/phpunit/code/clone-direct-static-call.php b/phpunit/code/clone-direct-static-call.php deleted file mode 100644 index 7ca1be23..00000000 --- a/phpunit/code/clone-direct-static-call.php +++ /dev/null @@ -1,13 +0,0 @@ -bindTo($target); +} diff --git a/phpunit/code/closure/closure-bind-unsupported.php b/phpunit/code/closure/closure-bind-unsupported.php new file mode 100644 index 00000000..8f7e6cd9 --- /dev/null +++ b/phpunit/code/closure/closure-bind-unsupported.php @@ -0,0 +1,8 @@ +call($target); +} diff --git a/phpunit/code/closure/closure-call-unsupported.php b/phpunit/code/closure/closure-call-unsupported.php new file mode 100644 index 00000000..0a358ab4 --- /dev/null +++ b/phpunit/code/closure/closure-call-unsupported.php @@ -0,0 +1,7 @@ + 'value'; + echo $callback->call(new stdClass()); +} diff --git a/phpunit/code/constructor-direct-method-call.php b/phpunit/code/constructor-direct-method-call.php deleted file mode 100644 index 40c4c5ec..00000000 --- a/phpunit/code/constructor-direct-method-call.php +++ /dev/null @@ -1,10 +0,0 @@ -__construct(); -} diff --git a/phpunit/code/constructor-direct-static-call.php b/phpunit/code/constructor-direct-static-call.php deleted file mode 100644 index 5344feb5..00000000 --- a/phpunit/code/constructor-direct-static-call.php +++ /dev/null @@ -1,10 +0,0 @@ -integer = $integer; $this->floating = $floating; - $this->integer += 1; - $this->floating += 1.5; } } diff --git a/phpunit/code/readonly-write-array-index.php b/phpunit/code/readonly-write-array-index.php new file mode 100644 index 00000000..324b470b --- /dev/null +++ b/phpunit/code/readonly-write-array-index.php @@ -0,0 +1,7 @@ +value = []; } + public function change(): void { $this->value[0] = 1; } +} diff --git a/phpunit/code/readonly-write-concat.php b/phpunit/code/readonly-write-concat.php new file mode 100644 index 00000000..9f962a48 --- /dev/null +++ b/phpunit/code/readonly-write-concat.php @@ -0,0 +1,7 @@ +value = 'a'; } + public function change(): void { $this->value .= 'b'; } +} diff --git a/phpunit/code/readonly-write-decrement.php b/phpunit/code/readonly-write-decrement.php new file mode 100644 index 00000000..785c0d1e --- /dev/null +++ b/phpunit/code/readonly-write-decrement.php @@ -0,0 +1,7 @@ +value = 1; } + public function change(): void { $this->value--; } +} diff --git a/phpunit/code/readonly-write-subtract.php b/phpunit/code/readonly-write-subtract.php new file mode 100644 index 00000000..b38c3b29 --- /dev/null +++ b/phpunit/code/readonly-write-subtract.php @@ -0,0 +1,7 @@ +value = 1; } + public function change(): void { $this->value -= 1; } +} diff --git a/phpunit/src/ClosureTest.php b/phpunit/src/ClosureTest.php index afb60044..58ce6214 100644 --- a/phpunit/src/ClosureTest.php +++ b/phpunit/src/ClosureTest.php @@ -2,7 +2,7 @@ use TypePhp\CompilerTest; -class ClosureTest extends \PHPUnit\Framework\TestCase +class ClosureTest extends \BaseTest { public function testUseReferenceCaptureCompiles(): void { @@ -17,4 +17,24 @@ class ClosureTest extends \PHPUnit\Framework\TestCase $this->assertTrue(true); } + + public function testClosureRebindingIsRejectedAtCompileTime(): void + { + $this->exec( + 'Closure::call() is not supported', + 'closure/closure-call-unsupported.php' + ); + $this->exec( + 'Closure::call() is not supported', + 'closure/closure-call-typed-unsupported.php' + ); + $this->exec( + 'Closure::bindTo() is not supported', + 'closure/closure-bind-to-unsupported.php' + ); + $this->exec( + 'Closure::bind() is not supported', + 'closure/closure-bind-unsupported.php' + ); + } } diff --git a/phpunit/src/NativePropertyTest.php b/phpunit/src/NativePropertyTest.php index c730abee..ffb2ba9a 100644 --- a/phpunit/src/NativePropertyTest.php +++ b/phpunit/src/NativePropertyTest.php @@ -72,7 +72,8 @@ class NativePropertyTest extends \BaseTest $this->assertStringNotContainsString('typephp_static_int_ref(', $code); $this->assertStringNotContainsString('typephp_static_float_ref(', $code); $this->assertStringNotContainsString('_object_prop_', $code); - $this->assertStringContainsString('.attr(', $code); + $this->assertStringNotContainsString('AttrMode::Update', $code); + $this->assertSame(2, substr_count($code, 'typephp_write_property_scoped(')); } public function testNativeIntPropertyAssignOpConvertsBitwiseNotClassConst(): void diff --git a/phpunit/src/ReadonlyPropertyTest.php b/phpunit/src/ReadonlyPropertyTest.php index 30721c7f..8e6b4f87 100644 --- a/phpunit/src/ReadonlyPropertyTest.php +++ b/phpunit/src/ReadonlyPropertyTest.php @@ -2,52 +2,19 @@ class ReadonlyPropertyTest extends \BaseTest { - public function testConstructorCannotBeCalledAsOrdinaryMethod(): void + public function testDirectInitializationIsAllowedOutsideConstructor(): void { - $this->exec('Constructor __construct() can only be invoked by new', 'constructor-direct-method-call.php'); - $this->exec('Constructor __construct() can only be invoked by new', 'constructor-direct-static-call.php'); + $this->compile('readonly-write-outside-constructor.php'); + $this->compile('readonly-write-child-constructor.php'); + $this->compile('readonly-write-constructor-closure.php'); + $this->compile('readonly-write-other-instance-constructor.php'); } - public function testCloneCannotBeCalledAsOrdinaryMethod(): void + public function testDirectCloneInitializationUsesTheSameWritePath(): void { - $this->exec('Clone method __clone() can only be invoked by clone', 'clone-direct-method-call.php'); - $this->exec('Clone method __clone() can only be invoked by clone', 'clone-direct-static-call.php'); - } - - public function testWriteOutsideConstructorIsRejected(): void - { - $this->exec('Readonly property `ReadonlyWriteOutsideConstructor::$value` can only be modified in its declaring `__construct` or `__clone` method', 'readonly-write-outside-constructor.php'); - } - - public function testChildConstructorCannotWriteParentReadonlyProperty(): void - { - $this->exec('Readonly property `ReadonlyParent::$value` can only be modified in its declaring `__construct` or `__clone` method', 'readonly-write-child-constructor.php'); - } - - public function testConstructorCannotWriteReadonlyPropertyOnAnotherObject(): void - { - $this->exec('Readonly property `ReadonlyOtherInstance::$value` can only be modified on `$this`', 'readonly-write-other-instance-constructor.php'); - } - - public function testClosureInsideConstructorCannotWriteReadonlyProperty(): void - { - $this->exec('Readonly property `ReadonlyConstructorClosure::$value` can only be modified directly in `__construct` or `__clone`', 'readonly-write-constructor-closure.php'); - } - - public function testReadonlyCloneWriteRetainsLexicalRestrictions(): void - { - $this->exec( - 'Readonly property `ReadonlyCloneParent::$value` can only be modified in its declaring `__construct` or `__clone` method', - 'readonly-write-child-clone.php' - ); - $this->exec( - 'Readonly property `ReadonlyCloneClosure::$value` can only be modified directly in `__construct` or `__clone`', - 'readonly-write-clone-closure.php' - ); - $this->exec( - 'Readonly property `ReadonlyCloneOtherInstance::$value` can only be modified on `$this`', - 'readonly-write-other-instance-clone.php' - ); + $this->compile('readonly-write-child-clone.php'); + $this->compile('readonly-write-clone-closure.php'); + $this->compile('readonly-write-other-instance-clone.php'); } public function testReadonlyPropertyCannotBeAssignedByReference(): void @@ -66,12 +33,16 @@ class ReadonlyPropertyTest extends \BaseTest foreach ([ 'readonly-write-compound.php', 'readonly-write-increment.php', + 'readonly-write-decrement.php', + 'readonly-write-subtract.php', + 'readonly-write-concat.php', 'readonly-write-array-dim.php', + 'readonly-write-array-index.php', 'readonly-write-coalesce.php', 'readonly-write-list.php', 'readonly-write-foreach.php', ] as $file) { - $this->exec('can only be modified in its declaring `__construct` or `__clone` method', $file); + $this->exec('only supports direct assignment', $file); } } } diff --git a/src/CompilerBase.php b/src/CompilerBase.php index def1e9ea..2c0d138c 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1095,11 +1095,6 @@ class CompilerBase implements PropertyAccessContext return $this->method === '__construct'; } - protected function isCurrentCloneMethod(): bool - { - return $this->method === '__clone'; - } - protected function getCurrentMethodDisplayName(): string { return $this->getFullClassName() . '::' . $this->method; @@ -1786,6 +1781,9 @@ class CompilerBase implements PropertyAccessContext protected function detectClassOfExpr(NodeAbstract $expr): string { + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { + return 'Closure'; + } if ($expr instanceof Expr\MethodCall && $this->isNamedMethod($expr->name)) { $keywordType = $this->findKeywordMethod($this->parseIdentifier($expr->name)); if ($keywordType !== null && $keywordType !== Type::OBJECT) { diff --git a/src/Entity/MethodDef.php b/src/Entity/MethodDef.php index cbf52344..93eca879 100644 --- a/src/Entity/MethodDef.php +++ b/src/Entity/MethodDef.php @@ -25,9 +25,6 @@ class MethodDef /** Source trait, retained only for diagnostics and the __TRAIT__ constant. */ public string $traitOrigin = ''; - /** Original trait method name before a use-site alias is applied. */ - public string $traitMethod = ''; - public function __construct(int $flags, string $name) { $this->flags = $flags; diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 1fe2da86..15f59391 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -212,7 +212,9 @@ trait AssignOpTrait return $this->parseAssignToList($left, $right); } - $propertyWriteTarget = $this->preparePropertyWriteTarget($left); + // A direct assignment to readonly must go through write_property so + // Zend can enforce scope, initialization state, type and clone rules. + $propertyWriteTarget = $this->preparePropertyWriteTarget($left, true); $type = $this->detectTypeOfExpr($right); $finalVarType = $this->getNormalAssignType($type); $runtimeObjectAssignClass = ''; @@ -433,6 +435,10 @@ trait AssignOpTrait return false; } + if ($def->isReadonly()) { + return true; + } + return !in_array($def->type, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR, Type::ARRAY], true) && $rightType === Type::VAR; } diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index b767d437..31299ea3 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -276,25 +276,6 @@ trait MethodCallTrait } return $this->genRuntimeFunctionCall($callable, $expr->args, $method, $parentClass); } - if ($method === '__construct') { - if (!$this->isConstructorImplementationContext() || $this->context->inClosure) { - $this->fatalError($expr, 'Constructor __construct() can only be invoked by new'); - } - if (empty($expr->args)) { - return 'typephp_call_parent_constructor(this_, ' . $methodPtr . ')'; - } - return 'typephp_call_parent_constructor(this_, ' . $methodPtr . ', ' - . $this->parseCallArgs($expr->args, $method, $parentClass) . ')'; - } - if ($method === '__clone') { - if (!$this->isCurrentCloneMethod() || $this->context->inClosure) { - $this->fatalError($expr, 'Clone method __clone() can only be invoked by clone'); - } - if (!empty($expr->args)) { - $this->fatalError($expr, 'Clone method __clone() does not accept arguments'); - } - return 'typephp_call_parent_clone(this_, ' . $methodPtr . ')'; - } if (empty($expr->args)) { return 'this_.call(' . $methodPtr . ')'; } @@ -322,15 +303,6 @@ trait MethodCallTrait protected function parseMethodCall(Expr\MethodCall $expr): string { - if ($this->isNamedMethod($expr->name)) { - $methodName = strtolower($expr->name->toString()); - if ($methodName === '__construct') { - $this->fatalError($expr, 'Constructor __construct() can only be invoked by new'); - } - if ($methodName === '__clone') { - $this->fatalError($expr, 'Clone method __clone() can only be invoked by clone'); - } - } if ($this->containsNullsafeChain($expr->var)) { return $this->parseNullsafeExpr($expr); } @@ -357,6 +329,16 @@ trait MethodCallTrait } } + if ($this->isNamedMethod($expr->name) + && in_array(strtolower($expr->name->toString()), ['call', 'bind', 'bindto'], true) + && strtolower(ltrim($class, '\\')) === 'closure') { + $closureMethod = $expr->name->toString(); + $this->fatalError( + $expr, + 'Closure::' . $closureMethod . '() is not supported' + ); + } + $magicMethod = false; $method = $this->identifierToStr($expr->name, literal: true); @@ -538,12 +520,6 @@ trait MethodCallTrait } } - private function isConstructorImplementationContext(): bool - { - return $this->isCurrentConstructor() - || strtolower($this->methodDef?->traitMethod ?? '') === '__construct'; - } - private function isDefinitelyObjectReceiver( Expr $receiver, string $object, @@ -607,23 +583,15 @@ trait MethodCallTrait $rtClass = ''; $class = $this->parseIdentifier($expr->class); - if ($this->isIdExpr($expr->name) && strtolower($expr->name->toString()) === '__construct') { - $isParentConstructor = $this->isNameExpr($expr->class) - && $class === 'parent' - && $this->isConstructorImplementationContext() - && !$this->context->inClosure; - if (!$isParentConstructor) { - $this->fatalError($expr, 'Constructor __construct() can only be invoked by new'); - } - } - if ($this->isIdExpr($expr->name) && strtolower($expr->name->toString()) === '__clone') { - $isParentClone = $this->isNameExpr($expr->class) - && $class === 'parent' - && $this->isCurrentCloneMethod() - && !$this->context->inClosure; - if (!$isParentClone) { - $this->fatalError($expr, 'Clone method __clone() can only be invoked by clone'); - } + if ($this->isNameExpr($expr->class) + && $this->isIdExpr($expr->name) + && strtolower(ltrim($expr->class->toString(), '\\')) === 'closure' + && in_array(strtolower($expr->name->toString()), ['bind', 'bindto', 'call'], true)) { + $closureMethod = $expr->name->toString(); + $this->fatalError( + $expr, + 'Closure::' . $closureMethod . '() is not supported' + ); } // parent::$method() still has a lexical parent class even when the diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index 54d2b3b8..377c220d 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -207,7 +207,9 @@ trait PropertyAccessTrait protected function emitDynamicPropertyFetchRef(Expr\PropertyFetch $expr, NodeAbstract $errorNode): string { - $target = $this->preparePropertyWriteTarget($expr); + // Reference diagnostics are more specific than the generic readonly + // mutation error emitted by preparePropertyWriteTarget(). + $target = $this->preparePropertyWriteTarget($expr, true); if ($this->canEmitDynamicPropertyTarget($target)) { $objectExpr = $target->getDynamicObjectExpr(); if (!$this->hasVar($objectExpr)) { @@ -443,7 +445,7 @@ trait PropertyAccessTrait $this->assertCanAssignObjectProperty($left, $right, 'static property'); } - protected function preparePropertyWriteTarget(NodeAbstract $left, bool $checkReadonlyWrite = true): ?PropertyWriteTarget + protected function preparePropertyWriteTarget(NodeAbstract $left, bool $allowReadonlyAssignment = false): ?PropertyWriteTarget { if ($left instanceof Expr\PropertyFetch) { $objectExpr = null; @@ -455,8 +457,8 @@ trait PropertyAccessTrait if ($this->isIdExpr($left->name)) { $this->getPropertyIdentifier($left, $left->var, $left->name); $this->assertPropertySetVisibility($left); - if ($checkReadonlyWrite) { - $this->assertReadonlyPropertyWriteContext($left); + if (!$allowReadonlyAssignment) { + $this->assertReadonlyPropertyDirectAssignmentOnly($left); } } return new PropertyWriteTarget($left, 'object property', $objectExpr, $propertyExpr); @@ -473,13 +475,7 @@ trait PropertyAccessTrait return null; } - /** - * TypePHP intentionally gives readonly an initialization-phase meaning: - * only the declaring class' __construct or __clone body may write its own - * property. This check is lexical; a nested closure is a different - * function and must not inherit the write privilege. - */ - private function assertReadonlyPropertyWriteContext(Expr\PropertyFetch $property): void + private function assertReadonlyPropertyDirectAssignmentOnly(Expr\PropertyFetch $property): void { $access = $this->getNativePropertyAccess($property); if ($access === null || !$access->getPropertyDef()->isReadonly()) { @@ -490,27 +486,7 @@ trait PropertyAccessTrait $propertyName = $this->parseIdentifier($property->name); $display = $declaringClass . '::$' . $propertyName; - if ($this->context->inClosure) { - $this->fatalError( - $property, - "Readonly property `{$display}` can only be modified directly in `__construct` or `__clone`" - ); - } - - if ((!$this->isCurrentConstructor() && !$this->isCurrentCloneMethod()) - || !$this->isSameClassName($this->getFullClassName(), $declaringClass)) { - $this->fatalError( - $property, - "Readonly property `{$display}` can only be modified in its declaring `__construct` or `__clone` method" - ); - } - - if (!$this->isVarExpr($property->var) || $this->parseIdentifier($property->var) !== 'this_') { - $this->fatalError( - $property, - "Readonly property `{$display}` can only be modified on `\$this`" - ); - } + $this->fatalError($property, "Readonly property `{$display}` only supports direct assignment"); } protected function assertReadonlyPropertyReferenceForbidden( @@ -806,7 +782,7 @@ trait PropertyAccessTrait } elseif ($this->isPropertyFetch($var)) { // unset has its own unconditional readonly diagnostic below; // it is forbidden even while __construct is running. - $propertyWriteTarget = $this->preparePropertyWriteTarget($var, false); + $propertyWriteTarget = $this->preparePropertyWriteTarget($var, true); $object = $this->getDynamicPropertyFetchObjectExpr($var, $propertyWriteTarget); $restoreDefault = null; if ($this->isIdExpr($var->name)) { diff --git a/src/Translator.php b/src/Translator.php index 3de716a5..bb0e5090 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -4630,7 +4630,6 @@ CODE; $methodDef = new MethodDef($flags, $name); $methodDef->node = $methodStmt; $methodDef->traitOrigin = (string) $methodStmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE, ''); - $methodDef->traitMethod = (string) $methodStmt->getAttribute(self::TRAIT_METHOD_ATTRIBUTE, ''); $this->method = $name; $this->methodDef = $methodDef; diff --git a/tests/compiler/object_property/readonly-clone-initialization.phpt b/tests/compiler/object_property/readonly-clone-initialization.phpt index bd44add7..a2c86bfd 100644 --- a/tests/compiler/object_property/readonly-clone-initialization.phpt +++ b/tests/compiler/object_property/readonly-clone-initialization.phpt @@ -1,5 +1,5 @@ --TEST-- -TypePHP readonly properties may be updated while cloning +readonly properties may be reinitialized once while cloning --FILE-- base++; - $this->base += 3; + $this->base = 5; } } @@ -36,10 +35,13 @@ class ReadonlyCloneValue extends ReadonlyCloneBase public function __clone(): void { parent::__clone(); - $this->name = 'clone'; - $this->name .= 'd'; - $this->items[] = 2; - $this->items[0] = 10; + $this->name = 'cloned'; + $this->items = [10, 2]; + try { + $this->name = 'again'; + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } } } @@ -52,6 +54,7 @@ function main(): void } ?> --EXPECT-- +Cannot modify readonly property ReadonlyCloneValue::$name int(1) string(8) "original" array(1) { diff --git a/tests/compiler/object_property/readonly-constructor-only.phpt b/tests/compiler/object_property/readonly-constructor-only.phpt deleted file mode 100644 index be429575..00000000 --- a/tests/compiler/object_property/readonly-constructor-only.phpt +++ /dev/null @@ -1,42 +0,0 @@ ---TEST-- -TypePHP readonly properties are mutable only during their declaring constructor ---FILE-- -number = 1; - $this->number = 2; - $this->number += 3; - ++$this->number; - - $this->text = 'a'; - $this->text .= 'b'; - - $this->items = []; - $this->items[] = 10; - $this->items[0] = 20; - } -} - -function main(): void -{ - $value = new ReadonlyConstructorOnly(); - var_dump($value->number, $value->text, $value->items); -} -?> ---EXPECT-- -int(6) -string(2) "ab" -array(1) { - [0]=> - int(20) -} diff --git a/tests/compiler/object_property/readonly-dynamic-clone-call.phpt b/tests/compiler/object_property/readonly-dynamic-clone-call.phpt deleted file mode 100644 index 00fd7a9b..00000000 --- a/tests/compiler/object_property/readonly-dynamic-clone-call.phpt +++ /dev/null @@ -1,35 +0,0 @@ ---TEST-- -Readonly clone method cannot be called dynamically ---FILE-- -value = 1; - } - - public function __clone(): void - { - $this->value = 2; - } -} - -function main(): void -{ - $value = new ReadonlyDynamicCloneCall(); - $method = '__clone'; - try { - $value->$method(); - } catch (Error $error) { - echo $error->getMessage(), "\n"; - } - var_dump($value->value); -} -?> ---EXPECT-- -Clone method ReadonlyDynamicCloneCall::__clone() can only be invoked by clone -int(1) diff --git a/tests/compiler/object_property/readonly-dynamic-constructor-call.phpt b/tests/compiler/object_property/readonly-dynamic-constructor-call.phpt deleted file mode 100644 index f95e07cd..00000000 --- a/tests/compiler/object_property/readonly-dynamic-constructor-call.phpt +++ /dev/null @@ -1,30 +0,0 @@ ---TEST-- -Readonly constructor cannot be called dynamically after construction ---FILE-- -value = $value; - } -} - -function main(): void -{ - $value = new ReadonlyDynamicConstructorCall(1); - $method = '__construct'; - try { - $value->$method(2); - } catch (Error $error) { - echo $error->getMessage(), "\n"; - } - var_dump($value->value); -} -?> ---EXPECT-- -Constructor ReadonlyDynamicConstructorCall::__construct() can only be invoked by new -int(1) diff --git a/tests/compiler/object_property/readonly-initialization.phpt b/tests/compiler/object_property/readonly-initialization.phpt new file mode 100644 index 00000000..411548a7 --- /dev/null +++ b/tests/compiler/object_property/readonly-initialization.phpt @@ -0,0 +1,82 @@ +--TEST-- +readonly properties use PHP one-time initialization semantics +--FILE-- +fromMethod = 10; + } + + public function initializeClosure(): void + { + $writer = function (): void { + $this->fromClosure = 30; + }; + $writer(); + } +} + +class ReadonlyChild extends ReadonlyBase +{ + public function initializeChild(): void + { + $this->fromChild = 20; + } +} + +class MutableValue +{ + public int $number = 0; +} + +class ReadonlyObjectHolder +{ + public readonly MutableValue $value; + + public function initialize(): void + { + $this->value = new MutableValue(); + } +} + +function main(): void +{ + $value = new ReadonlyChild(); + $value->initializeMethod(); + $value->initializeChild(); + $value->initializeClosure(); + var_dump($value->fromMethod, $value->fromChild, $value->fromClosure); + + try { + $value->initializeMethod(); + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } + + $external = new ReadonlyChild(); + try { + $external->fromMethod = 40; + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } + + $holder = new ReadonlyObjectHolder(); + $holder->initialize(); + $holder->value->number = 50; + var_dump($holder->value->number); +} +?> +--EXPECT-- +int(10) +int(20) +int(30) +Cannot modify readonly property ReadonlyBase::$fromMethod +Cannot modify protected(set) readonly property ReadonlyBase::$fromMethod from global scope +int(50)