C ++ 'da RAII ve akıllı işaretçiler


Yanıtlar:


317

RAII'nin basit (ve belki de aşırı) bir örneği bir File sınıfıdır. RAII olmadan, kod şöyle görünebilir:

File file("/path/to/file");
// Do stuff with file
file.close();

Başka bir deyişle, dosyayı bitirdikten sonra kapattığımızdan emin olmalıyız. Bunun iki dezavantajı vardır - ilk olarak, Dosya'yı her nerede kullanırsak kullanalım, File :: close () adını vermeliyiz. İkinci sorun, dosyayı kapatmadan önce bir istisna atılırsa ne olur?

Java, nihayet yan tümcesini kullanarak ikinci sorunu çözer:

try {
    File file = new File("/path/to/file");
    // Do stuff with file
} finally {
    file.close();
}

veya Java 7'den beri, bir kaynak-denemesi bildirimi:

try (File file = new File("/path/to/file")) {
   // Do stuff with file
}

C ++ RAII kullanarak her iki sorunu da çözer - yani, Dosya yıkıcısındaki dosyayı kapatır. File nesnesi doğru zamanda (yine de olması gerekir) yok edildiği sürece, dosyayı kapatmak bizim için halledilir. Yani, kodumuz şimdi şuna benziyor:

File file("/path/to/file");
// Do stuff with file
// No need to close it - destructor will do that for us

Bu, Java'da yapılamaz çünkü nesnenin ne zaman imha edileceğini garanti etmez, bu nedenle dosya gibi bir kaynağın ne zaman serbest bırakılacağını garanti edemeyiz.

Akıllı işaretçiler üzerine - çoğu zaman, sadece yığın üzerinde nesneler yaratırız. Örneğin (ve başka bir cevaptan bir örnek çalmak):

void foo() {
    std::string str;
    // Do cool things to or using str
}

Bu iyi çalışıyor - ama str dönmek istiyorsak? Bunu yazabiliriz:

std::string foo() {
    std::string str;
    // Do cool things to or using str
    return str;
}

Peki, bunda yanlış olan ne? Dönüş türü std :: string - yani değere göre döndürdüğümüz anlamına gelir. Bu, str kopyaladığımız ve aslında kopyayı döndürdüğümüz anlamına gelir. Bu pahalı olabilir ve kopyalama maliyetinden kaçınmak isteyebiliriz. Bu nedenle, referans veya işaretçi ile geri dönme fikri ortaya çıkabilir.

std::string* foo() {
    std::string str;
    // Do cool things to or using str
    return &str;
}

Ne yazık ki, bu kod çalışmıyor. Str'ye bir işaretçi döndürüyoruz - ancak yığın üzerinde str oluşturuldu, bu nedenle foo () 'dan çıktıktan sonra silineceğiz. Diğer bir deyişle, arayan işaretçiyi aldığında işe yaramaz (ve muhtemelen her türlü korkak hataya neden olabileceğinden işe yaramazdan daha kötüdür)

Peki, çözüm nedir? Yeni kullanarak yığın üzerinde str oluşturabiliriz - bu şekilde, foo () tamamlandığında str imha edilmez.

std::string* foo() {
    std::string* str = new std::string();
    // Do cool things to or using str
    return str;
}

Tabii ki, bu çözüm de mükemmel değil. Nedeni str oluşturduk, ama asla silmiyoruz. Bu çok küçük bir programda sorun olmayabilir, ancak genel olarak, sildiğimizden emin olmak istiyoruz. Arayanın nesneyi bitirdiğinde silmesi gerektiğini söyleyebiliriz. Dezavantajı, arayanın ekstra karmaşıklık ekleyen ve yanlış anlayabilen hafızayı yönetmesi gerektiğidir.

Akıllı işaretçiler burada devreye giriyor. Aşağıdaki örnek shared_ptr kullanıyor - aslında ne kullanmak istediğinizi öğrenmek için farklı akıllı işaretçi türlerine bakmanızı öneririm.

shared_ptr<std::string> foo() {
    shared_ptr<std::string> str = new std::string();
    // Do cool things to or using str
    return str;
}

Şimdi shared_ptr, str. Örneğin

shared_ptr<std::string> str = foo();
shared_ptr<std::string> str2 = str;

