18.9 – Нарезка объектов

Добавлено 21 августа 2021 в 12:13

Вернемся к примеру, который мы рассмотрели ранее:

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++ поддерживает присваивание объектов производных классов объектам базовых классов через нарезку объектов, в целом это, скорее всего, не вызовет ничего, кроме головной боли, и обычно нарезки следует стараться избегать. Убедитесь, что параметры вашей функции являются ссылками (или указателями), и постарайтесь избегать любого вида передачи по значению, когда дело касается производных классов.

Теги

C++ / CppLearnCppДля начинающихКласс (программирование)НаследованиеОбучениеПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.