Perfect forwarding

Universal reference, std::forward, dan penulisan wrapper yang utuh.

Daftar isi

Perfect forwarding adalah teknik menulis fungsi template yang meneruskan argumennya ke fungsi lain dengan kategori nilai aslinya tetap utuh, sehingga wrapper berperilaku persis seperti pemanggilan langsung. Topik ini membahas parameter T&& yang berperan sebagai universal reference, mekanisme reference collapsing atau pelipatan referensi yang melandasinya, serta std::forward sebagai alat penerusannya.

Parameter bernama selalu lvalue#

Wrapper yang meneruskan argumen menghadapi satu masalah yang tidak terlihat dari tipenya. Setiap ekspresi yang memiliki nama adalah lvalue, dan parameter fungsi jelas memiliki nama. Begitu argumen masuk ke dalam wrapper, informasi bahwa argumen itu semula prvalue atau xvalue hilang, sehingga pemanggilan berikutnya selalu jatuh ke overload lvalue tanpa memandang asal argumennya.

Wrapper yang meneruskan argumennya secara nilai menambah beban lain, yaitu satu penyalinan atau satu pemindahan ekstra pada saat masuk wrapper. Untuk tipe yang mengelola memori dinamis, biaya tambahan itu terjadi pada setiap panggilan, padahal tujuan wrapper hanyalah meneruskan argumen ke penerima sebenarnya. Program berikut menguji tiga gaya wrapper terhadap pemanggilan langsung, dan seluruhnya dapat dijalankan sebagai satu berkas.

cpp perfect-forwarding.cpp
#include <iostream>
#include <string>
#include <utility>

void terima(const std::string& nilai) {
    std::cout << "  dipilih: terima(const std::string&)\n";
}

void terima(std::string&& nilai) {
    std::cout << "  dipilih: terima(std::string&&)\n";
}

template <typename T>
void bungkusNilai(T nilai) {
    std::cout << "  wrapper by value, meneruskan nilai:\n";
    terima(nilai);
}

template <typename T>
void bungkusRujukan(T&& nilai) {
    std::cout << "  wrapper T&& tanpa std::forward:\n";
    terima(nilai);
}

template <typename T>
void bungkusLengkap(T&& nilai) {
    std::cout << "  wrapper T&& dengan std::forward:\n";
    terima(std::forward<T>(nilai));
}

int main() {
    std::string teks = "data";

    std::cout << "pemanggilan langsung:\n";
    terima(teks);
    terima(std::string("data"));

    std::cout << "lewat wrapper by value:\n";
    bungkusNilai(teks);
    bungkusNilai(std::string("data"));

    std::cout << "lewat wrapper T&& tanpa std::forward:\n";
    bungkusRujukan(teks);
    bungkusRujukan(std::string("data"));

    std::cout << "lewat wrapper T&& dengan std::forward:\n";
    bungkusLengkap(teks);
    bungkusLengkap(std::string("data"));
}
text Keluaran
pemanggilan langsung:
  dipilih: terima(const std::string&)
  dipilih: terima(std::string&&)
lewat wrapper by value:
  wrapper by value, meneruskan nilai:
  dipilih: terima(const std::string&)
  wrapper by value, meneruskan nilai:
  dipilih: terima(const std::string&)
lewat wrapper T&& tanpa std::forward:
  wrapper T&& tanpa std::forward:
  dipilih: terima(const std::string&)
  wrapper T&& tanpa std::forward:
  dipilih: terima(const std::string&)
lewat wrapper T&& dengan std::forward:
  wrapper T&& dengan std::forward:
  dipilih: terima(const std::string&)
  wrapper T&& dengan std::forward:
  dipilih: terima(std::string&&)

Dua gaya wrapper pertama memperlihatkan kegagalannya. Wrapper by value menerima argumen dengan benar, tetapi parameter bernamanya adalah lvalue, sehingga pemanggilan lanjutan selalu memilih terima(const std::string&). Wrapper T&& tanpa std::forward berperilaku sama, karena nilai di dalam badan fungsi tetap sebuah nama. Dua baris terakhir keluaran memperlihatkan satu-satunya wrapper yang menyampaikan kategori rvalue sampai ke pemanggilan terakhirnya.

Universal reference dan pelipatan referensi#

Pada template, parameter yang ditulis T&& bukan selalu referensi rvalue. Bila T dideduksi dari pemanggilan fungsi tersebut, T&& menjadi forwarding reference yang dapat mengikat kedua kategori. Argumen lvalue bertipe U mendeduksi T sebagai U&, sehingga tipe parameternya menjadi U& && yang dilipat menjadi U&. Argumen rvalue mendeduksi T sebagai U biasa, sehingga parameternya tetap U&&. Dua bentuk inilah yang menyimpan informasi kategori argumen aslinya.

Aturan pelipatan referensi hanya memiliki satu kunci, yaitu referensi lvalue selalu menang.

Kombinasi tipeHasil pelipatan
U& dengan &U&
U& dengan &&U&
U&& dengan &U&
U&& dengan &&U&&

Sifat ini hanya berlaku pada parameter T&& yang tipenya dideduksi dari parameter itu sendiri. Parameter std::vector<T>&& bukan forwarding reference, karena T dideduksi dari argumen template kelasnya, bukan dari parameter tersebut, sehingga parameter itu tetap referensi rvalue biasa. Literatur sering menyebut istilah universal reference untuk penekanan sifat dua arahnya, sedangkan istilah resmi pada standar dan dokumentasi rujukan adalah forwarding reference.

