Chuyển đến nội dung chính (Skip to content)
Mục lục bài viết
PHP

PHP EP 7: Vòng lặp, tham chiếu và Generator trong PHP

PHP EP 7: Vòng lặp, tham chiếu và Generator trong PHP

Hiểu cơ chế duyệt dữ liệu bằng for, while, foreach trong PHP, thực hành giải phóng biến tham chiếu bằng unset và dùng Generator yield để tiết kiệm bộ nhớ.

Tham chiếu foreach có thể làm đổi phần tử cuối

Trong phát triển ứng dụng PHP thực tế, có hai sự cố kinh điển liên quan đến vòng lặp khiến nhiều kỹ sư tốn hàng giờ gỡ lỗi: sự cố thứ nhất là sau khi dùng vòng lặp foreach ($items as &$val) để sửa trực tiếp các phần tử trong mảng, ở một vòng lặp thứ hai bên dưới vô tình dùng lại tên biến foreach ($items as $val), phần tử cuối cùng của mảng $items bỗng nhiên bị biến đổi thành giá trị của phần tử kế cuối mà không có bất kỳ dòng lệnh gán mảng nào. Sự cố thứ hai xảy ra khi viết kịch bản đọc tệp nhật ký hoặc xuất báo cáo hàng trăm nghìn dòng: việc nạp toàn bộ kết quả vào một mảng lớn rồi mới lặp qua làm tiến trình chạm ngưỡng memory_limit và sập ngay lập tức với lỗi Allowed memory size exhausted.

Bài học thứ bảy trong lộ trình PHP khép lại Chặng 1 (Nền tảng ngôn ngữ & Điều khiển luồng) và nối tiếp bài học về rẽ nhánh ở PHP EP 6. Dựa trên tài liệu chính thức về Vòng lặp foreach và Cơ chế Generators (yield) trên PHP 8.4, chúng ta sẽ làm rõ cách Zend Engine quản lý biến lặp, lý do bắt buộc phải gọi unset() sau khi lặp tham chiếu, cách dùng break/continue nhiều cấp và cách viết hàm Generator để xử lý luồng dữ liệu lớn với mức tiêu thụ RAM gần như không đổi.

Hồ sơ kiểm chứng & Môi trường Lab: Ngày kiểm chứng: Các kịch bản thực hành trong bài được kiểm chứng ngày 26/10/2026 bên trong container Docker cô lập mạng (--network none), tệp gốc chỉ đọc (--read-only) kèm vùng tạm /tmp, chạy dưới tài khoản thường phplab (UID 1001) trên Alpine Linux (aarch64) với PHP 8.4.27 (cli) và Zend Engine v4.4.27. Bài viết tập trung vào cơ chế lặp và đối tượng Generator chuẩn của lõi ngôn ngữ. Giới hạn kiểm chứng: CLI cục bộ, chưa kiểm chứng production.

Hợp đồng của vòng lặp và Generator

PHP cung cấp bốn cấu trúc vòng lặp (for, while, do-while, foreach) cùng cơ chế phát sinh lười (lazy evaluation) qua từ khóa yield. Mỗi công cụ có đặc tính quản lý con trỏ và bộ nhớ riêng biệt mà bạn cần phân biệt rõ:

Cấu trúc / Cơ chế Đầu vào & Đầu ra chính Hành vi thực tế trên PHP 8.4 Điều kiện / Không nên giả định
`foreach ($arr as $k => $v)` Đầu vào: Mảng hoặc đối tượng `Traversable`. Đầu ra: Bản sao giá trị từng phần tử. Sử dụng cơ chế Copy-on-Write: chỉ thực sự nhân bản bộ nhớ khi bạn sửa đổi `$v` hoặc `$arr` bên trong vòng lặp. Thay đổi biến `$v` trong thân vòng lặp mặc định không làm thay đổi phần tử gốc bên trong mảng `$arr`.
`foreach ($arr as &$v)` Đầu vào: Mảng có thể gán tham chiếu. Đầu ra: Tham chiếu trực tiếp tới phần tử mảng. Mọi phép gán lên `$v` sửa trực tiếp phần tử gốc trong `$arr`; khi kết thúc vòng lặp, `$v` vẫn trỏ vào phần tử cuối. Bắt buộc phải gọi `unset($v);` ngay sau vòng lặp để ngắt liên kết tham chiếu khỏi phần tử cuối cùng.
`for` / `while` / `do-while` Đầu vào: Biểu thức điều kiện dừng `bool`. Đầu ra: Thực thi lặp lại khối lệnh. Hỗ trợ `break N;` và `continue N;` (với `N` là số cấp vòng lặp lồng nhau cần thoát hoặc bỏ qua). Trong `for ($i = 0; $i < count($arr); $i++)`, không nên gọi hàm `count()` lặp lại ở mỗi bước nếu mảng không đổi.
Hàm `Generator` (`yield`) Đầu vào: Hàm chứa từ khóa `yield`. Đầu ra: Đối tượng thuộc lớp `Generator` (`Iterator`). Tạm dừng thực thi hàm tại mỗi lệnh `yield` để trả về một giá trị, rồi khôi phục đúng vị trí đó ở bước lặp kế tiếp. Một đối tượng `Generator` sau khi đã duyệt hết từ đầu đến cuối không thể tua lại (`rewind`) để duyệt lần thứ hai.

