Tìm hiểu về Iterable, Iterator và Generator trong Python

Khi tìm hiểu cách sử dụng các kiểu dữ liệu có nhiều phần tử như array, list, v.v. trong các ngôn ngữ lập trình hiện đại, chúng ta thường gặp các từ khóa như Iterable, Iterator, Enumerator … Dù rằng các khái niệm do các từ khóa này đưa ra không phải là phức tạp lắm, nhưng đôi khi chúng sẽ gây ra “nhức đầu, chóng mặt” cho các lập trình viên mới. Vì vậy, chúng ta sẽ tìm hiểu các khái niệm này một cách chi tiết trong bài viết này.

Chúng ta sẽ bắt đầu với một vấn đề nhập môn: nếu được yêu cầu để lập trình một đoạn mã để duyệt tuần tự qua mọi phần tử trong một tập hợp nhiều phẩn tử, bạn sẽ làm thế nào?

Với Python, đây là công việc rất đơn giản, chúng ta sẽ sử dụng vòng lặp for như sau:

Tiếp theo, chúng ta sẽ có một vấn đề khá thú vị: nếu được yêu cầu để làm công việc như trên mà không sử dụng cấu trúc lặp for, bạn sẽ làm thế nào?

Là một lập trình viên, có lẽ điều đầu tiên chúng ta nghĩ đến là một vòng lặp có điều kiện theo truyền thống tương tự như đoạn mã dưới đây:

Đây là cú pháp bắt nguồn từ ngôn ngữ C. Tuy nhiên, nếu sử dụng cấu trúc lặp này trong Python, chúng ta sẽ khám phá ra rằng nó chỉ hoạt động với một số kiểu dữ liệu nhất định như list (danh sách hoặc mảng), string (chuỗi) nhưng lại không hoạt động được với một số kiểu dữ liệu như là dictionary (từ điển) hoặc set (tập hợp).  Ví dụ như khi dùng cấu trúc lặp này với một set, chúng ta sẽ gặp lỗi như sau:

Theo gợi ý của thông báo lỗi từ đoạn mã trên, vấn đề là do kiểu dữ liệu “set” không hỗ trợ cho index (chỉ mục). Nếu phân tích kỹ hơn, chúng ta sẽ thấy rằng các kiểu dữ liệu có nhiều phẩn tử có sẵn trong Python được phân thành hai nhóm: dữ liệu kiểu tuần tự (sequence) và tập hợp (collection). Về bản chất, các dữ liệu kiểu sequence là các dữ liệu mà các phần tử trong đó được lập chỉ mục hoặc hiểu nôm na là được đánh số thứ tự từ 0 đến n (với n là độ dài của nhóm trừ đi 1). Còn các dữ liệu kiểu tập hợp là các dữ liệu không có chỉ mục. Trong Python, danh sách (list), chuỗi (string) và tuples là các kiểu dữ liệu thuộc nhóm thứ nhất. Còn các kiểu dữ liệu từ điển (dictionary), tập hợp (set) và một số kiểu dữ liệu khác thuộc nhóm thứ hai.

Vậy làm sao chúng ta có thể tạo ra một cấu trúc lặp có thể vượt qua giới hạn này và có thể áp dụng cho cả hai nhóm sequence lẫn collection? Để làm được điều này, chúng ta cần hiểu về cơ chế duyệt tuần tự qua các khái niệm iterable và iterator.

Iterable là gì?

Hiểu một cách đơn giản, một iterable trong Python là một đối tượng cho phép bạn duyệt qua các phần tử của nó với vòng lặp for.

Các đối tượng iterable không cần phải có chỉ mục, không cần phải có độ dài, thậm chí không cần phải hữu hạn. Đặc điểm tương đồng duy nhất của các đối tượng này là chúng có chứa nhiều hơn một phần tử.

Sau đây là một ví dụ về một đối tượng iterable vô hạn có chứa các bội số của 3:

Chúng ta sẽ dùng vòng lặp for để duyệt qua từng phần tử của đối tượng này như sau:

Nếu chúng ta bỏ lệnh break ra khỏi đoạn mã trên, vòng lặp sẽ được thi hành vô hạn.

Như vậy, các đối tượng iterable có thể có chiều dài vô hạn, và hệ quả là không phải lúc nào chúng ta cũng có thể chuyển đổi các đối tượng này về một đối tượng kiểu list (danh sách) hoặc bất kỳ kiểu đối tượng nào thuộc nhóm sequence. Do đó, chúng ta phải tìm cách nào đó để các đối tượng iterable có thể trả về từng phần tử trong chúng tương tự như cách làm việc của vòng lặp for. Và điều này dẫn chúng ta đến khái niệm tiếp theo là iterator.

Iterator

