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 benoexcept. The C++ compiler enforces this strictly: overriding anoexceptfunction 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
- Use the
overridekeyword to let the compiler enforce signature matching. - Keep preconditions equal to or weaker than the base class.
- Keep postconditions equal to or stronger than the base class.
- Maintain
noexceptconstraints; never make an override more permissive regarding exceptions. - Rely on covariant return types to return more specific types safely without violating consumer expectations.