RAII i inteligentne wskaźniki w C ++


Odpowiedzi:


317

Prostym (i być może nadużywanym) przykładem RAII jest klasa File. Bez RAII kod może wyglądać mniej więcej tak:

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

Innymi słowy, musimy upewnić się, że zamkniemy plik po zakończeniu. Ma to dwie wady - po pierwsze, gdziekolwiek używamy File, będziemy musieli wywołać File :: close () - jeśli zapomnimy to zrobić, będziemy trzymać plik dłużej niż jest to konieczne. Drugi problem dotyczy sytuacji, w której zgłoszony zostanie wyjątek przed zamknięciem pliku?

Java rozwiązuje drugi problem za pomocą klauzuli „wreszcie”:

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

lub od wersji Java 7 instrukcja try-with-resource:

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

C ++ rozwiązuje oba problemy za pomocą RAII - to znaczy zamykając plik w destruktorze File. Tak długo, jak obiekt File zostanie zniszczony we właściwym czasie (którym i tak powinien być), zamknięcie pliku jest załatwione za nas. Nasz kod wygląda teraz tak:

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

Nie można tego zrobić w Javie, ponieważ nie ma gwarancji, że obiekt zostanie zniszczony, więc nie możemy zagwarantować, kiedy zasób taki jak plik zostanie zwolniony.

Na inteligentne wskaźniki - często tworzymy obiekty na stosie. Na przykład (i kradnąc przykład z innej odpowiedzi):

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

Działa to dobrze - ale co, jeśli chcemy zwrócić str? Możemy to napisać:

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

Co jest z tym nie tak? Typem zwracanym jest std :: string - więc oznacza to, że zwracamy wartość. Oznacza to, że kopiujemy str i faktycznie zwracamy kopię. Może to być kosztowne i możemy chcieć uniknąć kosztów jego kopiowania. Dlatego możemy wymyślić pomysł powrotu przez odniesienie lub wskaźnik.

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

Niestety ten kod nie działa. Zwracamy wskaźnik do str - ale str został utworzony na stosie, więc jesteśmy usuwani po wyjściu z foo (). Innymi słowy, zanim dzwoniący otrzyma wskaźnik, jest bezużyteczny (i prawdopodobnie gorszy niż bezużyteczny, ponieważ jego użycie może powodować różnego rodzaju błędy funkcyjne)

Więc jakie jest rozwiązanie? Możemy utworzyć str na stercie za pomocą new - w ten sposób, gdy foo () zostanie zakończone, str nie zostanie zniszczony.

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

Oczywiście to rozwiązanie również nie jest idealne. Powodem jest to, że utworzyliśmy str, ale nigdy go nie usuwamy. Może to nie być problemem w bardzo małym programie, ale ogólnie chcemy się upewnić, że go usunęliśmy. Moglibyśmy po prostu powiedzieć, że osoba dzwoniąca musi usunąć obiekt, gdy już go skończy. Minusem jest to, że osoba dzwoniąca musi zarządzać pamięcią, co powoduje dodatkową złożoność i może ją pomylić, co prowadzi do wycieku pamięci, tj. Nie usuwa obiektu, nawet jeśli nie jest już wymagany.

W tym miejscu pojawiają się inteligentne wskaźniki. W poniższym przykładzie użyto shared_ptr - sugeruję, aby spojrzeć na różne typy inteligentnych wskaźników, aby dowiedzieć się, czego faktycznie chcesz użyć.

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

Teraz shared_ptr policzy liczbę referencji do str. Na przykład

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

Teraz są dwa odniesienia do tego samego ciągu. Gdy nie będzie już żadnych odniesień do str, zostanie on usunięty. W związku z tym nie musisz się już martwić samodzielnym usunięciem.

Szybka edycja: jak zauważyły ​​niektóre komentarze, ten przykład nie jest idealny z (przynajmniej!) Dwóch powodów. Po pierwsze, ze względu na implementację ciągów, kopiowanie ciągu jest zwykle niedrogie. Po drugie, ze względu na tak zwaną optymalizację nazw zwracanych, zwracanie według wartości może nie być drogie, ponieważ kompilator potrafi sprytnie przyspieszyć.

Wypróbujmy inny przykład, korzystając z naszej klasy File.

Powiedzmy, że chcemy użyć pliku jako dziennika. Oznacza to, że chcemy otworzyć nasz plik w trybie tylko dołączania:

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

Teraz ustawmy nasz plik jako dziennik dla kilku innych obiektów:

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

