feat(foreach): 支持动态类型表达式和IteratorAggregate嵌套

修复foreach遍历动态表达式时的类型分发,正确处理IteratorAggregate嵌套及无效返回值检测
pull/14/head
韩天峰 2 months ago
parent 6baaa9a221
commit f22499bdf9
  1. 1
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 32
      src/Php/CompilerBase.php
  3. 59
      src/Php/Translator.php
  4. 22
      tests/aot/loop/foreach-byref-dynamic-array.phpt
  5. 55
      tests/aot/loop/foreach-invalid-iteratoraggregate.phpt
  6. 102
      tests/aot/loop/foreach-iterable-objects.phpt
  7. 25
      tests/aot/loop/foreach-method-return-array.phpt
  8. 64
      tests/aot/stdlib/count_countable.phpt
  9. 2
      tests/aot/symfony/anonymous-iteratoraggregate-cache.phpt
  10. 25
      tests/aot/symfony/countable-object-count.phpt
  11. 46
      tests/aot/symfony/error-handler-finally-first-class.phpt
  12. 25
      tests/aot/symfony/foreach-traversable-method-result.phpt
  13. 58
      tests/aot/symfony/parameter-bag-filter-callback.phpt
  14. 47
      tests/aot/symfony/recursive-array-iterator-leaves.phpt
  15. 84
      tests/aot/symfony/route-collection-clone-iterator.phpt

@ -52,7 +52,6 @@
- `match` 的 arm condition 不能是 `match` 表达式。
- `foreach` by reference 的 value 只能是变量。
- `foreach` by reference 不支持 list destructuring。
- `foreach` 遍历 `IteratorAggregate` 返回的 `ArrayObject` 时,当前行为与标准 PHP 不完全一致。
- 固定 native typed object property 不允许按 PHP 未初始化语义自由 `unset()`
- native 类型变量执行 `unset()` 不会产生标准 PHP 的变量删除语义。

@ -5384,13 +5384,37 @@ class CompilerBase extends \PhpAot\Core\Translator implements PropertyAccessCont
}
}
$iteratorVar = $this->genTmpVarName();
$code = '';
$expr = $this->parseIdentifier($node->expr);
$code .= $this->parseBeforeStmtLines() . PHP_EOL;
$code .= self::TYPE_ARRAY . " {$iteratorVar} = " . $expr . ';' . PHP_EOL;
$code .= $this->parseForeachArray($node, $iteratorVar);
$iterableVar = $this->genTmpVarName();
$arrayVar = $this->genTmpVarName();
$objectVar = $this->genTmpVarName();
$this->addLocalVar($iterableVar, self::TYPE_VAR);
$this->addLocalVar($arrayVar, self::TYPE_ARRAY);
$this->addLocalVar($objectVar, self::TYPE_OBJECT);
$code .= $iterableVar . ' = ' . $expr . ';' . PHP_EOL;
$code .= 'if (' . $iterableVar . '.isArray()) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . $arrayVar . ' = ' . $iterableVar . ';' . PHP_EOL;
$code .= $this->parseForeachArray($node, $arrayVar) . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '} else if (' . $iterableVar . '.isObject()) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . $objectVar . ' = ' . $iterableVar . ';' . PHP_EOL;
if ($node->byRef) {
$code .= $this->getIndent() . 'php::throwException(zend_ce_error, "Cannot use & with foreach");' . PHP_EOL;
} else {
$code .= $this->parseForeachObject($node, $objectVar);
}
$this->indentLevel--;
$code .= $this->getIndent() . '} else {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . 'php::throwException(zend_ce_type_error, "foreach() argument must be of type array|object");' . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '}';
return $code;
}

