Day 32:依赖注入(Dependency Injection, DI)

对象不再自己 new 依赖,而是由外部把依赖「递」进来。它是依赖倒置原则(DIP)最常用的落地手段——上一周我们学了「怎么隐藏实现」(PIMPL),今天学「怎么获得实现」。

目录

01

今日主题

依赖注入(Dependency Injection):一个对象需要的协作者(依赖),不在它内部创建,而是由外部在创建它的时候传入——通过构造函数参数、setter 方法或接口方法。一句话:把 new 从类内部搬到程序的最外层(组合根 Composition Root)。

它和 Day 20 的策略模式长得很像,但关注点完全不同:策略模式关心「运行时切换同类算法」,依赖注入关心「对象的依赖从哪来」。DI 也不是 GoF 23 模式之一,它是一种实现依赖倒置原则(DIP)的技术手段——是 Martin Fowler 在 2004 年正式命名的「控制反转(IoC)的一种具体形式」。

对 Qt 开发者,DI 有额外的一层意义:Qt 的 QObject 父子树天然管的是生命周期,而 DI 管的是可见性与替换能力。两者配合,才能写出「既不会有悬空指针、又能在单测里全部替换掉」的代码。

02

为什么需要这个模式

先看一段「没有 DI」的典型坏代码。这是一个订单同步服务:

// ❌ 坏代码:依赖在内部硬编码创建
class OrderSyncService {
public:
    OrderSyncService() {
        repo_    = std::make_unique<MySqlOrderRepository>();  // 硬编码具体类
        mailer_  = std::make_unique<SmtpMailer>("smtp.corp.com"); // 硬编码配置
        metrics_ = std::make_unique<PrometheusClient>();
    }

    bool sync(const std::string& orderId) {
        auto order = repo_->find(orderId);
        if (!order) return false;
        mailer_->send(order->email, "订单已同步");
        metrics_->increment("order.sync");
        return true;
    }
private:
    std::unique_ptr<MySqlOrderRepository> repo_;
    std::unique_ptr<SmtpMailer>            mailer_;
    std::unique_ptr<PrometheusClient>    metrics_;
};

这段代码「能跑」,但每一个维度都被焊死了:

① 无法测试。单元测试里跑一次 sync(),就会真的连 MySQL、真的发邮件、真的上报指标。于是团队只能写「集成测试」,测试从 3 毫秒变成 300 秒,CI 一跑就红,最后没人跑测试。
② 无法替换。产品说「内网客户不用 MySQL,用 SQLite」「测试环境别真发邮件」,你只能在类内部写 #ifdef 或者加一个 bool isTest_ 开关——这就是臭名昭著的「测试污染生产代码」。
③ 编译期强耦合。头文件里必须 #include "MySqlOrderRepository.h",于是换一个仓储实现 = 重新编译整个上层模块,Day 31 学的 PIMPL「编译防火墙」被一句话废掉。
④ 生命周期与配置写死在构造函数里。smtp.corp.com 这种配置项进了代码,运维无法改;如果你还想让 OrderSyncService 持有一个全局共享的 mailer(而不是自己 new 一个),这段代码根本做不到。

SOLID 违背清点:

03

核心思想

生活类比一:插座与电器。你的电吹风不会自己在墙里铺电线、也不会自己建发电厂——它只需要一个「标准插座(接口)」,电从外面送进来。想换成空调供电、想换成发电机供电,电吹风一行都不用改。如果每个电器都自己建发电厂(内部 new),那你换个供电方案就等于重新装修整栋楼。

生活类比二:组装电脑。攒机时,主板不「生产」显卡,它只提供一个 PCIe 插槽(接口);你(组合根)决定今天插 4060、明天插 4080、测试时插一张假的「负载卡」。主板与显卡各自独立演进,中间靠标准协议约定——这正是「针对接口编程,而不是针对实现编程」。

技术上的三层递进(务必分清):

前端类比:你在 Vue/React 里写 props 传依赖、用 provide/inject(Vue)或 Context(React)跨层传递服务实例,本质上就是 DI 的前端形态;而「在组件内部 new Axios()」就等价于上面的坏代码——所以成熟前端工程都会把 axios 实例做成可注入的单例,方便 mock。转 Qt 时,把 props 换成构造函数参数,把 Context 换成组合根,心智模型可以完整迁移。

04

