RAII та розумні покажчики в C ++


Відповіді:


317

Простим (і, можливо, надмірним) прикладом RAII є клас File. Без RAII код може виглядати приблизно так:

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

Іншими словами, ми повинні переконатися, що ми закриємо файл, як тільки закінчимо його. У цього є два недоліки - по-перше, де б ми не використовували файл, нам доведеться викликати файл :: close () - якщо ми забудемо це зробити, ми тримаємось за файл довше, ніж нам потрібно. Друга проблема полягає в тому, що якщо викинути виняток, перш ніж закрити файл?

Java вирішує другу проблему за допомогою остаточного пункту:

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

або з Java 7, випробування з ресурсом:

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

C ++ вирішує обидві проблеми за допомогою RAII - тобто закриває файл у деструкторі File. Поки об’єкт File буде знищений у потрібний час (що це все одно має бути), закриття файлу береться за нас. Отже, наш код зараз виглядає приблизно так:

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

Це не може бути зроблено в Java, оскільки немає гарантії, коли об’єкт буде знищений, тому ми не можемо гарантувати, коли такий ресурс, як файл, буде звільнений.

На розумні покажчики - багато часу ми просто створюємо об’єкти на стеку. Наприклад (і вкрасти приклад з іншої відповіді):

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

Це прекрасно працює - але що робити, якщо ми хочемо повернути str? Ми могли б написати це:

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

Отже, що з цим погано? Ну, тип повернення - std :: string - значить, ми повертаємося за значенням. Це означає, що ми копіюємо str та фактично повертаємо копію. Це може бути дорого, і ми можемо захотіти уникнути витрат на його копіювання. Тому ми можемо придумати ідею повернення за посиланням або за вказівником.

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

На жаль, цей код не працює. Ми повертаємо вказівник на str - але str був створений на стеку, тому ми будемо видалені, як тільки вийдемо з foo (). Іншими словами, до моменту, коли абонент отримує вказівник, він марний (і, мабуть, гірше, ніж марний, оскільки його використання може спричинити всілякі фанкі помилки)

Отже, яке рішення? Ми могли б створити str на купі, використовуючи new - таким чином, коли foo () завершено, str не буде знищено.

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

Звичайно, і це рішення не є ідеальним. Причина в тому, що ми створили str, але ми її ніколи не видаляємо. Це може не бути проблемою для дуже малої програми, але загалом ми хочемо переконатися, що ми її видалимо. Ми можемо просто сказати, що абонент повинен видалити об'єкт, як тільки він закінчить його. Мінус полягає в тому, що абонент повинен керувати пам'яттю, що додає додаткових складностей, і може помилитися, що призведе до витоку пам'яті, тобто не видалення об'єкта, хоча це більше не потрібно.

Тут приходять смарт-покажчики. У наступному прикладі використовується shared_ptr - я пропоную вам переглянути різні типи розумних покажчиків, щоб дізнатися, що ви насправді хочете використовувати.

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

Тепер, shared_ptr буде рахувати кількість посилань на str. Наприклад

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

Зараз є два посилання на один і той же рядок. Як тільки немає інших посилань на str, вона буде видалена. Таким чином, вам більше не доведеться турбуватися про те, щоб видалити його самостійно.

Швидке редагування: як зазначали деякі коментарі, цей приклад не є ідеальним з (принаймні!) Двох причин. По-перше, через реалізацію рядків копіювання рядка, як правило, недороге. По-друге, завдяки тому, що називається оптимізацією зворотного значення, повернення за значенням може не бути дорогим, оскільки компілятор може зробити певну розумність, щоб прискорити роботу.

Отже, спробуємо інший приклад, використовуючи наш клас Файл.

Скажімо, ми хочемо використовувати файл як журнал. Це означає, що ми хочемо відкрити наш файл у режимі додавання лише:

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

Тепер давайте встановимо наш файл як журнал для пари інших об'єктів:

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

