среда, 4 декабря 2013 г.

[prog.c++] Выражение модели владения через типы аргументов

На прошлой неделе сразу в двух независимых обсуждениях всплыл вопрос модели владения объектами в C++. Даже не столько сам вопрос владения, сколько методы обозначения этого владения посредством типов аргументов для функций/методов/конструкторов. Одно из них -- это большой флейм на LOR-е, касающийся языка D. Второе обсуждение велось в переписке между разработчиками SObjectizer. По итогам обсуждений у меня возникла мысль, что могло бы быть полезным описание моего взгляда на проблему типов аргументов, который сформировался у меня за 20 лет использования C++, набивания собственных шишек, чтения рекомендаций от других специалистов и пр.

Итак, поскольку в C++ нет сборки мусора, то проблема владения объектами является одной из краеугольных для C++. Большая часть страшилок о C++ -- битые и повисшие ссылки/указатели, повторное удаление объекта, обращение к несуществующему еще объекту и т.д. -- являются ее следствиями и проявлениями ошибок при ее решении. Удивительно, но до сих пор приходится читать на профильных ресурсах или видеть в коде подходы к решению этих проблем, характерные для plain C. Тогда как C++ скоро подберется к своему тридцатилетнему рубежу и в нем за это время накопилось достаточно механизмов для более простого и наглядного решения проблемы владения. Ниже я попробую об этом рассказать.

Владение можно разделить на три типа.

Первый тип -- это эксклюзивное владение. Т.е. объект A эксклюзивно владеет объектом B, если A и только A отвечает за уничтожение объекта B. Объект B живет до тех пор, пока живет A (или даже меньше). После уничтожения A объекта B больше не существует.

Второй тип -- это совместное владение. Т.е. объекты A1, A2, ..., An совместно владеют объектом B. И объект B не может быть разрушен до тех пор, пока в живых остается хоть кто-нибудь из Ai.

Третий тип -- это использование чужого объекта. Т.е. объект A ссылается на объект B, но никак не может повлиять на время жизни объекта B. О том, чтобы ссылка на B оставалась валидной в течении жизни объекта A должен позаботится тот, кто дает A ссылку на B.

Каким образом современный C++ позволяет выразить эти три типа владения объектами в случае, когда речь идет об аргументах функции/метода/конструктора? Для простоты речь будет идти о типах параметров конструктора объекта A, которому передается "ссылка" на B (под "ссылкой" тут понимается более широкий спектр вариантов, нежели узкое понятие reference в смысле языка C++).


Эксклюзивное владение в C++ явным образом демонстрируется через передачу в конструктор объекта unique_ptr или же rvalue reference:

class A {
   public :
      A( std::unique_ptr< B > b ) ...
};

Вариант с unique_ptr явным образом говорит, что пользователь объекта A должен создать в динамической памяти объект B и передать ответственность за время жизни B объекту A. Прошу обратить внимание на создание в динамической памяти. Это очень важно. Поскольку язык C++ не имеет сборки мусора, работа с динамической памятью в нем обходится дороже. Особенно в многопоточных приложениях.

Примечание. Поскольку unique_ptr может быть снабжен пользовательским Deleter-ом, утверждение на счет размещения объекта в динамической памяти будет верным не всегда. Но для упрощения материала эта частная ситуация не рассматривается.

Может использоваться и rvalue reference:

class A {
   public :
      A( B && b ) ...
};

Вариант с rvalue reference явным образом говорит, что объект A заберет содержимое из переданного ему объекта B в свою собственную копию объекта B.

Если сравнивать варианты с unique_ptr и rvalue reference, то между ними есть важные отличия:

  • в случае с rvalue reference объект B может быть создан не только в динамической памяти, но и на стеке. Так же B может быть временным объектом, например, возвращенным по значению из какой-то вспомогательной функции. В современных многопоточных приложениях это может быть важным различием;
  • вариант с unique_ptr допускает передачу и нулевого указателя в том числе. Поэтому видя прототип A(unique_ptr<B>) нельзя просто так сказать, может ли A пережить переданный ему nullptr или не может. Тут нужно внимательнее смотреть на класс A, возможно, nullptr рассматривается им как опциональность аргумента. Тогда как с rvalue reference явно видно, что объект B должен существовать и быть валидным объектом;
  • вариант с unique_ptr позволяет передачу в A полиморфных объектов, т.е. наследников типа B. Тогда как с rvalue reference речь может идти только об объекте типа B.

Совместное владение в современном C++ явным образом выражается через использование std::shared_ptr:

class A {
   public :
      A( const std::shared_ptr< B > & b ) ...
};

До принятия C++11 для этих же целей могли использоваться нестандартные аналоги std::shared_ptr, т.к. boost::shared_ptr, ACE_Refcounted_Ptr, POCO::SharedPtr и т.д. В принципе, и сейчас для этих же целей могут применяться нестандартизированные варианты "умных указателей", если они дают преимущества по сравнению с std::unique_ptr. Например, интрузивные умные указатели (вроде boost::intrusive_ptr или POCO::AutoPtr).

Как бы то ни было, использование std::shared_ptr явным образом говорит о том, что объект A берет на себя часть ответственности за жизнь созданного в динамической памяти объекта B (хотя, с учетом того, что в shared_ptr можно передать собственный Deleter, это не обязательно так, но для упрощения материала эта тема рассматриваться не будет).

Так же, как и у unique_ptr, передача shared_ptr может допускать передачу значения nullptr. Поэтому для того, чтобы понять, способен ли A обрабатывать нулевой указатель на B одного формата конструктора недостаточно, нужно смотреть на класс A внимательнее.

