Pertanyaan Apa idiom copy-and-swap?


Apa idiom ini dan kapan seharusnya itu digunakan? Masalah apa yang dipecahkannya? Apakah idiom berubah ketika C ++ 11 digunakan?

Meskipun telah disebutkan di banyak tempat, kami tidak memiliki pertanyaan "apa pun" dan jawabannya, jadi inilah dia. Berikut ini sebagian daftar tempat-tempat yang disebutkan sebelumnya:


1668
2017-07-19 08:42


asal


Jawaban:


Ikhtisar

Mengapa kita membutuhkan idiom copy-and-swap?

Setiap kelas yang mengelola sumber daya (a pembungkus, seperti pointer pintar) perlu diimplementasikan The Big Three. Sementara tujuan dan implementasi dari copy-constructor dan destructor sangatlah mudah, operator copy-assignment bisa dibilang yang paling bernuansa dan sulit. Bagaimana cara melakukannya? Kesulitan apa yang harus dihindari?

Itu idiom copy-and-swap adalah solusinya, dan dengan elegan membantu operator penugasan dalam mencapai dua hal: menghindari duplikasi kode, dan menyediakan jaminan eksepsi yang kuat.

Bagaimana cara kerjanya?

Secara konseptual, ia bekerja dengan menggunakan fungsi copy-constructor untuk membuat salinan lokal dari data, kemudian mengambil data yang disalin dengan swap berfungsi, menukar data lama dengan data baru. Salinan sementara kemudian merusak, mengambil data lama dengannya. Kami ditinggalkan dengan salinan data baru.

Untuk menggunakan idiom copy-and-swap, kita membutuhkan tiga hal: copy-constructor yang berfungsi, destruktor yang berfungsi (keduanya adalah dasar dari setiap pembungkus, jadi harus lengkap pula), dan swap fungsi.

Fungsi swap adalah a tidak melempar fungsi yang menukar dua objek dari kelas, anggota untuk anggota. Kami mungkin tergoda untuk menggunakannya std::swap alih-alih menyediakan milik kita sendiri, tetapi ini tidak mungkin; std::swap menggunakan operator copy-constructor dan copy-assignment dalam implementasinya, dan kami akhirnya mencoba mendefinisikan operator penugasan dalam hal itu sendiri!

(Bukan hanya itu, tetapi panggilan yang tidak memenuhi syarat untuk swap akan menggunakan operator swap kustom kami, melompati konstruksi yang tidak perlu dan penghancuran kelas kami itu std::swap akan memerlukan.)


Penjelasan yang mendalam

Hasil

Mari kita pertimbangkan kasus konkret. Kami ingin mengelola, di kelas yang tidak berguna, array dinamis. Kami mulai dengan konstruktor yang berfungsi, copy-constructor, dan destructor:

#include <algorithm> // std::copy
#include <cstddef> // std::size_t

class dumb_array
{
public:
    // (default) constructor
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // copy-constructor
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr),
    {
        // note that this is non-throwing, because of the data
        // types being used; more attention to detail with regards
        // to exceptions must be given in a more general case, however
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // destructor
    ~dumb_array()
    {
        delete [] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};

Kelas ini hampir mengelola array dengan sukses, tetapi perlu operator= untuk berfungsi dengan benar.

Solusi yang gagal

Berikut ini bagaimana implementasi yang naif mungkin terlihat:

// the hard part
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other) // (1)
    {
        // get rid of the old data...
        delete [] mArray; // (2)
        mArray = nullptr; // (2) *(see footnote for rationale)

        // ...and put in the new
        mSize = other.mSize; // (3)
        mArray = mSize ? new int[mSize] : nullptr; // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
    }

    return *this;
}

