關於 PHP Traversing 的這檔事
foreach
大概是 PHP 程式中最常見的語法結構之後,本文將會介紹 foreach
的一些觀念,以及幾個跟它相關的 PHP 7 特色。
foreach 起手式
常見的 foreach 用法
一般我們最常看到 foreach
用在遍歷 (traversing) 陣列裡的元素,例如:
$arr = [1, 2, 3];
foreach ($arr as $num) {
echo "$num\n";
}
而如果想遍歷關連式陣列 (associative array) ,同時取得元素的鍵 (key) 與值 (value) ,可以用以下語法:
$arr = ['a' => 1, 'b' => 2, 'c' => 3];
foreach ($arr as $key => $num) {
echo "$key => $num\n";
}
如果陣列元素本身也是陣列,我們稱為「巢狀陣列 (nested array) 」。在遍歷巢狀陣列時,我們可以用 list(...)
來解構每個陣列元素:
$arr = [
['a', 1],
['b', 2],
['c', 3],
];
foreach ($arr as list($alpha, $num)) {
echo "$alpha => $num\n";
}
在 PHP 7.1 之後,你可以用方括號來取代 list()
:
foreach ($arr as [$alpha, $num]) {
echo "$alpha => $num\n";
}
當然別忘了 PHP 7.1 之後, list()
可以用指定鍵的方式來取值:
$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
/ prev
/ next
/ current
/ end
。
你可以用 while
搭配以上的函式來遍歷陣列:
$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
這個函式:
$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
來列舉屬性的話,你只能看到它的公開屬性:
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
做列舉屬性,那麼你可以看到這個物件所有的屬性:
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
這個介面。
if ($obj instanceof Traversable) {
// ...
}
但是要注意 Traversable
的幾個特點:
- 類別不能直接實作
Traversable
介面。 - 雖然陣列可以用
foreach
來遍歷,但它並不屬於Traversable
介面。
// 不能直接實作
class MyExample implements Traversable {} // Error
// 陣列不屬於 Traversable
$arr = [1, 2, 3];
var_dump($arr instanceof Traversable); // false
再次強調:物件雖然可以用 foreach
來操作,但它不一定是 Traversable
。
// 物件可以 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
等函式一樣,提供了操作指標與取得指標所指向的元素等方法介面,以供類別來實作。
以下是一個很典型的範例:
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
物件裡的元素:
$it = new IteratorExample(['a', null, 'b', 'c']);
$it->rewind();
while ($it->valid()) {
$key = $it->key();
var_dump($it->current());
$it->next();
}
當然也可以用 foreach
來遍歷,因為這時所生成的物件實體已經屬於 Traversable
介面了:
foreach ($it as $value) {
var_dump($value);
}
var_dump($it instanceof Traversable); // true
實作 Iterator
介面的好處,就是可以依照自定義的邏輯來遍歷物件內的元素,這在某些特別的情境下很好用。
另一個更為簡便的介面是 IteratorAggregate
,它只需要實作 getIterator
這個方法就可以了, getIterator
方法必須回傳一個實作 Iterator
的物件。
PHP 內建了多種 Iterator 類別讓開發者不需要從頭定義一個實作 Iterator
介面的類別,以下示範 ArrayIterator
這個類別如何跟 IteratorAggregate
介面搭配:
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
,可以用在 Argument type declarations (即 type hint) 及 Return type declarations 上。
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
為例:
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 這個類別,它可以協助我們在必要時才生成要處理的元素。
但是你不能直接用 new
來生成一個 Generator
類別的物件實體,取而代之的是 PHP 提供了 yield
這個新語法。
yield
用途和 return
很類似,但 yield
只能放在函式或類別方法中,而包含了 yield
的函式或類別方法,其回傳值的型態都是 Generator
類別。
由於 Generator
類別實作了 Iterator
介面,所以可以用 foreach
來遍歷其物件實體。
先來看一個基本的 yield
用法:
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
:
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 文字檔:
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()
來取得回傳值:
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) :
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
的值:
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
也可以用來回傳匿名函式:
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
後面:
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
就可以了:
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 這個機制來完成,例如以下這個加法測試:
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
就是了) :
public function additionProvider(): iterable
{
yield [0, 0, 0];
yield [0, 1, 1];
yield [1, 0, 1];
yield [1, 1, 2];
}
當然也可以用 key => value
的形式:
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];
}