Series C# hay ho: IEnumerable và yield, tưởng đơn giản mà lắm thứ phải bàn

Khái niệm IEnumerable thì chắc cũng có kha khá người biết, khi ta muốn duyệt tất cả các phần tử trong 1 danh sách, ta thường dùng hàm foreach như sau.

foreach(Student student in students) {}

Các kiểu Collection trong C# như List, ArrayList, Dictionary v…v đều implement interface IEnumerable, do đó ta có thể sử dụng foreach để duyệt.

Khái niệm Yield lại được ít người biết tới hơn (Mình thấy rất ít người biết yield là gì, chưa nói đến việc sử dụng). Yield là một keyword thường hay được dùng với IENumerable. Sử dụng yield sẽ làm code của bạn ngắn gọn, hiệu suất cao hơn rất nhiều. Bài viết này sẽ giải thích cũng như hướng dẫn cách áp dụng từ khóa yield.

1. Nhắc lại về IEnumerable

1 mảng IEnumerable có những thuộc tính sau:

  • Là một mảng read-only, chỉ có thể đọc, không thể thêm hay bớt phần tử.
  • Chỉ duyệt theo một chiều, từ đầu tới cuối mảng.

Hãy xét trường hợp sau, nếu ta muốn đọc 1 danh sách học sinh từ file, ta thường viết

public List<Student> ReadStudentsFromFile(string fileName)
{
  string[] lines = File.ReadAllLines(fileName);
  //Tạo một list trống
  List<Student> result = new List<Student>(); 

  foreach (var line in lines)
  {
    Student student = ParseTextToStudent(line);
    result.Add(student); //Thêm student vào list
  }
  return result; // Trả list ra
}

var students = ReadStudentsFromFile("students.txt");
foreach(var student in students) {};

Đoạn code này không có gì sai. Tuy nhiên ta thấy việc tạo list, thêm phần tử vào list, trả list ra có thể được rút gọn với từ khóa yield như sau

//Đổi kiểu trả về là IEnumerable
public IEnumerable<Student> ReadStudentsFromFile(string fileName)
 {
   string[] lines = File.ReadAllLines(fileName);
   foreach (var line in lines)
   {
      Student student = ParseTextToStudent(line);
      yield return student; //YIELD NÈ
   }
 }

//Dùng như cũ
var students = ReadStudentsFromFile("students.txt");
foreach(var student in students) {};

Bạn sẽ thắc mắc: Ừ, thì rút gọn được 2 dòng code, nhưng mà code có vẻ khó hiểu hơn. Ngày xưa minh cũng nghĩ thế. Ở phần sau, mình sẽ giải thích cơ chế hoạt động của yield, cũng như lý do chúng ta nên dùng yield trong code.

why1

2. Phân biệt return và yield return

Chúng ta đều biết điều cơ bản nhất khi viết 1 method: Từ khóa return sẽ kết thúc method, trả ra kết quả, ko chạy thêm bất kì câu lệnh gì phía sau:

public int GetNumber() { return 5; }
Console.WriteLine(GetNumber());

Thế trong trường hợp này, khi chúng ta yield 3 lần thì sao?

public IEnumerable<int> GetNumber()
 {
   yield return 5;
   yield return 10;
   yield return 15;
 }
foreach (int i in GetNumber()) Console.WriteLine(i);  //5 10 15

Sao lạ vậy ta, tại sao ta lấy được cả 3 kết quả? Ta có thể hiểu luồng chạy của chương trình như sau:

  1. Khi gọi method GetNumber, lấy phần từ đầu tiên, chương trình chạy tới dòng lệnh số 3, lấy ra kết quả là 5, in ra console.
  2. Duyệt tiếp phần từ tiếp theo, chương trình chạy vào dòng lệnh số 4, lấy kết quả 10, in ra màn hình.
  3. Tương tự với phần tử cuối cùng, sau khi in ra, chương trình kết thúc.

Ta hãy quay lại so sánh 2 method đã viết ở đầu chương trình:

