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
pull/48/head
韩天峰 3 weeks ago
parent e5f61eabfe
commit 8118fbd3dc
  1. 1
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 1
      docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md
  3. 13
      phpunit/code/clone-direct-method-call.php
  4. 13
      phpunit/code/clone-direct-static-call.php
  5. 8
      phpunit/code/closure/closure-bind-to-unsupported.php
  6. 8
      phpunit/code/closure/closure-bind-unsupported.php
  7. 6
      phpunit/code/closure/closure-call-typed-unsupported.php
  8. 7
      phpunit/code/closure/closure-call-unsupported.php
  9. 10
      phpunit/code/constructor-direct-method-call.php
  10. 10
      phpunit/code/constructor-direct-static-call.php
  11. 2
      phpunit/code/readonly-property-no-native-ref.php
  12. 7
      phpunit/code/readonly-write-array-index.php
  13. 7
      phpunit/code/readonly-write-concat.php
  14. 7
      phpunit/code/readonly-write-decrement.php
  15. 7
      phpunit/code/readonly-write-subtract.php
  16. 22
      phpunit/src/ClosureTest.php
  17. 3
      phpunit/src/NativePropertyTest.php
  18. 57
      phpunit/src/ReadonlyPropertyTest.php
  19. 8
      src/CompilerBase.php
  20. 3
      src/Entity/MethodDef.php
  21. 8
      src/Parser/AssignOpTrait.php
  22. 70
      src/Parser/MethodCallTrait.php
  23. 42
      src/Parser/PropertyAccessTrait.php
  24. 1
      src/Translator.php
  25. 17
      tests/compiler/object_property/readonly-clone-initialization.phpt
  26. 42
      tests/compiler/object_property/readonly-constructor-only.phpt
  27. 35
      tests/compiler/object_property/readonly-dynamic-clone-call.phpt
  28. 30
      tests/compiler/object_property/readonly-dynamic-constructor-call.phpt
  29. 82
      tests/compiler/object_property/readonly-initialization.phpt

@ -48,7 +48,6 @@
- 禁止子类用同名 `private` 属性隐藏父类私有属性;`public` / `protected` 同名声明视为同一个继承 property slot,仍须满足类型、可见性和 `readonly` 兼容性要求。 - 禁止子类用同名 `private` 属性隐藏父类私有属性;`public` / `protected` 同名声明视为同一个继承 property slot,仍须满足类型、可见性和 `readonly` 兼容性要求。
- 为避免 typed property 写入路径引入额外动态检查,native typed property 在右值类型不确定或与属性类型不一致时会退化为 `setProperty()`;部分标量赋值可能遵循 Zend 弱类型转换,而不是 AOT 默认 strict 语义。 - 为避免 typed property 写入路径引入额外动态检查,native typed property 在右值类型不确定或与属性类型不一致时会退化为 `setProperty()`;部分标量赋值可能遵循 Zend 弱类型转换,而不是 AOT 默认 strict 语义。
- constructor property promotion 的运行时属性可用,但 `ReflectionProperty::isPromoted()` 目前不返回标准 PHP 结果。 - constructor property promotion 的运行时属性可用,但 `ReflectionProperty::isPromoted()` 目前不返回标准 PHP 结果。
- `readonly` 使用“初始化窗口”语义,而不是 PHP 的“一次赋值”语义:属性只能在声明类自己的 `__construct()``__clone()` 中通过直接 `$this` 写入,且窗口内允许重复修改;其他方法、子类初始化方法、嵌套闭包、其他对象、引用和 `unset()` 均不可写。`__construct()` / `__clone()` 不能作为普通方法调用,合法的 `parent::__construct()` / `parent::__clone()` 初始化链除外。
## 表达式与控制流 ## 表达式与控制流

@ -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. | | 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. | | 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. | | 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 ## Implementable but Currently Unsupported

@ -1,13 +0,0 @@
<?php
class CloneDirectMethodCall
{
public function __clone(): void
{
}
public function invoke(): void
{
$this->__clone();
}
}

@ -1,13 +0,0 @@
<?php
class CloneDirectStaticCall
{
public function __clone(): void
{
}
public function invoke(): void
{
self::__clone();
}
}

@ -0,0 +1,8 @@
<?php
function closureBindToUnsupported(object $target): void
{
$callback = static function (): void {
};
$callback->bindTo($target);
}

@ -0,0 +1,8 @@
<?php
function closureBindUnsupported(): void
{
$callback = static function (): void {
};
Closure::bind($callback, null);
}

@ -0,0 +1,6 @@
<?php
function closureCallTypedUnsupported(Closure $callback, object $target): void
{
$callback->call($target);
}

@ -0,0 +1,7 @@
<?php
function closureCallUnsupported(): void
{
$callback = static fn(): string => 'value';
echo $callback->call(new stdClass());
}