Tham chiếu tồn tại sau vòng lặp, yield tạo dữ liệu lười

Để hiểu tại sao lỗi ghi đè phần tử cuối mảng lại xảy ra khi dùng foreach ($arr as &$v), cần nhìn vào phạm vi biến của PHP. Trong PHP, biến lặp $v tồn tại trong phạm vi của toàn bộ hàm hoặc tệp hiện tại. Khi vòng lặp đầu tiên foreach ($arr as &$v) kết thúc ở phần tử cuối cùng ($arr[2]), biến $v vẫn giữ nguyên trạng thái là biến tham chiếu trỏ thẳng tới ô nhớ của $arr[2]. Nếu ngay sau đó bạn viết vòng lặp thứ hai foreach ($arr as $v) (không có dấu &), ở mỗi bước lặp, PHP sẽ gán giá trị phần tử hiện tại vào $v (tức là ghi đè trực tiếp vào ô nhớ của $arr[2]). Cách phòng vệ triệt để là luôn gọi unset($v); ngay sau vòng lặp có dấu &.

Với bài toán bộ nhớ khi xử lý tập dữ liệu lớn, nếu gom hết vào mảng $result[] = $row; rồi mới return $result;, PHP phải cấp phát RAM cho toàn bộ phần tử cùng lúc. Ngược lại, khi dùng từ khóa yield bên trong hàm (kiểu trả về : Generator), hàm trả về một đối tượng Generator. Mỗi khi foreach bên ngoài yêu cầu phần tử tiếp theo, Zend Engine mới chạy tới lệnh yield kế tiếp, trao giá trị rồi tạm dừng. Nhờ vậy, tại mọi thời điểm chỉ có đúng một bản ghi nằm trong RAM.

Luồng kiểm tra điều kiện dừng, biến lặp và giải phóng tham chiếu khi duyệt mảng.
Hình 1. Luồng kiểm tra điều kiện dừng, biến lặp và giải phóng tham chiếu khi duyệt mảng. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Sơ đồ Hình 1 tổng hợp quy trình 4 bước kiểm soát vòng lặp: đánh giá kích thước dữ liệu, ưu tiên duyệt theo giá trị, gọi unset() ngay nếu dùng tham chiếu và chuyển sang yield khi xử lý luồng lớn.

Tạo Generator trong vùng làm việc tạm

Hãy mở terminal trong container Lab, tạo thư mục tạm và khởi tạo tệp loop_generator.php với hàm generate_ids() trả về đối tượng Generator:

lab_dir="$(mktemp -d /tmp/php-ep07-XXXXXX)"
cd "$lab_dir"

cat << 'EOF' > loop_generator.php
<?php
declare(strict_types=1);

function generate_ids(int $max): Generator
{
    for ($i = 1; $i <= $max; $i++) {
        if ($i % 2 === 0) {
            yield $i;
        }
    }
}

$sum = 0;
foreach (generate_ids(10) as $evenId) {
    $sum += $evenId;
}
EOF

Hàm generate_ids(10) lần lượt phát sinh từng số chẵn (2, 4, 6, 8, 10) qua lệnh yield $i để cộng dồn vào $sum mà không tạo mảng trung gian.

Hủy tham chiếu bằng unset sau foreach

Tiếp theo, chúng ta bổ sung đoạn mã sửa mảng qua tham chiếu &$refVal kèm lệnh unset($refVal) vào loop_generator.php và thực thi:

cat << 'EOF' >> loop_generator.php

$items = [10, 20, 30];
foreach ($items as &$refVal) {
    $refVal *= 2;
}
unset($refVal);

foreach ($items as $refVal) {
    // Duyệt lại bằng cùng tên biến $refVal an toàn nhờ đã unset
}

printf("EvenSum=%d | Items=%s\n", $sum, implode(',', $items));
EOF

php loop_generator.php

Kết quả xuất ra: EvenSum=30 | Items=20,40,60. Nhờ lệnh unset($refVal); xóa bỏ liên kết tham chiếu của tên biến $refVal (mà không xóa phần tử 60 trong mảng), vòng lặp thứ hai dùng lại tên biến $refVal hoàn toàn an toàn.

Tái hiện lỗi quên unset và dọn dẹp

Bây giờ chúng ta tái hiện lỗi ghi đè phần tử cuối mảng khi cố tình bỏ quên lệnh unset($v) giữa hai vòng lặp foreach trên mảng [1, 2, 3]:

php -r '
$arr = [1, 2, 3];
foreach ($arr as &$v) {}
foreach ($arr as $v) {}
echo "Mảng sau khi quên unset: " . implode(",", $arr) . PHP_EOL;
'

