Move semantics dan std::move

Mengalihkan sumber daya, constructor dan assignment untuk move.

Daftar isi

Move semantics adalah mekanisme C++ yang mengalihkan sumber daya dari satu objek ke objek lain tanpa menyalinnya, sedangkan std::move adalah cara menandai sebuah ekspresi agar pengalihan itu dipilih compiler. Topik ini membahas move constructor dan move assignment operator, menunjukkan keduanya pada kelas BufferAngka yang mengelola memori dinamis, serta menjelaskan keadaan objek sumber setelah dipindahkan.

Pengalihan sebagai pengganti penyalinan#

Penyalinan pada kelas yang mengelola sumber daya berarti membangun ulang seluruh isinya. std::vector yang berisi seribu elemen harus mengalokasikan memori baru dan menyalin seribu nilai, dan biaya yang sama menempel pada std::string panjang atau pada kelas pembungkus berkas. Sering kali objek sumbernya sudah selesai dipakai, misalnya ketika isinya diserahkan kepada container atau kepada pemilik baru, sehingga salinan yang lengkap tidak diperlukan sama sekali.

Untuk kasus seperti itu, yang dibutuhkan adalah pengalihan pointer internal objeknya. Move constructor mengambil alih pointer dan metadata milik objek sumber, lalu mengosongkan sumbernya sehingga destructor objek sumber tidak melepaskan sumber daya yang sudah berpindah. Setelah dipindahkan, objek sumber berada dalam keadaan sah tetapi nilainya tidak ditetapkan oleh standar. Kode tetap boleh memanggil operasi yang tidak menuntut nilai tertentu, seperti assignment baru atau penghancuran, sedangkan membaca isinya tanpa mengatur ulang tidak memberikan hasil yang terjamin.

Kelas dengan operasi pindah#

Kelas BufferAngka berikut mengelola array int dinamis dan mencetak setiap operasi khusus yang dijalankannya. Move constructor dan move assignment keduanya ditandai noexcept, dan alasan penandaiannya dijelaskan setelah contoh program.

cpp move-semantics.cpp
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>
#include <vector>

class BufferAngka {
public:
    explicit BufferAngka(std::size_t kapasitas)
        : ukuran_(kapasitas), data_(new int[kapasitas] {}) {
        std::cout << "  bangun " << ukuran_ << " elemen\n";
    }

    BufferAngka(const BufferAngka& lain)
        : ukuran_(lain.ukuran_), data_(new int[lain.ukuran_]) {
        std::copy(lain.data_, lain.data_ + ukuran_, data_);
        std::cout << "  salin " << ukuran_ << " elemen\n";
    }

    BufferAngka(BufferAngka&& lain) noexcept
        : ukuran_(lain.ukuran_), data_(lain.data_) {
        lain.ukuran_ = 0;
        lain.data_ = nullptr;
        std::cout << "  pindah " << ukuran_ << " elemen\n";
    }

    BufferAngka& operator=(const BufferAngka& lain) {
        if (this != &lain) {
            int* baru = new int[lain.ukuran_];
            std::copy(lain.data_, lain.data_ + lain.ukuran_, baru);
            delete[] data_;
            data_ = baru;
            ukuran_ = lain.ukuran_;
        }
        std::cout << "  assignment salin\n";
        return *this;
    }

    BufferAngka& operator=(BufferAngka&& lain) noexcept {
        if (this != &lain) {
            delete[] data_;
            data_ = lain.data_;
            ukuran_ = lain.ukuran_;
            lain.data_ = nullptr;
            lain.ukuran_ = 0;
        }
        std::cout << "  assignment pindah\n";
        return *this;
    }

    ~BufferAngka() {
        delete[] data_;
    }

    std::size_t ukuran() const { return ukuran_; }

private:
    std::size_t ukuran_;
    int* data_;
};

int main() {
    std::cout << "pembangunan dan penyalinan:\n";
    BufferAngka sumber(100);
    BufferAngka salinan = sumber;

    std::cout << "pemindahan:\n";
    BufferAngka pindahan = std::move(sumber);
    std::cout << "  ukuran sumber setelah dipindah: " << sumber.ukuran() << '\n';

    std::cout << "assignment:\n";
    BufferAngka tujuan(4);
    tujuan = salinan;
    tujuan = std::move(pindahan);

    std::cout << "move pada std::vector:\n";
    std::vector<int> angka(1000);
    std::cout << "  sebelum pindah: " << angka.size() << " elemen\n";
    std::vector<int> penerima = std::move(angka);
    std::cout << "  setelah pindah: " << angka.size() << " elemen pada sumber\n";
    std::cout << "  penerima: " << penerima.size() << " elemen\n";
}
text Keluaran
pembangunan dan penyalinan:
  bangun 100 elemen
  salin 100 elemen
pemindahan:
  pindah 100 elemen
  ukuran sumber setelah dipindah: 0
assignment:
  bangun 4 elemen
  assignment salin
  assignment pindah
move pada std::vector:
  sebelum pindah: 1000 elemen
  setelah pindah: 0 elemen pada sumber
  penerima: 1000 elemen

Keluaran tersebut memetakan langsung ke operasinya. Inisialisasi salinan memanggil copy constructor yang mengalokasikan buffer baru dan menyalin seratus nilai. Pemanggilan std::move(sumber) memilih move constructor, yang hanya menyalin alamat pointer dan mengosongkan milik sumbernya. Seratus nilai itu tidak disalin sama sekali, sehingga ukuran sumber tercetak nol. Dua bentuk assignment memperlihatkan pasangannya, yaitu assignment salin yang membangun ulang isi dan assignment pindah yang hanya menukar kepemilikan pointer.

