fix(compiler): 解决对象属性类型检查和赋值操作问题

- 添加对象属性赋值时的类型验证逻辑
- 实现固定类型对象属性的 unset 操作默认值恢复
- 修复 std 命名空间内置类型方法的类型检测
- 更新对象属性类型推断和方法返回类型解析
- 添加对象属性子类赋值到基类类型的错误检查
- 修改测试用例以反映 AOT 编译器的严格类型行为
pull/1/head
韩天峰 3 months ago
parent 21338f022f
commit 279fe22d82
  1. 36
      docs/NATIVE_TYPES.md
  2. 25
      phpunit/code/object-prop-subclass-mismatch.php
  3. 8
      phpunit/src/AssignTest.php
  4. 19
      phpunit/src/SsaAnalysisTest.php
  5. 115
      src/Php/CompilerBase.php
  6. 7
      src/Php/Optimizer/SsaPropOptimizer.php
  7. 4
      src/Php/Parser/AssignOpTrait.php
  8. 4
      tests/aot/operator/assign-op-mixed-types.phpt
  9. 46
      tests/aot/optimizations/objprop-nullable-union-default-null.phpt
  10. 58
      tests/aot/optimizations/objprop-typed-object-null-unset.phpt
  11. 73
      tests/aot/optimizations/objprop-unset-fixed-defaults.phpt
  12. 6
      tests/aot/optimizations/objprop-unset-this-typed.phpt

@ -16,6 +16,42 @@
---
## 对象属性类型是固定的
AOT 编译器要求对象属性在整个生命周期内始终保持声明时的类型。与 PHP 解释器不同,AOT 不允许通过运行时操作把一个已声明类型的属性改成其他类型。
尤其需要注意固定值类型属性上的 `unset($obj->prop)` 和赋值 `null`
```php
<?php
use native_types;
class User {
public int $id = 0;
public Profile $profile;
}
$user = new User();
unset($user->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 编译期函数
### 使用场景

@ -0,0 +1,25 @@
<?php
use native_types;
class TypedObjectPropBase
{
}
class TypedObjectPropChild extends TypedObjectPropBase
{
}
class TypedObjectPropHolder
{
public TypedObjectPropBase $prop;
public function set(): void
{
$this->prop = new TypedObjectPropChild();
}
}
function main(): void
{
(new TypedObjectPropHolder())->set();
}

@ -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()

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

@ -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)) {

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

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

@ -1,5 +1,5 @@
--TEST--
Compound assignment operators with mixed Var/native types
Compound assignment operators with mixed Var/explicit std native types
--FILE--
<?php
@ -40,7 +40,7 @@ function main() {
$g %= 3;
var_dump($g);
// ===== With use native_types =====
// ===== Explicit std::* native types without use native_types =====
// Int += Int
$h = std::int(100);
$h += 50;

@ -0,0 +1,46 @@
--TEST--
SSA object prop: nullable and union properties stay Var with null default
--FILE--
<?php
use native_types;
class FlexibleDefaults {
public ?int $nullable;
public ?object $nullableObject;
public int|string $union;
public function run(): void {
var_dump($this->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

@ -0,0 +1,58 @@
--TEST--
SSA object prop: typed object property allows null and unset
--FILE--
<?php
use native_types;
class ObjPropValue {
public function name(): string {
return "value";
}
}
function makeObjPropValue(): ObjPropValue {
return new ObjPropValue();
}
class ObjPropFactory {
public static function create(): ObjPropValue {
return new ObjPropValue();
}
}
class ObjPropHolder {
public ObjPropValue $prop;
public function run(): void {
$this->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"

@ -0,0 +1,73 @@
--TEST--
SSA object prop: unset fixed typed properties restores declared defaults
--FILE--
<?php
use native_types;
class FixedDefaults {
public int $i;
public float $f;
public bool $b;
public string $s;
public array $a;
public int $di = 42;
public string $ds = "seed";
public array $da = [1, 2];
public function run(): void {
$this->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)
}

@ -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--
<?php
use native_types;
@ -11,6 +11,7 @@ class Foo {
var_dump(isset($this->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)

Loading…
Cancel
Save