Dan kami katakan kami sudah selesai; ini sekarang mengelola sebuah array, tanpa kebocoran. Namun, itu menderita tiga masalah, ditandai secara berurutan dalam kode seperti (n).

  1. Yang pertama adalah tes penugasan diri. Pemeriksaan ini melayani dua tujuan: ini adalah cara mudah untuk mencegah kami menjalankan kode yang tidak perlu pada penetapan-diri, dan melindungi kita dari bug halus (seperti menghapus larik hanya untuk mencoba dan menyalinnya). Tetapi dalam semua kasus lain itu hanya berfungsi untuk memperlambat program, dan bertindak sebagai kebisingan dalam kode; penugasan diri jarang terjadi, sehingga sebagian besar waktu pemeriksaan ini adalah pemborosan. Akan lebih baik jika operator bisa bekerja dengan baik tanpanya.

  2. Yang kedua adalah bahwa ia hanya menyediakan jaminan pengecualian dasar. Jika new int[mSize] gagal, *this akan dimodifikasi. (Yakni, ukurannya salah dan datanya hilang!) Untuk jaminan pengecualian yang kuat, itu harus menjadi sesuatu yang mirip dengan:

    dumb_array& operator=(const dumb_array& other)
    {
        if (this != &other) // (1)
        {
            // get the new data ready before we replace the old
            std::size_t newSize = other.mSize;
            int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
            std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
    
            // replace the old data (all are non-throwing)
            delete [] mArray;
            mSize = newSize;
            mArray = newArray;
        }
    
        return *this;
    }
    
  3. Kode telah diperluas! Yang membawa kita ke masalah ketiga: duplikasi kode. Operator penugasan kami secara efektif menduplikasi semua kode yang sudah kami tulis di tempat lain, dan itu hal yang mengerikan.

Dalam kasus kami, intinya adalah hanya dua baris (alokasi dan salinan), tetapi dengan sumber daya yang lebih kompleks, kode ini bisa sangat merepotkan. Kita harus berusaha untuk tidak pernah mengulangi diri kita sendiri.

(Orang mungkin bertanya-tanya: jika kode sebanyak ini diperlukan untuk mengelola satu sumber daya dengan benar, bagaimana jika kelas saya mengelola lebih dari satu? Meskipun hal ini mungkin tampaknya menjadi perhatian yang valid, dan memang membutuhkan hal yang tidak sepele try/catch klausa, ini bukan masalah. Itu karena kelas harus mengatur satu sumber daya saja!)

Solusi yang sukses

Seperti disebutkan, idiom copy-and-swap akan memperbaiki semua masalah ini. Tetapi saat ini, kami memiliki semua persyaratan kecuali satu: a swap fungsi. Sementara The Rule of Three berhasil memasukkan keberadaan copy-constructor, operator penugasan, dan destructor, itu seharusnya benar-benar disebut "The Big Three and A Half": kapan pun kelas Anda mengelola sumber daya, itu juga masuk akal untuk menyediakan swap fungsi.

Kami perlu menambahkan fungsi swap ke kelas kami, dan kami melakukan itu sebagai berikut:

class dumb_array
{
public:
    // ...

    friend void swap(dumb_array& first, dumb_array& second) // nothrow
    {
        // enable ADL (not necessary in our case, but good practice)
        using std::swap;

        // by swapping the members of two objects,
        // the two objects are effectively swapped
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};

(Sini adalah penjelasannya mengapa public friend swap.) Sekarang, kita tidak hanya dapat menukar dumb_arrayTetapi, swap secara umum bisa lebih efisien; itu hanya swap pointer dan ukuran, daripada mengalokasikan dan menyalin seluruh array. Selain dari bonus ini dalam fungsi dan efisiensi, kami sekarang siap untuk mengimplementasikan idiom copy-and-swap.

Tanpa basa-basi lagi, operator penugasan kami adalah:

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}

Dan itu dia! Dengan satu gerakan, ketiga masalah ditangani dengan elegan sekaligus.

Mengapa itu berfungsi?

Kami pertama kali melihat pilihan penting: argumen parameter diambil nilai tambah. Sementara seseorang dapat dengan mudah melakukan hal berikut (dan memang, banyak penerapan naif dari idiom):

dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}

