18.9 – Нарезка объектов
Вернемся к примеру, который мы рассмотрели ранее:
class Base
{
protected:
int m_value{};
public:
Base(int value)
: m_value{ value }
{
}
virtual const char* getName() const { return "Base"; }
int getValue() const { return m_value; }
};
class Derived: public Base
{
public:
Derived(int value)
: Base{ value }
{
}
virtual const char* getName() const { return "Derived"; }
};
int main()
{
Derived derived{ 5 };
std::cout << "derived is a " << derived.getName()
<< " and has value " << derived.getValue() << '\n';
Base& ref{ derived };
std::cout << "ref is a " << ref.getName()
<< " and has value " << ref.getValue() << '\n';
Base* ptr{ &derived };
std::cout << "ptr is a " << ptr->getName()
<< " and has value " << ptr->getValue() << '\n';
return 0;
}
В приведенном выше примере ссылка ref
и указатель ptr
указывают на объект derived
, который содержит часть Base
и часть Derived
. Поскольку ref
и ptr
принадлежат типу Base
, они могут видеть только часть Base
объекта derived
– часть Derived
объекта derived
всё еще существует, но просто не может быть видна через ref
или ptr
. Однако с помощью механизма виртуальных функций мы можем получить доступ к наиболее производной версии функции. Следовательно, показанная выше программа печатает:
derived is a Derived and has value 5
ref is a Derived and has value 5
ptr is a Derived and has value 5
Но что произойдет, если вместо установки ссылки или указателя типа Base
на объект Derived
мы просто присвоим объект Derived
объекту Base
?
int main()
{
Derived derived{ 5 };
Base base{ derived }; // что здесь произойдет?
std::cout << "base is a " << base.getName()
<< " and has value " << base.getValue() << '\n';
return 0;
}
Помните, что у derived
есть часть Base
и часть Derived
. Когда мы присваиваем объект Derived
объекту Base
, копируется только часть Base
объекта Derived
. Часть Derived
– нет. В приведенном выше примере base
получает копию части Base
объекта derived
, но не части Derived
. Эта часть Derived
фактически «отрезается». Следовательно, присваивание объекта класса Derived
объекту класса Base
называется нарезкой объекта (или сокращенно нарезкой, англоязычный термин – object slicing).
Поскольку переменная base
не имеет части Derived
, вызов base.getName()
преобразуется в Base::getName()
.
Приведенный выше пример печатает:
base is a Base and has value 5
При продуманном использовании нарезка может быть безобидной. Однако при неправильном использовании нарезка может привести к неожиданным результатам несколькими способами. Давайте рассмотрим некоторые из этих случаев.
Нарезка объектов и функции
Вы можете подумать, что приведенный выше пример немного странный. В конце концов, зачем вам так присваивать объект производного класса объекту базового класса? Вы, наверное, не стали бы этого делать. Однако гораздо чаще нарезка происходит случайно, при использовании функций.
Рассмотрим следующую функцию:
// обратите внимание: base передается по значению, а не по ссылке
void printName(const Base base)
{
std::cout << "I am a " << base.getName() << '\n';
}
Это довольно простая функция с константным объектом base
в качестве параметра, который передается по значению. Если мы вызовем эту функцию так:
int main()
{
Derived d{ 5 };
// ой, вызывающий не понял, что передал это по значению
printName(d);
return 0;
}
Когда вы писали эту программу, вы могли не заметить, что base
– это параметр по значению, а не по ссылке. Следовательно, при вызове printName(d)
мы могли ожидать, что base.getName()
вызовет виртуализированную функцию getName()
и напечатает "I am a Derived", но это не происходит. Вместо этого объект d
класса Derived
обрезается, и в параметр base
копируется только часть Base
. Когда выполняется base.getName()
, даже несмотря на то, что функция getName()
виртуализирована, у класса нет части Derived
, в которую она могла бы разрешить этот вызов. Следовательно, эта программа печатает:
I am a Base
В этом случае довольно очевидно, что произошло, но если ваши функции на самом деле не выводят идентифицирующую информацию, подобную этой, отследить такую ошибку может быть сложно.
Конечно, нарезки здесь можно легко избежать, сделав параметр функции ссылкой, а не передачей по значению (еще одна причина, по которой передача объектов классов по ссылке вместо передачи по значению является хорошей идеей).
// обратите внимание: base теперь передается по ссылке
void printName(const Base& base)
{
std::cout << "I am a " << base.getName() << '\n';
}
int main()
{
Derived d{ 5 };
printName(d);
return 0;
}
Этот код печатает:
I am a Derived
Нарезка объектов и векторы
Еще одна область, где начинающие программисты сталкиваются с проблемами нарезки объектов, – это попытка реализовать полиморфизм с помощью std::vector
. Рассмотрим следующую программу:
#include <vector>
int main()
{
std::vector<Base> v{};
v.push_back(Base{ 5 }); // добавляем объект Base в наш вектор
v.push_back(Derived{ 6 }); // добавляем объект Derived в наш вектор
// Распечатываем все элементы в нашем векторе
for (const auto& element : v)
{
std::cout << "I am a " << element.getName()
<< " with value " << element.getValue() << '\n';
}
return 0;
}
Эта программа отлично компилируется. Но при запуске печатает:
I am a Base with value 5
I am a Base with value 6
Подобно предыдущим примерам, поскольку std::vector
был объявлен как вектор типа Base
, когда Derived{6}
был добавлен в этот вектор, он был обзрезан.
Исправить это немного сложнее. Многие начинающие программисты пытаются создать std::vector
из ссылок на объекты, например:
std::vector<Base&> v{};
К сожалению, это не скомпилируется. Элементы std::vector
должны быть присваиваемыми, тогда как ссылки нельзя переприсвоить (только инициализировать).
Один из способов решить эту проблему – создать вектор указателей:
#include <iostream>
#include <vector>
int main()
{
std::vector<Base*> v{};
// b и d не могут быть анонимными объектами
Base b{ 5 };
Derived d{ 6 };
v.push_back(&b); // добавляем объект Base в наш вектор
v.push_back(&d); // добавляем объект Derived в наш вектор
// Распечатываем все элементы в нашем векторе
for (const auto* element : v)
{
std::cout << "I am a " << element->getName()
<< " with value " << element->getValue() << '\n';
}
return 0;
}
Этот код печатает:
I am a Base with value 5
I am a Derived with value 6
Всё работает! Несколько комментариев по этому поводу. Во-первых, nullptr
теперь является допустимым вариантом, что может быть желательным, а может и не быть. Во-вторых, теперь вам нужно иметь дело с семантикой указателей, что может быть неудобным. Но с другой стороны, это также дает возможность динамического выделения памяти, что полезно, если ваши объекты могут выйти за пределы области видимости.
Объект-франкенштейн
В приведенных выше примерах мы видели случаи, когда нарезка приводила к неверному результату, поскольку объект производного класса обрезался. Теперь давайте рассмотрим еще один опасный случай, когда объект производного класса всё еще существует!
Рассмотрим следующий код:
int main()
{
Derived d1{ 5 };
Derived d2{ 6 };
Base& b{ d2 };
b = d1; // эта строка проблемная
return 0;
}
Первые три строки этой функции довольно просты. Создаются два объекта Derived
и устанавливается ссылка типа Base
на второй из этих объектов.
В четвертой строке всё сбивается. Поскольку b
указывает на d2
, а мы присваиваем d1
значению b
, вы можете подумать, что результатом будет то, что d1
будет скопирован в d2
– и так оно и было бы, если бы b
была типа Derived
. Но b
– типа Base
, а operator=
, который C++ по умолчанию предоставляет для классов, не является виртуальным. Следовательно, в d2
копируется только часть Base
из d1
.
В результате вы обнаружите, что d2
теперь содержит часть Base
от d1
и часть Derived
от d2
. В этом конкретном примере это не проблема (поскольку класс Derived
не имеет собственных данных), но в большинстве случаев вы просто создали объект-франкенштейн, состоящий из частей нескольких объектов. Хуже того, предотвратить это нет простого способа (кроме как максимально избегать подобных присваиваний).
Заключение
Хотя C++ поддерживает присваивание объектов производных классов объектам базовых классов через нарезку объектов, в целом это, скорее всего, не вызовет ничего, кроме головной боли, и обычно нарезки следует стараться избегать. Убедитесь, что параметры вашей функции являются ссылками (или указателями), и постарайтесь избегать любого вида передачи по значению, когда дело касается производных классов.