Niestety, ten przykład kończy się okropnie - plik zostanie zamknięty, gdy tylko zakończy się ta metoda, co oznacza, że ​​foo i bar mają teraz nieprawidłowy plik dziennika. Możemy zbudować plik na stercie i przekazać wskaźnik do pliku zarówno do foo, jak i do paska:

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

Ale kto jest odpowiedzialny za usunięcie pliku? Jeśli żaden plik nie zostanie usunięty, mamy przeciek pamięci i zasobów. Nie wiemy, czy plik foo czy bar skończy się najpierw na pliku, więc nie możemy oczekiwać, że sam usunie plik. Na przykład, jeśli foo usunie plik, zanim pasek się z nim skończy, pasek ma teraz nieprawidłowy wskaźnik.

Jak zapewne zgadliście, moglibyśmy użyć inteligentnych wskaźników, aby nam pomóc.

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

Teraz nikt nie musi się martwić usunięciem pliku - gdy zarówno foo, jak i pasek zakończą się i nie będą już miały żadnych odniesień do pliku (prawdopodobnie z powodu zniszczenia foo i paska), plik zostanie automatycznie usunięty.


7
Należy zauważyć, że wiele implementacji ciągów znaków jest zaimplementowanych w kategoriach wskaźnika liczonego w referencjach. Ta semantyka kopiowania przy zapisie sprawia, że ​​zwracanie ciągu pod względem wartości jest naprawdę tanie.

7
Nawet w przypadku tych, które nie są, wiele kompilatorów implementuje optymalizację NRV, która zajmowałaby się kosztami ogólnymi. Ogólnie uważam, że shared_ptr jest rzadko przydatny - po prostu trzymaj się RAII i unikaj współwłasności.
Nemanja Trifunovic

27
zwracanie ciągu nie jest dobrym powodem do używania inteligentnych wskaźników. optymalizacja wartości zwracanej może łatwo zoptymalizować zwrot, a semantyka przenoszenia c ++ 1x całkowicie wyeliminuje kopiowanie (przy prawidłowym użyciu). Zamiast tego pokaż przykład z prawdziwego świata (na przykład, gdy dzielimy ten sam zasób) :)
Johannes Schaub - litb

1
Wydaje mi się, że twoich wniosków na wczesnym etapie, dlaczego Java nie może tego zrobić, brakuje jasności. Najłatwiejszym sposobem opisania tego ograniczenia w Javie lub C # jest brak możliwości alokacji na stosie. C # pozwala na alokację stosu za pomocą specjalnego słowa kluczowego, jednak stracisz bezpieczeństwo.
ApplePieIsGood,

4
@Nemanja Trifunovic: Przez RAII w tym kontekście masz na myśli zwracanie kopii / tworzenie obiektów na stosie? To nie działa, jeśli masz zwracane / przyjmowane obiekty typów, które można podklasować. Następnie musisz użyć wskaźnika, aby uniknąć krojenia obiektu, a ja twierdzę, że inteligentny wskaźnik jest często lepszy niż surowy w takich przypadkach.
Frank Osterfeld,

141

RAII To dziwna nazwa prostej, ale niesamowitej koncepcji. Lepsza jest nazwa Scope Bound Resource Management (SBRM). Chodzi o to, że często zdarza się, że alokujesz zasoby na początku bloku i musisz go zwolnić przy wyjściu z bloku. Wyjście z bloku może nastąpić przez normalną kontrolę przepływu, wyskakując z niego, a nawet w drodze wyjątku. Aby uwzględnić wszystkie te przypadki, kod staje się bardziej skomplikowany i zbędny.

Tylko przykład robienia tego bez SBRM:

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

Jak widzisz, istnieje wiele sposobów, w jakie możemy zostać przekonani. Chodzi o to, że enkapsulujemy zarządzanie zasobami w klasę. Inicjalizacja obiektu pozyskuje zasób („Pozyskiwanie zasobów to inicjalizacja”). Kiedy wychodzimy z bloku (zakres bloku), zasób jest ponownie zwalniany.

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

To dobrze, jeśli masz własne klasy, które nie służą wyłącznie do alokacji / dezalokacji zasobów. Alokacja byłaby tylko dodatkową troską w wykonaniu ich pracy. Ale gdy tylko chcesz przydzielić / cofnąć przydział zasobów, powyższe staje się nieprzydatne. Musisz napisać klasę zawijania dla każdego rodzaju zasobów, które zdobędziesz. Aby to ułatwić, inteligentne wskaźniki pozwalają zautomatyzować ten proces:

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

