Có một niềm tin đã lỗi thời mà nhiều lập trình viên C++ (và người học từ tài liệu cũ) vẫn giữ: "trả về hay gán một object lớn thì tốn kém, vì phải sao chép cả nó". Đúng — trước C++11. Nhưng từ C++11, có move semantics (ngữ nghĩa di chuyển): thay vì sao chép dữ liệu, bạn chuyển sở hữu — lấy con trỏ buffer từ nguồn sang đích, không đụng tới dữ liệu. Tôi đo copy so với move trong container gcc:13, và con số cho thấy khác biệt không phải vài phần trăm mà hàng chục nghìn lần với container lớn.

Copy vs move: chi phí sao chép

Nhân đôi buffer O(n) so với chuyển con trỏ O(1)

Khi gán một object có buffer động (vector, string, map…), có hai cơ chế:

  • Copy (sao chép sâu): cấp một buffer mới và sao chép từng phần tử sang. Nhân đôi dữ liệu = O(n) thời gian + O(n) RAM. Với một vector 40 MB, copy phải malloc 40 MB rồi memcpy 40 MB.
  • Move (di chuyển, C++11): không sao chép dữ liệu. Chỉ chuyển con trỏ buffer từ nguồn sang đích (steal), rồi để nguồn rỗng. Vài phép gán con trỏ = O(1), bất kể buffer lớn cỡ nào.

Move xảy ra tự động trong nhiều trường hợp: trả giá trị từ hàm (RVO hoặc move ngầm), gán từ một tạm thời, hoặc khi bạn viết std::move(x) tường minh để báo "tôi không cần x nữa, cứ lấy ruột của nó". Đây chính là lý do "trả về object lớn tốn kém" là lầm tưởng thời tiền-C++11: giờ return một vector lớn chuyển sở hữu buffer trong O(1), không sao chép.

Đo: 42 nghìn lần nhanh hơn với vector 40 MB

g++ -O2:

A. vector<int> 10 triệu phần tử (~40 MB):
   COPY : 1787,5 µs   (nhân đôi buffer O(n) + malloc 40 MB + memcpy)
   MOVE : 0,042 µs    (chuyển con trỏ O(1), KHÔNG sao chép dữ liệu)
   -> move nhanh hơn ~42.554 lần

C. push_back 5 triệu string DÀI (50 ký tự, trên heap):
   push_back(s)            COPY : 25,9 ns/phần tử   (mỗi cái malloc + copy)
   push_back(std::move(s)) MOVE : 12,9 ns/phần tử   (steal con trỏ, không copy buffer)
   -> move ~2 lần nhanh

Nhìn A: sao chép một vector 40 MB mất 1787,5 micro-giây — vì nó phải cấp một buffer 40 MB mới và copy toàn bộ 40 MB. Còn move cùng vector đó chỉ mất 0,042 micro-giây — nó chỉ chuyển con trỏ buffer, kích thước, capacity (vài phép gán), rồi để vector nguồn rỗng. Move nhanh hơn ~42.554 lần, và tỷ số này tăng theo kích thước container — vì copy là O(n) còn move là O(1). Với một object 400 MB, move vẫn 0,042 µs, còn copy sẽ mất ~18 mili-giây.

C cho thấy move trong thao tác thường ngày: khi push_back một string dài vào vector, push_back(s) sao chép string (một malloc + copy buffer cho phần tử mới), còn push_back(std::move(s)) chuyển buffer của s sang phần tử mới (steal con trỏ, không copy). Đo được 25,9 ns so với 12,9 ns — move nhanh ~2 lần (tỷ số vừa phải vì trong cả hai, s vẫn được tạo một lần; chỉ bước push khác nhau). Với string dài hơn hoặc object phức tạp hơn, khoảng cách còn lớn hơn.

Một lần tôi đo hớ: "trả về object lớn luôn tốn kém" và "move như copy"

