JavaRush /Курсы /C++ SELF /Object slicing — Base b = Derived{} ломает полиморфизм

Object slicing — Base b = Derived{} ломает полиморфизм

C++ SELF
51 уровень , 1 лекция
Открыта

1. Что такое object slicing

Когда вы впервые слышите «object slicing», кажется, что это или про кухню (нарезка объекта тонкими ломтиками), или про хоррор-игру, где за вами гоняется std::vector. На самом деле метафора довольно точная: при slicing вы действительно «отрезаете» часть объекта. Только отрезаете не память ножом, а смысл — копированием.

Object slicing — это ситуация, когда объект производного класса (Derived) копируется или передаётся как объект базового класса (Base) по значению. В результате создаётся новый самостоятельный объект типа Base, в котором есть только базовая часть. Производная часть (поля Derived, его переопределённое поведение как объекта Derived) — в этот новый объект не попадает.

Ключевой момент: slicing не «портит исходный Derived». Он создаёт рядом новый Base, который вообще не обязан помнить, что когда-то «родился» из наследника.

Посмотрим на минимальный пример:

#include <iostream>

struct Base {
    virtual int value() const { return 1; }
};

struct Derived : Base {
    int value() const override { return 2; }
};

int main() {
    Derived d;
    Base b = d; // slicing
    std::cout << b.value() << '\n'; // 1
}

Комментарий к выводу здесь прямолинейный: печатается 1, потому что b — это реальный объект Base, а не «ссылка на d».

Почему virtual не спасает при slicing

Обычно после первого столкновения со slicing возникает священная фраза начинающего: «Подождите… у меня же метод virtual. Почему он не вызвался?!». Это хороший вопрос, потому что он показывает, что вы уже ждёте от языка правильного поведения — просто пока ещё не в том месте.

Виртуальный вызов выбирает реализацию по реальному типу объекта только если у вас всё ещё есть тот самый объект Derived и вы обращаетесь к нему через Base&/Base*. А при slicing вы создаёте новый объект Base. Реального Derived в переменной больше нет — буквально нечего выбирать.

Полезно держать в голове простую модель:

Что у вас в переменной Это “смотрит” на Есть ли внутри Derived-часть Может ли сработать полиморфизм
Base b = derived;
на новый объект Base нет нет
Base& r = derived;
на тот же объект Derived да да
Base* p = &derived;
на тот же объект Derived да да

Давайте закрепим это двумя короткими фрагментами.

С slicing:

#include <iostream>

struct Base {
    virtual const char* name() const { return "Base"; }
};

struct Derived : Base {
    const char* name() const override { return "Derived"; }
};

int main() {
    Derived d;
    Base b = d;
    std::cout << b.name() << '\n'; // Base
}

Без slicing (через ссылку):

#include <iostream>

struct Base {
    virtual const char* name() const { return "Base"; }
};

struct Derived : Base {
    const char* name() const override { return "Derived"; }
};

int main() {
    Derived d;
    const Base& b = d;
    std::cout << b.name() << '\n'; // Derived
}

Смысл не в том, что ссылки «магические». Смысл в том, что ссылка не создаёт новый объект, а привязывается к существующему.

2. Где slicing появляется в коде

В учебных примерах slicing выглядит подозрительно заметно (Base b = d; прямо кричит «я делаю копию»). В реальной жизни он часто возникает “сам”, потому что вы написали вполне невинный код: передали параметр, положили объект в контейнер, вернули значение. А дальше — тишина, всё компилируется, но полиморфизм «куда-то пропал».

Давайте разберём три классические точки появления slicing — именно их вы будете встречать чаще всего.

Присваивание и инициализация Base из Derived

Начнём с самого прямого случая. Он редко встречается в хорошем дизайне, но часто появляется в экспериментах и рефакторинге «на коленке», когда вы пробуете наследование.

struct Base {
    virtual int id() const { return 0; }
};

struct Derived : Base {
    int id() const override { return 10; }
};

void demo() {
    Derived d;
    Base b = d;     // slicing
    Base b2(d);     // slicing (то же самое)
}

В обоих вариантах создаётся новый объект Base. Никакой «ссылки на наследника» тут не подразумевалось и не появится.

Передача параметра по значению

Это самый коварный случай, потому что он выглядит очень «естественно»: функция принимает объект — что такого?

Но если функция принимает Base по значению, то при вызове f(derived) вы делаете копию аргумента в параметр — и получаете slicing прямо на входе в функцию.

