diff --git a/docs/NATIVE_TYPES.md b/docs/NATIVE_TYPES.md index da4b6e9a..b059b228 100644 --- a/docs/NATIVE_TYPES.md +++ b/docs/NATIVE_TYPES.md @@ -16,6 +16,42 @@ --- +## 对象属性类型是固定的 + +AOT 编译器要求对象属性在整个生命周期内始终保持声明时的类型。与 PHP 解释器不同,AOT 不允许通过运行时操作把一个已声明类型的属性改成其他类型。 + +尤其需要注意固定值类型属性上的 `unset($obj->prop)` 和赋值 `null`: + +```php +id); // ❌ AOT 不允许依赖这种语义 +$user->id = null; // ❌ 这同样会把 int 属性改成 null + +unset($user->profile); // ✅ 对象属性可进入 null/unset 状态 +$user->profile = null; // ✅ 对象属性可显式设置为 null +``` + +在 PHP 中,`unset($obj->prop)` 可以让对象属性脱离当前值状态,后续表现为未初始化或空值状态;对固定值类型属性赋值 `null` 也会把值状态改成空值。从 AOT 的类型系统角度看,这等价于把属性从声明的 `int`、`float`、`bool`、`string`、`array` 改变为 `null`/未初始化状态。AOT 编译器不允许这些固定值类型属性改变类型,因此属性永远是声明时的类型。 + +具体类对象属性使用更严格的对象类型规则:`public MyClass $object` 可以被 `unset()` 或设置为 `null`;但再次赋值对象时,运行时对象类型必须是 `MyClass` 本身。与 PHP 不同,AOT 不允许把子类对象赋给基类属性。 + +正确做法: + +- 不要对 `int`、`float`、`bool`、`string`、`array` 固定值类型对象属性使用 `unset()` 或赋值 `null`。 +- 如果属性业务上可能为空,应显式声明为可空类型,例如 `public ?int $id = null;`,并用赋值表达状态变化。 +- 如果对象属性声明为具体类名,非空赋值必须使用声明类本身,不要依赖 PHP 的子类兼容赋值语义。 +- 如果属性需要保存任意 PHP 值,应声明为可变类型/通用类型,而不是声明为原生类型后再尝试 `unset()` 或写入其他类型。 + +--- + ## 🎯 objval 编译期函数 ### 使用场景 diff --git a/phpunit/code/object-prop-subclass-mismatch.php b/phpunit/code/object-prop-subclass-mismatch.php new file mode 100644 index 00000000..07fe2086 --- /dev/null +++ b/phpunit/code/object-prop-subclass-mismatch.php @@ -0,0 +1,25 @@ +prop = new TypedObjectPropChild(); + } +} + +function main(): void +{ + (new TypedObjectPropHolder())->set(); +} diff --git a/phpunit/src/AssignTest.php b/phpunit/src/AssignTest.php index a16ba693..cf441fc0 100644 --- a/phpunit/src/AssignTest.php +++ b/phpunit/src/AssignTest.php @@ -71,6 +71,14 @@ class AssignTest extends \BaseTest $this->exec("Cannot re-assign `\$obj` from `php::Array` to `php::Object`", 're-assign-array-to-obj.php'); } + public function testCannotAssignSubclassToTypedObjectProperty() + { + $this->exec( + 'Cannot assign object of class `TypedObjectPropChild` to object property `prop` of class `TypedObjectPropBase`', + 'object-prop-subclass-mismatch.php' + ); + } + // === Str / Array value assigned to non-object scalar variable === public function testStrToInt() diff --git a/phpunit/src/SsaAnalysisTest.php b/phpunit/src/SsaAnalysisTest.php index 9601f330..ab7b6f0e 100644 --- a/phpunit/src/SsaAnalysisTest.php +++ b/phpunit/src/SsaAnalysisTest.php @@ -1154,6 +1154,25 @@ class SsaAnalysisTest extends TestCase $this->assertEquals(CompilerBase::TYPE_BOOL, $result); } + public function testDetectTypeOfExplicitStdNativeCalls(): void + { + $cases = [ + 'int' => CompilerBase::TYPE_INT, + 'float' => CompilerBase::TYPE_FLOAT, + 'bool' => CompilerBase::TYPE_BOOL, + ]; + + foreach ($cases as $method => $expectedType) { + $call = new Expr\StaticCall( + new Node\Name('std'), + new Node\Identifier($method), + [new Arg(new Scalar\LNumber(1))] + ); + + $this->assertSame($expectedType, $this->invoke('detectTypeOfExpr', $call)); + } + } + public function testDetectSsaDefTypeNull(): void { $ssaVar = new SsaVar(1, 'x'); diff --git a/src/Php/CompilerBase.php b/src/Php/CompilerBase.php index c2b1d12c..aa52ab7d 100644 --- a/src/Php/CompilerBase.php +++ b/src/Php/CompilerBase.php @@ -1414,12 +1414,19 @@ class CompilerBase extends \PhpAot\Core\Translator if (count($expr->args) === 2 and $fn === 'objval') { return $this->resolveClassNameArg($expr->args[1]->value); } + if ($this->hasFunction($fn)) { + return $this->getFunction($fn)->returnClass; + } } if ($this->isMethodCall($expr) and $this->isNamedMethod($expr->name)) { $method = $this->parseIdentifier($expr->name); if ($method === 'toObject' and !empty($expr->args)) { return $this->resolveClassNameArg($expr->args[0]->value); } + $classDef = $this->resolveObjectClassDef($expr->var); + if ($classDef !== null && $classDef->hasMethod($method)) { + return $classDef->getMethod($method)->functionDef->returnClass; + } if ($this->isVarExpr($expr->var)) { $object = $this->parseVariable($expr->var); try { @@ -1440,6 +1447,12 @@ class CompilerBase extends \PhpAot\Core\Translator } $class = $this->getNamespacedClassName($class); $method = $this->parseIdentifier($expr->name); + if ($this->hasClass($class)) { + $classDef = $this->getClass($class); + if ($classDef->hasMethod($method)) { + return $classDef->getMethod($method)->functionDef->returnClass; + } + } $nativeFunc = $this->getNativeMethod($expr, $class, $method); if ($nativeFunc) { return $this->getFunction($nativeFunc)->returnClass; @@ -1929,6 +1942,9 @@ class CompilerBase extends \PhpAot\Core\Translator if (strtolower($className) === 'std') { $method = strtolower($this->parseIdentifier($expr->name)); return match ($method) { + 'int' => self::TYPE_INT, + 'float' => self::TYPE_FLOAT, + 'bool' => self::TYPE_BOOL, 'bigint' => self::TYPE_BIGINT, 'decimal' => self::TYPE_DECIMAL, 'bigfloat' => self::TYPE_BIGFLOAT, @@ -3408,6 +3424,79 @@ class CompilerBase extends \PhpAot\Core\Translator return 'php::exit(' . $this->parseIdentifier($node->expr) . ')'; } + protected function getFixedObjectPropDefaultValue(PropertyDef $def): ?string + { + return match ($def->type) { + self::TYPE_INT => $def->default ?? '0', + self::TYPE_FLOAT => $def->default ?? '0.0', + self::TYPE_BOOL => $def->default ?? 'false', + self::TYPE_STR => $def->default ?? self::TYPE_STR . '()', + self::TYPE_ARRAY => $def->default ?? self::TYPE_ARRAY . '{}', + default => null, + }; + } + + protected function isFixedObjectProp(PropertyDef $def): bool + { + return in_array($def->type, [ + self::TYPE_INT, + self::TYPE_FLOAT, + self::TYPE_BOOL, + self::TYPE_STR, + self::TYPE_ARRAY, + ], true) && !$def->nullable; + } + + protected function assertCanAssignObjectProp(Expr\PropertyFetch $left, Expr $right): void + { + if (!$left->hasAttribute('nativePropertyDef')) { + return; + } + + /** @var PropertyDef $def */ + $def = $left->getAttribute('nativePropertyDef'); + + if ($this->isNull($right)) { + if ($this->isFixedObjectProp($def)) { + $this->fatalError( + $left, + "Cannot assign null to object property `{$this->parseIdentifier($left->name)}` of fixed type `{$def->type}`" + ); + } + return; + } + + if ($def->type !== self::TYPE_OBJECT) { + return; + } + + $rightType = $this->detectTypeOfExpr($right); + if ($rightType !== self::TYPE_VAR && $rightType !== self::TYPE_OBJECT) { + $this->fatalError( + $left, + "Cannot assign value of type `{$rightType}` to object property `{$this->parseIdentifier($left->name)}` of type `{$def->type}`" + ); + } + + if ($def->class === '') { + return; + } + + $rightClass = $this->detectClassOfExpr($right); + if ($rightClass === '') { + $this->fatalError( + $left, + "Cannot assign object of unknown class to object property `{$this->parseIdentifier($left->name)}` of class `{$def->class}`" + ); + } + if ($rightClass !== $def->class) { + $this->fatalError( + $left, + "Cannot assign object of class `{$rightClass}` to object property `{$this->parseIdentifier($left->name)}` of class `{$def->class}`" + ); + } + } + protected function parseUnset(Node\Stmt\Unset_ $node): string { $vars = $node->vars; @@ -3430,7 +3519,31 @@ class CompilerBase extends \PhpAot\Core\Translator } } elseif ($this->isPropertyFetch($var)) { $object = $this->parseIdentifier($var->var); - $lines[] = $object . '.unsetProperty(' . $this->identifierToStr($var->name, literal: true) . ');'; + $restoreDefault = null; + if ($this->isIdExpr($var->name)) { + $propertyId = $this->getPropertyIdentifier($var, $var->var, $var->name); + if ($var->hasAttribute('nativePropertyDef')) { + /** @var PropertyDef $def */ + $def = $var->getAttribute('nativePropertyDef'); + if ($this->isFixedObjectProp($def)) { + $restoreDefault = $this->getFixedObjectPropDefaultValue($def); + if ($restoreDefault === null) { + $this->fatalError($var, "Cannot unset object property `{$this->parseIdentifier($var->name)}` of fixed type `{$def->type}` without default value"); + } + $this->warning($var, "Object property `{$this->parseIdentifier($var->name)}` of fixed type cannot be unset; restoring its default value"); + $propName = $this->parseIdentifier($var->name); + $propVar = $this->getObjectPropVarName($object, $propName); + if ($this->hasObjectPropVar($propVar)) { + $lines[] = $propVar . ' = ' . $restoreDefault . ';'; + } else { + $lines[] = $object . '.attr(' . $propertyId . ', true) = ' . $restoreDefault . ';'; + } + } + } + } + if ($restoreDefault === null) { + $lines[] = $object . '.unsetProperty(' . $this->identifierToStr($var->name, literal: true) . ');'; + } } elseif ($this->isStaticPropertyFetch($var)) { $this->fatalError($var, 'Attempt to unset static property ' . $this->parseIdentifier($var->class) . '::$' . $this->parseIdentifier($var->name)); } elseif ($this->isVarExpr($var)) { diff --git a/src/Php/Optimizer/SsaPropOptimizer.php b/src/Php/Optimizer/SsaPropOptimizer.php index 192319b4..e733ede1 100644 --- a/src/Php/Optimizer/SsaPropOptimizer.php +++ b/src/Php/Optimizer/SsaPropOptimizer.php @@ -45,13 +45,6 @@ trait SsaPropOptimizer return; } - if ($this->class) { - $unsafeProps = $this->collectDangerousPropOps('this_', $ssa->getStmts()); - if ($unsafeProps) { - $this->context->unsafeObjectProps['this_'] = $unsafeProps; - } - } - if (empty($ssa->ssaVars)) { return; } diff --git a/src/Php/Parser/AssignOpTrait.php b/src/Php/Parser/AssignOpTrait.php index 38c5e0da..a6de8cb4 100644 --- a/src/Php/Parser/AssignOpTrait.php +++ b/src/Php/Parser/AssignOpTrait.php @@ -254,6 +254,10 @@ trait AssignOpTrait return $this->parseAssignArrayDim($left, $right); } + if ($this->isPropertyFetch($left)) { + $this->assertCanAssignObjectProp($left, $right); + } + $rightExpr = $this->parseAssignRightExpr($right); $leftExprType = $this->detectTypeOfExpr($left); $rightExprType = $this->detectTypeOfExpr($right); diff --git a/tests/aot/operator/assign-op-mixed-types.phpt b/tests/aot/operator/assign-op-mixed-types.phpt index 83c24824..6553aa66 100644 --- a/tests/aot/operator/assign-op-mixed-types.phpt +++ b/tests/aot/operator/assign-op-mixed-types.phpt @@ -1,5 +1,5 @@ --TEST-- -Compound assignment operators with mixed Var/native types +Compound assignment operators with mixed Var/explicit std native types --FILE-- nullable); + var_dump($this->nullableObject); + var_dump($this->union); + + $this->nullable = 13; + $this->nullableObject = null; + $this->union = "ok"; + var_dump($this->nullable); + var_dump($this->nullableObject); + var_dump($this->union); + + $this->nullable = null; + $this->nullableObject = null; + $this->union = null; + var_dump($this->nullable); + var_dump($this->nullableObject); + var_dump($this->union); + } +} + +function main(): void { + (new FlexibleDefaults())->run(); +} +?> +--EXPECT-- +NULL +NULL +NULL +int(13) +NULL +string(2) "ok" +NULL +NULL +NULL diff --git a/tests/aot/optimizations/objprop-typed-object-null-unset.phpt b/tests/aot/optimizations/objprop-typed-object-null-unset.phpt new file mode 100644 index 00000000..9da6fa2d --- /dev/null +++ b/tests/aot/optimizations/objprop-typed-object-null-unset.phpt @@ -0,0 +1,58 @@ +--TEST-- +SSA object prop: typed object property allows null and unset +--FILE-- +prop = new ObjPropValue(); + var_dump(isset($this->prop)); + var_dump($this->prop->name()); + + $this->prop = null; + var_dump(isset($this->prop)); + var_dump($this->prop); + + $this->prop = new ObjPropValue(); + unset($this->prop); + var_dump(isset($this->prop)); + + $this->prop = makeObjPropValue(); + var_dump($this->prop->name()); + + $this->prop = ObjPropFactory::create(); + var_dump($this->prop->name()); + } +} + +function main(): void { + (new ObjPropHolder())->run(); +} +?> +--EXPECT-- +bool(true) +string(5) "value" +bool(false) +NULL +bool(false) +string(5) "value" +string(5) "value" diff --git a/tests/aot/optimizations/objprop-unset-fixed-defaults.phpt b/tests/aot/optimizations/objprop-unset-fixed-defaults.phpt new file mode 100644 index 00000000..fd817471 --- /dev/null +++ b/tests/aot/optimizations/objprop-unset-fixed-defaults.phpt @@ -0,0 +1,73 @@ +--TEST-- +SSA object prop: unset fixed typed properties restores declared defaults +--FILE-- +i = 9; + $this->f = 2.5; + $this->b = true; + $this->s = "changed"; + $this->a = ["x"]; + $this->di = 77; + $this->ds = "changed"; + $this->da = [9]; + + unset($this->i); + unset($this->f); + unset($this->b); + unset($this->s); + unset($this->a); + unset($this->di); + unset($this->ds); + unset($this->da); + + var_dump(isset($this->i), $this->i); + var_dump(isset($this->f), $this->f); + var_dump(isset($this->b), $this->b); + var_dump(isset($this->s), $this->s); + var_dump(isset($this->a), $this->a); + var_dump(isset($this->di), $this->di); + var_dump(isset($this->ds), $this->ds); + var_dump(isset($this->da), $this->da); + } +} + +function main(): void { + (new FixedDefaults())->run(); +} +?> +--EXPECT-- +bool(true) +int(0) +bool(true) +float(0) +bool(true) +bool(false) +bool(true) +string(0) "" +bool(true) +array(0) { +} +bool(true) +int(42) +bool(true) +string(4) "seed" +bool(true) +array(2) { + [0]=> + int(1) + [1]=> + int(2) +} diff --git a/tests/aot/optimizations/objprop-unset-this-typed.phpt b/tests/aot/optimizations/objprop-unset-this-typed.phpt index 02ead36c..f360a1bb 100644 --- a/tests/aot/optimizations/objprop-unset-this-typed.phpt +++ b/tests/aot/optimizations/objprop-unset-this-typed.phpt @@ -1,5 +1,5 @@ --TEST-- -SSA object prop: unset typed this property keeps PHP uninitialized semantics +SSA object prop: unset typed this property keeps AOT native slot semantics --FILE-- a)); unset($this->a); var_dump(isset($this->a)); + var_dump($this->a); $this->a = 11; var_dump($this->a); } @@ -23,5 +24,6 @@ function main(): void { ?> --EXPECT-- bool(true) -bool(false) +bool(true) +int(7) int(11)