Kami kehilangan satu peluang pengoptimalan yang penting. Tidak hanya itu, tetapi pilihan ini sangat penting dalam C ++ 11, yang akan dibahas nanti. (Pada catatan umum, panduan yang sangat bermanfaat adalah sebagai berikut: jika Anda akan membuat salinan sesuatu dalam suatu fungsi, biarkan compiler melakukannya dalam daftar parameter. ‡)

Either way, metode ini untuk mendapatkan sumber daya kami adalah kunci untuk menghilangkan duplikasi kode: kita dapat menggunakan kode dari copy-constructor untuk membuat salinan, dan tidak perlu mengulang sedikit pun. Setelah salinannya dibuat, kami siap bertukar.

Amatilah bahwa setelah memasuki fungsi itu semua data baru sudah dialokasikan, disalin, dan siap untuk digunakan. Inilah yang memberi kami jaminan pengecualian yang kuat gratis: kami bahkan tidak akan masuk ke fungsi jika konstruksi salinan gagal, dan karena itu tidak mungkin mengubah keadaan *this. (Apa yang kami lakukan secara manual sebelumnya untuk jaminan pengecualian yang kuat, kompilator lakukan untuk kami sekarang; seberapa baik.)

Pada titik ini kami bebas dari rumah, karena swap adalah non-lempar. Kami menukar data kami saat ini dengan data yang disalin, mengubah keadaan kami dengan aman, dan data lama dimasukkan ke data sementara. Data lama kemudian dilepaskan ketika fungsi kembali. (Di mana pada lingkup parameter berakhir dan destruktornya disebut.)

Karena idiom tidak mengulang kode, kami tidak dapat memperkenalkan bug di dalam operator. Perhatikan bahwa ini berarti kita menyingkirkan kebutuhan untuk pemeriksaan penugasan diri, yang memungkinkan satu penerapan seragam operator=. (Selain itu, kami tidak lagi memiliki penalti kinerja pada non-self-assignments.)

Dan itu adalah idiom copy-and-swap.

Bagaimana dengan C ++ 11?

Versi berikutnya dari C ++, C ++ 11, membuat satu perubahan yang sangat penting untuk bagaimana kita mengelola sumber daya: Rule of Three sekarang Aturan Empat (dan setengah). Mengapa? Karena kita tidak hanya perlu menyalin sumber daya kita, kita perlu memindahkan-konstruksinya juga.

Untungnya bagi kami, ini mudah:

class dumb_array
{
public:
    // ...

    // move constructor
    dumb_array(dumb_array&& other)
        : dumb_array() // initialize via default constructor, C++11 only
    {
        swap(*this, other);
    }

    // ...
};

Apa yang terjadi di sini? Ingat kembali tujuan pembangunan-bergerak: untuk mengambil sumber daya dari kelas lain, membiarkannya dalam keadaan dijamin dapat dialihkan dan dirusak.

Jadi apa yang kami lakukan adalah sederhana: menginisialisasi melalui konstruktor default (fitur C ++ 11), lalu tukar dengan other; kami tahu instance bawaan bawaan kelas kami dapat ditetapkan dan dihancurkan dengan aman, jadi kami tahu other akan dapat melakukan hal yang sama, setelah bertukar.

(Perhatikan bahwa beberapa kompiler tidak mendukung delegasi konstruktor; dalam hal ini, kita harus membangun kelas secara manual. Ini adalah tugas yang tidak menguntungkan tapi untungnya sepele.)

Mengapa itu berhasil?

Itulah satu-satunya perubahan yang perlu kita lakukan untuk kelas kita, jadi mengapa itu berhasil? Ingat keputusan penting yang kami buat untuk membuat parameter menjadi nilai dan bukan referensi:

dumb_array& operator=(dumb_array other); // (1)

Sekarang, jika other sedang diinisialisasi dengan rvalue, itu akan bergerak-dibangun. Sempurna. Dengan cara yang sama C ++ 03 mari kita gunakan kembali fungsionalitas copy-constructor kita dengan mengambil argumen dengan nilai, C ++ 11 akan secara otomatis pilih konstruktor-bergerak ketika tepat juga. (Dan, tentu saja, sebagaimana disebutkan dalam artikel yang ditautkan sebelumnya, penyalinan / pemindahan nilai mungkin hanya ditiru sama sekali.)

