Invarian seumur hidup objek vs semantik bergerak

13

Ketika saya mempelajari C ++ sejak lama, sangat ditekankan kepada saya bahwa bagian dari titik C ++ adalah seperti halnya loop memiliki "loop-invariants", kelas juga memiliki invarian yang terkait dengan masa hidup objek - hal-hal yang seharusnya benar selama benda itu hidup. Hal-hal yang harus ditetapkan oleh konstruktor, dan dilestarikan dengan metode. Enkapsulasi / kontrol akses ada untuk membantu Anda menegakkan invarian. RAII adalah satu hal yang dapat Anda lakukan dengan ide ini.

Sejak C ++ 11 kita sekarang telah memindahkan semantik. Untuk kelas yang mendukung gerakan, bergerak dari suatu objek tidak secara resmi mengakhiri masa pakainya - gerakan tersebut seharusnya membiarkannya dalam keadaan "valid".

Dalam mendesain sebuah kelas, apakah itu praktik yang buruk jika Anda mendesainnya sehingga invarian dari kelas hanya dipertahankan sampai pada titik di mana ia dipindahkan? Atau tidak apa - apa jika itu akan memungkinkan Anda untuk membuatnya lebih cepat.

Untuk membuatnya konkret, anggaplah saya memiliki tipe sumber daya yang tidak dapat disalin tetapi dapat dipindahkan seperti:

class opaque {
  opaque(const opaque &) = delete;

public:
  opaque(opaque &&);

  ...

  void mysterious();
  void mysterious(int);
  void mysterious(std::vector<std::string>);
};

Dan untuk alasan apa pun, saya perlu membuat pembungkus yang dapat disalin untuk objek ini, sehingga dapat digunakan, mungkin dalam beberapa sistem pengiriman yang ada.

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { o_->mysterious(); }
  void operator()(int i) { o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { o_->mysterious(v); }
};

Dalam copyable_opaqueobjek ini , invarian dari kelas yang didirikan pada konstruksi adalah bahwa anggota o_selalu menunjuk ke objek yang valid, karena tidak ada ctor default, dan satu-satunya ctor yang bukan copy ctor menjamin ini. Semua operator()metode mengasumsikan bahwa invarian ini berlaku, dan melestarikannya setelahnya.

Namun, jika objek tersebut dipindahkan, maka o_akan menunjuk ke apa-apa. Dan setelah titik itu, memanggil salah satu metode operator()akan menyebabkan UB / crash.

Jika objek tidak pernah dipindahkan, maka invarian akan disimpan hingga panggilan dtor.

Anggap saja secara hipotetis, saya menulis kelas ini, dan berbulan-bulan kemudian, rekan kerja imajiner saya mengalami UB karena, dalam beberapa fungsi rumit di mana banyak objek ini sedang diaduk-aduk karena suatu alasan, ia pindah dari salah satu hal ini dan kemudian memanggil salah satu dari metodenya. Jelas itu salahnya pada akhirnya, tetapi apakah kelas ini "dirancang dengan buruk?"

Pikiran:

  1. Biasanya bentuk yang buruk di C ++ untuk membuat objek zombie yang meledak jika Anda menyentuhnya.
    Jika Anda tidak dapat membuat beberapa objek, tidak dapat membuat invarian, lalu lempar pengecualian dari ctor. Jika Anda tidak dapat mempertahankan invarian dalam beberapa metode, berikan sinyal kesalahan dan gulung balik. Haruskah ini berbeda untuk objek yang dipindahkan dari objek?

  2. Apakah cukup dengan hanya mendokumentasikan "setelah objek ini dipindahkan, itu ilegal (UB) untuk melakukan apa pun selain menghancurkannya" di header?

  3. Apakah lebih baik untuk terus-menerus menyatakan bahwa itu valid dalam setiap pemanggilan metode?

Seperti itu:

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { assert(o_); o_->mysterious(); }
  void operator()(int i) { assert(o_); o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { assert(o_); o_->mysterious(v); }
};

