Может ли иерархия классов быть безопасной и тривиально скопируемой?

Мы используем фреймворк, который полагается на memcpy в определенных функциях. Насколько я понимаю, я могу дать все, что тривиально можно скопировать в эти функции.

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

class Timestamp; //...

class Header
{
public:
  uint8_t Version() const;
  const Timestamp& StartTime();
  // ... more simple setters and getters with error checking

private:
  uint8_t m_Version;
  Timestamp m_StartTime;
};

class CanData : public Header
{
public:
  uint8_t Channel();
  // ... more setters and getters with error checking

private:
  uint8_t m_Channel;
};

Базовый класс используется в нескольких аналогичных подклассах. Здесь я опустил все конструкторы и деструкторы. Таким образом, классы тривиально копируются. Я полагаю, однако, что пользователь может написать код, который приводит к утечке памяти следующим образом:

void f()
{
  Header* h = new CanData();
  delete h;
}

Правильно ли, что иерархия классов без виртуального деструктора является проблемой, даже если все классы используют деструктор по умолчанию для компилятора? Правильно ли, что я не могу иметь безопасную иерархию классов, которая тривиально копируется?

Ответ 1

Этот код

Header* h = new CanData();
delete h;

приведет к срабатыванию undefined поведения, поскольку в §5.3.5/p3 указано:

В первом альтернативе (удалить объект), если статический тип подлежащего удалению объекта отличается от его динамический тип, статический тип должен быть базовым классом динамического типа объекта, подлежащего удалению, и статический тип должен иметь виртуальный деструктор или поведение undefined

и независимо от того, не имеют ли динамически выделенные объекты, содержащиеся в вашем производном классе (очень плохо, если есть), вы не должны этого делать. Наличие иерархии классов без виртуального деструктора базового класса само по себе не является проблемой, возникает проблема, когда вы пытаетесь смешивать статические и динамические типы с delete.

Выполнение memcpy для объекта производного класса пахнет плохим дизайном для меня, я бы скорее рассмотрел необходимость создания "виртуального конструктора" (т.е. виртуальной функции clone() в вашем базовом классе) для дублирования ваших производных объектов.

У вас может быть иерархия классов, которая тривиально копируется, если вы убедитесь, что ваш объект, его подобъекты и базовые классы тривиально можно копировать. Если вы хотите, чтобы пользователи не ссылались на ваши производные объекты через базовые классы, вы могли бы, как предположил Марк, визуализировать защищенное наследование

class Header
{
public:
};

class CanData : protected Header
{               ^^^^^^^^^
public:
};

int main() {
  Header *pt = new CanData(); // <- not allowed
  delete pt;
}

Обратите внимание, что вы не сможете использовать базовые указатели вообще, чтобы ссылаться на производные объекты из-за §4.10/p3 - преобразования указателей.

Ответ 2

Если вы удаляете указатель на производный тип, хранящийся в его базовом типе, и у вас нет виртуального деструктора, деструктор производных типов не будет вызываться, независимо от того, он неявно сгенерирован или нет. И независимо от того, неявно ли он создан или нет, вы хотите, чтобы его вызывали. Если деструктор производного типа на самом деле ничего не сделал бы, он мог бы не пропустить что-либо или вызвать проблему. Если производный тип содержит что-то вроде std::string, std::vector или что-либо с динамическим распределением, вы хотите, чтобы вызывали dtors. В качестве хорошей практики вам всегда нужен виртуальный деструктор базовых классов, нужно ли вызывать деструкторы производных классов (поскольку базовый класс не должен знать о том, что происходит от него, он не должен делать предположение, как это).

Если вы скопируете такой тип:

Base* b1 = new Derived;
Base b2 = *b1;

Вы вызываете только Base copy ctor. Части объекта, которые на самом деле от Derived, не будут задействованы. b2 не будет тайно быть Derived, это будет просто Base.

Ответ 3

Мой первый инстинкт - "не делайте этого - найдите другой способ, другую структуру или исправьте структуру". Но только для забавы пусть предположим, что для определенности ваша копия класса никоим образом не зависит от конструктора копирования класса или любой из его входящих частей, которые вызывается.

Тогда, поскольку вы явно наследуете, а не заменяете решение, просто: используйте protected наследование, и ваша проблема решена, потому что они больше не могут полиморфно обращаться к вашему объекту или удалять его, предотвращая поведение undefined.

Ответ 4

Это почти безопасно. В частности, нет утечки памяти в

Header* h = new CanData();
delete h;

delete h вызывает деструктор Header, а затем освобождает память, на которую указывает h. Объем освобожденной памяти такой же, как был первоначально выделен по этому адресу памяти, а не sizeof(Header). Поскольку Header и CanData тривиальны, их деструкторы ничего не делают.

Однако вы должны предоставить виртуальный деструктор, даже если он ничего не делает (по требованию стандарта, чтобы избежать поведения undefined). Общим правилом является то, что деструктор для базового класса должен быть открытым или виртуальным или защищенным и не виртуальным

Конечно, вы должны остерегаться нарезки, как обычно.

Ответ 5

Спасибо всем за публикацию различных предложений. Я попробую обобщающий ответ с дополнительным предложением для решения.

Предпосылкой моего вопроса было достижение иерархии классов, которая тривиально копируется. См. http://en.cppreference.com/w/cpp/concept/TriviallyCopyable и особенно требование тривиального деструктора (http://en.cppreference.com/w/cpp/language/destructor#Trivial_destructor). Класс не может быть реализован деструктором. Это ограничивает допустимые элементы данных, но подходит для меня. В примере показаны только C-совместимые типы без выделения динамической памяти.

Некоторые отметили, что проблема моего кода - это поведение undefined, не обязательно утечка памяти. Марко процитировал этот стандарт. Спасибо, очень полезно.

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

Решение 1: Предлагаемые решения используют защищенное наследование.

class CanData : protected Header
{
  ...
};

Он работает, но позволяет избежать доступа к публичному интерфейсу заголовка. Это было первоначальное намерение иметь базовый класс. CanData необходимо перенаправить эти функции в заголовок. Вследствие этого я передумал бы использовать композицию вместо наследования здесь. Но решение должно работать.

Решение 2: Деструктор заголовка должен быть защищен, а не базовый класс в целом.

class Header
{
public:
  uint8_t Version() const;
  const Timestamp& StartTime();
  // ... more simple setters and getters with error checking

protected:
  ~Header() = default;

private:
  uint8_t m_Version;
  Timestamp m_StartTime;
};

Тогда ни один пользователь не может удалить заголовок. Это хорошо для меня, потому что Header не имеет никакой цели сам по себе. С открытым деривацией открытый интерфейс остается доступным для пользователя.

Я понимаю, что CanData не нуждается в реализации деструктора для вызова дескриптора базового класса. Все могут использовать деструктор по умолчанию. Я не совсем уверен в этом, хотя.

В общем, ответы на мои вопросы в конце исходного положения:

  • Правильно ли, что иерархия классов без виртуального деструктора является проблемой, даже если все классы используют деструктор по умолчанию для компилятора?

    Это только проблема, если ваш деструктор является общедоступным. Вы должны избегать того, что люди могут получить доступ к вам desctrutor, за исключением производных классов. И вы должны обеспечить, чтобы производные классы вызывали (неявно) деструктор базового класса.

  • Является ли это правильным, что я не могу иметь безопасную иерархию классов, которая тривиально копируется?

    Вы можете сделать свой базовый класс безопасным с защищенным наследованием или защищенным дескриптором. Тогда у вас может быть иерархия тривиально сшиваемых классов.