Motivasi dan penggunaan konstruktor bergerak di C ++

17

Saya baru-baru ini telah membaca tentang memindahkan konstruktor di C ++ (lihat misalnya di sini ) dan saya mencoba memahami bagaimana mereka bekerja dan kapan saya harus menggunakannya.

Sejauh yang saya mengerti, move constructor digunakan untuk mengurangi masalah kinerja yang disebabkan oleh menyalin objek besar. Halaman wikipedia mengatakan: "Masalah kinerja kronis dengan C ++ 03 adalah salinan dalam yang mahal dan tidak perlu yang dapat terjadi secara implisit ketika objek dilewatkan oleh nilai."

Saya biasanya mengatasi situasi seperti itu

  • dengan melewati objek dengan referensi, atau
  • dengan menggunakan pointer pintar (mis. boost :: shared_ptr) untuk meneruskan objek (pointer pintar disalin bukan objek).

Apa situasi di mana kedua teknik di atas tidak cukup dan menggunakan konstruktor bergerak lebih nyaman?

Giorgio
sumber
1
Selain fakta bahwa semantik bergerak dapat mencapai lebih banyak (seperti yang dikatakan dalam jawaban), Anda tidak boleh bertanya situasi apa yang lewat dengan referensi atau dengan penunjuk pintar tidak cukup, tetapi jika teknik itu benar-benar cara terbaik dan terbersih untuk melakukannya (berhati-hatilah shared_ptrhanya demi menyalin cepat) dan jika memindahkan semantik dapat mencapai hal yang sama dengan hampir tidak ada coding, semantik, dan kebersihan-penalti.
Chris berkata Reinstate Monica

Jawaban:

16

Pindahkan semantik memperkenalkan seluruh dimensi ke C ++ - ini bukan hanya memungkinkan Anda mengembalikan nilai dengan murah.

Misalnya, tanpa langkah-semantik std::unique_ptrtidak berfungsi - lihat std::auto_ptr, yang sudah usang dengan pengenalan langkah-semantik dan dihapus di C ++ 17. Memindahkan sumber daya sangat berbeda dengan menyalinnya. Hal ini memungkinkan transfer kepemilikan barang unik.

Sebagai contoh, jangan melihat std::unique_ptr, karena cukup dibahas. Mari kita lihat, katakanlah, Objek Penyangga Vertex di OpenGL. Buffer vertex mewakili memori pada GPU - itu perlu dialokasikan dan dideallocated menggunakan fungsi khusus, mungkin memiliki kendala ketat pada berapa lama itu bisa hidup. Penting juga bahwa hanya satu pemilik yang menggunakannya.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Sekarang, ini bisa dilakukan dengan std::shared_ptr- tetapi sumber daya ini tidak untuk dibagikan. Ini membuatnya membingungkan untuk menggunakan pointer bersama. Anda bisa menggunakan std::unique_ptr, tetapi itu masih membutuhkan semantik bergerak.

Jelas, saya belum menerapkan konstruktor bergerak, tetapi Anda mendapatkan idenya.

Yang relevan di sini adalah bahwa beberapa sumber tidak dapat disalin . Anda dapat membagikan petunjuk alih-alih memindahkan, tetapi kecuali jika Anda menggunakan unique_ptr, ada masalah kepemilikan. Bermanfaat untuk menjadi sejelas mungkin seperti apa maksud dari kode itu, jadi seorang pembangun-konstruktor mungkin adalah pendekatan terbaik.