@ -3796,11 +3796,17 @@ CODE;
$existing->default === $incoming->default;
}
protected function parseForeachObject(Foreach_ $node): string
protected function parseForeachObject(Foreach_ $node, ?string $objectExpr = null): string
{
$obj = $this->parseIdentifier($node->expr);
$tmpVar = $this->genTmpVarName();
$this->addLocalVar($tmpVar, self::TYPE_OBJECT);
$obj = $objectExpr ?? $this->parseIdentifier($node->expr);
$iterableVar = $this->genTmpVarName();
$this->addLocalVar($iterableVar, self::TYPE_VAR);
$iteratorObj = $this->genTmpVarName();
$this->addLocalVar($iteratorObj, self::TYPE_OBJECT);
$aggregateObj = $this->genTmpVarName();
$this->addLocalVar($aggregateObj, self::TYPE_OBJECT);
$tmpArrayVar = $this->genTmpVarName();
$this->addLocalVar($tmpArrayVar, self::TYPE_ARRAY);
@ -3813,25 +3819,52 @@ CODE;
$keyStr = $this->getLiteralString('key');
$nextStr = $this->getLiteralString('next');
$rewindStr = $this->getLiteralString('rewind');
$invalidAggregateReturn = static function (string $aggregateObj): string {
return 'php::throwException(zend_ce_exception, (php::concat({'
. 'php::Str("Objects returned by "), '
. $aggregateObj . '.getClassName(), '
. 'php::Str("::getIterator() must be traversable or implement interface Iterator")'
. '})).toCString());';
};
$code = 'if (' . $obj . '.instanceOf(' . $IteratorAggregateCe . ')) {' . PHP_EOL;
$code .= $this->getIndent() . $tmpVar . ' = ' . $obj . '.call(' . $getIteratorStr . ');' . PHP_EOL . '}' . PHP_EOL;
$code .= 'else if (' . $obj . '.instanceOf(' . $IteratorCe . ')) {' . PHP_EOL;
$code .= $this->getIndent() . $tmpVar . ' = ' . $obj . ';' . PHP_EOL . '}' . PHP_EOL;
$code = $iterableVar . ' = ' . $obj . ';' . PHP_EOL;
$code .= $iteratorObj . ' = ' . $iterableVar . ';' . PHP_EOL;
$code .= 'if (' . $iteratorObj . '.instanceOf(' . $IteratorAggregateCe . ')) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . 'do {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . $aggregateObj . ' = ' . $iteratorObj . ';' . PHP_EOL;
$code .= $this->getIndent() . $iterableVar . ' = ' . $aggregateObj . '.call(' . $getIteratorStr . ');' . PHP_EOL;
$code .= $this->getIndent() . 'if (UNEXPECTED(!' . $iterableVar . '.isObject())) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . $invalidAggregateReturn($aggregateObj) . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '}' . PHP_EOL;
$code .= $this->getIndent() . $iteratorObj . ' = ' . $iterableVar . ';' . PHP_EOL;
$code .= $this->getIndent() . 'if (UNEXPECTED(!' . $iteratorObj . '.instanceOf(' . $IteratorCe . ') && !' . $iteratorObj . '.instanceOf(' . $IteratorAggregateCe . '))) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . $invalidAggregateReturn($aggregateObj) . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '}' . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '} while (' . $iteratorObj . '.instanceOf(' . $IteratorAggregateCe . '));' . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '}' . PHP_EOL;
$code .= 'if (' . $tmpVar . ') {' . PHP_EOL;
$code .= 'if (' . $iteratorObj . '.instanceOf(' . $IteratorCe . ')) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . $tmpVar . '.call(' . $rewindStr . ');' . PHP_EOL;
$code .= $this->getIndent() . 'for (;' . $tmpVar . '.call(' . $validStr . '); ' . $tmpVar . '.call(' . $nextStr . ')) {' . PHP_EOL;
$code .= $this->getIndent() . $iteratorObj . '.call(' . $rewindStr . ');' . PHP_EOL;
$code .= $this->getIndent() . 'for (;' . $iteratorObj . '.call(' . $validStr . '); ' . $iteratorObj . '.call(' . $nextStr . ')) {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->parseForeachKeyAssignment($node, $tmpVar . '.call(' . $keyStr . ')');
$code .= $this->parseForeachValueAssignment($node, $tmpVar . '.call(' . $currentStr . ')');
$code .= $this->parseForeachKeyAssignment($node, $iteratorObj . '.call(' . $keyStr . ')');
$code .= $this->parseForeachValueAssignment($node, $iteratorObj . '.call(' . $currentStr . ')');
$code .= $this->parseForeachBody($node);
$code .= '}' . PHP_EOL;
$this->indentLevel--;
$code .= $this->getIndent() . '} else {' . PHP_EOL;
$this->indentLevel++;
$code .= $this->getIndent() . $tmpArrayVar . ' = php::call(' . $this->getFuncPtr('get_object_vars') . ', {' . $obj . '});' . PHP_EOL;
$code .= $this->parseForeachArray($node, $tmpArrayVar);
$this->indentLevel--;

