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