Copy elision dan RVO

Kapan compiler melewatkan penyalinan sepenuhnya.

Daftar isi

Copy elision adalah izin bagi compiler untuk tidak membangun objek sementara sama sekali, sehingga constructor penyalin maupun pemindah tidak pernah dipanggil meskipun keduanya memiliki efek yang teramati. Topik ini membahas return value optimization (RVO) pada pengembalian nilai, bentuk bernamanya yang disebut NRVO, serta jaminan baru C++17 yang mengubah sebagian elision dari izin menjadi kewajiban.

Elision yang diizinkan dan diwajibkan#

Sebelum C++17, standar hanya mengizinkan compiler melewatkan pembangunan salinan ketika objek sementara dipakai untuk menginisialisasi objek lain yang bertipe sama. Bentuknya ada dua. RVO berlaku pada nilai kembalian fungsi yang ditulis sebagai prvalue, sedangkan NRVO (named return value optimization) berlaku pada variabel lokal bernama yang dikembalikan. Karena hanya bersifat izin, compiler bebas melakukannya atau tidak, dan keduanya dapat diamati lewat constructor yang terpanggil.

C++17 mengubah dasar kategori nilai prvalue. Prvalue tidak lagi menyebut objek yang sudah dibangun, melainkan resep pembangunannya, dan objek baru dimaterialisasi, yaitu dibangun langsung pada alamat tujuannya. Akibatnya, elision pada inisialisasi dari prvalue bukan lagi optimasi yang boleh gagal, melainkan makna bahasa yang dijamin. Bagian inilah yang membuat kelas tanpa constructor penyalin dan pemindah tetap dapat dikembalikan dari fungsi, sedangkan bentuk NRVO tetap berstatus izin hingga hari ini.

Menghitung constructor yang berjalan#

Kelas DaftarPengukuran berikut mencetak setiap operasi khusus yang dijalankannya, sehingga setiap penyalinan dan pemindahan yang lolos menjadi terlihat. Pengujian memakai flag bawaan g++ 14.2, yaitu standar gnu++17.

cpp copy-elision.cpp
#include <iostream>

class DaftarPengukuran {
public:
    DaftarPengukuran() {
        std::cout << "  constructor default\n";
    }

    explicit DaftarPengukuran(int jumlah) {
        std::cout << "  constructor " << jumlah << " data\n";
    }

    DaftarPengukuran(const DaftarPengukuran& lain) {
        std::cout << "  constructor salin\n";
    }

    DaftarPengukuran(DaftarPengukuran&& lain) noexcept {
        std::cout << "  constructor pindah\n";
    }

    DaftarPengukuran& operator=(const DaftarPengukuran& lain) {
        std::cout << "  assignment salin\n";
        return *this;
    }

    ~DaftarPengukuran() = default;
};

DaftarPengukuran buatSementara() {
    return DaftarPengukuran(24);
}

DaftarPengukuran buatBernama() {
    DaftarPengukuran hasil(36);
    return hasil;
}

int main() {
    std::cout << "prvalue dikembalikan dan langsung dibangun:\n";
    DaftarPengukuran a = buatSementara();

    std::cout << "variabel lokal bernama dikembalikan:\n";
    DaftarPengukuran b = buatBernama();

    std::cout << "assignment dari nilai kembalian:\n";
    DaftarPengukuran c(10);
    c = buatSementara();
}
text Keluaran
prvalue dikembalikan dan langsung dibangun:
  constructor 24 data
variabel lokal bernama dikembalikan:
  constructor 36 data
assignment dari nilai kembalian:
  constructor 10 data
  constructor 24 data
  assignment salin

Keluaran tersebut memuat tiga kasus yang berbeda. Baris constructor 24 data muncul satu kali karena prvalue yang dikembalikan buatSementara() dimaterialisasi langsung pada objek a. Tidak ada salinan maupun pemindahan di antaranya, dan bagian ini dijamin C++17. Baris constructor 36 data juga muncul sekali karena NRVO. Elision ini hanya diizinkan, tetapi g++ tetap melakukannya bahkan tanpa flag optimasi. Kasus assignment berbeda karena elision hanya berlaku pada inisialisasi. Nilai kembalian tetap dibangun sebagai objek sementara, dan operator= yang menyusulnya dipanggil setelahnya.

Membuktikan perbedaan standar#

Perbedaan antara yang diizinkan dan yang diwajibkan baru terlihat pada dua pengujian. Pengujian pertama memakai kelas yang constructor penyalin dan pemindahnya dihapus, lalu dikembalikan dari fungsi sebagai prvalue.

cpp kelas-hanya-bangun.cpp
#include <iostream>

struct HanyaBangun {
    HanyaBangun() {
        std::cout << "  dibangun langsung\n";
    }
    HanyaBangun(const HanyaBangun&) = delete;
    HanyaBangun(HanyaBangun&&) = delete;
};

HanyaBangun buat() {
    return HanyaBangun();
}

int main() {
    HanyaBangun h = buat();
}
text Keluaran
  dibangun langsung

Program ini lolos kompilasi dengan flag bawaan karena prvalue tidak membutuhkan constructor penyalin atau pemindah apa pun. Bila berkas yang sama dikompilasi ulang dengan -std=c++14, compiler menolaknya dengan error use of deleted function. Standar lama tetap menuntut constructor pemindah yang dapat diakses, meskipun elision pada praktiknya sudah membuatnya tidak terpanggil. Keluaran program utama, sebaliknya, kebetulan sama untuk kedua standar pada g++, karena compiler tetap menjalankan elision yang diizinkannya tanpa memandang versi standar.