Kết quả in ra là Mảng sau khi quên unset: 1,2,2! Dù thân của cả hai vòng lặp rỗng ({}), phần tử cuối cùng đã bị biến từ 3 thành 2 do ở vòng lặp thứ hai, $v vẫn đang tham chiếu tới $arr[2] nên bị gán đè tuần tự thành 1, 2, rồi 2.

So sánh mức tiêu thụ bộ nhớ khi nạp toàn bộ mảng vào RAM và khi phát sinh qua yield.
Hình 2. So sánh mức tiêu thụ bộ nhớ khi nạp toàn bộ mảng vào RAM và khi phát sinh qua yield. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Hình 2 đối chiếu cơ chế cấp phát bộ nhớ của mảng thông thường và hàm Generator (yield). Kết thúc bài thực hành, hãy xóa sạch thư mục tạm:

cd /tmp
rm -rf "$lab_dir"
test ! -e "$lab_dir" && echo "Đã dọn sạch thư mục Lab EP 07."

Dùng yield from và điều khiển vòng lặp nhiều cấp

Khi làm việc với các luồng dữ liệu phức tạp trên PHP 8.4, bạn có thể tận dụng thêm ba tính năng nâng cao của vòng lặp và Generator:

  1. Phát sinh cả khóa và giá trị (yield $key => $value): Giống như mảng kết hợp, câu lệnh yield cho phép trả về cặp khóa và giá trị để vòng lặp bên ngoài đọc bằng foreach ($gen as $k => $v).
  2. Ủy quyền sang Generator khác bằng yield from: Nếu một hàm Generator cần nối tiếp dữ liệu từ một mảng hoặc một hàm Generator con khác, thay vì viết vòng lặp foreach thủ công, bạn có thể dùng yield from $subGenerator; để chuyển thẳng luồng dữ liệu ra ngoài.
  3. Thoát nhiều vòng lặp lồng nhau bằng break 2; / continue 2;: Khi duyệt ma trận 2 chiều bằng hai vòng lặp foreach lồng nhau, lệnh break; mặc định chỉ thoát vòng lặp bên trong. Nếu muốn thoát ngay cả hai vòng lặp khi tìm thấy phần tử đích, PHP hỗ trợ tham số cấp độ nguyên dương break 2;.

Tự kiểm tra và giới hạn

Hãy tự kiểm tra lại kiến thức EP 7 và toàn bộ Chặng 1 qua 4 câu hỏi dưới đây:

  • Câu hỏi 1: Gọi unset($refVal) ngay sau vòng lặp foreach ($items as &$refVal) có làm mất phần tử cuối cùng bên trong mảng $items không? Đáp án: Không. Lệnh unset($refVal) chỉ hủy liên kết của tên biến $refVal, còn dữ liệu bên trong mảng $items vẫn được giữ nguyên vẹn hoàn toàn.
  • Câu hỏi 2: Nếu lưu kết quả của hàm Generator vào biến $gen = generate_ids(10); rồi chạy hai vòng lặp foreach ($gen as $x) liên tiếp trên cùng biến $gen, vòng lặp thứ hai có chạy được không? Đáp án: Không. Khi vòng lặp thứ nhất kết thúc, đối tượng Generator đã đóng và việc duyệt lại sẽ ném ngoại lệ Exception: Cannot traverse an already closed generator. Muốn duyệt lại, phải gọi lại generate_ids(10).
  • Câu hỏi 3: Trong một hàm PHP, có thể vừa dùng yield $val; vừa dùng return $summary; có giá trị trả về không? Đáp án: Có. PHP cho phép hàm Generator có lệnh return $summary; ở cuối, và bên ngoài có thể đọc giá trị cuối đó thông qua phương thức $gen->getReturn() sau khi đã duyệt xong toàn bộ phần tử.
  • Câu hỏi 4: Khi nào nên dùng mảng array thông thường thay vì Generator? Đáp án: Khi tập dữ liệu nhỏ và cần truy cập ngẫu nhiên nhiều lần. Nếu bạn cần đếm nhanh bằng count(), sắp xếp hoặc đọc đi đọc lại nhiều lần trong bộ nhớ, mảng thông thường tiện lợi hơn.

Tiêu chí nghiệm thu: Hoàn thành EP 7 và Chặng 1 khi bạn nắm vững cách dùng foreach an toàn với unset(), viết được hàm Generator bằng yield và giải thích được cơ chế bộ nhớ. Bước sang Chặng 2 (từ PHP EP 8), chúng ta sẽ đi sâu vào thiết kế Hàm hiện đại, Chuỗi UTF-8 và cấu trúc Mảng trên PHP 8.4.

Ôn bài trước: PHP EP 6: Rẽ nhánh với if, switch và match. Học tiếp: PHP EP 8: Hàm PHP: kiểu tham số, Closure và Named Arguments.

Bài liên quan trong bộ PHP

Tìm hiểu thêm