@ -0,0 +1,22 @@
--TEST--
foreach by reference supports dynamically evaluated arrays
--FILE--
<?php
function values(): array
{
return [1, 2, 3];
}
function main(): void
{
foreach (values() as &$value) {
$value *= 2;
var_dump($value);
}
}
?>
--EXPECT--
int(2)
int(4)
int(6)

@ -0,0 +1,55 @@
--TEST--
foreach rejects invalid IteratorAggregate getIterator return values
--FILE--
<?php
final class ArrayReturningAggregate implements IteratorAggregate
{
public function getIterator()
{
return ['bad'];
}
}
final class ObjectReturningAggregate implements IteratorAggregate
{
public function getIterator()
{
return (object) ['bad' => true];
}
}
final class NestedInvalidAggregate implements IteratorAggregate
{
public function getIterator(): Traversable
{
return new ObjectReturningAggregate();
}
}
function check(object $iterable): void
{
try {
foreach ($iterable as $value) {
var_dump($value);
}
} catch (Throwable $e) {
var_dump($e instanceof Exception);
var_dump(str_contains($e->getMessage(), 'must be traversable or implement interface Iterator'));
}
}
function main(): void
{
check(new ArrayReturningAggregate());
check(new ObjectReturningAggregate());
check(new NestedInvalidAggregate());
}
?>
--EXPECT--
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)

@ -0,0 +1,102 @@
--TEST--
foreach supports PHP iterable object interfaces
--FILE--
<?php
final class NumberIterator implements Iterator
{
private int $pos = 0;
public function __construct(private array $items)
{
}
public function rewind(): void
{
$this->pos = 0;
}
public function current(): mixed
{
return array_values($this->items)[$this->pos];
}
public function key(): mixed
{
return array_keys($this->items)[$this->pos];
}
public function next(): void
{
++$this->pos;
}
public function valid(): bool
{
return $this->pos < count($this->items);
}
}
final class ArrayAggregate implements IteratorAggregate
{
public function __construct(private array $items)
{
}
public function getIterator(): Traversable
{
return new ArrayIterator($this->items);
}
}
final class ArrayObjectAggregate implements IteratorAggregate
{
public function __construct(private array $items)
{
}
public function getIterator(): Traversable
{
return new ArrayObject($this->items);
}
}
final class TraversableProvider
{
public function getIterator(): Traversable
{
return new ArrayIterator(['m' => 'method']);
}
}
function dump_iterable(iterable $items): void
{
foreach ($items as $key => $value) {
var_dump($key.':'.$value);
}
}
function main(): void
{
dump_iterable(['a' => 'array']);
dump_iterable(new NumberIterator(['i' => 'iterator']));
dump_iterable(new ArrayAggregate(['g' => 'aggregate']));
dump_iterable(new ArrayObjectAggregate(['o' => 'arrayobject']));
$provider = new TraversableProvider();
foreach ($provider->getIterator() as $key => $value) {
var_dump($key.':'.$value);
}
foreach ((object) ['p' => 'property'] as $key => $value) {
var_dump($key.':'.$value);
}
}
?>
--EXPECT--
string(7) "a:array"
string(10) "i:iterator"
string(11) "g:aggregate"
string(13) "o:arrayobject"
string(8) "m:method"
string(10) "p:property"