#include <iostream>

struct Base {
    virtual int value() const { return 1; }
};

struct Derived : Base {
    int value() const override { return 2; }
};

void print_value(Base b) {              // <-- по значению
    std::cout << b.value() << '\n';     // 1
}

int main() {
    Derived d;
    print_value(d); // slicing случился при копировании в параметр
}

Исправление (полиморфный вариант) почти всегда такое:

#include <iostream>

struct Base {
    virtual int value() const { return 1; }
};

struct Derived : Base {
    int value() const override { return 2; }
};

void print_value(const Base& b) {       // <-- по ссылке
    std::cout << b.value() << '\n';     // 2
}

int main() {
    Derived d;
    print_value(d);
}

Хранение в контейнере: std::vector<Base>

Этот случай особенно обидный: вы хотели «коллекцию фигур/команд/операций», и рука тянется написать std::vector<Base>. Компилятор не против. Но каждый раз, когда вы кладёте туда Derived, вы кладёте туда срезанную копию базовой части.

Мини-демо:

#include <iostream>
#include <vector>

struct Base {
    virtual const char* name() const { return "Base"; }
};

struct Derived : Base {
    const char* name() const override { return "Derived"; }
};

int main() {
    std::vector<Base> v;
    v.push_back(Derived{});
    std::cout << v[0].name() << '\n'; // Base
}

Даже если вам очень хочется сказать «ну это же всё ещё Derived внутри!» — нет. Внутри лежит именно Base. Просто созданный из Derived.

3. Как работает slicing на пальцах

Сейчас мы сделаем небольшую паузу, потому что важно не просто запомнить «нельзя так делать», а понимать, что именно делает язык. Тогда вы будете находить slicing глазами в коде, а не по симптомам в рантайме.

У объекта Derived есть базовая часть (иногда говорят “base subobject”): кусок объекта, который соответствует полям и поведению Base как части Derived. Это нормальная и полезная идея: ведь Derived действительно «является» Base, и его можно использовать там, где ждут базовый интерфейс.

Но когда вы пишете Base b = derived;, компилятор интерпретирует это не как «сделай b ссылкой на derived». Он интерпретирует это как «создай новый объект Base и скопируй в него базовую часть derived».

Можно представить себе схему:

flowchart LR
    D["Derived object (Base part + Derived part)"]
    B["Base object (only Base part)"]

    D -- "копирование по значению в Base" --> B

После этого у вас есть два разных объекта: исходный Derived и новый Base. И у нового Base нет никаких шансов «вспомнить», что он был получен из наследника.

4. Пример из приложения: команды TaskApp

Теперь давайте сделаем то, ради чего всё это затевалось: посмотрим, как slicing проявляется в прикладном коде, который хотя бы отдалённо похож на программу, а не на музей из трёх классов.

Представим, что мы развиваем консольное приложение TaskApp: оно хранит задачи и умеет выполнять команды. На предыдущей лекции мы решили сделать базовый класс Command с виртуальным методом run(), чтобы разные команды выполнялись единым образом.

Базовый класс и две команды

Сначала — нормальные классы:

#include <string>

struct Command {
    virtual std::string name() const { return "command"; }
    virtual void run() const {}
};

И две конкретные команды:

#include <iostream>
#include <string>

struct HelpCommand : Command {
    std::string name() const override { return "help"; }
    void run() const override { std::cout << "Help!\n"; } // Help!
};

struct AddCommand : Command {
    std::string name() const override { return "add"; }
    void run() const override { std::cout << "Add task...\n"; } // Add task...
};

Пока всё хорошо: есть виртуальные методы, есть override, задумка ясна.

Ошибка дизайна: хранение по значению

А теперь — то, что реально может сделать новичок: «ну команды же — это просто объекты, сложу их в std::vector<Command>».

#include <vector>

void demo_bad_storage() {
    std::vector<Command> commands;
    commands.push_back(HelpCommand{});
    commands.push_back(AddCommand{});
}

С виду — прекрасно. Но внутри происходит slicing на каждом push_back. Поэтому, если мы попробуем запустить команды:

#include <iostream>
#include <vector>

void run_all(const std::vector<Command>& commands) {
    for (const Command& c : commands) {
        std::cout << c.name() << ": ";
        c.run();
    }
}

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

Почему это особенно неприятно

Самое неприятное в slicing — не то, что он существует. А то, что он производит правдоподобно неправильный результат.

