Menu

IEnumerable và yield return trong C#: giải thích chuỗi lazy

IEnumerable<T> là interface đứng sau foreach và LINQ, còn yield return là cách dễ nhất để triển khai nó. Tìm hiểu IEnumerable liên quan thế nào tới ICollection và List, một phương thức iterator chạy từng bước ra sao, yield break, chuỗi vô hạn và những cạm bẫy của đánh giá lazy.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Mọi vòng lặp foreach và mọi truy vấn LINQ đều hoạt động qua một interface nhỏ: IEnumerable<T>. Hiểu nó sẽ giải thích vì sao LINQ là lazy, vì sao có những chuỗi chỉ đọc được một lần, và cách yield return giúp bạn tự viết chuỗi của mình chỉ bằng một vòng lặp thông thường.

IEnumerable là gì

IEnumerable<T> có một phương thức duy nhất, GetEnumerator(). Enumerator mà nó trả về có MoveNext() (tiến lên, và báo còn phần tử hay không) và Current (phần tử hiện tại). Một vòng foreach là cách viết tắt để điều khiển enumerator đó:

Đầu ra:

red green blue 
red green blue 

Enumerator bắt đầu ở trước phần tử đầu tiên, nên lần MoveNext() đầu tiên di chuyển tới phần tử đó. Khi MoveNext() trả về false, chuỗi kết thúc. Khối using giải phóng (dispose) enumerator, điều này quan trọng với các iterator giữ tài nguyên như tệp đang mở.

IEnumerable, ICollection và List

Các interface collection tạo thành một bậc thang. Mỗi bậc hứa hẹn nhiều hơn:

KiểuBổ sungCách dùng điển hình
IEnumerable<T>Chỉ foreachTham số bạn chỉ lặp qua; kết quả LINQ; chuỗi lazy
IReadOnlyCollection<T>CountGiá trị trả về mà nơi gọi không nên sửa
ICollection<T>Count, Add, Remove, ContainsCode thêm phần tử vào bất kỳ loại collection nào
IReadOnlyList<T>Truy cập theo chỉ số [i]Kết quả chỉ đọc có vị trí
IList<T>Truy cập theo chỉ số, Insert, RemoveAtCode sửa theo vị trí
List<T>Lớp cụ thể triển khai tất cả những thứ trênLưu trữ

Một phương thức chỉ lặp qua đầu vào nên nhận IEnumerable<T>, để nơi gọi có thể truyền mảng, list, set hay truy vấn LINQ mà không cần chuyển đổi. Một phương thức trả về dữ liệu thường nên trả về thứ gì đó cho biết "dữ liệu này đã nằm trong bộ nhớ", như List<T> hoặc IReadOnlyList<T>, vì giá trị trả về kiểu IEnumerable<T> có thể là một truy vấn phải làm lại việc mỗi lần được đọc.

Đầu ra:

30
15
4.0
30

Viết iterator bằng yield return

Để tạo chuỗi của riêng bạn, hãy viết một phương thức trả về IEnumerable<T> và dùng yield return cho từng phần tử. Trình biên dịch biến phương thức đó thành một lớp triển khai enumerator giúp bạn.

Đầu ra:

0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri 

Không có list nào trong EvenNumbers. Mỗi yield return giao một giá trị cho nơi gọi rồi tạm dừng phương thức, giữ các biến cục bộ (ở đây là i) tồn tại cho đến khi nơi gọi xin giá trị tiếp theo.

Lazy: để ý thứ tự thực thi

Vì phương thức tạm dừng, phần thân của nó và vòng lặp của nơi gọi thay phiên nhau chạy. In ra từ cả hai phía sẽ cho thấy sự đan xen đó:

Đầu ra:

Called Numbers(), nothing printed yet
  start
  yielding 1
Loop got 1
  resumed after 1
  yielding 2
Loop got 2
  resumed after 2
  yielding 3
Loop got 3
  resumed after 3
  end

Gọi Numbers() không in ra "start": lời gọi đó chỉ tạo ra đối tượng enumerable. Phần thân bắt đầu chạy ở lần MoveNext() đầu tiên bên trong foreach, chạy tới yield return đầu tiên rồi dừng. Mỗi bước sau đó chạy tiếp từ dòng ngay sau yield. Where và Select của LINQ cũng hoạt động như vậy (runtime triển khai chúng bằng các lớp enumerator viết tay để chạy nhanh hơn, nhưng hiệu quả giống hệt), đó là lý do truy vấn LINQ được thực thi trễ (deferred).

yield break

yield break kết thúc chuỗi sớm, là phiên bản return của iterator:

Đầu ra:

Subject: Invoice
From: shop@example.com

Bạn không thể trộn return value; với yield return trong cùng một phương thức. Khi một phương thức đã chứa yield, nó là một iterator, và yield break là cách duy nhất để thoát sớm.

Chuỗi vô hạn

Vì chỉ những phần tử được yêu cầu mới được tính, một iterator có thể mô tả một chuỗi không có điểm kết thúc. Bên sử dụng quyết định lấy bao nhiêu:

Đầu ra:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon

