# All about PHP Traversing
###### tags: `PHP`
`foreach` 大概是 PHP 程式中最常見的語法結構之後,本文將會介紹 `foreach` 的一些觀念,以及幾個跟它相關的 PHP 7 特色。
## foreach 起手式
### 常見的 foreach 用法
一般我們最常看到 [`foreach`](https://www.php.net/manual/en/control-structures.foreach.php) 用在遍歷 (traversing) 陣列裡的元素,例如:
```php
$arr = [1, 2, 3];
foreach ($arr as $num) {
echo "$num\n";
}
```
而如果想遍歷關連式陣列 (associative array) ,同時取得元素的鍵 (key) 與值 (value) ,可以用以下語法:
```php
$arr = ['a' => 1, 'b' => 2, 'c' => 3];
foreach ($arr as $key => $num) {
echo "$key => $num\n";
}
```
如果陣列元素本身也是陣列,我們稱為「巢狀陣列 (nested array) 」。在遍歷巢狀陣列時,我們可以用 `list(...)` 來解構每個陣列元素:
```php
$arr = [
['a', 1],
['b', 2],
['c', 3],
];
foreach ($arr as list($alpha, $num)) {
echo "$alpha => $num\n";
}
```
在 PHP 7.1 之後,你可以用方括號來取代 `list()` :
```php
foreach ($arr as [$alpha, $num]) {
echo "$alpha => $num\n";
}
```
當然別忘了 PHP 7.1 之後, `list()` 可以用指定鍵的方式來取值:
```php
$users = [
['id' => 1, 'name' => 'Alice', 'age' => 18],
['id' => 2, 'name' => 'Bob', 'age' => 24],
['id' => 3, 'name' => 'Carl', 'age' => 33]
];
foreach ($users as ['name' => $name, 'age' => $age, 'id' => $id]) {
var_dump("$id $name: $age");
}
```
### 如果沒有 foreach
PHP 有提供幾個函式用來操作陣列裡的指標,以及取得指標指向的陣列元素;分別是 [`reset`](https://www.php.net/manual/en/function.reset.php) / [`prev`](https://www.php.net/manual/en/function.prev.php) / [`next`](https://www.php.net/manual/en/function.next.php) / [`current`](https://www.php.net/manual/en/function.current.php) / [`end`](https://www.php.net/manual/en/function.end.php) 。
你可以用 [`while`](https://www.php.net/manual/en/control-structures.while.php) 搭配以上的函式來遍歷陣列:
```php
$arr = [null, 1, 2, false, null, 3, 4];
// 直接找到最後一個元素
// 這裡指標是指向最後一個元素
var_dump(end($arr));
// 因為指標已經跑到最後一個元素的位置
// 所以要重置指標
reset($arr);
// 遍歷陣列裡的元素
while (!is_null(key($arr))) {
var_dump(current($arr));
next($arr);
}
// 指標的位置已經沒有元素了
var_dump(current($arr));
```
另一個不用 `foreach` 的方法是使用 `array_walk` 這個函式:
```php
$arr = [1, 2, 3];
array_walk($arr, function ($item) {
echo "$item ";
});
$arr = ['a' => 1, 'b' => 2, 'c' => 3];
array_walk($arr, function ($item, $key) {
echo "$key => $item";
});
```
### 用 foreach 來列舉物件屬性
`foreach` 也可以用來列舉 (listing) 物件的屬性,只要該物件不是屬於可遍歷的物件。
當你對一個物件實體用 `foreach` 來列舉屬性的話,你只能看到它的公開屬性:
```php
class MyClass
{
public $publicVar = 'public var';
protected $protectedVar = 'protected var';
private $privateVar = 'private var';
}
$class = new MyClass();
foreach ($class as $key => $value) {
echo "$key => $value\n";
}
```
但是如果你是在物件內部對 `$this` 做列舉屬性,那麼你可以看到這個物件所有的屬性:
```php
class MyClass
{
public $publicVar = 'public var';
protected $protectedVar = 'protected var';
private $privateVar = 'private var';
public function iterateSelf()
{
foreach ($this as $key => $value) {
print "$key => $value\n";
}
}
}
$class = new MyClass();
$class->iterateSelf();
```
注意,之所以特意用「列舉屬性」將「遍歷元素」的概念區分開來,實在是因為 `foreach` 對 PHP 物件的操作真的很微妙。
一般來說,我們希望用 `foreach` 來遍歷集合的元素,而不是列舉物件的屬性;所以當物件屬於一個集合時,就需要讓物件所屬的類別實作一些特別的介面。
## 用 foreach 來遍歷的介面
### Traversable
如果你需要的是一個可以被遍歷的物件 (通常是集合物件) ,那麼你可以檢查它是不是屬於 `Traversable` 這個介面。
```php
if ($obj instanceof Traversalbe) {
// ...
}
```
但是要注意 `Traversable` 的幾個特點:
1. 類別不能直接實作 `Traversable` 介面。
2. 雖然陣列可以用 `foreach` 來遍歷,但它並不屬於 `Traversable` 介面。
```php
// 不能直接實作
class MyExample implements Traversable {} // Error
// 陣列不屬於 Traversable
$arr = [1, 2, 3];
var_dump($arr instanceof Traversable); // false
```
再次強調:物件雖然可以用 `foreach` 來操作,但它不一定是 `Traversable` 。
```php
// 物件可以 foreach 列舉屬性,但不一定是 Traversable
$obj = (object) ['a' => 1, 'b' => 2, 'c' => 3];
foreach ($obj as $key => $value) {
echo "$key => $value\n";
}
var_dump($obj instanceof Traversable); // false
class MyExample {}
$obj = new MyExample();
var_dump($obj instanceof Traversable); // false
```
### Iterator 與 IteratorAggregate
由於不能直接實作 `Traversable` 介面,官方建議應該改為實作 `Iterator` 或是 `IteratorAggregate` 這類的介面,它們都繼承自 `Traversable` 介面。
`Iterator` 介面就如同前面介紹的 `next` 、 `current` 等函式一樣,提供了操作指標與取得指標所指向的元素等方法介面,以供類別來實作。
以下是一個很典型的範例:
```php
class IteratorExample implements Iterator
{
private $position = 0;
private $data = [];
public function __construct(array $data)
{
$this->position = 0;
$this->data = $data;
}
public function rewind()
{
$this->position = 0;
}
public function current()
{
return $this->data[$this->position];
}
public function key()
{
return $this->position;
}
public function next()
{
++$this->position;
}
public function valid()
{
return array_key_exists(
$this->position,
$this->data
);
}
}
```
我們可以用 `while` 敘述來遍歷 `Iterator` 物件裡的元素:
```php
$it = new IteratorExample(['a', null, 'b', 'c']);
$it->rewind();
while ($it->valid()) {
$key = $it->key();
var_dump($it->current());
$it->next();
}
```
當然也可以用 `foreach` 來遍歷,因為這時所生成的物件實體已經屬於 `Traveralbe` 介面了:
```php
foreach ($it as $value) {
var_dump($value);
}
var_dump($it instanceof Traversable); // true
```
實作 `Iterator` 介面的好處,就是可以依照自定義的邏輯來遍歷物件內的元素,這在某些特別的情境下很好用。
另一個更為簡便的介面是 `IteratorAggregate` ,它只需要實作 `getIterator` 這個方法就可以了, `getIterator` 方法必須回傳一個實作 `Iterator` 的物件。
PHP 內建了[多種 Iterator 類別](https://www.php.net/manual/en/spl.iterators.php)讓開發者不需要從頭定義一個實作 `Iterator` 介面的類別,以下示範 `ArrayIterator` 這個類別如何跟 `IteratorAggregate` 介面搭配:
```php
class IteratorAggregateExample implements IteratorAggregate
{
private $data = [];
public function __construct(array $data)
{
$this->data = $data;
}
public function getIterator()
{
return new ArrayIterator($this->data);
}
}
$it = new IteratorAggregateExample(['a', 'b', 'c']);
foreach ($it as $value) {
echo "$value\n";
}
```
用 `IteratorAggregate` 介面的好處是,你可以動態更換 `getIterator` 方法的回傳內容,而不必讓程式綁死在特定的 Iterator 類別上。
### iterable 型別
`Traversable` 雖然可以用來判斷變數可否被遍歷,但它卻不適用在陣列變數上。因此 PHP 在 7.1 加入了一個偽型別 (pseudo-type) : [`iterable`](https://www.php.net/manual/en/language.types.iterable.php) ,可以用在 [Argument type declarations](https://www.php.net/manual/en/functions.arguments.php#functions.arguments.type-declaration) (即 type hint) 及 [Return type declarations](https://www.php.net/manual/en/functions.returning-values.php#functions.returning-values.type-declaration) 上。
```php
function getArr(): iterable
{
return ['a' => 1, 'b' => 2, 'c' => 3];
}
function traverse(iterable $list)
{
foreach ($list as $item) {
echo "$item ";
}
}
$arr = getArr();
$it = new ArrayIterator($arr);
var_dump(is_iterable($arr)); // true
var_dump(is_iterable($it)); // true
traverse($arr); // 1 2 3
traverse($it); // 1 2 3
```
因此建議在程式中,可以用 `iterable` 型別來取代 `Traversable` 介面。
## 如何在 foreach 時節省記憶體
有時候要遍歷的對象,在生成後可能會佔用很大的記憶體空間,這可能會造成 PHP 執行時期的記憶體不足。以 `range` 為例:
```php
function showMemUsageIf(bool $show = false): void
{
if ($show) {
echo round(memory_get_usage() / 1024 / 1024, 2) . ' MB' . PHP_EOL;
}
}
showMemUsageIf(true); // 15.42 MB
$a = range(1, 1000000);
foreach ($a as $num) { ... }
showMemUsageIf(true); // 47.43 MB
```
因此在 PHP 5.5 之後的版本,提供了 [Generator](https://www.php.net/manual/en/class.generator.php) 這個類別,它可以協助我們在必要時才生成要處理的元素。
但是你不能直接用 `new` 來生成一個 `Generator` 類別的物件實體,取而代之的是 PHP 提供了 `yield` 這個新語法。
`yield` 用途和 `return` 很類似,但 `yield` 只能放在函式或類別方法中,而包含了 `yield` 的函式或類別方法,其回傳值的型態都是 `Generator` 類別。
由於 `Generator` 類別實作了 `Iterator` 介面,所以可以用 `foreach` 來遍歷其物件實體。
先來看一個基本的 `yield` 用法:
```php
function gen()
{
echo "a: ";
yield 1;
echo "b: ";
yield 2;
echo "c: ";
yield 3;
// 當然也可以放在迴圈裡
foreach (['d' => 4, 'e' => 5] as $key => $num) {
echo "$key: ";
yield $num;
}
}
foreach (gen() as $num) {
echo "$num ";
}
```
可以看到當執行 `foreach` 的第一輪時, `gen()` 函式並不是一次跑完,而是會停在第一個 `yield` 上,並回傳 `yield` 後面的值。而第二輪則是從第一個 `yield` 後繼續執行,然後停在第二個 `yield` 。
由此可以看出,每當執行到 `yield` 時, `Generator` 就會保留目前的執行位置,並給出當下 `yield` 的結果,這在處理大量資料時就顯得非常有用了。
所以我們用 `Generator` 來重寫 `range` ,這個新函式我們命名為 `xrange` :
```php
function xrange($start, $limit, $step = 1) {
if ($start < $limit) {
if ($step <= 0) {
throw new LogicException('Step must be +ve');
}
for ($i = $start; $i <= $limit; $i += $step) {
yield $i;
}
} else {
if ($step >= 0) {
throw new LogicException('Step must be -ve');
}
for ($i = $start; $i >= $limit; $i += $step) {
yield $i;
}
}
}
showMemUsageIf(true); // 15.42 MB
$a = xrange(1, 1000000);
foreach ($a as $num) { ... }
showMemUsageIf(true); // 15.42 MB
```
可以看到改用 `Generator` 後,記憶體的用量幾乎沒有什麼改變。
再舉一個例子,例如我們想要處理一個超大的 log 文字檔:
```php
function readLog(string $file)
{
$f = fopen($file, 'r');
try {
while ($line = fgets($f)) {
yield $line;
}
} finally {
fclose($f);
}
}
foreach (readLog("access.log") as $line) {
// echo $line;
}
```
透過這個方式,我們可以把每一行 log 的處理邏輯和讀檔邏輯分離開來,而且也不會佔用太多記憶體。
### Generator 的特異功能
`Generator` 有幾個特別 `yield` 的用法,這裡特別介紹一下。
`yield` 可以跟 `return` 一起使用,不過這時候必須用 `Generator::getReturn()` 來取得回傳值:
```php
function getValues(): iterable
{
yield 'value';
return 'returnValue';
}
$values = getValues(); // $values 是一個 Generator
foreach ($values as $value) {
var_dump($value);
}
echo $values->getReturn(); // 'returnValue'
```
`yield` 可以回傳鍵 (key) 與值 (value) :
```php
function getMembers(): iterable
{
yield 'a' => 1;
yield 'b' => 2;
yield 'c' => 3;
}
foreach (getMembers() as $key => $value) {
echo "$key: $value\n";
}
```
巢狀的 Generator 可以用 `yield from` 來達成, `yield from` 後面要跟著一個 `iterable` 的值:
```php
function one(): iterable
{
yield 1;
}
function two_one(): iterable
{
yield 2;
yield from one();
}
function ten_to_seven(): iterable
{
for ($i = 10; $i >= 7; $i--) {
yield $i;
}
}
function count_down(): iterable
{
yield from ten_to_seven();
yield from [6, 5];
yield from new ArrayIterator([4, 3]);
yield from two_one();
}
foreach (count_down() as $num) {
echo "$num ";
}
```
當然 `yield` 也可以用來回傳匿名函式:
```php
function getFunctions()
{
yield function ($num) {
return $num;
};
yield function ($num) {
return $num + 1;
};
yield function ($num) {
return $num + 2;
};
}
foreach (getFunctions() as $func) {
var_dump($func(1));
}
```
但以下這個例子是錯誤的,因為 anonymous function 是一個 `Closure` 物件,不能接在 `yield from` 後面:
```php
function getFunctions()
{
yield from function ($num) {
for ($i = 1; $i <= $num; $i++) {
yield $i;
}
};
}
foreach (getFunctions() as $func) {
// ...
}
// PHP Fatal error: Uncaught Error: Can use "yield from" only with arrays and Traversables
```
直接 `yield` 就可以了:
```php
function getFunctions()
{
yield function ($num) {
for ($i = 1; $i <= $num; $i++) {
yield $i;
}
};
yield function ($num) {
for ($i = $num; $i >= 1; $i--) {
yield $i;
}
};
}
foreach (getFunctions() as $func) {
foreach ($func(10) as $result) {
var_dump($result);
}
}
```
## 在 PHPUnit 的 Data Providers 中使用 yield
在寫 PHPUnit 的測試案例時,我們通常會對某個單元的程式給出多組不同的測試資料,好驗證它的邏輯正確性;而這通常會透過 [Data Providers](https://phpunit.readthedocs.io/en/8.5/writing-tests-for-phpunit.html#data-providers) 這個機制來完成,例如以下這個加法測試:
```php
use PHPUnit\Framework\TestCase;
class DataTest extends TestCase
{
/**
* @dataProvider additionProvider
*
* @param int $a
* @param int $b
* @param int $expected
*/
public function testAdd(int $a, int $b, int $expected)
{
$this->assertSame($expected, $a + $b);
}
public function additionProvider(): iterable
{
return [
[0, 0, 0],
[0, 1, 1],
[1, 0, 1],
[1, 1, 2],
];
}
}
```
在上面的測試中, `additionProvider` 這個方法就是我們的 data-provider ,它必須回傳一個陣列 (這裡稱為 data set) 的陣列;而在測試案例 `testAdd` 這個方法上,我們要加入它的註解,並用 `@dataProvider` 來宣告我們的 data-provider 是 `additionProvider` 這個方法;而 `additionProvider` 的第二層陣列的項目 (即 data set 裡的每個元素) ,就會依序代入 `testAdd` 方法參數 `$a` 、 `$b` 、 `$expected` 裡。
不過很多剛用 data-provider 的朋友常會忘了要包第一層的陣列,導致測試錯誤;這裡我們可以改用 `yield` 來回傳每個 data set ,好避開這類的錯誤,同時也可以讓 data-provider 更加易讀 (雖然要多打幾個 `yield` 就是了) :
```php
public function additionProvider(): iterable
{
yield [0, 0, 0];
yield [0, 1, 1];
yield [1, 0, 1];
yield [1, 1, 2];
}
```
當然也可以用 `key => value` 的形式:
```php
public function additionProvider(): iterable
{
yield 'Set 1' => [0, 0, 0];
yield 'Set 2' => [0, 1, 1];
yield 'Set 3' => [1, 0, 1];
yield 'Set 4' => [1, 1, 2];
}
```
<!--
## Bonus: 遍歷 vs 迭代 vs 疊代
- [遍歷](https://www.moedict.tw/%E9%81%8D%E6%AD%B7):各處都到過。
- [迭代](https://www.moedict.tw/%E8%BF%AD%E4%BB%A3):交換替代。
- [疊代](https://zh.wikipedia.org/wiki/%E8%BF%AD%E4%BB%A3):重複回饋過程的活動。
-->
## 參考
- [php 之 Generator 生成器及 yield](https://www.jianshu.com/p/86fefb0aacd9)
- [在 PHP 中使用 `yield` 來做內存優化](https://learnku.com/laravel/t/8704/using-yield-to-do-memory-optimization-in-php)
- [Yield in PHPUnit data providers](https://www.entropywins.wtf/blog/2017/10/09/yield-in-phpunit-data-providers/)