Nesne yaşam boyu değişmezler ve taşıma semantiği


13

C ++ 'ı uzun zaman önce öğrendiğimde, C ++' ın noktasının bir kısmının, döngülerin "döngü değişmezlerine" sahip olması gibi, sınıfların da nesnenin ömrü ile ilişkili değişmezleri olduğu gerçeği vurgulandı - doğru olması gereken şeyler çünkü nesne canlı. Yapıcılar tarafından kurulması ve yöntemlerle korunması gereken şeyler. Kapsülleme / erişim kontrolü, değişmezleri zorlamanıza yardımcı olmak için oradadır. RAII bu fikirle yapabileceğiniz bir şeydir.

C ++ 11'den beri artık hareket semantiği var. Hareket etmeyi destekleyen bir sınıf için, bir nesneden hareket etmek resmi olarak ömrünü sona erdirmez - hareketin onu bir "geçerli" durumda bırakması gerekir.

Bir sınıfı tasarlarken, sınıfın değişmezleri sadece taşındığı noktaya kadar korunacak şekilde tasarlarsanız kötü bir uygulama mıdır? Ya da daha hızlı gitmenizi sağlayacaksa , bu tamam mı?

Somut hale getirmek için, benim gibi kopyalanamayan ama taşınabilir bir kaynak türüne sahip olduğunu varsayalım:

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

public:
  opaque(opaque &&);

  ...

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

Ve her ne sebeple olursa olsun, belki de bazı mevcut dağıtım sistemlerinde kullanılabilmesi için bu nesne için kopyalanabilir bir ambalaj yapmam gerekiyor.

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); }
};

Bu copyable_opaquenesnede, inşaatta kurulan sınıfın değişmezi, o_varsayılan bir ctor olmadığı ve bir kopya ctor olmayan tek ctor bunları garanti ettiği için üyenin daima geçerli bir nesneyi işaret etmesidir. Tüm operator()yöntemler bu değişmezin tuttuğunu varsayar ve daha sonra korur.

Ancak, nesne hareket ettirilirse, o o_zaman hiçbir şeye işaret etmez. Ve bu noktadan sonra, yöntemlerden herhangi birini çağırmak operator()UB / çökmesine neden olur.

Nesne hiçbir zaman taşınmazsa, değişmez dtor çağrısına kadar korunur.

Diyelim ki bu sınıfı yazdım ve aylar sonra hayali iş arkadaşım UB'yi deneyimledi, çünkü bu nesnelerin çoğunun bir nedenden dolayı karıştırıldığı bazı karmaşık işlevlerde, bu şeylerden birinden taşındı ve daha sonra yöntemleri. Açıkçası gün sonunda onun hatası, ama bu sınıf "kötü tasarlanmış?"

Düşünceler:

  1. Onlara dokunursanız patlayan zombi nesneleri oluşturmak genellikle C ++ 'da kötü bir formdur.
    Eğer bir nesne inşa edemezseniz, değişmezleri kuramazsanız, o zaman kraterden bir istisna atarsınız. Değişmezleri bir yöntemle koruyamazsanız, bir şekilde bir hata sinyali verin ve geri alın. Bu, taşınan nesneler için farklı mı olmalı?

  2. Sadece "bu nesne taşındıktan sonra, onunla yok etmek dışında herhangi bir şey yapmak yasadışıdır (UB)" başlığını belgelemek yeterli mi?

  3. Her yöntem çağrısında geçerli olduğunu sürekli olarak iddia etmek daha mı iyi?

Şöyle ki:

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); }
};

İddialar davranışı önemli ölçüde iyileştirmez ve yavaşlamaya neden olur. Projeniz her zaman iddialarla çalışmak yerine "sürüm oluştur / hata ayıklama derlemesi" şemasını kullanıyorsa, sürüm derlemesindeki çekler için ödeme yapmadığınız için daha cazip olduğunu düşünüyorum. Aslında hata ayıklama yapıları yoksa, bu oldukça çirkin görünüyor.

  1. Sınıfı kopyalanabilir kılmak daha iyidir, ancak taşınabilir değil mi?
    Bu da kötü görünüyor ve bir performans isabetine neden oluyor, ancak "değişmez" sorunu basit bir şekilde çözüyor.

Burada ilgili "en iyi uygulamalar" olarak neyi düşünürdünüz?



Yanıtlar:


20

Onlara dokunursanız patlayan zombi nesneleri oluşturmak genellikle C ++ 'da kötü bir formdur.

Ama yaptığınız bu değil. Yanlış dokunduğunuzda patlayacak bir "zombi nesnesi" oluşturuyorsunuz . Sonuçta diğer devlet temelli koşullardan farklı değildir.

Aşağıdaki işlevi göz önünde bulundurun:

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

Bu işlev güvenli mi? Hayır; kullanıcı boş bir alanı geçebilir vector. Yani fonksiyonun içinde ven az bir eleman bulunan fiili bir önkoşul vardır. Değilse, aradığınızda UB alırsınız func.

Bu nedenle bu işlev "güvenli" değildir. Ama bu kırıldığı anlamına gelmiyor. Yalnızca onu kullanan kod önkoşulu ihlal ederse bozulur. Belki funcdiğer fonksiyonların uygulanmasında yardımcı olarak kullanılan statik bir fonksiyondur. Bu şekilde yerelleştirilen kimse, ön koşullarını ihlal edecek şekilde demez.

İster isim alanı kapsamındaki ister sınıf üyeleri olsun, birçok işlev üzerinde çalıştıkları değerin durumu hakkında beklentilere sahip olacaktır. Bu önkoşullar karşılanmazsa, işlevler, genellikle UB ile başarısız olur.

