C++ Part I · Module 17 · Runtime Polymorphism
Object Slicing
Chapter 16.2 measured that copying a derived object into a base object copies only the base part, and presented it as an unremarkable consequence of layout: a twelve-byte object going into an eight-byte one, with nowhere for the remainder to go. That framing was honest and it understated the problem, because at the time the discarded part was just data. Once the hierarchy is polymorphic the discarded part includes the object's identity — the pointer that tells a virtual call which implementation to reach — so the copy is not merely incomplete, it is a different object that answers questions differently. This chapter measures that across every route by which it happens, and the routes are more numerous than people expect: a by-value parameter, a copy-initialisation, an assignment to an existing base object, and a container declared to hold base objects. The results are identical in all four, and three things about them are worth knowing before you start. The resulting object is perfectly valid — it is a base object, it is not corrupt, and nothing will crash. The base part is copied faithfully, so the data that survives is correct, which makes the result look right. And nothing warns, at any level. The size does not even give it away: in the measured case the derived class is exactly the same number of bytes as the base, because its extra member fits in padding the base was already carrying.
Advanced18 min readC++Part IFundamentalsPolymorphism
Part I · Module 17 · Chapter 17.6 · Object Slicing
1. The Setup
struct Model {
int id = 1;
virtual ~Model() = default;
virtual const char* name() const { return "Model"; }
};
struct FastModel : Model {
int extra = 99;
const char* name() const override { return "FastModel"; }
};A concrete polymorphic base — which is the condition for everything in this chapter, as §9 explains. In the measurements f.id is set to 7, so the base part is distinguishable from a default.
2. What Chapter 16.2 Established
Chapter 16.2 §5 measured the layout consequence:
a
Transactionobject is 8 bytes; there is no field in one that could hold amask.
True, mechanical, and incomplete. What that chapter's types did not have was a virtual function, so the part being discarded was only data. Here it includes the table pointer from Chapter 17.2 §3 — the thing that makes a virtual call find FastModel::name.
3. One Object, Two Parts, One Copy
The copy is made by Model's copy constructor, which copies Model's members. It is not defective and it is not truncated — it does exactly its job. The problem is that its job does not include being a FastModel.
4. Route One: A By-Value Parameter
static void byRef (const Model& m) { /* prints m.name() */ }
static void byValue(Model m) { /* prints m.name() */ }B. byRef -> name()=FastModel
C. byValue -> name()=ModelOne character of difference in the signature, and the function receives a different object. byRef saw the FastModel; byValue received a Model constructed from its base part.
This is the most common route because it is the easiest to write by accident — a parameter list is where & goes missing.
5. Route Two: Copy-Initialisation
Model m = f;D. Model m = f; sizeof(m)=16 m.name()=ModelSame result, and note sizeof(m) is 16 — §8 is about why that number is not the clue it looks like.
6. Route Three: Assignment to an Existing Base Object
Model m2;
m2 = f;E. m2 = f; m2.id=7 m2.name()=ModelThis is the measurement that states the bug most precisely. m2.id is 7 — the base part was copied faithfully, including the value that distinguishes f from a default-constructed Model. And m2.name() is Model.
So the result is not garbage and not partially-initialised. It is a correct Model holding f's base data and Model's behaviour — a combination that corresponds to no object in the program.
Slicing copies the state and drops the identity. The data that survives is right, which is what makes the result convincing.
A detail worth noting: Model::operator= is what ran. Chapter 13.3 established the copy-assignment signature, and Model's takes a const Model& — so f was converted to its base part before assignment even began. There is no point at which anything could have objected.
7. Route Four: A Container of Base Objects
std::vector<Model> v;
v.push_back(f);F. vector<Model> v; v.push_back(f); v[0].name()=ModelEvery element of a std::vector<Model> is a Model, so storing a FastModel slices it on the way in. This is the route that does the most damage in practice, because it slices every element, silently, at every insertion — and the container looks like it is holding models polymorphically.
The fix is the one Chapter 14.1 §9 measured:
G. vector<Model*> pv; pv.push_back(&f); pv[0]->name()=FastModelA container of pointers preserves identity, because every element is the same size whatever it points at. In real code that is std::vector<std::unique_ptr<Model>> — Chapter 14.8 §6's table — rather than raw pointers.
8. The Size Does Not Reveal It
A reasonable expectation is that slicing shows up as a size difference. Measured, it does not:
A. sizes: Model=16 FastModel=16The same number of bytes. The reason:
sizeof(Model)=16 sizeof(FastModel)=16 sizeof(Fatter)=32
offset of id=8 extra=12Model is a table pointer (8) plus id (4) plus 4 bytes of padding to reach 16 — Chapter 9.8 §6's arithmetic. FastModel puts extra at offset 12, inside that padding, so it needs no additional storage. A Fatter derived class with two longs is 32, which does differ.
So sizeof is not a slicing detector. Neither is anything else:
0 own-file warnings at -Wall -Wextra -WpedanticNothing at any level mentions it, in any of the four routes. This is the ninth finding across Modules 15 to 17 where the interesting failure draws no diagnostic.
9. The Fix That Makes It Impossible
Chapter 17.4 §9 measured it:
Model m = r; -> error: variable type 'Model' is an abstract classMake the base abstract and every route in this chapter becomes a compile error. There is no Model object for a derived object to be sliced into, so §4's by-value parameter cannot be defined or called, §5's copy cannot be written, §6's assignment has no left-hand side, and §7's container cannot construct an element.
That is the complete fix, and it costs two characters. Everything else in §10 is advice for hierarchies where the base is concrete for a reason.
10. If the Base Must Be Concrete
Pass by const& or by pointer, always — Chapter 6.5's rule, with a second reason.
Hold collections as std::vector<std::unique_ptr<Base>>.
Consider deleting the base's copy operations:
struct Model {
Model(const Model&) = delete;
Model& operator=(const Model&) = delete;
virtual ~Model() = default;
// ...
};Chapter 12.4 §7 established = delete as a way to remove an operation. Measured against all four routes:
base copy ops DELETED
BYVAL : call to deleted constructor of 'Model'
COPYINIT : call to deleted constructor of 'Model'
ASSIGN : overload resolution selected deleted operator '='
VECTOR : call to deleted constructor of 'Model'All four refused. Two declarations close every route in this chapter even with a concrete base — and it is often the right call, because a polymorphic base is rarely something you want to copy by value anyway.
Or make them protected, which is the better choice when derived classes legitimately copy themselves:
base copy ops PROTECTED
BYVAL : calling a protected constructor of class 'Model'
COPYINIT : calling a protected constructor of class 'Model'
ASSIGN : 'operator=' is a protected member of 'Model'
VECTOR : calling a protected constructor of class 'Model'Also all four, and the difference is what it still permits:
Fast b = a; ok, b.name()=Fast extra=99A derived class copies itself normally. Fast's copy constructor may call the base's, because it is a derived class and Chapter 16.2 §7's access table gives it protected members — while outside code cannot construct a Model from a Fast at all. That is Chapter 16.4's protected is for derived classes rule used for exactly what it is good at: the one audience that should be allowed to copy a base part is the one implementing a derived copy.
And where copying a polymorphic object is genuinely needed, the conventional answer is a virtual clone():
virtual std::unique_ptr<Model> clone() const = 0;Each implementation returns a copy of its own type, so no identity is lost. Measured on a container of two different models:
std::unique_ptr<Model> clone() const override { return std::make_unique<FastModel>(*this); }A. the originals:
FastModel id=7
SlowModel id=1
B. a deep copy of the whole container, via clone():
FastModel id=7
SlowModel id=1
C. independence -- change the copy, check the original:
copy id=999 original id=7
distinct objects? 1Both types survived the copy, with their base data intact — compare §7, where the same container copied by value gave Model for every element. And C confirms the copies are genuinely separate objects rather than shared references: mutating one left the other at 7.
The mechanism is worth seeing because it is the chapter's whole problem turned around. Each clone makes its copy inside a function that knows its own type, so *this is a FastModel and make_unique<FastModel> copies all of it. The call site knows only Model and never needs to — it receives an owning pointer and the identity is already preserved.
Chapter 17.2 §9's note on covariant return types applies if you want FastModel::clone to be statically typed as returning a FastModel; for a container of base pointers the Model return is what you want anyway.
11. What to Actually Do
Make polymorphic bases abstract — measured, it makes all four routes compile errors.
Pass by const Base& or Base*, never by value.
Store std::vector<std::unique_ptr<Base>>, never std::vector<Base>.
Delete or protect the base's copy operations if the base must be concrete.
Provide a virtual clone() where copying a polymorphic object is genuinely needed.
Do not expect sizeof to reveal it — measured, both types were 16 bytes.
And do not expect a warning — measured, none at any level, on any route.
12. Mistakes Worth Making Once
A by-value base parameter. Measured: the function received a Model, not the FastModel.
Base b = derived; Measured: name() reverted to the base's.
baseObj = derived; Measured: id=7 copied, identity lost — the most convincing version.
std::vector<Base>. Slices every element on insertion, silently.
Reasoning from sizeof. Measured: both 16 bytes, because the derived member fit in the base's padding.
Expecting a crash or corruption. The result is a valid base object.
Testing only the data. It is correct; only the behaviour is wrong.
Leaving a concrete polymorphic base copyable when nothing needs to copy it.
Writing a non-virtual clone() — it would slice in exactly the same way.
13. Interview Relevance
"What is object slicing?" Copying a derived object into a base object copies only the base part, so the result is a base object and virtual calls reach the base's implementations. The strong answer stresses that the result is valid, not corrupt.
"Where does it happen?" A by-value parameter, a copy-initialisation, an assignment to a base object, and a container of base values. Naming all four distinguishes.
"Does the compiler warn?" No — measured, nothing at any level.
"Would sizeof reveal it?" Not necessarily — measured, the derived type was the same size because its extra member sat in the base's tail padding. A good discriminator.
"How do you prevent it?" Make the base abstract, which turns every route into a compile error. Failing that, delete or protect the base's copy operations, and pass by reference.
"How do you copy a polymorphic object on purpose?" A virtual clone() returning an owning pointer.
"Why does polymorphism require a reference or pointer?" Because by value you get a different object — this is the measurement, not a convention.
14. What You Should Be Able to Say Now
- Slicing copies the base part and discards the derived part, including the table pointer that selects implementations.
- Chapter 16.2 §5's layout fact becomes a behavioural bug once the hierarchy is polymorphic.
- Measured, route one: a by-value parameter received
name()=Modelwhere aconst¶meter receivedFastModel. - Measured, route two:
Model m = f;gavename()=Model. - Measured, route three:
m2 = f;gaveid=7andname()=Model— state copied, identity dropped. - The result is a valid base object, not corrupt and not partially initialised.
- Measured, route four:
vector<Model>sliced on insertion;vector<Model*>preservedFastModel. - Measured: both types were 16 bytes — the derived member sat in the base's tail padding at offset 12.
- So
sizeofis not a slicing detector. - Measured: 0 warnings at any level, on every route — the ninth such finding across three modules.
- Measured: an abstract base makes all four routes compile errors — the complete fix, two characters.
- If the base must be concrete: pass by
const&, store owning pointers, and delete or protect the copy operations. - A virtual
clone()returning an owning pointer is how you copy polymorphically. - The surviving data is correct, which is why data-only tests pass.
- The reason polymorphism needs a reference or pointer is §4's measurement, not a style rule.
- The repair is subtractive, like every other inheritance repair in Modules 16 and 17.
15. Exercises
Exercise 1 — the four routes. Write one line of code for each route by which slicing occurs.
Exercise 2 — predict the output. For a Shape base with virtual area() returning 0 and a Square returning side*side, give area() for a by-value parameter, a copy-initialisation, and a vector<Shape> element.
Exercise 3 — the precise statement. Explain what baseObj = derived; copies and what it does not, using the measured id=7 result.
Exercise 4 — the size trap. Explain why the measured types were both 16 bytes, and construct a derived class for which the sizes do differ.
Exercise 5 — the complete fix. Make the base abstract and say what each of the four routes now reports.
Exercise 6 — the concrete-base fix. Given a base that must be concrete, write the two declarations that make slicing a compile error.
Exercise 7 — clone. Write a virtual clone() for a two-level hierarchy, with the right return type and ownership.
Exercise 8 — why non-virtual clone fails. Say what a non-virtual clone() returning Base by value would do.
Exercise 9 — the pattern. Name four repairs from Modules 16 and 17 and say what each one removes.
16. Summary
- Slicing copies the base part and drops the derived part, including the identity that selects virtual implementations.
- Measured across four routes — by-value parameter, copy-initialisation, assignment, container of values — with the same result every time.
- Measured, the precise version:
m2 = f;gaveid=7andname()=Model— correct data, wrong behaviour. - The result is a valid base object, which is why nothing crashes and data-only tests pass.
- Measured: both types were 16 bytes, the derived member sitting in the base's tail padding — so
sizeofreveals nothing. - Measured: 0 warnings at any level, on every route.
- Measured: an abstract base makes all four routes compile errors — the complete fix, for two characters.
- Otherwise:
const¶meters,vector<unique_ptr<Base>>, deleted or protected copy operations, and a virtualclone(). - Polymorphism requires a reference or a pointer because by value you get a different object.
Two of the three classic failures are now measured, and both repairs were free. What has been taken on trust throughout is the cost of the mechanism itself. Chapter 17.1 measured eight bytes per object and deferred the rest; Chapter 17.2 measured three instructions and deferred what they are worth; Chapter 17.3 measured final collapsing a call to two instructions and deferred what that saves. The next chapter settles all of it, and the answer is more interesting than either of the usual claims. Timed properly against a non-inlinable direct call, the indirection costs about six picoseconds — one per cent, which is to say nothing. Timed against a call the compiler was able to inline, the same virtual call is three times slower. Both numbers are real and they are measuring different things, and knowing which one applies to your situation is the difference between a reasonable design decision and a superstition.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