На жаль, цей приклад жахливо закінчується - файл буде закритий, як тільки цей метод закінчиться, це означає, що foo та bar тепер мають недійсний файл журналу. Ми можемо сконструювати файл на купі та передати вказівник на файл і foo, і бар:

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

Але тоді хто несе відповідальність за видалення файлу? Якщо жодного файлу не видалити, у нас є витік пам'яті та ресурсів. Ми не знаємо, чи спочатку foo чи bar завершать роботу з файлом, тому ми не можемо сподіватися на те, щоб видалити файл самі. Наприклад, якщо foo видаляє файл до того, як бар закінчився, бар тепер має недійсний вказівник.

Отже, як ви вже здогадалися, ми могли б використовувати розумні покажчики, щоб допомогти нам.

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

Тепер нікому не потрібно турбуватися про видалення файлу - як тільки foo, так і смуга закінчуються і більше не мають посилань на файл (можливо, через foo та bar знищуються), файл автоматично буде видалений.


7
Слід зазначити, що багато рядкових реалізацій реалізовано з точки зору відліку, що підраховується. Ці семантики копіювання при записі роблять повернення рядка за значенням дійсно недорогим.

7
Навіть для тих, кого немає, багато компіляторів впроваджують оптимізацію NRV, яка б подбала про накладні витрати. Взагалі, мені здається, що спільний_ptr рідко корисний - просто дотримуйтесь RAII і уникайте спільного володіння.
Неманья Трифунович

27
повернення рядка не є вагомою причиною для використання смарт-покажчиків. оптимізація повернутого значення може легко оптимізувати повернення, а семантика переміщення c ++ 1x повністю усуне копію (при правильному використанні). Покажіть натомість приклад із реального світу (наприклад, коли ми ділимось одним і тим же ресурсом) :)
Йоханнес Шауб - ліб

1
Я думаю, що ваш висновок про те, чому Java не може цього зробити, не має чіткості. Найпростіший спосіб описати це обмеження на Java або C # - це те, що немає можливості виділити їх у стеку. C # дозволяє розподіляти стеки за допомогою спеціального ключового слова, проте ви втрачаєте тип saftey.
ApplePieIsGood

4
@Nemanja Trifunovic: Під RAII в цьому контексті ви маєте на увазі повернення копій / створення об'єктів у стеці? Це не працює, якщо у вас є об'єкти повернення / прийняття типів, які можна підкласифікувати. Тоді вам потрібно використовувати вказівник, щоб уникнути нарізання об'єкта, і я заперечую, що розумний вказівник у цих випадках часто кращий за сирий.
Френк Остерфельд

141

RAII Це дивна назва простого, але дивовижного поняття. Краще - назва Управління обмеженими ресурсами (SBRM). Ідея полягає в тому, що часто трапляється виділяти ресурси на початку блоку, і потрібно випускати їх на виході з блоку. Вихід з блоку може статися за допомогою нормального контролю потоку, вистрибування з нього і навіть за винятком. Щоб охопити всі ці випадки, код стає складнішим і зайвим.

Просто приклад робити це без 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);
}

Як ви бачите, існує багато способів, до яких ми можемо потрапити. Ідея полягає в тому, що ми інкапсулюємо управління ресурсами в клас. Ініціалізація його об'єкта набуває ресурс ("Придбання ресурсів - це ініціалізація"). Під час виходу з блоку (області блоку) ресурс знову звільняється.

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

Це добре, якщо у вас є власні заняття, які призначені не лише для розподілу / розподілу ресурсів. Виділення були б просто додатковою турботою про те, щоб виконати свою роботу. Але як тільки ви хочете виділити / розподілити ресурси, вищезазначене стає непристойним. Ви повинні написати клас упаковки для кожного типу ресурсів, які ви придбаєте. Щоб полегшити це, смарт-покажчики дозволяють автоматизувати цей процес:

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

