组件的「可用」不只是编译通过
一个组件被正确使用时工作正常,这不叫设计好。一个组件被错误使用的时候能够清晰地告诉用户错在哪、怎么改,这才叫设计好。
这篇文章讲三样东西的配合:concept 定义接口契约,static_assert 守住类型底线,编译期测试验证组件行为。
三层各司其职:
| 层次 | 工具 | 拦截时机 | 反馈给谁 |
|---|---|---|---|
| 接口层 | concept | 模板实例化时 | 组件使用者 |
| 实现层 | static_assert | 模板实例化时 | 组件使用者 |
| 测试层 | static_assert + 编译期测试 | CI / 构建时 | 组件开发者 |
第一层:Concepts 定义接口契约
前面那篇已经讲过 concept 的语法,这里聚焦一个观点:concept 不只是约束,它是组件的「类型自述文档」。
看一个概念清晰和不清晰的对比:
// 不清晰:谁知道 U 要支持什么操作?
template <typename T, typename U>
auto combine(const T& a, const U& b) {
return a.value() + b.value();
}
// 清晰:concept 就是文档
template <typename T>
concept HasValue = requires(const T& x) {
{ x.value() } -> std::convertible_to<int>;
};
template <HasValue T, HasValue U>
auto combine(const T& a, const U& b) {
return a.value() + b.value();
}
看第二个版本的函数签名,不需要读函数体就知道:「传两个有 .value() 返回 int 的东西进来」。
好的 concept 命名本身就是最好的文档。 建议用动名词或形容词命名:
Sortable— 可以排序HasWriteMethod— 有 write 方法Serializable— 可序列化ThreadSafe— 线程安全
第二层:static_assert 守住底线
Concept 管的是「有没有这个方法」,static_assert 管的是「类型特性对不对」。
template <typename T>
class ThreadSafeQueue {
static_assert(std::is_nothrow_move_constructible_v<T>,
"ThreadSafeQueue: T must be nothrow move constructible. "
"If move can throw, the queue cannot guarantee strong exception safety.");
static_assert(std::is_nothrow_destructible_v<T>,
"ThreadSafeQueue: T must be nothrow destructible.");
// ...
};
为什么不用 concept 做这个?因为 is_nothrow_move_constructible 是标准库已经有的类型 trait,用 static_assert 配合自定义错误信息比包一层 concept 更直接。
两者的分工
// concept → 定义了「接口形状」
template <typename T>
concept OutputPolicy = requires(T& t, std::string_view msg) {
{ t.write(msg) } -> std::same_as<void>;
};
// static_assert → 守住「类型底线」
template <typename T>
class Logger : private T {
static_assert(sizeof(T) <= 64,
"Logger: OutputPolicy type is too large (>64 bytes). "
"Consider using a pointer-to-impl pattern.");
static_assert(std::is_destructible_v<T>,
"Logger: OutputPolicy must be destructible.");
public:
// ...
};
一句话概括:concept 说「你要长这样」,static_assert 说「你这些维度不能超标」。
第三层:编译期测试
运行时测试大家都会写。编译期测试的思路是:用 static_assert 验证类型行为,不需要运行程序。
最简单的编译期测试
// 测试:Singleton 的行为契约
class TestSingleton : public Singleton<TestSingleton> {
friend class Singleton<TestSingleton>;
TestSingleton() = default;
};
// 测试 1:instance() 返回引用
static_assert(std::is_reference_v<decltype(TestSingleton::instance())>);
// 测试 2:返回的是正确的类型
static_assert(std::is_same_v<TestSingleton&, decltype(TestSingleton::instance())>);
// 测试 3:不能拷贝
static_assert(!std::is_copy_constructible_v<TestSingleton>);
static_assert(!std::is_move_constructible_v<TestSingleton>);
// 测试 4:两次调用返回同一个对象
// 这个没法完全在编译期测(涉及地址比较),但可以用 constexpr 辅助
用 constexpr 函数做编译期行为测试
// 测试 Builder 的 build() && 语义
constexpr bool test_builder_rvalue_only() {
// 正常流程:右值调用 build()
auto req = HttpRequest::builder()
.url("https://test.com")
.method("GET")
.build();
// req 是值,可以用
(void)req.url();
return true;
}
static_assert(test_builder_rvalue_only());
// 下面的代码如果取消注释,会编译失败——这就是我们要验证的行为
// constexpr bool test_builder_lvalue_fails() {
// auto b = HttpRequest::builder();
// auto req = b.build(); // 错误:build() 要求右值
// return true;
// }
这种方式的价值:你不需要写一个 main()、不需要链接、不需要跑可执行文件,编译通过就说明组件契约成立。
用 SFINAE 或 concept 验证「某种用法不能编译」
更进阶的玩法是验证「错误使用必须编译失败」。这个需要几个辅助工具:
// 辅助:检查一个表达式是否能编译
template <typename F, typename... Args>
concept CanCall = requires(F f, Args... args) {
f(std::forward<Args>(args)...);
};
// 测试:Singleton 不能被拷贝构造
auto test_no_copy = [] {
TestSingleton a;
// auto b = a; ← 这一行不能编译
};
static_assert(!CanCall<decltype(test_no_copy)>);
有专门的测试框架(如 static_assert_test、Boost 的 TMP 测试工具)可以做更完善的编译期行为验证,但对于大多数组件项目,上面的手法已经够用了。
实战:一个完整的组件契约
把三层组合起来,看一个完整的 Signal(信号槽)组件头文件结构:
// signal.hpp
#pragma once
#include <functional>
#include <memory>
#include <vector>
#include <mutex>
#include <type_traits>
#include <concepts>
namespace patterns {
// ====== 第一层:Concept 接口契约 ======
template <typename F, typename... Args>
concept CallableWith = requires(F f, Args... args) {
{ f(std::forward<Args>(args)...) };
// 不要求返回值类型,void 或其他都可以
};
// ====== 第二层:static_assert ======
template <typename F, typename... Args>
constexpr void checkSlotRequirements() {
static_assert(std::is_invocable_v<F, Args...>,
"Signal::connect: the callable must be invocable with the signal's argument types.");
static_assert(sizeof(F) <= 128,
"Signal::connect: callable object is too large. Consider wrapping in std::shared_ptr.");
}
// ====== Signal 本身 ======
template <typename... Args>
class Signal {
struct SlotBase {
virtual ~SlotBase() = default;
virtual void invoke(Args...) = 0;
};
template <typename F>
struct Slot : SlotBase {
F fn_;
Slot(F f) : fn_(std::move(f)) {}
void invoke(Args... args) override { fn_(std::forward<Args>(args)...); }
};
std::vector<std::unique_ptr<SlotBase>> slots_;
public:
template <CallableWith<Args...> F>
void connect(F&& fn) {
checkSlotRequirements<std::decay_t<F>, Args...>();
slots_.push_back(std::make_unique<Slot<std::decay_t<F>>>(
std::forward<F>(fn)));
}
void emit(Args... args) {
for (auto& slot : slots_) {
slot->invoke(std::forward<Args>(args)...);
}
}
};
// ====== 第三层:编译期测试(同文件底部或单独的 test 文件) ======
#ifdef PATTERNS_SELF_TEST
static_assert([] {
Signal<int, int> sig;
int sum = 0;
sig.connect([&sum](int a, int b) { sum = a + b; });
sig.emit(3, 4);
return sum == 7;
}());
#endif
} // namespace patterns
开启 -DPATTERNS_SELF_TEST 编译时,static_assert 里的 lambda 会被立即执行,验证基本行为是否正确。这个测试在编译期间运行,不产生任何运行时开销。
三层契约的总结
flowchart TD
A["用户使用组件"] --> B
subgraph L1["第一层:Concept 接口形状"]
B["你有 .write() 方法吗?"]
B -->|没有| B1["❌ 编译错误<br/>告诉你缺什么"]
end
B -->|通过了| C
subgraph L2["第二层:static_assert 类型底线"]
C["你的类型够小吗?<br/>移动不抛异常吗?"]
C -->|不满足| C1["❌ 编译错误<br/>告诉你为什么不行"]
end
C -->|通过了| D
subgraph L3["第三层:编译期测试 行为验证"]
D["实际行为符合预期吗?"]
D -->|不通过| D1["❌ 编译错误<br/>组件作者修 bug"]
end
D -->|通过了| E["✅ 组件可用"]
这三层不是负担,是投资。写的时候多花五分钟,后续每个用你组件的人少花五小时。
系列文章:C++ 组件化基础 | 作者:Logic