组件的「可用」不只是编译通过

一个组件被正确使用时工作正常,这不叫设计好。一个组件被错误使用的时候能够清晰地告诉用户错在哪、怎么改,这才叫设计好。

这篇文章讲三样东西的配合: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