Зазвичай смарт-покажчики - це тонкі обгортки навколо нового / видалення, які просто трапляються, deleteколи дзвінок виходить за межі сфери дії. Деякі розумні покажчики, як-от shared_ptr, дозволяють вам сказати їм так званий делетер, який використовується замість delete. Це дозволяє, наприклад, керувати ручками вікон, регулярними ресурсами вираження та іншими довільними речами, якщо ви повідомляєте shared_ptr про потрібний делетер.

Існують різні розумні покажчики для різних цілей:

unique_ptr

є розумним покажчиком, який володіє виключно об'єктом. Це не поштовх, але, швидше за все, з'явиться у наступному C ++ Standard. Це не можна скопіювати, але підтримує передачу права власності . Деякі приклади коду (наступний C ++):

Код:

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, унікальний_ptr можна помістити в контейнер, оскільки контейнери зможуть вміщувати не копіювані (але рухомі) типи, як потоки, так і унікальні_ptr.

scoped_ptr

являє собою інтелектуальний покажчик, що не підлягає копіюванню та переміщенню. Це ідеальна річ, яку потрібно використовувати, коли ви хочете переконатися, що покажчики видаляються, коли виходите за межі області.

Код:

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

призначено для спільної власності. З цього приводу можна скопіювати та перемістити. Кілька екземплярів розумних покажчиків можуть мати один і той же ресурс. Як тільки останній розумний вказівник, що володіє ресурсом, вийде з рамки, ресурс буде звільнений. Деякі приклади реального світу одного з моїх проектів:

Код:

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. 

Як бачите, сюжет-джерело (функція fx) є спільним, але кожен має окремий запис, на якому ми встановлюємо колір. Існує клас слабких_птрів, який використовується, коли код повинен посилатися на ресурс, що належить інтелектуальному вказівнику, але йому не потрібно володіти ресурсом. Замість передачі необробленого покажчика слід створити слабкий_птр. Він викине виняток, коли він помітить, що ви намагаєтеся отримати доступ до ресурсу через слабкий шлях доступу, хоча навіть у ресурсі shared_ptr більше немає власника.


Наскільки я знаю об'єкти, які не можна скопіювати, взагалі недобре використовувати в stl контейнерах, оскільки вони покладаються на значення семантики - що станеться, якщо ви хочете сортувати цей контейнер? сортувати копіює елементи ...
fmuecke

Контейнери C ++ 0x будуть змінені таким чином, щоб вони відповідали типам unique_ptr, що рухаються лише як , і теж sortбудуть змінені.
Йоханнес Шауб - ліб

Ви пам’ятаєте, де ви вперше почули термін SBRM? Джеймс намагається відстежити це.
GManNickG

які заголовки чи бібліотеки я повинен включати для їх використання? будь-які подальші читання з цього приводу?
atoMerz

Одна порада тут: якщо є відповідь на питання C ++ від @litb, це правильна відповідь (незалежно від того, голоси чи відповіді позначені як "правильні") ...
fnl

32

Передумова і причини поняття прості.

RAII - це парадигма проектування, щоб забезпечити, щоб змінні обробляли всю необхідну ініціалізацію в своїх конструкторах і всю необхідну очистку в своїх деструкторах. Це зменшує всю ініціалізацію та очищення до одного кроку.

C ++ не вимагає RAII, але все частіше приймається, що використання методів RAII створює більш надійний код.

Причиною того, що RAII є корисним у C ++, є те, що C ++ по суті керує створенням та знищенням змінних під час їх входу та виходу із сфери застосування, чи то через нормальний потік коду, чи через розмотування стека, викликане винятком. Це халява на C ++.

Прив’язуючи всі механізми ініціалізації та очищення до цих механізмів, ви гарантуєте, що C ++ подбає про цю роботу і для вас.

Якщо говорити про RAII в C ++, зазвичай це призводить до обговорення розумних покажчиків, оскільки вказівники особливо крихкі, коли мова йде про очищення. Під час керування пам'яттю, виділеною з купи, придбаної з malloc або new, програміст, як правило, несе обов'язок звільнити або видалити цю пам'ять перед знищенням покажчика. Інтелектуальні покажчики використовують філософію RAII для того, щоб забезпечити знищення купи виділених об'єктів будь-коли, коли змінна вказівник знищена.


