Введение
Построение модульного, тестируемого приложения требует разделения «что делать» (интерфейс) от «как делать» (конкретная реализация) — классический принцип инверсии зависимостей. В 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>для разделяемого владения, если это уместно архитектурно.