How C++ Public Inheritance Models the Is-A Rule

Public inheritance in C++ is the language mechanism designed specifically to model the object-oriented concept of an "is-a" relationship. This article explores how public inheritance guarantees behavioral compatibility through the Liskov Substitution Principle (LSP), how C++ type checking and access control enforce this guarantee, and how developers can avoid common design pitfalls like the Square-Rectangle problem by focusing on interface and behavioral contracts rather than superficial real-world classifications.

The Semantic Meaning of Public Inheritance

In C++, inheritance can be declared as public, protected, or private. Declaring a base class as public explicitly communicates to both the compiler and other programmers that every instance of the derived class is, fundamentally and behaviorally, also an instance of the base class.

Under public inheritance, every public member of the base class retains its public access level in the derived class. This creates a type hierarchy where the derived class adopts the entire public interface of the base class. Consequently, any code written to interact with the base class must be able to interact with the derived class without modification or special handling.

The Liskov Substitution Principle (LSP)

The theoretical foundation of the "is-a" relationship in software design is the Liskov Substitution Principle (LSP). Introduced by Barbara Liskov, the principle states that if \(S\) is a subtype of \(T\), then objects of type \(T\) may be replaced with objects of type \(S\) without altering any of the desirable properties of the program (correctness, task performed, etc.).

In C++, this principle is realized through pointers and references:

void processEntity(const Entity& entity);

If Player publicly inherits from Entity, a Player reference can be passed directly to processEntity. For the "is-a" relationship to hold true, processEntity must not encounter unexpected behavior, broken invariants, or runtime exceptions specific to Player that violate the contract established by Entity.

Interface and Contract Enforcement

C++ provides specific language constructs to maintain the behavioral contract of base classes:

  1. Virtual Functions for Dynamic Dispatch: When a base class declares a member function as virtual, it specifies an interface that derived classes can customize. The derived class must honor the semantic contract of that function—it may refine how the operation is performed, but it must not alter what the operation fundamentally accomplishes.
  2. Invariance of Non-Virtual Functions: Public non-virtual member functions define mandatory behavior and class invariants that should not be redefined in derived classes. Hiding a non-virtual base function in a derived class breaks the "is-a" relationship because calling the function via a base pointer yields different behavior than calling it via a derived pointer.
  3. Virtual Destructors: A base class designed for polymorphic use must declare a virtual destructor. Without a virtual destructor, deleting a derived instance through a base pointer results in undefined behavior, immediately breaking the substitutability guarantee.

Common Pitfalls: The Difference Between Real-World and Behavioral "Is-A"

A common mistake in C++ design is confusing real-world taxonomy with object-oriented substitutability. The classic example is the relationship between a Rectangle and a Square.

Mathematically, a square is a rectangle. However, in code:

class Rectangle {
public:
    virtual void setWidth(double w) { width = w; }
    virtual void setHeight(double h) { height = h; }
    double getArea() const { return width * height; }
private:
    double width, height;
};

class Square : public Rectangle {
public:
    void setWidth(double w) override { width = height = w; }
    void setHeight(double h) override { width = height = h; }
};

If a client function modifies a rectangle's width:

void makeWider(Rectangle& r) {
    double oldHeight = r.getHeight();
    r.setWidth(r.getWidth() + 10);
    assert(r.getHeight() == oldHeight); // Fails if r is a Square!
}

The assertion fails when passed a Square because modifying the width unexpectedly alters the height. Therefore, in software engineering, a Square is not a Rectangle if the Rectangle interface permits independent modification of width and height. Public inheritance must reflect behavioral compatibility, not merely real-world definitions.

Summary of Rules for True "Is-A" Modeling

To correctly model an "is-a" relationship using public inheritance in C++:

  • Ensure that all public member functions in the base class make sense and maintain their invariants in the derived class.
  • Do not override non-virtual member functions inherited from a base class.
  • Declare base class destructors as virtual whenever polymorphic deletion is possible.
  • Use private or protected inheritance, or prefer composition ("has-a"), when you only want to reuse implementation details rather than conform to an interface.