C ++ standart kitaplığı "geçerli ancak belirtilmemiş" bir kural tanımlar. Bu, standart aksini belirtmedikçe, taşınan her nesnenin geçerli olacağını (bu türün yasal bir nesnesidir), ancak o nesnenin belirli durumunun belirtilmediğini belirtir. Taşınan kaç element vectorvar? Söylemiyor.

Bu, herhangi bir ön koşulu olan hiçbir işlevi çağıramayacağınız anlamına gelir . vector::operator[], vectoren az bir öğeye sahip olması ön koşuluna sahiptir. Durumunu bilmediğiniz için vectoronu arayamazsınız. funcÖnce vectorboş olmadığını doğrulamadan aramaktan daha iyi olmazdı .

Ancak bu aynı zamanda önkoşulları olmayan fonksiyonların iyi olduğu anlamına gelir . Bu tamamen yasal C ++ 11 kodu:

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

vector::assignönkoşulu yoktur. Taşınan herhangi bir geçerli vectornesne ile çalışır .

Yani kırık bir nesne yaratmıyorsunuz. Durumu bilinmeyen bir nesne yaratıyorsunuz.

Eğer bir nesne inşa edemezseniz, değişmezleri kuramazsanız, o zaman kraterden bir istisna atarsınız. Değişmezleri bir yöntemle koruyamazsanız, bir şekilde bir hata sinyali verin ve geri alın. Bu, taşınan nesneler için farklı mı olmalı?

Bir hareket yapıcıdan istisnalar atmak genellikle kaba kabul edilir. Belleğin sahibi olan bir nesneyi taşırsanız, o belleğin sahipliğini aktarırsınız. Ve bu genellikle fırlatabilecek hiçbir şey içermez.

Ne yazık ki, bunu çeşitli nedenlerle uygulayamayız . Atma hareketinin bir olasılık olduğunu kabul etmeliyiz.

Ayrıca "geçerli ama henüz belirtilmemiş" dili takip etmek zorunda olmadığınız da unutulmamalıdır. C ++ standart kitaplığının standart türler için hareketin varsayılan olarak çalıştığını söylediği gibi . Bazı standart kütüphane türlerinin daha katı garantileri vardır. Örneğin, unique_ptrtaşınan bir unique_ptrörneğin durumu hakkında çok açık : eşittir nullptr.

İsterseniz daha güçlü bir garanti vermeyi seçebilirsiniz.

Sadece unutmayın: hareketi olan performans optimizasyonu , genellikle nesneler üzerinde yapılıyor biri yaklaşık imha edilecek. Bu kodu düşünün:

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

Bu v, dönüş değerine hareket edecektir (derleyicinin elit olmadığı varsayılırsa). Ve hareket tamamlandıktan sonra referans vermenin bir yolu yokv . vYararlı bir duruma sokmak için ne yaparsanız yapın anlamsızdır.

Çoğu kodda, taşınan nesne örneğini kullanma olasılığı düşüktür.

Sadece "bu nesne taşındıktan sonra, onunla yok etmek dışında herhangi bir şey yapmak yasadışıdır (UB)" başlığını belgelemek yeterli mi?

Her yöntem çağrısında geçerli olduğunu sürekli olarak iddia etmek daha mı iyi?

Önkoşullara sahip olmanın asıl amacı bu tür şeyleri kontrol etmemek . verilen dizine sahip bir öğeye sahip olma operator[]önkoşuluna sahiptir vector. UB boyutu dışında erişmeye çalışırsanız alırsınız vector. vector::at etmez böyle bir ön koşulu vardır; vectorböyle bir değere sahip değilse açıkça bir istisna atar .

Performans nedeniyle önkoşullar mevcuttur. Böylece arayanın kendileri için doğrulayabileceği şeyleri kontrol etmek zorunda kalmazsınız. Her çağrının boş v[0]olup olmadığını kontrol etmek zorunda değildir v; sadece ilki yapar.

Sınıfı kopyalanabilir kılmak daha iyidir, ancak taşınabilir değil mi?

Hayır. Aslında, bir sınıf asla "kopyalanamaz, ancak hareket ettirilemez". Kopyalanabiliyorsa, kopya oluşturucu çağrılarak taşınabilmelidir. Kullanıcı tanımlı bir kopya oluşturucu bildirir, ancak bir hareket yapıcı bildirmezseniz, bu C ++ 11'in standart davranışıdır. Özel hareket semantiği uygulamak istemiyorsanız benimsemen gereken davranış budur.

Çok belirli bir sorunu çözmek için taşıma semantiği mevcuttur: kopyalama işleminin aşırı derecede pahalı veya anlamsız olacağı (yani: dosya tanıtıcıları) büyük kaynaklara sahip nesnelerle uğraşmak. Nesneniz uygun değilse, kopyalama ve taşıma sizin için aynıdır.


5
Güzel. +1. Şunu not ediyorum: "Ön koşullara sahip olmanın asıl amacı bu tür şeyleri kontrol etmemek." - Bunun iddialar için geçerli olduğunu düşünmüyorum. İddialar, IMHO'nun ön koşulları kontrol etmek için iyi ve geçerli bir araçtır (çoğu zaman en azından.)
Martin Ba

3
Kopyalama / taşıma karmaşası, bir hareket ctorunun , yeni nesneyle aynı dahil olmak üzere , kaynak nesneyi herhangi bir durumda bırakabileceğini fark ederek netleştirilebilir - bu, olası sonuçların bir kopya ctorunkinin üst kümesi olduğu anlamına gelir.
MSalters
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.