Mengapa kita menyalin lalu pindah?


98

Saya melihat kode di suatu tempat di mana seseorang memutuskan untuk menyalin objek dan kemudian memindahkannya ke anggota data kelas. Ini membuat saya bingung karena saya pikir inti dari pindah adalah untuk menghindari penyalinan. Berikut contohnya:

struct S
{
    S(std::string str) : data(std::move(str))
    {}
};

Inilah pertanyaan saya:

  • Mengapa kita tidak mengambil rvalue-reference str?
  • Bukankah salinannya mahal, terutama jika diberi sesuatu seperti itu std::string?
  • Apa alasan penulis memutuskan untuk membuat salinan lalu pindah?
  • Kapan saya harus melakukan ini sendiri?

Sepertinya kesalahan yang konyol bagi saya, tetapi saya akan tertarik untuk melihat apakah seseorang yang memiliki lebih banyak pengetahuan tentang masalah ini memiliki sesuatu untuk dikatakan.
Dave


T&J ini yang awalnya saya lupa tautkan mungkin juga relevan dengan topik.
Andy Prowl

Jawaban:


97

Sebelum saya menjawab pertanyaan Anda, satu hal yang tampaknya Anda salah: menganggap nilai dalam C ++ 11 tidak selalu berarti menyalin. Jika nilai r dilewatkan, itu akan dipindahkan (asalkan ada konstruktor pemindahan yang layak) daripada disalin. Dan std::stringmemang memiliki konstruktor bergerak.

Tidak seperti di C ++ 03, di C ++ 11 sering idiomatis untuk mengambil parameter berdasarkan nilai, untuk alasan yang akan saya jelaskan di bawah. Lihat juga Tanya Jawab ini di StackOverflow untuk kumpulan pedoman yang lebih umum tentang cara menerima parameter.

Mengapa kita tidak mengambil rvalue-reference str?

Karena itu akan membuat tidak mungkin untuk melewatkan lvalues, seperti di:

std::string s = "Hello";
S obj(s); // s is an lvalue, this won't compile!

Jika Shanya memiliki konstruktor yang menerima rvalues, hal di atas tidak akan dikompilasi.

Bukankah salinannya mahal, terutama jika diberi sesuatu seperti itu std::string?

Jika Anda melewatkan nilai r, nilai itu akan dipindahkan ke str, dan pada akhirnya akan dipindahkan ke data. Tidak ada penyalinan yang akan dilakukan. Sebaliknya, jika Anda memberikan nilai l, nilai l itu akan disalin ke str, dan kemudian dipindahkan ke data.

Jadi untuk meringkasnya, dua gerakan untuk nilai r, satu salinan dan satu gerakan untuk nilai l.

Apa alasan penulis memutuskan untuk membuat salinan lalu pindah?

Pertama-tama, seperti yang saya sebutkan di atas, yang pertama tidak selalu merupakan salinan; dan ini berkata, jawabannya adalah: " Karena efisien (memindahkan std::stringbenda murah) dan sederhana ".

Dengan asumsi bahwa pergerakan itu murah (mengabaikan SSO di sini), mereka praktis dapat diabaikan saat mempertimbangkan efisiensi keseluruhan dari desain ini. Jika kami melakukannya, kami memiliki satu salinan untuk lvalues ​​(seperti yang akan kami miliki jika kami menerima referensi lvalue const) dan tidak ada salinan untuk rvalues ​​(sementara kami masih akan memiliki salinan jika kami menerima referensi lvalue const).

Ini berarti bahwa mengambil nilai sama baiknya dengan mengambil dengan referensi nilai constl saat nilai l diberikan, dan lebih baik lagi saat nilai r diberikan.

PS: Untuk memberikan beberapa konteks, saya yakin ini adalah T&J yang dimaksud OP.


2
Layak untuk disebutkan, ini adalah pola C ++ 11 yang menggantikan penerusan const T&argumen: dalam kasus terburuk (lvalue) ini sama, tetapi dalam kasus sementara Anda hanya perlu memindahkan yang sementara. Menang-menang.
syam

3
@ user2030677: Tidak ada jalan keluar dari salinan itu, kecuali Anda menyimpan referensi.
Benjamin Lindley

5
@ user2030677: Siapa yang peduli betapa mahalnya salinan selama Anda membutuhkannya (dan Anda melakukannya, jika Anda ingin menyimpan salinan di dataanggota Anda )? Anda akan memiliki salinannya bahkan jika Anda akan mengambil dengan referensi lvalueconst
Andy Prowl