@ -1,10 +0,0 @@
<?php
class ConstructorDirectMethodCall
{
public function __construct() {}
}
function invoke_constructor(ConstructorDirectMethodCall $object): void
{
$object->__construct();
}

@ -1,10 +0,0 @@
<?php
class ConstructorDirectStaticCall
{
public function __construct() {}
public function invoke(): void
{
self::__construct();
}
}

@ -11,7 +11,5 @@ class ReadonlyPropertyNoNativeRef
{ {
$this->integer = $integer; $this->integer = $integer;
$this->floating = $floating; $this->floating = $floating;
$this->integer += 1;
$this->floating += 1.5;
} }
} }

@ -0,0 +1,7 @@
<?php
class ReadonlyArrayIndexWrite
{
public readonly array $value;
public function __construct() { $this->value = []; }
public function change(): void { $this->value[0] = 1; }
}

@ -0,0 +1,7 @@
<?php
class ReadonlyConcatWrite
{
public readonly string $value;
public function __construct() { $this->value = 'a'; }
public function change(): void { $this->value .= 'b'; }
}

@ -0,0 +1,7 @@
<?php
class ReadonlyDecrementWrite
{
public readonly int $value;
public function __construct() { $this->value = 1; }
public function change(): void { $this->value--; }
}

@ -0,0 +1,7 @@
<?php
class ReadonlySubtractWrite
{
public readonly int $value;
public function __construct() { $this->value = 1; }
public function change(): void { $this->value -= 1; }
}

@ -2,7 +2,7 @@
use TypePhp\CompilerTest; use TypePhp\CompilerTest;
class ClosureTest extends \PHPUnit\Framework\TestCase class ClosureTest extends \BaseTest
{ {
public function testUseReferenceCaptureCompiles(): void public function testUseReferenceCaptureCompiles(): void
{ {
@ -17,4 +17,24 @@ class ClosureTest extends \PHPUnit\Framework\TestCase
$this->assertTrue(true); $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'
);
}
} }

@ -72,7 +72,8 @@ class NativePropertyTest extends \BaseTest
$this->assertStringNotContainsString('typephp_static_int_ref(', $code); $this->assertStringNotContainsString('typephp_static_int_ref(', $code);
$this->assertStringNotContainsString('typephp_static_float_ref(', $code); $this->assertStringNotContainsString('typephp_static_float_ref(', $code);
$this->assertStringNotContainsString('_object_prop_', $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 public function testNativeIntPropertyAssignOpConvertsBitwiseNotClassConst(): void

@ -2,52 +2,19 @@
class ReadonlyPropertyTest extends \BaseTest 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->compile('readonly-write-outside-constructor.php');
$this->exec('Constructor __construct() can only be invoked by new', 'constructor-direct-static-call.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->compile('readonly-write-child-clone.php');
$this->exec('Clone method __clone() can only be invoked by clone', 'clone-direct-static-call.php'); $this->compile('readonly-write-clone-closure.php');
} $this->compile('readonly-write-other-instance-clone.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'
);
} }
public function testReadonlyPropertyCannotBeAssignedByReference(): void public function testReadonlyPropertyCannotBeAssignedByReference(): void
@ -66,12 +33,16 @@ class ReadonlyPropertyTest extends \BaseTest
foreach ([ foreach ([
'readonly-write-compound.php', 'readonly-write-compound.php',
'readonly-write-increment.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-dim.php',
'readonly-write-array-index.php',
'readonly-write-coalesce.php', 'readonly-write-coalesce.php',
'readonly-write-list.php', 'readonly-write-list.php',
'readonly-write-foreach.php', 'readonly-write-foreach.php',
] as $file) { ] as $file) {
$this->exec('can only be modified in its declaring `__construct` or `__clone` method', $file); $this->exec('only supports direct assignment', $file);
} }
} }
} }