Jadi menyimpulkan idiom copy-and-swap.


Catatan kaki

* Mengapa kita mengatur mArray ke nol? Karena jika ada kode lebih lanjut di operator melempar, destructor dumb_array mungkin disebut; dan jika itu terjadi tanpa menyetelnya ke null, kami berusaha menghapus memori yang sudah dihapus! Kami menghindari ini dengan menyetelnya ke null, karena menghapus nol adalah tidak ada operasi.

† Ada klaim lain yang harus kami spesialiskan std::swap untuk tipe kami, berikan di kelasnya swap bersama-sama dengan fungsi bebas swap, dll. Tetapi ini semua tidak perlu: penggunaan yang tepat swap akan melalui panggilan wajar tanpa pengecualian, dan fungsi kami akan ditemukan melalui ADL. Satu fungsi akan dilakukan.

‡ Alasannya sederhana: setelah Anda memiliki sumber daya untuk diri sendiri, Anda dapat menukar dan / atau memindahkannya (C + + 11) di mana pun itu perlu. Dan dengan membuat salinan dalam daftar parameter, Anda memaksimalkan pengoptimalan.


1835
2017-07-19 08:43



Tugas, pada intinya, adalah dua langkah: meruntuhkan keadaan lama benda itu dan membangun negara barunya sebagai salinan dari beberapa status objek lain.

Pada dasarnya, itulah yang terjadi perusak dan salin konstruktor lakukan, jadi ide pertama adalah mendelegasikan pekerjaan kepada mereka. Namun, karena penghancuran tidak boleh gagal, sementara konstruksi mungkin, kita sebenarnya ingin melakukannya dengan cara sebaliknya: pertama melakukan bagian yang konstruktif dan jika itu berhasil, lalu lakukan bagian yang merusak. Ungkapan copy-and-swap adalah cara untuk melakukan hal itu: Ini pertama-tama memanggil konstruktor copy kelas untuk membuat sementara, lalu menukar datanya dengan yang sementara, dan kemudian membiarkan destruktor sementara menghancurkan negara lama.
Sejak swap() Seharusnya tidak pernah gagal, satu-satunya bagian yang mungkin gagal adalah konstruksi salinan. Itu dilakukan pertama, dan jika gagal, tidak ada yang akan diubah dalam objek yang ditargetkan.

Dalam bentuk yang disempurnakan, copy-and-swap diimplementasikan dengan memiliki salinan yang dilakukan dengan menginisialisasi parameter (non-referensi) dari operator penugasan:

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}

226
2017-07-19 08:55



Sudah ada beberapa jawaban yang bagus. Saya akan fokus terutama pada apa yang saya pikir mereka kekurangan - penjelasan tentang "kontra" dengan idiom copy-and-swap ....

Apa idiom copy-and-swap?

Cara menerapkan operator penugasan dalam hal fungsi swap:

X& operator=(X rhs)
{
    swap(rhs);
    return *this;
}

Ide dasarnya adalah:

  • bagian yang paling rawan kesalahan dalam menetapkan objek adalah memastikan sumber daya apa pun yang dibutuhkan oleh kebutuhan negara yang baru (misalnya, memori, deskriptor)

  • Akuisisi itu bisa dicoba sebelum memodifikasi status objek saat ini (mis. *this) jika salinan nilai baru dibuat, itulah sebabnya rhs diterima berdasarkan nilai (mis. disalin) daripada dengan referensi

  • menukar keadaan salinan lokal rhs dan *this aku s biasanya relatif mudah dilakukan tanpa potensi kegagalan / pengecualian, mengingat salinan lokal tidak memerlukan keadaan tertentu setelahnya (hanya perlu fit state untuk destructor untuk dijalankan, sama seperti untuk objek yang sedang terharu dari dalam> = C ++ 11)

