Skip to content

编译期计算——让错误在编译时就炸

概念介绍

C++ 程序员有一个执念:能尽早暴露的错误,绝不拖到运行时。编译期计算(compile-time computation)就是这个执念的具体体现——让一部分计算在编译阶段就完成,如果计算过程中发现了错误,编译直接失败,而不是等到程序跑起来才发现不对劲。这个理念从 C++11 的 constexpr 开始,到 C++14/17 逐步放宽限制,再到 C++20/23 的 consteval 和更强大的编译期能力,已经形成了一套非常成熟的机制。

我们先来区分几个容易混淆的关键字。constexpr 用在变量上表示"这个变量的值在编译期就可以确定",用在函数上表示"这个函数可能在编译期被求值——如果调用它的上下文需要一个编译期常量(比如模板参数、数组大小),那它就在编译期算;否则它就在运行期算"。也就是说 constexpr 函数具有"双重身份",它既可以在编译期执行也可以在运行期执行,取决于你怎样使用它。

consteval 是 C++23 引入的关键字(不过很多编译器在 C++20 模式下就支持它作为扩展),用在函数上表示"这个函数必须在编译期被求值"。和 constexpr 不同,consteval 函数没有运行期身份——你调用它,就一定是在编译期算完的,没有任何商量余地。如果编译器发现无法在编译期完成计算,直接报错。这个特性特别适合用来做编译期常量生成和类型属性检查。

然后是 static_assert,它是编译期断言,接受一个编译期布尔表达式和一个可选的字符串消息。如果表达式为 false,编译直接失败并显示消息。它和运行时的 assert 不同——static_assert 不需要运行程序就能发现错误,而且不会产生任何运行时开销。

最后是编译期变量的声明方式。constexpr 变量本身就是编译期常量,而 C++20 引入的 constinit 关键字可以确保一个变量在编译期初始化(但运行时可以修改),这在嵌入式开发中对于避免静态初始化顺序问题非常有用。

动机

为什么要费劲把计算搬到编译期?我们来看一个实际的场景。假设你在写一个图像处理库,你的像素格式有一个 channels 属性表示通道数,你需要根据通道数和值类型计算每个像素占多少字节。这个值对于同一种像素格式来说是固定的——BGR 永远是 3 字节,RGBA 永远是 4 字节。如果你把这个计算放到运行时,每次调用都要算一遍,虽然开销很小,但这是一种不必要的浪费,而且更重要的是你失去了编译期检查的机会。

如果某个像素格式的 channelsvalue_type 组合有错误(比如 channels 是负数),运行时你可能永远不会发现,直到某个边界条件下才触发 bug。但如果你把计算放在编译期,错误的格式定义会在编译阶段就直接被抓住,根本不会生成有问题的二进制文件。

另一个更重要的动机是模板参数。模板参数必须是编译期常量,所以如果你的某个函数需要根据像素格式来选择不同的代码路径(比如调用不同的 OpenCV 转换函数),你就必须在编译期算出正确的转换码。这种"用编译期计算来驱动模板分支"的模式在 edgecv 里被大量使用。

最小示例:constexpr 函数与 static_assert

让我们从一个经典的例子开始——编译期阶乘计算,用它来展示 constexprconstevalstatic_assert 如何配合工作。

cpp
#include <type_traits>

