Embercode
✕
Object-Oriented ProgrammingLesson 12 of 17

45 min read

Object slicing and the Object class

Why it matters

Objects in a class hierarchy often have more data than their base class knows about. A Dog is an Animal, and it also has a breed. Code that works with animals usually does not care about breeds, so it is tempting to store a dog in a plain Animal variable and move on. In C++ that quiet step throws away the part of the dog the base class does not have. The bug does not stop the program. It only makes the dog act like a generic animal. This is called object slicing, and it is one of the most common surprises for people who learn inheritance in C++.

The second half of this lesson covers Java. Java has no slicing problem in the same form, because a Java object variable always refers to the whole object. Java does have a special base class, named Object, that every class inherits from. It gives each object a few methods, and the defaults are often not what a beginner expects. The C++ equivalents of these methods are different, so the lesson shows both.

The earlier lessons in this module covered inheritance and polymorphism, where a base pointer runs the derived version of a virtual method. This lesson is about what happens when the value is copied, rather than pointed to.

The idea: a copy keeps only the base part

Think of a shipping box. A dog arrives in a box that has room for an animal's name and a sound. The breed card does not fit in that box. When you pack a dog into a box sized for animals, the box is closed, the breed card is left behind, and nothing in the box says that a card was left out. The box holds an animal. It looks like a dog from the outside only if you remember what was packed.

C++ copies work the same way. When you write Animal copy = rex; where rex is a Dog, C++ creates a new Animal and copies only the Animal part of rex into it. The breed member has no place in an Animal, so it is not copied. The new object is a real Animal with a real name and a real sound function, but it is not a dog any more.

The copy also loses the dog's version of virtual functions. A virtual function is a method that C++ chooses at run time, based on the real object. After slicing, the real object is an Animal, so the Animal version runs. That is why slicing looks like a logic bug, not a crash.

Example 1: copying into a base value

Here is a small program with a base class Animal and a derived class Dog. Dog adds a breed member and overrides sound. The program makes a dog, copies it into an Animal, and then reaches the same dog through a pointer and a reference.

C++ · editable

Expected output:

copy sound: ...
copy name: Rex
pointer sound: Woof
reference sound: Woof

The line Animal copy = rex; is the slice. The name survived, because name is a member of Animal. The sound did not, because the copy's real type is Animal, so copy.sound() runs Animal::sound and prints the three dots. The pointer and the reference both point at the original rex, so sound runs the Dog version and prints Woof.

Note that rex.breed is not printed here, because copy has no breed member at all. Trying to write copy.breed is a compile error, and the mistakes section shows its message.

There is one detail in this program that is easy to miss. Animal has a virtual destructor, written as virtual ~Animal() = default;. A destructor is the function that runs when an object ends. Without virtual, deleting a dog through an Animal pointer is undefined behaviour, and in practice only the Animal destructor runs. Any cleanup in Dog would then be skipped. The virtual destructor makes sure that the right cleanup runs.

Example 2: passing and storing by value

Slicing happens in more places than a plain assignment. It happens every time an object is copied into a variable of a base type. Two common places are passing an argument by value and storing values in a container.

The next program shows both. It has one function that takes an Animal by value and one that takes it by reference. It also has two containers: a std::vector of Animal values and a std::vector of std::unique_ptr<Animal>. A std::unique_ptr is a smart pointer. It owns an object on the heap and deletes it automatically when the pointer goes away.

C++ · editable

Expected output:

by value: ...
by ref: Woof Lab
vector of values: ...
vector of pointers: Woof Lab

The by-value function receives a new Animal built from rex, so it prints .... The by-reference function receives the original dog, so it prints Woof Lab. The same rule applies to the vectors. std::vector<Animal> holds objects of type Animal, so push_back(rex) slices the dog. std::vector<std::unique_ptr<Animal>> holds pointers, and each pointer owns a whole Dog created by std::make_unique<Dog>(rex). That is the form to use when a container must keep a mix of derived types.

A rule that helps here: when a function's parameter type is a base class, take it by const reference unless the function really needs its own copy. The by-value form is the one that slices. The by-reference form costs nothing extra and keeps the object whole.

Example 3: a copy that is wanted

Slicing is not always a bug. Sometimes a caller wants only the base part. For example, a logging function may need only a name, and a copy of the base part is cheap and safe. The problem is that the copy happens by accident in many programs, so a reader cannot tell whether the author meant it. The way to make the intent clear is to write the base type on purpose and to say so in a comment or a name.

This short program makes the intent visible. A function named base_copy returns an Animal by value and says so in its name. The caller knows that only the base part comes back.

C++ · editable