@ -0,0 +1,25 @@
--TEST--
foreach supports dynamic method calls returning arrays
--FILE--
<?php
final class ArrayProvider
{
public function values(): array
{
return ['a' => 1, 'b' => 2];
}
}
function main(): void
{
$provider = new ArrayProvider();
foreach ($provider->values() as $key => $value) {
var_dump($key.':'.$value);
}
}
?>
--EXPECT--
string(3) "a:1"
string(3) "b:2"

@ -0,0 +1,64 @@
--TEST--
count: Countable objects use Countable::count()
--FILE--
<?php
final class RouteBag implements Countable
{
public function __construct(private array $routes)
{
}
public function count(): int
{
echo "RouteBag::count\n";
return count($this->routes);
}
}
function count_mixed(mixed $value): int
{
return count($value);
}
function main(): void
{
$bag = new RouteBag(['home', 'about', 'contact']);
var_dump(count($bag));
var_dump(count($bag, COUNT_RECURSIVE));
var_dump(count_mixed($bag));
$anonymous = new class([1, 2, 3, 4]) implements Countable {
public function __construct(private array $items)
{
}
public function count(): int
{
echo "anonymous::count\n";
return count($this->items);
}
};
var_dump(count($anonymous));
$arrayObject = new ArrayObject(['x', 'y']);
var_dump(count($arrayObject));
try {
count(new stdClass());
} catch (TypeError $e) {
echo $e->getMessage(), "\n";
}
}
?>
--EXPECT--
RouteBag::count
int(3)
RouteBag::count
int(3)
RouteBag::count
int(3)
anonymous::count
int(4)
int(2)
count(): Argument #1 ($value) must be of type Countable|array

@ -1,7 +1,5 @@
--TEST--
Symfony pattern: anonymous IteratorAggregate with cached ??= ArrayObject
--XFAIL--
Known AOT bug: foreach over ArrayObject returned from IteratorAggregate can call ArrayObject::rewind() directly.
--FILE--
<?php

@ -0,0 +1,25 @@
--TEST--
Symfony pattern: count() dispatches to Countable object
--FILE--
<?php
final class CountableRoutes implements Countable
{
public function __construct(private array $routes)
{
}
public function count(): int
{
return count($this->routes);
}
}
function main(): void
{
$routes = new CountableRoutes(['home', 'about', 'contact']);
var_dump(count($routes));
}
?>
--EXPECT--
int(3)

@ -0,0 +1,46 @@
--TEST--
Symfony pattern: set_error_handler with first-class static method and finally restore
--FILE--
<?php
final class BoxedErrorHandler
{
private static ?string $lastError = null;
public static function handleError(int $type, string $message): bool
{
self::$lastError = $type.':'.$message;
return true;
}
public static function call(callable $callback): mixed
{
set_error_handler(self::handleError(...));
try {
return $callback();
} finally {
restore_error_handler();
}
}
public static function lastError(): ?string
{
return self::$lastError;
}
}
function main(): void
{
$result = BoxedErrorHandler::call(static function (): string {
trigger_error('boxed-warning', E_USER_WARNING);
return 'done';
});
var_dump($result);
var_dump(BoxedErrorHandler::lastError());
}
?>
--EXPECT--
string(4) "done"
string(17) "512:boxed-warning"

@ -0,0 +1,25 @@
--TEST--
Symfony pattern: foreach over Traversable returned from method
--FILE--
<?php
final class TraversableProvider
{
public function getIterator(): ArrayIterator
{
return new ArrayIterator(['first' => 1, 'second' => 2]);
}
}
function main(): void
{
$provider = new TraversableProvider();
foreach ($provider->getIterator() as $key => $value) {
var_dump($key.':'.$value);
}
}
?>
--EXPECT--
string(7) "first:1"
string(8) "second:2"

