feat(php): 添加联合类型和可空类型的运行时检查支持

- 在 ArgInfo 中新增 typeCheck、typeStr 和 typeNode 属性用于类型检查
- 在 FunctionDef 中新增 returnTypeCheck、returnTypeStr 和 returnTypeNode 属性
- 实现 buildTypeCheckFromNode 方法解析联合/可空类型节点
- 实现 genSingleTypeCondition 方法生成单个类型检查条件
- 实现 genUnionParamCheck 方法生成参数类型检查代码块
- 实现 genUnionReturnCheck 方法生成返回值类型检查代码块
- 在编译过程中集成参数和返回值的运行时类型检查
- 添加 union-param-check.phpt 和 union-return-check.phpt 测试用例
- 支持基本类型、类类型和特殊类型的联合类型检查
- 实现详细的错误消息生成机制
pull/1/head
韩天峰 3 months ago
parent 593c6ec2a5
commit 3b5345d68e
  1. 13
      src/Php/ArgInfo.php
  2. 217
      src/Php/CompilerBase.php
  3. 10
      src/Php/Entity/FunctionDef.php
  4. 17
      src/Php/Preprocessor.php
  5. 102
      tests/aot/type_decl/union-param-check.phpt
  6. 69
      tests/aot/type_decl/union-return-check.phpt

@ -9,6 +9,7 @@
namespace PhpAot\Php;
use PhpParser\Node\Expr;
use PhpParser\NodeAbstract;
class ArgInfo
{
@ -21,4 +22,16 @@ class ArgInfo
public bool $variadic = false;
public bool $nullable = false;
public bool $property = false;
/**
* Each element: ['kind' => 'isInt'|'isFloat'|...|'instanceof', 'class' => '']
* Null means no runtime type check needed.
*/
public ?array $typeCheck = null;
/** Human-readable type string for error messages, e.g. "int|string", "?int" */
public string $typeStr = '';
/** Original union/nullable AST node. Only set when typeCheck is non-null. */
public ?NodeAbstract $typeNode = null;
}