Expected output:

Rex
...

The function base_part_of takes a const Animal&, so the caller can pass a Dog. The copy happens inside the return statement, and the result is an Animal. The name base_part_of tells a reader that the result is an Animal and that the dog part is not kept. If the function had simply returned an Animal with a name like copy, a reader would have to check the body to learn whether the slice was meant.

How a virtual call chooses its version

The examples above rely on one rule. A virtual call uses the type of the object the call is made on. Here is the rule in order.

If the call is made through a pointer or a reference, the object is the one the pointer or reference refers to. That object still has its full type, so the derived version runs.

If the call is made on a plain value, such as copy.sound() where copy is an Animal object, the object's type is Animal. Any derived part was lost when the value was created, so the base version runs.

This is why the rule of thumb is to avoid creating base values from derived objects. Use a pointer, a reference or a std::unique_ptr to keep the full object.

Common mistakes

Storing a derived object in a base value

The most common mistake is writing a base type where a derived object goes. This compiles without any warning under -Wall, and the program runs, so the mistake is silent. The result shows up later, when a method returns the base version and the derived version was expected.

Reading a derived member through a base value

After slicing, the derived members are not in the object at all, and the compiler says so. The file below copies a Dog into an Animal and then reads breed from the copy.

#include <iostream>
#include <string>
 
struct Animal {
    std::string name = "unnamed";
    virtual ~Animal() = default;
    virtual std::string sound() const { return "..."; }
};
 
struct Dog : Animal {
    std::string breed = "mixed";
    std::string sound() const override { return "Woof " + breed; }
};
 
int main() {
    Dog rex;
    rex.breed = "Lab";
    Animal copy = rex;
    std::cout << copy.breed << "\n";
    return 0;
}

Running g++ -std=c++20 -Wall broken_slice.cpp -o a3 prints this error. The file name is the one used when the command was run.

broken_slice.cpp:19:23: error: 'struct Animal' has no member named 'breed'
   19 |     std::cout << copy.breed << "\n";
      |                       ^~~~~

The message is a useful hint. It says the type of copy is Animal, and Animal has no breed. The fix is to use a pointer or a reference, or to avoid the copy, not to add breed to Animal.

Passing by value out of habit

A function that takes a base type by value slices any derived argument, and it does so without any message. Taking the parameter as const Animal& avoids the copy, keeps the object whole and costs nothing extra. Example 2 showed the two forms side by side.

Expecting the copy to be a dog

A sliced object can still have a name and a sound, so the program may appear to work for simple tests. The mistake appears only when the sound or the behaviour of a derived class is needed. A useful habit is to print a value from the derived class after every copy in a small test, the way Example 1 does with copy.sound().

Part 2: the Object class in Java

The methods every Java class inherits

In Java every class is a child of a class named Object, even when the source code does not mention it. If a class does not extend another class, it extends Object implicitly. Because of this, every Java object has the same small set of methods. The ones that beginners meet most are the following.

equals(Object other) answers whether two objects are the same. The default version compares object identity, which means it returns true only when both names refer to the same object in memory. Two separate objects with the same field values are not equal under the default rule.

hashCode() returns an integer that is used by hash-based collections, such as a HashMap or a HashSet. The default version gives each object a number that is based on its identity, not on its fields.

toString() returns a text form of the object. The default version returns the class name, an @ sign and a hexadecimal number, so printing a plain object gives a line such as Box@1b6d3586. The exact number depends on the JVM, so another machine may print a different one.

getClass() returns a value that describes the object's class at run time. Its getName() method gives the name, and a nested class shows its outer class name with a dollar sign between them.

clone() makes a field-by-field copy of an object. It is protected in Object, so a class must choose to make it available. Its use is rare in modern Java, because copy constructors and factory methods are clearer.

finalize() was once a way to run cleanup when the garbage collector removed an object. It is deprecated, and the Java team has marked it for removal, so new code should not use it.

wait(), notify() and notifyAll() let threads wait for and signal each other while holding a lock on an object. They are advanced topics that belong to threaded code.

Two of these methods are used very often. A class that stores objects in a HashSet should override equals and hashCode together, so that equal objects produce the same hash. A class whose objects are printed in logs or tests should override toString so the output says something useful.

Why the defaults matter

The default equals is identity. A program that compares two objects with == checks identity too. A program that compares them with equals gets identity unless the class overrides it. This is a frequent source of confusion. Two Point objects with the same coordinates are different objects, so the default equals says they are not equal. The class must say what equal means for its data.

The default toString prints the class name and the hash number. That output does not show any field, so a log line such as Box@1b6d3586 is hard to read. Overriding toString to print the fields makes logs and test failures useful.

