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 | нет | нет |
|
на тот же объект 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