public List<Student> ReadStudentsFromFile(string fileName)
{
  string[] lines = File.ReadAllLines(fileName);
  List<Student> result = new List<Student>(); //Tạo một list trống

  foreach (var line in lines)
  {
    Student student = ParseTextToStudent(line);
    result.Add(student); //Thêm student vào list
  }
  return result; // Trả list ra
}

public IEnumerable<Student> YieldReadStudentsFromFile(string fileName)
{
 string[] lines = File.ReadAllLines(fileName);
 foreach (var line in lines)
 {
   Student student = ParseTextToStudent(line);
   yield return student;
 }
}
  • Ở method đầu, ta trả về kết quả sau khi đã chạy hết hàm for, đưa kết quả vào trong 1 list mới, hàm ReadStudentsFromFile kết thúc.
  • Ở method thứ 2, kết quả được ngay sau khi parse được student đầu tiên, với mỗi vòng lặp tiếp theo, chương trình sẽ chạy tiếp vào method YieldReadStudentsFromFile, lấy kết quả ra dần dần.

Sau khi đã hiểu bản chất, ta có thể ứng dụng yield vào những trường hợp sau:

  • Cần method trả về một danh sách read-only, chỉ đọc, không được thêm bớt xóa sửa.
  • Như trường hợp trên, giả sử ta có 50 dòng, hàm ParseTextToStudent tốn 1s 1 lần. Với cách cũ, khi gọi hàm ReadStudentsFromFile, ta phảo đợi 50s. Với hàm YieldReadStudentsFromFile, hàm ParseTextToStudent chỉ được chạy mỗi khi ta đọc thông tin của học sinh, đó đó tăng performance lên rất nhiều (Nếu ta chỉ lấy 5 học sinh đầu chỉ cần đợi 5s).
  • Trong một số trường hợp, danh sách trả về có vô hạn phần tử, hoăc lấy toàn bộ phần tử rất mất thời gian, ta phải sử dụng yield để giải quyết.

Bài viết vừa rồi chỉ hướng dẫn cho bạn khái niệm yield cơ bản. Khi dùng hàm yield, thật ra C# sẽ compile method đã viết lại thành 1 state machine, implement các method Next, Current, … của IEnumrator. Bạn nào muốn tìm hiểu thêm có thể đọc thêm ở đây: http://coding.abel.nu/2011/12/return-ienumerable-with-yield-return/

Yield là một câu hỏi khá khó nhằn, có thể bạn sẽ bị hỏi khi phỏng vấn vào vị trí Senior Developer nhé. Yield cũng là 1 trong “5 anh em siêu nhân” tạo nên sự bá đạo của LINQ (4 người còn lại là: Extension method, Delegate, Lambda expression, Generic). Nếu bạn thường xuyên theo dõi blog, có lẽ bạn đã viết về “5 anh em” này.

pr-2

Ở bài sau, mình sẽ giới thiệu về LINQ, cũng như lật mặt nạ sự bá đạo nằm sau những method đơn giản như Where, First, … của nó.

