parent
7fece68f12
commit
244fa4d8b2
53 changed files with 1217 additions and 2 deletions
@ -0,0 +1,114 @@ |
||||
# `#[Immutable]` compile-time effect checking |
||||
|
||||
## Purpose |
||||
|
||||
`#[Immutable]` is a TypePHP compile-time annotation modelled after C++ `const`. |
||||
It prevents accidental mutation in statically compiled code without adding a |
||||
wrapper object, Zend metadata, runtime branch, or ABI change. |
||||
|
||||
It is intentionally a best-effort static tool rather than a security boundary. |
||||
Calls whose target is deliberately made dynamic are an escape hatch and do not |
||||
receive a runtime guard. |
||||
|
||||
## Supported targets |
||||
|
||||
```php |
||||
#[Immutable] |
||||
public function name(): string |
||||
{ |
||||
return $this->name; |
||||
} |
||||
|
||||
function inspect(#[Immutable] User $user): string |
||||
{ |
||||
return $user->name(); |
||||
} |
||||
``` |
||||
|
||||
The attribute is valid on methods and on function, method, and closure |
||||
parameters. On an instance method it makes `$this` immutable. On a parameter it |
||||
makes the binding immutable and, when it can contain an object, treats the |
||||
referenced object as immutable as well. |
||||
|
||||
## Rejected operations |
||||
|
||||
For an immutable root such as `$this` or `$user`, the compiler rejects: |
||||
|
||||
- assignment, destructuring, and array-element or object-property writes; |
||||
- compound assignment, `++`, `--`, `unset()`, taking a reference, and |
||||
`foreach (... as &$value)`; |
||||
- a statically named method call unless the resolved method is also marked |
||||
`#[Immutable]`; |
||||
- a mutating value extension such as `$array->sort()`; read-only array/string |
||||
methods remain available; |
||||
- passing an object to a statically resolved parameter that is not itself |
||||
`#[Immutable]`; |
||||
- passing any immutable value to a mutable by-reference parameter, including |
||||
extension functions such as `sort()`; |
||||
- storing an immutable object identity in an object property, array, |
||||
global/static variable, or returning/yielding it as a mutable value. |
||||
|
||||
An immutable by-reference parameter is supported. It acts like a C++ `const &`: |
||||
the reference is accepted because the callee is checked against mutation. |
||||
|
||||
`#[MethodsFor]` follows the same contract. An object extension is callable on |
||||
an immutable receiver only when its receiver parameter is marked |
||||
`#[Immutable]`. |
||||
|
||||
## Aliases, closures, generators, and inheritance |
||||
|
||||
Local aliases of immutable objects remain immutable: |
||||
|
||||
```php |
||||
$alias = $user; |
||||
$alias->rename('new'); // compile-time error |
||||
``` |
||||
|
||||
`clone` creates a distinct mutable object and therefore intentionally drops the |
||||
annotation. Captured variables, arrow functions, closure `$this`, and Fiber |
||||
generator bodies carry immutable metadata into their generated function |
||||
contexts. |
||||
|
||||
An overriding class or interface method may strengthen an ordinary contract by |
||||
adding `#[Immutable]`, but it cannot remove `#[Immutable]` from an inherited |
||||
method or parameter. |
||||
|
||||
## Value versus object semantics |
||||
|
||||
Scalar values and PHP copy-on-write values can be read and copied normally. For |
||||
example, `count($values)` and `$copy = $values` do not modify an immutable array. |
||||
The compiler propagates immutability through an expression only when object |
||||
identity is possible. |
||||
|
||||
## Explicit escape hatches |
||||
|
||||
The following intentionally bypass static method-effect checking: |
||||
|
||||
```php |
||||
$method = 'rename'; |
||||
$user->$method('new'); |
||||
|
||||
$callable = getRuntimeCallable(); |
||||
$callable($user); |
||||
``` |
||||
|
||||
The same applies to other runtime-only mechanisms that hide the target from the |
||||
compiler, including reflection and dynamic ZendVM code. TypePHP neither inserts |
||||
a runtime read-only proxy nor attempts to recover the escaped value later. |
||||
|
||||
This boundary is deliberate: `#[Immutable]` should cost nothing in generated |
||||
code and should not complicate PHPX/ZendVM object semantics. |
||||
|
||||
## Property hooks and magic access |
||||
|
||||
Property-hook reads are lowered to generated method calls. Consequently, a hook |
||||
used through an immutable receiver must itself carry an `#[Immutable]` method |
||||
contract; otherwise the generated call is rejected. Fully dynamic magic access |
||||
is covered by the same escape-hatch rule as other runtime-only behavior. |
||||
|
||||
## Implementation boundaries |
||||
|
||||
The implementation is isolated in `src/Immutable/ImmutableSupportTrait.php`. |
||||
`FunctionDef` and `ArgInfo` retain only compile-time effect bits, while each |
||||
`FunctionContext` stores the immutable roots and object aliases relevant to that |
||||
body. Checks run during AST lowering and emit no C++ code when successful. |
||||
@ -0,0 +1,9 @@ |
||||
<?php |
||||
|
||||
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)] |
||||
class Immutable |
||||
{ |
||||
public function __construct() |
||||
{ |
||||
} |
||||
} |
||||
@ -0,0 +1,22 @@ |
||||
<?php |
||||
|
||||
class UserTest { |
||||
private string $name; |
||||
|
||||
#[Immutable] |
||||
function foo(): void |
||||
{ |
||||
// 不允许,方法是 Immutable 的,不可修改对象的属性 |
||||
$this->name = 'hello'; |
||||
} |
||||
|
||||
function bar( |
||||
#[Immutable] |
||||
string $name): void |
||||
{ |
||||
// 允许,方法不是 Immutable 的 |
||||
$this->name = 'hello'; |
||||
// 不允许,$name 是 Immutable 的,不可修改 |
||||
$name = 'world'; |
||||
} |
||||
} |
||||
@ -0,0 +1,5 @@ |
||||
<?php |
||||
function immutableArrayByRefBuiltin(#[Immutable] array $values): void |
||||
{ |
||||
sort($values); |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function immutableArrayMutatingMethod(#[Immutable] array $values): void |
||||
{ |
||||
$values->sort(); |
||||
} |
||||
@ -0,0 +1,12 @@ |
||||
<?php |
||||
|
||||
class ImmutableChainAliasTarget |
||||
{ |
||||
public function mutate(): void {} |
||||
} |
||||
|
||||
function immutableChainAlias(#[Immutable] ImmutableChainAliasTarget $target): void |
||||
{ |
||||
$first = $second = $target; |
||||
$first->mutate(); |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
<?php |
||||
|
||||
class ImmutableClosureCapture |
||||
{ |
||||
public int $value = 1; |
||||
|
||||
public function run(#[Immutable] ImmutableClosureCapture $target): void |
||||
{ |
||||
$callback = function () use ($target): void { |
||||
$target->value = 2; |
||||
}; |
||||
$callback(); |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
<?php |
||||
|
||||
class ImmutableClosureThis |
||||
{ |
||||
public function mutate(): void {} |
||||
|
||||
#[Immutable] |
||||
public function callback(): Closure |
||||
{ |
||||
return function (): void { |
||||
$this->mutate(); |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function immutableCompoundWrite(#[Immutable] array $values): void |
||||
{ |
||||
$values[0] += 1; |
||||
} |
||||
@ -0,0 +1,13 @@ |
||||
<?php |
||||
|
||||
class ImmutableConstructorTarget {} |
||||
|
||||
class MutableConstructor |
||||
{ |
||||
public function __construct(ImmutableConstructorTarget $value) {} |
||||
} |
||||
|
||||
function immutableConstructorParameter(#[Immutable] ImmutableConstructorTarget $value): void |
||||
{ |
||||
new MutableConstructor($value); |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function immutableDestructuringWrite(#[Immutable] array $values): void |
||||
{ |
||||
[$values] = [[1, 2]]; |
||||
} |
||||
@ -0,0 +1,12 @@ |
||||
<?php |
||||
|
||||
class ImmutableGeneratorTarget |
||||
{ |
||||
public function mutate(): void {} |
||||
} |
||||
|
||||
function immutableGeneratorContext(#[Immutable] ImmutableGeneratorTarget $target): Generator |
||||
{ |
||||
yield 1; |
||||
$target->mutate(); |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
<?php |
||||
class ImmutableMethodCallsMutable |
||||
{ |
||||
public function mutate(): void {} |
||||
|
||||
#[Immutable] |
||||
public function read(): void |
||||
{ |
||||
$this->mutate(); |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
<?php |
||||
|
||||
class ImmutableOverrideParent |
||||
{ |
||||
#[Immutable] |
||||
public function read(): int |
||||
{ |
||||
return 1; |
||||
} |
||||
} |
||||
|
||||
class ImmutableOverrideChild extends ImmutableOverrideParent |
||||
{ |
||||
public function read(): int |
||||
{ |
||||
return 2; |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@ |
||||
<?php |
||||
class ImmutableMethodPropertyWrite |
||||
{ |
||||
private string $value = ''; |
||||
|
||||
#[Immutable] |
||||
public function read(): string |
||||
{ |
||||
$this->value = 'changed'; |
||||
return $this->value; |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
<?php |
||||
|
||||
class ImmutableExtensionTarget {} |
||||
|
||||
#[MethodsFor(ImmutableExtensionTarget::class)] |
||||
class MutableExtensionMethods |
||||
{ |
||||
public static function touch(ImmutableExtensionTarget $value): void {} |
||||
} |
||||
|
||||
function immutableMutableExtensionMethod(#[Immutable] ImmutableExtensionTarget $value): void |
||||
{ |
||||
$value->touch(); |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
<?php |
||||
|
||||
class ImmutableMutablePropertyHook |
||||
{ |
||||
public string $value = 'value' { |
||||
get => $this->value; |
||||
} |
||||
|
||||
#[Immutable] |
||||
public function read(): string |
||||
{ |
||||
return $this->value; |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
<?php |
||||
class ImmutableAliasTarget |
||||
{ |
||||
public function mutate(): void {} |
||||
} |
||||
|
||||
function immutableObjectAliasMutation(#[Immutable] ImmutableAliasTarget $target): void |
||||
{ |
||||
$alias = $target; |
||||
$alias->mutate(); |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
<?php |
||||
class ImmutableObjectParameterTarget |
||||
{ |
||||
public function mutate(): void {} |
||||
} |
||||
|
||||
function immutableObjectParameterCallsMutable( |
||||
#[Immutable] ImmutableObjectParameterTarget $target, |
||||
): void { |
||||
$target->mutate(); |
||||
} |
||||
@ -0,0 +1,10 @@ |
||||
<?php |
||||
class ImmutableObjectPassedValue {} |
||||
|
||||
function receiveMutableObject(ImmutableObjectPassedValue $value): void {} |
||||
|
||||
function immutableObjectPassedToMutableParameter( |
||||
#[Immutable] ImmutableObjectPassedValue $value, |
||||
): void { |
||||
receiveMutableObject($value); |
||||
} |
||||
@ -0,0 +1,8 @@ |
||||
<?php |
||||
|
||||
class ImmutableReturnEscapeTarget {} |
||||
|
||||
function immutableReturnEscape(#[Immutable] ImmutableReturnEscapeTarget $value): ImmutableReturnEscapeTarget |
||||
{ |
||||
return $value; |
||||
} |
||||
@ -0,0 +1,13 @@ |
||||
<?php |
||||
|
||||
class ImmutableStorageEscapeTarget {} |
||||
|
||||
class ImmutableStorageEscape |
||||
{ |
||||
public ?ImmutableStorageEscapeTarget $stored = null; |
||||
|
||||
public function store(#[Immutable] ImmutableStorageEscapeTarget $value): void |
||||
{ |
||||
$this->stored = $value; |
||||
} |
||||
} |
||||
@ -0,0 +1,13 @@ |
||||
<?php |
||||
|
||||
class ImmutableOverrideValue {} |
||||
|
||||
class ImmutableParameterParent |
||||
{ |
||||
public function inspect(#[Immutable] ImmutableOverrideValue $value): void {} |
||||
} |
||||
|
||||
class ImmutableParameterChild extends ImmutableParameterParent |
||||
{ |
||||
public function inspect(ImmutableOverrideValue $value): void {} |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
function immutableParameterReassign(#[Immutable] string $value): string |
||||
{ |
||||
$value = 'changed'; |
||||
return $value; |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
<?php |
||||
class ImmutablePropertyByRefBuiltin |
||||
{ |
||||
private array $values = [2, 1]; |
||||
|
||||
#[Immutable] |
||||
public function sorted(): void |
||||
{ |
||||
sort($this->values); |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function immutableReference(#[Immutable] array $values): void |
||||
{ |
||||
$item =& $values[0]; |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function immutableUnset(#[Immutable] object $value): void |
||||
{ |
||||
unset($value->name); |
||||
} |
||||
@ -0,0 +1,8 @@ |
||||
<?php |
||||
|
||||
function immutableWriteForms(#[Immutable] array $values): void |
||||
{ |
||||
foreach ($values as &$value) { |
||||
$value++; |
||||
} |
||||
} |
||||
@ -0,0 +1,183 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of Swoole-Compiler(AOT). |
||||
* |
||||
* @link https://www.swoole.com/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Tests\Immutable; |
||||
|
||||
use TypePhp\Exception\TestError; |
||||
|
||||
/** |
||||
* @internal |
||||
* @coversNothing |
||||
*/ |
||||
final class ImmutableValidationTest extends \BaseTest |
||||
{ |
||||
public function testRejectsPropertyWriteInImmutableMethod(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot modify immutable value `$this`'); |
||||
$this->compile('immutable-method-property-write.php'); |
||||
} |
||||
|
||||
public function testRejectsImmutableParameterReassignment(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot modify immutable value `$value`'); |
||||
$this->compile('immutable-parameter-reassign.php'); |
||||
} |
||||
|
||||
public function testRejectsMutableMethodCallOnImmutableThis(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutable method `ImmutableMethodCallsMutable::mutate()` on immutable value `$this`'); |
||||
$this->compile('immutable-method-calls-mutable.php'); |
||||
} |
||||
|
||||
public function testRejectsMutableMethodCallOnImmutableObjectParameter(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutable method `ImmutableObjectParameterTarget::mutate()` on immutable value `$target`'); |
||||
$this->compile('immutable-object-parameter-calls-mutable.php'); |
||||
} |
||||
|
||||
public function testRejectsImmutableObjectPassedToMutableParameter(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Immutable object `$value` requires an #[Immutable] parameter'); |
||||
$this->compile('immutable-object-passed-to-mutable-parameter.php'); |
||||
} |
||||
|
||||
public function testRejectsImmutablePropertyPassedToBuiltinReferenceParameter(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot pass immutable value `$this` to reference parameter 1 of sort()'); |
||||
$this->compile('immutable-property-byref-builtin.php'); |
||||
} |
||||
|
||||
public function testRejectsImmutableArrayPassedToBuiltinReferenceParameter(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot pass immutable value `$values` to reference parameter 1 of sort()'); |
||||
$this->compile('immutable-array-byref-builtin.php'); |
||||
} |
||||
|
||||
public function testImmutableObjectAliasesRemainImmutable(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutable method `ImmutableAliasTarget::mutate()` on immutable value `$alias`'); |
||||
$this->compile('immutable-object-alias-mutation.php'); |
||||
} |
||||
|
||||
public function testMethodOverrideCannotDropImmutableContract(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Declaration of `ImmutableOverrideChild::read()` must be compatible'); |
||||
$this->compile('immutable-method-override-drops-contract.php'); |
||||
} |
||||
|
||||
public function testParameterOverrideCannotDropImmutableContract(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Declaration of `ImmutableParameterChild::inspect()` must be compatible'); |
||||
$this->compile('immutable-parameter-override-drops-contract.php'); |
||||
} |
||||
|
||||
public function testClosureCapturePreservesImmutableBinding(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot modify immutable value `$target`'); |
||||
$this->compile('immutable-closure-capture-mutation.php'); |
||||
} |
||||
|
||||
public function testForeachByReferenceCannotEscapeImmutableArray(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot modify immutable value `$values`'); |
||||
$this->compile('immutable-write-forms.php'); |
||||
} |
||||
|
||||
/** @dataProvider immutableWriteProvider */ |
||||
public function testRejectsAdditionalImmutableWriteForms(string $fixture, string $variable): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage("Cannot modify immutable value `\${$variable}`"); |
||||
$this->compile($fixture); |
||||
} |
||||
|
||||
public static function immutableWriteProvider(): array |
||||
{ |
||||
return [ |
||||
'compound array write' => ['immutable-compound-write.php', 'values'], |
||||
'unset property' => ['immutable-unset.php', 'value'], |
||||
'take reference' => ['immutable-reference.php', 'values'], |
||||
'destructuring assignment' => ['immutable-destructuring-write.php', 'values'], |
||||
]; |
||||
} |
||||
|
||||
public function testRightAssociativeObjectAliasesRemainImmutable(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutable method `ImmutableChainAliasTarget::mutate()` on immutable value `$first`'); |
||||
$this->compile('immutable-chain-alias.php'); |
||||
} |
||||
|
||||
public function testImmutableObjectCannotBeStoredInMutableProperty(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Immutable object `$value` cannot be stored in mutable state'); |
||||
$this->compile('immutable-object-storage-escape.php'); |
||||
} |
||||
|
||||
public function testImmutableObjectCannotEscapeAsMutableReturnValue(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Immutable object `$value` cannot escape through a return value'); |
||||
$this->compile('immutable-object-return-escape.php'); |
||||
} |
||||
|
||||
public function testGeneratorBodyPreservesImmutableParameters(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutable method `ImmutableGeneratorTarget::mutate()` on immutable value `$target`'); |
||||
$this->compile('immutable-generator-context.php'); |
||||
} |
||||
|
||||
public function testClosureInImmutableMethodPreservesImmutableThis(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutable method `ImmutableClosureThis::mutate()` on immutable value `$this`'); |
||||
$this->compile('immutable-closure-this.php'); |
||||
} |
||||
|
||||
public function testPropertyHookMustDeclareImmutableContract(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutable method'); |
||||
$this->compile('immutable-mutable-property-hook.php'); |
||||
} |
||||
|
||||
public function testConstructorMustAcceptImmutableObjectContract(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Immutable object `$value` requires an #[Immutable] parameter'); |
||||
$this->compile('immutable-constructor-parameter.php'); |
||||
} |
||||
|
||||
public function testRejectsMutatingArrayExtensionMethod(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutating method `sort()` on immutable value `$values`'); |
||||
$this->compile('immutable-array-mutating-method.php'); |
||||
} |
||||
|
||||
public function testExtensionReceiverMustDeclareImmutableContract(): void |
||||
{ |
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Cannot call mutable method `ImmutableExtensionTarget::touch()`'); |
||||
$this->compile('immutable-mutable-extension-method.php'); |
||||
} |
||||
} |
||||
@ -0,0 +1,365 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of Swoole-Compiler(AOT). |
||||
* |
||||
* @link https://www.swoole.com/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Immutable; |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\NodeAbstract; |
||||
use TypePhp\Entity\ArgInfo; |
||||
use TypePhp\Entity\FunctionDef; |
||||
use TypePhp\Type; |
||||
|
||||
/** Compile-time effect checks; successful checks emit no runtime code. */ |
||||
trait ImmutableSupportTrait |
||||
{ |
||||
protected function immutableTypeNodeMayBeObject(?NodeAbstract $type): bool |
||||
{ |
||||
if ($type === null) { |
||||
return true; |
||||
} |
||||
if ($type instanceof Node\NullableType) { |
||||
return $this->immutableTypeNodeMayBeObject($type->type); |
||||
} |
||||
if ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { |
||||
foreach ($type->types as $member) { |
||||
if ($this->immutableTypeNodeMayBeObject($member)) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
if ($type instanceof Node\Name) { |
||||
return true; |
||||
} |
||||
if ($type instanceof Node\Identifier) { |
||||
return in_array(strtolower($type->toString()), ['mixed', 'object', 'iterable', 'callable'], true); |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
protected function initializeImmutableFunctionContext(): void |
||||
{ |
||||
if ($this->functionDef?->immutable && $this->methodDef !== null) { |
||||
$this->context->immutableVars['this_'] = true; |
||||
$this->context->immutableObjectVars['this_'] = true; |
||||
} |
||||
foreach ($this->functionDef?->argInfoList ?? [] as $argument) { |
||||
if (!$argument->immutable) { |
||||
continue; |
||||
} |
||||
$this->context->immutableVars[$argument->name] = true; |
||||
if ($argument->type === Type::OBJECT || $argument->type === Type::VAR) { |
||||
$this->context->immutableObjectVars[$argument->name] = true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
protected function immutableRootName(NodeAbstract $expression): ?string |
||||
{ |
||||
if ($this->context->immutableVars === []) { |
||||
return null; |
||||
} |
||||
if ($expression instanceof Node\Expr\ErrorSuppress) { |
||||
return $this->immutableRootName($expression->expr); |
||||
} |
||||
if ($expression instanceof Node\Expr\Variable && is_string($expression->name)) { |
||||
$name = $this->parseVariable($expression); |
||||
return isset($this->context->immutableVars[$name]) ? $name : null; |
||||
} |
||||
if ($expression instanceof Node\Expr\PropertyFetch |
||||
|| $expression instanceof Node\Expr\NullsafePropertyFetch |
||||
|| $expression instanceof Node\Expr\ArrayDimFetch |
||||
) { |
||||
return $this->immutableRootName($expression->var); |
||||
} |
||||
if (($expression instanceof Node\Expr\MethodCall |
||||
|| $expression instanceof Node\Expr\NullsafeMethodCall) |
||||
&& $this->immutableRootName($expression->var) !== null |
||||
&& $this->immutableCalledMethod($expression)?->immutable |
||||
) { |
||||
return $this->immutableRootName($expression->var); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
protected function immutableDisplayName(string $name): string |
||||
{ |
||||
return $name === 'this_' ? '$this' : '$' . $this->unescapeVarName($name); |
||||
} |
||||
|
||||
protected function immutableValueMayBeObject(NodeAbstract $expression): bool |
||||
{ |
||||
$root = $this->immutableRootName($expression); |
||||
if ($root === null) { |
||||
return false; |
||||
} |
||||
$type = $this->detectTypeOfExpr($expression); |
||||
// An immutable receiver may produce an ordinary scalar/COW value. |
||||
// Preserve constness only when object identity is possible. |
||||
if ($type !== Type::OBJECT && $type !== Type::VAR && $type !== Type::REF) { |
||||
return false; |
||||
} |
||||
if (isset($this->context->immutableObjectVars[$root])) { |
||||
return true; |
||||
} |
||||
return $this->detectClassOfExpr($expression) !== '' |
||||
|| $type === Type::OBJECT; |
||||
} |
||||
|
||||
protected function assertImmutableMutationTarget(NodeAbstract $target): void |
||||
{ |
||||
if ($target instanceof Node\Expr\List_ || $target instanceof Node\Expr\Array_) { |
||||
foreach ($target->items as $item) { |
||||
if ($item !== null) { |
||||
$this->assertImmutableMutationTarget($item->value); |
||||
} |
||||
} |
||||
return; |
||||
} |
||||
$root = $this->immutableRootName($target); |
||||
if ($root !== null) { |
||||
$this->fatalError( |
||||
$target, |
||||
'Cannot modify immutable value `' . $this->immutableDisplayName($root) . '`', |
||||
); |
||||
} |
||||
} |
||||
|
||||
protected function recordImmutableAlias(NodeAbstract $left, NodeAbstract $right): void |
||||
{ |
||||
if ($right instanceof Node\Expr\Clone_) { |
||||
return; |
||||
} |
||||
$root = $this->immutableRootName($right); |
||||
if ($root === null || !$this->immutableValueMayBeObject($right)) { |
||||
return; |
||||
} |
||||
if (!$left instanceof Node\Expr\Variable || !is_string($left->name)) { |
||||
$this->fatalError( |
||||
$right, |
||||
'Immutable object `' . $this->immutableDisplayName($root) |
||||
. '` cannot be stored in mutable state', |
||||
); |
||||
} |
||||
$name = $this->parseVariable($left); |
||||
if ($this->hasScopeGlobalVar($name) || $this->hasStaticVar($name)) { |
||||
$this->fatalError( |
||||
$right, |
||||
'Immutable object `' . $this->immutableDisplayName($root) |
||||
. '` cannot be stored in mutable state', |
||||
); |
||||
} |
||||
$this->context->immutableVars[$name] = true; |
||||
$this->context->immutableObjectVars[$name] = true; |
||||
$class = $this->detectClassOfExpr($right); |
||||
if ($class !== '') { |
||||
$this->addObject($name, $class); |
||||
} |
||||
} |
||||
|
||||
protected function assertImmutableObjectDoesNotEscape(NodeAbstract $expression, string $destination): void |
||||
{ |
||||
$root = $this->immutableRootName($expression); |
||||
if ($root !== null && $this->immutableValueMayBeObject($expression)) { |
||||
$this->fatalError( |
||||
$expression, |
||||
'Immutable object `' . $this->immutableDisplayName($root) |
||||
. '` cannot escape through ' . $destination, |
||||
); |
||||
} |
||||
} |
||||
|
||||
protected function assertImmutableValueMethodDoesNotMutate( |
||||
Node\Expr\MethodCall|Node\Expr\NullsafeMethodCall $call, |
||||
string $root, |
||||
): void { |
||||
if (!$call->name instanceof Node\Identifier) { |
||||
return; |
||||
} |
||||
$type = $this->detectTypeOfExpr($call->var); |
||||
$method = $call->name->toString(); |
||||
$definition = self::UNIVERSAL_METHODS[$type][$method] ?? null; |
||||
if ($definition !== null && in_array($definition['handler'], self::MUTATING_HANDLERS, true)) { |
||||
$this->fatalError( |
||||
$call, |
||||
"Cannot call mutating method `{$method}()` on immutable value `" |
||||
. $this->immutableDisplayName($root) . '`', |
||||
); |
||||
} |
||||
} |
||||
|
||||
protected function immutableExtensionAcceptsReceiver( |
||||
Node\Expr\MethodCall|Node\Expr\NullsafeMethodCall $call, |
||||
): bool { |
||||
if (!$call->name instanceof Node\Identifier) { |
||||
return false; |
||||
} |
||||
$method = $call->name->toString(); |
||||
$class = $this->detectClassOfExpr($call->var); |
||||
if ($class !== '') { |
||||
$definition = $this->findObjectExtensionMethod($class, $method, true); |
||||
} else { |
||||
$definition = $this->findExtensionMethod($this->detectTypeOfExpr($call->var), $method); |
||||
} |
||||
$definition ??= $this->findKeywordExtensionMethod($method); |
||||
return (bool) ($definition['receiver_immutable'] ?? false); |
||||
} |
||||
|
||||
protected function immutableCalledMethod( |
||||
Node\Expr\MethodCall|Node\Expr\NullsafeMethodCall $call, |
||||
): ?FunctionDef { |
||||
$ordinary = $call instanceof Node\Expr\NullsafeMethodCall |
||||
? new Node\Expr\MethodCall($call->var, $call->name, $call->args, $call->getAttributes()) |
||||
: $call; |
||||
return $this->resolveCalledFunctionDef($ordinary); |
||||
} |
||||
|
||||
protected function immutableClassName(Node\Name $name): string |
||||
{ |
||||
$class = $this->parseIdentifier($name); |
||||
if ($class === 'self' || $class === 'static') { |
||||
return $this->getFullClassName(); |
||||
} |
||||
if ($class === 'parent') { |
||||
return $this->classDef?->extends ?? ''; |
||||
} |
||||
return $this->getNamespacedClassName($class); |
||||
} |
||||
|
||||
protected function immutableArgInfo( |
||||
FunctionDef $function, |
||||
Node\Arg $argument, |
||||
int $index, |
||||
): ?ArgInfo { |
||||
if ($argument->name === null) { |
||||
return $this->getArgInfoByIndex($function, $index); |
||||
} |
||||
if (!$argument->name instanceof Node\Identifier) { |
||||
return null; |
||||
} |
||||
$name = $argument->name->toString(); |
||||
$variadic = null; |
||||
foreach ($function->argInfoList as $info) { |
||||
if ($info->variadic) { |
||||
$variadic = $info; |
||||
} |
||||
if (($info->phpName ?: $this->unescapeVarName($info->name)) === $name) { |
||||
return $info; |
||||
} |
||||
} |
||||
return $variadic; |
||||
} |
||||
|
||||
/** @return array{string, string} function/method name and class name */ |
||||
protected function immutableCallableName(Node\Expr\CallLike $call): array |
||||
{ |
||||
if ($call instanceof Node\Expr\FuncCall && $call->name instanceof Node\Name) { |
||||
return [ltrim($this->parseIdentifier($call->name), '\\'), '']; |
||||
} |
||||
if (($call instanceof Node\Expr\MethodCall || $call instanceof Node\Expr\NullsafeMethodCall) |
||||
&& $call->name instanceof Node\Identifier |
||||
) { |
||||
$class = $this->detectClassOfExpr($call->var); |
||||
if ($class === '' && $call->var instanceof Node\Expr\Variable && is_string($call->var->name)) { |
||||
$name = $this->parseVariable($call->var); |
||||
$class = $name === 'this_' ? $this->getFullClassName() : $this->getDeclaredObjectType($name); |
||||
} |
||||
return [$call->name->toString(), $class]; |
||||
} |
||||
if ($call instanceof Node\Expr\StaticCall |
||||
&& $call->class instanceof Node\Name |
||||
&& $call->name instanceof Node\Identifier |
||||
) { |
||||
$class = $this->immutableClassName($call->class); |
||||
return [$call->name->toString(), $class]; |
||||
} |
||||
if ($call instanceof Node\Expr\New_ && $call->class instanceof Node\Name) { |
||||
return ['__construct', $this->immutableClassName($call->class)]; |
||||
} |
||||
return ['', self::DYNAMIC_CALLED_CLASS]; |
||||
} |
||||
|
||||
protected function validateImmutableCall(Node\Expr\CallLike $call): void |
||||
{ |
||||
if ($this->context->immutableVars === []) { |
||||
return; |
||||
} |
||||
if ($call->getAttribute('typephpImmutableValidated', false)) { |
||||
return; |
||||
} |
||||
$call->setAttribute('typephpImmutableValidated', true); |
||||
|
||||
$function = $this->resolveCalledFunctionDef($call); |
||||
if ($call instanceof Node\Expr\NullsafeMethodCall) { |
||||
$function = $this->immutableCalledMethod($call); |
||||
} elseif ($call instanceof Node\Expr\New_ && $call->class instanceof Node\Name) { |
||||
$class = $this->immutableClassName($call->class); |
||||
$function = $class === '' ? null : $this->findAotMethodFunctionDef($class, '__construct'); |
||||
} |
||||
|
||||
if (($call instanceof Node\Expr\MethodCall || $call instanceof Node\Expr\NullsafeMethodCall) |
||||
&& $call->name instanceof Node\Identifier |
||||
&& ($root = $this->immutableRootName($call->var)) !== null |
||||
) { |
||||
// A named call must be proven immutable. A variable method name |
||||
// is an explicit escape hatch, similar to const_cast in C++; |
||||
// #[Immutable] deliberately has no runtime component. |
||||
if (!$this->immutableValueMayBeObject($call->var)) { |
||||
$this->assertImmutableValueMethodDoesNotMutate($call, $root); |
||||
} elseif (($function === null || !$function->immutable) |
||||
&& !$this->immutableExtensionAcceptsReceiver($call) |
||||
) { |
||||
$method = $call->name->toString(); |
||||
$class = $this->detectClassOfExpr($call->var) ?: 'object'; |
||||
$this->fatalError( |
||||
$call, |
||||
"Cannot call mutable method `{$class}::{$method}()` on immutable value `" |
||||
. $this->immutableDisplayName($root) . '`', |
||||
); |
||||
} |
||||
} |
||||
|
||||
[$callable, $class] = $this->immutableCallableName($call); |
||||
$staticallyResolved = $function !== null |
||||
|| ($call instanceof Node\Expr\FuncCall && $callable !== '') |
||||
|| ($callable !== '' && $class !== '' && $class !== self::DYNAMIC_CALLED_CLASS); |
||||
if (!$staticallyResolved) { |
||||
return; |
||||
} |
||||
foreach ($call->args as $index => $argument) { |
||||
if ($argument instanceof Node\VariadicPlaceholder) { |
||||
continue; |
||||
} |
||||
$root = $this->immutableRootName($argument->value); |
||||
if ($root === null) { |
||||
continue; |
||||
} |
||||
$info = $function === null ? null : $this->immutableArgInfo($function, $argument, $index); |
||||
$byRef = $info?->byRef ?? false; |
||||
if ($function === null) { |
||||
$byRef = $argument->name instanceof Node\Identifier |
||||
? $this->isReferenceNamedArgument($callable, $class, $argument->name->toString()) |
||||
: $this->isReferenceArgument($callable, $class, $index); |
||||
} |
||||
if ($byRef && !($info?->immutable ?? false)) { |
||||
$this->fatalError( |
||||
$argument, |
||||
'Cannot pass immutable value `' . $this->immutableDisplayName($root) |
||||
. '` to reference parameter ' . ($index + 1) . ' of ' . $callable . '()', |
||||
); |
||||
} |
||||
if ($this->immutableValueMayBeObject($argument->value) && !($info?->immutable ?? false)) { |
||||
$this->fatalError( |
||||
$argument, |
||||
'Immutable object `' . $this->immutableDisplayName($root) |
||||
. '` requires an #[Immutable] parameter', |
||||
); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,137 @@ |
||||
--TEST-- |
||||
Immutable compile-time attribute preserves read-only methods and parameters |
||||
--FILE-- |
||||
<?php |
||||
|
||||
function inspectImmutable(#[Immutable] ImmutableUser $user): string |
||||
{ |
||||
$alias = $user; |
||||
return $alias->label(); |
||||
} |
||||
|
||||
function sumImmutable(#[Immutable] array $values): int |
||||
{ |
||||
return count($values) + $values->count() + $values[0] + $values[1]; |
||||
} |
||||
|
||||
function inspectImmutableReference(#[Immutable] ImmutableUser &$user): string |
||||
{ |
||||
return $user->name(); |
||||
} |
||||
|
||||
trait ImmutableNameTrait |
||||
{ |
||||
#[Immutable] |
||||
public function traitName(): string |
||||
{ |
||||
return $this->name(); |
||||
} |
||||
} |
||||
|
||||
class ImmutableUser |
||||
{ |
||||
use ImmutableNameTrait; |
||||
|
||||
private string $name = 'Rango'; |
||||
|
||||
#[Immutable] |
||||
public function name(): string |
||||
{ |
||||
return $this->name; |
||||
} |
||||
|
||||
#[Immutable] |
||||
public function describe(): string |
||||
{ |
||||
return inspectImmutable($this) . ':' . $this->name(); |
||||
} |
||||
|
||||
public function rename(string $name): void |
||||
{ |
||||
$this->name = $name; |
||||
} |
||||
} |
||||
|
||||
class ImmutableHookedValue |
||||
{ |
||||
public string $value = 'hook' { |
||||
#[Immutable] |
||||
get => strtoupper($this->value); |
||||
} |
||||
|
||||
#[Immutable] |
||||
public function read(): string |
||||
{ |
||||
return $this->value; |
||||
} |
||||
} |
||||
|
||||
class ImmutableReader |
||||
{ |
||||
public function __construct(#[Immutable] ImmutableUser $user) |
||||
{ |
||||
echo $user->name(), PHP_EOL; |
||||
} |
||||
} |
||||
|
||||
#[MethodsFor(ImmutableUser::class)] |
||||
class ImmutableUserMethods |
||||
{ |
||||
public static function label(#[Immutable] ImmutableUser $user): string |
||||
{ |
||||
return $user->name(); |
||||
} |
||||
} |
||||
|
||||
function cloneImmutable(#[Immutable] ImmutableUser $user): string |
||||
{ |
||||
$copy = clone $user; |
||||
$copy->rename('Clone'); |
||||
return $copy->name(); |
||||
} |
||||
|
||||
function deliberatelyEscapeImmutableCheck(#[Immutable] ImmutableUser $user): string |
||||
{ |
||||
$method = 'rename'; |
||||
$user->$method('Dynamic'); |
||||
return $user->name(); |
||||
} |
||||
|
||||
function closureImmutableParameter(ImmutableUser $user): string |
||||
{ |
||||
$callback = function (#[Immutable] ImmutableUser $value): string { |
||||
return $value->name(); |
||||
}; |
||||
return $callback($user); |
||||
} |
||||
|
||||
function dynamicTargetEscape(mixed $target, #[Immutable] ImmutableUser $user): void |
||||
{ |
||||
// The runtime receiver hides the parameter contract from the compiler. |
||||
$target->accept($user); |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$user = new ImmutableUser(); |
||||
echo $user->describe(), PHP_EOL; |
||||
echo $user->traitName(), PHP_EOL; |
||||
echo sumImmutable([2, 3]), PHP_EOL; |
||||
echo cloneImmutable($user), PHP_EOL; |
||||
echo inspectImmutableReference($user), PHP_EOL; |
||||
echo deliberatelyEscapeImmutableCheck($user), PHP_EOL; |
||||
echo (new ImmutableHookedValue())->read(), PHP_EOL; |
||||
echo closureImmutableParameter($user), PHP_EOL; |
||||
new ImmutableReader($user); |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
Rango:Rango |
||||
Rango |
||||
9 |
||||
Clone |
||||
Rango |
||||
Dynamic |
||||
HOOK |
||||
Dynamic |
||||
Dynamic |
||||
Loading…
Reference in new issue