Menu

Iterator trong C++: begin, end và các loại iterator

Cách iterator trong C++ hoạt động như con trỏ tổng quát vào container: begin() và end(), dereference, tiến tới, các biến thể const và reverse, cùng các bẫy invalidation và dereference end() gây undefined behavior.

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.

Iterator thực chất là gì

Mỗi container chuẩn, như vector, string, map, set, list, lưu phần tử bên trong theo một cách khác nhau. vector là một khối liên tục, map là một cây cân bằng, list là các nút liên kết. Vậy mà bạn vẫn duyệt qua tất cả chúng theo cùng một cách. Thứ làm được điều đó là iterator: một đối tượng nhỏ "trỏ vào" một phần tử và biết cách bước sang phần tử kế tiếp.

Hãy coi iterator như một con trỏ tổng quát. Bạn lấy nó từ begin(), đọc phần tử nó trỏ tới bằng *, và đưa nó tiến lên bằng ++. Các mảnh ghép khớp với nhau như sau:

v.begin() trả về iterator trỏ tới phần tử đầu tiên; *it cho bạn phần tử đó; ++it chuyển sang phần tử kế tiếp. Bộ ba dereference, tiến tới, so sánh chính là toàn bộ mô hình tư duy.

begin(), end() và khoảng nửa mở

Nửa còn lại của bức tranh là end(). Điều quan trọng là end() không trỏ vào phần tử cuối cùng: nó trỏ vào ô ngay sau phần tử cuối. Đây là khoảng "nửa mở" [begin, end) được thiết kế có chủ đích: begin được tính vào, còn end là tín hiệu dừng.

Thiết kế này giúp vòng lặp chuẩn rất gọn: bạn đi tiếp cho đến khi iterator bằng end():

Chú ý là it != v.end(), không phải it < v.end(). Iterator của hầu hết container (như map hay list) không hỗ trợ <, chỉ hỗ trợ == và !=, nên != là lựa chọn dùng được ở mọi nơi. Còn auto giúp bạn khỏi phải tự viết vector<int>::iterator: trình biên dịch tự suy ra kiểu.

Trường hợp container rỗng được xử lý một cách tự nhiên: khi container rỗng, begin() == end(), nên thân vòng lặp không bao giờ chạy. Không cần xử lý riêng.

Đừng bao giờ dereference end()

Bug iterator phổ biến nhất là dereference end(). Vì nó trỏ vào vị trí sau phần tử cuối, *v.end() đọc vùng nhớ không thuộc về bạn: đó là undefined behavior, nghĩa là chương trình crash hoặc âm thầm cho ra giá trị rác, chứ không có thông báo lỗi thân thiện nào:

vector<int> v = {1, 2, 3};
cout << *v.end();   // UNDEFINED BEHAVIOR - end() is not an element

Cái bẫy tương tự cũng xảy ra với các hàm tìm kiếm. std::find trả về end() khi không tìm thấy giá trị, nên bạn phải kiểm tra trước khi dereference:

Hãy luôn so sánh iterator trả về với end() trước khi dereference. Quên câu if này là một trong những nguyên nhân crash thường gặp nhất trong code STL của người mới.

const, cbegin và reverse iterator

Container cung cấp nhiều loại iterator khác nhau tùy vào nhu cầu của bạn:

  • begin() / end(): iterator đọc/ghi thông thường (*it = ... dùng được).
  • cbegin() / cend(): các const_iterator; bạn đọc được qua chúng nhưng không sửa được phần tử.
  • rbegin() / rend(): reverse iterator đi từ cuối về đầu; ++ thực ra là lùi lại.

Reverse iterator là cách gọn gàng để duyệt ngược mà không phải tính toán chỉ số rườm rà:

Với reverse iterator, bạn vẫn viết ++it để đi tiếp: iterator tự xử lý chiều "ngược" bên trong. Hãy dùng cbegin()/cend() (hoặc một tham chiếu const tới container) khi vòng lặp chỉ cần đọc, để trình biên dịch ngăn bạn vô tình ghi vào.

Iterator của map trả về pair