@ -1094,6 +1094,189 @@ class CompilerBase extends \PhpAot\Core\Translator
}
}
/**
* Build type-check descriptor array from UnionType or NullableType AST node.
* Returns ['check' => array, 'typeStr' => string] or empty check array if no check needed.
*/
protected function buildTypeCheckFromNode(NodeAbstract $typeNode): array
{
$check = [];
$names = [];
if ($typeNode instanceof NullableType) {
$subTypes = [$typeNode->type];
$isNullable = true;
} elseif ($typeNode instanceof UnionType) {
$subTypes = $typeNode->types;
$isNullable = false;
} else {
return ['check' => [], 'typeStr' => ''];
}
foreach ($subTypes as $subType) {
$name = $this->parseIdentifier($subType);
$nameLower = strtolower($name);
if ($nameLower === 'void' or $nameLower === 'never') {
$this->fatalError($subType, "Type '{$nameLower}' cannot be part of a union type");
}
if ($nameLower === 'mixed') {
// mixed accepts everything — don't add any check
$names[] = $name;
continue;
}
$entry = match ($nameLower) {
'int' => ['kind' => 'isInt'],
'float', 'double' => ['kind' => 'isFloat'],
'bool' => ['kind' => 'isBool'],
'string' => ['kind' => 'isString'],
'array' => ['kind' => 'isArray'],
'object' => ['kind' => 'isObject'],
'null' => ['kind' => 'isNull'],
'true' => ['kind' => 'isTrue'],
'false' => ['kind' => 'isFalse'],
'resource' => ['kind' => 'isResource'],
'callable' => ['kind' => 'callable'],
'iterable' => ['kind' => 'iterable'],
default => null,
};
if ($entry !== null) {
$check[] = $entry;
} else {
// Class/interface type
if ($name === 'self') {
$class = $this->getFullClassName();
} elseif ($name === 'parent') {
$class = $this->classDef->extends ?? '';
} elseif ($name === 'static') {
$class = 'static';
} else {
$class = $this->getNamespacedClassName($name);
}
if ($class) {
$check[] = ['kind' => 'instanceof', 'class' => $class];
}
}
$names[] = $name;
}
if ($isNullable) {
// NullableType: prepend null to both check array and typeStr
array_unshift($check, ['kind' => 'isNull']);
$typeStr = '?' . implode('|', $names);
} else {
$typeStr = implode('|', $names);
}
if (empty($check)) {
return ['check' => [], 'typeStr' => $typeStr];
}
return ['check' => $check, 'typeStr' => $typeStr];
}
/**
* Generate a C++ boolean expression for a single type descriptor entry.
*/
protected function genSingleTypeCondition(string $varName, array $entry): string
{
$v = $varName;
return match ($entry['kind']) {
'isInt' => $v . '.isInt()',
'isFloat' => $v . '.isFloat()',
'isBool' => $v . '.isBool()',
'isString' => $v . '.isString()',
'isArray' => $v . '.isArray()',
'isObject' => $v . '.isObject()',
'isNull' => $v . '.isNull()',
'isTrue' => $v . '.isTrue()',
'isFalse' => $v . '.isFalse()',
'isResource' => $v . '.isResource()',
'callable' => $v . '.isCallable()',
'iterable' => '(' . $v . '.isArray() || (' . $v . '.isObject() && php::instanceOf(' . $v . ', zend_ce_traversable)))',
'instanceof' => $entry['class'] === 'static'
? '(' . $v . '.isObject() && php::instanceOf(' . $v . ', php_get_called_ce(this_)))'
: '(' . $v . '.isObject() && php::instanceOf(' . $v . ', ' . $this->getClassEntryPtr($entry['class']) . '))',
default => '',
};
}
/**
* Generate C++ runtime type-check block for a function parameter with union/nullable type.
*/
protected function genUnionParamCheck(ArgInfo $argInfo, int $argIndex): string
{
if (empty($argInfo->typeCheck)) {
return '';
}
$varName = $argInfo->name;
$conditions = [];
foreach ($argInfo->typeCheck as $entry) {
$cond = $this->genSingleTypeCondition($varName, $entry);
if ($cond !== '') {
$conditions[] = $cond;
}
}
if (empty($conditions)) {
return '';
}
$orExpr = implode(' || ', $conditions);
$fnName = $this->functionDef->getNamespacedName();
$msgExpr = 'php::concat(php::concat(php::Str("' . $fnName . '(): Argument #' . ($argIndex + 1)
. ' ($' . $varName . ') must be of type ' . $argInfo->typeStr . ', "), '
. $varName . '.typeStr()), php::Str(" given"))';
$code = $this->getIndent() . 'if (UNEXPECTED(!(' . $orExpr . '))) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . 'php::throwException(zend_ce_type_error, (' . $msgExpr . ').toCString());' . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '}' . PHP_EOL;
return $code;
}
/**
* Generate C++ runtime type-check block for a function return value with union/nullable type.
*/
protected function genUnionReturnCheck(string $varName): string
{
$typeCheck = $this->functionDef->returnTypeCheck;
if (empty($typeCheck)) {
return '';
}
$conditions = [];
foreach ($typeCheck as $entry) {
$cond = $this->genSingleTypeCondition($varName, $entry);
if ($cond !== '') {
$conditions[] = $cond;
}
}
if (empty($conditions)) {
return '';
}
$orExpr = implode(' || ', $conditions);
$fnName = $this->functionDef->getNamespacedName();
$typeStr = $this->functionDef->returnTypeStr;
$msgExpr = 'php::concat(php::concat(php::Str("' . $fnName . '(): Return value must be of type '
. $typeStr . ', "), ' . $varName . '.typeStr()), php::Str(" given"))';
$code = $this->getIndent() . 'if (UNEXPECTED(!(' . $orExpr . '))) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . 'php::throwException(zend_ce_type_error, (' . $msgExpr . ').toCString());' . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '}' . PHP_EOL;
return $code;
}
/**
* @throws \Exception
*/
@ -1167,6 +1350,12 @@ class CompilerBase extends \PhpAot\Core\Translator
}
$code .= $this->genPropertyPromotion($argInfo);
}
// Runtime union/nullable parameter type checks
foreach ($this->functionDef->argInfoList as $i => $argInfo) {
if (!empty($argInfo->typeCheck)) {
$code .= $this->genUnionParamCheck($argInfo, $i);
}
}
$this->indentLevel--;
// 构建 PHP 级别的函数名用于 debug backtrace
if ($this->class) {
@ -2119,6 +2308,13 @@ class CompilerBase extends \PhpAot\Core\Translator
if ($v->expr === null) {
if ($this->functionDef->returnType === self::TYPE_VOID and !$this->context->inClosure) {
return 'return;';
} elseif ($this->functionDef->returnTypeCheck) {
$tmpVar = $this->genTmpVarName();
$this->addLocalVar($tmpVar, self::TYPE_VAR);
$code = $tmpVar . ' = ' . self::VALUE_NULL . ';' . PHP_EOL;
$code .= $this->genUnionReturnCheck($tmpVar);
$code .= $this->getIndent() . 'return ' . $tmpVar . ';';
return $code;
} else {
return 'return ' . self::VALUE_NULL . ';';
}
@ -2153,9 +2349,16 @@ class CompilerBase extends \PhpAot\Core\Translator
}
$exprCode = $this->convertExprType($expr, $returnType, $type);
// return 如果使用了 Indirect 语句,可能会导致变量提前析构,出现悬空指针
// 将 Indirect 赋值给临时变量后,使用 Ctor::Copy 解除了 Indirect,保证内存安全
if (!$this->isVarExpr($v->expr) and !$this->isScalar($v->expr)) {
// Union/nullable return type: always use tmpVar for runtime check
if ($this->functionDef->returnTypeCheck) {
$tmpVar = $this->genTmpVarName();
$this->addLocalVar($tmpVar, self::TYPE_VAR);
$code = $tmpVar . ' = ' . $exprCode . ';' . PHP_EOL;
$code .= $this->genUnionReturnCheck($tmpVar);
$this->context->afterStmtLines[] = $this->getIndent() . 'return ' . $tmpVar . ';';
} elseif (!$this->isVarExpr($v->expr) and !$this->isScalar($v->expr)) {
// return 如果使用了 Indirect 语句,可能会导致变量提前析构,出现悬空指针
// 将 Indirect 赋值给临时变量后,使用 Ctor::Copy 解除了 Indirect,保证内存安全
$tmpVar = $this->genTmpVarName();
// 必须提前声明变量,否则在末尾声明并 return 可能会被 gcc 优化掉
$this->addLocalVar($tmpVar, $returnType);
@ -6302,6 +6505,14 @@ class CompilerBase extends \PhpAot\Core\Translator
if ($this->functionDef->returnType === self::TYPE_VOID) {
return '';
}
if ($this->functionDef->returnTypeCheck) {
$tmpVar = $this->genTmpVarName();
$this->addLocalVar($tmpVar, self::TYPE_VAR);
$code = $tmpVar . ' = ' . self::VALUE_NULL . ';' . PHP_EOL;
$code .= $this->genUnionReturnCheck($tmpVar);
$code .= $this->getIndent() . 'return ' . $tmpVar . ';';
return $code;
}
if ($this->functionDef->returnType === self::TYPE_INT
or $this->functionDef->returnType === self::TYPE_FLOAT
or $this->functionDef->returnType === self::TYPE_BOOL) {

@ -9,6 +9,7 @@
namespace PhpAot\Php\Entity;
use PhpAot\Php\ArgInfo;
use PhpParser\NodeAbstract;
class FunctionDef
{
@ -30,6 +31,15 @@ class FunctionDef
*/
public string $returnClass = '';
/** Same format as ArgInfo::$typeCheck. Null means no runtime return type check. */
public ?array $returnTypeCheck = null;
/** Human-readable return type string for error messages. */
public string $returnTypeStr = '';
/** Original union/nullable return type AST node. */
public ?NodeAbstract $returnTypeNode = null;
public function __construct(string $name, string $returnType, string $namespace)
{
$this->name = $name;

@ -280,6 +280,14 @@ class Preprocessor extends CompilerBase
if ($param->type and $param->type instanceof NullableType) {
$argInfo->nullable = true;
}
if ($param->type instanceof NullableType or $param->type instanceof UnionType) {
$typeInfo = $this->buildTypeCheckFromNode($param->type);
if (!empty($typeInfo['check'])) {
$argInfo->typeCheck = $typeInfo['check'];
$argInfo->typeStr = $typeInfo['typeStr'];
$argInfo->typeNode = $param->type;
}
}
if ($param->variadic) {
$list[] = self::TYPE_ARRAY . ' ' . $name;
} else {
@ -340,6 +348,15 @@ class Preprocessor extends CompilerBase
$functionDef->returnClass = $class;
$functionDef->stub = $this->stubFile;
if ($v->returnType instanceof NullableType or $v->returnType instanceof UnionType) {
$typeInfo = $this->buildTypeCheckFromNode($v->returnType);
if (!empty($typeInfo['check'])) {
$functionDef->returnTypeCheck = $typeInfo['check'];
$functionDef->returnTypeStr = $typeInfo['typeStr'];
$functionDef->returnTypeNode = $v->returnType;
}
}
$this->parseParams($v->params, $functionDef);
// main 函数,返回值必须为 void 类型,参数必须为空或者 argc, argv 两个参数

@ -0,0 +1,102 @@
--TEST--
Union type: parameter runtime type checking
--FILE--
<?php
function expect_int_or_string(int|string $x): void {
var_dump($x);
}
function expect_int_or_string_or_null(int|string|null $x): void {
var_dump($x);
}
function expect_int_or_float(int|float $x): void {
var_dump($x);
}
function expect_nullable_int(?int $x): void {
var_dump($x);
}
function expect_nullable_string(?string $x): void {
var_dump($x);
}
function expect_bool_or_array(bool|array $x): void {
var_dump($x);
}
function main() {
// Valid calls - should pass
expect_int_or_string(42);
expect_int_or_string("hello");
expect_int_or_string_or_null(42);
expect_int_or_string_or_null("hello");
expect_int_or_string_or_null(null);
expect_int_or_float(42);
expect_int_or_float(3.14);
expect_nullable_int(42);
expect_nullable_int(null);
expect_nullable_string("test");
expect_nullable_string(null);
expect_bool_or_array(true);
expect_bool_or_array([1, 2, 3]);
// Invalid calls - should throw TypeError
$errors = [];
try {
expect_int_or_string(3.14);
} catch (\TypeError $e) {
$errors[] = $e->getMessage();
}
try {
expect_int_or_string([]);
} catch (\TypeError $e) {
$errors[] = $e->getMessage();
}
try {
expect_nullable_int("hello");
} catch (\TypeError $e) {
$errors[] = $e->getMessage();
}
try {
expect_bool_or_array(42);
} catch (\TypeError $e) {
$errors[] = $e->getMessage();
}
foreach ($errors as $err) {
var_dump($err);
}
}
?>
--EXPECT--
int(42)
string(5) "hello"
int(42)
string(5) "hello"
NULL
int(42)
float(3.14)
int(42)
NULL
string(4) "test"
NULL
bool(true)
array(3) {
[0]=>
int(1)
[1]=>
int(2)
[2]=>
int(3)
}
string(80) "expect_int_or_string(): Argument #1 ($x) must be of type int|string, float given"
string(80) "expect_int_or_string(): Argument #1 ($x) must be of type int|string, array given"
string(74) "expect_nullable_int(): Argument #1 ($x) must be of type ?int, string given"
string(78) "expect_bool_or_array(): Argument #1 ($x) must be of type bool|array, int given"

@ -0,0 +1,69 @@
--TEST--
Union type: return runtime type checking
--FILE--
<?php
function return_int_or_string($value): int|string {
return $value;
}
function return_nullable_int($value): ?int {
return $value;
}
function return_int_or_float($value): int|float {
return $value;
}
function main() {
// Valid returns - should pass
var_dump(return_int_or_string(42));
var_dump(return_int_or_string("hello"));
var_dump(return_nullable_int(42));
var_dump(return_nullable_int(null));
var_dump(return_int_or_float(42));
var_dump(return_int_or_float(3.14));
// Invalid returns - should throw TypeError
$errors = [];
try {
return_int_or_string(3.14);
} catch (\TypeError $e) {
$errors[] = $e->getMessage();
}
try {
return_int_or_string([]);
} catch (\TypeError $e) {
$errors[] = $e->getMessage();
}
try {
return_nullable_int("hello");
} catch (\TypeError $e) {
$errors[] = $e->getMessage();
}
try {
return_int_or_float("hello");
} catch (\TypeError $e) {
$errors[] = $e->getMessage();
}
foreach ($errors as $err) {
var_dump($err);
}
}
?>
--EXPECT--
int(42)
string(5) "hello"
int(42)
NULL
int(42)
float(3.14)
string(76) "return_int_or_string(): Return value must be of type int|string, float given"
string(76) "return_int_or_string(): Return value must be of type int|string, array given"
string(70) "return_nullable_int(): Return value must be of type ?int, string given"
string(75) "return_int_or_float(): Return value must be of type int|float, string given"
Loading…
Cancel
Save