Liskov Substitution Principle in C++ Overrides

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In C++, this principle governs how derived classes override virtual member functions, ensuring that polymorphism preserves expected behavior rather than merely satisfying compiler-enforced type signatures. This article explores the design and language rules that dictate behavioral subtyping, contract enforcement, and syntax mechanics for derived class overrides in C++.

Behavioral Contracts: Preconditions and Postconditions

At its core, LSP is a behavioral guideline formalized by Design by Contract. C++ compilers enforce signature compatibility, but the developer must enforce contract compatibility:

  • Preconditions cannot be strengthened: A derived class override must not demand more than the base class method. If a base class method accepts any integer, an override must not reject negative integers. If it does, code written to the base interface will break unexpectedly when passed a derived instance.
  • Postconditions cannot be weakened: A derived class override must guarantee at least as much as the base class. If a base class method guarantees a non-null return pointer, the derived override must not return nullptr.
  • Invariants must be preserved: Any invariant maintained by the base class (e.g., a member variable staying within a certain range) must remain valid after the derived class method completes execution.

A classic violation occurs when a Square class inherits from a Rectangle class. If Rectangle::setWidth(int w) allows independent dimension modification, Square::setWidth(int w) breaks the base postcondition by mutating the height as well, violating LSP.

Covariant Return Types

C++ directly supports LSP at the type system level through covariant return types. When overriding a virtual function, the return type in the derived class does not need to match the base class return type identically; it can be a pointer or reference to a class derived from the base class return type.

struct BaseResource {};
struct DerivedResource : public BaseResource {};

class Base {
public:
    virtual ~Base() = default;
    virtual BaseResource* getResource();
};

class Derived : public Base {
public:
    // Covariant return type adheres to LSP
    DerivedResource* getResource() override; 
};

This adheres to LSP because any caller expecting a pointer to BaseResource receives DerivedResource*, which is a valid substitute.

Parameter Invariance and Method Hiding

Unlike return types, C++ does not support contravariant parameters (accepting broader types in derived overrides). In C++, parameter types must match the base class virtual function signature exactly. If the parameter types differ, the derived function overloads or hides the base function instead of overriding it:

class Base {
public:
    virtual void process(int x);
};

class Derived : public Base {
public:
    // Hides Base::process(int), does not override it
    void process(double x); 
};

To prevent unintended hiding and verify accurate overrides, always mark overrides with the override specifier. The compiler will emit an error if the signature does not match a base virtual function.

Exception Specifications and noexcept

Exception handling is a critical aspect of LSP governed by the C++ type system. A derived class override must not throw exceptions that the caller of the base interface is not prepared to handle:

  • If a base method is marked noexcept, the derived class override must also be noexcept. The C++ compiler enforces this strictly: overriding a noexcept function with a potentially throwing function results in a compilation error.
  • If a base method specifies certain exceptions (or conceptually documents them), derived overrides should only throw the same exceptions or subtypes of those exceptions.
class Base {
public:
    virtual void execute() noexcept;
};

class Derived : public Base {
public:
    // Compiler error: derived override cannot be potentially-throwing
    void execute() override; 
};

Summary of Rules for C++ Overrides

  1. Use the override keyword to let the compiler enforce signature matching.
  2. Keep preconditions equal to or weaker than the base class.
  3. Keep postconditions equal to or stronger than the base class.
  4. Maintain noexcept constraints; never make an override more permissive regarding exceptions.
  5. Rely on covariant return types to return more specific types safely without violating consumer expectations.