Программа не падает. Ошибки компиляции нет. Вы просто видите, что «почему-то всегда печатается command и ничего не выполняется». И начинаете подозревать:

  • что virtual «не работает»,
  • что override «сломался»,
  • что C++ «странный язык».

Хотя на самом деле C++ просто буквально выполнил то, что вы попросили: сложил в вектор объекты Command, а не «что-то производное».

5. Как избежать slicing

Сейчас будет практический вывод, но не в стиле «запомните и не думайте», а в стиле «делайте так, потому что это отражает смысл».

Если вам нужно полиморфное поведение, вы почти всегда хотите работать не с объектом Base по значению, а с ссылкой/указателем на Base, которые могут ссылаться на объект наследника.

Полиморфный интерфейс функции: const Base&

Самая частая починка — поменять Base на const Base& в параметре:

#include <iostream>

struct Base { virtual int value() const { return 1; } };
struct Derived : Base { int value() const override { return 2; } };

void print(const Base& b) {
    std::cout << b.value() << '\n'; // 2 для Derived
}

Такой подход отлично работает, когда вам не нужно владение и хранение «на потом», а нужно просто «обработать объект прямо сейчас».

Полиморфное хранение через владельца: std::unique_ptr<Base>

Хранение полиморфных объектов в контейнере почти всегда означает: «нужны указатели/владельцы». Из уже знакомых нам инструментов самый «здоровый по умолчанию» — std::unique_ptr.

Минимальная идея выглядит так:

#include <memory>
#include <vector>

std::vector<std::unique_ptr<Command>> commands;
commands.push_back(std::make_unique<HelpCommand>());
commands.push_back(std::make_unique<AddCommand>());

А запускать можно так:

for (const auto& cmd : commands) {
    cmd->run(); // виртуальный вызов по реальному типу
}

Обратите внимание, что здесь нет slicing: в контейнере лежат не объекты Command, а владельцы, которые хранят реальные объекты HelpCommand и AddCommand.

(Мы здесь сознательно не углубляемся в стратегию владения, копирование полиморфных объектов и прочие взрослые темы: сейчас достаточно понять сам принцип «полиморфизм ≠ хранение по значению».)

Правило-предохранитель

Если у вас есть базовый класс с виртуальными методами, и вы где-то видите std::vector<Base> или параметр f(Base b), то воспринимайте это как сигнал тревоги. Не обязательно ошибка — но очень вероятно, что вы случайно выключили полиморфизм.

6. Типичные ошибки при object slicing

Ошибка №1: ожидать, что virtual «магически работает всегда».
Виртуальность работает тогда, когда вызов идёт через ссылку или указатель на базовый тип и при этом реально существует объект наследника. При slicing вы создаёте отдельный объект Base, и вызывать там уже нечего — кроме Base.

Ошибка №2: принимать Base по значению «потому что так проще».
Это частая ловушка, потому что параметры по значению кажутся «универсальными». Но для полиморфизма это почти всегда неверный контракт: вы не просто принимаете аргумент, вы просите сделать копию базовой части. Правильная привычка: полиморфные параметры обычно принимают как const Base&.

Ошибка №3: хранить наследников в std::vector<Base> и удивляться, что поведение базовое.
Контейнер по значению хранит именно элементы своего типа. Это значит, что std::vector<Base> хранит Base. Если вы положили туда Derived, то вы положили туда «копию Base-части Derived». Полиморфизм не пропал — он просто не применим к объекту, который больше не является наследником.

Ошибка №4: «починить» slicing добавлением ещё одного override или переписыванием virtual.
override помогает поймать несовпадение сигнатур, но не чинит логику хранения/копирования. Если проблема в том, что вы создали новый объект Base, то сколько ни полируй virtual, объект Derived обратно не «вырастет».

Ошибка №5: пытаться бороться со slicing «костылями» вроде ручного копирования полей наследника.
Это обычно превращается в ситуацию «я сам себе компилятор и рантайм». Если вам нужен полиморфизм — храните/передавайте через ссылки или указатели. Если вам нужно хранить значение — тогда честно признайте, что вы храните Base, и не ждите поведения Derived.

1
Задача
C++ SELF, 51 уровень, 1 лекция
Недоступна
Два способа
Два способа
1
Задача
C++ SELF, 51 уровень, 1 лекция
Недоступна
Команды вектором
Команды вектором
1
Задача
C++ SELF, 51 уровень, 1 лекция
Недоступна
Выбор объекта
Выбор объекта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