UML / 角色关系

                    ┌──────────────────────────────┐
                    │   «interface»                │
                    │   IOrderRepository           │   ← 抽象(属于业务层)
                    │  + find(id) : optional<Order>│
                    │  + save(order) : bool        │
                    └──────────────▲───────────────┘
                                   │ implements(实现依赖方向:低层 → 抽象)
             ┌─────────────────────┴─────────────────────┐
             │                                           │
   ┌─────────┴──────────┐                     ┌──────────┴─────────┐
   │ MySqlOrderRepository│                    │ FakeOrderRepository │
   │  (生产实现)         │                     │  (测试替身)         │
   └────────────────────┘                     └────────────────────┘
             ▲
             │ 注入(构造时传入,持有抽象引用)
   ┌─────────┴────────────────────────────────┐
   │  OrderSyncService(Consumer / Client)    │   ← 只认识抽象
   │  - repo_    : IOrderRepository*          │
   │  - mailer_  : IMailer*                   │
   │  + sync(orderId)                         │
   └──────────────────────────────────────────┘
             ▲
             │ 组装(唯一出现 new 具体类的地方)
   ┌─────────┴────────────────────────────────┐
   │  main() / AppBootstrapper                │   ← Composition Root 组合根
   └──────────────────────────────────────────┘
角色职责本课对应物
抽象接口
Abstraction
定义业务需要的能力,屏蔽实现细节;定义在业务侧,由业务命名IOrderRepository、ISettingsStorage、ILogger
具体服务
Concrete Service
实现接口,携带真实副作用(IO、网络、硬件)MySqlOrderRepository、FileSettingsStorage
消费者
Client / Consumer
只持有抽象引用并调用之,不知道具体是谁OrderSyncService、SettingsSyncService
测试替身
Test Double
Fake / Stub / Spy,让测试无副作用且可断言调用FakeOrderRepository、InMemoryStorage
组合根
Composition Root
全程序唯一组装对象图的地方;决定谁真谁假、谁共享谁独占main.cpp 里的 AppBootstrapper
(可选)DI 容器
Container
按注册表自动解析依赖图、管理生命周期作用域C++ 里通常不需要,见第 10、13 节

三种注入形式(按推荐度排序):

05

最小 C++ 示例

完整可编译(C++17),一个文件搞定。注意 main() 里出现的两处不同组装,就是「组合根」的全部意义。

// di_minimal.cpp —— 构造函数注入最小示例
// 编译:g++ -std=c++17 -Wall -Wextra di_minimal.cpp -o di_minimal && ./di_minimal
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <algorithm>

// ── ① 抽象接口:业务侧定义,只描述业务需要什么 ──────────────
struct Order { std::string id; std::string email; };

class IOrderRepository {
public:
    // 虚析构必不可少:否则通过基类指针 delete 派生对象是未定义行为
    virtual ~IOrderRepository() = default;
    virtual std::optional<Order> find(const std::string& id) const = 0;
    virtual bool save(const Order& o) = 0;
};

// IMailer 用「窄接口」:只暴露业务真正用到的一个方法(ISP)
class IMailer {
public:
    virtual ~IMailer() = default;
    virtual void send(const std::string& to, const std::string& body) = 0;
};

// ── ② 生产实现 ────────────────────────────────────────────
class MemoryOrderRepository final : public IOrderRepository {
public:
    std::optional<Order> find(const std::string& id) const override {
        for (const auto& o : data_)
            if (o.id == id) return o;
        return std::nullopt;             // C++17,避免用空指针表达「没找到」
    }
    bool save(const Order& o) override {
        std::cout << "[Repo] persist order " << o.id << "\n";
        data_.push_back(o);
        return true;
    }
private:
    std::vector<Order> data_;
};

class ConsoleMailer final : public IMailer {
public:
    void send(const std::string& to, const std::string& body) override {
        std::cout << "[Mail] to=" << to << " : " << body << "\n";
    }
};

// ── ③ 测试替身:记录调用,零副作用 ─────────────────────────
class SpyMailer final : public IMailer {
public:
    void send(const std::string& to, const std::string&) override { sent.push_back(to); }
    std::vector<std::string> sent;   // 测试断言用的「探针」
};

// ── ④ 消费者:只认识抽象,且借用而非拥有 ───────────────────
class OrderSyncService {
public:
    // 构造函数注入:依赖必须一次给全,服务自身没有 new 关键字
    OrderSyncService(IOrderRepository& repo, IMailer& mailer)
        : repo_(repo), mailer_(mailer) {}

    bool sync(const std::string& orderId) {
        const auto order = repo_.find(orderId);
        if (!order) return false;
        mailer_.send(order->email, "order " + order->id + " synced");
        return true;
    }
private:
    IOrderRepository& repo_;      // 引用 = 借用,生命周期不属于我
    IMailer&         mailer_;
};