Zwykle inteligentne wskaźniki są cienkimi opakowaniami wokół nowego / usuwania, które po prostu wywołują, deletegdy zasób, którego są właścicielami, wykracza poza zakres. Niektóre inteligentne wskaźniki, takie jak shared_ptr, pozwalają im powiedzieć tak zwany deleter, który jest używany zamiast delete. Pozwala to na przykład zarządzać uchwytami okien, zasobami wyrażeń regularnych i innymi dowolnymi rzeczami, pod warunkiem, że powiesz shared_ptr o właściwym usuwaczu.

Istnieją różne inteligentne wskaźniki do różnych celów:

Unique_ptr

jest inteligentnym wskaźnikiem, który jest właścicielem wyłącznie obiektu. Nie działa, ale prawdopodobnie pojawi się w następnym standardzie C ++. Nie można go kopiować, ale obsługuje przeniesienie własności . Przykładowy kod (następny 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);

W przeciwieństwie do auto_ptr, unikalny_ptr można umieścić w kontenerze, ponieważ kontenery będą mogły przechowywać typy niemożliwe do kopiowania (ale ruchome), takie jak strumienie i unikalne_ptr.

scoped_ptr

to inteligentny wskaźnik doładowania, którego nie można kopiować ani przenosić. Jest to idealna rzecz do użycia, gdy chcesz mieć pewność, że wskaźniki zostaną usunięte, gdy wyjdziesz poza zakres.

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

jest dla współwłasności. Dlatego jest on zarówno do kopiowania, jak i do przenoszenia. Wiele instancji inteligentnego wskaźnika może posiadać ten sam zasób. Gdy tylko ostatni inteligentny wskaźnik będący właścicielem zasobu znajdzie się poza zasięgiem, zasób zostanie zwolniony. Oto przykład jednego z moich projektów w świecie rzeczywistym:

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. 

Jak widać, źródło wydruku (funkcja fx) jest współdzielone, ale każdy z nich ma osobny wpis, w którym ustawiamy kolor. Istnieje klasa słaba_ptr, która jest używana, gdy kod musi odwoływać się do zasobu będącego własnością inteligentnego wskaźnika, ale nie musi być właścicielem zasobu. Zamiast przekazywać nieprzetworzony wskaźnik, powinieneś utworzyć słaby_ptr. Zgłasza wyjątek, gdy zauważy, że próbujesz uzyskać dostęp do zasobu za pomocą ścieżki dostępu słaby_ptr, nawet jeśli nie ma już zasobu współużytkowanego_ptr.


O ile wiem, obiektów, których nie można kopiować, nie są w ogóle przydatne w kontenerach STL, ponieważ opierają się na semantyce wartości - co się stanie, jeśli chcesz posortować ten kontener? sort czy kopiuje elementy ...
fmuecke

Kontenery C ++ 0x zostaną zmienione, tak aby były zgodne z typami tylko do przenoszenia unique_ptr, i sortzostaną również zmienione.
Johannes Schaub - litb

Czy pamiętasz, gdzie po raz pierwszy usłyszałeś termin SBRM? James próbuje to wyśledzić.
GManNickG

jakie nagłówki lub biblioteki powinienem dołączyć, aby ich użyć? jakieś dalsze odczyty na ten temat?
atoMerz

Jedna rada tutaj: jeśli istnieje odpowiedź na pytanie C ++ autorstwa @litb, jest to właściwa odpowiedź (bez względu na głosy lub odpowiedź oznaczona jako „poprawna”) ...
fnl

32

Założenie i powody są proste, w koncepcji.

RAII jest paradygmatem projektowym zapewniającym, że zmienne obsługują wszystkie potrzebne inicjalizacje w swoich konstruktorach i wszystkie potrzebne porządki w swoich destrukterach. Zmniejsza to całą inicjalizację i czyszczenie do jednego kroku.

C ++ nie wymaga RAII, ale coraz częściej przyjmuje się, że użycie metod RAII da bardziej niezawodny kod.

Powodem, dla którego RAII jest użyteczne w C ++ jest to, że C ++ wewnętrznie zarządza tworzeniem i niszczeniem zmiennych, gdy wchodzą one i wychodzą z zasięgu, czy to poprzez normalny przepływ kodu, czy poprzez odwijanie stosu wywołane przez wyjątek. To darmowy program w C ++.

Wiążąc całą inicjalizację i czyszczenie z tymi mechanizmami, masz pewność, że C ++ zajmie się tą pracą również dla Ciebie.

Mówienie o RAII w C ++ zwykle prowadzi do dyskusji na temat inteligentnych wskaźników, ponieważ wskaźniki są szczególnie delikatne, jeśli chodzi o czyszczenie. Podczas zarządzania pamięcią alokowaną na stercie pozyskanej z malloc lub nowej, programista zwykle ma obowiązek zwolnić lub usunąć tę pamięć przed zniszczeniem wskaźnika. Inteligentne wskaźniki wykorzystają filozofię RAII, aby zapewnić zniszczenie obiektów przydzielonych na stosie za każdym razem, gdy niszczona jest zmienna wskaźnika.