// constexpr 函数:可以在编译期或运行期执行
constexpr int factorial(int n) {
    int result = 1;
    for (int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

// consteval 函数:必须在编译期执行
consteval int factorial_compiletime(int n) {
    int result = 1;
    for (int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

注意 C++14 之后的 constexpr 函数可以使用循环、局部变量等大部分常规控制流了,不像 C++11 那样只能写递归。这让编译期计算的代码和普通代码几乎没有区别。

cpp
int main() {
    // constexpr 变量:强制在编译期求值
    constexpr int f5 = factorial(5);
    static_assert(f5 == 120, "5! should be 120");
    // f5 的值在编译期就确定了,运行时没有任何计算开销

    // 普通变量:在运行期求值(即使函数是 constexpr 的)
    int n;
    std::cin >> n;
    int f_runtime = factorial(n);  // OK,运行期计算
    // constexpr int f_err = factorial(n);  // 编译错误!n 不是编译期常量

    // consteval 函数:无论什么上下文,都在编译期执行
    constexpr int f6 = factorial_compiletime(6);
    static_assert(f6 == 720, "6! should be 720");

    // int f_err2 = factorial_compiletime(n);  // 编译错误!
    // consteval 不接受运行时参数

    return 0;
}

static_assert 特别适合用来做类型属性的编译期测试。比如你可以验证你的类型特征是否正确:

cpp
// 用 static_assert 验证类型属性
static_assert(std::is_integral_v<int>);
static_assert(!std::is_integral_v<float>);
static_assert(std::is_same_v<int, int>);
static_assert(!std::is_same_v<int, long>);

// 验证自定义类型的属性
struct MyFormat {
    static constexpr int channels = 3;
    using value_type = unsigned char;
};

static_assert(MyFormat::channels == 3);
static_assert(sizeof(MyFormat::value_type) == 1);
static_assert(MyFormat::channels * sizeof(MyFormat::value_type) == 3);

这种编译期测试非常有用——如果你后续修改了 MyFormat 的定义导致 channels 变了,相关的 static_assert 会立刻编译失败,提醒你检查相关的代码逻辑是否需要同步更新。

编译期变量模板(variable template)是 C++14 引入的特性,它允许你定义一个参数化的编译期常量。结合 constexpr,你可以实现非常优雅的编译期计算:

cpp
// 变量模板:编译期常量,带模板参数
template<typename T>
constexpr size_t type_size = sizeof(T);

static_assert(type_size<int> == 4);
static_assert(type_size<char> == 1);

与 edgecv 的关联

edgecv 把编译期计算发挥到了一个相当深的程度,几乎每个核心组件都涉及到编译期的值计算和类型检查。

首先是 bytes_size<F> 变量模板,它计算某种像素格式每像素占用的字节数:

cpp
template <is_pixel_format pixel_format>
constexpr size_t bytes_size =
    pixel_format::channels * sizeof(typename pixel_format::value_type);

这是一个编译期常量。因为 channelsstatic constexpr intsizeof 也是编译期运算,所以整个表达式在编译期就能算出来。你在代码里写 bytes_size<BGR>,编译器直接把它替换成字面量 3,没有任何运行时开销。如果你写了 bytes_size<Float3>,编译器算出来是 12(3 通道乘 float 的 4 字节)。这个值还会被用在 static_assert 里做编译期检查,确保某些操作的字节计算是正确的。

然后是 cvt_color_code<Src, Dst>(),这是一个 consteval 函数模板,它根据源像素格式和目标像素格式在编译期计算 OpenCV 的颜色转换码:

cpp
template <typename Src, typename Dst>
consteval int cvt_color_code() {
    if constexpr (std::is_same_v<Src, Dst>) {
        return -1;  // 相同格式,不需要转换
    }
    if constexpr (std::is_same_v<Src, BGR> &&
                  std::is_same_v<Dst, Gray>) {
        return cv::COLOR_BGR2GRAY;
    }
    // ... 更多分支
    return -2;  // 不支持的转换组合
}

这个函数是 consteval 的,意味着它一定在编译期执行完毕。在 Image::convert_to() 方法里,它被用来在编译期确定转换码:

cpp
template <is_pixel_format format_type>
template <is_pixel_format target_format>
expected<Image<target_format>, ConversionalError>
Image<format_type>::convert_to() const {
    constexpr int code = detail::cvt_color_code<format_type, target_format>();
    if constexpr (code == -2) {
        return unexpected(ConversionalError::InternalError);
    }
    // ...
}

注意 constexpr int code = ... 这行——因为 cvt_color_codeconsteval,所以 code 的值在编译期就确定了。接下来的 if constexpr (code == -2) 是编译期分支——如果某种转换组合是不支持的,包含错误返回码的那段代码根本不会被编译进去。这就实现了"不支持的转换在编译期就能被发现"的效果,而且不支持的代码路径不会出现在最终的二进制文件里。

static_assert 在 edgecv 里也扮演了重要角色。比如 cv_depth<T>() 函数里用它来确保只处理支持的像素值类型:

cpp
template <typename T> constexpr int cv_depth() {
    if constexpr (std::is_same_v<T, uint8_t>) {
        return CV_8U;
    } else if constexpr (std::is_same_v<T, uint16_t>) {
        return CV_16U;
    } else if constexpr (std::is_same_v<T, float>) {
        return CV_32F;
    } else {
        static_assert(!std::is_same_v<T, T>,
                      "Unsupported pixel value type");
    }
}

最后一行的 static_assert(!std::is_same_v<T, T>, ...) 是一个经典的技巧——std::is_same_v<T, T> 永远是 true,取反后永远是 false,所以这个 static_assert 永远会失败。但它被放在了 else 分支里,只有在所有合法类型都没匹配上的时候才会被实例化。如果有人定义了一个 value_typedouble 的像素格式,编译器会立刻报错并显示 "Unsupported pixel value type"。这种"用 static_assert 做穷举检查"的模式在模板代码里非常常见,它确保了你的类型分支覆盖了所有可能的情况。

总的来说,edgecv 的设计哲学是"能在编译期确定的,绝不拖到运行期"。像素格式的字节数、颜色转换码、类型兼容性检查——这些统统在编译期完成。这不仅消除了运行时开销,更重要的是让大量潜在的错误在编译阶段就被暴露出来,而不是在生产环境中才被发现。

Built with VitePress