Neden kopyalayıp taşıyoruz?


98

Birinin bir nesneyi kopyalamaya ve ardından onu bir sınıfın veri üyesine taşımaya karar verdiği bir yerde kodu gördüm. Bu, hareket etmenin tüm amacının kopyalamaktan kaçınmak olduğunu düşündüğüm için kafamı karıştırdı. İşte örnek:

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

İşte sorularım:

  • Neden bir rvalue referansı almıyoruz str?
  • Özellikle böyle bir şey verildiğinde bir kopya pahalı olmayacak std::stringmı?
  • Yazarın bir kopya çekip ardından hamle yapmaya karar vermesinin nedeni ne olabilir?
  • Bunu kendim ne zaman yapmalıyım?

Bana aptalca bir hata gibi görünüyor, ancak konu hakkında daha fazla bilgiye sahip birinin bu konuda söyleyecek bir şeyi olup olmadığını görmekle ilgileneceğim.
Dave



Yanıtlar:


97

Sorularınızı cevaplamadan önce, yanlış anladığınız bir şey var: C ++ 11'de değer bazında almak her zaman kopyalama anlamına gelmez. Bir rvalue geçilirse, bu kopyalanmak yerine taşınacaktır (uygun bir hareket yapıcısı olması koşuluyla). Ve std::stringbir hareket oluşturucuya sahip.

C ++ 03'ün aksine, C ++ 11'de aşağıda açıklayacağım nedenlerden ötürü parametreleri değere göre almak genellikle deyimseldir. Parametrelerin nasıl kabul edileceğine ilişkin daha genel bir yönergeler kümesi için StackOverflow'daki bu Soru-Cevap bölümüne de bakın .

Neden bir rvalue referansı almıyoruz str?

Çünkü bu, aşağıdaki gibi değerlerin geçirilmesini imkansız hale getirir:

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

Eğer Ssadece SağDeğerler kabul eden bir kurucu vardı, yukarıda derlemek olmaz.

Özellikle böyle bir şey verildiğinde bir kopya pahalı olmayacak std::stringmı?

Bir rvalue geçerseniz, bu değer içine taşınacakstr ve bu sonunda içine taşınacaktır data. Kopyalama yapılmayacaktır. Öte yandan, bir değer geçerseniz, o değer içine kopyalanırstr ve sonra içine taşınır data.

Özetlemek gerekirse, rdeğerler için iki hareket, l değerler için bir kopya ve bir hareket.

Yazarın bir kopya çekip ardından hamle yapmaya karar vermesinin nedeni ne olabilir?

Öncelikle yukarıda da bahsettiğim gibi ilki her zaman bir kopya değildir; ve cevap şu oldu: " Çünkü verimli ( std::stringnesnelerin hareketleri ucuz) ve basittir ".

Hareketlerin ucuz olduğu varsayımı altında (burada SSO göz ardı edilerek), bu tasarımın genel verimliliği dikkate alındığında pratik olarak göz ardı edilebilirler. Bunu yaparsak, ldeğerler için bir nüshamız olur (bir ldeğer başvurusunu kabul etseydik yapacağımız gibi const) ve rdeğerler için kopya yoktur (lvalue referansını kabul edersek yine de bir kopyamız olur const).

Bu, değere göre almanın, ldeğerlerin sağlandığı constzamana göre l değeri referans almak kadar iyi ve r değerleri sağlandığında daha iyi olduğu anlamına gelir .

Not: Bir bağlam sağlamak için, bunun OP'nin bahsettiği Soru ve Cevap olduğuna inanıyorum .


2
Bunun const T&argüman geçişinin yerini alan bir C ++ 11 örüntüsü olduğunu belirtmeye değer : en kötü durumda (lvalue) bu aynıdır, ancak geçici olması durumunda yalnızca geçici olanı hareket ettirmeniz gerekir. Kazan-kazan.
syam

3
@ user2030677: Bir referans kaydetmediğiniz sürece bu kopyayı dolaşmak yok.
Benjamin Lindley