Kapan seharusnya digunakan? (Masalah apa yang dipecahkannya [/membuat]?)

  • Bila Anda ingin orang yang ditugasi keberatan tidak terpengaruh oleh tugas yang melempar pengecualian, dengan asumsi Anda memiliki atau dapat menulis swap dengan jaminan pengecualian yang kuat, dan idealnya yang tidak bisa gagal /throw.. †

  • Bila Anda menginginkan cara yang bersih, mudah dimengerti, dan tangguh untuk menentukan operator penugasan dalam hal konstruktor salin (lebih sederhana), swap dan fungsi destruktor.

    • Penugasan diri yang dilakukan sebagai penghindaran copy dan swap dari kasus tepian yang diabaikan. ‡

  • Ketika ada penalti kinerja atau penggunaan sumber daya yang lebih tinggi sementara dibuat dengan memiliki objek sementara tambahan selama tugas tidak penting untuk aplikasi Anda. ⁂

swap lempar: biasanya memungkinkan untuk menukar data anggota secara andal bahwa objek dilacak oleh penunjuk, tetapi anggota data non-penunjuk yang tidak memiliki pertukaran bebas lemparan, atau untuk mana pertukaran harus diterapkan sebagai X tmp = lhs; lhs = rhs; rhs = tmp; dan copy-konstruksi atau tugas dapat membuang, masih berpotensi gagal meninggalkan beberapa data anggota bertukar dan yang lainnya tidak. Potensi ini berlaku bahkan untuk C ++ 03 std::stringsebagai komentar James pada jawaban lain:

@wilhelmtell: Dalam C ++ 03, tidak ada penyebutan pengecualian yang berpotensi dilemparkan oleh std :: string :: swap (yang disebut dengan std :: swap). Dalam C ++ 0x, std :: string :: swap tidak ada pengecualian dan tidak boleh membuang pengecualian. - James McNellis Des 22 '10 at 15:24


‡ implementasi operator penugasan yang tampaknya waras ketika menugaskan dari objek yang berbeda dapat dengan mudah gagal untuk penentuan tugas sendiri. Meskipun mungkin tampak tidak terbayangkan bahwa kode klien bahkan akan mencoba penugasan sendiri, itu bisa terjadi relatif mudah selama operasi algo pada kontainer, dengan x = f(x); kode di mana f adalah (mungkin hanya untuk beberapa orang #ifdef cabang-cabang) secara makro #define f(x) x atau suatu fungsi yang mengembalikan referensi ke x, atau bahkan (cenderung tidak efisien tetapi ringkas) seperti kode x = c1 ? x * 2 : c2 ? x / 2 : x;). Sebagai contoh:

struct X
{
    T* p_;
    size_t size_;
    X& operator=(const X& rhs)
    {
        delete[] p_;  // OUCH!
        p_ = new T[size_ = rhs.size_];
        std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);
    }
    ...
};

Pada penugasan-diri, kode di atas menghapus x.p_;, poin p_ di wilayah heap yang baru dialokasikan, lalu mencoba untuk membaca tidak diinisialisasi data didalamnya (Undefined Behavior), jika itu tidak melakukan sesuatu yang aneh, copy mencoba penugasan diri ke setiap 'T' yang baru saja dihancurkan!


⁂ Ungkapan copy-and-swap dapat memperkenalkan ketidakefisienan atau keterbatasan karena penggunaan ekstra sementara (ketika parameter operator dibuat dengan menyalin):

struct Client
{
    IP_Address ip_address_;
    int socket_;
    X(const X& rhs)
      : ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_))
    { }
};

Di sini, tulisan tangan Client::operator= mungkin memeriksa apakah *this sudah terhubung ke server yang sama dengan rhs (mungkin mengirim "reset" kode jika berguna), sedangkan pendekatan copy-dan-swap akan memanggil copy-constructor yang kemungkinan akan ditulis untuk membuka koneksi soket yang berbeda kemudian tutup yang asli. Bukan hanya itu bisa berarti interaksi jaringan jauh daripada salinan variabel sederhana dalam proses, itu bisa bertabrakan dengan klien atau batas server pada sumber daya atau koneksi soket. (Tentu saja kelas ini memiliki antarmuka yang cukup mengerikan, tapi itu masalah lain ;-P).