Ponadto - wskaźniki są najczęstszą aplikacją RAII - prawdopodobnie przydzielisz tysiące razy więcej wskaźników niż jakikolwiek inny zasób.
Zaćmienie

8

Inteligentny wskaźnik jest odmianą RAII. RAII oznacza, że ​​pozyskiwanie zasobów jest inicjalizacją. Inteligentny wskaźnik pobiera zasób (pamięć) przed użyciem, a następnie wyrzuca go automatycznie w destruktorze. Stają się dwie rzeczy:

  1. Przydzielamy pamięć zanim z niej skorzystamy, zawsze, nawet jeśli nie mamy na to ochoty - trudno jest zrobić inaczej za pomocą inteligentnego wskaźnika. Jeśli tak się nie stanie, spróbujesz uzyskać dostęp do pamięci NULL, co spowoduje awarię (bardzo bolesne).
  2. Zwalniamy pamięć nawet w przypadku błędu. Brak pamięci wisi.

Na przykład innym przykładem jest gniazdo sieciowe RAII. W tym przypadku:

  1. Zawsze otwieramy gniazdo sieciowe, zanim z niego skorzystamy, nawet jeśli nie mamy na to ochoty - trudno jest zrobić to inaczej z RAII. Jeśli spróbujesz to zrobić bez RAII, możesz otworzyć puste gniazdo dla, powiedzmy połączenia MSN. Wtedy wiadomość typu „zróbmy to dziś wieczorem” może nie zostać przeniesiona, użytkownicy nie zostaną zniesieni i możesz ryzykować zwolnieniem.
  2. Zamykamy gniazdo sieciowe, nawet jeśli wystąpi błąd. Żadne gniazdo nie jest zawieszone, ponieważ może to uniemożliwić odesłanie komunikatu odpowiedzi „na pewno jestem na dole”.

Teraz, jak widać, RAII jest bardzo przydatnym narzędziem w większości przypadków, ponieważ pomaga ludziom się położyć.

Źródła C ++ inteligentnych wskaźników są w milionach w sieci, w tym odpowiedzi nade mną.


2

Boost ma wiele z nich, w tym te w Boost.Interprocess dla pamięci współużytkowanej. Znacznie upraszcza zarządzanie pamięcią, szczególnie w sytuacjach wywołujących ból głowy, takich jak 5 procesów współużytkujących tę samą strukturę danych: gdy wszyscy mają dość pamięci, chcesz, aby automatycznie się uwolniła i nie musiała tam siedzieć, próbując rozgryźć kto powinien być odpowiedzialny za wywołanie deletefragmentu pamięci, aby nie skończył się wyciekiem pamięci lub wskaźnikiem, który zostanie dwukrotnie uwolniony i może uszkodzić całą stertę.


0
void foo ()
{
   std :: string bar;
   //
   // więcej kodu tutaj
   //
}

Bez względu na to, co się stanie, pasek zostanie poprawnie usunięty po pozostawieniu zakresu funkcji foo ().

Wewnętrzne implementacje std :: string często używają wskaźników liczonych w referencjach. Zatem wewnętrzny ciąg musi zostać skopiowany tylko wtedy, gdy zmieni się jedna z kopii ciągów. Dlatego inteligentny wskaźnik zliczony z odniesieniem umożliwia kopiowanie tylko w razie potrzeby.

Ponadto wewnętrzne zliczanie referencji umożliwia prawidłowe usunięcie pamięci, gdy kopia wewnętrznego łańcucha nie jest już potrzebna.


1
void f () {Obj x; } Obj x zostanie usunięty poprzez utworzenie / zniszczenie ramki stosu (odwijanie) ... nie ma to związku z liczeniem referencji.
Hernán

Liczenie referencji jest cechą wewnętrznej implementacji ciągu. RAII to koncepcja usuwania obiektu, gdy obiekt wykracza poza zakres. Pytanie dotyczyło RAII, a także inteligentnych wskaźników.

1
„Bez względu na to, co się stanie” - co się stanie, jeśli wyjątek zostanie zgłoszony przed powrotem funkcji?
titaniumdecoy

Która funkcja jest zwracana? Jeśli w foo zostanie zgłoszony wyjątek, wówczas pasek zostanie usunięty. Domyślny konstruktor paska zgłaszający wyjątek byłby niezwykłym wydarzeniem.
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.