Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Base Class Pointers and References
Store derived objects in base class pointers for uniform handling.
Pointers and References to the Base Class of Derived Objects
In previous chapters, you learned how inheritance allows you to create derived classes that extend the functionality of base classes. In this chapter, we'll explore one of the most powerful aspects of inheritance: virtual functions.
But first, we need to understand an important concept about how base and derived classes interact through pointers and references.
In the chapter on construction of derived classes, you learned that when you create a derived object, it contains multiple parts: one part for each inherited class, and a part for itself.
Consider this simple example:
#include <string_view>
class Logger
{
protected:
int m_logLevel{};
public:
Logger(int level)
: m_logLevel{ level }
{
}
std::string_view getType() const { return "Logger"; }
int getLogLevel() const { return m_logLevel; }
};
class FileLogger: public Logger
{
public:
FileLogger(int level)
: Logger{ level }
{
}
std::string_view getType() const { return "FileLogger"; }
int getMaxFileSize() const { return m_logLevel * 1000; }
};
When we create a FileLogger object, it contains a Logger part (constructed first), and a FileLogger part (constructed second). Because FileLogger is-a Logger, it's natural that FileLogger contains a Logger part.
Pointers, references, and derived classes
It should be straightforward that we can set FileLogger pointers and references to FileLogger objects:
#include <iostream>
int main()
{
FileLogger fileLogger{ 3 };
std::cout << "fileLogger is a " << fileLogger.getType() << " with level " << fileLogger.getLogLevel() << '\n';
FileLogger& rFileLogger{ fileLogger };
std::cout << "rFileLogger is a " << rFileLogger.getType() << " with level " << rFileLogger.getLogLevel() << '\n';
FileLogger* pFileLogger{ &fileLogger };
std::cout << "pFileLogger is a " << pFileLogger->getType() << " with level " << pFileLogger->getLogLevel() << '\n';
return 0;
}
This produces the following output:
fileLogger is a FileLogger with level 3 rFileLogger is a FileLogger with level 3 pFileLogger is a FileLogger with level 3
However, since FileLogger has a Logger part, a more interesting question is whether C++ will let us set a Logger pointer or reference to a FileLogger object. It turns out, we can!
#include <iostream>
int main()
{
FileLogger fileLogger{ 3 };
// These are both legal!
Logger& rLogger{ fileLogger };
Logger* pLogger{ &fileLogger };
std::cout << "fileLogger is a " << fileLogger.getType() << " with level " << fileLogger.getLogLevel() << '\n';
std::cout << "rLogger is a " << rLogger.getType() << " with level " << rLogger.getLogLevel() << '\n';
std::cout << "pLogger is a " << pLogger->getType() << " with level " << pLogger->getLogLevel() << '\n';
return 0;
}
This produces the result:
fileLogger is a FileLogger with level 3 rLogger is a Logger with level 3 pLogger is a Logger with level 3
This result may not be quite what you expected!
It turns out that because rLogger and pLogger are a Logger reference and pointer, they can only see members of Logger (or any classes that Logger inherited). So even though FileLogger::getType() shadows (hides) Logger::getType() for FileLogger objects, the Logger pointer/reference cannot see FileLogger::getType(). Consequently, they call Logger::getType(), which is why rLogger and pLogger report that they are a Logger rather than a FileLogger.
Note that this also means it is not possible to call FileLogger::getMaxFileSize() using rLogger or pLogger. They are unable to see anything in FileLogger.
Here's another slightly more complex example that we'll build on in the next lesson:
#include <iostream>
#include <string_view>
#include <string>
class Notification
{
protected:
std::string m_recipient;
// We're making this constructor protected because
// we don't want people creating Notification objects directly,
// but we still want derived classes to be able to use it.
Notification(std::string_view recipient)
: m_recipient{ recipient }
{
}
// To prevent slicing (covered later)
Notification(const Notification&) = delete;
Notification& operator=(const Notification&) = delete;
public:
std::string_view getRecipient() const { return m_recipient; }
std::string_view getFormat() const { return "???"; }
};
class EmailNotification: public Notification
{
public:
EmailNotification(std::string_view recipient)
: Notification{ recipient }
{
}
std::string_view getFormat() const { return "Email"; }
};
class SmsNotification: public Notification
{
public:
SmsNotification(std::string_view recipient)
: Notification{ recipient }
{
}
std::string_view getFormat() const { return "SMS"; }
};
int main()
{
const EmailNotification email{ "user@example.com" };
std::cout << "email to " << email.getRecipient() << ", format: " << email.getFormat() << '\n';
const SmsNotification sms{ "+1234567890" };
std::cout << "sms to " << sms.getRecipient() << ", format: " << sms.getFormat() << '\n';
const Notification* pNotification{ &email };
std::cout << "pNotification to " << pNotification->getRecipient() << ", format: " << pNotification->getFormat() << '\n';
pNotification = &sms;
std::cout << "pNotification to " << pNotification->getRecipient() << ", format: " << pNotification->getFormat() << '\n';
return 0;
}
This produces the result:
email to user@example.com, format: Email sms to +1234567890, format: SMS pNotification to user@example.com, format: ??? pNotification to +1234567890, format: ???
We see the same issue here. Because pNotification is a Notification pointer, it can only see the Notification portion of the class. Consequently, pNotification->getFormat() calls Notification::getFormat() rather than the EmailNotification::getFormat() or SmsNotification::getFormat() function.
Use for pointers and references to base classes
Now you might be saying, "The above examples seem kind of silly. Why would I set a pointer or reference to the base class of a derived object when I can just use the derived object?" It turns out that there are quite a few good reasons.
First, let's say you wanted to write a function that sent a notification and printed its details. Without using a pointer to a base class, you'd have to write it using overloaded functions, like this:
void sendNotification(const EmailNotification& email)
{
std::cout << email.getRecipient() << " via " << email.getFormat() << '\n';
}
void sendNotification(const SmsNotification& sms)
{
std::cout << sms.getRecipient() << " via " << sms.getFormat() << '\n';
}
Not too difficult, but consider what would happen if we had 30 different notification types instead of 2. You'd have to write 30 almost identical functions! Plus, if you ever added a new type of notification, you'd have to write a new function for that one too. This is a huge waste of time considering the only real difference is the type of the parameter.
However, because EmailNotification and SmsNotification are derived from Notification, EmailNotification and SmsNotification have a Notification part. Therefore, it makes sense that we should be able to do something like this:
void sendNotification(const Notification& notification)
{
std::cout << notification.getRecipient() << " via " << notification.getFormat() << '\n';
}
This would let us pass in any class derived from Notification, even ones that we created after we wrote the function! Instead of one function per derived class, we get one function that works with all classes derived from Notification!
The problem is, of course, that because notification is a Notification reference, notification.getFormat() will call Notification::getFormat() instead of the derived version of getFormat().
Advanced note: We could also use a template function to reduce the number of overloaded functions we need to write:
template <typename T>
void sendNotification(const T& notification)
{
std::cout << notification.getRecipient() << " via " << notification.getFormat() << '\n';
}
And while this works, it has its own issues:
- It's not clear what type
Tis supposed to be, as we've lost the documentation thatTis intended to be aNotification. - This function does not enforce that
Tis aNotification. Rather, it will accept an object of any type that contains agetRecipient()andgetFormat()member function, whether that makes sense or not.
Second, let's say you had 3 emails and 3 SMS notifications that you wanted to keep in an array for easy access. Because arrays can only hold objects of one type, without a pointer or reference to a base class, you'd have to create a different array for each derived type, like this:
#include <array>
#include <iostream>
// EmailNotification and SmsNotification from the example above
int main()
{
const auto& emails{ std::to_array<EmailNotification>({{ "alice@example.com" }, { "bob@example.com" }, { "charlie@example.com" }}) };
const auto& smsMessages{ std::to_array<SmsNotification>({{ "+1111111111" }, { "+2222222222" }, { "+3333333333" }}) };
for (const auto& email : emails)
{
std::cout << email.getRecipient() << " via " << email.getFormat() << '\n';
}
for (const auto& sms : smsMessages)
{
std::cout << sms.getRecipient() << " via " << sms.getFormat() << '\n';
}
return 0;
}
Now, consider what would happen if you had 30 different types of notifications. You'd need 30 arrays, one for each type of notification!
However, because both EmailNotification and SmsNotification are derived from Notification, it makes sense that we should be able to do something like this:
#include <array>
#include <iostream>
// EmailNotification and SmsNotification from the example above
int main()
{
const EmailNotification email1{ "alice@example.com" };
const EmailNotification email2{ "bob@example.com" };
const EmailNotification email3{ "charlie@example.com" };
const SmsNotification sms1{ "+1111111111" };
const SmsNotification sms2{ "+2222222222" };
const SmsNotification sms3{ "+3333333333" };
// Set up an array of pointers to notifications
const auto notifications{ std::to_array<const Notification*>({&email1, &sms1, &email2, &sms2, &email3, &sms3 }) };
for (const auto notification : notifications)
{
std::cout << notification->getRecipient() << " via " << notification->getFormat() << '\n';
}
return 0;
}
While this compiles and executes, unfortunately the fact that each element of array "notifications" is a pointer to a Notification means that notification->getFormat() will call Notification::getFormat() instead of the derived class version of getFormat() that we want. The output is:
alice@example.com via ??? +1111111111 via ??? bob@example.com via ??? +2222222222 via ??? charlie@example.com via ??? +3333333333 via ???
Although both of these techniques could save us a lot of time and energy, they have the same problem. The pointer or reference to the base class calls the base version of the function rather than the derived version. If only there was some way to make those base pointers call the derived version of a function instead of the base version...
Want to take a guess what virtual functions are for? :)
Quiz time
- Our Notification/EmailNotification/SmsNotification example above doesn't work like we want because a reference or pointer to a Notification can't access the derived version of getFormat() needed to return the right value for the EmailNotification or SmsNotification. One way to work around this issue would be to make the data returned by the getFormat() function accessible as part of the Notification base class (much like the Notification's recipient is accessible via member m_recipient).
Update the Notification, EmailNotification, and SmsNotification classes in the lesson above by adding a new member to Notification named m_format. Initialize it appropriately. The following program should work properly:
#include <array>
#include <iostream>
int main()
{
const EmailNotification email1{ "alice@example.com" };
const EmailNotification email2{ "bob@example.com" };
const EmailNotification email3{ "charlie@example.com" };
const SmsNotification sms1{ "+1111111111" };
const SmsNotification sms2{ "+2222222222" };
const SmsNotification sms3{ "+3333333333" };
const auto notifications{ std::to_array<const Notification*>({ &email1, &sms1, &email2, &sms2, &email3, &sms3 }) };
for (const auto notification : notifications)
{
std::cout << notification->getRecipient() << " via " << notification->getFormat() << '\n';
}
return 0;
}
#include <array>
#include <string>
#include <string_view>
#include <iostream>
class Notification
{
protected:
std::string m_recipient;
std::string m_format;
// We're making this constructor protected because
// we don't want people creating Notification objects directly,
// but we still want derived classes to be able to use it.
Notification(std::string_view recipient, std::string_view format)
: m_recipient{ recipient }, m_format{ format }
{
}
// To prevent slicing (covered later)
Notification(const Notification&) = delete;
Notification& operator=(const Notification&) = delete;
public:
std::string_view getRecipient() const { return m_recipient; }
std::string_view getFormat() const { return m_format; }
};
class EmailNotification: public Notification
{
public:
EmailNotification(std::string_view recipient)
: Notification{ recipient, "Email" }
{
}
};
class SmsNotification: public Notification
{
public:
SmsNotification(std::string_view recipient)
: Notification{ recipient, "SMS" }
{
}
};
int main()
{
const EmailNotification email1{ "alice@example.com" };
const EmailNotification email2{ "bob@example.com" };
const EmailNotification email3{ "charlie@example.com" };
const SmsNotification sms1{ "+1111111111" };
const SmsNotification sms2{ "+2222222222" };
const SmsNotification sms3{ "+3333333333" };
const auto notifications{ std::to_array<const Notification*>({ &email1, &sms1, &email2, &sms2, &email3, &sms3 }) };
for (const auto notification : notifications)
{
std::cout << notification->getRecipient() << " via " << notification->getFormat() << '\n';
}
return 0;
}
- Why is the above solution non-optimal?
Hint: Think about the future state of EmailNotification and SmsNotification where we want to differentiate them in more ways. Hint: Think about the ways in which having a member that needs to be set at initialization limits you.
The current solution is non-optimal for a number of reasons.
First, we need to add a member to Notification for each way we want to differentiate EmailNotification and SmsNotification. Over time, our Notification class could become quite large memory-wise, and complicated!
Second, this solution only works if the base class member can be determined at initialization time. For example, if getFormat() returned a randomized result for each Notification (e.g. calling SmsNotification::getFormat() could return "SMS", "Text", or "Message"), this kind of solution starts to get awkward and fall apart.
Third, because getFormat() is a member of Notification, getFormat() will have the same behavior for EmailNotifications and SmsNotifications (that is, it will always return m_format). If we wanted getFormat() to have different behavior for EmailNotifications and SmsNotifications (e.g. have SmsNotifications return a random format), we'd have to put all that extra logic in Notification, which makes Notification even more complex.
Summary
Base pointers to derived objects: C++ allows setting a base class pointer or reference to a derived class object because the derived object contains a base class part. This is valid because a derived object "is-a" base object, satisfying the is-a relationship that inheritance models.
Limited visibility: When accessing a derived object through a base class pointer or reference, only members visible in the base class can be accessed. The pointer/reference type determines what members are visible, not the actual type of the object being pointed to. This means derived-specific members and overridden functions cannot be accessed through a base pointer/reference.
Function resolution with pointers: When calling a member function through a base class pointer/reference, the base class version is called, even if the pointer/reference is pointing to a derived object. This occurs because the compiler determines which function to call based on the pointer/reference type (compile-time), not the actual object type (runtime).
Use case for base pointers: Base class pointers and references enable powerful programming techniques. They allow writing single functions that work with any derived type (instead of overloaded functions for each type), storing objects of different derived types in the same container (array, vector), and reducing code duplication across class hierarchies.
The limitation: The problem with using base class pointers/references is that calling a function through them always resolves to the base class version, not the derived class version, even when pointing to a derived object. This limits their usefulness for polymorphic behavior unless combined with virtual functions (covered in the next lesson).
Understanding how base class pointers and references interact with derived objects sets the foundation for comprehending virtual functions, which solve the function resolution limitation and enable true runtime polymorphism.
Base Class Pointers and References - Quiz
Test your understanding of the lesson.
Practice Exercises
Base Class Pointers and References
Practice using base class pointers and references to access derived objects. Understand how the type of pointer/reference determines which member functions are called.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!