Pernyataan tidak secara substansial meningkatkan perilaku, dan mereka menyebabkan pelambatan. Jika proyek Anda memang menggunakan skema "rilis build / debug build", daripada hanya selalu berjalan dengan pernyataan, saya kira ini lebih menarik, karena Anda tidak membayar untuk cek di build rilis. Jika Anda tidak benar-benar memiliki debug build, ini tampaknya cukup tidak menarik.

  1. Apakah lebih baik membuat kelas dapat disalin, tetapi tidak dapat dipindahkan?
    Ini juga tampaknya buruk dan menyebabkan kinerja yang buruk, tetapi ini memecahkan masalah "invarian" secara langsung.

Apa yang Anda anggap sebagai "praktik terbaik" yang relevan di sini?

Chris Beck
sumber

Jawaban:

20

Biasanya bentuk yang buruk di C ++ untuk membuat objek zombie yang meledak jika Anda menyentuhnya.

Tapi bukan itu yang kamu lakukan. Anda sedang membuat "objek zombie" yang akan meledak jika Anda menyentuhnya salah . Yang pada akhirnya tidak berbeda dengan prasyarat berbasis negara lainnya.

Pertimbangkan fungsi berikut:

void func(std::vector<int> &v)
{
  v[0] = 5;
}

Apakah fungsi ini aman? Tidak; pengguna dapat melewatkan yang kosong vector . Jadi fungsinya memiliki prasyarat de-facto yang vmemiliki setidaknya satu elemen di dalamnya. Jika tidak, maka Anda mendapatkan UB saat Anda menelepon func.

Jadi fungsi ini bukan "aman". Tapi itu tidak berarti itu rusak. Itu hanya rusak jika kode yang menggunakannya melanggar prasyarat. Mungkin funcfungsi statis digunakan sebagai penolong dalam implementasi fungsi lainnya. Dilokalkan sedemikian rupa, tidak ada yang akan menyebutnya dengan cara yang melanggar prasyaratnya.

Banyak fungsi, apakah namespace-scoped atau anggota kelas, akan memiliki harapan pada keadaan nilai yang mereka operasikan. Jika prasyarat ini tidak terpenuhi, maka fungsi akan gagal, biasanya dengan UB.

Pustaka standar C ++ mendefinisikan aturan "valid-but-unspecified". Ini mengatakan bahwa, kecuali standar mengatakan sebaliknya, setiap objek yang dipindahkan akan valid (itu adalah objek hukum dari jenis itu), tetapi keadaan spesifik objek itu tidak ditentukan. Berapa banyak elemen yang vectordimiliki dari pindah ? Tidak dikatakan.

Ini berarti bahwa Anda tidak dapat memanggil fungsi apa pun yang memiliki prasyarat apa pun . vector::operator[]memiliki prasyarat bahwa vectorsetidaknya memiliki satu elemen. Karena Anda tidak tahu keadaannya vector, Anda tidak bisa menyebutnya. Akan lebih baik daripada menelepon functanpa terlebih dahulu memverifikasi bahwa vectoritu tidak kosong.

Tetapi ini juga berarti bahwa fungsi yang tidak memiliki prasyarat baik-baik saja. Ini adalah kode C ++ 11 yang legal:

vector<int> v1 = {1, 2, 3, 4, 5};
vector<int> v2{std::move(v1)};
v1.assign({6, 7, 8, 9, 10});

vector::assigntidak memiliki prasyarat. Ini akan bekerja dengan vectorobjek yang valid , bahkan yang telah dipindahkan.

Jadi, Anda tidak membuat objek yang rusak. Anda sedang membuat objek yang keadaannya tidak diketahui.

Jika Anda tidak dapat membuat beberapa objek, tidak dapat membuat invarian, lalu lempar pengecualian dari ctor. Jika Anda tidak dapat mempertahankan invarian dalam beberapa metode, berikan sinyal kesalahan dan gulung balik. Haruskah ini berbeda untuk objek yang dipindahkan dari objek?

Melempar pengecualian dari konstruktor bergerak umumnya dianggap ... kasar. Jika Anda memindahkan objek yang memiliki memori, maka Anda mentransfer kepemilikan memori itu. Dan itu biasanya tidak melibatkan apa pun yang bisa melempar.

