Дозвольте спробувати констатувати різні життєздатні режими передачі покажчиків на об’єкти, пам’яттю яких керує екземпляр std::unique_ptrшаблону класу; він також застосовується до std::auto_ptrшаблону старшого класу (який, на мій погляд, дозволяє використовувати всі ті унікальні покажчики, але для яких додатково можуть змінюватися значення, де очікуються rvalues, не потребуючи посилань std::move), а також до певної міри також і до std::shared_ptr.
Як конкретний приклад для обговорення я розгляну наступний простий тип списку
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
Примірники такого списку (яким не можна дозволити ділитися частинами з іншими екземплярами або бути круговими) повністю належать тому, хто має початковий listпокажчик. Якщо код клієнта знає, що список, який він зберігає, ніколи не буде порожнім, він також може вибрати безпосередньо для зберігання першого, nodeа не list. Жодного деструктора не nodeпотрібно визначати: оскільки деструктори для його полів викликаються автоматично, весь список буде рекурсивно видалено деструктором інтелектуального вказівника, коли закінчується термін дії початкового вказівника чи вузла.
Цей рекурсивний тип дає можливість обговорити деякі випадки, які менш помітні у випадку розумного вказівника на звичайні дані. Також самі функції час від часу (рекурсивно) також надають приклад клієнтського коду. Звичайно, typedef для listмає тенденцію до зміни unique_ptr, але це визначення може бути змінено на використання auto_ptrабо shared_ptrзамість цього без особливих змін до того, що сказано нижче (особливо щодо забезпечення безпеки виключень без необхідності писати деструктори).
Режими передачі розумних покажчиків навколо
Режим 0: передайте покажчик або опорний аргумент замість розумного вказівника
Якщо ваша функція не стосується права власності, це кращий метод: не змушуйте її взагалі брати розумний вказівник. У цьому випадку вашій функції не потрібно хвилюватися, кому належить вказаний об’єкт, або якими засобами керується право власності, тому передача необробленого вказівника є і цілком безпечною, і найбільш гнучкою формою, оскільки незалежно від права власності клієнт може завжди створити необроблений покажчик (або зателефонувавши за getметодом, або з адреси оператора &).
Наприклад, функція обчислення довжини такого списку повинна бути не listаргументом, а необробленим покажчиком:
size_t length(const node* p)
{ size_t l=0; for ( ; p!=nullptr; p=p->next.get()) ++l; return l; }
Клієнт, який містить змінну, list headможе викликати цю функцію як length(head.get()), тоді як клієнт, який обрав замість цього зберігати node nпредставлений не порожній список, може зателефонувати length(&n).
Якщо вказівник гарантовано не є нульовим (що тут не так, оскільки списки можуть бути порожніми), можна скористатись передавачем, а не вказівником. Це може бути вказівник / посилання на non- constякщо функція потребує оновлення вмісту вузлів (-ів), не додаючи або видаляючи жодного з них (останній передбачає право власності).
Цікавий випадок, який потрапляє в категорію режиму 0, - це (глибока) копія списку; хоча ця функція повинна, звичайно, передати право власності на створену ним копію, вона не стосується права власності на список, який він копіює. Отже, це можна визначити так:
list copy(const node* p)
{ return list( p==nullptr ? nullptr : new node{p->entry,copy(p->next.get())} ); }
Цей код заслуговує уважного огляду, як на питання про те, чому він взагалі компілюється (результат рекурсивного виклику copyв списку ініціалізатора пов'язується з контрольним аргументом rvalue в конструкторі переміщення unique_ptr<node>, aka list, при ініціалізації nextполя згенеровано node), а також на питання, чому це безпечно для винятків (якщо під час процесу рекурсивного розподілу вичерпується пам'ять та якийсь виклик newкидків std::bad_alloc, тоді в цей час вказівник на частково сконструйований список утримується анонімно тимчасовим типом listстворений для списку ініціалізаторів, і його деструктор очистить цей частковий список). До речі, слід протистояти спокусі замінити (як я спочатку робив) другоюnullptr поp, зрештою, як відомо, в цей момент є нульовим: не можна побудувати розумний вказівник від (сировинного) покажчика до константи , навіть коли це, як відомо, є нульовим.
Режим 1: передайте інтелектуальний покажчик за значенням
Функція, яка приймає значення інтелектуального вказівника як аргумент, заволодіває об'єктом, вказаним відразу: інтелектуальний покажчик, який утримує абонент (чи в іменованій змінній, або в анонімній тимчасовій), скопіюється у значення аргументу на вході функції та в телефоні вказівник став нульовим (у випадку тимчасової копії, можливо, було б упущено, але в будь-якому випадку абонент втратив доступ до вказаного об’єкта). Я хотів би зателефонувати цьому режиму дзвінка готівкою : абонент оплачує передню послугу, яку викликає, і не може мати ілюзій щодо власності після дзвінка. Щоб це було зрозуміло, мовні правила вимагають від абонента завершити аргументstd::moveякщо розумний покажчик утримується в змінній (технічно, якщо аргумент є значенням); у цьому випадку (але не для режиму 3 нижче) ця функція робить те, що пропонує її назва, а саме переміщує значення зі змінної на тимчасову, залишаючи змінну нульовою.
У випадках, коли викликана функція безумовно приймає право власності на (піліфери) на об'єкт із загостреним об'єктом, цей режим використовується з std::unique_ptrабо std::auto_ptrє хорошим способом передачі покажчика разом із його власністю, що дозволяє уникнути будь-якого ризику витоку пам'яті. Тим не менш, я думаю, що є лише дуже мало ситуацій, коли режим 3 нижче не слід віддавати перевагу (ніколи не дуже) над режимом 1. З цієї причини я не наводжу прикладів використання цього режиму. (Але дивіться reversedприклад режиму 3 нижче, де зазначається, що режим 1 буде робити як мінімум також.) Якщо функція бере більше аргументів, ніж лише цей покажчик, може статися, що є додатково технічна причина уникнути режиму 1 (з std::unique_ptrабо std::auto_ptr): оскільки фактична операція переміщення відбувається під час передачі змінної вказівникаpза виразом std::move(p)не можна припустити, що він pмістить корисне значення під час оцінки інших аргументів (порядок оцінки не визначений), що може призвести до тонких помилок; навпаки, використання режиму 3 гарантує, що pдо виклику функції не відбувається переміщення , тому інші аргументи можуть безпечно отримати доступ до значення через p.
У режимі використання std::shared_ptrцей режим цікавий тим, що за допомогою єдиного визначення функції він дозволяє абоненту вибирати, чи зберігати спільну копію вказівника для себе, створюючи нову копію спільного використання, яку використовуватиме функція (це відбувається, коли значення lvalue надається аргумент; конструктор копіювання для загальних покажчиків, використовуваних під час виклику, збільшує кількість посилань) або просто надає функції копію вказівника, не зберігаючи жодного і не торкаючись відліку посилань (це відбувається, коли надається аргумент rvalue, можливо lvalue, загорнутий у дзвінок std::move). Наприклад
void f(std::shared_ptr<X> x) // call by shared cash
{ container.insert(std::move(x)); } // store shared pointer in container
void client()
{ std::shared_ptr<X> p = std::make_shared<X>(args);
f(p); // lvalue argument; store pointer in container but keep a copy
f(std::make_shared<X>(args)); // prvalue argument; fresh pointer is just stored away
f(std::move(p)); // xvalue argument; p is transferred to container and left null
}
Те ж можна досягти, окремо визначивши void f(const std::shared_ptr<X>& x)(для випадку lvalue) та void f(std::shared_ptr<X>&& x)(для випадку rvalue), при цьому функції функцій відрізняються лише тим, що перша версія викликає семантику копії (використовуючи побудову / призначення копії при використанні x), а друга версія переміщує семантику (написання std::move(x)замість цього, як у прикладі коду). Отже, для загальних покажчиків режим 1 може бути корисним, щоб уникнути деякого дублювання коду.
Режим 2: передайте інтелектуальний покажчик за (модифікованою) посиланням на значення
Тут функція просто вимагає модифікованого посилання на інтелектуальний вказівник, але не дає ніяких вказівок, що з нею буде робити. Я хотів би викликати цей метод дзвінка карткою : абонент забезпечує оплату, вказавши номер кредитної картки. Довідку можна використовувати для отримання права власності на об'єкт із загостреним об'єктом, але це не обов'язково. Цей режим вимагає надання змінного аргументу lvalue, відповідного тому, що бажаний ефект функції може включати залишення корисного значення в змінній аргументу. Абонент із виразом rvalue, який він бажає передати такій функції, змушений буде зберігати його у названій змінній, щоб мати змогу здійснювати виклик, оскільки мова забезпечує лише неявне перетворення у константупосилання lvalue (посилається на тимчасовий) з rvalue. ( В відміну від ситуації , протилежній від перекачується std::move, гіпсі від Y&&до Y&, з Yсмарт - вказівного типу, що не представляється можливим, проте , це перетворення може бути отриманий з допомогою функції шаблону просто , якщо дійсно потрібної, см https://stackoverflow.com/a/24868376 / 1436796 ). У випадку, коли викликана функція має намір безумовно взяти право власності на об'єкт, викрадаючи аргумент, обов'язок надати аргумент значення має подання неправильного сигналу: змінна не матиме корисного значення після виклику. Тому режим 3, який дає однакові можливості всередині нашої функції, але просить абонентів надати рецензію, слід віддати перевагу такому використанню.
Однак існує дійсний випадок використання для режиму 2, а саме функції, які можуть змінювати покажчик або об’єкт, на який вказується у спосіб, що передбачає право власності . Наприклад, функція, яка префіксує вузол до а, listнадає приклад такого використання:
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
Зрозуміло, що тут було б небажано змушувати абонентів використовувати std::move, оскільки їх розумний вказівник все ще має чітко визначений і не порожній список після виклику, хоча і інший, ніж раніше.
Знову ж таки цікаво спостерігати, що відбувається, якщо prependвиклик не працює через відсутність вільної пам'яті. Тоді newдзвінок кине std::bad_alloc; на даний момент часу, оскільки жоден не nodeміг бути виділений, напевно, що передана посилання на оцінку (режим 3) з цього моменту ще не може бути розграблена std::move(l), оскільки це було б зроблено для побудови nextполя того, nodeщо не вдалося виділити. Тож оригінальний смарт-покажчик lвсе ще зберігає оригінальний список, коли помилка видалена; цей список буде або належним чином знищений деструктором смарт-покажчика, або у випадку, lякщо він виживе завдяки достатньо ранньому catchзастереженню, він все одно буде містити початковий список.
Це був конструктивний приклад; з намигненням до цього питання можна також навести деструктивніший приклад видалення першого вузла, що містить задане значення, якщо таке є:
void remove_first(int x, list& l)
{ list* p = &l;
while ((*p).get()!=nullptr and (*p)->entry!=x)
p = &(*p)->next;
if ((*p).get()!=nullptr)
(*p).reset((*p)->next.release()); // or equivalent: *p = std::move((*p)->next);
}
Знову ж коректність тут досить тонка. Зокрема, у заключному висловленні вказівник, (*p)->nextщо знаходиться у видаленому вузлі, від’єднується (від release, який повертає вказівник, але робить початковий нуль), перш ніж reset (неявно) знищує цей вузол (коли він знищує старе значення, яке утримує p), гарантуючи, що в цей час знищується один і єдиний вузол. (В альтернативній формі, згаданій у коментарі, цей термін буде залишений внутрішнім органам реалізації оператора переміщення-присвоєння std::unique_ptrекземпляра list; стандарт говорить 20.7.1.2.3; 2, що цей оператор повинен діяти "як би виклик reset(u.release())", тому термін повинен бути безпечним і тут.)
Зверніть увагу , що prependі remove_firstне можуть бути викликані клієнтами , які зберігають локальну nodeзмінну для завжди непорожньої списку, і це правильно , тому що реалізація дається не може працювати в таких випадках.
Режим 3: передайте інтелектуальний вказівник по (модифікованому) посиланню rvalue
Це кращий режим, який потрібно використовувати, коли просто переймаєте вказівник. Я хотів би викликати цей метод викликом чеком : абонент повинен прийняти відмову від власності, як би надаючи готівку, підписавши чек, але фактичне вилучення відкладається до тих пір, поки викликана функція насправді не погрунтує покажчик (саме так, як це було б у режимі 2 ). "Підписання чека" конкретно означає, що абоненти повинні std::moveзавернути аргумент (як у режимі 1), якщо це значення (якщо це rvalue, частина "відмовлятися від власності" очевидна і не потребує окремого коду).
Зауважте, що технічно режим 3 поводиться точно як режим 2, тому викликана функція не повинна брати на себе право власності; Однак я б наполягати на тому, що якщо є якась - або невизначеність в відношенні передачі прав власності (в нормальних умовах експлуатації), режим 2 слід віддати перевагу режим 3, так що режим 3 , використовуючи неявно сигнал для абонентів , що вони будуть віддають власність. Можна сказати, що аргумент лише режиму 1, передаючи дійсно, сигналізує про вимушену втрату права власності на абонентів. Але якщо у клієнта є будь-які сумніви щодо намірів викликаної функції, вона повинна знати характеристики виклику функції, що повинно усунути будь-які сумніви.
Напрочуд важко знайти типовий приклад із залученням нашого listтипу, який використовує передачу аргументів у режимі 3. Переміщення списку bдо кінця іншого списку a- типовий приклад; однак a(який виживає і утримує результат операції) краще передавати режим 2:
void append (list& a, list&& b)
{ list* p=&a;
while ((*p).get()!=nullptr) // find end of list a
p=&(*p)->next;
*p = std::move(b); // attach b; the variable b relinquishes ownership here
}
Чистим прикладом передачі аргументу режиму 3 є наступний, який приймає список (та його право власності) та повертає список, що містить однакові вузли у зворотному порядку.
list reversed (list&& l) noexcept // pilfering reversal of list
{ list p(l.release()); // move list into temporary for traversal
list result(nullptr);
while (p.get()!=nullptr)
{ // permute: result --> p->next --> p --> (cycle to result)
result.swap(p->next);
result.swap(p);
}
return result;
}
Ця функція може бути викликана як l = reversed(std::move(l));повернення списку до себе, але перевернутий список також може використовуватися по-різному.
Тут аргумент негайно переноситься на локальну змінну для ефективності (можна було б використовувати параметр lбезпосередньо на місці p, але тоді доступ до нього кожного разу передбачав би додатковий рівень непрямості); отже, різниця при передачі аргументу 1 режиму мінімальна. Фактично, використовуючи цей режим, аргумент міг служити безпосередньо локальною змінною, уникаючи, таким чином, початкового переміщення; це лише екземпляр загального принципу, що якщо аргумент, переданий за посиланням, служить лише для ініціалізації локальної змінної, можна просто так само передати її за значенням і використовувати параметр як локальну змінну.
Використання режиму 3, як видається, підтримується стандартом, про що свідчить той факт, що всі надані бібліотечні функції, які передають право власності на смарт-покажчики, використовуючи режим 3. Особливим переконливим випадком є конструктор std::shared_ptr<T>(auto_ptr<T>&& p). Цей конструктор використовував (in std::tr1), щоб взяти змінену посилання на значення (як і auto_ptr<T>&конструктор копії), і тому він може бути викликаний значенням auto_ptr<T>lvalue, pяк у std::shared_ptr<T> q(p), після чого pбуло скинуто до нуля. Через зміну аргументу від режиму 2 до 3 цей старий код тепер повинен бути переписаний std::shared_ptr<T> q(std::move(p))і продовжувати працювати. Я розумію, що комітету не сподобався режим 2 тут, але вони мали можливість перейти до режиму 1, визначившисьstd::shared_ptr<T>(auto_ptr<T> p)натомість вони могли б переконатися, що старий код працює без змін, оскільки (на відміну від унікальних покажчиків) авто-покажчики можуть бути мовчки відкинуті до значення (сам об’єкт вказівника скидається до нуля в процесі). Мабуть, комітет настільки віддав перевагу режиму 3 над режимом 1, що вони вирішили активно порушувати існуючий код, а не використовувати режим 1 навіть для вже застарілого використання.
Коли віддати перевагу режиму 3 над режимом 1
Режим 1 є ідеально корисним у багатьох випадках і може віддавати перевагу режиму 3 у випадках, коли припущення власності в іншому випадку має форму переміщення інтелектуального вказівника на локальну змінну, як у reversedнаведеному вище прикладі. Однак я бачу дві причини віддати перевагу режиму 3 у більш загальному випадку:
Трохи ефективніше пройти посилання, ніж створити тимчасовий та зав'язати старий покажчик (обробка готівки дещо трудомістка); в деяких сценаріях вказівник може бути переданий кілька разів незмінним до іншої функції, перш ніж він насправді розкрадений. Таке проходження, як правило, вимагатиме написання std::move(якщо не використовується режим 2), але зауважте, що це лише акторський склад, який насправді нічого не робить (зокрема, не відсилає), тому він додає нульову вартість.
Чи слід вважати, що що-небудь кидає виняток між початком виклику функції та точкою, коли він (або якийсь міститься виклик) насправді переміщує вказаний об'єкт в іншу структуру даних (і цей виняток вже не потрапляє всередину самої функції ), тоді при використанні режиму 1 об'єкт, на який посилається інтелектуальний вказівник, буде знищений до того, як catchпропозиція може обробляти виняток (оскільки параметр функції був знищений під час розмотування стека), але не так, коли використовується режим 3. Останній дає абонент має можливість відновити дані об'єкта в таких випадках (шляхом вилучення виключення). Зауважте, що режим 1 тут не спричиняє витоку пам'яті , але може призвести до незворотної втрати даних для програми, що також може бути небажаним.
Повернення розумного вказівника: завжди за значенням
На закінчення слова про повернення розумного вказівника, імовірно, вказуючи на об’єкт, створений для використання абонентом. Це насправді не випадок, порівнянний з передачею покажчиків на функції, але для повноти я хотів би наполягати на тому, що в таких випадках завжди повертаються за значенням (і не використовують std::move у returnвиписці). Ніхто не хоче отримати посилання на вказівник, який, ймовірно, щойно був прикутий.