genel bakış
Kopyalama ve değiştirme deyimine neden ihtiyacımız var?
Bir kaynağı ( akıllı bir işaretçi gibi bir sarıcı) yöneten herhangi bir sınıfın Büyük Üç'ü uygulaması gerekir . Kopya oluşturucu ve yıkıcıların hedefleri ve uygulaması açık olsa da, kopya atama operatörü tartışmasız en nüanslı ve zordur. Nasıl yapılmalı? Hangi tuzaklardan kaçınılmalıdır?
Kopyala-takas deyim çözümdür ve zarif iki şey ulaşmada atama operatöre yardımcı olur: kaçınarak kod çoğaltma ve sağlayan güçlü istisna garanti .
O nasıl çalışır?
Kavramsal olarak , verilerin yerel bir kopyasını oluşturmak için kopya oluşturucu işlevini kullanarak çalışır, daha sonra kopyalanan verileri bir swap
işlevle alır ve eski verileri yeni verilerle değiştirir. Geçici kopya daha sonra eski verileri beraberinde alarak yok eder. Yeni verilerin bir kopyası bırakıldı.
Kopyala-takas deyimini kullanmak için üç şeye ihtiyacımız var: çalışan bir kopya oluşturucu, çalışan bir yıkıcı (her ikisi de herhangi bir sargının temelidir, bu yüzden zaten tamamlanmalıdır) ve bir swap
işlev.
Takas işlevi , üyeye üye olan bir sınıfın iki nesnesini değiştiren fırlatmayan bir fonksiyondur. Kendimizi std::swap
sağlamak yerine kullanmaya cazip gelebiliriz , ama bu imkansız olurdu; std::swap
kopya oluşturucu ve kopya atama işlecini uygulaması içinde kullanır ve sonuçta atama işlecini kendisi açısından tanımlamaya çalışırız!
(Sadece bu değil, aynı zamanda kalifiye olmayan çağrılar, swap
özel takas operatörünüzü kullanacak ve sınıfımızın gereksiz inşaat ve yıkımını std::swap
atlayacaktır.)
Ayrıntılı bir açıklama
Amaç
Somut bir durumu ele alalım. Aksi halde işe yaramaz bir sınıfta dinamik bir dizi yönetmek istiyoruz. Çalışan bir kurucu, kopya kurucu ve yıkıcı ile başlıyoruz:
#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;
};
Bu sınıf neredeyse diziyi başarıyla yönetir, ancak operator=
düzgün çalışması gerekir .
Başarısız bir çözüm
Saf bir uygulama şöyle görünebilir:
// 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;
}
Ve biz bittiğimizi söylüyoruz; bu artık bir diziyi sızıntı olmadan yönetiyor. Bununla birlikte, kodda sırayla işaretlenen üç sorundan muzdariptir (n)
.
Birincisi, kendini atama testidir. Bu kontrol iki amaca hizmet eder: kendi kendine atamada gereksiz kod çalıştırmamızı önlemenin kolay bir yoludur ve bizi küçük hatalardan korur (diziyi yalnızca denemek ve kopyalamak için silmek gibi). Ancak diğer tüm durumlarda, yalnızca programı yavaşlatmaya ve kodda gürültü görevi görmeye hizmet eder; kendi kendine atama nadiren gerçekleşir, bu nedenle bu kontrol çoğu zaman bir israftır. Operatörün onsuz düzgün çalışabilmesi daha iyi olurdu.
İkincisi, sadece temel bir istisna garantisi sağlamasıdır. Eğer new int[mSize]
başarısız *this
değiştirilmiş olacaktır. (Yani, boyut yanlış ve veriler kayboldu!) Güçlü bir istisna garantisi için aşağıdakine benzer bir şey olması gerekir:
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;
}
Kod genişledi! Bu da bizi üçüncü soruna götürür: kod çoğaltma. Atama işlecimiz, başka bir yerde yazmış olduğumuz tüm kodları etkili bir şekilde çoğaltır ve bu korkunç bir şeydir.
Bizim durumumuzda, çekirdeği sadece iki satırdır (tahsis ve kopya), ancak daha karmaşık kaynaklarla bu kod şişmesi oldukça zor olabilir. Kendimizi asla tekrar etmemeye çalışmalıyız.
(Biri merak edebilir: bir kaynağı doğru bir şekilde yönetmek için bu kadar kod gerekiyorsa, ya sınıfım birden fazla yönetiyorsa? Bu geçerli bir endişe gibi görünse de, gerçekten önemsiz try
/ catch
cümle gerektiriyorsa , bu bir Çünkü bir sınıf sadece bir kaynağı yönetmelidir !)
Başarılı bir çözüm
Belirtildiği gibi, kopyala-takas deyimi tüm bu sorunları çözecektir. Ama şu anda, biri hariç tüm gereksinimlere sahibiz: bir swap
işlev. Üçüncül Kural, kopya oluşturucu, atama operatörümüz ve yıkıcımızın varlığını başarıyla gerektirse de, buna gerçekten "Büyük Üç Buçuk" denmelidir: sınıfınız bir kaynağı yönettiğinde, bir swap
işlev sağlamak da mantıklıdır .
Sınıfımıza takas işlevselliği eklememiz gerekiyor ve bunu aşağıdaki gibi yapıyoruz †:
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);
}
// ...
};
( İşte neden açıklaması public friend swap
.) Şimdi sadece bizimkileri dumb_array
değil, aynı zamanda genel olarak takaslar daha verimli olabilir; tüm dizileri ayırmak ve kopyalamak yerine yalnızca işaretçileri ve boyutları değiştirir. İşlevsellik ve verimlilikteki bu bonusun yanı sıra, artık kopyala ve takas deyimini uygulamaya hazırız.
Daha fazla uzatmadan, atama operatörümüz:
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
Ve bu kadar! Biri düştüğünde, her üç problem de aynı anda zarif bir şekilde ele alınır.
Neden çalışıyor?
İlk önce önemli bir seçim olduğunu fark ediyoruz: parametre argümanı by-value alınır . Biri aşağıdakileri kolayca yapabilirken (ve aslında deyimin birçok naif uygulaması bunu yapabilir):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
Önemli bir optimizasyon fırsatını kaybediyoruz . Sadece bu değil, bu seçenek daha sonra tartışılacak olan C ++ 11'de kritik öneme sahiptir. (Genel bir notta, son derece yararlı bir kılavuz şöyledir: bir işlevdeki bir şeyin kopyasını yapacaksanız, derleyicinin parametre listesinde yapmasına izin verin. ‡)
Her iki durumda da, kaynağımızı elde etmenin bu yöntemi kod çoğaltmayı ortadan kaldırmanın anahtarıdır: kopyayı yapmak için kodu kopya oluşturucudan kullanıyoruz ve hiçbir zaman tekrarlamamıza gerek yok. Artık kopya yapıldığı için değiştirmeye hazırız.
Fonksiyona girdikten sonra tüm yeni verilerin önceden tahsis edildiğini, kopyalandığını ve kullanıma hazır olduğunu gözlemleyin. Bize güçlü bir istisna garantisi veren şey budur: kopyanın yapımı başarısız olursa işleve bile girmeyiz ve bu nedenle durumunu değiştirmek mümkün değildir *this
. (Güçlü bir istisna garantisi için daha önce manuel olarak yaptığımız şey, derleyici şimdi bizim için yapıyor; nasıl tür.)
Bu noktada evde özgürüz, çünkü swap
fırlatmayan. Mevcut verilerimizi kopyalanan verilerle değiştiririz, güvenli bir şekilde durumumuzu değiştiririz ve eski veriler geçici hale getirilir. İşlev geri döndüğünde eski veriler serbest bırakılır. (Parametrenin kapsamı biter ve yıkıcısı çağrılır.)
Deyim hiçbir kodu tekrarlamadığından, operatöre hatalar ekleyemeyiz. Bunun, kendi kendine atama kontrolüne ihtiyaç duyduğumuz anlamına geldiğini ve tek bir tek tip uygulamaya izin verdiğimizi unutmayın operator=
. (Buna ek olarak, artık kendi hesabına atamalarda performans cezası da yok.)
Ve bu kopyala-takas deyimidir.
C ++ 11 ne olacak?
C ++ 'ın bir sonraki sürümü olan C ++ 11, kaynakları yönetme şeklimizde çok önemli bir değişiklik yapar: Üç Kural şimdi Dört Kural (bir buçuk). Neden? Çünkü sadece kaynağımızı kopyalayabilmemiz gerekmiyor, aynı zamanda onu da taşımamız gerekiyor .
Neyse ki bizim için bu kolay:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
Burada neler oluyor? Taşımacılık-inşaatın amacını hatırlayın: kaynakları sınıfın başka bir örneğinden almak, tahsis edilebilir ve yıkılabilir olması garanti edilen bir durumda bırakmak.
Yaptığımız şey basit: varsayılan kurucu (C ++ 11 özelliği) ile başlangıç durumuna getirin, sonra ile değiştirin other
; sınıfımızın varsayılan olarak oluşturulmuş bir örneğinin güvenli bir şekilde atanıp yok other
edilebileceğini biliyoruz, bu yüzden takas ettikten sonra da aynısını yapabileceğimizi biliyoruz .
(Bazı derleyicilerin kurucu temsilcisini desteklemediğini unutmayın; bu durumda sınıfı el ile varsayılan olarak yapılandırmamız gerekir. Bu talihsiz ama neyse ki önemsiz bir görevdir.)
Neden işe yarıyor?
Sınıfımızda yapmamız gereken tek değişiklik bu, neden işe yarıyor? Parametreyi referans değil, değer yapmak için verdiğimiz önemli kararı hatırlayın:
dumb_array& operator=(dumb_array other); // (1)
Şimdi, other
bir rvalue ile başlatılırsa, bu hareket inşa edilecektir . Mükemmel. Aynı şekilde C ++ 03, argüman değer değeri alarak kopya oluşturucu işlevimizi yeniden kullanmamıza izin verir, C ++ 11 uygun olduğunda otomatik olarak hareket yapıcısını seçer. (Ve elbette, daha önce bağlantılı makalede belirtildiği gibi, değerin kopyalanması / taşınması tamamen birlikte yapılabilir.)
Ve böylece kopyala-takas deyimini bitirir.
Dipnotlar
* Neden mArray
null değerine ayarlıyoruz ? Çünkü operatörde başka bir kod atarsa, yıkıcısı dumb_array
çağrılabilir; ve bu durum null değerine ayarlanmadan gerçekleşirse, zaten silinmiş olan belleği silmeye çalışırız! Null değerini silmek işlem olmadığından, bunu null değerine ayarlayarak bundan kaçınırız.
† Tipimiz std::swap
için uzmanlaşmamız , sınıf içi swap
bir serbest fonksiyon swap
, vb. Sağlamamız gerektiğine dair başka iddialar swap
da var. ADL aracılığıyla bulundu . Bir işlev yapacak.
‡ Sebebi basit: Bir kez kaynağınız varsa, onu değiştirebilir ve / veya olması gereken yere taşıyabilirsiniz (C ++ 11). Ve parametre listesinde kopya yaparak optimizasyonu en üst düzeye çıkarırsınız.
†→ Hareket yapıcı genellikle olmalıdır noexcept
, aksi takdirde bazı kodlar (örn. Yeniden std::vector
boyutlandırma mantığı) bir hareket mantıklı olsa bile kopya yapıcısını kullanır. Tabii ki, sadece içindeki kod istisnalar atmıyorsa bunu işaretleyin.