@ -1095,11 +1095,6 @@ class CompilerBase implements PropertyAccessContext
return $this->method === '__construct'; return $this->method === '__construct';
} }
protected function isCurrentCloneMethod(): bool
{
return $this->method === '__clone';
}
protected function getCurrentMethodDisplayName(): string protected function getCurrentMethodDisplayName(): string
{ {
return $this->getFullClassName() . '::' . $this->method; return $this->getFullClassName() . '::' . $this->method;
@ -1786,6 +1781,9 @@ class CompilerBase implements PropertyAccessContext
protected function detectClassOfExpr(NodeAbstract $expr): string 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)) { if ($expr instanceof Expr\MethodCall && $this->isNamedMethod($expr->name)) {
$keywordType = $this->findKeywordMethod($this->parseIdentifier($expr->name)); $keywordType = $this->findKeywordMethod($this->parseIdentifier($expr->name));
if ($keywordType !== null && $keywordType !== Type::OBJECT) { if ($keywordType !== null && $keywordType !== Type::OBJECT) {

@ -25,9 +25,6 @@ class MethodDef
/** Source trait, retained only for diagnostics and the __TRAIT__ constant. */ /** Source trait, retained only for diagnostics and the __TRAIT__ constant. */
public string $traitOrigin = ''; public string $traitOrigin = '';
/** Original trait method name before a use-site alias is applied. */
public string $traitMethod = '';
public function __construct(int $flags, string $name) public function __construct(int $flags, string $name)
{ {
$this->flags = $flags; $this->flags = $flags;

@ -212,7 +212,9 @@ trait AssignOpTrait
return $this->parseAssignToList($left, $right); 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); $type = $this->detectTypeOfExpr($right);
$finalVarType = $this->getNormalAssignType($type); $finalVarType = $this->getNormalAssignType($type);
$runtimeObjectAssignClass = ''; $runtimeObjectAssignClass = '';
@ -433,6 +435,10 @@ trait AssignOpTrait
return false; return false;
} }
if ($def->isReadonly()) {
return true;
}
return !in_array($def->type, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR, Type::ARRAY], true) return !in_array($def->type, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR, Type::ARRAY], true)
&& $rightType === Type::VAR; && $rightType === Type::VAR;
} }