while (true) an toàn ở đây vì Take và First ngừng xin thêm khi đã có đủ thứ chúng cần. Ngược lại, gọi ToList() hoặc Count() trên một chuỗi vô hạn sẽ không bao giờ trả về, và duyệt một OrderBy trên chuỗi đó cũng vậy, vì sắp xếp cần có mọi phần tử trước.

Cạm bẫy của chuỗi lazy

Kiểm tra đối số chạy muộn. Toàn bộ phần thân của iterator bị trì hoãn, kể cả phần kiểm tra ở đầu. Cách khắc phục là tách phương thức: một phương thức thông thường kiểm tra đối số rồi trả về kết quả của một iterator private.

Đầu ra:

LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call

Mỗi lần duyệt lại chạy phương thức từ đầu. Iterator không lưu đệm gì cả. Lặp hai lần qua Numbers() sẽ in "start" hai lần, đọc lại tệp hai lần, hoặc gọi web service hai lần. Các IDE cảnh báo điều này là "possible multiple enumeration of IEnumerable". Hãy gọi ToList() một lần khi bạn cần dùng các phần tử nhiều hơn một lần.

finally chạy khi bên sử dụng dừng lại. Nếu nơi gọi thoát khỏi foreach sớm, enumerator được giải phóng và mọi khối finally trong iterator đều chạy, đó là cách một iterator đọc tệp đóng được tệp:

Đầu ra:

open sensor
10
20
30
close sensor
done

Một vài quy tắc trình biên dịch áp đặt lên iterator: yield return không được nằm trong khối try có catch (dùng try/finally thì được), trong lambda hoặc phương thức ẩn danh, hoặc trong phương thức có tham số ref hay out.

Async stream

Với những chuỗi có phần tử đến một cách bất đồng bộ (các trang dữ liệu từ API, các hàng từ cơ sở dữ liệu), C# 8 trở lên (với .NET Core 3.0 trở lên) kết hợp iterator với await thông qua IAsyncEnumerable<T>:

static async IAsyncEnumerable<string> FetchPages()
{
    for (int page = 1; page <= 3; page++)
    {
        await Task.Delay(100);           // simulate a network call
        yield return $"page {page}";
    }
}

await foreach (string p in FetchPages())
{
    Console.WriteLine(p);
}

Bên sử dụng dùng await foreach, và mỗi phần tử được await ngay khi nó được tạo ra.

Lỗi thường gặp

  • Trả về IEnumerable<T> từ một truy vấn rồi đọc nó nhiều lần. Mỗi lần đọc chạy lại truy vấn; hãy trả về List<T> thay vào đó.
  • Kiểm tra đối số bên trong iterator. Bước kiểm tra bị trì hoãn; hãy tách thành một phương thức bọc ngoài và một iterator.
  • Gọi Count() hoặc ToList() trên một iterator vô hạn. Nó không bao giờ kết thúc; hãy Take trước.
  • Mong tham số IEnumerable<T> có Count hoặc [i]. Hãy dùng Count() của LINQ (có thể phải duyệt) hoặc nhận IReadOnlyList<T>.

Câu hỏi thường gặp

IEnumerable trong C# là gì?

IEnumerable<T> là interface cho mọi thứ có thể duyệt bằng foreach. Nó có một phương thức duy nhất, GetEnumerator(), trả về một IEnumerator<T> với MoveNext() và Current. Mảng, list, dictionary, set, truy vấn LINQ và phương thức iterator đều triển khai interface này.

IEnumerable và List trong C# khác nhau thế nào?

IEnumerable<T> chỉ hứa rằng bạn có thể duyệt; nó không có thuộc tính Count, không có indexer, không có Add, và có thể được tính lại một cách lazy mỗi lần bạn lặp. List<T> là một collection cụ thể lưu trong bộ nhớ và có đủ những thứ đó. Hãy nhận IEnumerable<T> ở tham số khi bạn chỉ lặp, và trả về List<T> hoặc IReadOnlyList<T> khi nơi gọi cần truy cập theo chỉ số hoặc đếm.

yield return làm gì trong C#?

yield return value; bên trong một phương thức trả về IEnumerable<T> giao một phần tử cho nơi gọi và tạm dừng phương thức tại đó. Lần tiếp theo nơi gọi xin một phần tử, phương thức chạy tiếp từ dòng kế tiếp. Trình biên dịch viết lại phương thức thành một máy trạng thái (state machine), nên bạn chỉ viết một vòng lặp mà có được một chuỗi lazy.

yield break trong C# là gì?

yield break; kết thúc chuỗi: vòng foreach của nơi gọi kết thúc bình thường sau các phần tử đã được tạo ra. Đây là phiên bản return của iterator và được dùng để dừng sớm, ví dụ khi đạt giới hạn hoặc đầu vào rỗng.

Tại sao phương thức iterator không ném ngoại lệ khi tôi gọi nó?

Phần thân của phương thức iterator chưa chạy cho đến khi chuỗi được duyệt, nên các bước kiểm tra đối số ở đầu phương thức chạy ở lần MoveNext đầu tiên, không phải lúc gọi. Để kiểm tra ngay, hãy đặt các bước kiểm tra trong một phương thức public bình thường, rồi phương thức đó gọi một phương thức iterator private chứa các câu lệnh yield.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu