Дозвольте спробувати констатувати різні життєздатні режими передачі покажчиків на об’єкти, пам’яттю яких керує екземпляр 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
виписці). Ніхто не хоче отримати посилання на вказівник, який, ймовірно, щойно був прикутий.