Immutable 实现

master
韩天峰 7 days ago
parent 7fece68f12
commit 244fa4d8b2
  1. 114
      docs/IMMUTABLE.md
  2. 1
      docs/README.md
  3. 9
      examples/attributes/Immutable.php
  4. 22
      examples/attributes/readonly.php
  5. 12
      phpunit/code/compiler_api/library_import_php.php
  6. 5
      phpunit/code/immutable-array-byref-builtin.php
  7. 6
      phpunit/code/immutable-array-mutating-method.php
  8. 12
      phpunit/code/immutable-chain-alias.php
  9. 14
      phpunit/code/immutable-closure-capture-mutation.php
  10. 14
      phpunit/code/immutable-closure-this.php
  11. 6
      phpunit/code/immutable-compound-write.php
  12. 13
      phpunit/code/immutable-constructor-parameter.php
  13. 6
      phpunit/code/immutable-destructuring-write.php
  14. 12
      phpunit/code/immutable-generator-context.php
  15. 11
      phpunit/code/immutable-method-calls-mutable.php
  16. 18
      phpunit/code/immutable-method-override-drops-contract.php
  17. 12
      phpunit/code/immutable-method-property-write.php
  18. 14
      phpunit/code/immutable-mutable-extension-method.php
  19. 14
      phpunit/code/immutable-mutable-property-hook.php
  20. 11
      phpunit/code/immutable-object-alias-mutation.php
  21. 11
      phpunit/code/immutable-object-parameter-calls-mutable.php
  22. 10
      phpunit/code/immutable-object-passed-to-mutable-parameter.php
  23. 8
      phpunit/code/immutable-object-return-escape.php
  24. 13
      phpunit/code/immutable-object-storage-escape.php
  25. 13
      phpunit/code/immutable-parameter-override-drops-contract.php
  26. 6
      phpunit/code/immutable-parameter-reassign.php
  27. 11
      phpunit/code/immutable-property-byref-builtin.php
  28. 6
      phpunit/code/immutable-reference.php
  29. 6
      phpunit/code/immutable-unset.php
  30. 8
      phpunit/code/immutable-write-forms.php
  31. 4
      phpunit/src/CompileTimeAttributeRegistryTest.php
  32. 5
      phpunit/src/CompilerBaseApiTest.php
  33. 183
      phpunit/src/Immutable/ImmutableValidationTest.php
  34. 9
      src/CompilerBase.php
  35. 6
      src/Context/FunctionContext.php
  36. 2
      src/Entity/ArgInfo.php
  37. 2
      src/Entity/FunctionDef.php
  38. 26
      src/Generator/ClosureGenerator.php
  39. 8
      src/Generator/FiberGenerator.php
  40. 365
      src/Immutable/ImmutableSupportTrait.php
  41. 10
      src/Parser/AssignOpTrait.php
  42. 3
      src/Parser/ForeachTrait.php
  43. 1
      src/Parser/FunctionCallTrait.php
  44. 2
      src/Parser/MethodCallTrait.php
  45. 1
      src/Parser/PropertyAccessTrait.php
  46. 1
      src/Parser/UniversalMethodCall.php
  47. 2
      src/Preprocessor.php
  48. 4
      src/Transform/CompileTimeAttribute.php
  49. 2
      src/Transform/CompileTimeAttributeRegistry.php
  50. 12
      src/Transform/FunctionAttributeLowering.php
  51. 11
      src/Translator.php
  52. 5
      src/polyfills.php
  53. 137
      tests/compiler/attribute/immutable.phpt

@ -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.

@ -12,6 +12,7 @@
- [编译期函数](COMPILE_TIME_FUNCTIONS.md):`any()`、`refval()`、`objval()`、`expected()`、`unexpected()` 和关键词方法。 - [编译期函数](COMPILE_TIME_FUNCTIONS.md):`any()`、`refval()`、`objval()`、`expected()`、`unexpected()` 和关键词方法。
- [原生类型](NATIVE_TYPES.md)、[高精度类型](HIGH_PRECISION_TYPES.md)、[Std 容器](STD_CONTAINERS.md)。 - [原生类型](NATIVE_TYPES.md)、[高精度类型](HIGH_PRECISION_TYPES.md)、[Std 容器](STD_CONTAINERS.md)。
- [通用与扩展方法](UNIVERSAL_METHODS.md)、[Generator](YIELD_GENERATOR.md)。 - [通用与扩展方法](UNIVERSAL_METHODS.md)、[Generator](YIELD_GENERATOR.md)。
- [`#[Immutable]` 编译期只读契约](IMMUTABLE.md):方法、参数、别名、调用边界与动态逃逸规则。
- [类继承](CLASS_INHERITANCE.md)、[混合 C++/PHP](MIXED_CPP_PHP.md)。 - [类继承](CLASS_INHERITANCE.md)、[混合 C++/PHP](MIXED_CPP_PHP.md)。
## 架构与维护 ## 架构与维护