Sayangnya, kami tidak dapat menegakkan ini karena berbagai alasan . Kita harus menerima bahwa melempar-pindah adalah suatu kemungkinan.

Perlu juga dicatat bahwa Anda tidak harus mengikuti bahasa "valid-belum-ditentukan". Begitulah cara perpustakaan standar C ++ mengatakan bahwa gerakan untuk tipe standar bekerja secara default . Jenis perpustakaan standar tertentu memiliki jaminan yang lebih ketat. Sebagai contoh, unique_ptrsangat jelas tentang keadaan unique_ptrinstance move-from : itu sama dengan nullptr.

Jadi, Anda dapat memilih untuk memberikan jaminan yang lebih kuat jika Anda mau.

Hanya ingat: gerakan adalah optimasi kinerja , salah satu yang biasanya dilakukan pada objek yang sekitar harus dihancurkan. Pertimbangkan kode ini:

vector<int> func()
{
  vector<int> v;
  //fill up `v`.
  return v;
}

Ini akan berpindah dari vnilai pengembalian (dengan anggapan kompiler tidak mengelaknya). Dan tidak ada cara untuk referensi vsetelah langkah selesai. Jadi pekerjaan apa pun yang Anda lakukan untuk menjadikannya vberguna tidak ada artinya.

Di sebagian besar kode, kemungkinan menggunakan instance objek yang dipindahkan dari rendah.

Apakah cukup dengan hanya mendokumentasikan "setelah objek ini dipindahkan, itu ilegal (UB) untuk melakukan apa pun selain menghancurkannya" di header?

Apakah lebih baik untuk terus-menerus menyatakan bahwa itu valid dalam setiap pemanggilan metode?

Inti dari memiliki prasyarat adalah untuk tidak memeriksa hal-hal seperti itu. operator[]memiliki prasyarat bahwa vectorelemen memiliki indeks yang diberikan. Anda mendapatkan UB jika Anda mencoba mengakses di luar ukuran vector. vector::at tidak memiliki prasyarat seperti itu; itu secara eksplisit melempar pengecualian jika vectortidak memiliki nilai seperti itu.

Ada prasyarat untuk alasan kinerja. Itu agar Anda tidak perlu memeriksa hal-hal yang bisa diverifikasi oleh penelepon itu sendiri. Setiap panggilan ke v[0]tidak harus memeriksa apakah vkosong; hanya yang pertama yang melakukannya.

Apakah lebih baik membuat kelas dapat disalin, tetapi tidak dapat dipindahkan?

Sebenarnya, suatu kelas tidak boleh "dapat disalin tetapi tidak dapat dipindahkan". Jika itu bisa disalin, maka itu harus bisa dipindahkan dengan memanggil copy constructor. Ini adalah perilaku standar C ++ 11 jika Anda mendeklarasikan copy constructor yang ditentukan pengguna tetapi tidak menyatakan move constructor. Dan itu perilaku yang harus Anda adopsi jika Anda tidak ingin menerapkan semantik gerakan khusus.

Pindahkan semantik yang ada untuk memecahkan masalah yang sangat spesifik: berurusan dengan objek yang memiliki sumber daya besar di mana penyalinan akan menjadi sangat mahal atau tidak berarti (mis .: menangani file). Jika objek Anda tidak memenuhi syarat, maka menyalin dan memindahkan adalah sama untuk Anda.

Nicol Bolas
sumber
5
Bagus. +1. Saya akan mencatat bahwa: "Inti dari memiliki prasyarat adalah untuk tidak memeriksa hal-hal seperti itu." - Saya tidak berpikir ini berlaku untuk pernyataan. Pernyataan adalah IMHO alat yang baik dan valid untuk memeriksa prasyarat (sebagian besar waktu setidaknya.)
Martin Ba
3
Kekeliruan menyalin / memindahkan dapat diklarifikasi dengan menyadari bahwa pemindahan pemindah dapat meninggalkan objek sumber dalam keadaan apa pun, termasuk identik dengan objek baru - yang berarti bahwa hasil yang mungkin adalah superset dari pemicu pemicu.
MSalters