Maks
sumber
Terima kasih atas jawabannya. Apa yang akan terjadi jika seseorang menggunakan pointer bersama di sini?
Giorgio
Saya mencoba menjawab sendiri: menggunakan pointer bersama tidak akan memungkinkan untuk mengontrol masa pakai objek, sementara itu adalah persyaratan bahwa objek hanya dapat hidup selama jangka waktu tertentu.
Giorgio
3
@ Giorgio Anda bisa menggunakan pointer yang dibagikan, tetapi secara semantik akan salah. Berbagi buffer tidak mungkin dilakukan. Juga, itu pada dasarnya akan membuat Anda meneruskan sebuah pointer ke sebuah pointer (karena vbo pada dasarnya adalah sebuah pointer unik ke memori GPU). Seseorang yang melihat kode Anda nanti mungkin bertanya-tanya 'Mengapa ada pointer bersama di sini? Apakah ini sumber daya bersama? Itu mungkin bug! '. Lebih baik sejelas mungkin dengan maksud aslinya.
Maks
@Iorgio Ya, itu juga bagian dari persyaratan. Ketika 'renderer' dalam kasus ini ingin membatalkan alokasi beberapa sumber daya (mungkin tidak cukup memori untuk objek baru pada GPU), tidak boleh ada pegangan lain ke memori. Menggunakan shared_ptr yang keluar dari ruang lingkup akan berfungsi jika Anda tidak menyimpannya di tempat lain, tetapi mengapa tidak membuatnya sepenuhnya jelas ketika Anda bisa?
Maks
@Iorgio Lihat hasil edit saya untuk percobaan lain dalam klarifikasi.
Maks
5

Memindahkan semantik tidak selalu merupakan peningkatan besar saat Anda mengembalikan nilai - dan ketika / jika Anda menggunakan shared_ptr(atau yang serupa) Anda mungkin pesimisasi secara prematur. Pada kenyataannya, hampir semua kompiler modern melakukan apa yang disebut Return Value Optimization (RVO) dan Named Return Value Optimization (NRVO). Ini berarti bahwa ketika Anda mengembalikan nilai, bukannya benar-benar menyalin nilai sama sekali, mereka hanya melewatkan pointer / referensi tersembunyi ke tempat nilai akan diberikan setelah pengembalian, dan fungsi menggunakannya untuk membuat nilai di mana itu akan berakhir. Standar C ++ menyertakan ketentuan khusus untuk memperbolehkan ini, jadi bahkan jika (misalnya) copy constructor Anda memiliki efek samping yang terlihat, itu tidak diharuskan untuk menggunakan copy constructor untuk mengembalikan nilai. Sebagai contoh:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

Ide dasar di sini cukup sederhana: buat kelas dengan konten yang cukup, kami lebih suka menghindari menyalinnya, jika mungkin ( std::vectorkami isi dengan 32767 int acak). Kami memiliki ctor salinan eksplisit yang akan menunjukkan kepada kami kapan / jika itu akan disalin. Kami juga memiliki sedikit lebih banyak kode untuk melakukan sesuatu dengan nilai acak dalam objek, sehingga pengoptimal tidak akan (setidaknya dengan mudah) menghilangkan segala sesuatu tentang kelas hanya karena ia tidak melakukan apa-apa.

Kami kemudian memiliki beberapa kode untuk mengembalikan salah satu objek ini dari suatu fungsi, dan kemudian menggunakan penjumlahan untuk memastikan objek benar-benar dibuat, tidak hanya diabaikan sepenuhnya. Ketika kami menjalankannya, paling tidak dengan kompiler terbaru / modern, kami menemukan bahwa copy constructor yang kami tulis tidak pernah berjalan sama sekali - dan ya, saya cukup yakin bahwa bahkan salinan cepat dengan yang shared_ptrmasih lebih lambat daripada tidak menyalin. sama sekali.

Bergerak memungkinkan Anda melakukan banyak hal yang tidak dapat Anda lakukan (langsung) secara adil. Pertimbangkan bagian "gabungan" dari semacam gabungan eksternal - Anda memiliki, katakanlah, 8 file yang akan Anda gabungkan bersama. Idealnya Anda ingin menempatkan semua 8 file tersebut ke dalam vector- tetapi karena vector( sejak C ++ 03) harus dapat menyalin elemen, dan ifstreamtidak dapat disalin, Anda terjebak dengan beberapa unique_ptr/ shared_ptr, atau sesuatu pada urutan itu untuk dapat menempatkan mereka dalam vektor. Perhatikan bahwa bahkan jika (misalnya) kita memberi reserveruang dalam vectorjadi kami yakin bahwa kami ifstreamtidak akan pernah benar-benar disalin, kompiler tidak akan tahu itu, jadi kode tidak akan dikompilasi meskipun kita tahu konstruktor salinan tidak akan pernah menjadi tetap digunakan.

