Lambda pada chapter sebelumnya memindahkan string masuk dengan std::move dan programnya justru makin cepat — sekarang kita bongkar mengapa. Konsepnya sederhana tapi dampaknya besar: copy menduplikasi isi objek (buffer baru dialokasi, data disalin byte demi byte), sedangkan move mengambil alih buffer milik sumber — pointer ditukar, sumber dibiarkan kosong, dan biayanya konstan tidak peduli datanya 10 byte atau 10 juta byte. Untuk tipe pustaka yang punya buffer di heap (std::string, std::vector, std::map), perbedaan ini menentukan performa program sungguhan. Chapter ini membahas move semantics dari sisi pengguna: apa yang terjadi, kapan compiler melakukannya otomatis, dan bagaimana memintanya secara eksplisit dengan std::move. Detail teori penuh — rvalue reference, value category, move assignment, dan rule of five — ditanggung khusus oleh Section Move Semantics dan Value Categories; di sini kita fokus ke kebiasaan pemakaian yang benar.

Program: Mengamati Copy, Move, dan NRVO #

Agar copy dan move bisa teramati, class Dokumen di bawah mencetak label setiap kali constructor-nya dipanggil:

cpp
// move-demo.cpp — move semantics: copy vs move, std::move, dan move otomatis
// Kompilasi: g++ -std=c++23 -Wall -Wextra move-demo.cpp -o move-demo

#include <print>
#include <string>
#include <utility>
#include <vector>

struct Dokumen {
    std::string isi;

    Dokumen(std::string t) : isi(std::move(t)) {}
    Dokumen(const Dokumen& lain) : isi(lain.isi) { std::print("  [copy]\n"); }
    Dokumen(Dokumen&& lain) noexcept : isi(std::move(lain.isi)) { std::print("  [move]\n"); }
};

Dokumen buat() {
    Dokumen d("laporan akhir");
    return d;                    // NRVO: objek dibangun langsung di tujuan
}

Dokumen pilih(bool pakaiA) {
    Dokumen a("versi-A"), b("versi-B");
    if (pakaiA) return a;        // NRVO tak mungkin: move implisit
    return b;
}

int main() {
    std::print("-- return by value: NRVO\n");
    Dokumen x = buat();
    std::print("x.isi = {}\n", x.isi);

    std::print("-- return di dalam if: move implisit\n");
    Dokumen y = pilih(true);
    std::print("y.isi = {}\n", y.isi);

    std::print("-- copy dari lvalue\n");
    Dokumen s1 = x;              // lvalue -> copy

    std::print("-- std::move: minta dipindahkan\n");
    Dokumen s2 = std::move(x);   // rvalue -> move
    std::print("s2.isi = {}\n", s2.isi);

    std::print("-- push_back dengan dan tanpa std::move\n");
    std::vector<Dokumen> v;
    v.reserve(3);
    v.push_back(Dokumen("sementara"));   // temporary -> move
    v.push_back(s1);                     // lvalue -> copy
    v.push_back(std::move(s1));          // std::move -> move
    std::print("v[0]={} v[1]={} v[2]={}\n", v[0].isi, v[1].isi, v[2].isi);
    return 0;
}

Output yang Dihasilkan:

text
-- return by value: NRVO
x.isi = laporan akhir
-- return di dalam if: move implisit
  [move]
y.isi = versi-A
-- copy dari lvalue
  [copy]
-- std::move: minta dipindahkan
  [move]
s2.isi = laporan akhir
-- push_back dengan dan tanpa std::move
  [move]
  [copy]
  [move]
v[0]=sementara v[1]=laporan akhir v[2]=laporan akhir

Mari kita bedah:

  1. Baris 13-14 (dua constructor): copy constructor menyalin isi milik lain — alokasi buffer baru; move constructor (tipe parameternya Dokumen&&) memanggil std::move(lain.isi) sehingga buffer pindah alih-alih disalin. Keduanya noexcept — alasan pentingnya dibahas di bawah.
  2. Baris 29-31 (NRVO): buat() mengembalikan Dokumen by value, dan tidak ada label yang tercetak — compiler membangun objek langsung di tempat x berada (Return Value Optimization / NRVO). Tidak ada copy, tidak ada move: inilah cara termurah mengembalikan objek, dan alasannya return by value justru dianjurkan.
  3. Baris 33-35 (move implisit): di pilih(), NRVO tidak mungkin karena ada dua objek kandidat, tetapi compiler otomatis memperlakukan return a; sebagai rvalue — label [move] tercetak tanpa kita menulis std::move. Aturan standar: return sebuah variabel lokal otomatis menjadi move bila NRVO gagal.
  4. Baris 38 vs 41 (lvalue vs rvalue): Dokumen s1 = x; menyalin karena x adalah lvalue bernama; Dokumen s2 = std::move(x); memicu [move] karena std::move mengubah x menjadi ekspresi rvalue. Setelah baris itu, x jangan dipakai lagi isinya — buffer-nya sudah menjadi milik s2.
  5. Baris 45-50 (pola di container): push_back dengan temporary memicu move; dengan lvalue memicu copy; dengan std::move(s1) memicu move. Pola ketiga inilah cara standar mengosongkan variabel sementara masuk ke std::vector tanpa salinan. v.reserve(3) di baris 46 menjaga jumlah label tetap bersih — tanpa itu, realokasi std::vector akan memicu move tambahan untuk setiap elemen lama yang direlokasi.