@ -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';
}
}

@ -8,6 +8,7 @@ use \Constructor;
use \Validate; use \Validate;
use \Getter; use \Getter;
use \Hot; use \Hot;
use \Immutable;
use \NotNull; use \NotNull;
use \NoExport as Internal; use \NoExport as Internal;
use \Override; use \Override;
@ -40,6 +41,12 @@ class Counter
return $this->value; return $this->value;
} }
#[Immutable]
public function current(): int
{
return $this->value;
}
#[MustUse, Cold] #[MustUse, Cold]
public function label(#[NotNull, Validate(FILTER_VALIDATE_EMAIL)] string $value): string public function label(#[NotNull, Validate(FILTER_VALIDATE_EMAIL)] string $value): string
{ {
@ -78,6 +85,11 @@ function twice(int $value): int
return $value * 2; return $value * 2;
} }
function inspect(#[Immutable] Counter $counter): int
{
return $counter->current();
}
#[Internal] #[Internal]
function internal_twice(int $value = 2): int function internal_twice(int $value = 2): int
{ {

@ -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++;
}
}

@ -9,7 +9,7 @@ final class CompileTimeAttributeRegistryTest extends TestCase
{ {
$expected = [ $expected = [
'Native', 'MethodsFor', 'NoExport', 'WasmExport', 'Getter', 'Setter', 'With', 'Printer', 'Arrayable', 'Native', 'MethodsFor', 'NoExport', 'WasmExport', 'Getter', 'Setter', 'With', 'Printer', 'Arrayable',
'NotNull', 'NotEmpty', 'Validate', 'Override', 'MustUse', 'Hot', 'Cold', 'Constructor', 'NotNull', 'NotEmpty', 'Validate', 'Override', 'MustUse', 'Immutable', 'Hot', 'Cold', 'Constructor',
]; ];
$this->assertSame($expected, CompileTimeAttributeRegistry::names()); $this->assertSame($expected, CompileTimeAttributeRegistry::names());
@ -26,7 +26,7 @@ final class CompileTimeAttributeRegistryTest extends TestCase
$this->assertContains('Getter', CompileTimeAttributeRegistry::names(true)); $this->assertContains('Getter', CompileTimeAttributeRegistry::names(true));
$this->assertContains('Override', CompileTimeAttributeRegistry::names(true)); $this->assertContains('Override', CompileTimeAttributeRegistry::names(true));
$this->assertSame( $this->assertSame(
['Override', 'MustUse', 'Hot', 'Cold'], ['Override', 'MustUse', 'Immutable', 'Hot', 'Cold'],
CompileTimeAttributeRegistry::namesForPhase(CompileTimeAttributeRegistry::PHASE_ENTER), CompileTimeAttributeRegistry::namesForPhase(CompileTimeAttributeRegistry::PHASE_ENTER),
); );
} }

@ -1175,6 +1175,11 @@ YAML);
$this->assertStringContainsString('#[\MustUse, \Cold]', $stub); $this->assertStringContainsString('#[\MustUse, \Cold]', $stub);
$this->assertStringContainsString('#[\MustUse, \Hot]', $stub); $this->assertStringContainsString('#[\MustUse, \Hot]', $stub);
$this->assertStringContainsString('#[\Override]', $stub); $this->assertStringContainsString('#[\Override]', $stub);
$this->assertStringContainsString('#[\Immutable]', $stub);
$this->assertMatchesRegularExpression(
'/function inspect\(\s*#\[\\\\Immutable\]\s*\\\\LibraryApi\\\\Counter \$counter\s*\): int/s',
$stub,
);
$this->assertMatchesRegularExpression( $this->assertMatchesRegularExpression(
'/public int \$doubled\s*\{\s*get\s*\{\s*\}\s*set\(int \$value\)\s*\{\s*\}\s*\}/s', '/public int \$doubled\s*\{\s*get\s*\{\s*\}\s*set\(int \$value\)\s*\{\s*\}\s*\}/s',
$stub, $stub,

@ -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');
}
}

@ -84,6 +84,7 @@ use TypePhp\TypeSystem\CompositeTypeCheckerTrait;
use TypePhp\TypeSystem\NativeTypeCompatibilityTrait; use TypePhp\TypeSystem\NativeTypeCompatibilityTrait;
use TypePhp\NativeClass\NativeClassSupportTrait; use TypePhp\NativeClass\NativeClassSupportTrait;
use TypePhp\NativeClass\NativeGlobalTypeResolver; use TypePhp\NativeClass\NativeGlobalTypeResolver;
use TypePhp\Immutable\ImmutableSupportTrait;
use PhpParser\Modifiers; use PhpParser\Modifiers;
use PhpParser\Node; use PhpParser\Node;
use PhpParser\Node\ArrayItem; use PhpParser\Node\ArrayItem;
@ -105,6 +106,7 @@ class CompilerBase implements PropertyAccessContext
use CompilationStateTrait; use CompilationStateTrait;
use NativeTypeCompatibilityTrait; use NativeTypeCompatibilityTrait;
use NativeClassSupportTrait; use NativeClassSupportTrait;
use ImmutableSupportTrait;
use NativeBuildConfigurationTrait; use NativeBuildConfigurationTrait;
use PythonModuleTrait; use PythonModuleTrait;
use DeclarationSymbolTrait; use DeclarationSymbolTrait;
@ -2223,6 +2225,9 @@ class CompilerBase implements PropertyAccessContext
protected function parseReturn(Node\Stmt\Return_ $v): string protected function parseReturn(Node\Stmt\Return_ $v): string
{ {
if ($v->expr !== null) {
$this->assertImmutableObjectDoesNotEscape($v->expr, 'a return value');
}
if ($v->expr !== null && $this->isVarExpr($v->expr)) { if ($v->expr !== null && $this->isVarExpr($v->expr)) {
$this->assertStdContainerDoesNotEscapeNativeObjects( $this->assertStdContainerDoesNotEscapeNativeObjects(
$v, $v,
@ -3202,6 +3207,7 @@ class CompilerBase implements PropertyAccessContext
protected function parsePreInc(Expr\PreInc $expr): string protected function parsePreInc(Expr\PreInc $expr): string
{ {
$this->assertImmutableMutationTarget($expr->var);
$this->assertNativeArrayAccessDirectWrite($expr->var, false); $this->assertNativeArrayAccessDirectWrite($expr->var, false);
$this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, '++'); $this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, '++');
$this->assertNotNullsafeWriteContext($expr->var); $this->assertNotNullsafeWriteContext($expr->var);
@ -3591,6 +3597,7 @@ class CompilerBase implements PropertyAccessContext
protected function parsePostOp(Expr\PostDec|Expr\PostInc $expr, string $op): string protected function parsePostOp(Expr\PostDec|Expr\PostInc $expr, string $op): string
{ {
$this->assertImmutableMutationTarget($expr->var);
$this->assertNativeArrayAccessDirectWrite($expr->var, false); $this->assertNativeArrayAccessDirectWrite($expr->var, false);
$this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, str_repeat($op, 2)); $this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, str_repeat($op, 2));
$this->assertNotNullsafeWriteContext($expr->var); $this->assertNotNullsafeWriteContext($expr->var);
@ -3642,6 +3649,7 @@ class CompilerBase implements PropertyAccessContext
protected function parsePreDec(Expr\PreDec $expr): string protected function parsePreDec(Expr\PreDec $expr): string
{ {
$this->assertImmutableMutationTarget($expr->var);
$this->assertNativeArrayAccessDirectWrite($expr->var, false); $this->assertNativeArrayAccessDirectWrite($expr->var, false);
$this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, '--'); $this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, '--');
$this->assertNotNullsafeWriteContext($expr->var); $this->assertNotNullsafeWriteContext($expr->var);
@ -3699,6 +3707,7 @@ class CompilerBase implements PropertyAccessContext
protected function parseNew(Expr\New_ $expr): string protected function parseNew(Expr\New_ $expr): string
{ {
$this->validateImmutableCall($expr);
if (!$expr->class instanceof Node\Stmt\Class_ && !$this->isNameExpr($expr->class)) { if (!$expr->class instanceof Node\Stmt\Class_ && !$this->isNameExpr($expr->class)) {
$this->assertNotNativeObjectDynamicClassTarget($expr->class, $expr); $this->assertNotNativeObjectDynamicClassTarget($expr->class, $expr);
} }

@ -72,6 +72,10 @@ class FunctionContext
public bool $needsUserCodeCallableScope = false; public bool $needsUserCodeCallableScope = false;
public int $tmpVarIndex = 0; public int $tmpVarIndex = 0;
public array $arguments = []; public array $arguments = [];
/** @var array<string, true> Bindings protected by #[Immutable]. */
public array $immutableVars = [];
/** @var array<string, true> Immutable bindings which may contain object identity. */
public array $immutableObjectVars = [];
/** True while parsing a breakable loop or switch. */ /** True while parsing a breakable loop or switch. */
public bool $inLoop = false; public bool $inLoop = false;
/** True while parsing a for/foreach/while/do-while body. */ /** True while parsing a for/foreach/while/do-while body. */
@ -102,6 +106,8 @@ class FunctionContext
$this->localVars = []; $this->localVars = [];
$this->staticVars = []; $this->staticVars = [];
$this->arguments = []; $this->arguments = [];
$this->immutableVars = [];
$this->immutableObjectVars = [];
$this->objects = []; $this->objects = [];
$this->nativeObjects = []; $this->nativeObjects = [];
$this->nonNullNativeObjects = []; $this->nonNullNativeObjects = [];

@ -42,6 +42,8 @@ class ArgInfo
public bool $undeclared = false; public bool $undeclared = false;
public bool $explicitMixed = false; public bool $explicitMixed = false;
public bool $property = false; public bool $property = false;
/** This parameter binding and any referenced object are read-only in the callee. */
public bool $immutable = false;
/** /**
* Each element: ['kind' => 'isInt'|'isFloat'|...|'instanceof', 'class' => ''] * Each element: ['kind' => 'isInt'|'isFloat'|...|'instanceof', 'class' => '']

@ -42,6 +42,8 @@ class FunctionDef
public bool $generator = false; public bool $generator = false;
/** The call result must not be discarded as a statement expression. */ /** The call result must not be discarded as a statement expression. */
public bool $mustUse = false; public bool $mustUse = false;
/** This instance method may not mutate its receiver. */
public bool $immutable = false;
/** The method must override an inherited class or interface method. */ /** The method must override an inherited class or interface method. */
public bool $overrideRequired = false; public bool $overrideRequired = false;
/** Prefer optimizing this function for frequently executed paths. */ /** Prefer optimizing this function for frequently executed paths. */

@ -12,6 +12,7 @@ use TypePhp\Type;
use TypePhp\Entity\ArgInfo; use TypePhp\Entity\ArgInfo;
use TypePhp\Context\FunctionContext; use TypePhp\Context\FunctionContext;
use TypePhp\Transform\CompileTimeAttribute;
use PhpParser\Node; use PhpParser\Node;
use PhpParser\Node\Expr; use PhpParser\Node\Expr;
use PhpParser\Node\IntersectionType; use PhpParser\Node\IntersectionType;
@ -201,6 +202,9 @@ trait ClosureGenerator
$code .= $this->getIndent() . '}' . PHP_EOL; $code .= $this->getIndent() . '}' . PHP_EOL;
$code .= $this->genExtraNamedVariadicArgs($var); $code .= $this->genExtraNamedVariadicArgs($var);
$this->addArgument($var, Type::ARRAY); $this->addArgument($var, Type::ARRAY);
if (CompileTimeAttribute::consume($param, 'Immutable')) {
$this->context->immutableVars[$var] = true;
}
$code .= $this->genClosureParamTypeCheck($param, $var, $phpName, $i, true); $code .= $this->genClosureParamTypeCheck($param, $var, $phpName, $i, true);
continue; continue;
} }
@ -209,6 +213,18 @@ trait ClosureGenerator
: 'php::getCallArg(' . $i . ', ' . $this->parseParamDefaultValue($param->default) . ')'; : 'php::getCallArg(' . $i . ', ' . $this->parseParamDefaultValue($param->default) . ')';
$code .= $this->getIndent() . 'auto ' . $var . ' = ' . $argExpr . ';' . PHP_EOL; $code .= $this->getIndent() . 'auto ' . $var . ' = ' . $argExpr . ';' . PHP_EOL;
$this->addArgument($var, Type::VAR); $this->addArgument($var, Type::VAR);
if (CompileTimeAttribute::consume($param, 'Immutable')) {
$this->context->immutableVars[$var] = true;
if ($this->immutableTypeNodeMayBeObject($param->type)) {
$this->context->immutableObjectVars[$var] = true;
}
if ($param->type !== null) {
[, $parameterClass] = $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM);
if ($parameterClass !== '') {
$this->addObject($var, $parameterClass);
}
}
}
$code .= $this->genClosureParamTypeCheck($param, $var, $phpName, $i, false); $code .= $this->genClosureParamTypeCheck($param, $var, $phpName, $i, false);
} }
@ -216,10 +232,20 @@ trait ClosureGenerator
$var = $this->parseIdentifier($useItem->var); $var = $this->parseIdentifier($useItem->var);
$code .= 'auto ' . $var . ' = vars_.get(' . $i . ');' . PHP_EOL; $code .= 'auto ' . $var . ' = vars_.get(' . $i . ');' . PHP_EOL;
$this->addArgument($var, Type::VAR); $this->addArgument($var, Type::VAR);
if (isset($oriContext->immutableVars[$var])) {
$this->context->immutableVars[$var] = true;
if (isset($oriContext->immutableObjectVars[$var])) {
$this->context->immutableObjectVars[$var] = true;
}
}
} }
if ($this->methodDef && !$expr->static) { if ($this->methodDef && !$expr->static) {
$this->addArgument('this_', Type::OBJECT); $this->addArgument('this_', Type::OBJECT);
if (isset($oriContext->immutableVars['this_'])) {
$this->context->immutableVars['this_'] = true;
$this->context->immutableObjectVars['this_'] = true;
}
} }
$body = $isGenerator $body = $isGenerator

@ -185,6 +185,7 @@ trait FiberGenerator
private function materializeYieldOperand(Node $expr, bool $force = false): string private function materializeYieldOperand(Node $expr, bool $force = false): string
{ {
$this->assertImmutableObjectDoesNotEscape($expr, 'a yielded value');
if ($this->isNativeObjectClass($this->detectClassOfExpr($expr))) { if ($this->isNativeObjectClass($this->detectClassOfExpr($expr))) {
// Yield payloads are stored in a Zend array and cross the Fiber / // Yield payloads are stored in a Zend array and cross the Fiber /
// Generator object boundary. A Native pointer has no zval form. // Generator object boundary. A Native pointer has no zval form.
@ -281,10 +282,17 @@ trait FiberGenerator
foreach ($functionDef->argInfoList as $i => $argInfo) { foreach ($functionDef->argInfoList as $i => $argInfo) {
$code .= $this->getIndent() . Type::VAR . ' ' . $argInfo->name . ' = vars_.get(' . $i . ');' . PHP_EOL; $code .= $this->getIndent() . Type::VAR . ' ' . $argInfo->name . ' = vars_.get(' . $i . ');' . PHP_EOL;
$this->addArgument($argInfo->name, Type::VAR); $this->addArgument($argInfo->name, Type::VAR);
$argumentClass = $argInfo->declaredClass ?: $argInfo->class;
if ($argumentClass !== '') {
$this->addObject($argInfo->name, $argumentClass);
}
} }
if ($this->class) { if ($this->class) {
$this->addArgument('this_', Type::OBJECT); $this->addArgument('this_', Type::OBJECT);
} }
// The Fiber body has its own FunctionContext. Reapply compile-time
// effect metadata so suspension does not erase Immutable guarantees.
$this->initializeImmutableFunctionContext();
$body = ''; $body = '';
$this->indentLevel++; $this->indentLevel++;

@ -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',
);
}
}
}
}

@ -130,6 +130,10 @@ trait AssignOpTrait
$rightVar = new Variable($tmpVar); $rightVar = new Variable($tmpVar);
foreach ($chain as $var) { foreach ($chain as $var) {
$list[] = $this->parseAssignFinally($var, $rightVar); $list[] = $this->parseAssignFinally($var, $rightVar);
// The synthetic temporary has no PHP-level binding metadata. Use
// the original RHS to retain immutable object identity across a
// right-associative assignment chain after validating the write.
$this->recordImmutableAlias($var, $next);
} }
return '(' . implode(', ', $list) . ')'; return '(' . implode(', ', $list) . ')';
@ -232,6 +236,8 @@ trait AssignOpTrait
protected function parseAssignFinally(Expr $left, Expr $right): string protected function parseAssignFinally(Expr $left, Expr $right): string
{ {
$this->assertImmutableMutationTarget($left);
$this->recordImmutableAlias($left, $right);
$this->assertNotNullsafeWriteContext($left); $this->assertNotNullsafeWriteContext($left);
$this->assertNativeArrayAccessDirectWrite($left, true); $this->assertNativeArrayAccessDirectWrite($left, true);
if ($left instanceof Expr\ArrayDimFetch if ($left instanceof Expr\ArrayDimFetch
@ -739,6 +745,7 @@ trait AssignOpTrait
protected function parseAssignOp(Expr\AssignOp $node, string $op): string protected function parseAssignOp(Expr\AssignOp $node, string $op): string
{ {
$this->assertImmutableMutationTarget($node->var);
$this->assertNativeArrayAccessDirectWrite($node->var, false); $this->assertNativeArrayAccessDirectWrite($node->var, false);
$this->assertNativeObjectOperatorOperandSupported($node->var, $node, $op); $this->assertNativeObjectOperatorOperandSupported($node->var, $node, $op);
$this->assertNotNullsafeWriteContext($node->var); $this->assertNotNullsafeWriteContext($node->var);
@ -1072,6 +1079,8 @@ trait AssignOpTrait
protected function parseAssignRef(Expr\AssignRef $expr): string protected function parseAssignRef(Expr\AssignRef $expr): string
{ {
$this->assertImmutableMutationTarget($expr->var);
$this->assertImmutableMutationTarget($expr->expr);
$this->assertNativeArrayAccessReferenceForbidden($expr->var); $this->assertNativeArrayAccessReferenceForbidden($expr->var);
$this->assertNativeArrayAccessReferenceForbidden($expr->expr); $this->assertNativeArrayAccessReferenceForbidden($expr->expr);
$this->assertNotNullsafeWriteContext($expr->var); $this->assertNotNullsafeWriteContext($expr->var);
@ -1218,6 +1227,7 @@ trait AssignOpTrait
protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string
{ {
$this->assertImmutableMutationTarget($expr->var);
$this->assertNativeArrayAccessDirectWrite($expr->var, false); $this->assertNativeArrayAccessDirectWrite($expr->var, false);
$this->checkLeftValue($expr->var); $this->checkLeftValue($expr->var);

@ -171,6 +171,9 @@ trait ForeachTrait
protected function parseForeach(Foreach_ $node): string protected function parseForeach(Foreach_ $node): string
{ {
if ($node->byRef) {
$this->assertImmutableMutationTarget($node->expr);
}
$nativeClass = $this->detectClassOfExpr($node->expr); $nativeClass = $this->detectClassOfExpr($node->expr);
if ($this->isNativeObjectClass($nativeClass)) { if ($this->isNativeObjectClass($nativeClass)) {
if ($this->nativeClassImplementsInterface($nativeClass, 'Iterator')) { if ($this->nativeClassImplementsInterface($nativeClass, 'Iterator')) {

@ -71,6 +71,7 @@ trait FunctionCallTrait
protected function parseFuncCall(Expr\FuncCall $expr): string protected function parseFuncCall(Expr\FuncCall $expr): string
{ {
$this->validateImmutableCall($expr);
$pythonCall = $this->parsePythonFunctionCall($expr); $pythonCall = $this->parsePythonFunctionCall($expr);
if ($pythonCall !== null) { if ($pythonCall !== null) {
return $pythonCall; return $pythonCall;

@ -361,6 +361,7 @@ trait MethodCallTrait
protected function parseMethodCall(Expr\MethodCall $expr): string protected function parseMethodCall(Expr\MethodCall $expr): string
{ {
$this->validateImmutableCall($expr);
if ($this->containsNullsafeChain($expr->var)) { if ($this->containsNullsafeChain($expr->var)) {
return $this->parseNullsafeExpr($expr); return $this->parseNullsafeExpr($expr);
} }
@ -752,6 +753,7 @@ trait MethodCallTrait
protected function parseStaticCall(Expr\StaticCall $expr): string protected function parseStaticCall(Expr\StaticCall $expr): string
{ {
$this->validateImmutableCall($expr);
if (!$this->isNameExpr($expr->class)) { if (!$this->isNameExpr($expr->class)) {
$this->assertNotNativeObjectDynamicClassTarget($expr->class, $expr); $this->assertNotNativeObjectDynamicClassTarget($expr->class, $expr);
} }

@ -785,6 +785,7 @@ trait PropertyAccessTrait
$vars = $node->vars; $vars = $node->vars;
$lines = []; $lines = [];
foreach ($vars as $var) { foreach ($vars as $var) {
$this->assertImmutableMutationTarget($var);
$this->assertNotNullsafeWriteContext($var); $this->assertNotNullsafeWriteContext($var);
$this->assertNativePropertyHookDirectWriteTarget($var); $this->assertNativePropertyHookDirectWriteTarget($var);
if ($this->isArrayDimFetch($var)) { if ($this->isArrayDimFetch($var)) {

@ -397,6 +397,7 @@ trait UniversalMethodCall
'return_type' => $function->returnType, 'return_type' => $function->returnType,
'min_args' => max(0, $function->argCountRequired - 1), 'min_args' => max(0, $function->argCountRequired - 1),
'max_args' => $function->hasVariadicArg() ? -1 : count($function->argInfoList) - 1, 'max_args' => $function->hasVariadicArg() ? -1 : count($function->argInfoList) - 1,
'receiver_immutable' => $receiver->immutable,
]; ];
} }
} }

@ -640,6 +640,7 @@ class Preprocessor extends CompilerBase
$argInfo->byRef = $param->byRef; $argInfo->byRef = $param->byRef;
$argInfo->variadic = $param->variadic; $argInfo->variadic = $param->variadic;
$argInfo->property = $param->isPromoted(); $argInfo->property = $param->isPromoted();
$argInfo->immutable = \TypePhp\Transform\CompileTimeAttribute::consume($param, 'Immutable');
if ($param->type === null || $param->type instanceof NullableType) { if ($param->type === null || $param->type instanceof NullableType) {
$argInfo->nullable = true; $argInfo->nullable = true;
} }
@ -746,6 +747,7 @@ class Preprocessor extends CompilerBase
$functionDef = new FunctionDef($fnName, $returnType, $this->namespace); $functionDef = new FunctionDef($fnName, $returnType, $this->namespace);
$functionDef->mustUse = (bool) $v->getAttribute(FunctionAttributeLowering::MUST_USE_ATTRIBUTE, false); $functionDef->mustUse = (bool) $v->getAttribute(FunctionAttributeLowering::MUST_USE_ATTRIBUTE, false);
$functionDef->immutable = (bool) $v->getAttribute(FunctionAttributeLowering::IMMUTABLE_ATTRIBUTE, false);
$functionDef->overrideRequired = (bool) $v->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false); $functionDef->overrideRequired = (bool) $v->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false);
$functionDef->hot = (bool) $v->getAttribute(FunctionAttributeLowering::HOT_ATTRIBUTE, false); $functionDef->hot = (bool) $v->getAttribute(FunctionAttributeLowering::HOT_ATTRIBUTE, false);
$functionDef->cold = (bool) $v->getAttribute(FunctionAttributeLowering::COLD_ATTRIBUTE, false); $functionDef->cold = (bool) $v->getAttribute(FunctionAttributeLowering::COLD_ATTRIBUTE, false);

@ -189,6 +189,10 @@ final class CompileTimeAttribute
&& $node instanceof Node\Stmt\ClassMethod) { && $node instanceof Node\Stmt\ClassMethod) {
return true; return true;
} }
if (in_array(CompileTimeAttributeRegistry::TARGET_PROPERTY_HOOK, $targets, true)
&& $node instanceof Node\PropertyHook) {
return true;
}
if (in_array(CompileTimeAttributeRegistry::TARGET_PROPERTY, $targets, true) if (in_array(CompileTimeAttributeRegistry::TARGET_PROPERTY, $targets, true)
&& ($node instanceof Node\Stmt\Property || ($node instanceof Node\Param && $node->isPromoted()))) { && ($node instanceof Node\Stmt\Property || ($node instanceof Node\Param && $node->isPromoted()))) {
return true; return true;

@ -15,6 +15,7 @@ final class CompileTimeAttributeRegistry
public const TARGET_CLASS_LIKE = 'class_like'; public const TARGET_CLASS_LIKE = 'class_like';
public const TARGET_FUNCTION = 'function'; public const TARGET_FUNCTION = 'function';
public const TARGET_METHOD = 'method'; public const TARGET_METHOD = 'method';
public const TARGET_PROPERTY_HOOK = 'property_hook';
public const TARGET_PROPERTY = 'property'; public const TARGET_PROPERTY = 'property';
public const TARGET_DECLARED_PROPERTY = 'declared_property'; public const TARGET_DECLARED_PROPERTY = 'declared_property';
public const TARGET_PARAMETER = 'parameter'; public const TARGET_PARAMETER = 'parameter';
@ -88,6 +89,7 @@ final class CompileTimeAttributeRegistry
$add('Validate', [self::TARGET_PARAMETER], 'Validate can only be applied to function or method parameters', self::ARGUMENTS_VALIDATE, self::PHASE_FUNCTION_LEAVE); $add('Validate', [self::TARGET_PARAMETER], 'Validate can only be applied to function or method parameters', self::ARGUMENTS_VALIDATE, self::PHASE_FUNCTION_LEAVE);
$add('Override', [self::TARGET_METHOD], 'Override can only be applied to methods', self::ARGUMENTS_NONE, self::PHASE_ENTER); $add('Override', [self::TARGET_METHOD], 'Override can only be applied to methods', self::ARGUMENTS_NONE, self::PHASE_ENTER);
$add('MustUse', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'MustUse can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER); $add('MustUse', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'MustUse can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER);
$add('Immutable', [self::TARGET_METHOD, self::TARGET_PROPERTY_HOOK, self::TARGET_PARAMETER], 'Immutable can only be applied to methods, property hooks, or function parameters', self::ARGUMENTS_NONE, self::PHASE_ENTER);
$add('Hot', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Hot can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Cold']); $add('Hot', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Hot can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Cold']);
$add('Cold', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Cold can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Hot']); $add('Cold', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Cold can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Hot']);
$add('Constructor', [self::TARGET_DECLARED_PROPERTY], 'Constructor can only be applied to instance properties', self::ARGUMENTS_NONE, self::PHASE_CLASS_LEAVE); $add('Constructor', [self::TARGET_DECLARED_PROPERTY], 'Constructor can only be applied to instance properties', self::ARGUMENTS_NONE, self::PHASE_CLASS_LEAVE);

@ -15,6 +15,7 @@ use TypePhp\Exception\SyntaxError;
final class FunctionAttributeLowering final class FunctionAttributeLowering
{ {
public const MUST_USE_ATTRIBUTE = 'typephpMustUse'; public const MUST_USE_ATTRIBUTE = 'typephpMustUse';
public const IMMUTABLE_ATTRIBUTE = 'typephpImmutable';
public const OVERRIDE_ATTRIBUTE = 'typephpOverride'; public const OVERRIDE_ATTRIBUTE = 'typephpOverride';
public const HOT_ATTRIBUTE = 'typephpHot'; public const HOT_ATTRIBUTE = 'typephpHot';
public const COLD_ATTRIBUTE = 'typephpCold'; public const COLD_ATTRIBUTE = 'typephpCold';
@ -25,6 +26,17 @@ final class FunctionAttributeLowering
if (!CompileTimeAttribute::has($node, $name)) { if (!CompileTimeAttribute::has($node, $name)) {
continue; continue;
} }
if ($name === 'Immutable' && $node instanceof Node\Param) {
// Parameter metadata is consumed while building ArgInfo.
continue;
}
if ($name === 'Immutable' && $node instanceof Node\PropertyHook) {
// Property hooks are later lowered to generated ClassMethod
// nodes. Carry the effect bit through the source attributes.
CompileTimeAttribute::consume($node, $name);
$node->setAttribute(self::IMMUTABLE_ATTRIBUTE, true);
continue;
}
if (!$node instanceof Stmt\Function_ && !$node instanceof Stmt\ClassMethod) { if (!$node instanceof Stmt\Function_ && !$node instanceof Stmt\ClassMethod) {
throw new SyntaxError($name . ' can only be applied to functions or methods'); throw new SyntaxError($name . ' can only be applied to functions or methods');
} }

@ -3753,6 +3753,7 @@ CODE;
$this->markNativeObjectNonNull($argInfo->name); $this->markNativeObjectNonNull($argInfo->name);
} }
} }
$this->initializeImmutableFunctionContext();
if ($this->functionDef->generator) { if ($this->functionDef->generator) {
try { try {
@ -3984,6 +3985,13 @@ CODE;
)); ));
} }
// Immutable is an effect contract. Code compiled against the parent
// may pass a read-only object or call the method through a read-only
// receiver, so an override must not silently regain write access.
if ($parentFuncDef->immutable && !$childFuncDef->immutable) {
$this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass);
}
if (!$this->isReturnTypeOverrideCompatible( if (!$this->isReturnTypeOverrideCompatible(
$childFuncDef, $childFuncDef,
$parentFuncDef, $parentFuncDef,
@ -4008,6 +4016,9 @@ CODE;
$this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass);
} }
$childArg = $childFuncDef->argInfoList[$i]; $childArg = $childFuncDef->argInfoList[$i];
if ($parentArg->immutable && !$childArg->immutable) {
$this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass);
}
if (!$this->isParameterTypeOverrideCompatible($childArg, $parentArg)) { if (!$this->isParameterTypeOverrideCompatible($childArg, $parentArg)) {
$this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass);
} }

@ -95,6 +95,11 @@ final readonly class MustUse
{ {
} }
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)]
final readonly class Immutable
{
}
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] #[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
final readonly class Hot final readonly class Hot
{ {

@ -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…
Cancel
Save