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 *