std::forward menjaga kategori#

std::forward<T>(nilai) melakukan cast bersyarat. Bila T dideduksi sebagai tipe biasa, hasilnya adalah ekspresi rvalue, sedangkan bila T dideduksi sebagai U&, hasilnya kembali menjadi lvalue. Pada wrapper bungkusLengkap, argumen lvalue diteruskan sebagai lvalue dan pemanggilan berakhir pada terima(const std::string&), sedangkan argumen rvalue diteruskan sebagai rvalue dan berakhir pada terima(std::string&&). Tidak ada penyalinan tambahan dan tidak ada kategori yang berubah, dan itulah definisi perfect forwarding.

Pola inilah yang dipakai pustaka standar pada fungsi pabrik dan container. std::make_unique<T>(...) serta std::vector::emplace_back(...) menerima argumen sebagai forwarding reference dan meneruskannya lewat std::forward ke constructor yang dituju, sehingga wrapper-nya transparan terhadap kategori argumen.

Fungsi pabrik yang meneruskan argumen#

Kombinasi forwarding reference dan std::forward paling sering digunakan pada fungsi pabrik, yaitu fungsi template yang membangun objek baru dari argumen yang diterimanya. Pabrik yang meneruskan dengan benar membangun objek tujuannya tanpa satu penyalinan pun pada jalur rvalue, sehingga perilakunya sama dengan pemanggilan constructor langsung. Bentuk dasarnya pendek, karena seluruh mekanismenya bertumpu pada pelipatan referensi yang sudah diuraikan.

Program berikut menulis pabrik minimal untuk kelas Catatan yang memiliki dua constructor, lalu memanggilnya dengan kedua kategori argumen.

cpp Contoh: pabrik dengan std::forward
#include <iostream>
#include <string>
#include <utility>

class Catatan {
public:
    explicit Catatan(const std::string& isi) : isi_(isi) {
        std::cout << "  dipilih: constructor salin\n";
    }

    explicit Catatan(std::string&& isi) : isi_(std::move(isi)) {
        std::cout << "  dipilih: constructor pindah\n";
    }

private:
    std::string isi_;
};

template <typename T>
Catatan buat(T&& isi) {
    return Catatan(std::forward<T>(isi));
}

int main() {
    std::string teks = "isi catatan";

    std::cout << "pabrik dengan argumen lvalue:\n";
    Catatan a = buat(teks);

    std::cout << "pabrik dengan argumen rvalue:\n";
    Catatan b = buat(std::string("isi catatan"));
}
text Keluaran
pabrik dengan argumen lvalue:
  dipilih: constructor salin
pabrik dengan argumen rvalue:
  dipilih: constructor pindah

Pemanggilan buat(teks) mendeduksi T sebagai std::string&, sehingga std::forward mengembalikan ekspresi lvalue dan constructor yang terpilih adalah constructor penyalin. Pemanggilan buat(std::string("isi catatan")) mendeduksi T sebagai std::string, sehingga std::forward mengembalikan ekspresi rvalue dan constructor yang terpilih adalah constructor pindah. Karena hasil pabrik berupa prvalue, objeknya dibangun langsung pada variabel penerima tanpa pemindahan tambahan, dan bentuk ini merupakan versi minimal dari fungsi pabrik pustaka standar.

Kesalahan umum#

  • Menggunakan T&& di luar konteks deduksi. Pada fungsi biasa atau pada template kelas yang T-nya sudah diketahui, T&& adalah referensi rvalue biasa yang hanya menerima rvalue. Pastikan T dideduksi dari pemanggilan parameter tersebut agar sifat forwarding referencenya aktif.
  • Lupa std::forward pada wrapper. Parameter bernama adalah lvalue, sehingga seluruh pemanggilan lanjutan jatuh ke overload penyalinan dan argumen rvalue kehilangan perlakuannya. Bungkus penerusan dengan std::forward<T> seperti pada contoh di atas.
  • Meneruskan argumen yang sama lebih dari sekali. Setelah diteruskan sebagai rvalue, argumen pada wrapper berada dalam keadaan sah yang nilainya tidak ditetapkan standar. Simpan argumennya ke variabel bila masih perlu dipakai setelah pemanggilan pertama.
  • Menggunakan std::move di dalam wrapper yang meneruskan. Pemanggilan terima(std::move(nilai)) memaksa seluruh argumen berperilaku sebagai rvalue, sehingga argumen lvalue pemanggil ikut tercuri padahal masih akan digunakan. Gunakan std::forward<T> agar keputusannya menuruti kategori asli argumennya.

Ringkasan#

  • Parameter yang bernama selalu ekspresi lvalue, sehingga wrapper tanpa std::forward kehilangan informasi rvalue dari argumennya.
  • T&& yang tipenya sedang dideduksi menjadi forwarding reference, dan pelipatan referensi menjaga parameternya sebagai U& untuk lvalue atau U&& untuk rvalue.
  • std::forward<T> melakukan cast bersyarat sehingga kategori argumen aslinya tetap utuh sampai pemanggilan terakhir.

Referensi#