Заметки по языку с и с++

Множественное наследование интерфейсов в Qt: подводные камни

Введение

C++ поддерживает множественное наследование, и это часто используется для реализации нескольких интерфейсов одним классом — обычная практика в объектно-ориентированном проектировании. Но Qt накладывает специфичные ограничения на эту возможность, когда речь заходит о классах, наследующих QObject. В статье разберём, что можно и что нельзя делать с множественным наследованием в Qt-проектах, и как обходить ограничения там, где это необходимо.

Концепция

Класс может наследовать только от одного QObject-производного класса — множественное наследование от двух разных классов, оба из которых (прямо или через цепочку) наследуют QObject, не поддерживается метаобъектной системой Qt и не компилируется с понятной ошибкой. Однако класс, наследующий QObject, может дополнительно наследовать от произвольного количества «обычных» (не-QObject) абстрактных интерфейсов — это стандартный, поддерживаемый паттерн, который мы уже видели в статье про абстрактные интерфейсы.

Когда же действительно нужно сочетать функциональность нескольких разных QObject-классов в одном объекте, решением становится композиция (объект-обёртка содержит экземпляры нужных классов как члены и делегирует им вызовы) вместо наследования.

Пример кода: Множественное наследование интерфейсов в Qt

// Поддерживаемый паттерн: QObject + несколько НЕ-QObject интерфейсов
#include <QObject>

class ISerializable
{
public:
    virtual ~ISerializable() = default;
    virtual QByteArray serialize() const = 0;
};

class IComparable
{
public:
    virtual ~IComparable() = default;
    virtual int compareTo(const IComparable &other) const = 0;
};

// Множественное наследование от QObject + двух НЕ-QObject интерфейсов — корректно
class Document : public QObject, public ISerializable, public IComparable
{
    Q_OBJECT
public:
    explicit Document(const QString &title, QObject *parent = nullptr)
        : QObject(parent), m_title(title) {}

    QByteArray serialize() const override
    {
        return m_title.toUtf8();
    }

    int compareTo(const IComparable &other) const override
    {
        const auto *otherDoc = dynamic_cast<const Document *>(&other);
        if (!otherDoc) return 0;
        return QString::compare(m_title, otherDoc->m_title);
    }

private:
    QString m_title;
};
// НЕ поддерживается: множественное наследование от двух QObject-классов
// class Invalid : public QWidget, public QAbstractItemModel { ... }; // ошибка компиляции

// Решение через композицию вместо наследования
#include <QWidget>
#include <QAbstractItemModel>

class TableWidgetWithModel : public QWidget
{
    Q_OBJECT
public:
    explicit TableWidgetWithModel(QWidget *parent = nullptr) : QWidget(parent)
    {
        m_model = new QStandardItemModel(this); // композиция: модель как член,
                                                  // а не базовый класс
    }

    QAbstractItemModel *model() const { return m_model; }

private:
    QStandardItemModel *m_model;
};

Пояснения к коду

Document демонстрирует корректный паттерн: единственный базовый QObject-класс (сам QObject) в сочетании с произвольным числом дополнительных интерфейсов, не наследующих QObject (ISerializable, IComparable) — это компилируется и работает без ограничений, поскольку метаобъектная система требует лишь единственной «линии» наследования QObject для корректной генерации moc.

Закомментированная строка показывает запрещённую конструкцию — наследование одновременно от QWidget и QAbstractItemModel (оба — QObject-производные) не скомпилируется. TableWidgetWithModel показывает практическое решение через композицию: вместо наследования от QAbstractItemModel, класс просто содержит указатель на модель как член и предоставляет доступ к ней через метод model() — функциональность модели доступна через делегирование, а не через прямое наследование её интерфейса.

Подводные камни

  • Попытка «обойти» ограничение через виртуальное наследование от QObject. В отличие от обычных диамантовых иерархий в C++, виртуальное наследование не решает проблему множественного наследования QObject-классов — ограничение заложено в самой архитектуре генерации moc-кода и требований к единственному QMetaObject на класс, а не в технических деталях C++ наследования, которые можно было бы обойти ключевым словом virtual.
  • dynamic_cast внутри compareTo(), предполагающий конкретный производный тип. Реализация compareTo() в примере полагается на приведение к конкретному типу Document, что нарушает чистоту абстракции IComparable (она формально объявлена для сравнения с любым IComparable, но фактически работает только при сравнении однотипных объектов) — для строгого соответствия контракту интерфейса такие реализации либо должны корректно обрабатывать сравнение с другими типами, либо контракт интерфейса должен явно документировать это ограничение.
  • Путаница между «интерфейсом в духе Java» (чистый абстрактный класс) и QObject-классом с сигналами, который тоже иногда называют интерфейсом. Эти два понятия по-разному ограничивают возможности наследования, и неосознанное смешивание подходов в одной архитектуре (где часть «интерфейсов» — чистые абстрактные классы, а часть — полноценные QObject-классы с сигналами) усложняет понимание правил, по которым строится иерархия классов в конкретном проекте.
  • Композиция вместо наследования требует явного делегирования каждого нужного метода. В отличие от наследования, где методы базового класса автоматически доступны через производный класс, композиция (как в TableWidgetWithModel) требует вручную писать методы-обёртки для каждого аспекта функциональности вложенного объекта, который должен быть виден через внешний интерфейс класса — это более многословно, но даёт явный контроль над тем, какая часть функциональности действительно экспортируется наружу.