diff --git a/phpunit/code/native-property-unset-disables-hoist.php b/phpunit/code/native-property-unset-disables-hoist.php new file mode 100644 index 00000000..54fe4487 --- /dev/null +++ b/phpunit/code/native-property-unset-disables-hoist.php @@ -0,0 +1,22 @@ +value); + unset($this->value); + var_dump($this->value); + $this->value = 11; + var_dump($this->value); + } +} + +function main(): void +{ + (new NativePropertyUnsetDisablesHoist())->run(); +} diff --git a/phpunit/code/typed-object-unset-assign-null.php b/phpunit/code/typed-object-unset-assign-null.php new file mode 100644 index 00000000..10ee93e0 --- /dev/null +++ b/phpunit/code/typed-object-unset-assign-null.php @@ -0,0 +1,12 @@ +exec( + 'Cannot assign null to typed object `$value` of type `TypedObjectUnsetNullValue`; use unset() to clear it', + 'typed-object-unset-assign-null.php' + ); + } + public function testCannotAssignUnrelatedObjectToInterfaceDeclaredObject() { $this->exec( diff --git a/phpunit/src/NativePropertyTest.php b/phpunit/src/NativePropertyTest.php index 10b0fda6..ef6fd24a 100644 --- a/phpunit/src/NativePropertyTest.php +++ b/phpunit/src/NativePropertyTest.php @@ -100,6 +100,19 @@ class NativePropertyTest extends \BaseTest $this->assertStringContainsString('_object_prop_this___value = php::toIntExact(dynamicValue, "NativePropertyThisWriteConversionBox::$value");', $code); } + public function testUnsetTypedPropertyDisablesSlotHoisting(): void + { + try { + $outputFile = $this->compileNativeProperty('native-property-unset-disables-hoist.php'); + } catch (TestError $e) { + $this->fail($e->getMessage()); + } + + $code = file_get_contents($outputFile); + $this->assertStringNotContainsString('_object_prop_this___value', $code); + $this->assertStringContainsString('this_.attr(', $code); + } + public function testNativePropertyStaticScalarTypeMismatchFailsAtCompileTime(): void { $this->exec( diff --git a/phpunit/src/SsaAnalysisTest.php b/phpunit/src/SsaAnalysisTest.php index 55619ac8..6fb77bb4 100644 --- a/phpunit/src/SsaAnalysisTest.php +++ b/phpunit/src/SsaAnalysisTest.php @@ -655,7 +655,7 @@ class SsaAnalysisTest extends TestCase )); $result = $this->invoke('hasDangerousPropOps', 'obj', [$unset, $read]); - $this->assertFalse($result, 'unset($obj->prop) is blocked by the object handlers and cannot invalidate a hoisted reference'); + $this->assertTrue($result, 'unset($obj->prop) must disable property slot hoisting'); } public function testHasDangerousPropOpsUnsetDifferentObj(): void @@ -933,7 +933,7 @@ class SsaAnalysisTest extends TestCase $this->assertSame([], $result); } - public function testCollectDangerousPropOpsUnsetAfterLastAccessIsSafe(): void + public function testCollectDangerousPropOpsUnsetAfterLastAccessIsAlwaysUnsafe(): void { $read = new Stmt\Expression(new Expr\Assign( new Expr\Variable('value'), @@ -942,7 +942,7 @@ class SsaAnalysisTest extends TestCase $unset = new Stmt\Unset_([new Expr\PropertyFetch(new Expr\Variable('obj'), 'a')]); $result = $this->invoke('collectDangerousPropOps', 'obj', [$read, $unset]); - $this->assertSame([], $result); + $this->assertSame(['a' => true], $result); } public function testCollectDangerousPropOpsAssignRefToPropertyIsAlwaysUnsafe(): void diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index 3b979f67..063ba191 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -29,6 +29,15 @@ class FunctionContext */ public array $objects = []; + /** + * Object variables that require a runtime value check before use. This + * includes nullable declarations and typed objects cleared by unset(). + * Their class constraint remains in objects/declaredObjects. + * + * @var array + */ + public array $runtimeCheckedObjects = []; + /** * Declared object constraints that are not used for native-call dispatch. * @@ -86,6 +95,7 @@ class FunctionContext $this->staticVars = []; $this->arguments = []; $this->objects = []; + $this->runtimeCheckedObjects = []; $this->declaredObjects = []; $this->stdArrays = []; $this->stdContainers = []; diff --git a/src/Generator/TypeCheckGenerator.php b/src/Generator/TypeCheckGenerator.php index fc8ea15b..8520cc32 100644 --- a/src/Generator/TypeCheckGenerator.php +++ b/src/Generator/TypeCheckGenerator.php @@ -273,6 +273,11 @@ trait TypeCheckGenerator && !$this->compositeTypeContainsKind($typeCheck, 'isInt'); } + protected function typeCheckAllowsNull(array $typeCheck): bool + { + return $this->compositeTypeContainsKind($typeCheck, 'isNull'); + } + private function compositeTypeContainsKind(array $typeCheck, string $kind): bool { foreach ($typeCheck as $entry) { diff --git a/src/Optimizer/SsaPropOptimizer.php b/src/Optimizer/SsaPropOptimizer.php index ec9fdfde..0137ea74 100644 --- a/src/Optimizer/SsaPropOptimizer.php +++ b/src/Optimizer/SsaPropOptimizer.php @@ -19,8 +19,8 @@ * 8. First access is not inside a loop or nested block scope * 9. Property is not readonly (including properties of a readonly class) * - * Direct unset($o->prop) is not dangerous: the object handlers reset/reject the - * unset path, so a hoisted reference is not invalidated by direct unset alone. + * Direct unset($o->prop) invalidates the property slot assumption. Any property + * observed in an unset operation must use dynamic attr() access. */ namespace TypePhp\Optimizer; @@ -361,8 +361,8 @@ trait SsaPropOptimizer * - mutate($o) or $o->method() — dynamic code may turn the property slot * into a reference through the exposed object * - * Direct unset($o->prop) is intentionally not treated as dangerous: the - * object handlers reset/reject property unset. + * Direct unset($o->prop) invalidates the property slot and disables + * hoisting for that property. */ protected function hasDangerousPropOps(string $objName, array $stmts): bool { @@ -408,10 +408,9 @@ trait SsaPropOptimizer if ($node instanceof Node\Stmt\Unset_) { foreach ($node->vars as $var) { - // unset($o->prop) cannot destroy the slot: the object handlers - // reject property unset, so a hoisted reference stays valid. $propName = $this->getPropNameOfObj($var, $objName); if ($propName !== null) { + $events[] = ['kind' => 'danger_always', 'prop' => $propName]; $this->collectPropEventsInDynamicParts($var, $objName, $events); } else { $this->collectPropEvents($var, $objName, $events); diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index e1aa501f..71405edc 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -236,6 +236,12 @@ trait AssignOpTrait if ($var === 'this_') { $this->fatalError($left, 'Cannot re-assign $this'); } + if ($this->hasVar($var) + && $this->getVarType($var) === Type::OBJECT + && $this->isNull($right)) { + $class = $this->getDeclaredObjectType($var) ?: 'object'; + $this->fatalError($right, "Cannot assign null to typed object `\${$var}` of type `{$class}`; use unset() to clear it"); + } if ($this->isStdContainer($var)) { $copyAssign = $this->parseStdContainerCopyAssign($var, $right); if ($copyAssign !== null) { diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 08ca8dbe..d1c69b76 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -120,6 +120,10 @@ trait MethodCallTrait protected function findNativeMethod(CallLike $expr, string $object, string $method): string|false { + if ($object !== 'this_' && $this->requiresRuntimeObjectCheck($object)) { + return false; + } + $classDef = null; if ($object === 'this_') { $class = $this->getFullClassName(); diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index b590573a..8566592c 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -765,6 +765,19 @@ trait PropertyAccessTrait $type = $this->getVarType($name); if ($this->isNativeType($type)) { $this->warning($var, "Variable of native type `\${$name}` cannot be unset"); + } elseif ($type === Type::OBJECT) { + // A PHP local read after unset() evaluates to null (and may + // emit an undefined-variable warning). Keep the Object + // wrapper so later object assignments remain valid, but + // store NULL rather than IS_UNDEF so strict null checks + // retain PHP value semantics. + // + // Keep the declared class: unset() changes only the value + // state and does not make null or another class assignable. + // Native operations must validate the current value before + // using the retained class declaration. + $this->context->runtimeCheckedObjects[$name] = true; + $lines[] = "{$name} = php::null;"; } else { $lines[] = "{$name}.unset();"; } diff --git a/src/Parser/TypeDetectionTrait.php b/src/Parser/TypeDetectionTrait.php index 0e3762ca..83d97ba2 100644 --- a/src/Parser/TypeDetectionTrait.php +++ b/src/Parser/TypeDetectionTrait.php @@ -19,7 +19,13 @@ trait TypeDetectionTrait { public function isTypedObject(string $object): bool { - return isset($this->context->objects[$object]) || isset($this->context->stableObjects[$object]); + return !isset($this->context->runtimeCheckedObjects[$object]) + && (isset($this->context->objects[$object]) || isset($this->context->stableObjects[$object])); + } + + protected function requiresRuntimeObjectCheck(string $object): bool + { + return isset($this->context->runtimeCheckedObjects[$object]); } protected function isSuperGlobal(string $var): bool diff --git a/src/Translator.php b/src/Translator.php index e485cc1d..eddc4318 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3603,6 +3603,9 @@ CODE; $this->addArgument($argInfo->name, $argInfo->variadic ? Type::ARRAY : $argInfo->type); if (!$argInfo->variadic and $argInfo->declaredClass) { $this->addObject($argInfo->name, $argInfo->declaredClass); + if ($argInfo->nullable || $this->typeCheckAllowsNull($argInfo->typeCheck ?? [])) { + $this->context->runtimeCheckedObjects[$argInfo->name] = true; + } } } diff --git a/src/TypeSystem/NativeTypeCompatibilityTrait.php b/src/TypeSystem/NativeTypeCompatibilityTrait.php index 4fd9c467..28e43ed0 100644 --- a/src/TypeSystem/NativeTypeCompatibilityTrait.php +++ b/src/TypeSystem/NativeTypeCompatibilityTrait.php @@ -223,6 +223,10 @@ trait NativeTypeCompatibilityTrait // 如果无法证明,但右值是已知 concrete object,说明一定不兼容,直接编译期 fatal; // 其他动态/外部库/any 场景保留 php::toObject() 作为运行时兜底。 if ($this->isObjectClassStaticallyAssignableTo($class, $declaredClass)) { + if ($this->isVarExpr($arg->value) + && $this->requiresRuntimeObjectCheck($this->parseIdentifier($arg->value))) { + return $this->convertObjectExpr($expr, $this->getClassEntryPtr($declaredClass)); + } return $type === Type::OBJECT ? $expr : $this->convertObjectExpr($expr); } if ($this->isKnownConcreteObjectExpr($arg->value, $class)) { diff --git a/tests/compiler/basic/unset-typed-object-reassign.phpt b/tests/compiler/basic/unset-typed-object-reassign.phpt new file mode 100644 index 00000000..ee2277f6 --- /dev/null +++ b/tests/compiler/basic/unset-typed-object-reassign.phpt @@ -0,0 +1,58 @@ +--TEST-- +unset typed object retains its declared class constraint on reassignment +--FILE-- +value()); +} +?> +--EXPECT-- +TypeError +TypeError +string(8) "expected" diff --git a/tests/compiler/basic/unset-typed-object-state.phpt b/tests/compiler/basic/unset-typed-object-state.phpt new file mode 100644 index 00000000..dfe23f7d --- /dev/null +++ b/tests/compiler/basic/unset-typed-object-state.phpt @@ -0,0 +1,58 @@ +--TEST-- +unset typed object invalidates native-call assumptions and reads as null +--FILE-- +value(); +} + +function main() +{ + $value = makeUnsetTypedObjectValue(); + unset($value); + + var_dump(@$value === null); + var_dump(@$value instanceof UnsetTypedObjectValue); + var_dump(isset($value)); + + try { + acceptUnsetTypedObjectValue(@$value); + } catch (Throwable $error) { + echo $error::class, "\n"; + } + + try { + @$value->value(); + } catch (Throwable $error) { + echo $error::class, "\n"; + } + + $value = makeUnsetTypedObjectValue(); + var_dump(acceptUnsetTypedObjectValue($value)); + var_dump($value->value()); +} +?> +--EXPECT-- +bool(true) +bool(false) +bool(false) +TypeError +Error +entered +string(5) "value" +string(5) "value" diff --git a/tests/compiler/optimizations/objprop-unset-this-typed.phpt b/tests/compiler/optimizations/objprop-unset-this-typed.phpt index f360a1bb..16a1051f 100644 --- a/tests/compiler/optimizations/objprop-unset-this-typed.phpt +++ b/tests/compiler/optimizations/objprop-unset-this-typed.phpt @@ -1,5 +1,5 @@ --TEST-- -SSA object prop: unset typed this property keeps AOT native slot semantics +SSA object prop: unset typed this property disables property slot hoisting --FILE--