10 thoughts on “Series C# hay ho: IEnumerable và yield, tưởng đơn giản mà lắm thứ phải bàn”

  1. Ai giải thích giúp e phần này được không ạ?

    “hàm ParseTextToStudent chỉ được chạy mỗi khi ta đọc thông tin của học sinh, đó đó tăng performance lên rất nhiều (Nếu ta chỉ lấy 5 học sinh đầu chỉ cần đợi 5s).”

    theo e hiểu thì nó cũng chạy 50 lần khác gì yield đây ạ?

    Liked by 1 person

  2. Như bác hoàng nói.Bản chất nó sinh ra từ các hàm movenext,current đặc thù viết từ cơ chế,đặc điểm mảng của IENUMERABLE.
    Đó là lý do yield return hay đi với IENUMERABLE.
    Cũng như bác hoàng nói.Tại sao mọi người ít dùng nó.Mình không giỏi chuyên sâu.chỉ đoán mò.Nhưng nếu chỉ đơn thuần viết các lệnh duyệt bình thường hàm foreach các kiểu đáp ứng được.
    Vậy nguyên nhân nào sinh ra nó ?
    Đặc điểm và cũng là điểm mạnh của nó là duyệt đơn độc so với các tiến trình khác của chương trình.Có thể coi đây là một trường hợp nhượng bộ của hệ thống xử lý.Họ viết ra nhằm đảm bảo xử lý một công việc gì đó mà mình không có đủ trải nghiệm để liệt kê phong phú vấn đề này.
    Tuy nhiên đã gặp phải trường hợp kinh điển trong việc viết game trong unity.

    Giả sử một con lính bị bắn tèo.Viết cho nó chết ngay và giải phóng tài nguyên thì dễ.Nhưng vậy thì có gì vui nữa.Nó phải la ó,giẫy chết,báo cáo này nọ để ghi điểm,trạng thái khác …cũng trong lúc đó nó cũng phải được ngắt,thay đổi các trạng thái animation,va chạm ,cùng tỷ thứ khác phải xử lý..vân ơi và vân ơi.Vậy cho nó chết ngay thì các lệnh diễn hoạt.la ó, kia chưa kịp show đã bị xử lý luôn.
    Vâng..nếu chỉ có thế thì cũng có các cách viết khác.Hỡi ôi là trong game các tình huống diễn ra khá là phức tạp,thời gian thực, tuỳ trường hợp,nên không thể nào tạo “bẫy” cái chết của nó diễn ra như ý.Chưa kể rằng thêm dòng code ngớ ngẩn,tốn tài nguyên và hiệu năng xử lý.
    Đặc thù là phải nắm bắt kiểm soát mọi thứ nếu để lọt thì khá là phức tạp.Cũng như trong lập trình,tạm gọi đây là lỗi ” tiềm năng”.

    ĐỂ KHẮC PHỤC VÀ ĐẢM BẢO CHÚNG CHẾT CHẮC VÀ TRĂN TRỐI HỢP LÝ.UNITY HỘ TRỢ CÁC HÀM :
    Destroyaffter,waitforsecon,printafter,các thứ này đều trả lại một mảng kiểu IENUMERABLE.Cơ chế của bọn này là giãn thời gian trước khi thực hiện dòng lệnh kế đó.Và trường hợp này unity cũng dùng yield return để duyệt bọn này .Tức vấn đề này được hệ thống xử lý riêng và không màng tới các hàm khác đang được xử lý nên không ảnh hưởng tới đứa nào hết.
    Cũng trong unity là một hãng thứ ba nên phát sinh vấn đề tương thích .Họ hộ trở tiến trình độc lập của yield bằng cách gọi coroutine bằng hàm Starcoroutine (xxx ()); thay vì được tự động như viết trên visual studio.
    Ở trên chỉ là suy luận cá nhân và tham khảo tài liệu.dưới đây các bạn có thể xem qua mẫu code :

    void OnCollisionEnter(Collision other) // lệnh khi có va chạm
    {
    StartCoroutine(DestroyAfter(1f));
    //khởi động coroutine của destroyafter mà ienumerator quản lý và trả về mảng ienumerator.
    }
    IEnumerator DestroyAfter(float t)
    {
    t= 3f;
    yield return new WaitForSeconds(t); //yield duyệt trả về time 3t
    Destroy(gameObject);// sau đó tới hàm này tiêu diệt object.
    }

    Liked by 3 people

  3. Anh bảo nên dùng yield thay vì foreach khi danh sách có phần tử vô hạn mà mk ko muốn đọc hết, vậy nếu dùng for thì sao? Ta vẫn sẽ kiểm soát được số phần tử cần đọc như yield đúng ko? Vậy yield có ưu điểm gì so với for ko anh?

    Like

  4. chỗ Ứng dụng vào những trường hợp sau em thấy hơi khó hiểu…
    “Với hàm YieldReadStudentsFromFile, hàm ParseTextToStudent chỉ được chạy mỗi khi ta đọc thông tin của học sinh, đó đó tăng performance lên rất nhiều (Nếu ta chỉ lấy 5 học sinh đầu chỉ cần đợi 5s)”
    Ý anh là có những trường hợp ta không lấy hết danh sách mà chỉ lấy một phần, thì nó hiệu quả hơn phải ko ạ?
    Sẵn cho em hỏi thêm, C# hỗ trợ sẵn các class lập trình song song, có phải nó ứng dụng Yield này ko anh?

    Like

Leave a comment