Крім того - покажчики є найпоширенішим додатком RAII - ви, ймовірно, виділите в тисячі разів більше покажчиків, ніж будь-який інший ресурс.
Затемнення

8

Смарт-покажчик - це варіація RAII. RAII означає придбання ресурсів - ініціалізацію. Розумний вказівник отримує ресурс (пам'ять) перед використанням, а потім автоматично викидає його в деструктор. Дві речі трапляються:

  1. Ми виділяємо пам’ять, перш ніж використовувати її завжди, навіть коли нам не подобається - важко зробити інший шлях із розумним вказівником. Якщо цього не сталося, ви спробуєте отримати доступ до пам'яті NULL, що призведе до аварії (дуже болісно).
  2. Ми звільняємо пам'ять, навіть коли є помилка. Не залишається жодної пам'яті.

Наприклад, ще один приклад - мережевий сокет RAII. В цьому випадку:

  1. Ми відкриваємо мережевий сокет перед тим, як користуватися ним завжди, навіть коли нам не здається, що це важко зробити іншим способом з RAII. Якщо ви спробуєте зробити це без RAII, ви можете відкрити порожній сокет для, скажімо, MSN-з'єднання. Тоді повідомлення типу "дозволяє це зробити сьогодні" може не перенести, користувачі не будуть звільнені, і ви можете ризикувати звільнення.
  2. Ми закриваємо мережеву розетку навіть тоді, коли є помилка. Жодна розетка не залишається підвішеною, оскільки це може запобігти удару відправника на повідомлення "напевно, що внизу".

Тепер, як бачите, RAII є дуже корисним інструментом у більшості випадків, оскільки він допомагає людям розкутитися.

Джерела C ++ розумних покажчиків є мільйонами по всій мережі, включаючи відповіді над мною.


2

У Boost є ряд таких, в тому числі в Boost.Interprocess для спільної пам'яті. Це значно спрощує управління пам’яттю, особливо в ситуаціях, що викликають головний біль, як, наприклад, у вас 5 процесів, які діляться однаковою структурою даних: коли все зроблено з шматком пам’яті, ви хочете, щоб він звільнився автоматично і не потрібно сидіти там, намагаючись розібратися. хто повинен відповідати за виклик deleteшматка пам'яті, щоб у вас не виникло витоку пам’яті або вказівника, який помилково звільняється двічі і може зіпсувати всю купу.


0
пустоту ()
{
   std :: рядок рядка;
   //
   // Більше коду тут
   //
}

Незалежно від того, що станеться, панель буде належним чином видалена, як тільки сфера функції foo () залишиться позаду.

Внутрішньо std :: string-реалізації часто використовують вказівники, що підраховуються. Тому внутрішній рядок потрібно скопіювати лише тоді, коли змінилася одна з копій рядків. Тому посилання, що зараховується до інтелектуального вказівника, дозволяє лише скопіювати щось, коли це необхідно.

Крім того, внутрішній підрахунок посилань дає змогу належним чином видалити пам'ять, коли копія внутрішнього рядка більше не потрібна.


1
void f () {Obj x; } Obj x видаляється за допомогою створення / знищення кадру стека (розмотування) ... це не пов'язано з підрахунком посилань.
Ернан

Підрахунок посилань є особливістю внутрішньої реалізації рядка. RAII - це концепція, що стоїть за видаленням об'єкта, коли об'єкт виходить за межі області. Питання стосувалося RAII, а також розумних покажчиків.

1
"Незалежно від того, що станеться" - що станеться, якщо виключення буде кинуто до повернення функції?
titaniumdecoy

Яка функція повертається? Якщо виняток кидається в foo, бар видаляється. За замовчуванням конструктор викидання бару був би надзвичайною подією.
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.