3
@BenjaminLindley: Sebagai permulaan, saya menulis: " Dengan asumsi bahwa pergerakan itu murah, mereka praktis dapat diabaikan saat mempertimbangkan efisiensi keseluruhan dari desain ini. ". Jadi ya, akan ada biaya overhead dari suatu perpindahan, tetapi itu harus dianggap dapat diabaikan kecuali ada bukti bahwa ini adalah masalah nyata yang membenarkan perubahan desain sederhana menjadi sesuatu yang lebih efisien.
Andy Prowl

1
@ user2030677: Tapi itu adalah contoh yang sama sekali berbeda. Dalam contoh dari pertanyaan Anda, Anda selalu berakhir dengan memegang salinannya data!
Andy Prowl

51

Untuk memahami mengapa ini adalah pola yang baik, kita harus memeriksa alternatifnya, baik di C ++ 03 dan di C ++ 11.

Kami memiliki metode C ++ 03 untuk mengambil std::string const&:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str)
  {}
};

dalam hal ini, akan selalu ada satu salinan yang dilakukan. Jika Anda membuat dari string C mentah, a std::stringakan dibangun, lalu disalin lagi: dua alokasi.

Ada metode C ++ 03 untuk mengambil referensi ke a std::string, lalu menukarnya menjadi lokal std::string:

struct S
{
  std::string data; 
  S(std::string& str)
  {
    std::swap(data, str);
  }
};

itu adalah "semantik bergerak" versi C ++ 03, dan swapsering kali dapat dioptimalkan agar sangat murah untuk dilakukan (seperti a move). Ini juga harus dianalisis dalam konteks:

S tmp("foo"); // illegal
std::string s("foo");
S tmp2(s); // legal

dan memaksa Anda untuk membentuk non-sementara std::string, lalu membuangnya. (Sementara std::stringtidak dapat mengikat ke referensi non-const). Namun, hanya satu alokasi yang dilakukan. Versi C ++ 11 akan mengambil &&dan meminta Anda untuk memanggilnya dengan std::move, atau dengan sementara: ini mengharuskan pemanggil secara eksplisit membuat salinan di luar panggilan, dan memindahkan salinan itu ke dalam fungsi atau konstruktor.

struct S
{
  std::string data; 
  S(std::string&& str): data(std::move(str))
  {}
};

Menggunakan:

S tmp("foo"); // legal
std::string s("foo");
S tmp2(std::move(s)); // legal

Selanjutnya, kita dapat melakukan versi C ++ 11 lengkap, yang mendukung penyalinan dan move:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str) {} // lvalue const, copy
  S(std::string && str) : data(std::move(str)) {} // rvalue, move
};

Kami kemudian dapat memeriksa bagaimana ini digunakan:

S tmp( "foo" ); // a temporary `std::string` is created, then moved into tmp.data

std::string bar("bar"); // bar is created
S tmp2( bar ); // bar is copied into tmp.data

std::string bar2("bar2"); // bar2 is created
S tmp3( std::move(bar2) ); // bar2 is moved into tmp.data

Cukup jelas bahwa teknik kelebihan beban 2 ini setidaknya sama efisiennya, jika tidak lebih efisien, daripada dua gaya C ++ 03 di atas. Saya akan menjuluki versi 2-kelebihan ini sebagai versi "paling optimal".

Sekarang, kita akan memeriksa versi take-by-copy:

struct S2 {
  std::string data;
  S2( std::string arg ):data(std::move(x)) {}
};

di setiap skenario tersebut:

S2 tmp( "foo" ); // a temporary `std::string` is created, moved into arg, then moved into S2::data

std::string bar("bar"); // bar is created
S2 tmp2( bar ); // bar is copied into arg, then moved into S2::data

std::string bar2("bar2"); // bar2 is created
S2 tmp3( std::move(bar2) ); // bar2 is moved into arg, then moved into S2::data

Jika Anda membandingkan ini secara berdampingan dengan versi "paling optimal", kami melakukan satu tambahan move! Tidak sekali kami melakukan ekstra copy.

Jadi jika kami menganggap itu movemurah, versi ini memberi kami kinerja yang hampir sama dengan versi paling optimal, tetapi kode 2 kali lebih sedikit.

Dan jika Anda mengambil 2 hingga 10 argumen, pengurangan kode adalah eksponensial - 2x kali lebih kecil dengan 1 argumen, 4x dengan 2, 8x dengan 3, 16x dengan 4, 1024x dengan 10 argumen.