int main() {
    // ── 场景 A:生产组装(组合根)───────────────────────────
    MemoryOrderRepository repo;
    ConsoleMailer         mailer;
    repo.save({"ORD-1", "alice@corp.com"});

    OrderSyncService prod(repo, mailer);
    prod.sync("ORD-1");
    prod.sync("ORD-9");          // 不存在

    // ── 场景 B:测试组装(同一个 Service 类,零改动)──────────
    MemoryOrderRepository testRepo;
    SpyMailer             spy;
    testRepo.save({"ORD-2", "bob@corp.com"});

    OrderSyncService underTest(testRepo, spy);
    const bool ok = underTest.sync("ORD-2");
    std::cout << "[Test] ok=" << (ok ? "true" : "false")
              << " mails=" << spy.sent.size() << "\n";
    return spy.sent.size() == 1 ? 0 : 1;   // 测试失败则返回码非 0
}

程序输出:

[Repo] persist order ORD-1
[Repo] persist order ORD-1
[Repo] persist order ORD-2
[Mail] to=alice@corp.com : order ORD-1 synced
[Repo] persist order ORD-2
[Test] ok=true mails=1

关键点拆解:①OrderSyncService 里没有一次 new,它甚至不知道数据库存在;②依赖用引用表达「借用」,所有权仍归 main() 里的栈对象,因此绝不会有悬空指针;③同一个类在场景 B 中零修改地被测试,这就是 DI 的全部红利。

06

Qt 实战示例

场景:一个「设置同步服务」SettingsSyncService,把应用配置推送到后端存储(文件 / QSettings / 云端),并记录日志。存储与日志都是注入进来的,因此可以:生产用文件、测试用内存、日志在测试里静音。

① src/ISettingsStorage.h(接口,纯虚,不继承 QObject)

#pragma once
#include <QString>
#include <QVariant>

// 接口刻意不继承 QObject:这样实现类可以自由地再继承 QObject,
// 也避免 QObject 的多继承限制(QObject 只能作为第一个基类)无用武之地。
class ISettingsStorage {
public:
    virtual ~ISettingsStorage() = default;
    virtual bool write(const QString& key, const QVariant& value) = 0;
    virtual QVariant read(const QString& key,
                             const QVariant& fallback = {}) const = 0;
};

② src/ILogger.h(日志接口)

#pragma once
#include <QString>

class ILogger {
public:
    virtual ~ILogger() = default;
    virtual void info(const QString& msg) = 0;
};

③ src/FileSettingsStorage.h / .cpp(生产实现,QObject,用 QSaveFile 原子写)

// FileSettingsStorage.h
#pragma once
#include "ISettingsStorage.h"
#include <QJsonObject>
#include <QObject>
#include <QString>

class FileSettingsStorage : public QObject, public ISettingsStorage {
    Q_OBJECT
public:
    // 文件路径同样由外部注入:测试可以指向 QTemporaryDir
    explicit FileSettingsStorage(QString filePath, QObject* parent = nullptr);

    bool write(const QString& key, const QVariant& value) override;
    QVariant read(const QString& key, const QVariant& fallback) const override;

private:
    QJsonObject load() const;
    QString     filePath_;
};
// FileSettingsStorage.cpp
#include "FileSettingsStorage.h"
#include <QFile>
#include <QJsonDocument>
#include <QSaveFile>        // 原子写:写临时文件 + rename,断电不损坏原文件

FileSettingsStorage::FileSettingsStorage(QString filePath, QObject* parent)
    : QObject(parent), filePath_(std::move(filePath)) {}

QJsonObject FileSettingsStorage::load() const {
    QFile f(filePath_);
    if (!f.open(QIODevice::ReadOnly)) return {};
    return QJsonDocument::fromJson(f.readAll()).object();
}

bool FileSettingsStorage::write(const QString& key, const QVariant& value) {
    QJsonObject obj = load();
    obj.insert(key, QJsonValue::fromVariant(value));

    QSaveFile out(filePath_);
    if (!out.open(QIODevice::WriteOnly)) return false;
    out.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
    return out.commit();      // commit 才真正落盘,返回 false 表示放弃写入
}

QVariant FileSettingsStorage::read(const QString& key,
                                          const QVariant& fallback) const {
    const QJsonObject obj = load();
    return obj.contains(key) ? obj.value(key).toVariant() : fallback;
}

④ src/SettingsSyncService.h / .cpp(消费者:构造注入 + 信号回报)

// SettingsSyncService.h
#pragma once
#include <QObject>
#include <QVariantMap>

class ISettingsStorage;
class ILogger;

class SettingsSyncService : public QObject {
    Q_OBJECT
public:
    // 引用语义:依赖的生命周期由组合根掌握,本类只借用,不负责释放
    SettingsSyncService(ISettingsStorage& storage, ILogger& logger,
                        QObject* parent = nullptr);