Tham khảo các tài liệu về Python, chúng ta sẽ thấy rằng các đối tượng iterable đều trả về một đối tượng iterator khi chúng ta đưa chúng vào  phương thức iter() có sẵn trong Python như trong ví dụ sau:

Như vậy iterator là gì? Theo định nghĩa từ Python Wiki, một iterator là một đối tượng với một công việc duy nhất là trả về phần tử tiếp theo (“next”) trong các đối tượng iterable hoặc một ngoại lệ StopIteration nếu tất cả các phần tử trong đối tượng iterable đã được duyệt qua.

Chúng ta có thể nhận được một đối tượng iterator từ bất cứ đối tượng iterable nào:

Các đối tượng iterator cũng có thể được truyền vào phương thức next() để trả về phần tử tiếp theo như sau:

Iterator cũng là Iterable

Như vậy, phương thức iter() sẽ trả về một đối tượng iterator khi chúng ta truyền cho nó một đối tượng iterable. Và nếu gọi phương thức next() với một iterator, chúng ta sẽ nhận được phần tử tiếp theo hoặc ngoại lệ StopIteration nếu không còn phần tử nào trong nhóm.

Thật ra vẫn còn một điểm đáng lưu ý: chúng ta có thể truyền một iterator cho phương thức iter() và nhận được kết quả là chính nó! Điều này có nghĩa là bản thân iterator cũng là iterable.

Điều này dẫn đến một số kết quả khá thú vị mà chúng ta sẽ không thảo luận trong bài viết này. Chúng ta sẽ trở lại vấn đề này trong một bài viết tương lai.

Giao thức Iterator

Giao thức iterator thật ra chỉ là một cách gọi hoa mỹ cho một khái niệm đơn giản là cách làm việc của iterable trong Python.

Từ góc độ của Python, chúng ta có thể định nghĩa iterable và iterator theo cách làm việc như sau:

Các đối tượng Iterable:

  • Có thể được truyền cho phương thức iter() để trả về một iterator tương ứng.

Các đối tượng Iterator:

  • Có thể được truyền cho phương thức next() để trả về phần tử tiếp theo trong nhóm hoặc ngoại lệ StopIteration nếu không còn phần tử nào
  • Trả về chính nó khi được truyền cho phương thức iter()

Các định nghĩa này cũng đúng theo chiều ngược lại, có nghĩa là:

  • Bất kỳ một đối tượng nào có thể được truyền cho phương thức iter() mà không gây ra lỗi sẽ là một iterable.
  • Bất kỳ một đối tượng nào có thể được truyền cho phương thức next() mà không gây ra lỗi (trừ ngoại lệ StopIteration) sẽ là một iterator.
  • Bất kỳ một đối tượng nào trả về chính nó khi được truyền cho phương thức iter()cũng là một iterator.

Vòng lặp bằng Iterator

Đến đây, với những gì chúng ta đã biết về các iterable và iterator, chúng ta có thể tạo ra một kiểu vòng lặp tương tự như for nhưng lại không dùng đến vòng lặp for như trong ví dụ dưới đây.

Vòng lặp while dưới đây sẽ duyệt tuần tự các phần tử trong một đố tượng iterable và in ra từng phần tử:

Chúng ta có thể gọi hàm này với bất kỳ đối tượng iterable nào và duyệt qua các phần tử chứa trong nó:

Hàm print_member() trên có tác dụng tương tự như vòng lặp for dưới đây:

Vòng lặp for sẽ tự động thực thi công việc mà chúng ta phải làm theo cách thủ công như trên: gọi phương thức iter() để nhận được đối tượng iterator tương ứng và sau đó tiếp tục gọi phương thức next() cho đến khi gặp ngoại lệ StopIteration.

Giao thức iterator được sử dụng trong vòng lặp for, tuple unpacking (truy cập nhiều giá trị của tuple cùng lúc), list comprehension và các phương thức khác trong các thư viện mặc định của Python có thể được sử dụng với các đối tượng iterable. Sử dụng giao thức iterator là cách tổng quát nhất để duyệt qua bất kỳ đối tượng iterable nào trong Python.

Cách xây dựng đối tượng Iterable

Đến đây, chúng ta đã hiểu khá rõ về iterable. Vậy làm cách nào chúng ta có thể định nghĩa các đối tượng iterable mới ngoài các đối tượng iterable đã có sẵn trong Python?

Để tạo ra một đối tượng iterable, chúng ta sẽ cần thực hiện các công việc sau:

  1. Định nghĩa phương thức iter() trong đối tượng mới của chúng ta. Phương thức này sẽ  trả về chinh đối tượng hiện hành
  2. Tạo ra đối tượng iterator tương ứng với đối tượng iterable mới của chúng ta. Đối tượng iterator này sẽ phải override phương thức next() để trả về phần tử tiếp theo trong đối tượng iterable.

Ví dụ như chúng ta có một lớp ClassRoom được định nghĩa như sau:

