对象不再自己 new 依赖,而是由外部把依赖「递」进来。它是依赖倒置原则(DIP)最常用的落地手段——上一周我们学了「怎么隐藏实现」(PIMPL),今天学「怎么获得实现」。
依赖注入(Dependency Injection):一个对象需要的协作者(依赖),不在它内部创建,而是由外部在创建它的时候传入——通过构造函数参数、setter 方法或接口方法。一句话:把 new 从类内部搬到程序的最外层(组合根 Composition Root)。
它和 Day 20 的策略模式长得很像,但关注点完全不同:策略模式关心「运行时切换同类算法」,依赖注入关心「对象的依赖从哪来」。DI 也不是 GoF 23 模式之一,它是一种实现依赖倒置原则(DIP)的技术手段——是 Martin Fowler 在 2004 年正式命名的「控制反转(IoC)的一种具体形式」。
对 Qt 开发者,DI 有额外的一层意义:Qt 的 QObject 父子树天然管的是生命周期,而 DI 管的是可见性与替换能力。两者配合,才能写出「既不会有悬空指针、又能在单测里全部替换掉」的代码。
先看一段「没有 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 违背清点:
OrderSyncService 的构造函数——对扩展开放、对修改关闭没有做到。SmtpMailer 的所有方法。
生活类比一:插座与电器。你的电吹风不会自己在墙里铺电线、也不会自己建发电厂——它只需要一个「标准插座(接口)」,电从外面送进来。想换成空调供电、想换成发电机供电,电吹风一行都不用改。如果每个电器都自己建发电厂(内部 new),那你换个供电方案就等于重新装修整栋楼。
生活类比二:组装电脑。攒机时,主板不「生产」显卡,它只提供一个 PCIe 插槽(接口);你(组合根)决定今天插 4060、明天插 4080、测试时插一张假的「负载卡」。主板与显卡各自独立演进,中间靠标准协议约定——这正是「针对接口编程,而不是针对实现编程」。
技术上的三层递进(务必分清):
IOrderRepository* 而不是 MySqlOrderRepository。这是前提。IOrderRepository 应该放在 domain/ 目录,不是 infra/mysql/。
前端类比:你在 Vue/React 里写 props 传依赖、用 provide/inject(Vue)或 Context(React)跨层传递服务实例,本质上就是 DI 的前端形态;而「在组件内部 new Axios()」就等价于上面的坏代码——所以成熟前端工程都会把 axios 实例做成可注入的单例,方便 mock。转 Qt 时,把 props 换成构造函数参数,把 Context 换成组合根,心智模型可以完整迁移。
┌──────────────────────────────┐
│ «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 节 |
三种注入形式(按推荐度排序):
const/引用、天然不可变。C++ 中配合 std::unique_ptr 表达所有权,配合裸引用/指针表达「借用」。setRepository(IRepo*)。适合可选依赖或运行期必须能替换的依赖。缺点:存在「对象已创建但依赖还没注入」的中间状态,必须靠文档或断言兜底。sync(orderId, IClock& clock)。适合「每次调用都可能不同」的依赖,如时间戳、随机数、事务上下文。使用率最低,但能让时间在测试中可控(注入 FakeClock 替代 now())。完整可编译(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 的全部红利。
场景:一个「设置同步服务」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 明确。
① 组装(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 // 逆序,无泄漏
① 解耦点:接口成为「缝」(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# 更需强调的一点,也是避免悬空指针最有效的手段。
反例一: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 组合能把这道墙拆掉。
适合的场景:
IClock 就能让时间可控,这是最被低估的一条。IPluginContext(日志、设置、主窗口句柄)注入给插件,插件反过来实现宿主定义的抽象——这是 DI 在进程边界的延伸。不适合 / 应避免的场景:
struct Order、QPoint、QString 不是「服务」,没有行为可替换,注入它们只是噪音。std::vector 或 QVector 包一层接口毫无价值——你不会想为「容器」写替身,用真实容器即可。
过度设计提醒(很常见):①为了 DI 而 DI——一个只有 30 行的工具类被拆成 3 个接口 + 4 个实现,读者要跳 7 个文件才能看懂一次调用;②接口没有语义——IStringProcessor { virtual QString run(QString) = 0; } 这种「万能接口」把所有实现都当成匿名函数,抽象没有表达业务语言,等于没有抽象;③抽象泄漏——接口方法名带着实现细节(executeSqlWithRetry),一旦换实现接口就不成立;④迷信 DI 容器——在 C++ 里引入运行时反射/容器框架,往往换来运行时失败、编译期检查丢失和一堆看不懂的注册代码,收益远小于成本。经验法则:当一个类出现了「测试时必须连真实环境」的症状,就是该上 DI 的信号;没这个症状,就别急着加接口。
| 对比项 | 质的不同 | 判据 |
|---|---|---|
| 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,零虚函数开销但当依赖需要在运行期决定时不够用——工程上常见做法是「默认模板(快)+ 边界处虚接口(灵活)」。
Qt 没有 DI 框架,但它的 API 设计里到处都是 DI 思想——尤其是「构造函数注入」和「setter 注入」两种形式:
QTextStream(QIODevice*)QTextStream 不知道数据流向文件还是 socket,它只接收一个抽象的 QIODevice*。QFile、QTcpSocket、QBuffer、QProcess 都是同一个抽象的派生——这就是教科书级的 DI:依赖抽象 + 由外部提供。QGraphicsView(QGraphicsScene*) / QWidget::setLayout()View 不负责创建 Scene,只是接受它(构造函数注入);而 QLayout 通过 setLayout() 注入(setter 注入)——因为布局允许被替换。Qt 在这两个场景里选择了两种不同的注入形式,正好对应我们第 4 节的 A/B 两条规则。QApplication::setStyle(QStyle*) / QWidget::setItemDelegate()「换皮肤」「换渲染策略」都是把实现对象的控制权交给外部:QApplication::setStyle(new QFusionStyle)、view->setItemDelegate(new MyDelegate)。应用开发者常常在 main() 里做这件事——那行 setStyle 所在的 main() 就是组合根。QSortFilterProxyModel::setSourceModel()代理模型必须能换源模型,这是 Model/View 架构可组合的关键——Day 30 学的模型-视图架构之所以能任意套娃(源模型 → 过滤 → 排序),正是因为依赖是注入的而不是内部 new 的。反例参考:QSqlDatabase::addDatabase() 的全局连接名机制,本质是一个「服务定位器」,它带来了线程约束和全局状态管理成本,Qt 文档里专门有一节讲「线程与连接名」,是定位器比 DI 更难用的真实写照。QPluginLoader 与插件接口Qt 官方插件范式要求插件实现一个继承 QObject 的纯虚接口(如自定义 MyInterface + Q_DECLARE_INTERFACE),宿主用 qobject_cast<MyInterface*>(loader.instance()) 取得抽象指针——Day 27 讲过:插件的本质就是「运行期把实现注入进程」。
反过来,Qt 中「不该学」的地方也要认清:QObject::connect 是消息机制而非 DI(它传的是「事件通知」,不是「能力依赖」);QSettings 的默认构造(无参)会去猜组织名/应用名并直接落在注册表或 plist 上——这是「内部创建依赖」的典型,测试时必须用带路径的构造函数(QSettings(path, QSettings::IniFormat)),也就是DI 视角下的"手动注入依赖"。Qt 官方在 Qt 6 中对 QSettings 的测试建议正是如此。
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 中是主流。
目标(约 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。