    int push(const QVariantMap& values);   // 返回成功写入的字段数

signals:
    void syncCompleted(int written, int failed);

private:
    ISettingsStorage& storage_;
    ILogger&         logger_;
};
// SettingsSyncService.cpp
#include "SettingsSyncService.h"
#include "ILogger.h"
#include "ISettingsStorage.h"
#include <QDebug>

SettingsSyncService::SettingsSyncService(ISettingsStorage& storage,
                                             ILogger& logger,
                                             QObject* parent)
    : QObject(parent), storage_(storage), logger_(logger) {}

int SettingsSyncService::push(const QVariantMap& values) {
    int ok = 0, failed = 0;
    for (auto it = values.constBegin(); it != values.constEnd(); ++it) {
        if (storage_.write(it.key(), it.value())) ++ok; else ++failed;
    }
    logger_.info(QString("settings sync: ok=%1 failed=%2").arg(ok).arg(failed));
    emit syncCompleted(ok, failed);
    return ok;
}

// ConsoleLogger:另一个可注入的实现(放在 ConsoleLogger.h/cpp 亦可)
class ConsoleLogger final : public ILogger {
public:
    void info(const QString& msg) override { qInfo().noquote() << "[app]" << msg; }
};

⑤ src/main.cpp(组合根:全程序唯一知道具体类的地方)

#include "FileSettingsStorage.h"
#include "SettingsSyncService.h"
#include <QCoreApplication>
#include <QStandardPaths>

class ConsoleLogger final : public ILogger {
public:
    void info(const QString& msg) override { qInfo().noquote() << msg; }
};

int main(int argc, char* argv[]) {
    QCoreApplication app(argc, argv);
    QCoreApplication::setApplicationName("settingssync");

    // ── 组装根:所有具体类型只在这里出现一次 ────────────────
    const QString dir = QStandardPaths::writableLocation(
                          QStandardPaths::AppConfigLocation);
    FileSettingsStorage storage(dir + "/settings.json"); // 被借用
    ConsoleLogger       logger;                            // 被借用

    SettingsSyncService service(storage, logger);            // 注入
    QObject::connect(&service, &SettingsSyncService::syncCompleted,
                     [](int ok, int bad) {
                         qInfo() << "done:" << ok << "failed:" << bad;
                     });

    service.push({ {"ui/theme", "dark"}, {"ui/fontSize", 14} });
    return 0;
}

⑥ CMakeLists.txt(Qt6)

cmake_minimum_required(VERSION 3.16)
project(settingssync LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)              # FileSettingsStorage / SettingsSyncService 有 Q_OBJECT

find_package(Qt6 REQUIRED COMPONENTS Core)

