Menu

pair và tuple trong C++: gom giá trị không cần struct

Cách std::pair và std::tuple gom hai hay nhiều giá trị vào một đối tượng: cách tạo, cách truy cập từng thành phần, structured binding và khi nào nên dùng loại nào.

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.

Hai giá trị, một đối tượng

Đôi khi bạn cần giữ hai thứ đi cùng nhau: một cái tên và một điểm số, một x và một y, một cờ "có thành công không" và kết quả. Bạn có thể định nghĩa một struct cho mỗi cặp như vậy, nhưng với những nhóm dùng một lần thì quá rườm rà. std::pair (trong <utility>) gom đúng hai giá trị vào một đối tượng, còn std::tuple (trong <tuple>) mở rộng ý tưởng đó cho một số lượng giá trị cố định bất kỳ.

Thật ra bạn đã gặp std::pair một cách gián tiếp trước đó: mỗi phần tử của std::map là một pair<const Key, Value>. Trang này nói rõ điều đó và chỉ ra những cách hiện đại, dễ đọc để tạo và tách các kiểu này.

Hai thành viên luôn có tên là .first và .second, chúng không mang tên biến của bạn. Đó là cái giá của một kiểu tổng quát: tên trường chỉ nói vị trí, không nói ý nghĩa.

Tạo pair

Có ba cách phổ biến để tạo một pair, và cả ba đều cho ra cùng một đối tượng.

make_pair tự suy ra kiểu của các phần tử, rất tiện trước C++17. Ngày nay khởi tạo bằng ngoặc nhọn cùng cơ chế suy luận đối số template của lớp (pair p{"Boris", 85};) đã đủ cho hầu hết trường hợp, nhưng bạn vẫn sẽ thấy make_pair ở khắp nơi trong code có sẵn.

Một cái bẫy với suy luận kiểu: make_pair("hi", 3) suy ra pair<const char*, int>, không phải pair<string, int>. Chuỗi ký tự literal không phải là std::string. Nếu bạn cần string, hãy viết rõ ra, như make_pair(string("hi"), 3) hoặc ghi đầy đủ kiểu pair, nếu không bạn có thể gặp những phép so sánh hay sao chép bất ngờ về sau.

Tách giá trị bằng structured binding

Đọc .first và .second ở khắp nơi sẽ nhanh chóng khó hiểu, vì các tên đó chẳng nói lên điều gì. Structured binding của C++17 cho phép bạn đặt tên thật cho hai trường chỉ trong một dòng:

Cách này phát huy tác dụng trong for dạng range duyệt qua một map, nơi mỗi phần tử là một pair. Thay vì it->first / it->second, bạn đặt tên trực tiếp cho khóa và giá trị:

Hãy dùng const auto& trong vòng lặp, như với bất kỳ phần tử container nào: nó tránh sao chép từng pair và cho thấy bạn chỉ đọc. Bỏ & đi là bạn sao chép mọi phần tử; đó là một bug hiệu năng âm thầm với một map lớn.

Khi hai là chưa đủ: tuple

pair chỉ dừng ở hai giá trị. Khi cần từ ba trở lên, std::tuple là cùng ý tưởng đó với số lượng tùy ý. Bạn tạo tuple bằng ngoặc nhọn hoặc make_tuple, và đọc nó bằng std::get<N>, trong đó N là chỉ số biết trước lúc biên dịch.

Chỉ số trong get<> phải là hằng số biết trước lúc biên dịch. get<i>(record) với i là biến lúc chạy sẽ không biên dịch được: các trường của tuple có thể khác kiểu nhau, nên kiểu của phần tử phải được xác định trong lúc biên dịch, không phải lúc chạy. Nếu bạn thấy mình muốn dùng chỉ số lúc chạy, có lẽ thứ bạn cần là một vector.

Structured binding cũng dùng được với tuple, và đây là cách dễ đọc để lấy dữ liệu ra:

Trả về nhiều giá trị

Lý do thường ngày để dùng các kiểu này là trả về nhiều hơn một giá trị từ một hàm mà không phải tạo struct mới hay xoay xở với tham số đầu ra. Gom kết quả vào một pair hoặc tuple rồi tách ra tại chỗ gọi.

Với ba kết quả trở lên, trả về tuple theo cùng cách. Ngoài ra còn có std::tie, một mẹo cũ hơn để tách vào các biến đã có sẵn thay vì khai báo biến mới, hữu ích khi bạn muốn bỏ qua một trường bằng std::ignore:

Nhưng nên dừng ở đâu: nếu cùng một nhóm trường xuất hiện ở nhiều nơi, hoặc bạn cứ quên .second là điểm số hay số lượng, hãy định nghĩa một struct với các thành viên có tên. pair và tuple hợp nhất với những nhóm cục bộ, sống ngắn; trường có tên sẽ thắng ngay khi dữ liệu sống lâu hơn một biểu thức.

So sánh và sắp xếp

Một điểm cộng tiện lợi: pair và tuple có sẵn các toán tử so sánh hoạt động theo thứ tự từ điển: chúng so sánh phần tử đầu tiên, và chỉ xét đến phần tử tiếp theo khi các phần tử đầu bằng nhau. Nhờ vậy chúng là khóa lý tưởng để sắp xếp.

Để ý rằng thứ tự các trường rất quan trọng: đặt age lên đầu thì sắp xếp chủ yếu theo tuổi, rồi mới theo tên khi tuổi bằng nhau. Nếu muốn sắp theo tên trước, bạn chỉ cần đổi thứ tự phần tử trong tuple. Cách so sánh mặc định này chính là lý do pair<priority, item> là một cách viết quen thuộc cho hàng đợi ưu tiên.

Tiếp theo: iterator

Bạn đã thấy .first, .second, it->first và *it xuất hiện quanh các container; thứ thực sự nối một phần tử pair với map chứa nó là một iterator. Trang tiếp theo sẽ giải thích iterator cặn kẽ: begin() và end() thực sự trả về gì, ++it duyệt qua container như thế nào, và những cái bẫy iterator mất hiệu lực gây ra một số hành vi không xác định khó chịu nhất trong C++.

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

pair và tuple trong C++ khác nhau thế nào?

std::pair chứa đúng hai giá trị, truy cập bằng .first và .second. std::tuple chứa một số lượng giá trị cố định bất kỳ (không, hai, ba hoặc nhiều hơn), truy cập bằng std::get<N>(t). Về bản chất, pair là một tuple hai phần tử với tên thành viên dễ đọc hơn; chỉ dùng tuple khi bạn cần từ ba trường trở lên.

Làm sao truy cập phần tử của tuple trong C++?

Dùng std::get<N>(t) với chỉ số biết trước lúc biên dịch, ví dụ std::get<0>(t). Từ C++17 bạn còn có thể tách tuple bằng structured binding: auto [a, b, c] = t; cho mỗi phần tử một biến có tên riêng. Bạn không thể truy cập tuple bằng một biến chỉ số lúc chạy, vì std::get<i> cần i là hằng.

Làm sao trả về nhiều giá trị từ một hàm trong C++?

Trả về std::pair hoặc std::tuple rồi tách ra ngay tại chỗ gọi bằng structured binding: auto [ok, value] = parse(text);. Cách này gọn hơn tham số đầu ra và không phải định nghĩa một struct dùng một lần, dù struct có tên vẫn dễ đọc hơn khi các trường được dùng lâu hơn một lần gọi.

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