Versi tanpa elision dapat diperlihatkan dengan opsi -fno-elide-constructors, yang mematikan seluruh elision yang statusnya masih izin. Program utama perlu dikompilasi ulang dengan -std=c++14 agar setiap bentuk elision berada dalam status tersebut.

bash
# mengompilasi ulang program utama dengan standar C++14 tanpa elision
c++ -std=c++14 copy-elision.cpp -fno-elide-constructors -o /tmp/copy-elision-cxx14
# menjalankan hasil kompilasi tersebut
/tmp/copy-elision-cxx14
text Keluaran
prvalue dikembalikan dan langsung dibangun:
  constructor 24 data
  constructor pindah
  constructor pindah
variabel lokal bernama dikembalikan:
  constructor 36 data
  constructor pindah
  constructor pindah
assignment dari nilai kembalian:
  constructor 10 data
  constructor 24 data
  constructor pindah
  assignment salin

Lima constructor pindah tambahan memperlihatkan perpindahan antara objek sementara, tempat nilai kembalian, dan variabel penerima. Perpindahan itu semula berlangsung tanpa objek apa pun karena elision. Pada flag bawaan gnu++17, opsi yang sama tidak dapat menghapus elision prvalue karena bagian itu sudah menjadi kewajiban, sehingga keluaran hanya berubah pada bagian NRVO. Dokumentasi GCC untuk opsi ini mencatat batas tersebut secara eksplisit.

Pabrik yang mengembalikan nilai#

Jaminan elision C++17 membuat pengembalian objek kompleks sebagai nilai kembalian menjadi pola yang murah, dan pola ini menjadi dasar pabrik sumber daya. Fungsi pabrik yang mengembalikan std::unique_ptr membangun sumber dayanya tepat satu kali di dalam badannya, sedangkan yang ditransfer keluar hanyalah satu pointer kepemilikan tunggal. Ekspresi pemanggilannya berupa prvalue, sehingga pointer itu dimaterialisasi langsung pada variabel penerima tanpa pemindahan antar smart pointer yang teramati.

Program berikut membangun kelas yang mengikuti pola RAII, yaitu kelas yang melepaskan sumber dayanya pada destructor, lalu mengembalikannya melalui fungsi pabrik.

cpp Contoh: pabrik yang mengembalikan std::unique_ptr
#include <iostream>
#include <memory>
#include <string>
#include <utility>

class Koneksi {
public:
    explicit Koneksi(std::string nama) : nama_(std::move(nama)) {
        std::cout << "  buka koneksi " << nama_ << '\n';
    }

    ~Koneksi() {
        std::cout << "  tutup koneksi " << nama_ << '\n';
    }

private:
    std::string nama_;
};

std::unique_ptr<Koneksi> buatKoneksi(const std::string& nama) {
    return std::make_unique<Koneksi>(nama);
}

int main() {
    std::unique_ptr<Koneksi> k = buatKoneksi("lab-01");
}
text Keluaran
  buka koneksi lab-01
  tutup koneksi lab-01

Constructor Koneksi dijalankan tepat satu kali di dalam badan pabrik, dan destructor berjalan satu kali ketika k mencapai akhir masa hidupnya. Yang dipindahkan keluar dari fungsi hanyalah pointer tunggal milik std::unique_ptr, sehingga pengembalian oleh nilai tidak menyalin maupun membangun ulang koneksinya. Pola inilah yang dipegang std::make_unique, yaitu pabrik pustaka standar yang mengembalikan prvalue sehingga pemanggilnya menerima hasilnya tanpa biaya tambahan.

Pengembalian Koneksi* mentah menyerahkan keputusan penghancuran kepada pemanggil tanpa penanda apa pun, dan satu jalur yang lupa delete cukup untuk kebocoran memori. Pengembalian std::unique_ptr<Koneksi> menyatakan kepemilikannya di dalam tipe kembalian, sehingga penghancuran otomatis dijalankan pada akhir masa hidup penerima. Tipe kembalian menjadi bagian dari kontrak fungsi, dan itu sejalan dengan janji RAII sejak objeknya dibangun.

Kesalahan umum#

  • Menghitung jumlah penyalinan dari teori tanpa menguji. Elision yang hanya diizinkan berbeda antara versi compiler, flag, dan standar yang dipakai. Ukur dengan program nyata, dan -fno-elide-constructors membantu memperlihatkan kasus terburuknya.
  • Membungkus nilai kembalian lokal dengan std::move. Pernyataan return std::move(hasil) mengubah ekspresi kembalian menjadi xvalue sehingga NRVO tidak berlaku dan pemindahan tetap terjadi. Kembalikan variabelnya secara langsung.
  • Bergantung pada NRVO. NRVO tidak dijamin, dan pemindahan dapat muncul bila fungsi mengembalikan objek dari cabang yang berbeda atau mengembalikan parameternya sendiri. Untuk jaminan penuh, kembalikan prvalue yang elisionnya diwajibkan C++17.

Ringkasan#

  • Copy elision melewatkan pembangunan salinan atau pemindahan, dan sejak C++17 hal itu wajib untuk prvalue karena prvalue berhenti menjadi objek dan menjadi resep pembangunannya.
  • NRVO pada pengembalian variabel lokal bersifat pengizinan saja dan dapat berubah menjadi pemindahan bila polanya tidak didukung compiler.
  • Kelas tanpa constructor penyalin dan pemindah tetap dapat dikembalikan sebagai prvalue pada C++17, sedangkan -std=c++14 menolaknya, dan -fno-elide-constructors memperlihatkan perilaku tanpa elision.

Referensi#