std::move Tidak Memindahkan Apa pun #

Nama std::move menyesatkan sekaligus menginspirasi pemahaman yang benar: fungsi ini tidak memindahkan byte, tidak memanggil constructor apa pun, dan tidak mengubah objek sumber. Ia hanya cast — mengubah ekspresi lvalue menjadi rvalue reference, alias menempelkan permintaan “silakan pindahkan objek ini”. Pelaku pindahan yang sesungguhnya adalah move constructor atau move assignment yang dipilih compiler ketika argumennya rvalue. Karena itu std::move pada tipe tanpa move constructor (mis. tipe yang hanya punya copy) tidak error — ia turun menjadi copy, diam-diam.

Konsekuensi pentingnya adalah keadaan objek sumber setelah dipindahkan (moved-from state). Standar menjamin objek itu tetap valid — destructornya jalan, operator= masih boleh dipanggil — tetapi tidak menjamin nilai isinya. std::string hasil pindahan biasanya kosong, tetapi “biasanya” bukan jaminan; bila kita menulis fungsi yang menerima objek pindahan, kita tidak boleh mengandalkan isinya kecuali kita sendiri yang mengatur ulang. Inilah kontrak move: sumber adalah objek valid dengan nilai tak tentu, tujuan memiliki seluruh isinya.

Kapan Move Terjadi Otomatis #

Ada empat situasi yang memicu move tanpa kita menulis apa pun. Pertama, return sebuah temporary: return Dokumen("baru"); — objek dibangun langsung di tujuan (elisi). Kedua, return variabel lokal: compiler mencoba NRVO dulu; bila tidak mungkin (objek kandidat lebih dari satu, atau yang dikembalikan adalah parameter), standar memerintahkan implicit move. Ketiga, melempar dan menangkap exception by value. Keempat, operasi container standar: std::vector::push_back pada temporary, realokasi memindahkan elemen lama ke buffer baru, dan std::ranges::sort menukar elemen lewat move.

Situasi keempat menjelaskan kenapa move constructor di baris 14 ditandai noexcept. Saat std::vector tumbuh dan harus memindahkan elemennya, ia memilih antara move (aman, cepat) dan copy (aman terhadap exception tetapi mahal) — dan ia hanya berani move bila move constructor dijamin tidak melempar. Tanpa noexcept, class yang menampung std::string besar akan kalah performa di container tanpa kita sadari. Praktik yang benar: tandai move constructor dan move assignment noexcept kecuali ada alasan kuat yang didokumentasikan.

Aturan Praktis Pemakaian #

Dari semua mekanisme di atas, kebiasaan sehari-hari bisa diringkas pendek:

  • Kembalikan objek by value — NRVO dan implicit move membuatnya hampir selalu optimal; jangan kembalikan pointer/reference hanya demi menghindari “salinan” yang sebenarnya tidak terjadi.
  • std::move hanya saat selesai dengan sumbernya — untuk variabel lokal di akhir hidupnya, atau saat memasukkan objek berat ke container.
  • Jangan std::move kembalian fungsi — return std::move(x); justru mematikan NRVO; return x langsung.
  • Tandai move constructor/assignment noexcept agar container berani memindahkan elemen kita.
  • Tipe kecil (int, double, pointer) tidak perlu std::move — menyalinnya lebih murah daripada memindahkan.

Satu sisa pertanyaan yang belum kita jawab: bagaimana sebuah fungsi generik bisa menerima argumen lvalue maupun rvalue dan meneruskannya tanpa menggugurkan kualitas pindahannya? std::move saja tidak cukup, karena ia selalu menghasilkan rvalue — padahal pemanggil mungkin ingin lvalue tetap lvalue. Jawabannya adalah std::forward dan forwarding reference, topik penuh chapter berikutnya.

Rujukan resmi: cppreference — std::move , cppreference — move constructor .