@ -276,25 +276,6 @@ trait MethodCallTrait
} }
return $this->genRuntimeFunctionCall($callable, $expr->args, $method, $parentClass); 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)) { if (empty($expr->args)) {
return 'this_.call(' . $methodPtr . ')'; return 'this_.call(' . $methodPtr . ')';
} }
@ -322,15 +303,6 @@ trait MethodCallTrait
protected function parseMethodCall(Expr\MethodCall $expr): string 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)) { if ($this->containsNullsafeChain($expr->var)) {
return $this->parseNullsafeExpr($expr); 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; $magicMethod = false;
$method = $this->identifierToStr($expr->name, literal: true); $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( private function isDefinitelyObjectReceiver(
Expr $receiver, Expr $receiver,
string $object, string $object,
@ -607,23 +583,15 @@ trait MethodCallTrait
$rtClass = ''; $rtClass = '';
$class = $this->parseIdentifier($expr->class); $class = $this->parseIdentifier($expr->class);
if ($this->isIdExpr($expr->name) && strtolower($expr->name->toString()) === '__construct') { if ($this->isNameExpr($expr->class)
$isParentConstructor = $this->isNameExpr($expr->class) && $this->isIdExpr($expr->name)
&& $class === 'parent' && strtolower(ltrim($expr->class->toString(), '\\')) === 'closure'
&& $this->isConstructorImplementationContext() && in_array(strtolower($expr->name->toString()), ['bind', 'bindto', 'call'], true)) {
&& !$this->context->inClosure; $closureMethod = $expr->name->toString();
if (!$isParentConstructor) { $this->fatalError(
$this->fatalError($expr, 'Constructor __construct() can only be invoked by new'); $expr,
} 'Closure::' . $closureMethod . '() is not supported'
} );
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');
}
} }
// parent::$method() still has a lexical parent class even when the // parent::$method() still has a lexical parent class even when the

@ -207,7 +207,9 @@ trait PropertyAccessTrait
protected function emitDynamicPropertyFetchRef(Expr\PropertyFetch $expr, NodeAbstract $errorNode): string 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)) { if ($this->canEmitDynamicPropertyTarget($target)) {
$objectExpr = $target->getDynamicObjectExpr(); $objectExpr = $target->getDynamicObjectExpr();
if (!$this->hasVar($objectExpr)) { if (!$this->hasVar($objectExpr)) {
@ -443,7 +445,7 @@ trait PropertyAccessTrait
$this->assertCanAssignObjectProperty($left, $right, 'static property'); $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) { if ($left instanceof Expr\PropertyFetch) {
$objectExpr = null; $objectExpr = null;
@ -455,8 +457,8 @@ trait PropertyAccessTrait
if ($this->isIdExpr($left->name)) { if ($this->isIdExpr($left->name)) {
$this->getPropertyIdentifier($left, $left->var, $left->name); $this->getPropertyIdentifier($left, $left->var, $left->name);
$this->assertPropertySetVisibility($left); $this->assertPropertySetVisibility($left);
if ($checkReadonlyWrite) { if (!$allowReadonlyAssignment) {
$this->assertReadonlyPropertyWriteContext($left); $this->assertReadonlyPropertyDirectAssignmentOnly($left);
} }
} }
return new PropertyWriteTarget($left, 'object property', $objectExpr, $propertyExpr); return new PropertyWriteTarget($left, 'object property', $objectExpr, $propertyExpr);
@ -473,13 +475,7 @@ trait PropertyAccessTrait
return null; return null;
} }
/** private function assertReadonlyPropertyDirectAssignmentOnly(Expr\PropertyFetch $property): void
* 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
{ {
$access = $this->getNativePropertyAccess($property); $access = $this->getNativePropertyAccess($property);
if ($access === null || !$access->getPropertyDef()->isReadonly()) { if ($access === null || !$access->getPropertyDef()->isReadonly()) {
@ -490,27 +486,7 @@ trait PropertyAccessTrait
$propertyName = $this->parseIdentifier($property->name); $propertyName = $this->parseIdentifier($property->name);
$display = $declaringClass . '::$' . $propertyName; $display = $declaringClass . '::$' . $propertyName;
if ($this->context->inClosure) { $this->fatalError($property, "Readonly property `{$display}` only supports direct assignment");
$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`"
);
}
} }
protected function assertReadonlyPropertyReferenceForbidden( protected function assertReadonlyPropertyReferenceForbidden(
@ -806,7 +782,7 @@ trait PropertyAccessTrait
} elseif ($this->isPropertyFetch($var)) { } elseif ($this->isPropertyFetch($var)) {
// unset has its own unconditional readonly diagnostic below; // unset has its own unconditional readonly diagnostic below;
// it is forbidden even while __construct is running. // it is forbidden even while __construct is running.
$propertyWriteTarget = $this->preparePropertyWriteTarget($var, false); $propertyWriteTarget = $this->preparePropertyWriteTarget($var, true);
$object = $this->getDynamicPropertyFetchObjectExpr($var, $propertyWriteTarget); $object = $this->getDynamicPropertyFetchObjectExpr($var, $propertyWriteTarget);
$restoreDefault = null; $restoreDefault = null;
if ($this->isIdExpr($var->name)) { if ($this->isIdExpr($var->name)) {

@ -4630,7 +4630,6 @@ CODE;
$methodDef = new MethodDef($flags, $name); $methodDef = new MethodDef($flags, $name);
$methodDef->node = $methodStmt; $methodDef->node = $methodStmt;
$methodDef->traitOrigin = (string) $methodStmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE, ''); $methodDef->traitOrigin = (string) $methodStmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE, '');
$methodDef->traitMethod = (string) $methodStmt->getAttribute(self::TRAIT_METHOD_ATTRIBUTE, '');
$this->method = $name; $this->method = $name;
$this->methodDef = $methodDef; $this->methodDef = $methodDef;

@ -1,5 +1,5 @@
--TEST-- --TEST--
TypePHP readonly properties may be updated while cloning readonly properties may be reinitialized once while cloning
--FILE-- --FILE--
<?php <?php
@ -16,8 +16,7 @@ class ReadonlyCloneBase
public function __clone(): void public function __clone(): void
{ {
$this->base++; $this->base = 5;
$this->base += 3;
} }
} }
@ -36,10 +35,13 @@ class ReadonlyCloneValue extends ReadonlyCloneBase
public function __clone(): void public function __clone(): void
{ {
parent::__clone(); parent::__clone();
$this->name = 'clone'; $this->name = 'cloned';
$this->name .= 'd'; $this->items = [10, 2];
$this->items[] = 2; try {
$this->items[0] = 10; $this->name = 'again';
} catch (Error $error) {
echo $error->getMessage(), "\n";
}
} }
} }
@ -52,6 +54,7 @@ function main(): void
} }
?> ?>
--EXPECT-- --EXPECT--
Cannot modify readonly property ReadonlyCloneValue::$name
int(1) int(1)
string(8) "original" string(8) "original"
array(1) { array(1) {

@ -1,42 +0,0 @@
--TEST--
TypePHP readonly properties are mutable only during their declaring constructor
--FILE--
<?php
use native_types;
class ReadonlyConstructorOnly
{
public readonly int $number;
public readonly string $text;
public readonly array $items;
public function __construct()
{
$this->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)
}

@ -1,35 +0,0 @@
--TEST--
Readonly clone method cannot be called dynamically
--FILE--
<?php
class ReadonlyDynamicCloneCall
{
public readonly int $value;
public function __construct()
{
$this->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)

@ -1,30 +0,0 @@
--TEST--
Readonly constructor cannot be called dynamically after construction
--FILE--
<?php
class ReadonlyDynamicConstructorCall
{
public readonly int $value;
public function __construct(int $value)
{
$this->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)

@ -0,0 +1,82 @@
--TEST--
readonly properties use PHP one-time initialization semantics
--FILE--
<?php
class ReadonlyBase
{
public readonly int $fromMethod;
public readonly int $fromChild;
public readonly int $fromClosure;
public function initializeMethod(): void
{
$this->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)
Loading…
Cancel
Save