Şimdi aynı dizeye iki referans var. Str için kalan referans olmadığında, silinir. Bu nedenle, artık kendiniz silmek konusunda endişelenmenize gerek yok.

Hızlı düzenleme: yorumların bazılarının belirttiği gibi, bu örnek (en azından!) İki nedenden dolayı mükemmel değildir. İlk olarak, dizelerin uygulanması nedeniyle, bir dizenin kopyalanması ucuz olma eğilimindedir. İkincisi, adı verilen dönüş değeri optimizasyonu olarak bilinen şey nedeniyle, derleyici işleri hızlandırmak için biraz akıllılık yapabileceğinden değere göre döndürmek pahalı olmayabilir.

Şimdi File sınıfımızı kullanarak farklı bir örnek deneyelim.

Diyelim ki bir dosyayı günlük olarak kullanmak istiyoruz. Bu, dosyamızı yalnızca ekleme modunda açmak istediğimiz anlamına gelir:

File file("/path/to/file", File::append);
// The exact semantics of this aren't really important,
// just that we've got a file to be used as a log

Şimdi, dosyamızı birkaç başka nesnenin günlüğü olarak ayarlayalım:

void setLog(const Foo & foo, const Bar & bar) {
    File file("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Ne yazık ki, bu örnek korkunç bir şekilde bitiyor - bu yöntem sona erdiğinde dosya kapatılacak, yani foo ve bar artık geçersiz bir günlük dosyasına sahip olacak. Öbek üzerinde dosya oluşturabilir ve hem foo hem de çubuğa dosyaya bir işaretçi aktarabiliriz:

void setLog(const Foo & foo, const Bar & bar) {
    File* file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Ama sonra dosyayı silmekten kim sorumlu? Her iki dosyayı da silemezseniz, hem bellek hem de kaynak sızıntısı olur. Foo'nun veya çubuğun önce dosyayla bitip bitmeyeceğini bilmiyoruz, bu nedenle her ikisinin de dosyayı silmesini bekleyemeyiz. Örneğin, foo, çubuk tamamlanmadan dosyayı silerse, çubuk artık geçersiz bir işaretçiye sahiptir.

Tahmin edebileceğiniz gibi, bize yardımcı olması için akıllı işaretçiler kullanabiliriz.

void setLog(const Foo & foo, const Bar & bar) {
    shared_ptr<File> file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Şimdi, hiç kimsenin dosyayı silme konusunda endişelenmesine gerek yok - hem foo hem de çubuk bittiğinde ve artık dosyaya herhangi bir referansı olmadığında (muhtemelen foo ve bar imha edildiğinden), dosya otomatik olarak silinecektir.


7
Birçok dize uygulamasının, referans sayılan bir işaretçi açısından uygulandığına dikkat edilmelidir. Bu yazma üzerine kopyalama semantiği, bir dizgiyi değere göre döndürmeyi gerçekten ucuz hale getirir.

7
Olmayanlar için bile, birçok derleyici yükü ile ilgilenecek NRV optimizasyonu uygular. Genel olarak, shared_ptr'i nadiren kullanışlı buluyorum - sadece RAII'ye sadık kalın ve paylaşılan sahiplikten kaçının.
Nemanja Trifunovic

27
bir dize döndürmek gerçekten akıllı işaretçiler kullanmak için iyi bir neden değildir. dönüş değeri optimizasyonu dönüşü kolayca optimize edebilir ve c ++ 1x taşıma semantiği bir kopyayı tamamen ortadan kaldırır (doğru kullanıldığında). Bunun yerine bazı gerçek dünya örneğini göster (örneğin aynı kaynağı paylaştığımızda) :)
Johannes Schaub - litb

1
Sanırım Java'nın bunu neden yapamadığına dair erken sonucunuz net değil. Java veya C # 'da bu sınırlamayı tanımlamanın en kolay yolu, yığına ayırmanın bir yolu olmamasıdır. C #, özel bir anahtar kelime ile yığın tahsisine izin verir, ancak tür güvenliğini kaybedersiniz.
ApplePieIsGood

4
@Nemanja Trifunovic: Bu bağlamda RAII ile yığınları iade etmek / yığın üzerinde nesne oluşturmak mı demek istiyorsunuz? Alt sınıflara ayrılabilen türden nesneler iade / kabul ediyorsanız bu işe yaramaz. Sonra nesneyi dilimlemekten kaçınmak için bir işaretçi kullanmak zorundasınız ve bu durumlarda akıllı bir işaretçinin genellikle ham bir işaretçiden daha iyi olduğunu iddia ediyorum.
Frank Osterfeld

141

RAII Bu basit ama harika bir konsept için garip bir isim. Scope Bound Kaynak Yönetimi (SBRM) adı daha iyidir . Fikir, genellikle bir bloğun başlangıcında kaynakları tahsis etmeniz ve bir bloğun çıkışında serbest bırakmanız gerektiğidir. Bloktan çıkmak normal akış kontrolüyle, dışarı atlayarak ve hatta bir istisna ile olabilir. Tüm bu durumları kapsamak için, kod daha karmaşık ve gereksiz hale gelir.

SBRM olmadan bunu yapan bir örnek:

void o_really() {
     resource * r = allocate_resource();
     try {
         // something, which could throw. ...
     } catch(...) {
         deallocate_resource(r);
         throw;
     }
     if(...) { return; } // oops, forgot to deallocate
     deallocate_resource(r);
}

Gördüğünüz gibi, kandırmanın birçok yolu var. Fikir, kaynak yönetimini bir sınıfa yerleştirmemiz. Nesnesinin başlatılması kaynağı alır ("Kaynak Edinimi Başlatmadır"). Bloktan çıktığımızda (blok kapsamı), kaynak tekrar serbest bırakılır.

struct resource_holder {
    resource_holder() {
        r = allocate_resource();
    }
    ~resource_holder() {
        deallocate_resource(r);
    }
    resource * r;
};

void o_really() {
     resource_holder r;
     // something, which could throw. ...
     if(...) { return; }
}

Sadece kaynakları tahsis etme / dağıtma amacı taşımayan kendi sınıflarınız varsa bu güzeldir. Tahsis, işlerini yapmak için sadece ek bir endişe olacaktır. Ancak, kaynakları ayırmak / dağıtmak istediğinizde, yukarıdakiler kullanışlı olmaz. Aldığınız her türlü kaynak için bir sarma sınıfı yazmalısınız. Bunu kolaylaştırmak için akıllı işaretçiler bu işlemi otomatikleştirmenize izin verir:

shared_ptr<Entry> create_entry(Parameters p) {
    shared_ptr<Entry> e(Entry::createEntry(p), &Entry::freeEntry);
    return e;
}

Normalde akıllı işaretçiler, sahip deleteoldukları kaynak kapsam dışına çıktığında çağıran yeni / sil etrafında ince sarmalayıcılardır . Shared_ptr gibi bazı akıllı işaretçiler, onlara bunun yerine kullanılan bir silici söylemenizi sağlar delete. Örneğin, shared_ptr öğesine doğru silme hakkında bilgi verdiğiniz sürece pencere tutamaçlarını, normal ifade kaynaklarını ve diğer rasgele şeyleri yönetmenizi sağlar.

Farklı amaçlar için farklı akıllı işaretçiler vardır:

unique_ptr

yalnızca bir nesnenin sahibi olan akıllı bir göstericidir. Desteklenmiyor, ancak muhtemelen bir sonraki C ++ Standardında görünecek. Bu var olmayan copyable ama destekleri aktarmak sahip olma . Bazı örnek kodlar (sonraki C ++):

Kod:

unique_ptr<plot_src> p(new plot_src); // now, p owns
unique_ptr<plot_src> u(move(p)); // now, u owns, p owns nothing.
unique_ptr<plot_src> v(u); // error, trying to copy u

vector<unique_ptr<plot_src>> pv; 
pv.emplace_back(new plot_src); 
pv.emplace_back(new plot_src);

Auto_ptr öğesinin aksine, unique_ptr bir kaba konabilir, çünkü kaplar akışlar ve unique_ptr gibi kopyalanamayan (ancak taşınabilir) türleri de tutabilir.

scoped_ptr

ne kopyalanabilir ne de taşınabilir bir boost akıllı işaretçi. Kapsam dışına çıkarken işaretçilerin silinmesini sağlamak istediğinizde kullanmak için mükemmel bir şeydir.

Kod:

void do_something() {
    scoped_ptr<pipe> sp(new pipe);
    // do something here...
} // when going out of scope, sp will delete the pointer automatically. 

shared_ptr

ortak mülkiyet içindir. Bu nedenle, hem kopyalanabilir hem de taşınabilir. Birden fazla akıllı işaretçi örneği aynı kaynağa sahip olabilir. Kaynağa sahip olan son akıllı işaretçi kapsam dışına çıkar çıkmaz, kaynak serbest bırakılır. Projelerimden birine gerçek dünyadan bir örnek:

Kod:

shared_ptr<plot_src> p(new plot_src(&fx));
plot1->add(p)->setColor("#00FF00");
plot2->add(p)->setColor("#FF0000");
// if p now goes out of scope, the src won't be freed, as both plot1 and 
// plot2 both still have references. 

Gördüğünüz gibi, arsa kaynağı (işlev fx) paylaşılır, ancak her birinin rengini ayarladığımız ayrı bir girişi vardır. Kodun akıllı bir işaretçinin sahip olduğu kaynağa başvurması gerektiğinde kullanılan ancak kaynağın sahibi olması gerekmeyen bir zayıf_ptr sınıfı vardır. Ham bir işaretçiyi iletmek yerine, bir poor_ptr oluşturmanız gerekir. Kaynağa artık paylaşılan bir shareptr olmasa bile, kaynağa zayıf bir_ptr erişim yolu ile erişmeye çalıştığınızı fark ettiğinde bir istisna atar.


Bildiğim kadarıyla, kopyalanamayan nesneler değer semantiğine bağlı olarak stl kaplarda hiç iyi değildir - bu kapsayıcı sıralamak isterseniz ne olur? sıralama öğeleri kopyalar ...
fmuecke

C ++ 0x kapları, yalnızca taşıma türlerine saygı gösterecek şekilde değiştirilecek unique_ptrve sortaynı şekilde değiştirilecektir.
Johannes Schaub - litb

SBRM terimini ilk duyduğunuz yeri hatırlıyor musunuz? James onu bulmaya çalışıyor.
GManNickG

bunları kullanmak için hangi başlıkları veya kütüphaneleri eklemeliyim? bu konuda başka okumalar var mı?
atoMerz

Burada bir tavsiye: @litb tarafından bir C ++ sorusuna cevap varsa, doğru cevap (oylar veya "doğru" olarak işaretlenmiş cevap ne olursa olsun) ...
fnl

32

Öncül ve nedenler kavram olarak basittir.

RAII, değişkenlerin yapıcılarında gerekli tüm başlatmayı ve yıkıcılarında gerekli tüm temizlemeyi gerçekleştirmelerini sağlayan tasarım paradigmasıdır . Bu, tüm başlatma ve temizlemeyi tek bir adıma azaltır.

C ++ RAII gerektirmez, ancak RAII yöntemlerini kullanmanın daha sağlam kod üreteceği giderek daha fazla kabul edilmektedir.

RAII'nin C ++ 'da yararlı olmasının nedeni, C ++' ın, normal kod akışı veya bir istisna tarafından tetiklenen yığın çözme yoluyla kapsama girip çıkarken değişkenlerin oluşturulmasını ve yok edilmesini kendiliğinden yönetmesidir. Bu C ++ 'da bir freebie.

Tüm başlatma ve temizleme işlemlerini bu mekanizmalara bağlayarak, C ++ 'ın da bu işi sizin için halledeceğinden emin olursunuz.

C ++ 'da RAII hakkında konuşmak genellikle akıllı işaretçilerin tartışılmasına yol açar, çünkü işaretçiler temizlik söz konusu olduğunda özellikle kırılgandır. Malloc veya new'den alınan yığın tahsisli belleği yönetirken, imleç imha edilmeden önce bu belleği boşaltmak veya silmek genellikle programcının sorumluluğundadır. Akıllı işaretçiler, işaretçi değişkeni her yok edildiğinde yığın tahsisli nesnelerin yok edilmesini sağlamak için RAII felsefesini kullanır.


Buna ek olarak - işaretçiler RAII'nin en yaygın uygulamasıdır - muhtemelen diğer kaynaklardan binlerce kat daha fazla işaretçi tahsis edersiniz.
Tutulma

8

Akıllı işaretçi RAII'nin bir varyasyonudur. RAII, kaynak edinmenin başlatma olduğu anlamına gelir. Akıllı işaretçi kullanımdan önce bir kaynak (bellek) alır ve daha sonra otomatik olarak bir yıkıcıya atar. İki şey olur:

  1. Belleği kullanmadan önce, her zaman, öyle hissetmediğimizde bile tahsis ediyoruz - akıllı bir işaretçi ile başka bir yol yapmak zor. Bu gerçekleşmezse, NULL belleğe erişmeye çalışacaksınız, bu da bir çarpışma (çok acı verici) ile sonuçlanacak.
  2. Biz özgür hafıza bir hata olmasa bile. Asılı bellek kalmadı.

Örneğin, başka bir örnek ağ soketi RAII'dir. Bu durumda:

  1. Ağ soketini kullanmadan önce, her zaman, hissetmediğimiz zamanlarda bile açıyoruz - RAII ile başka bir şekilde yapmak zor. Bunu RAII olmadan yapmayı denerseniz boş bir soket açabilirsiniz, MSN bağlantısı diyelim. Sonra "bu gece yapalım" gibi mesajlar aktarılmayabilir, kullanıcılar yerleştirilmez ve işten çıkarılma riskiyle karşı karşıya kalabilirsiniz.
  2. Bir hata olsa bile ağ soketini kapatıyoruz . Hiçbir soket asılı kalmaz, çünkü bu yanıt mesajının "altta olacağından emin olun" göndericiyi geri vurmasını engelleyebilir.

Gördüğünüz gibi, RAII çoğu durumda insanların atılmasına yardımcı olduğu için çok yararlı bir araçtır.

Akıllı işaretçilerin C ++ kaynakları, üstümdeki yanıtlar da dahil olmak üzere, net milyonlarca kişidir.


2

Boost, Boost'da olanlar da dahil olmak üzere bunlardan birkaçına sahiptir . Özellikle aynı veri yapısını paylaşan 5 işleminiz olduğu gibi baş ağrısına neden olan durumlarda bellek yönetimini büyük ölçüde basitleştirir: Herkes bir bellek yığını ile işini bitirdiğinde, otomatik olarak serbest kalmasını ve anlamaya çalışarak orada oturmasını istemezsiniz. deletebir bellek hafızasını çağırmaktan kim sorumlu olmalı , bir bellek sızıntısıyla sonuçlanmasa veya yanlışlıkla iki kez serbest bırakılan ve tüm yığını bozabilecek bir işaretçi ile sonuçlanmalıdır.


0
geçersiz foo ()
{
   std :: dize çubuğu;
   //
   // burada daha fazla kod
   //
}

Ne olursa olsun, foo () işlevinin kapsamı geride bırakıldıktan sonra çubuk düzgün bir şekilde silinecektir.

Dahili olarak std :: string uygulamaları genellikle referans sayılan işaretçiler kullanır. Bu nedenle, iç dizginin yalnızca dizelerin kopyalarından biri değiştiğinde kopyalanması gerekir. Bu nedenle referans olarak sayılan akıllı işaretçi, yalnızca gerektiğinde bir şey kopyalamayı mümkün kılar.

Ayrıca, dahili referans sayımı, dahili dizenin kopyası artık gerekli olmadığında belleğin düzgün bir şekilde silinmesini mümkün kılar.


1
void f () {Obj x; } Obj x, yığın çerçeve oluşturma / imha (çözme) ile silinir ... ref sayımı ile ilgili değildir.
Hernán

Referans sayımı, dizenin dahili uygulamasının bir özelliğidir. RAII, nesne kapsam dışına çıktığında nesne silme işleminin arkasındaki kavramdır. Soru RAII ve akıllı işaretçilerle ilgiliydi.

1
"Ne olursa olsun" - işlev dönmeden önce bir istisna atılırsa ne olur?
titaniumdecoy

Hangi işlev döndürülür? Foo'da bir istisna atılırsa, çubuk silinir. Bir istisna fırlatma çubuğunun varsayılan kurucusu olağanüstü bir olay olacaktır.
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.