Tôi vào đo với niềm tin cũ: "trả về hay gán một vector/string lớn thì đắt, nên truyền con trỏ/tham chiếu để tránh copy". Đo cho thấy từ C++11 điều đó sai: move chuyển sở hữu buffer trong O(1) — trả về một vector 40 MB không tốn 1787 µs như copy mà chỉ 0,042 µs. Việc né trả-về-theo-giá-trị để "tránh copy" giờ thường là tối ưu vô ích, thậm chí làm code khó đọc hơn. Còn niềm tin ngược "move và copy như nhau, đằng nào cũng gán" cũng sai: copy nhân đôi cả buffer (O(n)), move chuyển con trỏ (O(1)) — chênh hàng nghìn lần với container lớn. Nhầm hai cái là bỏ lỡ một tối ưu khổng lồ.

Bài học đo lường: từ C++11, MOVE chuyển sở hữu buffer trong O(1) (chỉ vài phép gán con trỏ, KHÔNG sao chép dữ liệu) trong khi COPY nhân đôi cả buffer O(n) — đo vector 40 MB: copy 1787,5 µs vs move 0,042 µs = nhanh ~42.554x, và tỷ số tăng theo kích thước; push_back(std::move(x)) tránh sao chép (2x). return/gán từ tạm thời tự động move; dùng std::move tường minh khi không cần nguồn nữa. Nếu tôi tin "trả về object lớn luôn đắt" tôi phức tạp hóa code để né copy không tồn tại; nếu tôi tin "move như copy" tôi bỏ lỡ một tối ưu O(1) vs O(n) khổng lồ.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: trả về object lớn theo giá trị là ổn — trình biên dịch move/RVO giùm. Đừng viết hàm trả void và nhận tham chiếu ra chỉ để "tránh copy" — trả thẳng std::vector<T> rõ ràng hơn và không chậm hơn nhờ move/RVO. Đây là một trong những thay đổi lớn nhất mà C++11 mang lại cho phong cách viết code: giá trị lớn không còn là gánh nặng.

Hệ quả thứ hai: dùng std::move khi bạn không cần nguồn nữa. Khi thêm một object vào container (push_back, emplace_back, insert), hoặc chuyển một object đi mà không dùng lại, std::move(x) báo "lấy ruột của x" — tránh một lần copy sâu. Cùng vậy khi hoán đổi, chuyển sở hữu tài nguyên (unique_ptr chỉ move được, không copy). Nhưng nhớ: sau std::move(x), x ở trạng thái rỗng/hợp lệ-nhưng-không-xác-định — đừng dùng lại giá trị của nó.

Hệ quả thứ ba là tinh thần đo lường: "gán/trả về là tốn kém" phụ thuộc cơ chế, và move đổi O(n) thành O(1) — một sự thật ngôn ngữ mà trực giác cũ bỏ lỡ. Con số mang theo: COPY nhân đôi buffer O(n) (vector 40 MB: malloc + memcpy 40 MB = 1787,5 µs) vs MOVE chuyển con trỏ O(1) (0,042 µs = ~42.554x nhanh, tỷ số tăng theo kích thước container); push_back(std::move(x)) tránh copy sâu (~2x). return theo giá trị và gán từ tạm thời tự động move (C++11); dùng std::move khi bỏ nguồn. Sau move, nguồn rỗng — không dùng lại. Cùng kết quả, hai cơ chế, chênh nghìn lần.

Thử ba mươi giây

Tạo một vector lớn (vài chục triệu int), rồi bấm giờ hai việc: gán auto b = a; (copy) và auto b = std::move(a); (move). Copy mất mili-giây (nhân đôi cả buffer), move gần như tức thời (chuyển con trỏ) — chênh hàng chục nghìn lần. Rồi in a.size() sau khi move: bạn sẽ thấy a rỗng, vì ruột của nó đã bị lấy đi. Cuối cùng, thử push_back(s) so với push_back(std::move(s)) cho một string dài vài triệu lần: bản move nhanh hơn vì không copy buffer chuỗi. Ba mươi giây đó cho bạn thấy điều mà "trả về object lớn thì tốn kém" giấu đi: từ C++11, di chuyển một object khổng lồ chỉ là chuyển một con trỏ — O(1) bất kể nó lớn cỡ nào — và biết khi nào dùng std::move biến những phép gán tưởng đắt thành gần như miễn phí.