5
@ user2030677: Kim kopya sürece ihtiyaç olarak ne kadar pahalı umurunda (ve bir tutmak istiyorsanız, yapmanız kopyasını sizin de dataüyesi)? Değerini referans alarak alsanız bile bir kopyanız olurduconst
Andy Prowl

3
@BenjaminLindley: Bir ön hazırlık olarak şunu yazdım: " Hareketlerin ucuz olduğu varsayımı altında, bu tasarımın genel verimliliği düşünüldüğünde pratik olarak göz ardı edilebilirler. " Yani evet, bir hareketin ek yükü olabilir, ancak bunun basit bir tasarımı daha verimli bir şeye dönüştürmeyi haklı kılan gerçek bir endişe olduğuna dair kanıt olmadığı sürece bu ihmal edilebilir olarak kabul edilmelidir.
Andy Prowl

1
@ user2030677: Ama bu tamamen farklı bir örnek. Sorunuzdaki örnekte, her zaman bir kopyasını elinizde tutuyorsunuz data!
Andy Prowl

51

Bunun neden iyi bir model olduğunu anlamak için hem C ++ 03 hem de C ++ 11'deki alternatifleri incelemeliyiz.

Aşağıdakileri almak için C ++ 03 yöntemimiz var std::string const&:

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

bu durumda, her zaman tek bir kopya gerçekleştirilir. Ham bir C dizesinden inşa ederseniz, a std::stringoluşturulur, sonra tekrar kopyalanır: iki ayırma.

std::stringA'ya referans almak ve sonra onu yerel olarak değiştirmek için C ++ 03 yöntemi vardır std::string:

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

bu, "taşıma anlambiliminin" C ++ 03 sürümüdür ve swapgenellikle yapılması çok ucuz olacak şekilde optimize edilebilir (a gibi move). Aynı zamanda bağlam içinde de analiz edilmelidir:

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

ve sizi geçici olmayanı oluşturmaya zorlar std::string, sonra onu atın. (Geçici std::string, const olmayan bir referansa bağlanamaz). Ancak yalnızca bir tahsis yapılır. C ++ 11 sürümü a alır &&ve onu std::movegeçici olarak veya geçici olarak çağırmanızı gerektirir: bu, arayanın açıkça çağrının dışında bir kopya oluşturmasını ve bu kopyayı işleve veya kurucuya taşımasını gerektirir.

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

Kullanım:

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

Ardından, hem kopyayı hem de moveşunları destekleyen tam C ++ 11 sürümünü yapabiliriz :

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

Daha sonra bunun nasıl kullanıldığını inceleyebiliriz:

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

Bu 2 aşırı yükleme tekniğinin, yukarıdaki iki C ++ 03 stilinden daha fazla olmasa da en az o kadar verimli olduğu oldukça açıktır. Bu 2-aşırı yüklemeli versiyonu "en uygun" versiyon olarak adlandıracağım.

Şimdi, kopyalayıp kopyalanan sürümü inceleyeceğiz:

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

bu senaryoların her birinde:

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

Bunu yan yana "en uygun" sürümle karşılaştırırsanız, tam olarak bir tane daha yapıyoruz move! Fazladan bir kez yapmayız copy.

Öyleyse movebunun ucuz olduğunu varsayarsak , bu sürüm bize en uygun sürümle neredeyse aynı performansı verir, ancak 2 kat daha az kod.

Ve eğer 2 ila 10 argüman alıyorsanız, koddaki azalma üsteldir - 1 bağımsız değişkenle 2 kat daha az, 2 ile 4x, 3 ile 16x, 4 ile 16x, 10 bağımsız değişkenle 1024x.

Şimdi, mükemmel yönlendirme ve SFINAE yoluyla, 10 argüman alan tek bir kurucu veya işlev şablonu yazmanıza izin vererek, argümanların uygun türlerde olmasını sağlamak için SFINAE yapar ve sonra bunları içine taşır veya kopyalarız. gerektiği gibi yerel eyalet. Bu, program boyutu sorunundaki bin kat artışı önlerken, yine de bu şablondan oluşturulan bir dizi işlev olabilir. (şablon işlev somutlaştırmaları işlevler üretir)