Sekarang, kita bisa menyiasatinya melalui penerusan sempurna dan SFINAE, memungkinkan Anda untuk menulis satu konstruktor atau template fungsi yang membutuhkan 10 argumen, melakukan SFINAE untuk memastikan bahwa argumen memiliki jenis yang sesuai, dan kemudian memindahkan-atau-menyalinnya ke dalam negara bagian lokal sesuai kebutuhan. Meskipun hal ini mencegah masalah ukuran program yang bertambah ribuan kali lipat, masih ada tumpukan fungsi yang dihasilkan dari template ini. (Instansiasi fungsi template menghasilkan fungsi)

Dan banyak fungsi yang dihasilkan berarti ukuran kode yang dapat dieksekusi lebih besar, yang dengan sendirinya dapat mengurangi kinerja.

Dengan biaya beberapa movedetik, kita mendapatkan kode yang lebih pendek dan kinerja yang hampir sama, dan seringkali lebih mudah untuk memahami kode.

Sekarang, ini hanya berfungsi karena kita tahu, ketika fungsi (dalam hal ini, konstruktor) dipanggil, bahwa kita akan menginginkan salinan lokal dari argumen itu. Idenya adalah jika kita tahu bahwa kita akan membuat salinan, kita harus memberi tahu penelepon bahwa kita sedang membuat salinan dengan memasukkannya ke dalam daftar argumen kita. Mereka kemudian dapat mengoptimalkan sekitar fakta bahwa mereka akan memberi kita salinannya (dengan beralih ke argumen kita, misalnya).

Keuntungan lain dari teknik 'ambil dengan nilai "adalah bahwa sering memindahkan konstruktor tidak terkecuali. Itu berarti fungsi yang mengambil nilai demi dan keluar dari argumennya sering kali tidak terkecuali, memindahkan apa pun throwkeluar dari tubuhnya dan ke dalam lingkup pemanggilan (yang kadang-kadang dapat menghindarinya melalui konstruksi langsung, atau membangun item dan movemenjadi argumen, untuk mengontrol di mana terjadi lemparan) Membuat metode nothrow seringkali sepadan.


Saya juga akan menambahkan jika kita tahu bahwa kita akan membuat salinan, kita harus membiarkan kompiler melakukannya, karena kompilator selalu lebih tahu.
Rayniery

6
Sejak saya menulis ini, keuntungan lain telah ditunjukkan kepada saya: sering menyalin konstruktor dapat melempar, sedangkan konstruktor sering bergerak noexcept. Dengan mengambil data demi-salinan, Anda dapat membuat fungsi Anda noexcept, dan memiliki konstruksi salinan yang menyebabkan potensi lemparan (seperti kehabisan memori) terjadi di luar pemanggilan fungsi Anda.
Yakk - Adam Nevraumont

Mengapa Anda memerlukan versi "lvalue non-const, copy" dalam teknik 3 overload? Bukankah "lvalue const, copy" juga menangani kasus non const?
Bruno Martinez

@Brunoartinez kami tidak!
Yakk - Adam Nevraumont

13

Ini mungkin disengaja dan mirip dengan idiom salin dan tukar . Pada dasarnya karena string disalin sebelum konstruktor, konstruktor itu sendiri adalah pengecualian aman karena hanya menukar (memindahkan) string sementara str.


1 untuk paralel salin dan tukar. Memang memiliki banyak kesamaan.
syam

11

Anda tidak ingin mengulang diri sendiri dengan menulis konstruktor untuk pemindahan dan satu untuk salinannya:

S(std::string&& str) : data(std::move(str)) {}
S(const std::string& str) : data(str) {}

Ini banyak kode boilerplate, terutama jika Anda memiliki banyak argumen. Solusi Anda menghindari duplikasi pada biaya perpindahan yang tidak perlu. (Namun, operasi pemindahan seharusnya cukup murah.)

Idiom yang bersaing adalah menggunakan penerusan sempurna:

template <typename T>
S(T&& str) : data(std::forward<T>(str)) {}

Template ajaib akan memilih untuk dipindahkan atau disalin tergantung pada parameter yang Anda berikan. Ini pada dasarnya memperluas ke versi pertama, di mana kedua konstruktor ditulis dengan tangan. Untuk informasi latar belakang, lihat posting Scott Meyer tentang referensi universal .

Dari aspek kinerja, versi penerusan yang sempurna lebih unggul dari versi Anda karena menghindari gerakan yang tidak perlu. Namun, orang dapat membantah bahwa versi Anda lebih mudah dibaca dan ditulis. Dampak kinerja yang mungkin terjadi seharusnya tidak menjadi masalah dalam kebanyakan situasi, jadi pada akhirnya ini tampaknya menjadi masalah gaya.

Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.