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

Абстрактные интерфейсы в Qt-проектах: чистая архитектура через QObject и сигналы

Введение

Построение модульного, тестируемого приложения требует разделения «что делать» (интерфейс) от «как делать» (конкретная реализация) — классический принцип инверсии зависимостей. В Qt-проектах это можно реализовать через чистые абстрактные классы (интерфейсы в духе Java/C#) в сочетании с QObject-наследниками для конкретных реализаций, что даёт одновременно строгую типизацию интерфейса и доступ к сигналам/слотам там, где они нужны. В статье разберём паттерн построения слабосвязанной архитектуры на этой основе.

Концепция

Интерфейс описывается как чистый абстрактный класс (все методы — чистые виртуальные, = 0) без наследования от QObject — это позволяет конкретным реализациям при необходимости наследоваться и от QObject, и от интерфейса одновременно (поскольку QObject не поддерживает множественное наследование от нескольких QObject-классов, но поддерживает дополнительное наследование от классов без QObject). Для интерфейсов, которым нужны сигналы (например, чтобы потребитель интерфейса мог подписаться на уведомления независимо от конкретной реализации), сам интерфейс должен наследовать QObject, а сигнал объявляется как чистый виртуальный метод — что Qt поддерживает только начиная с относительно новых версий через виртуальные сигналы, либо обходится дополнительным слоем абстракции.

Пример кода: Абстрактные интерфейсы в Qt-проектах

// idatarepository.h — интерфейс без зависимости от конкретного хранилища
#pragma once
#include <QString>
#include <QVector>

struct UserRecord
{
    int id;
    QString name;
};

class IDataRepository
{
public:
    virtual ~IDataRepository() = default;
    virtual QVector<UserRecord> findAll() = 0;
    virtual bool save(const UserRecord &record) = 0;
    virtual bool remove(int id) = 0;
};
// sqlrepository.h — конкретная реализация на основе QSqlDatabase
#pragma once
#include "idatarepository.h"
#include <QSqlQuery>
#include <QSqlDatabase>

class SqlUserRepository : public IDataRepository
{
public:
    explicit SqlUserRepository(QSqlDatabase db) : m_db(db) {}

    QVector<UserRecord> findAll() override
    {
        QVector<UserRecord> result;
        QSqlQuery query(m_db);
        query.exec("SELECT id, name FROM users");
        while (query.next()) {
            result.append({query.value(0).toInt(), query.value(1).toString()});
        }
        return result;
    }

    bool save(const UserRecord &record) override
    {
        QSqlQuery query(m_db);
        query.prepare("INSERT OR REPLACE INTO users (id, name) VALUES (?, ?)");
        query.addBindValue(record.id);
        query.addBindValue(record.name);
        return query.exec();
    }

    bool remove(int id) override
    {
        QSqlQuery query(m_db);
        query.prepare("DELETE FROM users WHERE id = ?");
        query.addBindValue(id);
        return query.exec();
    }

private:
    QSqlDatabase m_db;
};
// inmemoryrepository.h — реализация для тестов, без реальной базы данных
#pragma once
#include "idatarepository.h"

class InMemoryUserRepository : public IDataRepository
{
public:
    QVector<UserRecord> findAll() override { return m_records; }

    bool save(const UserRecord &record) override
    {
        for (auto &existing : m_records) {
            if (existing.id == record.id) {
                existing = record;
                return true;
            }
        }
        m_records.append(record);
        return true;
    }

    bool remove(int id) override
    {
        const int sizeBefore = m_records.size();
        m_records.removeIf([id](const UserRecord &r) { return r.id == id; });
        return m_records.size() != sizeBefore;
    }

private:
    QVector<UserRecord> m_records;
};
// usercontroller.h — бизнес-логика, зависящая только от интерфейса
#pragma once
#include "idatarepository.h"

class UserController
{
public:
    explicit UserController(IDataRepository *repository) : m_repository(repository) {}

    void registerUser(int id, const QString &name)
    {
        m_repository->save({id, name});
    }

private:
    IDataRepository *m_repository;
};

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

IDataRepository — чистый интерфейс без наследования от QObject, описывающий три операции, которые должна поддерживать любая реализация хранилища пользователей. SqlUserRepository реализует этот интерфейс через реальную базу данных, в то время как InMemoryUserRepository — лёгковесная реализация, хранящая данные в QVector в памяти, предназначенная специально для модульных тестов, не требующих реального подключения к СУБД.

UserController зависит исключительно от абстракции IDataRepository, не зная, какая конкретная реализация ему передана через конструктор (классический паттерн dependency injection) — это позволяет в production-коде передать SqlUserRepository, а в тестах — InMemoryUserRepository, проверяя логику UserController в полной изоляции от реальной базы данных.

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

  • Интерфейс, наследующий QObject, не может одновременно наследоваться от другого QObject-класса множественным наследованием. Если нужна абстракция с сигналами Qt (а не просто чистыми виртуальными методами), и конкретная реализация также должна наследовать от другого QObject-класса для своей функциональности, возникает архитектурный конфликт, требующий либо отказа от прямого наследования интерфейса от QObject (в пользу композиции), либо более сложного паттерна.
  • Виртуальный деструктор в интерфейсе — не опция, а обязательное требование. Без virtual ~IDataRepository() = default; удаление объекта конкретной реализации через указатель на интерфейс (IDataRepository *ptr; delete ptr;) не вызовет деструктор производного класса, что приводит к утечке ресурсов, выделенных в этом производном классе.
  • Избыточная абстракция там, где существует и предположительно будет существовать только одна реализация. Введение интерфейса «на всякий случай» для класса, для которого нет реальной потребности в подмене реализации (ни сейчас, ни в обозримом будущем — например, для тестирования через mock), добавляет лишний уровень индирекции и читательской нагрузки без реальной архитектурной выгоды.
  • Владение указателем на интерфейс, переданным через конструктор. В примере UserController хранит «сырой» указатель IDataRepository *m_repository, не управляя его жизненным циклом — вызывающий код обязан гарантировать, что объект репозитория переживёт UserController, либо стоит явно перейти на std::shared_ptr<IDataRepository> для разделяемого владения, если это уместно архитектурно.