An investigation of why dynamic_cast violates LSP

Signal sender cast in QT

QT implies that the client code will do qobject_cast that is actually dynamic_cast:

awl::ProcessTask<void> MarketModel::coPlaceOrder(OrderPtr p)
{
    // Update SQLite databse...
    // ...
    // QT signals are used in both C++ and QML.
    // They works with QObject*, and they are not aware of concrete types.
    QObject::connect(p.get(), &OrderModel::statusChanged, this, &MarketModel::onOrderStatusChanged);
}
void MarketModel::onOrderStatusChanged()
{
    // When a signal is emitted sender() holds its sender as QObject*.
    OrderPtr p = qobject_cast<OrderModel*>(sender())->shared_from_this();

    // Working with concrete type OrderModel*.
    switch (p->data().status)
    {
    case data::OrderStatus::Pending:

        break;

    case data::OrderStatus::Open:

    // ...
}

Event sender cast in WPF

A typical WPF event handler in C# looks like this:

private void okButton_Click(object sender, RoutedEventArgs e)
{
    if (topLevelProjectCheckBox.IsChecked ?? false)
    {
        ProjectRow.ProjectRowParent = null;
    }
    
    DialogResult = true;
}

an example of a type cast from my old code in C#:

public class DbList<T> : IList<T>, System.Collections.IList, INotifyCollectionChanged
    where T : class
{
    //.. 

    T Object2T(object value)
    {
        T t = value as T;

        if (t == null)
        {
            throw new ArgumentException(
                String.Format("The value '{0}' is not of type '{1}' and cannot be used in this generic collection.",
                value, typeof(T).Name));
        }

        return t;
    }

    void PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        if (Dispatcher.CheckAccess())
        {
            T item = Object2T(sender);

            bool should_exist = Filter(item);

            int index = this.IndexOf(item);

            bool exists = index != -1;

            if (should_exist != exists)
            {
                if (should_exist)
                {
                    this.InternalAdd(item);
                }
                else
                {
                    this.RemoveAt(index);
                }
            }
        }
        else
        {
            Dispatcher.Invoke(
                new System.ComponentModel.PropertyChangedEventHandler(PropertyChanged),
                sender, e);
        }
    }
}

An example of violation of LSP with dynamic_cast

#include <iostream>
#include <memory>
#include <string>
#include <vector>
#include <cassert>

/// Base interface — contract: any Logger can log messages.
struct Logger {
    virtual ~Logger() = default;
    virtual void log(std::string_view msg) = 0;
};

/// Simple console implementation.
struct ConsoleLogger : Logger {
    void log(std::string_view msg) override {
        std::cout << "[console] " << msg << '\n';
    }
};

/// File logger with an extra feature: rotation.
struct FileLogger : Logger {
    void rotate_now() { rotated_ = true; }  // Additional behavior not in base contract
    void log(std::string_view msg) override { (void)msg; /* write to file here */ }

    bool rotated_ = false;
};

/// Client code — decides to treat FileLogger specially using dynamic_cast.
void write_startup(Logger& lg) {
    if (auto* fl = dynamic_cast<FileLogger*>(&lg)) {
        // Special-case for a specific subtype.
        fl->rotate_now();  // <-- strengthens preconditions for FileLogger
    }
    lg.log("service start");
}

int main() {
    ConsoleLogger c;
    FileLogger f;

    write_startup(c); // Works fine
    write_startup(f); // Extra behavior for subtype

    assert(f.rotated_); // Behavior now depends on the concrete type → violates LSP
}

❌ Why this violates LSP

  • The client code branches on the subtype and changes the contract:
    The base Logger promised “you can log messages,” but the client now assumes
    “if it’s a FileLogger, you must also rotate first.”
  • Replacing FileLogger with another subclass (e.g., DatabaseLogger)
    will change the behavior or break expectations.
  • The client depends on implementation details of a specific subtype.

String-based Logger Factory (LSP-compliant)

#include <iostream>
#include <fstream>
#include <memory>
#include <string>
#include <stdexcept>

/// Base interface — all loggers must support this.
struct Logger {
    virtual ~Logger() = default;
    virtual void log(std::string_view msg) = 0;
};

/// Writes logs to the console.
struct ConsoleLogger : Logger {
    void log(std::string_view msg) override {
        std::cout << "[console] " << msg << '\n';
    }
};

/// Writes logs to a file.
struct FileLogger : Logger {
    explicit FileLogger(const std::string& filename)
        : file_(filename, std::ios::app)
    {
        if (!file_)
            throw std::runtime_error("Failed to open log file: " + filename);
    }

    void log(std::string_view msg) override {
        file_ << "[file] " << msg << '\n';
    }

private:
    std::ofstream file_;
};

/// Factory that creates a Logger by string name.
/// Supported: "console", "file"
std::unique_ptr<Logger> make_logger(const std::string& name, const std::string& param = "")
{
    if (name == "console") {
        return std::make_unique<ConsoleLogger>();
    }
    else if (name == "file") {
        if (param.empty())
            throw std::invalid_argument("File logger requires a filename");
        return std::make_unique<FileLogger>(param);
    }
    else {
        throw std::invalid_argument("Unknown logger type: " + name);
    }
}