classroom.py: Module cho một lớp iterable mới

Trong lớp ClassRoom, chúng ta sẽ sử dụng một list nội bộ là _students để lưu dữ liệu của các học sinh. Chúng ta cũng định nghĩa phương thức add_student() để thêm học sinh mới vào danh sách các học sinh trong lớp. Nhưng quan trọng nhất là phương thức __iter__() để trả về đối tượng Iterator tương ứng cho lớp này được gọi là ClassRoomIterator mà chúng ta sẽ định nghĩa dưới đây.

Tiếp theo, chúng ta cần định nghĩa lớp ClassRoomIterator:

classroomIterator.py: Module cho iterator của lớp ClassRoom

Trong lớp ClassRoomIterator, chúng ta định nghĩa constructor (hàm dựng) __init__() và sử dụng biến nội bộ _classroom để tham chiếu đến đối tượng iterable tương ứng là ClassRoom. Lớp này còn có một biến nội bộ khác là _current có tác dụng làm chỉ mục cho danh sách học sinh trong lớp ClassRoom. Và theo đúng định nghĩa của iterator, chúng ta phải khai báo phương thức __next__() cho nó để trả về dữ liệu của học sinh kế tiếp trong danh sách hoặc ngoại lệ StopIteration nếu đã duyệt qua toàn bộ các học sinh trong danh sách học sinh của lớp.

Đến đây, chúng ta có thể kiểm tra hoạt động của lớp iterable mới và iterator tương ứng với Python shell:

Kết quả đúng như chúng ta mong đợi: vòng lặp for sẽ duyệt qua từng phần tử trong đối tượng ClassRoom và in ra tên của từng phần tử đó (cũng là tên của các học sinh trong lớp) theo đúng định nghĩa của iterable.

Generator là gì?

Như vậy chúng ta đã hiểu thấu đáo về iterable, iterator và cách làm việc của vòng lặp for và có thể yên tâm khi sử dụng các đối tượng này rồi phải không? Thật không may là vẫn còn một chủ đề nữa cần được thảo luận trước khi chúng ta có thể hoàn tất phần này. Bây giờ bạn hãy thử hình dung điều gì sẽ xảy ra nếu bạn sử dụng vòng lặp for để đọc một file văn bản theo định dạng csv vào một danh sách như trong ví dụ sau:

Đọc file csv theo từng dòng

Với phương thức csv_reader được định nghĩa như sau:

Phương thức csv_reader()

Phương thức này sẽ đọc file và trả về một danh sách với mỗi phần tử là một dòng trong file. Nó sẽ làm việc tốt với các file nhỏ. Tuy nhiên, khi thi hành đoạn mã này với một file có kích thước rất lớn, bạn sẽ gặp kết quả như sau:

Điều gì đã xảy ra? Rất đơn giản, khi chúng ta mở file bằng phương thức open(file_name), phương thức này sẽ trả về một đối tượng generator để chúng ta có thể đọc từng dòng một trong file. Tuy nhiên, khi file.read().split() được gọi, nó sẽ đọc tất cả nội dung của file cùng lúc để đưa vào danh sách result dẫn đến lỗi không đủ bộ nhớ.

Để giải quyết vấn đề này, Python cung cấp một giải pháp khá đơn giản, chúng ta chỉ cần sửa đổi lại phương thức csv_reader() như sau:

Với thay đổi này, chúng ta mở file, lần lượt đọc các dòng trong file, và mỗi khi đọc một dòng, chúng ta “nhường lại” (yield) dòng đó. Khi chạy đoạn mã mới này, chúng ta sẽ nhận được kết quả tương tự như sau và không có lỗi phát sinh:

Như vậy chính xác là chúng ta đã làm gì với thay đổi trên? Chúng ta vừa chuyển đổi hàm csv_reader() thành một hàm generator (generator function).  Phiên bản mới của hàm sẽ mở file, đi tuần tự qua các dòng trong file và trả về mỗi lần một dòng thay vì tất cả các dòng cùng lúc.

Hàm generator được giới thiệu từ PEP 255. Các hàm thuộc loại này có tác dụng như các iterator nhưng có thể được xây dựng với cú pháp đơn giản hơn nhiều so với các iterator. Và cũng như các iterator, các giá trị do hàm generator trả về sẽ được đánh giá theo phương pháp đánh giá trì hoãn (lazy evaluation). Lazy evaluation là một kỹ thuật cho phép các giá trị trả về của các hàm chỉ được tạo ra khi chúng được sử dụng đến chứ không phải ở thời điểm hàm được gọi. Một lợi thế của kỹ thuật này là các iterator chỉ trả về mỗi lần một phần tử và không lưu các phần tử này vào bộ nhớ trong quá trình duyệt. Điều này cho phép các iterator duyệt qua các nhóm phần tử có số lượng rất lớn mà không bị ràng buộc về giới hạn bộ nhớ. Với Python, phương pháp này cho phép các iterator hoạt động được ngay cả với các nhóm phần tử có độ dài không giới hạn.