32
2018-03-06 14:51



Jawaban ini lebih seperti penambahan dan sedikit modifikasi pada jawaban di atas.

Dalam beberapa versi Visual Studio (dan mungkin kompiler lain) ada bug yang benar-benar menjengkelkan dan tidak masuk akal. Jadi jika Anda menyatakan / mendefinisikan Anda swap berfungsi seperti ini:

friend void swap(A& first, A& second) {

    std::swap(first.size, second.size);
    std::swap(first.arr, second.arr);

}

... kompilator akan berteriak pada Anda ketika Anda memanggil swap fungsi:

enter image description here

Ini ada hubungannya dengan friend fungsi dipanggil dan this objek yang dilewatkan sebagai parameter.


Cara di sekitar ini adalah tidak digunakan friend kata kunci dan mendefinisikan kembali swap fungsi:

void swap(A& other) {

    std::swap(size, other.size);
    std::swap(arr, other.arr);

}

Kali ini, Anda cukup menelepon swap dan lulus other, sehingga membuat bahagia compiler:

enter image description here


Lagi pula, Anda tidak perlu untuk menggunakan friend berfungsi untuk menukar 2 objek. Itu masuk akal untuk dibuat swap fungsi anggota yang memiliki satu other objek sebagai parameter.

Anda sudah memiliki akses ke this objek, sehingga melewatkannya sebagai parameter secara teknis redundan.


19
2017-09-04 04:50



Saya ingin menambahkan kata peringatan ketika Anda berurusan dengan wadah sadar-alokasi C ++ 11-style. Pertukaran dan penugasan memiliki semantik yang berbeda.

Untuk konkret, mari kita pertimbangkan sebuah wadah std::vector<T, A>, dimana A adalah beberapa tipe pengatur negara bagian, dan kami akan membandingkan fungsi-fungsi berikut:

void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{ 
    a.swap(b);
    b.clear(); // not important what you do with b
}

void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
    a = std::move(b);
}

Tujuan dari kedua fungsi tersebut fs dan fm adalah memberi a menyatakan itu b memiliki awalnya. Namun, ada pertanyaan tersembunyi: Apa yang terjadi jika a.get_allocator() != b.get_allocator()? Jawabannya adalah, tergantung. Mari menulis AT = std::allocator_traits<A>.

  • Jika AT::propagate_on_container_move_assignment aku s std::true_type, kemudian fm menetapkan kembali pengalokasi a dengan nilai b.get_allocator(), jika tidak, dan a terus menggunakan alokator aslinya. Dalam hal ini, elemen data perlu ditukar secara individual, karena penyimpanan a dan b tidak kompatibel.

  • Jika AT::propagate_on_container_swap aku s std::true_type, kemudian fs menukar data dan alokator dengan cara yang diharapkan.

  • Jika AT::propagate_on_container_swap aku s std::false_type, maka kita perlu pemeriksaan dinamis.

    • Jika a.get_allocator() == b.get_allocator(), maka dua kontainer menggunakan penyimpanan yang kompatibel, dan pertukaran dilakukan dengan cara biasa.
    • Namun, jika a.get_allocator() != b.get_allocator(), programnya perilaku tidak terdefinisi (lih. [container.requirements.general / 8].

Hasilnya adalah bahwa pertukaran telah menjadi operasi non-sepele dalam C ++ 11 segera setelah penampung Anda mulai mendukung pengalokasi yang memenuhi syarat. Itu agak "kasus penggunaan lanjutan", tapi itu tidak sepenuhnya tidak mungkin, karena memindahkan pengoptimalan biasanya hanya menjadi menarik setelah kelas Anda mengelola sumber daya, dan memori adalah salah satu sumber daya paling populer.


10
2018-06-24 08:16