Ve çok sayıda üretilen işlev, daha büyük yürütülebilir kod boyutu anlamına gelir ve bu da performansı düşürebilir.

Birkaç movesaniyenin maliyetine, daha kısa kod ve neredeyse aynı performans elde ediyoruz ve genellikle kodu anlamak daha kolay.

Şimdi, bu sadece işe yarıyor, çünkü fonksiyon (bu durumda bir yapıcı) çağrıldığında, bu argümanın yerel bir kopyasını isteyeceğimizi biliyoruz. Buradaki fikir şudur ki, eğer bir kopya yapacağımızı bilirsek, arayan kişiye onu argüman listemize ekleyerek bir kopya yaptığımızı bildirmeliyiz. Daha sonra bize bir kopya verecekleri gerçeği etrafında optimizasyon yapabilirler (örneğin argümanımıza geçerek).

'Değere göre alma' tekniğinin bir başka avantajı da, genellikle kurucuları hareket ettirmenin istisnasız olmasıdır. Bu, değer olarak alan ve argümanlarından çıkan işlevlerin çoğu zaman hariç tutulabileceği anlamına gelir, herhangi bir throws'yi vücutlarından dışarıya ve çağrı kapsamına taşır. (bazen doğrudan inşa yoluyla kim kaçınabilir veya movefırlatmanın nerede gerçekleşeceğini kontrol etmek için öğeleri ve argümanın içine inşa eder ) Atışsız yöntemler yapmak çoğu zaman buna değer.


Bir kopya yapacağımızı biliyorsak, derleyicinin yapmasına izin vermeliyiz, çünkü derleyici her zaman daha iyisini bilir.
Rayniery

6
Bunu yazdığımdan beri, bana başka bir avantaj daha vurgulandı: genellikle oluşturucular atabilir, ancak hareket oluşturucular genellikle atabilir noexcept. Verileri kopyalamaya alarak, işlevinizi yapabilir noexceptve işlev çağrınızın dışında herhangi bir kopyalama yapısının neden olduğu potansiyel atmalar (bellek yetersizliği gibi) olmasını sağlayabilirsiniz .
Yakk - Adam Nevraumont

3 aşırı yükleme tekniğindeki "lvalue non-const, copy" sürümüne neden ihtiyacınız var? "Lvalue const, copy" aynı zamanda const olmayan durumu da işlemez mi?
Bruno Martinez

@BrunoMartinez biz yok!
Yakk - Adam Nevraumont

13

Bu muhtemelen kasıtlıdır ve copy ve swap deyimine benzer . Temel olarak, dizge kurucudan önce kopyalandığından, kurucunun kendisi istisnai olarak güvenlidir, çünkü yalnızca geçici dizeyi değiştirir (taşır).


Kopyala ve değiştir paralel için +1. Gerçekten de birçok benzerliği var.
syam

11

Hareket için bir kurucu ve kopya için bir kurucu yazarak kendinizi tekrarlamak istemezsiniz:

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

Bu, özellikle birden fazla argümanınız varsa, daha çok standart koddur. Çözümünüz, gereksiz bir hareketin maliyeti üzerinde bu tekrarlamayı önler. (Bununla birlikte, taşıma işlemi oldukça ucuz olmalıdır.)

Rakip deyim, mükemmel iletmeyi kullanmaktır:

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

Şablon sihri, ilettiğiniz parametreye bağlı olarak taşımayı veya kopyalamayı seçecektir. Temel olarak, her iki kurucunun da elle yazıldığı ilk sürüme genişler. Arka plan bilgisi için, Scott Meyer'in evrensel referanslar hakkındaki gönderisine bakın .

Performans açısından bakıldığında, mükemmel yönlendirme sürümü, gereksiz hareketleri önlediği için sürümünüzden daha üstündür. Bununla birlikte, sürümünüzün okunması ve yazılması daha kolay olduğu söylenebilir. Olası performans etkisi çoğu durumda önemli olmamalı, bu yüzden sonunda bir tarz meselesi gibi görünüyor.

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.