Peran std::move dan noexcept#

std::move tidak memindahkan apa pun. Fungsi ini hanyalah cast yang menghasilkan ekspresi xvalue dari objek yang sama, sehingga compiler berhak memilih overload atau constructor versi rvalue. Pengalihan sesungguhnya dilakukan oleh move constructor atau move assignment yang menerima ekspresi tersebut. Apabila kelasnya tidak memiliki versi pindah, pemanggilan std::move(x) jatuh ke penyalinan, dan program tetap benar, hanya lebih lambat.

Penanda noexcept pada operasi pindah bukan formalitas. std::vector yang memperluas kapasitasnya menyalin atau memindahkan elemen satu per satu. Vector tersebut hanya berani memindahkan bila move constructor elemennya menjanjikan tidak akan melempar exception, karena jaminan exception kuatnya harus terjaga. Tanpa noexcept, proses perluasan berbalik ke penyalinan dan keuntungan move semantics hilang. Modul penanganan error membahas jaminan ini lebih rinci.

Tiga baris terakhir program mengamati perilaku implementasinya. Ukuran std::vector sumber yang tercetak nol setelah dipindah merupakan perilaku implementasi pustaka standar, sedangkan standar bahasa hanya menjamin keadaan sah yang nilainya tidak ditetapkan. Kode produksi tidak boleh membaca ukuran vektor hasil pindah untuk menentukan alur programnya.

Menyimpan argumen dengan pindah#

Fungsi yang perlu menyimpan salinan argumennya dapat menerima parameter sebagai nilai lalu memindahkannya ke member. Parameter std::string teks dibangun dari argumen pemanggil, yaitu satu penyalinan untuk argumen lvalue dan satu pemindahan untuk argumen rvalue. Pernyataan isi_ = std::move(teks) kemudian memindahkan isi parameter ke member, sehingga totalnya menjadi satu penyalinan untuk lvalue dan nol penyalinan untuk rvalue.

Program berikut menguji kedua jenis argumen pada fungsi yang sama.

cpp Contoh: parameter nilai dipindah ke member
#include <iostream>
#include <string>
#include <utility>

class PenyimpanTeks {
public:
    void simpan(std::string teks) {
        isi_ = std::move(teks);
    }

    const std::string& isi() const { return isi_; }

private:
    std::string isi_;
};

int main() {
    PenyimpanTeks penyimpan;
    std::string sumber = "data lama";

    penyimpan.simpan(sumber);
    std::cout << "argumen lvalue: isi=" << penyimpan.isi()
              << ", sumber=" << sumber << '\n';

    penyimpan.simpan(std::string("data baru"));
    std::cout << "argumen rvalue: isi=" << penyimpan.isi() << '\n';
}
text Keluaran
argumen lvalue: isi=data lama, sumber=data lama
argumen rvalue: isi=data baru

Keluarannya memperlihatkan dua hal sekaligus. Argumen lvalue tetap utuh pada pemanggilnya, karena parameter nilainya adalah salinan tersendiri yang boleh dihabiskan oleh fungsi. Argumen rvalue tidak menyalin apa pun, karena pemindahan sudah menangani inisialisasi parameternya. Tanpa std::move di dalam badan simpan, isi parameter akan disalin ulang ke member dan keuntungan parameternya hilang.

Pola parameter nilai ini menjadi alternatif menulis dua overload, yaitu const T& untuk penyalinan dan T&& untuk pengalihan. Bentuk dua overload menghemat satu penyalinan pada argumen lvalue dengan harga dua fungsi yang harus dipelihara, sedangkan pola parameter nilai memilih satu fungsi yang lebih sederhana. Untuk argumen yang memang akan disimpan, keterbacaan satu fungsi sering sepadan dengan biaya penyalinan tambahan itu.

Kesalahan umum#

  • Menganggap std::move benar-benar memindahkan. Pemanggilan std::move(x) hanya menghasilkan ekspresi xvalue. Apabila kelasnya tidak punya move constructor, penyalinan yang terjadi dan hasilnya tetap benar, hanya biayanya tidak berkurang.
  • Lupa noexcept pada operasi pindah. Container seperti std::vector berbalik menyalin elemen saat memperluas kapasitas karena jaminan exception-nya. Tandai move constructor dan move assignment dengan noexcept agar pengalihan benar-benar dipilih.
  • Membaca nilai objek sumber setelah dipindah. Standar menjamin objeknya sah tetapi tidak menjamin nilainya, sehingga hasil pembacaan bergantung implementasi. Beri nilai baru lewat assignment atau hancurkan objek sumber sebelum digunakan lagi.
  • Melupakan std::move saat menyimpan parameter nilai. Parameter std::string teks sudah menjadi milik fungsi, tetapi penulisan isi_ = teks; menyalin ulang isinya alih-alih memindahkannya. Simpan dengan isi_ = std::move(teks); agar pekerjaan parameter nilai tidak terbuang.

Ringkasan#

  • Move constructor dan move assignment mengalihkan sumber daya dengan mengambil pointer milik sumber, lalu mengosongkan sumbernya agar destructor tidak melepaskannya dua kali.
  • std::move hanya mengubah kategori ekspresi menjadi xvalue, dan pengalihan sesungguhnya dilakukan oleh operasi pindah kelas tujuannya.
  • Objek sumber setelah dipindah berada dalam keadaan sah tetapi nilainya tidak tercantum dalam standar, dan penanda noexcept menentukan apakah container memilih pengalihan atau penyalinan.

Referensi#