четверг, 19 ноября 2015 г.

[prog.thoughts] Про использование ссылок и указателей в C++

В комментариях к одной из предыдущих заметок была затронута тема использования указателей (как умных, так и "голых") и ссылок в качестве параметров методов/функций в C++. Тема эта не простая, сдобренная изрядной долей вкусовщины и legacy-подходов. Тем не менее, можно вскользь по ней пройтись.

Итак, в начале был чистый C, в котором аргументы в функции передавались либо по значению, либо по указателю. И все было "просто". Потом появился C++, в котором к уже привычным по C "by value" и "by pointer" добавился еще один вариант: "by reference". Ну и понеслось :)

Кто-то советовал, чтобы аргументы, которые дорого или невозможно передавать по значению, передавались только по указателю. Кто-то советовал обратное: аргументы передаются по ссылке, если только не передается владение динамически созданным объектом. Кто-то советовал комбинированные подходы: если объект передается только для чтения, то по ссылке, если же для изменения, то по указателю (как правило, люди, высказывавшие такие рекомендации, не знали про возможность объявлять const-ссылки и const-указатели, либо же не пользовались этим по каким-то своим религиозным соображениям). Причем, что самое невероятное, жестокие священные войны на эту тему на профильных форумах возникают даже сейчас.

Лично я давным-давно придерживался такого принципа: если объект нельзя передать по значению, то он передается в функцию/метод по ссылке.

Объясняется это тем, что использование ссылки требует меньше телодвижений: не нужно брать адрес объекта и не нужно писать a->f(), а можно обойтись a.f(). Ну и еще несколько приятных бонусов: нельзя просто так передать вместо ссылки на объект ноль (тогда как для указателя это сделать элементарно) и нельзя просто так вызвать delete для ссылки. От всего этого страхует компилятор (не на 100%, т.к. можно разыменовать нулевой указатель, но все-таки).

Правило это выработалось еще до того, как в C++ стали активно использовать умные указатели (и задолго до того, как умные указатели попали в стандарт C++). И это важный момент: до тех пор, пока аналоги современных shared_ptr и unique_ptr не имели широкого распространения, вопрос передачи владения динамически созданным объектом был далеко не очевидным.

Например, если была функция с прототипом void f(T *p), то для чего ей требовалось получение параметра через указатель?

Может быть для того, чтобы иметь возможность передать 0 (NULL, nullptr) в качестве индикатора, что объекта-параметра нет? А ведь передача нуля вместо указателя на реальный объект -- это довольно распространенная практика.

Или для того, чтобы функция f() получила право владения объектом p и удалила его самостоятельно, когда он ей уже не нужен?

Вот как раз для того, чтобы иметь четкий ответ на этот вопрос я и использую передачу аргументов по ссылке. Это дает мне возможность понять, что void f(T &p) изменяет свой аргумент, но не уничтожает его, а void f(const T &p) не изменяет и не уничтожает.

Тогда как void f(const T *p) не изменяет, не уничтожает и может получать ноль вместо p. А void f(T *p) либо изменяет, либо уничтожает и может получать ноль в качестве аргумента.

Такая вот стройная картинка сложилась где-то к середине 90-х годов. Хотя при использовании сторонних библиотек все равно приходилось разбираться с их особенностями. Ну и, кроме того, данная система не давала ответов на вопрос о том, насколько безопасно сохранять полученную в аргументе ссылку/указатель для последующего использования.

К концу 90-х годов ситуация стала, с одной стороны, проясняться. В массы стали приходить умные указатели. Что упростило индикацию передачи владения объектом: прототип void f(auto_ptr<T> p) однозначно указывал, что функция f требует передачи ей полного владения объектом p и она обязуется удалить его.

Так же появилась возможность указать, что функция хочет разделить владение динамически созданным объектом: void f(refcounter_ptr<T> p) указывает на то, что f сохранит свою копию умного указателя где-то у себя внутри и тем самым гарантирует себе, что объект p не будет уничтожен раньше времени.

Тем самым упростилась ситуация с функциями, получающими аргументы в виде "голых" указателей: void f(T *p) меняет объект p и может получить ноль вместо актуального указателя, тогда как void f(const T *p) не меняет объект p и может получить ноль в качестве аргумента. Тем не менее, ни одна из этих функций не требует себе права владения объектом, т.к. такое право выражается через аргумент auto_ptr<T> (или unique_ptr<T> в C++11/14).

Однако, с другой стороны, ситуация усложнилась. Во-первых, была куча старого кода, в котором вполне себе спокойно жили старые принципы (т.е. передача неконстантного голого указателя означала передачу владения). Более того, даже новый код продолжали писать в таком же стиле по тем или иным причинам.

Во-вторых, в стандартной библиотеке был только довольно своеобразный auto_ptr, а умного указателя с подсчетом ссылок не было. Не было так же и аналога auto_ptr для T[]. Поэтому в каждой библиотеке были свои велосипеды на этот счет и нужно было уметь их дружить друг с другом.

В-третьих, появился некоторый overuse умных указателей. Т.е. как только у какого-то не очень опытного C++ника в руках появлялись умные указатели с подсчетом ссылок внутри, так эти указатели сразу же начинали использоваться направо и налево. Что можно понять, т.к. умные указатели упрощали борьбу с некоторыми проблемами (например, повисшие указатели, утечки памяти, повторное удаление объектов). Но нельзя простить, т.к. чрезмерное увлечение подсчетом ссылок имело свою цену в ран-тайм, да и легко могло приводить к циклам, в борьбе с которыми счетчики ссылок не сильны.

Тем не менее, с годами опыт работы с умными указателями был поднакоплен, плюс к тому shared_ptr и unique_ptr стали частью стандартной библиотеки C++11. Так что теперь довольно легко различать, что именно делает функция:

  • void f(const T &p) ожидает реально существующий объект, но не изменяет его;
  • void f(T &p) ожидает реально существующий объект и изменяет его;
  • void f(const T *p) ожидает объект или nullptr, если объект существует, то не изменяет его;
  • void f(T *p) ожидает объект или nullptr, если объект существует, то изменяет его;
  • void f(unique_ptr<T> p) ожидает объект или nullptr; если объект существует, то забирает ответственность за его удаление;
  • void f(shared_ptr<T> p) ожидает объект или nullptr; если объект существует, то разделяет владение этим объектом (что дает право функции f() сохранить p у себя для дальнейшего использования или передать кому-то еще).

Хотя это еще не все возможные варианты, т.к. может быть еще и void f(const shared_ptr<T> &p) и void f(shared_ptr<T> &p) ;)

Ну и плюс к тому древний код все еще остается и запросто можно нарваться на функцию void f(T *p) которая использует голый указатель для того, чтобы забрать себе владение объектом.

А после публикации C++ Guidelines ситуация может стать еще веселее: добавится еще и owner<T*>. Т.е. функция с прототипом void f(owner<T*> p); будет требовать передачи ей владения объектом p. Что, несомненно, сделает жизнь проще, когда древний код будет приведен в соответствие новым рекомендациям (в том числе и с использованием специальных статических анализаторов и модификаторов кода). А до тех пор жизнь будет не столько проще, сколько интереснее ;)

Комментариев нет: