01 Introduction“Imagine you are running an animal shelter with both cats and dogs. Now you want to manage all the animals using a list, and every day you call out ‘Dinner time’, and all the animals start eating. How would you implement this scenario in C++ code?”Question: Cats and dogs are different classes; how can they be placed in a unified list?This article will guide you through a small story to easily understand the very important concept of type conversion in C++, helping you know when to use it and how to use it safely.02 Upcasting – A Natural “Reunion”First, understand the relationship: a cat is an animal, and a dog is also an animal, so both cats and dogs can be classified as animals.
class Animal { public: virtual void eat() { ... } };class Dog : public Animal { ... };class Cat : public Animal { ... };
What is upcasting? It is treating a derived class pointer (Dog*) as a base class pointer (Animal*). This is like putting a specific “dog” into an “animal” cage, which is completely reasonable.Why is upcasting needed?Assuming our shelter has both cats and dogs, we need to manage them and let them eat.This scenario definitely requires polymorphism, so the class definition relationship is as follows:
#include <iostream>#include <vector>#include <string>// Common base class and derived class definitionsclass Animal {public: virtual void eat() const { std::cout << "The animal is eating..." << std::endl; } virtual ~Animal() {}};class Dog : public Animal {public: void eat() const override { std::cout << "The dog is gnawing on a bone..." << std::endl; }};class Cat : public Animal {public: void eat() const override { std::cout << "The cat is eating dried fish..." << std::endl; }};
Scenario 1: Clumsily using polymorphism (utilizing upcasting only during calls)In this scenario, the programmer understands that polymorphism can occur during function calls, but still tends to categorize by specific types at the data management level.
// This is a function that implements polymorphism and can accept any subclass of Animalvoid feedAnimal(const Animal* animal) { animal->eat(); // Polymorphism takes effect here!}void manageShelter_Clumsy() { // Data storage is still separated by specific types std::vector<Dog*> dogs; dogs.push_back(new Dog()); dogs.push_back(new Dog()); std::vector<Cat*> cats; cats.push_back(new Cat()); std::cout << "--- Dinner time (Clumsy Way) ---" << std::endl; // Logic processing still requires separate loops for (Dog* dog : dogs) { // At this moment of function call, implicit upcasting from Dog* to Animal* occurs feedAnimal(dog); } for (Cat* cat : cats) { // At this moment of function call, implicit upcasting from Cat* to Animal* occurs feedAnimal(cat); } // ... Free memory ...}
Advantages: Compared to not using polymorphism at all, there is progress here. We have abstracted a common feedAnimal function, avoiding writing if/else statements to check animal types inside the function.Disadvantages (core issues):(1) Data management is fragmented: We still need to maintain two separate vectors for Dog and Cat.(2) Code logic is repetitive: We still need to write two for loops to iterate through these lists.(3) Scalability is still poor: If we add a Bird class, we still have to create a std::vector<Bird*> and add a third for loop.Understanding upcasting: Here, the programmer merely sees upcasting as a convenient tool for function parameter passing, without realizing it can be used for higher-level architectural design—that is, data storage and management.Scenario 2: Elegantly using polymorphism (fully utilizing upcasting for unified management)In this scenario, the programmer deeply understands the power of upcasting and uses it to unify the data structure, thereby simplifying the entire processing logic.
void manageShelter_Elegant() { // Core change: Use a container of base class pointers to unify storage of all objects std::vector<Animal*> shelter; // [Utilizing upcasting during storage] // new Dog() returns Dog*, which is implicitly upcast to Animal* when stored in shelter shelter.push_back(new Dog()); // new Cat() returns Cat*, which is implicitly upcast to Animal* when stored in shelter shelter.push_back(new Cat()); shelter.push_back(new Dog()); std::cout << "--- Dinner time (Elegant Way) ---" << std::endl; // [Unified logic processing] // A single loop processes all types of animals, making the code concise and stable for (const Animal* animal : shelter) { animal->eat(); // Polymorphism takes effect here! } // ... Free memory ...}
Advantages:(1) Data management is unified: One vector manages all animals, regardless of how many types there are.(2) Code logic is unified: One for loop processes all animals.(3) Scalability is excellent: After adding a Bird class, you only need to shelter.push_back(new Bird());, and the code for traversing and processing does not need to change at all.Understanding upcasting: Here, the programmer views upcasting as the cornerstone of “programming to an interface”. He/she knows that as long as an object “is” an Animal, it can be uniformly managed by std::vector<Animal*>, and all subsequent operations can be based on the abstract interface of Animal without caring about its specific type..Now let’s look at an application-level example.A complex logging system may need to record various log formats, such as:(1) Plain text logs(TextMessage)(2) User action logs(UserActionMessage), which includes user ID and action type.(3) Database query logs(DbQueryMessage), which includes the executed SQL statement and duration.They all need to be written to a unified target (such as a file or console), but the formats are different.We first establish a polymorphic log message system. LogMessage is the base class for all log messages, defining a common interface format() to format the log content into a string.
#include <iostream>#include <vector>#include <string>#include <sstream>// Common base class and derived class definitionsclass LogMessage {public: virtual std::string format() const = 0; // Pure virtual function, forcing derived classes to implement virtual ~LogMessage() {}};class TextMessage : public LogMessage {private: std::string msg_;public: TextMessage(const std::string& msg) : msg_(msg) {} std::string format() const override { return "[INFO] " + msg_; }};class UserActionMessage : public LogMessage {private: int userId_; std::string action_;public: UserActionMessage(int userId, const std::string& action) : userId_(userId), action_(action) {} std::string format() const override { std::ostringstream oss; oss << "[ACTION] UserID: " << userId_ << ", Action: " << action_; return oss.str(); }};class DbQueryMessage : public LogMessage {private: std::string sql_; int durationMs_;public: DbQueryMessage(const std::string& sql, int duration) : sql_(sql), durationMs_(duration) {} std::string format() const override { std::ostringstream oss; oss << "[DB_QUERY] SQL: \"" << sql_ << "\", Duration: " << durationMs_ << "ms"; return oss.str(); }};
Scenario 1: Clumsily using polymorphism (processing logs by type)In this scenario, the logging system internally maintains a separate buffer queue for each log type. When it needs to write logs to disk, it traverses each queue in turn.
// Log writing function, can accept any LogMessage subclassvoid writeLogToFile(const LogMessage* msg) { // Polymorphism takes effect here! msg->format() will call the correct version std::cout << msg->format() << std::endl; }class Logger_Clumsy {private: // Data storage is separated by specific types std::vector<TextMessage*> textQueue_; std::vector<UserActionMessage*> actionQueue_; std::vector<DbQueryMessage*> dbQueue_;public: void log(TextMessage* msg) { textQueue_.push_back(msg); } void log(UserActionMessage* msg) { actionQueue_.push_back(msg); } void log(DbQueryMessage* msg) { dbQueue_.push_back(msg); } // Write all buffered logs to the target void flush() { std::cout << "--- Flushing logs (Clumsy Way) ---" << std::endl; // Logic processing requires separate loops for (const auto* msg : textQueue_) { // Upcasting occurs when called TextMessage* -> LogMessage* writeLogToFile(msg); } for (const auto* msg : actionQueue_) { writeLogToFile(msg); } for (const auto* msg : dbQueue_) { writeLogToFile(msg); } // ... Clear queues and free memory ... }};
Problems:(1) Bulky interface: The Logger_Clumsy class needs to overload a log method for each new log type.(2) Rigid internal structure: Every time a new log type is added, a new vector member must be added to the class.(3) Repetitive flushing logic: The flush method is filled with repetitive for loops.(4) Scalability disaster: If another developer on the team wants to add a new log type, such as NetworkRequestMessage, he/she must modify the definition and implementation of the core Logger_Clumsy class, which is unacceptable in large projects.Scenario 2: Elegantly using polymorphism (unified log queue)In this scenario, the logging system maintains a single unified buffer queue that can accommodate all types of log messages.
class Logger_Elegant {private: // Core change: Use a container of base class pointers to unify storage of all logs std::vector<LogMessage*> messageQueue_;public: // One interface handles all logs! // The parameter passed here is a base class pointer, so polymorphism can be used, // When called, it is equivalent to upcasting the derived class to the base class void log(LogMessage* msg) { // [Utilizing upcasting during storage] // Regardless of whether TextMessage* or DbQueryMessage* is passed, // both are upcast to LogMessage* when stored in the queue. if (msg) { messageQueue_.push_back(msg); } } // Flushing logic becomes extremely simple and stable void flush() { std::cout << "--- Flushing logs (Elegant Way) ---" << std::endl; // [Unified logic processing] for (const LogMessage* msg : messageQueue_) { // Polymorphism takes effect here! std::cout << msg->format() << std::endl; } // ... Clear queues and free memory ... }};// --- Main Application ---void runApplication() { Logger_Elegant logger; // The application can freely log any type of log logger.log(new TextMessage("Application started.")); logger.log(new UserActionMessage(1001, "Login")); logger.log(new DbQueryMessage("SELECT * FROM users", 5)); logger.flush();}
Advantages:
(1) Concise interface: Logger_Elegant has only one log(LogMessage* msg) method, which achieves compatibility with all log types by accepting a base class pointer.
(2) Stable internal structure: No matter how many types of logs are added in the future, the internal member messageQueue_ of Logger_Elegant never needs to change.
(3) Clear flushing logic: The flush method has only one loop, with a single and clear responsibility.
(4) Perfect scalability: Any developer can freely create new subclasses of LogMessage and directly pass them to logger.log(), without modifying the Logger_Elegant class. This makes the logging system a truly stable and scalable framework.
Chapter 3: Downcasting – Risky “Identification”New requirement:Only dogs can “shake hands”: The story continues, and now you want all the dogs in the shelter to shake hands with you. But the Animal class does not have a shakeHand() method; only the Dog class does.What is downcasting? It is converting a base class pointer (Animal*) back to a derived class pointer (Dog*). This is like pointing to an “animal” cage and saying, “I believe there is a dog in here; I want it to shake hands with me.”Why is downcasting needed? To call methods unique to the derived class.Example: Let the dogs in the shelter shake hands.We have already implemented an animal shelter through “upcasting”, where all animals are stored in a std::vector<Animal*> container.New requirement: You want to traverse the shelter, find all the dogs, and let them perform their unique skill: shakeHand().Modified class definition:
class Animal {public: virtual void eat() const { /* ... */ } virtual ~Animal() {}};class Dog : public Animal {public: void eat() const override { /* ... */ } // Dog class's exclusive method void shakeHand() const { std::cout << "The dog extends its paw and shakes hands with you!" << std::endl; }};class Cat : public Animal {public: void eat() const override { /* ... */ } // Cat class does not have a shakeHand method};
Encountered problem: When you take an Animal* pointer from the container, the compiler only knows it points to an “animal” and does not know if it is specifically a “dog”. Therefore, you cannot directly call shakeHand().
std::vector<Animal*> shelter;shelter.push_back(new Dog());shelter.push_back(new Cat());Animal* someAnimal = shelter[0]; // someAnimal points to a Dog object// someAnimal->shakeHand(); // Compilation error! Animal class does not have a shakeHand member.
This is why downcasting is needed: We need a way to tell the compiler, “Trust me, this pointer actually points to a dog; please allow me to call the dog’s exclusive method.”How to solve it?Code implementation:
void interactWithAnimals_Good() { std::vector<Animal*> shelter; shelter.push_back(new Dog()); shelter.push_back(new Cat()); for (Animal* animal : shelter) { // [Downcasting occurs here] // Attempt to safely convert the animal pointer to a Dog* pointer Dog* dog = dynamic_cast<Dog*>(animal); // [Safety check] // If the conversion is successful (the pointer is not nullptr), it means the animal indeed points to a Dog object if (dog != nullptr) { // Now it is 100% safe to call the Dog's exclusive method dog->shakeHand(); } // If the conversion fails (for example, if the animal points to a Cat), dog will be nullptr, and the if condition will not hold, // we elegantly skip it, doing nothing, and there is no risk. } // ... Free memory ...}
Advantages:(1) Safe and reliable: dynamic_cast performs type checking at runtime, ensuring that “pointing a cat as a dog” error never occurs. This is its greatest advantage over static_cast in such uncertain scenarios.(2) Clear code: The intent of if (dynamic_cast<Dog*>(…)) is very clear, which is “If this object is a dog, then execute the following operation”, making it highly readable.(3) Adheres to the Open/Closed Principle: This is the most important benefit. If we add a Bird class in the future, and it has a fly() method, the interactWithAnimals_Good function does not need any modification. The logic for handling dogs continues to run perfectly. If we need to let the bird fly, we can add another independent logic if (Bird* bird = dynamic_cast<Bird*>(animal)) { bird->fly(); }, without interfering with the existing code.
Chapter 4 – dynamic_cast and static_cast
We already know that when holding a base class pointer Animal*, but wanting to call the derived class Dog’s exclusive method shakeHand(), downcasting is needed. Now the question is, C++ provides more than one tool to accomplish this; how should we choose?Let us get to know two stylistically different “casting operators”: static_cast and dynamic_cast.dynamic_castdynamic_cast is a type conversion that occurs at runtime with safety checks. When you use it, you are telling the program: “I guess this Animal* might point to a Dog object; please check and confirm at runtime.”It utilizes C++’s runtime type information (RTTI) mechanism to query the real type of the object pointed to by the pointer..The greatest benefit of dynamic_cast is safety.Avoiding illegal conversions: This is its core value.If the conversion is successful (the pointer indeed points to a Dog or its subclass object), it will return a valid Dog* pointer.If the conversion fails (the pointer points to a Cat object), it will not force the conversion but will return a nullptr (null pointer)..Transforming potential crashes into controllable logic: You can safely execute subsequent code by checking whether the return value is nullptr..
std::vector<Animal*> shelter;shelter.push_back(new Dog());shelter.push_back(new Cat());for (Animal* animal : shelter) { // Attempt to identify Dog* dog = dynamic_cast<Dog*>(animal); // Check identification result if (dog != nullptr) { // Identification successful! We can operate 100% safely dog->shakeHand(); } else { // Identification failed, returned nullptr. We know this is not a dog, so we skip it. std::cout << "This is not a dog, cannot shake hands." << std::endl; }}
This code will never crash and is very robust.The runtime check capability of dynamic_cast does not come from nowhere; it requires the class being converted (here, Animal) to contain at least one virtual function (for example, a virtual destructor). This generates the virtual function table needed for RTTI, which dynamic_cast relies on to query runtime information..static_caststatic_cast is a type conversion that is completed at compile time. When you use it, you are actually telling the compiler: “Don’t ask, just trust me; I guarantee this Animal* points to a Dog object; just treat it as Dog*.”It only performs type checking at compile time (for example, whether there is an inheritance relationship between Dog and Animal), but it does not care what the real object pointed to by the pointer is at runtime.The greatest and almost only benefit of static_cast is performance..Zero runtime overhead: Because it does not perform any checks at runtime, the conversion process does not consume any CPU time. This line of code is almost equivalent to a simple pointer assignment in the final machine code, making it extremely fast.Its “confidence” also brings great risks. If your guarantee is wrong, the consequences will be disastrous.
std::vector<Animal*> shelter;shelter.push_back(new Dog());shelter.push_back(new Cat());// --- Successful example ---Animal* animal1 = shelter[0]; // Points to Dog objectDog* dog1 = static_cast<Dog*>(animal1); // You are right, conversion successfuldog1->shakeHand(); // Runs normally// --- Disaster example ---Animal* animal2 = shelter[1]; // Points to Cat objectDog* dog2 = static_cast<Dog*>(animal2); // You lied to the compiler! // Compilation passes, but dog2 is a Dog* pointer pointing to a Cat object// Runtime error! The program will try to find the shakeHand method in the memory layout of the Cat object, // resulting in undefined behavior, usually causing the program to crash immediately.dog2->shakeHand();
Summary:dynamic_cast is runtime checked, so it is safe but slow.static_cast is compile-time only confirming the existence of an inheritance relationship, so it is fast but unsafe.