@ -0,0 +1,58 @@
--TEST--
Symfony pattern: parameter bag filter callback with coalesce throw
--FILE--
<?php
final class MiniParameterBag
{
public function __construct(private array $parameters)
{
}
public function filter(string $key, mixed $default, int $filter, array|int $options = []): mixed
{
$value = $this->parameters[$key] ?? $default;
if (is_int($options)) {
$options = ['flags' => $options];
}
if ((FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof Closure)) {
throw new InvalidArgumentException('callback filter requires a Closure');
}
$options['flags'] ??= 0;
return filter_var($value, $filter, $options);
}
public function getInt(string $key): int
{
return $this->filter($key, null, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE)
?? throw new UnexpectedValueException('invalid int: '.$key);
}
}
function main(): void
{
$bag = new MiniParameterBag([
'port' => '9501',
'name' => 'Symfony',
]);
var_dump($bag->getInt('port'));
var_dump($bag->filter('name', '', FILTER_CALLBACK, [
'options' => static fn (string $value): string => strtolower($value),
]));
try {
$bag->getInt('missing');
} catch (UnexpectedValueException $e) {
var_dump($e->getMessage());
}
}
?>
--EXPECT--
int(9501)
string(7) "symfony"
string(20) "invalid int: missing"

@ -0,0 +1,47 @@
--TEST--
Symfony pattern: recursive iterator over nested context attributes
--FILE--
<?php
function flattenLeaves(array $attributes): array
{
$it = new RecursiveIteratorIterator(
new RecursiveArrayIterator($attributes),
RecursiveIteratorIterator::LEAVES_ONLY
);
$result = [];
foreach ($it as $key => $value) {
$path = [];
for ($depth = 0; $depth <= $it->getDepth(); ++$depth) {
$path[] = $it->getSubIterator($depth)->key();
}
$result[implode('.', $path)] = $value;
}
ksort($result);
return $result;
}
function main(): void
{
$flat = flattenLeaves([
'groups' => ['Default', 'Extra'],
'options' => [
'normalizer' => [
'trim' => true,
'lower' => false,
],
],
]);
foreach ($flat as $key => $value) {
var_dump($key.'='.json_encode($value));
}
}
?>
--EXPECT--
string(18) "groups.0="Default""
string(16) "groups.1="Extra""
string(30) "options.normalizer.lower=false"
string(28) "options.normalizer.trim=true"

@ -0,0 +1,84 @@
--TEST--
Symfony pattern: route collection deep clone with ArrayIterator
--FILE--
<?php
final class MiniRoute
{
public function __construct(public string $path)
{
}
}
final class MiniAlias
{
public function __construct(public string $target)
{
}
}
final class MiniRouteCollection implements IteratorAggregate
{
private array $routes = [];
private array $aliases = [];
public function add(string $name, MiniRoute $route): void
{
$this->routes[$name] = $route;
}
public function addAlias(string $name, MiniAlias $alias): void
{
$this->aliases[$name] = $alias;
}
public function __clone()
{
foreach ($this->routes as $name => $route) {
$this->routes[$name] = clone $route;
}
foreach ($this->aliases as $name => $alias) {
$this->aliases[$name] = clone $alias;
}
}
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->routes);
}
public function routeCount(): int
{
return count($this->routes);
}
public function aliasTarget(string $name): string
{
return $this->aliases[$name]->target;
}
}
function main(): void
{
$collection = new MiniRouteCollection();
$collection->add('home', new MiniRoute('/'));
$collection->add('about', new MiniRoute('/about'));
$collection->addAlias('root', new MiniAlias('home'));
$copy = clone $collection;
foreach (iterator_to_array($copy->getIterator()) as $name => $route) {
$route->path = strtoupper($route->path);
var_dump($name.':'.$route->path);
}
var_dump($collection->routeCount());
var_dump($collection->aliasTarget('root'));
}
?>
--EXPECT--
string(6) "home:/"
string(12) "about:/ABOUT"
int(2)
string(4) "home"
Loading…
Cancel
Save