Аналогично unique_ptr, передача shared_ptr позволяет отдавать A полиморфные объекты, т.е. наследников B.


Использование чужого объекта в C++ выражается через передачу объекта по ссылке или по "голому" указателю.

class A {
   public :
      // (1)
      A( const B & b ) ...
      // (2)
      A( B & b ) ...
      // (3)
      A( const B * b ) ...
      // (4)
      A( B * b ) ...
};

Вариант (1) указывает на передачу существующего объекта только для чтения. Ситуацию с наличием в B mutable-полей сейчас рассматривать не будем, это не проблема объекта A.

Вариант (2) указывает на передачу существующего объекта для чтения и для изменения.

Вариант (3) указывает на передачу, возможно, не существующего объекта только для чтения. Т.е. в варианте (3) в конструктор A может быть передан nullptr и A обязуется с этой ситуацией справиться. Именно обязуется. Если бы это было не так, следовало бы использовать вариант (1).

Вариант (4) указывает на передачу, возможно, не существующего объекта для чтения и изменения. Как и в варианте (3), в конструктор A может быть передан nullptr и A обязуется с этой ситуацией справиться.

Важно отметить, что все четыре варианта позволяют передать в A полиморфный объект, т.е. реально вместо B может быть наследник B. Эту особенность необходимо помнить, если внутри A потребуется создать копию B.

Первые два варианта я считаю вполне безопасными и стараюсь пользоваться только ими. Хотя тут нужно отметить, что вариант (1) позволяет передать в A ссылку на временный объект, который может быть разрушен сразу после завершения работы конструктора A.

Вариант (3) менее безопасен, т.к. компилятор спокойно пропустит, например, A(0), хотя это в большинстве случаев говорит о допущенной разработчиком опечатке. Данный вариант я применяю либо когда мне нужно обозначить опциональность значения B. Либо же когда приходится плотно работать с C-шными строками (тогда речь идет о const char *).

Вариант (4) для меня не является сколько-нибудь безопасным вообще. Кроме конструкции A(0) компилятор так же вполне спокойно допустит и применение delete к неконстантному указателю. Кроме того, широкое использование неконстантных указателей намекает либо на то, что код писал C-шный разработчик старой школы, который принципально не любит и не хочет использовать, или же просто не знает C++ные возможности (что до сих пор бывает). Либо что код написан для тесной интеграции с C-шным кодом. И тогда нужно очень внимательно следить за тем, а не является ли отсутствие в A вызовов delete или free ошибкой, допущенной разработчиком.


Теперь можно немного поговорить о применимости и побочных эффектах тех или иных типов владения.

На мой взгляд, третий тип, т.е. использование чужих объектов, накладывает меньше всего ограничений на то, как будет использоваться объект A. Действительно, объекты B для него могут быть созданы на стеке или размещены в динамической памяти, могут выделяться из какого-то пула и возвращаться туда, могут быть самостоятельными объектами или же являться частью (полем, одним из базовых типов) другого объекта. Т.е. автор класса A решает только специфическую для класса A задачу, не беря на себя ответственность за что-то еще.

Тогда как два других типа владения накладывают на A больше ответственности: класс A решает теперь не только свою прикладную задачу, но еще и отвечает за время жизни объекта B. Так же возникает больше ограничений на использование класса A. Так, если конструктор A объявлен как использующий std::unique_ptr<B> (без указания Deleter-а), то A будет требоваться именно размещенный в динамической памяти объект B, уничтожение которого будет выполняться через delete. И пользователь класса A уже не сможет передать в A объект B, созданный на стеке или размещенный во временной арене, или же являющегося полем другого объекта.

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

И тут нужно явно подчеркнуть важную вещь. На уровне языка все четыре варианта, перечисленных мной при обсуждении ссылок на чужие объекты, одинаково уязвимы к невалидным/битым/повисшим ссылкам/указателям. Т.е неважно, как вы опишите класс A, так:

class A {
   public :
      A( const B & b )
         :  m_b( b )
      {}
   ...
   private :
      const B & m_b;
};

или так:

class A {
   public :
      A( B * b )
         :  m_b( b )
      {}
   ...
   private :
      B * m_b;
};

Все равно компилятор не сможет защитить вас от вот такого кода:

B * b = new B();
A a(b);
delete b;

Кто-то, возможно, скажет, что когда он видит передачу аргумента по указателю, то он начинает относиться к этому месту внимательнее, читать документацию к A или изучать реализацию A. Это ерунда. Не более чем заморочки конкретного разработчика. Если вы думаете что передача B в A по указателю дает A право сохранить A это указатель у себя внутри, а передача B по ссылке такого права не дает, то это не более, чем ваши личные тараканы. Разработчик за соседним столом может думать совсем по другому. А язык, что наиболее важно, позволяет по всякому. Но в случае ссылок компилятор дает чуть больше гарантий, для нарушения которых нужно приложить некоторые усилия. Поэтому-то использование ссылок в C++ я считаю более предпочтительным, чем голых указателей.


PS. К первому типу владения, т.е. к эксклюзивному, относится так же и передача B в конструктор A по значению. Такая передача, возможно, имела смысл в C++98/03, где не было rvalue references. Но в C++11 она может применяться разве что для небольших по размеру объектов B, для которых операция копирования может быть эффективнее, чем операция move. Впрочем, для чисто вычислительных задач, где в качестве типа B могут выступать небольшие структуры (вроде point2d или point3d, комплексные числа или небольшие вектора базовых типов (вроде int, float или double)), передача по значению может быть эффективной из-за более компактного размещения данных в памяти в результате копирования значений.

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