Bạn chuyển nhà. Có hai cách "giao căn nhà" cho người mới. Cách thứ nhất: xây một căn y hệt ở địa chỉ mới, khuân từng viên gạch, từng món đồ sang — mệt và lâu tỉ lệ với căn nhà to cỡ nào. Cách thứ hai: đưa cho họ chùm chìa khóa, thế là xong — căn nhà cũ giờ là của họ, bạn không giữ gì nữa. Cả hai đều khiến người mới "có nhà", nhưng một cách mất hàng giờ còn cách kia mất vài giây. Trong C++, cách thứ nhất là copy, cách thứ hai là move — và khác biệt về tốc độ cũng cách nhau đúng cỡ đó.

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.

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. Đây là "xây lại cả căn nhà".
  • 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. Đây là "trao chùm chìa khóa".

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. Đúng cái logic chuyển nhà: trao chìa khóa mất chừng đó thời gian dù nhà to hay nhỏ, còn xây lại thì càng to càng lâu.

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ôi trường đo: container gcc:13 trên host ARM AArch64, g++ -O2, mỗi số lấy MIN sau warmup. Con số tuyệt đối đổi theo máy; cái cần nhìn là move O(1) không đổi theo kích thước, copy O(n) tăng theo — nên tỷ số càng lớn với container càng to.)

Hai niềm tin đối nghịch, cùng sai

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". Con số 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.

Nhưng niềm tin ngược lại — "move với copy thì cũng như nhau, đằng nào chả là một phép gán" — cũng sai, và sai ở đúng chỗ tốn kém nhất. 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ồ, hoặc tệ hơn, vô tình copy ở đúng đường nóng mà tưởng mình đang move.

Về đến code hằng ngày

Điều đầ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.

Điều 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ó. Trao chìa khóa xong thì đừng tưởng mình còn ở căn nhà cũ.

Nếu bạn viết Java, Go hay Python thì ý tưởng "trao chìa khóa" này quen đến mức bạn làm nó mặc định mà không biết. Ở các ngôn ngữ đó, biến của một object luôn là một tham chiếu: gán b = a hay return a chỉ chép một con trỏ, không bao giờ nhân đôi cả object — nghĩa là bạn luôn "move giá rẻ", nhưng đổi lại nhiều object cùng trỏ vào một dữ liệu và phải cẩn thận chuyện chia sẻ trạng thái. C++ thì để bạn chọn: copy khi thật sự cần một bản độc lập, move khi chỉ cần chuyển quyền — và std::move là cách nói rõ ý định đó. Rust đẩy triết lý này xa nhất: move là mặc định, và trình biên dịch cấm bạn dùng lại nguồn sau khi move, biến cái bẫy "dùng lại object đã bị lấy ruột" thành lỗi biên dịch thay vì bug âm thầm.

Muốn tự thấy thì 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. Vài phút đó 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í.