Không phải iterator nào cũng chỉ là lớp vỏ mỏng bọc quanh con trỏ. Iterator của std::map đi qua một cây, và dereference nó cho bạn một std::pair gồm khóa và giá trị, truy cập qua ->first và ->second (giống con trỏ, iterator cũng hỗ trợ ->):

Vòng lặp range-based for được xây dựng trực tiếp trên begin()/end(), nên với việc duyệt xuôi đơn giản, bạn thường sẽ dùng nó. Iterator tường minh phát huy giá trị khi bạn cần duyệt ngược, cần vị trí của một phần tử, hoặc cần truyền một khoảng vào thuật toán.

Bẫy lớn: iterator invalidation

Đây là cái bẫy mà sớm muộn ai cũng dính. Khi bạn thay đổi cấu trúc của container, các iterator đang có có thể bị mất hiệu lực (invalidated): chúng trỏ vào vùng nhớ đã bị giải phóng hoặc di chuyển. Dùng một iterator như vậy là undefined behavior.

Với vector, push_back có thể cấp phát lại toàn bộ bộ đệm để mở rộng, làm mọi iterator đang có mất hiệu lực. Xóa phần tử trong lúc duyệt còn khét tiếng hơn, đây là một kiểu crash kinh điển:

vector<int> v = {1, 2, 3, 4};
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it % 2 == 0)
        v.erase(it);   // BUG - erase invalidates it, then ++it is UB
}

Cách sửa là dùng giá trị mà erase trả về: một iterator hợp lệ trỏ tới phần tử sau phần tử vừa bị xóa. Chỉ tiến tới khi bạn không xóa gì:

Để ý phần đầu vòng for không có ++it: thân vòng lặp quyết định có tiến tới hay không. (Trong code thực tế, idiom erase-remove hoặc std::erase_if của C++20 làm việc này chỉ trong một dòng.) Quy tắc cần nhớ: mọi thao tác thêm hoặc xóa phần tử đều có thể làm iterator mất hiệu lực, nên đừng giữ một iterator cũ qua một thay đổi như vậy.

Tiếp theo: thuật toán

Giờ bạn đã mô tả được một khoảng bằng cặp begin/end, bạn đã mở khóa toàn bộ thư viện thuật toán của STL. Các hàm như sort, find, count và accumulate không quan tâm bạn dùng container nào: chúng làm việc trên các khoảng iterator, nên cùng một lời gọi chạy được trên vector, mảng, hay một phần của chúng. Tiếp theo, chúng ta sẽ đưa các iterator này vào việc và để thư viện chuẩn lo phần vòng lặp cho bạn.

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

Iterator trong C++ là gì?

Iterator là một đối tượng trỏ vào một phần tử bên trong container và biết cách chuyển sang phần tử kế tiếp. Bạn lấy iterator đầu tiên bằng container.begin() và một mốc nằm sau phần tử cuối bằng container.end(). Dereference bằng *it để đọc hoặc ghi phần tử, và tiến tới bằng ++it. Iterator là giao diện chung giúp các thuật toán STL làm việc được với mọi container.

Iterator và con trỏ trong C++ khác nhau thế nào?

Với một vector hay mảng, iterator hoạt động gần như y hệt con trỏ: dereference bằng *, tiến tới bằng ++, so sánh bằng ==/!=. Nhưng iterator là một khái niệm, không nhất thiết là con trỏ thô: iterator của map hay list đi qua một cây hoặc các nút liên kết, nên nó là một kiểu lớp có nạp chồng * và ++. Con trỏ là một loại iterator; iterator tổng quát hóa ý tưởng đó cho mọi container.

Điều gì gây ra iterator invalidation trong C++?

Thay đổi cấu trúc của container có thể khiến các iterator đang có trỏ vào vùng nhớ đã bị giải phóng hoặc di chuyển. Với vector, push_back có thể cấp phát lại và làm mọi iterator mất hiệu lực; erase làm mất hiệu lực các iterator tại và sau phần tử bị xóa. Dùng một iterator đã mất hiệu lực là undefined behavior. Để an toàn, hãy dùng iterator mà erase trả về, hoặc dành sẵn dung lượng từ đầu.

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