# 核心库:只依赖接口所在目录,不依赖任何具体实现的头文件
add_library(sync_core STATIC
    src/FileSettingsStorage.h  src/FileSettingsStorage.cpp
    src/SettingsSyncService.h  src/SettingsSyncService.cpp
)
target_include_directories(sync_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_link_libraries(sync_core PUBLIC Qt6::Core)

add_executable(settingssync src/main.cpp)
target_link_libraries(settingssync PRIVATE sync_core)

# 单测:注入内存实现,零 IO、零真实文件、毫秒级
enable_testing()
add_executable(test_sync tests/test_settings_sync.cpp)
target_link_libraries(test_sync PRIVATE sync_core Qt6::Core)
add_test(NAME sync COMMAND test_sync)

⑦ tests/test_settings_sync.cpp(注入测试替身,无需 Qt Test 框架)

#include "SettingsSyncService.h"
#include <QCoreApplication>
#include <QHash>
#include <cassert>

class InMemoryStorage final : public ISettingsStorage {
public:
    bool write(const QString& k, const QVariant& v) override { map_.insert(k, v); return true; }
    QVariant read(const QString& k, const QVariant& f) const override { return map_.value(k, f); }
    QHash<QString, QVariant> map_;      // 供断言
};

class SilentLogger final : public ILogger {
public:
    void info(const QString&) override {}          // 测试里不刷屏
};

int main(int argc, char* argv[]) {
    QCoreApplication app(argc, argv);
    InMemoryStorage   storage;
    SilentLogger      logger;
    SettingsSyncService svc(storage, logger);

    int signalled = 0;
    QObject::connect(&svc, &SettingsSyncService::syncCompleted,
                     [&signalled](int, int) { ++signalled; });

    const int written = svc.push({ {"a", 1}, {"b", 2} });

    assert(written == 2);
    assert(signalled == 1);
    assert(storage.read("a").toInt() == 1);
    assert(storage.read("missing", 42).toInt() == 42);
    qInfo() << "all assertions passed";
    return 0;
}

一个必须讲清楚的 Qt 特例——QObject 的所有权 vs DI 的所有权。上面的 FileSettingsStorage 同时继承了 QObject 与 ISettingsStorage,这没问题(QObject 放第一个基类即可);但不要让 ISettingsStorage 本身继承 QObject——接口一旦带上 QObject,任何实现类都无法再继承别的 QObject 派生类,接口也不再是「纯粹的能力描述」。另一条规则:用裸指针/引用注入「别人拥有的对象」,用 unique_ptr 注入「我独占的对象」,绝不用裸指针表达所有权。当依赖是 QObject 且带 parent 时,Qt 的父子树已经在管生命周期,此时注入裸指针是安全的、也是唯一合理的做法——因为所有权已经由 parent 明确。

07

代码执行流程

① 组装(Composition):main() 中按顺序创建低层对象——先 FileSettingsStorage、ConsoleLogger,它们此刻还只是普通对象,不知道谁会用到自己。
② 注入(Injection):构造 SettingsSyncService 时把两者的引用传进去,成员变量 storage_/logger_ 绑定到具体对象。此时「业务逻辑」与「IO 实现」才在内存中第一次相遇。
③ 调用(Service Call):业务代码调用 push(),里面执行的是 storage_.write(...)——由于 storage_ 是引用且被声明为接口类型,编译器这次调用走的是虚函数表(vtable)动态派发,实际执行的是 FileSettingsStorage::write。
④ 回报与销毁(Callback & Teardown):循环写完后通过 emit syncCompleted(ok, failed) 通知外部;函数返回时栈上对象按构造逆序析构(service → logger → storage),因为服务只持有引用,析构过程不会重复释放任何东西——这正是「借用语义」带来的安全性。

一次调用链的完整时间线:

main()
 ├─ FileSettingsStorage storage(path)     // 具体实现诞生
 ├─ ConsoleLogger       logger
 ├─ SettingsSyncService service(storage, logger)
 │    └─ storage_ = storage (引用绑定)      // 注入完成,编译期即确定
 ├─ service.push({{"ui/theme","dark"}, ...})
 │    ├─ storage_.write("ui/theme", "dark")
 │    │    └─ vtable 派发 → FileSettingsStorage::write
 │    │         ├─ load() 读旧 JSON
 │    │         ├─ obj.insert(...)
 │    │         └─ QSaveFile::commit() 原子落盘
 │    └─ emit syncCompleted(2, 0) → lambda 打印
 └─ 作用域结束:~service → ~logger → ~storage   // 逆序,无泄漏
08

为什么这样设计

① 解耦点:接口成为「缝」(Seam)。DI 把「谁提供能力」和「谁使用能力」切开,两条编译单元之间只共享一个纯虚接口头文件。结果是:改 FileSettingsStorage.cpp 不需要重编译 SettingsSyncService.cpp;反过来,向业务模块新增一个实现,业务模块一行不动。seam 这个词很重要——Michael Feathers 说「可测试性 = 你能在多大程度上把程序切开并替换其中一段」,接口就是刀口。

② 扩展点:新增实现 = 新增文件。产品要加「云端同步」,你写 CloudSettingsStorage 实现同一个接口,然后在组合根换一行。业务的 push() 逻辑、它的测试、它的信号协议,全部零改动——这就是 OCP(对扩展开放、对修改关闭) 的教科书形态。

③ DIP:让抽象归属高层。接口放在业务目录、由业务命名(ISettingsStorage 而不是 IMySqlWriter),保证「抽象不为某个实现量身定做」。如果你发现自己为了迁就某个数据库的怪癖而在接口里加方法,说明抽象被低层污染了,该重构。

④ 组合优于继承。如果不用 DI,「不同环境用不同仓储」只能靠继承:TestOrderSyncService : OrderSyncService 覆写 createRepository()——一旦有 2 个维度(仓储 × 邮件器)就要 4 个子类,3 个维度就 8 个,这就是类爆炸。DI 把这些维度变成构造参数的正交组合,N 个维度仍然只有 N 个实现类。

⑤ 可测试性:测试替身是 DI 的直接产物。测试代码里 OrderSyncService underTest(testRepo, spy) 一行就完成替换,测试无 IO、无网络、确定性、毫秒级。更关键的是:可以给替身塞入「抛出异常的存储」,去验证业务在失败路径上的行为——这是真实数据库极难做到的。

⑥ 让所有权显式(C++ 特有收益)。用 unique_ptr 表示「我拥有」,用 T&/T* 表示「我借用」,用 shared_ptr 表示「共享所有权」。DI 把这个决策提前到构造函数签名里,读签名就知道谁负责释放——这是 C++ 里比 Java/C# 更需强调的一点,也是避免悬空指针最有效的手段。

09

不使用会怎样

反例一:if-else / switch 膨胀。「支持三种存储后端」写进一个类里:

// ❌ 每加一种后端,这个函数就要改一次;分支还会互相纠缠
bool SettingsStore::write(const QString& k, const QVariant& v) {
    switch (backend_) {
    case Backend::File:   return writeFile(k, v);
    case Backend::Sqlite: return writeSqlite(k, v);
    case Backend::Cloud:  return writeCloud(k, v);   // 网络失败还得分支重试
    case Backend::Test:   return true;                  // 测试逻辑混进生产代码
    }
    return false;
}

这是典型的「数据驱动的类型码 + switch」坏味道。类被迫成为「所有实现的集合」,任何一个后端的编译错误都会阻塞所有人;单元测试还必须把 Backend::Test 这条分支也一起带上。

反例二:类膨胀(Cartesian Explosion)。用继承组合维度:SyncServiceFile、SyncServiceSqlite、SyncServiceFileTest、SyncServiceSqliteTest……每加一个维度类数量翻倍,而且大部分子类只为了覆写一行创建逻辑。

反例三:单例做「隐蔽依赖」。没有 DI 时最省事的做法是全局单例:

// ❌ 依赖被藏进函数体,签名上完全看不出来
int SettingsSyncService::push(const QVariantMap& values) {
    auto* storage = StorageSingleton::instance();  // 谁在提供?不知道
    auto* logger  = LoggerSingleton::instance();   // 何时初始化?不知道
    ...
}

后果是:①构造函数签名撒谎——看不出有依赖;②单例在进程内全局共享状态,测试之间互相污染,必须写 resetForTest() 这种补丁;③初始化顺序不确定(C++ 静态初始化顺序问题),单例在别的静态对象构造函数里被用到时可能是空指针;④多线程下要么加锁变慢,要么裸用变危险。DI 用「构造参数」把依赖写在签名上,一切暴露在阳光下。

反例四:链接期雪崩。因为业务头文件直接 include 了 MySQL 驱动头文件,一个驱动升级导致整个项目重新编译;CI 里一次改动触发 40 分钟构建。DI/PIMPL 组合能把这道墙拆掉。

10

何时使用

适合的场景:

不适合 / 应避免的场景:

过度设计提醒(很常见):①为了 DI 而 DI——一个只有 30 行的工具类被拆成 3 个接口 + 4 个实现,读者要跳 7 个文件才能看懂一次调用;②接口没有语义——IStringProcessor { virtual QString run(QString) = 0; } 这种「万能接口」把所有实现都当成匿名函数,抽象没有表达业务语言,等于没有抽象;③抽象泄漏——接口方法名带着实现细节(executeSqlWithRetry),一旦换实现接口就不成立;④迷信 DI 容器——在 C++ 里引入运行时反射/容器框架,往往换来运行时失败、编译期检查丢失和一堆看不懂的注册代码,收益远小于成本。经验法则:当一个类出现了「测试时必须连真实环境」的症状,就是该上 DI 的信号;没这个症状,就别急着加接口。

11

与其他模式的区别

对比项质的不同判据
DI vs 工厂模式
(Day 2/3)
工厂回答「对象怎么被创建」——把 new 收拢到一处;DI 回答「对象从哪获得依赖」——把已创建的对象递进去。二者互补而非替代:DI 的组合根里经常用工厂来创建"按参数决定实现"的对象;而工厂返回的对象,也可以由外部注入给消费者。 看方向:向外产出对象是工厂;向内接收对象是注入。
DI vs 服务定位器
Service Locator
服务定位器是「拉」(pull):类内部调用 Services::get<ILogger>() 自己去取;DI 是「推」(push):依赖由外部送来。可测试性是分水岭——定位器要求全局注册表在测试前被正确配置(隐式全局状态、失败在运行期抛出「服务未注册」),DI 则编译期就不允许漏传(构造函数参数少了编不过)。结论:优先 DI,定位器只作为框架边界的妥协(如插件系统里给插件的服务入口)。 看依赖是否出现在签名里:出现在签名/构造函数 = DI;藏在函数体内查表 = 定位器。
DI vs 策略模式
(Day 20)
策略模式关心「把一个算法族做成可替换的成员」,它本身就是靠注入(构造或 setter)落地的;区别在抽象粒度与意图:策略的抽象通常是「同一种计算的变体」(排序、压缩、计费规则),且常需运行期切换;DI 的抽象是「外部世界的能力」(仓储、时钟、日志),通常在组装时一次性定好。可以说:策略是 DI 在"算法"这一维度的特例。 抽象概念是「算法」还是「协作方」;切换发生在运行中还是组装时。
DI vs 依赖倒置原则
DIP
DIP 是原则(依赖应指向抽象,抽象不依赖细节);DI 是手段。可以用 DIP 但不用 DI(如模板泛型编程——编译期多态也是依赖抽象);也可以用了 DI 却违背 DIP(注入了一个具体类指针 MySqlRepo*,仍然耦合)。 问自己:注入的类型是纯虚接口/模板参数,还是具体类?后者只是「把耦合推迟到构造函数」,测试依然难做。

还有两个常被混淆的概念:控制反转(IoC)是更大的范畴(「程序流程的控制权交给框架」,如 Qt 的 QCoreApplication::exec() 事件循环、回调、信号槽都是 IoC),DI 只是 IoC 在「依赖获取」这一维度的实现;模板泛型注入 vs 运行期多态注入:template<class Repo> class Sync { Repo& repo_; } 属于编译期 DI,零虚函数开销但当依赖需要在运行期决定时不够用——工程上常见做法是「默认模板(快)+ 边界处虚接口(灵活)」。

12

Qt 源码中的体现

Qt 没有 DI 框架,但它的 API 设计里到处都是 DI 思想——尤其是「构造函数注入」和「setter 注入」两种形式:

反过来,Qt 中「不该学」的地方也要认清:QObject::connect 是消息机制而非 DI(它传的是「事件通知」,不是「能力依赖」);QSettings 的默认构造(无参)会去猜组织名/应用名并直接落在注册表或 plist 上——这是「内部创建依赖」的典型,测试时必须用带路径的构造函数(QSettings(path, QSettings::IniFormat)),也就是DI 视角下的"手动注入依赖"。Qt 官方在 Qt 6 中对 QSettings 的测试建议正是如此。

13

面试常见问题

Q1:依赖注入是设计模式吗?和 IoC、DIP 是什么关系?

答:不是 GoF 23 之一,而是 Martin Fowler 命名的「一种 IoC 的具体实现技术」。层次从大到小是:IoC(控制反转,思想层)→ DIP(依赖倒置,设计原则)→ DI(依赖注入,实现手段)。IoC 泛指「框架调用你的代码」(Qt 事件循环、回调、信号槽都是 IoC);DIP 说「高层和低层都依赖抽象」;DI 回答「抽象实例怎么进到消费者手里」。面试时把这三层讲清,比背定义得分高得多。

Q2:构造函数注入、setter 注入、方法注入怎么选?

答:默认构造函数注入——依赖不可缺失、对象一建成即完整可用、可加 const/引用、天然适配 C++ RAII。当依赖是可选的或运行期必须替换时用 setter 注入(如 setStyle、setSourceModel),但要接受「中间状态」并用断言/默认实现兜底。方法注入留给「每次调用都可能不同」的上下文(时间、事务、请求级 ID)。反模式是「构造函数注入 + 允许空指针」——那等于把必选依赖变成了可能忘记的必填项。

Q3:一定要用 DI 容器吗?C++ 里为什么很少见?

答:不需要。容器的价值在于「依赖图很深、对象很多、需要按作用域自动解析」——这在 Java/Spring 的大型服务端有意义。C++ 里的替代品是组合根 + 手动装配:几十行 main() 就能把手写装配做完,而且编译期检查、零反射、零启动开销、栈上析构。引入容器会丢掉这些优势,还要付出运行时「未注册」异常、调试栈变复杂、宏/反射代码难读的代价。经验值:当 main() 的装配代码超过 100 行且重复度很高时,才考虑写一个轻量的注册表(而不是引第三方容器)。

Q4:两个类互相依赖(A 需要 B,B 需要 A)怎么办?

答:这是循环依赖,先别急着改注入方式,而要问「是不是职责划分错了」。三条实用路径:①拆接口——A 依赖 IB,B 依赖 IA,两个接口都定义在各自模块,实现类只出现在组合根,编译期即可解开;②延迟注入 / 回调——其中一个依赖用 std::function 或信号槽在运行期绑定,构造时不需要对方实体(Qt 里这几乎是默认解法:connect 本身就是运行期解耦);③引入中介者(Day 16)把双向引用降级为「双方都依赖中介者」。真正需要警惕的是「注入一个 *_ptr 后再调用对方构造函数」的写法——那会把构造顺序变成隐式契约。

Q5:Qt 里 QObject 有 parent 父子树,还需要 DI 吗?两者冲突吗?

答:不冲突,它们管的是两件事:parent 管生命周期(谁释放谁),DI 管可见性与替换能力(谁能看到谁、能不能换掉)。实操规则三条:①接口不要继承 QObject(否则实现类不能再继承其他 QObject 派生类,抽象也被污染);②实现类可以「QObject + 接口」双继承,QObject 放第一位,构造时把 parent 透传上去;③所有权只有一种表达——对象归 parent 管就注入裸引用/裸指针,对象归自己就注入 unique_ptr,绝不用裸指针假装所有权。QObject 还有一个与 DI 相关的坑:QObject 不可拷贝、不可赋值,因此它们只能通过指针/引用注入,这也反过来印证了「注入借用语义」在 Qt 中是主流。

14

今日练习

目标(约 20–30 分钟):把 Day 14 的命令模式(Command)实战代码改造为依赖注入版本,并为它补上第一个真正的单元测试。

需求:
① CommandInvoker 目前内部直接 new 了 QUndoStack / 日志对象 / 计时器。把这三个依赖改为构造函数注入,接口自定:至少给「日志」和「计时(IClock,用 std::chrono::steady_clock 做真实实现)」各定义一个纯虚接口;QUndoStack 直接沿用 QUndoStack& 引用注入即可(不需要为它写接口)。
② 写一个 FakeClock,让「命令耗时统计」在测试里返回固定值(如每次推进 100 ms),并断言 invoker 记录的总耗时等于 命令数 × 100。
③ 把「执行 / 撤销」两条路径都覆盖:执行后 undoStack 的 count() 增加,撤销后 index() 回到 0。
④ 要求:CommandInvoker.h 中不得出现任何具体实现类的头文件(只允许接口 + 前置声明),并在 CMake 里把核心库与测试目标分开链接。

提示一:改造时遵循「先抽接口 → 再让具体类实现 → 最后动消费者构造函数」的顺序,每步都保持能编译(小步重构)。如果发现某个依赖实在难抽接口,先问自己:它是否有副作用或全局状态?如果没有,也许它根本不需要注入。
提示二:所有权用签名说话——CommandInvoker(QUndoStack& stack, ILogger& log, IClock& clock) 全是引用表示「借用」,调用方的栈对象负责生命周期;如果你希望 invoker 独占日志,则改用 std::unique_ptr<ILogger> 并配 = std::move(...)。不要混用:同一个类里一半 unique_ptr 一半裸指针表达所有权,是 Code Review 的重点枪口。进阶(可选):给 CommandInvoker 加一个「命令工厂注入」,让它能按名字创建命令——这就是 DI 与工厂模式的组合,也正好复习 Day 2。

15

今日总结

一句话记忆:依赖注入就是「把 new 从类里赶到 main() 里去」——类只声明它需要什么抽象,由组合根决定给谁;于是业务逻辑与 IO 实现各自独立演进,测试不再需要真实环境。

代码特征信号(看到这些就是 DI):
① 构造函数参数里出现纯虚接口的引用/指针(IOrderRepository& repo、ILogger& log),而成员变量类型是抽象而非具体类;
② 类的 .h 文件只 include 接口头 + 前置声明,看不到任何具体实现(MySql*、File*)的 include;
③ 成员声明上写着 Xxx& dep_; 或 std::unique_ptr<IXxx> dep_;,并在构造函数初始化列表里绑定(: repo_(repo));
④ 全工程唯一出现 make_unique<具体实现> 的地方集中在 main.cpp / *Bootstrapper / 工厂里(组合根);
⑤ tests/ 目录下存在 FakeXxx / InMemoryXxx / SpyXxx 类,测试代码里没有一行真实 IO,且能对替身做断言。

今日与昨日的关系:Day 31 的 PIMPL 解决「实现对外不可见」(编译防火墙),今天解决「依赖从外注入」(可替换性)——两者合起来,就是一个既能快速编译、又能无痛测试的 C++/Qt 模块该有的样子:头文件只有抽象,实现全在 .cpp,具体类型只在组合根出现一次。

明日预告 · Day 33:类型擦除(Type Erasure,std::function / std::any / QMetaObject 背后的机制)。今天我们用「虚接口 + 引用」实现了运行期多态,代价是每个消费类都要为一个新接口写模板式代码(一个 ILogger 接口只能服务日志)。明天要学的是:如何用一个类型去装下任意实现——它正是 std::function、std::any、Qt 的 QVariant 与信号槽参数传递的底层原理。内容包含外部多态/内部多态、概念建模(Concept-based 的 drawable)、SBO 小对象优化,以及「什么时候该写虚接口、什么时候该写类型擦除」的工程判据。