Các phương pháp tạo ra generator

Để tạo ra các generator, chúng ta có hai phương pháp:

  • Sử dụng hàm generator: Để tạo ra một hàm generator, chúng ta chỉ cần thay thế từ khóa return của hàm đó bằng từ khóa yield như ví dụ ở phần trên. Ví dụ như hàm reverse_str() để trả về một chuỗi theo thức tư đảo ngược trong ví dụ dưới đây:
  • Sử dụng biểu thức generator (generator expression): các biểu thức generator được giới thiệu từ PEP 289. Các biểu thức này sẽ tạo ra các hàm generator ẩn danh (anonymous generator function) – là các hàm được sử dụng mà không cần định nghĩa trước – tương tự như các hàm ẩn danh được tạo ra từ các biểu thức lambda. Cú pháp của các biểu thức generator cũng gần giống như cú pháp của list comprehension trong  Python nhưng sử dụng dấu ngoặc tròn (()) thay vì dấu ngoặc vuông ([]) như trong ví dụ sau:

Lợi ích của việc sử dụng generator

  • Dễ xây dựng: so với các iterator, mã để tạo ra các generator một cách đơn giản và chính xác hơn dù có cùng mục đích. Ví dụ như mã để tạo ra một dãy các số lũy thừa của 2 như trong ví dụ sau:

So với iterator thì mã cho generator ngắn gọn và dễ hiểu hơn nhiều phải không?

  • Sử dụng bộ nhớ hiệu quả hơn: Một hàm thông thường để trả về một dãy (sequence) sẽ tạo ra toàn bộ các phần tử của dãy trong bộ nhớ trước khi trả về kết quả. Điều này sẽ gây ra lỗi bộ nhớ nếu dãy quá lớn. Generator cho phép các hàm làm việc hiệu quả hơn với các dãy có kích thước lớn vì nó chỉ tạo ra một phần tử tại một thời điểm.
  • Tạo ra các dãy không giới hạn: Hơn thế nữa, bởi vì bản chất của chúng, các generator có thể được dùng để tạo ra các dãy không giới hạn. Bởi vì các dãy không giới hạn không thể lưu trong bộ nhớ, generator là cách thích hợp nhất để tạo ra các dãy này như trong ví dụ sau:
  • Kết nối các generator: các generator có thể được kết nối với nhau như trong ví dụ dưới đây:

Giả sử chúng ta có một log file ghi nhận dữ liệu từ một chuỗi cửa hàng fastfood nổi tiếng. Mỗi hàng trong log file này được phân thành nhiều cột. Trong đó cột thứ sáu là số hamburger bán ra mỗi giờ. Chúng ta muốn tìm ra tổng số bánh bán ra trong toàn bộ file.

Giả sử dữ liệu trong file được ghi nhận dưới dạng chuỗi và các số liệu không được nhập vào sẽ được đánh dấu bằng chuỗi ‘N/A’, chúng ta có thể xây dựng hàm tính tổng số bánh bằng cách kết hợp hai generator hamburger_colper_hour như sau:

Các khái niệm tương đương trong Java và .NET

Java có hỗ trợ cho Iterable với interface Iterable<T>. Iterator trong Java được hỗ trợ qua framework Collection với ba kiểu iterator là Enumeration, Iterator và ListIterator. Tuy nhiên, Java không có khái niệm generator như Python và từ khóa yield của Java được sử dụng cho mục đích hoàn toàn khác (chỉ sử dụng cho các ứng dụng multithreaded).

.NET cho phép định nghĩa một lớp tập hợp các phần tử (collection class) là iterable khi lớp đó được tạo ra với các interface IEnumerableIEnumerable<T> và overload phương thức GetEnumerator() của các interface này. Trong .NET, chúng ta cũng có từ khóa yield với tác dụng tương tự như Python, nhưng được sử dụng với cú pháp yield return thay vì chỉ là yield như trong Python.

Trong khuôn khổ bài viết này, chúng ta sẽ không đi sâu vào chi tiết của các đối tượng này trong các ngôn ngữ khác.

Kết luận

Hy vọng bài viết này sẽ giải đáp các câu hỏi của bạn về các khái niệm Iterable, Iterator và Generator và cách hoạt động của chúng trong Python nói riêng và một số ngôn ngữ lập trình khác nói chung.

2 thoughts on “Tìm hiểu về Iterable, Iterator và Generator trong Python

    1. Rất vui khi bài viết này giúp ích cho bạn. Đây cũng là mục đích của tôi. Chắc chắn tôi sẽ có những bài viết khác nữa.

Leave a Reply

Your email address will not be published. Required fields are marked *