148. Slicing Breaks Polymorphism

In embedded firmware systems, hardware drivers are often accessed through a common interface so that different peripherals can be managed uniformly.
This relies on runtime polymorphism, where derived driver implementations override virtual functions defined in a base driver interface.

However, passing polymorphic objects by value causes object slicing, which removes the derived portion of the object and breaks runtime dispatch.

Your task is to correct the given program so that runtime polymorphism is preserved, and the correct derived driver behavior executes when processed through the base interface.

You must fix the object slicing issue without redesigning the driver class hierarchy.

 

Input / Program Flow

  • One integer value is read from standard input.

Driver selection rules:

  • If the input value is 0, use the SPI driver
  • If the input value is 1, use the I2C driver

Program flow:

  1. Read one integer value
  2. Create the corresponding derived driver object
  3. Pass the driver object to a processing function
  4. The processing function calls the driver function through the base interface

Output

  • If input is 0, output:

    SPI driver running
  • If input is 1, output:

    I2C driver running

Output Requirements

  • Exactly one line of output
  • Exact text and spacing must match
  • Output must originate from the driver function call inside the processing function

 

Constraints

  • Language standard: C++11
  • Do NOT modify driver class data members
  • Do NOT remove polymorphism
  • Do NOT use dynamic memory allocation (new, delete)
  • Do NOT use STL containers
  • You MAY change how objects are passed to functions
  • Output must match exactly

 

 

 

Need Help? Refer to the Quick Guide below

Polymorphism ("Many Forms") allows you to treat different objects (like TempSensor, PressureSensor) as a single generic type (Sensor). It decouples the Interface (what a function does) from the Implementation (how it does it).

In Embedded C++, it comes in two flavors:

  1. Runtime (Dynamic): Uses virtual functions. Flexible, but has slight overhead.
  2. Compile-Time (Static): Uses Overloading and Templates. Zero overhead, but less flexible.

Syntax & Usage (Runtime Polymorphism)

1. Virtual Functions

The virtual keyword tells the compiler: "Don't bind this function call yet. Wait until the program runs to see what type of object this actually is."

class Protocol {
public:
    // Virtual: Can be overridden by children
    virtual void send(uint8_t data) { 
        printf("Generic Send\n"); 
    }
};

class UART : public Protocol {
public:
    // Override: Replace generic logic with UART logic
    void send(uint8_t data) override { 
        UART_HW->DR = data; 
    }
};

class SPI : public Protocol {
public:
    void send(uint8_t data) override { 
        SPI_HW->DR = data; 
    }
};

// The Magic: This function works for UART, SPI, I2C, etc.
void transmit_data(Protocol* p, uint8_t byte) {
    p->send(byte); // Calls the specific version automatically
}

2. Abstract Classes (Interfaces)

In HAL design, the Base class often has no logic—it just defines the API. We use Pure Virtual Functions (= 0) to enforce this.

A class with pure virtual functions cannot be instantiated.

// Interface (Contract)
class IDigitalOut {
public:
    virtual void write(bool state) = 0; // Pure Virtual
    virtual ~IDigitalOut() {}           // Virtual Destructor (Required)
};

// Implementation
class GpioPin : public IDigitalOut {
public:
    void write(bool state) override {
        // Hardware specific code
    }
};

How It Works: The V-Table

Dynamic polymorphism isn't magic; it uses a lookup table.

  1. V-Table (Flash): The compiler creates a static table of function pointers for every class.
  2. vptr (RAM): Every object gets a hidden pointer (vptr) pointing to its class's V-Table.
StructureCost (Overhead)
RAM4 bytes per object (for the vptr).
FlashOne table per Class (not per object).
CPU2-3 extra cycles (Fetch pointer -> Jump).

Syntax & Usage (Static Polymorphism)

1. Function Overloading

Same function name, different arguments. Resolved at compile time.

void log(int i);
void log(char* s);
// Compiler picks the right one. Zero overhead.

2. Templates (CRTP)

Used when you want polymorphic behavior (generic drivers) but cannot afford the RAM cost of V-Tables.

template <typename T>
void send_byte(T& driver, uint8_t b) {
    driver.send(b); // Resolved at compile time!
}

Relevance in Embedded/Firmware

1. Hardware Abstraction Layer (HAL)

This is the standard use case. You define an ISerial interface. Your application uses ISerial.

  • Board A uses STM32UART.
  • Board B uses NordicUART.
  • Unit Tests use MockUART. You can swap hardware without changing a single line of application logic.

2. Heterogeneous Collections

You can iterate over a list of different devices.

// Array of pointers to the Base class
Sensor* sensors[] = { &temp, &imu, &battery };

for (auto* s : sensors) {
    s->read(); // Calls Temp::read(), then IMU::read()...
}

3. Testability (Mocking)

Polymorphism is essential for Unit Testing on a PC.

You pass a MockGPIO (which just prints to screen) into your driver instead of a real RealGPIO (which touches registers).

Common Pitfalls (Practical Tips)

PitfallDetails
❌ Missing Virtual DestructorCRITICAL: If a Base class has virtual functions, the Destructor must be virtual. If not, delete base_ptr will not call the Derived destructor, causing resource leaks.
❌ Object Slicing

Polymorphism only works with Pointers (Base*) or References (Base&).

void func(Base b) -> Slices the object (removes derived data).

void func(Base& b) -> Keeps polymorphism.

override KeywordAlways use override in derived classes. If you accidentally change the signature (send(int) vs send(uint8_t)), override triggers a compile error. Without it, you create a generic bug.
❌ Calling Virtual in ConstructorNever call a virtual function inside a Constructor or Destructor. The object is not fully formed yet, so it will call the Base version, not the Derived version.