Bagaimana cara melewatkan parameter dengan benar?

108

Saya seorang pemula C ++ tetapi bukan pemula pemrograman. Saya mencoba mempelajari C ++ (c ++ 11) dan bagi saya hal yang paling tidak jelas adalah parameter yang lewat.

Saya mempertimbangkan contoh sederhana ini:

  • Kelas yang semua anggotanya memiliki tipe primitif:
    CreditCard(std::string number, int expMonth, int expYear,int pin):number(number), expMonth(expMonth), expYear(expYear), pin(pin)

  • Kelas yang memiliki tipe primitif sebagai anggota + 1 tipe kompleks:
    Account(std::string number, float amount, CreditCard creditCard) : number(number), amount(amount), creditCard(creditCard)

  • Kelas yang memiliki tipe primitif sebagai anggota + 1 kumpulan dari beberapa tipe kompleks: Client(std::string firstName, std::string lastName, std::vector<Account> accounts):firstName(firstName), lastName(lastName), accounts(accounts)

Saat saya membuat akun, saya melakukan ini:

    CreditCard cc("12345",2,2015,1001);
    Account acc("asdasd",345, cc);

Jelas kartu kredit akan disalin dua kali dalam skenario ini. Jika saya menulis ulang konstruktor itu sebagai

Account(std::string number, float amount, CreditCard& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(creditCard)

akan ada satu salinan. Jika saya menulis ulang sebagai

Account(std::string number, float amount, CreditCard&& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(std::forward<CreditCard>(creditCard))

Akan ada 2 gerakan dan tidak ada salinan.

Saya pikir terkadang Anda mungkin ingin menyalin beberapa parameter, terkadang Anda tidak ingin menyalin saat Anda membuat objek itu.
Saya berasal dari C # dan, digunakan untuk referensi, itu agak aneh bagi saya dan saya pikir harus ada 2 kelebihan beban untuk setiap parameter tetapi saya tahu saya salah.
Apakah ada praktik terbaik tentang cara mengirim parameter dalam C ++ karena saya benar-benar menemukannya, katakanlah, tidak sepele. Bagaimana Anda menangani contoh saya yang disajikan di atas?

Jack Willson
sumber
9
Meta: Saya tidak percaya seseorang baru saja menanyakan pertanyaan C ++ yang bagus. +1.
23
FYI, std::stringadalah kelas, sama seperti CreditCard, bukan tipe primitif.
chris
7
Karena kebingungan string, meskipun tidak terkait, Anda harus tahu bahwa string literal,, "abc"bukanlah tipe std::string, bukan tipe char */const char *, tetapi tipe const char[N](dengan N = 4 dalam kasus ini karena tiga karakter dan null). Itu bagus, kesalahpahaman umum untuk disingkirkan.
chris
10
@chuex: Jon Skeet menjawab semua pertanyaan C #, lebih jarang C ++.
Steve Jessop

Jawaban:

158

PERTANYAAN PALING PENTING PERTAMA:

Apakah ada praktik terbaik tentang cara mengirim parameter dalam C ++ karena saya benar-benar menemukannya, katakanlah, tidak sepele

Jika fungsi Anda perlu mengubah objek asli yang sedang diteruskan, sehingga setelah panggilan kembali, modifikasi pada objek itu akan terlihat oleh pemanggil, Anda harus meneruskan referensi lvalue :

void foo(my_class& obj)
{
    // Modify obj here...
}

Jika fungsi Anda tidak perlu memodifikasi objek aslinya, dan tidak perlu membuat salinannya (dengan kata lain, hanya perlu mengamati statusnya), maka Anda harus meneruskan referensi lvalue keconst :

void foo(my_class const& obj)
{
    // Observe obj here
}

Ini akan memungkinkan Anda untuk memanggil fungsi baik dengan lvalues ​​(lvalues ​​adalah objek dengan identitas stabil) dan dengan rvalues ​​(rvalues ​​adalah, misalnya temporaries , atau objek yang akan Anda pindahkan sebagai hasil pemanggilan std::move()).

Seseorang juga dapat berargumen bahwa untuk tipe atau tipe fundamental yang penyalinannya cepat , sepertiint , bool, atau char, tidak perlu lewat referensi jika fungsi hanya perlu mengamati nilai, dan lewat nilai harus disukai . Itu benar jika semantik referensi tidak diperlukan, tetapi bagaimana jika fungsi ingin menyimpan pointer ke objek input yang sama di suatu tempat, sehingga pembacaan di masa depan melalui pointer itu akan melihat modifikasi nilai yang telah dilakukan di beberapa bagian lain dari kode? Dalam hal ini, melewatkan referensi adalah solusi yang tepat.

Jika fungsi Anda tidak perlu mengubah objek asli, tetapi perlu menyimpan salinan objek itu ( mungkin untuk mengembalikan hasil transformasi input tanpa mengubah input ), maka Anda dapat mempertimbangkan untuk mengambil berdasarkan nilai :

void foo(my_class obj) // One copy or one move here, but not working on
                       // the original object...
{
    // Working on obj...

    // Possibly move from obj if the result has to be stored somewhere...
}

Memanggil fungsi di atas akan selalu menghasilkan satu salinan saat meneruskan nilai l, dan satu salinan saat meneruskan nilai r. Jika fungsi Anda perlu untuk menyimpan objek di suatu tempat ini, Anda bisa melakukan tambahan langkah darinya (misalnya, dalam kasus foo()ini adalah fungsi anggota yang perlu menyimpan nilai dalam anggota data ).

Dalam kasus bergerak mahal untuk objek jenismy_class , maka Anda dapat mempertimbangkan untuk membebani foo()dan memberikan satu versi untuk lvalues ​​(menerima referensi lvalue const) dan satu versi untuk rvalues ​​(menerima referensi rvalue):

// Overload for lvalues
void foo(my_class const& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = obj; // Copy!
    // Working on copyOfObj...
}

// Overload for rvalues
void foo(my_class&& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = std::move(obj); // Move! 
                                         // Notice, that invoking std::move() is 
                                         // necessary here, because obj is an
                                         // *lvalue*, even though its type is 
                                         // "rvalue reference to my_class".
    // Working on copyOfObj...
}

Fungsi di atas sangat mirip, pada kenyataannya, Anda bisa membuat satu fungsi darinya: foo()bisa menjadi template fungsi dan Anda bisa menggunakan penerusan sempurna untuk menentukan apakah perpindahan atau salinan objek yang diteruskan akan dihasilkan secara internal:

template<typename C>
void foo(C&& obj) // No copy, no move (just reference binding)
//       ^^^
//       Beware, this is not always an rvalue reference! This will "magically"
//       resolve into my_class& if an lvalue is passed, and my_class&& if an
//       rvalue is passed
{
    my_class copyOfObj = std::forward<C>(obj); // Copy if lvalue, move if rvalue
    // Working on copyOfObj...
}

Anda mungkin ingin mempelajari lebih lanjut tentang desain ini dengan menonton ceramah oleh Scott Meyers ini (ingatlah fakta bahwa istilah " Referensi Universal " yang dia gunakan tidak standar).

Satu hal yang perlu diingat adalah bahwa std::forwardbiasanya akan berakhir di a perpindahan nilai r, jadi meskipun terlihat relatif tidak berbahaya, meneruskan objek yang sama beberapa kali dapat menjadi sumber masalah - misalnya, berpindah dari objek yang sama dua kali! Jadi berhati-hatilah untuk tidak mengulanginya, dan jangan meneruskan argumen yang sama beberapa kali dalam pemanggilan fungsi:

template<typename C>
void foo(C&& obj)
{
    bar(std::forward<C>(obj), std::forward<C>(obj)); // Dangerous!
}

Perhatikan juga, bahwa Anda biasanya tidak menggunakan solusi berbasis template kecuali Anda memiliki alasan kuat untuk itu, karena membuat kode Anda lebih sulit untuk dibaca. Biasanya, Anda harus fokus pada kejelasan dan kesederhanaan .

Di atas hanyalah pedoman sederhana, tetapi sebagian besar waktu itu akan mengarahkan Anda ke keputusan desain yang baik.


TENTANG SISA POSTING ANDA:

Jika saya menulis ulang sebagai [...] akan ada 2 gerakan dan tidak ada salinan.

Ini tidak benar. Untuk memulainya, referensi rvalue tidak bisa mengikat ke lvalue, jadi ini hanya akan dikompilasi ketika Anda meneruskan rvalue type CreditCardke konstruktor Anda. Misalnya:

// Here you are passing a temporary (OK! temporaries are rvalues)
Account acc("asdasd",345, CreditCard("12345",2,2015,1001));

CreditCard cc("12345",2,2015,1001);
// Here you are passing the result of std::move (OK! that's also an rvalue)
Account acc("asdasd",345, std::move(cc));

Tetapi itu tidak akan berhasil jika Anda mencoba melakukan ini:

CreditCard cc("12345",2,2015,1001);
Account acc("asdasd",345, cc); // ERROR! cc is an lvalue

Karena ccadalah nilai l dan referensi nilai r tidak dapat mengikat ke nilai l. Selain itu, saat mengikat referensi ke suatu objek, tidak ada pemindahan yang dilakukan : itu hanya mengikat referensi. Jadi, hanya akan ada satu gerakan.


Jadi berdasarkan pedoman yang diberikan di bagian pertama jawaban ini, jika Anda khawatir dengan jumlah gerakan yang dihasilkan saat Anda mengambil CreditCardnilai by, Anda dapat menentukan dua overload konstruktor, satu mengambil referensi lvalue ke const( CreditCard const&) dan satu pengambilan referensi rvalue ( CreditCard&&).

Resolusi kelebihan beban akan memilih yang pertama saat meneruskan nilai l (dalam hal ini, satu salinan akan dilakukan) dan yang terakhir saat meneruskan nilai r (dalam hal ini, satu gerakan akan dilakukan).

Account(std::string number, float amount, CreditCard const& creditCard) 
: number(number), amount(amount), creditCard(creditCard) // copy here
{ }

Account(std::string number, float amount, CreditCard&& creditCard) 
: number(number), amount(amount), creditCard(std::move(creditCard)) // move here
{ }

Penggunaan Anda std::forward<>biasanya terlihat saat Anda ingin mencapai penerusan sempurna . Dalam hal ini, konstruktor Anda sebenarnya adalah template konstruktor , dan akan terlihat kurang lebih seperti berikut

template<typename C>
Account(std::string number, float amount, C&& creditCard) 
: number(number), amount(amount), creditCard(std::forward<C>(creditCard)) { }

Dalam arti tertentu, ini menggabungkan kedua overload yang telah saya tunjukkan sebelumnya menjadi satu fungsi tunggal: Cakan disimpulkan CreditCard&jika Anda meneruskan nilai l, dan karena referensi aturan menciutkan, ini akan menyebabkan fungsi ini dibuat:

Account(std::string number, float amount, CreditCard& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard&>(creditCard)) 
{ }

Hal ini akan menyebabkan copy-konstruksi dari creditCard, seperti yang Anda inginkan. Di sisi lain, ketika nilai r dilewatkan, Cakan disimpulkan menjadi CreditCard, dan fungsi ini akan dipakai sebagai gantinya:

Account(std::string number, float amount, CreditCard&& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard>(creditCard)) 
{ }

Hal ini akan menyebabkan langkah-konstruksi dari creditCard, yang adalah apa yang Anda inginkan (karena nilai yang berlalu adalah nilai p, dan itu berarti kami berwenang untuk berpindah dari itu).

Andy Prowl
sumber
Apakah salah untuk mengatakan bahwa untuk jenis parameter non-primitif tidak masalah untuk selalu menggunakan versi template dengan teruskan?
Jack Willson
1
@JackWillson: Saya akan mengatakan Anda harus menggunakan versi templat hanya ketika Anda benar-benar mengkhawatirkan kinerja gerakan. Lihat pertanyaan saya ini , yang memiliki jawaban bagus, untuk informasi lebih lanjut. Secara umum, jika Anda tidak perlu membuat salinan dan hanya perlu mengamati, lihat referensi const. Jika Anda perlu mengubah objek asli, gunakan ref ke non-`const. Jika Anda perlu membuat salinan dan bergerak murah, ambil berdasarkan nilai dan kemudian pindah.
Andy Prowl
2
Saya rasa tidak perlu memindahkan contoh kode ketiga. Anda bisa menggunakan objalih-alih membuat salinan lokal untuk dipindahkan, bukan?
juanchopanza
3
@AndyProwl: Anda mendapatkan +1 jam yang lalu, tetapi membaca ulang jawaban Anda (luar biasa), saya ingin menunjukkan dua hal: a) nilai sampingan tidak selalu membuat salinan (jika tidak dipindahkan). Objek dapat dibangun di tempat di situs penelepon, terutama dengan RVO / NRVO ini bahkan bekerja lebih sering daripada yang dipikirkan pertama kali. b) Harap tunjukkan bahwa dalam contoh terakhir, std::forwarddapat dipanggil hanya sekali . Saya telah melihat orang-orang memasukkannya ke dalam loop, dll. Dan karena jawaban ini akan dilihat oleh banyak pemula, IMHO harus ada label "Peringatan!" Yang gemuk untuk membantu mereka menghindari jebakan ini.
Daniel Frey
1
@SteveJessop: Dan selain itu, ada masalah teknis (tidak harus masalah) dengan fungsi penerusan ( terutama konstruktor yang mengambil satu argumen ), sebagian besar fakta bahwa mereka menerima argumen jenis apa pun dan dapat mengalahkan std::is_constructible<>sifat tipe kecuali jika mereka benar-benar SFINAE- dibatasi - yang mungkin tidak sepele bagi sebagian orang.
Andy Prowl
11

Pertama, izinkan saya mengoreksi beberapa detail. Saat Anda mengucapkan yang berikut:

akan ada 2 gerakan dan tidak ada salinan.

Itu salah. Mengikat ke referensi nilai r bukanlah suatu gerakan. Hanya ada satu gerakan.

Selain itu, karena CreditCardbukan parameter template, std::forward<CreditCard>(creditCard)ini hanyalah cara untuk mengatakannya secara verbose std::move(creditCard).

Sekarang...

Jika tipe Anda memiliki gerakan "murah", Anda mungkin ingin membuat hidup Anda mudah dan mengambil semuanya dengan nilai dan "std::move bersama".

Account(std::string number, float amount, CreditCard creditCard)
: number(std::move(number),
  amount(amount),
  creditCard(std::move(creditCard)) {}

Pendekatan ini akan menghasilkan dua gerakan jika hanya menghasilkan satu, tetapi jika gerakan itu murah, mungkin bisa diterima.

Sementara kita berada pada masalah "langkah murah", saya harus mengingatkan Anda itu std::string membahas ini sering diterapkan dengan apa yang disebut pengoptimalan string kecil, jadi gerakannya mungkin tidak semurah menyalin beberapa petunjuk. Seperti biasa dengan masalah pengoptimalan, apakah penting atau tidak adalah sesuatu yang ditanyakan kepada profiler Anda, bukan saya.

Apa yang harus dilakukan jika Anda tidak ingin melakukan gerakan ekstra itu? Mungkin mereka terbukti terlalu mahal, atau lebih buruk, mungkin jenisnya tidak dapat benar-benar dipindahkan dan Anda mungkin dikenakan salinan tambahan.

Jika hanya ada satu parameter yang bermasalah, Anda dapat memberikan dua kelebihan beban, dengan T const&danT&& . Itu akan mengikat referensi sepanjang waktu hingga inisialisasi anggota yang sebenarnya, di mana salinan atau pemindahan terjadi.

Namun, jika Anda memiliki lebih dari satu parameter, hal ini menyebabkan ledakan eksponensial dalam jumlah kelebihan beban.

Ini adalah masalah yang bisa diselesaikan dengan penerusan sempurna. Itu berarti Anda menulis template sebagai gantinya, dan gunakan std::forwarduntuk membawa kategori nilai argumen ke tujuan akhirnya sebagai anggota.

template <typename TString, typename TCreditCard>
Account(TString&& number, float amount, TCreditCard&& creditCard)
: number(std::forward<TString>(number),
  amount(amount),
  creditCard(std::forward<TCreditCard>(creditCard)) {}
R. Martinho Fernandes
sumber
Ada masalah dengan versi template: pengguna tidak dapat menulis Account("",0,{brace, initialisation})lagi.
ipc
@ ipc ah, benar. Itu benar-benar menjengkelkan, dan menurut saya tidak ada solusi yang mudah diskalakan.
R. Martinho Fernandes
6

Pertama-tama, std::stringadalah tipe kelas yang lumayan besar std::vector. Ini jelas tidak primitif.

Jika Anda mengambil tipe besar yang dapat dipindahkan berdasarkan nilai ke dalam konstruktor, saya akan std::movememasukkannya ke anggota:

CreditCard(std::string number, float amount, CreditCard creditCard)
  : number(std::move(number)), amount(amount), creditCard(std::move(creditCard))
{ }

Ini persis bagaimana saya akan merekomendasikan menerapkan konstruktor. Ini menyebabkan anggota numberdancreditCard untuk dipindahkan dibangun, daripada salinan dibangun. Saat Anda menggunakan konstruktor ini, akan ada satu salinan (atau pindahkan, jika sementara) saat objek diteruskan ke konstruktor dan kemudian satu gerakan saat menginisialisasi anggota.

Sekarang mari pertimbangkan konstruktor ini:

Account(std::string number, float amount, CreditCard& creditCard)
  : number(number), amount(amount), creditCard(creditCard)

Anda benar, ini akan melibatkan satu salinan creditCard, karena ini pertama kali diteruskan ke konstruktor melalui referensi. Tapi sekarang Anda tidak bisa meneruskan constobjek ke konstruktor (karena referensinya non- const) dan Anda tidak bisa meneruskan objek sementara. Misalnya, Anda tidak dapat melakukan ini:

Account account("something", 10.0f, CreditCard("12345",2,2015,1001));

Sekarang mari pertimbangkan:

Account(std::string number, float amount, CreditCard&& creditCard)
  : number(number), amount(amount), creditCard(std::forward<CreditCard>(creditCard))

Di sini Anda telah menunjukkan kesalahpahaman tentang referensi rvalue dan std::forward. Anda seharusnya hanya benar-benar menggunakan std::forwardketika objek yang Anda teruskan dideklarasikan sebagai T&&beberapa tipe deduksi T . Di sini CreditCardtidak disimpulkan (saya berasumsi), sehingga std::forwarddigunakan dalam kesalahan. Cari referensi universal .

Joseph Mansfield
sumber
1

Saya menggunakan aturan yang cukup sederhana untuk kasus umum: Gunakan salinan untuk POD (int, bool, double, ...) dan const & untuk yang lainnya ...

Dan mau menyalin atau tidak, tidak dijawab oleh metode tanda tangan tetapi lebih oleh apa yang Anda lakukan dengan paramaters.

struct A {
  A(const std::string& aValue, const std::string& another) 
    : copiedValue(aValue), justARef(another) {}
  std::string copiedValue;
  const std::string& justARef; 
};

presisi untuk penunjuk: Saya hampir tidak pernah menggunakannya. Hanya keuntungan dari & adalah bahwa mereka dapat menjadi nol, atau ditugaskan kembali.

David Fleury
sumber
2
"Saya menggunakan aturan yang cukup sederhana untuk kasus umum: Gunakan salinan untuk POD (int, bool, double, ...) dan const & untuk yang lainnya." Tidak. Hanya Tidak.
Sepatu
Dapat ditambahkan, jika Anda ingin mengubah nilai menggunakan & (no const). Kalau tidak, saya tidak mengerti ... kesederhanaan sudah cukup baik. Tapi jika Anda bilang begitu ...
David Fleury
Terkadang Anda ingin memodifikasi dengan referensi tipe primitif, dan terkadang Anda perlu membuat salinan objek dan terkadang Anda harus memindahkan objek. Anda tidak bisa begitu saja mengurangi semuanya menjadi POD> berdasarkan nilai dan UDT> dengan referensi.
Sepatu
Oke, ini hanya yang saya tambahkan setelahnya. Mungkin jawaban yang terlalu cepat.
David Fleury
1

Bagi saya, hal yang paling penting agak tidak jelas: parameter yang lewat.

  • Jika Anda ingin mengubah variabel yang dilewatkan di dalam fungsi / metode
    • Anda melewatkannya dengan referensi
    • Anda meneruskannya sebagai penunjuk (*)
  • Jika Anda ingin membaca nilai / variabel yang diteruskan di dalam fungsi / metode
    • Anda melewatkannya dengan referensi const
  • Jika Anda ingin mengubah nilai yang diteruskan di dalam fungsi / metode
    • Anda meneruskannya secara normal dengan menyalin objek (**)

(*) pointer mungkin mengacu pada memori yang dialokasikan secara dinamis, oleh karena itu jika memungkinkan Anda harus lebih memilih referensi daripada pointer bahkan jika referensi, pada akhirnya, biasanya diimplementasikan sebagai pointer.

(**) "normal" berarti dengan konstruktor salinan (jika Anda mengirimkan objek dengan tipe parameter yang sama) atau dengan konstruktor normal (jika Anda meneruskan tipe yang kompatibel untuk kelas). Ketika Anda mengirimkan sebuah objek myMethod(std::string), misalnya, salinan konstruktor akan digunakan jika sebuah std::stringdilewatkan, oleh karena itu Anda harus memastikan bahwa objek itu ada.

Sepatu
sumber