Meskipun masih tidak dapat disalin, dalam C ++ 11 sebuah ifstream dapat dipindahkan. Dalam hal ini, objek mungkin tidak akan pernah dipindahkan, tetapi fakta bahwa mereka bisa jika perlu membuat compiler senang, sehingga kita dapat menempatkan ifstreamobjek kita secara vectorlangsung, tanpa ada peretas pointer pintar.

Sebuah vektor yang tidak memperluas adalah contoh yang cukup baik dari waktu bahwa langkah semantik benar-benar dapat menjadi / adalah meskipun berguna. Dalam hal ini, RVO / NRVO tidak akan membantu, karena kami tidak berurusan dengan nilai pengembalian dari suatu fungsi (atau yang serupa). Kami memiliki satu vektor yang menahan beberapa objek, dan kami ingin memindahkan objek-objek itu ke dalam memori baru yang lebih besar.

Di C ++ 03, itu dilakukan dengan membuat salinan objek di memori baru, lalu menghancurkan objek lama di memori lama. Membuat semua salinan itu hanya untuk membuang yang lama, bagaimanapun, cukup membuang waktu. Di C ++ 11, Anda bisa berharap mereka akan dipindahkan. Ini biasanya memungkinkan kita, pada dasarnya, melakukan salinan dangkal alih-alih salinan yang dalam (umumnya jauh lebih lambat). Dengan kata lain, dengan string atau vektor (hanya untuk beberapa contoh) kami hanya menyalin pointer (s) di objek, daripada membuat salinan dari semua data yang merujuk pointer.

Jerry Coffin
sumber
Terima kasih atas penjelasannya yang sangat rinci. Jika saya mengerti dengan benar, semua situasi di mana pergerakan berperan dapat ditangani oleh pointer normal, tetapi itu akan menjadi tidak aman (kompleks dan rawan kesalahan) untuk memprogram semua pointer juggling setiap kali. Jadi, sebagai gantinya, ada beberapa unique_ptr (atau mekanisme serupa) di bawah tenda dan semantik langkah memastikan bahwa pada akhir hari hanya ada beberapa menyalin pointer dan tidak ada menyalin objek.
Giorgio
@Iorgio: Ya, itu benar sekali. Bahasa tidak benar-benar menambahkan semantik bergerak; itu menambah referensi nilai. Referensi nilai (cukup jelas) dapat mengikat nilai, dalam hal ini Anda tahu aman untuk "mencuri" representasi internal data dan hanya menyalin pointer daripada melakukan salinan yang mendalam.
Jerry Coffin
4

Mempertimbangkan:

vector<string> v;

Ketika menambahkan string ke v, itu akan berkembang sesuai kebutuhan, dan pada setiap realokasi string harus disalin. Dengan memindahkan konstruktor, ini pada dasarnya bukan masalah.

Tentu saja, Anda juga bisa melakukan sesuatu seperti:

vector<unique_ptr<string>> v;

Tapi itu akan bekerja dengan baik hanya karena std::unique_ptrmengimplementasikan konstruktor bergerak.

Menggunakan std::shared_ptrhanya masuk akal dalam situasi (jarang) ketika Anda benar-benar memiliki kepemilikan bersama.

Nemanja Trifunovic
sumber
tetapi bagaimana jika bukannya stringkita memiliki contoh di Foomana ia memiliki 30 anggota data? The unique_ptrVersi tidak akan lebih efisien?
Vassilis
2

Nilai pengembalian adalah tempat paling sering saya ingin melewati nilai alih-alih semacam referensi. Mampu dengan cepat mengembalikan objek 'di stack' tanpa penalti performa yang besar akan menyenangkan. Di sisi lain, itu tidak terlalu sulit untuk mengatasi ini (pointer bersama sangat mudah digunakan ...), jadi saya tidak yakin itu benar-benar layak melakukan pekerjaan ekstra pada objek saya hanya untuk dapat melakukan ini.

Michael Kohne
sumber
Saya juga biasanya menggunakan pointer pintar untuk membungkus objek yang dikembalikan dari fungsi / metode.
Giorgio
1
@Iorgio: Itu jelas membingungkan dan lambat.
DeadMG
Kompiler modern harus melakukan gerakan otomatis jika Anda mengembalikan objek on-the-stack yang sederhana, sehingga tidak perlu untuk berbagi ptr dll.
Christian Severin