Некоторые мысли про конструкторы копирования в C++
Данный пост не претендует на революционность или какие-то неслыханные открытия – я пишу о вещах очевидных, и возможно кто-то до меня уже писал об этом. Скромно полагаю, что начинающим осваивать язык некоторые мысли, высказанные здесь, будут не бесполезны.
Думаю, что ни для кого не секрет, что подавляющее большинство современных строго типизированных языков с поддержкой ООП (Java, C#, Object Pascal/Delphi) все объекты размещают исключительно в куче, предоставляя пользователю возможность взаимодействия с ними исключительно через ссылки. Принудительно вводить такой же радикальный подход в C++ вряд ли стоит, стековые объекты и RAII это наше все, однако, как мне кажется, подавляющее большинство объектов в программах должны передаваться только по ссылке (чаще всего константной). Конструктор копирования имеет одну неприятную (в некоторых раскладах) особенность – его вызов абсолютно прозрачен, и поэтому, иной раз, просто не бросается в глаза в коде. Иной раз это приводит к серьезным накладным расходам, которые с первого наскока можно и не обнаружить. Для того, чтобы не давать возможности банально описАться в прототипе функции, потеряв & (чаще всего const T&), конструктор копирования (с оператором присваивания в придачу) в таких типах нужно сделать закрытым, т.е. private.
Из личного опыта большинство классов в приложении вряд ли нуждаются в функции копирования из одного экземпляра в другой. Ну а если такая функция нужна, то вызов такой процедуры копирования нужно сделать очевидным, а не "прозрачным" через конструктор копирования.
Аналогичный подход используется в других языках; например, в Java базовый для всех объектов класс Object имеет метод clone(). Он виртуальный (там вообще коммунизм, все виртуальное на всякий случай) и protected. Для того, чтобы в runtime можно было узнать, допустимо ли копирование того или иного объекта, был придуман интерфейс Cloneable, который не содержит ни одного (!) метода. В C#, при проектировании которого постарались учесть все недоработки, нестыковки и нелогичности Java, поступили проще, придумав интерфейс ICloneable, разумеется, с методом Clone(), а в protected членах ввели функцию MemberwiseClone() для простого почленного копирования.
Что касается Delphi, то там логика проектирования библиотеки классов несколько иная (множественное наследование, вернее интерфейсы, по историческим причинам практически не используются), и в качестве некоторого аналога можно называть класс TPersistent, с его методом Assign.
Однако, вернемся к нашим баранам, т.е. C++. Можно ли в этом языке реализовать интерфейс, подобный ICloneble? Ответ не однозначный – и да, и нет. Камень преткновения – возвращаемое значение, должно ли это быть T* или просто T? Первый вариант реализуем, только вот с учетом того, что GC в языке нету, зато есть стековые объекты, я бы на нем не стал останавливаться – нарушается правило выделения и освобождения памяти на одном уровне, принуждающее к использованию умных указателей (это громоздко). Ну а реализовать виртуальный метод T Clone() не получится, т.к. в потомке T' метод придется переправить на "T' Clone()", которым заместить (override) метод потомка не получится, т.к. послабления типа для виртуальных методов работает только для указателей и ссылок (это очевидно).
ОК, давайте попробуем реализовать класс String, с закрытым конструктором копирования и методом Clone().
class String
{
int m_length;
char *m_buff;
String(const String& other) // полноценный конструктор копирования
{
// ...
}
public:
String() : m_length(0), m_buff(0)
{
}
String(const char* p) // выделяем m_buff и копируем в него строку p
{
// ...
}
~String()
{
if (m_buff) delete[] m_buff;
}
const char* c_str() const
{
return m_buff;
}
String Clone() const
{
String s(m_buff);
return s;
}
};
Все замечательно, но лично мне очень не нравится последняя строчка метода Clone(), с виду безобидное "return s", т.к. это вызов нашего полноценного конструктора копирования, что означает, что при вызове Clone() наша строка будет скопирована фактически дважды. Проблема эта древняя, хорошо известная, и решаемая путем введения вспомогательного класса, который фактически хранит буфер с данными, а экземпляры класса хранят на него ссылки. Копирование в этом случае означает всего лишь копирование ссылки на такой класс-хранилище, а настоящее копирование происходит лишь в том случае, когда кто-то пытается модифицировать данные в этом буфере.
С проблемой такого накладного копирования я столкнулся на этапе разработки легковесного класса String для одной встраевоемой системы. Ресурсы в той системе были предельно ограничены, и об использовании чего-то вроде std::string и речи быть не могло. Так вот, смотрел я на этот "return s;" и чесал репу. Класс задумывался как легковесный, и что-то переделывать, добавлять еще один тип (содержащий в себе буфер) было откровенно лень, а это чувство, как известно, самый эффективный двигатель прогресса.
Решение, конечно, далеко не самое красивое, однако эффективное, идеально подходящее для моего случая, в конце-то-концов, пришло мне в голову.
Вот оно:
// Внимание! 'Поверхностый' конструктор, забирающий буфер у объекта other.
// Только для внутренного использования в классе!
String(String& other) // не const String&, т.к. мы модифицируем передаваемый объект
{
m_length = other.m_length;
m_buff = other.m_buff;
// забираем у объекта other его буфер, все равно жить ему осталось недолго
other.m_buff = 0;
}
Идея проста – при копировании мы "забираем" буфер у входящего объекта, т.е. использование такого конструктора подразумевает, что мы объект other уничтожается сразу же после выполнения этой функции. Разумеется, это не пример "высокого стиля", писать код внутри такого класса нужно аккуратно, т.к. нельзя передавать объект по значению как аргумент функции, однако с точки зрения пользователя такого класса все вполне адекватно и безопасно.
На этом все.