/// Function that creates a logger by name and writes startup messages.
/// It depends only on the Logger interface — no LSP violations.
void write_startup(const std::string& logger_name, const std::string& param = "")
{
    try {
        // 1. Create a logger (could be console, file, network, etc.)
        auto logger = make_logger(logger_name, param);

        // 2. Use it through the base interface only
        logger->log("=== Application startup ===");
        logger->log("Initializing subsystems...");
        logger->log("Startup complete.");
    }
    catch (const std::exception& ex) {
        std::cerr << "Failed to write startup log: " << ex.what() << '\n';
    }
}

int main()
{
    // Writes to console
    write_startup("console");

    // Writes to file
    write_startup("file", "startup.log");

    // Uncomment to see error handling
    // write_startup("unknown");

    return 0;
}

✅ Why this still satisfies LSP

  • Both ConsoleLogger and FileLogger implement the same interface Logger.
  • The factory hides subtype knowledge.
  • The client code (run_service) treats every logger uniformly.
  • Adding a new subtype (e.g. "network", "syslog") doesn’t change the client — only the factory.

We still use different types of Logger inside write_startup method depending on name parameter, right? Is it a hidden dynamic_cast? Or a dynamic_cast is a bit different style? Does make_logger violates LSP?

Why dynamic_cast is not a factory?

The factory as a parameter

#include <iostream>
#include <fstream>
#include <memory>
#include <string>
#include <stdexcept>

/// Base interface — any logger can write messages somewhere.
struct Logger {
    virtual ~Logger() = default;
    virtual void log(std::string_view msg) = 0;
};

/// Writes to console.
struct ConsoleLogger : Logger {
    void log(std::string_view msg) override {
        std::cout << "[console] " << msg << '\n';
    }
};

/// Writes to a file.
struct FileLogger : Logger {
    explicit FileLogger(const std::string& filename)
        : file_(filename, std::ios::app)
    {
        if (!file_)
            throw std::runtime_error("Failed to open log file: " + filename);
    }

    void log(std::string_view msg) override {
        file_ << "[file] " << msg << '\n';
    }

private:
    std::ofstream file_;
};

/// Interface for factories that create loggers.
/// Different factory implementations can decide *how* to create them.
struct LoggerFactory {
    virtual ~LoggerFactory() = default;

    /// Factory method: creates a logger by name (e.g. "console", "file")
    virtual std::unique_ptr<Logger> make_logger(
        const std::string& name,
        const std::string& param = "") const = 0;
};

/// Default implementation of LoggerFactory.
/// Encapsulates creation logic for known types.
struct DefaultLoggerFactory : LoggerFactory {
    std::unique_ptr<Logger> make_logger(
        const std::string& name,
        const std::string& param = "") const override
    {
        if (name == "console") {
            return std::make_unique<ConsoleLogger>();
        } else if (name == "file") {
            if (param.empty())
                throw std::invalid_argument("File logger requires a filename");
            return std::make_unique<FileLogger>(param);
        } else {
            throw std::invalid_argument("Unknown logger type: " + name);
        }
    }
};

/// High-level function that uses the factory to create a logger.
/// It doesn't depend on the specific factory implementation.
void write_startup(const LoggerFactory& factory,
                   const std::string& logger_name,
                   const std::string& param = "")
{
    try {
        // Create a logger using the factory interface
        auto logger = factory.make_logger(logger_name, param);

        // Use it through the base interface only (LSP preserved)
        logger->log("=== Application startup ===");
        logger->log("Initializing subsystems...");
        logger->log("Startup complete.");
    }
    catch (const std::exception& ex) {
        std::cerr << "Failed to write startup log: " << ex.what() << '\n';
    }
}

int main()
{
    DefaultLoggerFactory factory;

    // Works for console
    write_startup(factory, "console");

    // Works for file
    write_startup(factory, "file", "startup.log");

    return 0;
}

Liskov Substitution Principle (LSP)

📜 Formal Definition (Barbara Liskov, 1987)

Objects of a superclass should be replaceable with objects of its subclasses without altering the correctness of the program.


💡 In simpler terms:

If you have a base class Base and a derived class Derived,
then anywhere the program expects a Base,
you should be able to use a Derived instead —
and everything should still work correctly.


⚙️ What this really means

A subclass must preserve the behavior promised by the base class.
It can extend or specialize it, but not break it.

Formally:

If a property φ(x) is true for all objects x of type Base,
then φ(y) must also be true for all objects y of any subtype Derived.


🚫 Violations (breaking LSP)

A subclass violates LSP if it:

  1. Strengthens preconditions — it requires more than the base class did.
    (e.g., “must call initialize() before using doWork().”)
  2. Weakens postconditions — it delivers less than the base class promised.
    (e.g., base guarantees “always non-null,” derived may return null.)
  3. Breaks invariants — it changes essential behavior or assumptions of the base type.
    (e.g., Square inheriting from Rectangle but redefining width/height rules.)

What does “correctness of the program” mean

“Correctness” means that when you replace a base class object with a derived class object,
the program still behaves according to its intended specification
it still does the right thing, follows the same rules, and doesn’t break any expectations.

It’s not just about compiling or running —
it’s about logical and behavioral correctness.

Leave a Reply

Your email address will not be published. Required fields are marked *