The C++ equivalents

C++ has no universal base class, so none of these methods exist on every object. Instead, a C++ class writes the operations it needs. The code below shows the three most common equivalents: an equality operator, a stream operator for printing, and a hash specialization for unordered containers. It also shows the typeid operator, which gives a type's name.

C++ · editable

Expected output:

Point(1, 2)
a == b: 1
a == c: 0
unique points: 2
type name is set: 1

The operator== method plays the role of Java's equals, but it is not a default. A C++ class has no equality unless the author writes one. The stream operator operator<< plays the role of toString for printing. The std::hash specialization plays the role of hashCode for std::unordered_set. Points a and b have equal coordinates, so the set keeps only one of them, and its size is 2.

The typeid(a).name() call returns a string for the type. The text of that string depends on the compiler. With GCC it is a mangled form and not a readable class name, so a program should not rely on its exact contents. The check in the program only confirms that a name is returned.

The comparison between C++ and Java is a useful exercise. In Java, a class gets these methods for free from Object, and the defaults may be wrong for the class. In C++, a class gets none of them, so the author writes only the ones the class needs. Both approaches require the author to decide what equality means for the data.

Practice

Exercise 1

Predict the output of this program, then run it to check.

C++ · editable

Show the answer

The output is shape circle. The value s is a copy of the Shape part of c, so its type is Shape and kind runs the base version. The reference r points at c, so kind runs the Circle version.

Exercise 2

The function below takes a Shape by value, and the program then prints kind of a Circle. Change the function so that it does not slice, and say what the output becomes.

#include <iostream>
#include <string>
 
struct Shape {
    virtual ~Shape() = default;
    virtual std::string kind() const { return "shape"; }
};
 
struct Circle : Shape {
    std::string kind() const override { return "circle"; }
};
 
void describe(Shape s) {
    std::cout << s.kind() << "\n";
}
 
int main() {
    Circle c;
    describe(c);
    return 0;
}

Show the answer

Change the parameter to a const Shape&, so the function refers to the original object. The line becomes void describe(const Shape& s). The body does not change. The output becomes circle, because s.kind() now runs the Circle version. The original version printed shape, because the by-value copy was a plain Shape.

Exercise 3

Write a C++ class Money with a long cents member and an operator== that compares the cents. Show that two Money values with the same cents are equal, and that two with different cents are not.

Show the answer

#include <iostream>
 
class Money {
public:
    explicit Money(long c) : cents_(c) {}
    bool operator==(const Money& other) const { return cents_ == other.cents_; }
private:
    long cents_;
};
 
int main() {
    Money a(250);
    Money b(250);
    Money c(300);
    std::cout << (a == b) << " " << (a == c) << "\n";
    return 0;
}

The output is 1 0. Without the operator== method, the line a == b would not compile, because a C++ class has no default equality. The method compares the stored values, not the object addresses.

Exercise 4

A Java class Box does not override equals. Two Box objects with the same size are placed in a HashSet. Will the set hold one box or two? Explain why, using the words "identity" and "equals" in your answer.

Show the answer

The set holds two boxes. The default equals in Object compares identity, which means whether both names refer to the same object. Two separate Box objects are different objects, so equals returns false, and the set treats them as different entries. To hold one box, Box must override equals to compare sizes, and must override hashCode in the same way, so that equal boxes produce the same hash.

Exercise 5

Explain in two or three sentences what goes wrong when a C++ program stores Dog objects in a std::vector<Animal>, and what changes when it stores them in a std::vector<std::unique_ptr<Animal>> instead.

Show the answer

Each Dog is copied into an Animal when it is pushed, so the breed and the dog version of sound are lost, and calls such as values[0].sound() run the base version. With std::vector<std::unique_ptr<Animal>>, each element is a pointer to a whole Dog, so calls through the pointer run the Dog version. The pointers own the objects, so they are deleted when the vector is destroyed.

Summary

  • Copying a derived object into a base value keeps only the base part. The derived members and the derived virtual behaviour are lost.
  • Use a pointer, a reference or a std::unique_ptr to keep the whole object, and take base parameters by const reference.
  • Virtual calls use the real type of the object they are called on, so a sliced copy always runs the base version.
  • Java gives every class the methods equals, hashCode, toString, getClass, clone, finalize, wait, notify and notifyAll through Object, and their defaults are often not what a class needs.
  • C++ has no universal base class, so a class writes the equality, printing and hashing operations that it needs.
Check yourself: Slicing and Object (7 questions) →Practice this topic: coding problems for Object-Oriented Programming →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Interface design