Skip to main content
  1. Articles/

The History of constexpr in C++! (Part Two)

·7137 words·15 mins· ·
ykiko
Author
ykiko
Table of Contents
Constexpr - This article is part of a series.
Part 2: This Article

前情提要:
#

2015-2016:模板的语法糖
#

在 C++ 中支持 全特化 (full specialization) 的模板很多,但是支持 偏特化 (partial specialization) 的模板并不多,事实上其实只有类模板 (class template) 和变量模板 (variable template) 两种支持,而变量模板其实可以看做类模板的语法糖,四舍五入一下其实只有类模板支持偏特化。不支持偏特化会导致有些代码十分难写

假设我们想实现一个destroy_at函数,效果就是调用对象的析构函数。特别的,如果析构函数是 trivial 的,那我们就省去这次无意义的析构函数调用。

直觉上我们能写出下面这样的代码

template<typename T, bool value = std::is_trivially_destructible_v<T>>
void destroy_at(T* p) { p->~T(); }

template<typename T>
void destroy_at<T, true>(T* p) {}

很可惜,clangd 已经可以智慧的提醒你:Function template partial specialization is not allowed。函数模板不能偏特化,那咋办呢?当然了,可以包一层类模板解决,但是每次遇到这种情况都额外包一层实在是让人难以接受。

旧时代的做法是利用 SFINAE 来解决这个问题

template<typename T, std::enable_if_t<(!std::is_trivially_destructible_v<T>)>* = nullptr>
void destroy_at(T* p) { p->~T(); }

template<typename T, std::enable_if_t<std::is_trivially_destructible_v<T>>* = nullptr>
void destroy_at(T* p) {}

具体的原理这里就不叙述了,虽然少了一层包装,但是仍然有很多与代码逻辑无关的东西出现。这里的std::enable_if_t就是典型例子,严重影响了代码的可读性。

提案 N4461 希望引入static_if(借鉴自 D 语言)可以用来编译期控制代码生成,只会把实际用到的分支编译进最终的二进制代码。这样就可以写出下面这样的代码,其中static_if的条件必须是常量表达式

template<typename T>
void destroy_at(T* p){
    static_if(!std::is_trivially_destructible_v<T>){
        p->~T();
    }
}

可以发现逻辑非常清晰,但是委员会一般对于加新的关键字比较谨慎。后来static_if被重命名为constexpr_if,再后来变成了我们今天熟悉的这种形式并且进入 C++17

if constexpr (...){...}
else if constexpr (...){...}
else {...}

巧妙地避免了加新的关键字,C++ 委员会还真是喜欢关键字复用呢。

2015:constexpr lambda
#

提案 N4487 讨论了支持 constexpr lambda 可能性,尤其希望能在 constexpr 计算中能够使用 lambda 表达式,并附带了一个实验性实现。

其实支持 constexpr 的 lambda 表达式并不困难,我们都知道 lambda 在 C++ 里面是很透明的,基本上完全就是一个匿名的函数对象。函数对象都能是 constexpr 的,那么支持 constexpr 的 lambda 也就是理所当然的事情了。

唯一需要注意的就是,lambda 是可以进行捕获的,捕获 constexpr 的变量会怎么样呢?

void foo() {
    constexpr int x = 3;
    constexpr auto foo = [=]() { return x + 1; };
    static_assert(sizeof(foo) == 1);
}

从直觉上来说,由于x是常量表达式,没有必要给它分配空间来储存。那么f其实里面没有任何成员,在 C++ 中空类的 size 至少是1。上面的代码挺合理的,但是在文章的上篇也说到了,constexpr 变量其实也是可以占用内存的,我们可以显式取它的地址

void foo() {
    constexpr int x = 3;
    constexpr auto foo = [=]() { return &x + 1; };
    static_assert(sizeof(foo) == 4);
}

可以发现这种情况下,编译器不得不给x分配内存。实际上的判断规则更复杂一些,感兴趣的可以自行参考 lambda capture。最终这个提案被接受,进入了 C++17

2017-2019:编译期和运行期…不同?
#

通过不断放宽 constexpr 的限制,越来越多的函数可以在编译期执行。但是具有外部链接(也就是被extern的函数)无论如何是无法在编译期执行的。绝大部分从 C 继承过来的函数都是这样的,例如memcpy, memmove等等。

假设我写了一个 constexpr 的memcpy

template <typename T>
constexpr T* memcpy(T* dest, const T* src, std::size_t count) {
    for(std::size_t i = 0; i < count; ++i) {
        dest[i] = src[i];
    }
    return dest;
}

虽然能在编译期用了,编译期执行效率倒是无所谓,但是运行期效率肯定不如标准库的实现。如果能在编译期使用我的实现,运行期使用外部链接的标准库函数就好了。

提案 P0595 希望加入一个新的 magic function 也就是 constexpr() 用来判断当前的函数是否在编译期执行,后来被更名为is_constant_evaluated并且进入 C++20。使用起来就像下面这样

constexpr int foo(int x) {
    if(std::is_constant_evaluated()) {
        return x;
    } else {
        return x + 1;
    }
}

这样的话编译期和运行期就可以采用不同的逻辑实现了,我们可以对外部链接的函数进行一层封装,使得它们在内部暴露为 constexpr 的函数接口,既可以代码复用又可以保证运行期效率,两全其美。

唯一的问题是,假设上面的foo在运行期运行,你会发现第一个分支仍然被编译了,虽然可能编译器最终应该会把if(false)这个分支优化掉。但是这个分支里面仍然会进行语法检查之类的工作,如果里面用到了模板,那么模板实例化仍然会被触发(甚至产生预料外的实例化导致编译错误),显然这不是我们想要的结果。尝试使用if constexpr改写上面的代码呢?

constexpr int foo(int x) {
    if constexpr(std::is_constant_evaluated()) {
        // ...
    }
}

这种写法被认为是 obviously incorrect,因为if constexpr的条件只能在编译期执行,所以这里is_constant_evaluated永远会返回true,这与我们最开始的目的相悖了。 所以提案 P1938R3 提议加入新的语法来解决这个问题

if consteval /* !consteval */ {
    // ...
} else {
    // ...
}

代码看上去是一目了然的,两个分支一个编译期一个运行期。这个升级过后的版本最终被接受并加入 C++23。

2017-2019: 高效的调试
#

C++ 模板一个最被人诟病的问题就是报错信息非常糟糕,而且难以调试。内层模板实例化失败之后,会把整个实例化栈打印出来,能轻松产生成百上千行报错。但是事情在 constexpr 函数这里其实也并没有变好,如果 constexpr 函数常量求值失败,也会把整个函数调用堆栈打印出来

constexpr int foo(){ return 13 + 2147483647; }
constexpr int bar() { return oo(); }
constexpr auto x = bar();

报错

in 'constexpr' expansion of 'bar()'
in 'constexpr' expansion of 'foo()'
error: overflow in constant expression [-fpermissive]
  233 | constexpr auto x = bar();

如果函数嵌套多了,报错信息也非常糟糕。不同于模板的地方在于,constexpr 函数也可以在运行期运行。所以我们可以在运行期调试代码,最后在编译期执行就好了。但是如果考虑到上一小节加的is_constant_evaluated,就会发现这种做法并不完全可行,因为编译期和运行期的代码逻辑可能不同。提案 P0596 希望引入constexpr_traceconstexpr_assert来方便编译期调试代码,虽然投票一致赞成,但是暂时未进入 C++ 标准。

2017: 编译期可变容器
#

尽管在先前的提案中,允许了 constexpr 函数使用和修改变量,但是动态内存分配还是不允许的。如果有未知长度的数据需要处理,一般就是在栈上开一个大数组,这没什么问题。但是从实践上来说,有特别多的函数依赖于动态内存分配,支持 constexpr 函数中使用vector势在必得。

在当时,直接允许在 constexpr 函数中使用new/delete似乎过于让人惊讶了,所以提案 P0597 想了一个折中的办法,先提供一个 magic container 叫做std::constexpr_vector,它由编译器实现,并且支持在 constexpr 函数中使用和修改。

constexpr constexpr_vector<int> x;  // ok
constexpr constexpr_vector<int> y{ 1, 2, 3 };  // ok

constexpr auto series(int n) {
    std::constexpr_vector<int> r{};
    for(int k; k < n; ++k) {
        r.push_back(k);
    }
    return r;
}

这并不彻底解决问题,用户仍然需要重写它的代码以支持常量求值。从在 constexpr 函数支持循环的那一节来看,这种加重语言不一致性的东西,很难被加入标准。最终有更好的提案取代了它,后面会提到。

2018:真正的编译期多态?
#

提案 P1064R0 希望在常量求值中支持虚函数调用。哎,还不支持动态内存分配呢,咋就要支持虚函数调用了?其实不依赖动态内存分配也可以弄出来多态指针嘛,指向栈上的对象或者静态储存就可以了。

struct Base {
    virtual int foo() const { return 1; }
};

struct Derived : Base {
    int foo() const override { return 2; }
};

constexpr auto foo() {
    Base* p;
    Derived d;
    p = &d;
    return p->foo();
}

似乎没有任何理由拒绝上面这段代码编译通过。由于是在编译期执行,编译器当然能知道p指向的是Derived,然后调用Derived::f,实践上没有任何难度。的确如此,之后又有一个新的提案 P1327R1 进一步希望dynamic_casttypeid也能在常量求值中使用,最终它们都被接受并且加入了 C++20,现在可以自由的在编译期使用这些特性了。

2017-2019: 真正的动态内存分配!
#

constexpr everything 的这个演示视频中,展示了一个能在编译期处理JSON对象的例子

constexpr auto jsv= R"({
    "feature-x-enabled": true,
    "value-of-y": 1729,
    "z-options": {"a": null,
        "b": "220 and 284",
         "c": [6, 28, 496]}
 })"_json;

if constexpr (jsv["feature-x-enabled"]) {
    // feature x
} else {
    // feature y
}

希望能直接通过解析常量字符串起到配置文件的作用(字符串文本可以由#include引入)。作者们因为不能使用 STL 的容器受到了严重影响,并且自己编写了替代品。通过std::array来实现std::vectorstd::map这样的容器,由于没有动态内存分配,只能预先计算出需要的大小(可能导致多次遍历)或者在栈上开块大内存。

提案 P0784R7 重新讨论了在常量求值中支持标准库容器的可能性

主要有以下三个难点:

  • 析构函数不能被声明为 constexpr(对于 constexpr 对象,它们必须是 trivial 的)
  • 无法进行动态内存分配/释放
  • 无法在常量求值中使用 placement new 来调用对象的构造函数

针对第一个问题,作者们与 MSVC,GCC,Clang,EDG 等前端开发人员快速讨论并解决了这个问题。C++20 起,可以符合 literal type 要求的类型具有 constexpr 修饰的析构函数,而不是严格要求平凡的析构函数。

针对第二个问题,处理起来并不简单。C++ 有很多未定义行为都是由于错误的内存处理导致的,相比之下,不能直接操作内存的脚本语言则安全的多。但是为了复用代码,C++ 编译器中的常量求值器不得不直接操作内存,不过由于所有信息都是编译期已知的,理论上可以保证常量求值中不会出现内存错误 (out of range, double free, memory leak, …),如果出现应该中止编译并报告错误。

常量求值器需要跟踪许多对象的的元信息,并找出这些错误

  • 记录union哪个 field 是 active 的,访问 unactive 的成员导致未定义行为,这由 P1330 阐明
  • 正确记录对象的 lifetime,访问未初始化的内存和已经析构的对象都是不允许的

当时还不允许在常量求值中把void*转换成T*,所以理所当然的

void* operator new(std::size_t);

不支持在常量求值中使用,取而代之的是

// new => initialize when allocate
auto pa = new int(42);
delete pa;

// std::allocator => initialize after allocate
std::allocator<int> alloc;
auto pb = alloc.allocate(1);
alloc.deallocate(pb, 1);

它们返回的都是T*,并且由编译器实现,这对于支持标准库容器来说已经足够了。

对于第三个问题,则是添加了一个 magic function 即 std::construct_at,它的作用是在指定的内存位置上调用对象的构造函数,用来在常量求值中取代placement new。这样的话我们就可以先通过std::allocator分配内存,再通过std::construct_at来构造对象了。该提案最终被接受,进入了 C++20,同时使得std::vectorstd::string在常量求值中可用(其它的容器理论上也行,但是目前的实现还没支持,如果非常想要只能自己搓一个了)。

虽然支持了动态内存分配,但并不是毫无限制。在一次常量求值中分配的内存必须要在这次常量求值结束之前释放完全,不能有内存泄漏,否则会导致编译错误。这种类型的内存分配被叫做 transient constexpr allocations(瞬态内存分配) 。该提案也讨论了 non-transient allocation(非瞬态内存分配) ,在编译期未被释放的内存,将被转为静态储存(其实就是存在数据区,就像全局变量那样)。但是,委员会认为这种可能性 “too brittle”,出于多种原因,目前尚未采纳。

2018:更多的 constexpr
#

提案 P1002 希望在 constexpr 函数中支持try-catch块。但是不能throw,这样是为了能把更多的标准库容器的成员函数标记为constexpr

constexpr int foo(){
    throw 1;
    return 1;
}

constexpr auto x = foo();  // error

// expression '<throw-expression>' is not a constant expression
//    233 |     throw 1;

如果在编译期throw会直接导致编译错误,由于throw不会发生,那自然也不会有异常被捕获。

2018:保证编译期执行!
#

有些时候我们想保证一个函数在编译期执行

extern int foo(int x);

constexpr int bar(int x){ return x; }

foo(bar(1)); // evaluate at compile time ?

事实上g无论是在编译期还是运行期执行,理论上都可以。为了保证它在编译期执行,我们需要多写一些代码

constexpr auto x = bar(1);
foo(x);

这样就保证了g在编译期执行,同样,这种没意义的局部变量实在是多余。提案 P1073 希望增加一个标记 constexpr!来确保一个函数在编译期执行,如果不满足则导致编译错误。最终该标记被更名为 consteval 并进入了 C++20。

extern int foo(int x);

consteval int bar(int x){ return x; }

foo(bar(1)); // ensure evaluation at compile time

consteval函数不能获取指针或引用,编译器后端根本不需要(也不应该)知道这些函数的存在,也不需要将它们放入符号表中等等。事实上该提案也为未来可能引入的 static reflection 做了铺垫,未来将会有大量只在编译期执行的函数进入标准。

2018:默认 constexpr ?
#

在当时,有很多提案的内容仅仅是把标准库的某个部分标记为constexpr,在本文中没有讨论它们,因为它们具有相同的模式。

提案 P1235 希望把所有函数都标记为 implicit constexpr 的

  • non:如果可能,将方法标记为 constexpr。
  • constexpr:与当前行为相同
  • constexpr(false):不能在编译时调用
  • constexpr(true):只能在编译时调用

该提案最终没有被接受。

2020:更强的动态内存分配?
#

正如之前提到的,在 constexpr 函数中支持内存分配已经被允许了,也可以在 constexpr 函数中使用std::vector这样的容器,但是由于是瞬态内存分配,无法创建全局的std::vector

constexpr std::vector<int> v{1, 2, 3};  // error

所以如果一个 constexpr 函数返回一个std::vector,只能额外包装一层把这个std::vector转成std::array然后作为全局变量

constexpr auto f() { return std::vector<int>{1, 2, 3}; }

constexpr auto arr = [](){
    constexpr auto len = f().size();
    std::array<int, len> result{};
    auto temp = f();
    for(std::size_t i = 0; i < len; ++i){
        result[i] = temp[i];
    }
    return result;
};

提案 P1974 提议使用propconst来支持非瞬态内存分配,这样上述的额外的包装代码就不需要了。

非瞬态内存分配的原理很简单

constexpr std::vector vec = {1, 2, 3};

编译器会将上述代码编译为类似下面这样

constexpr int data[3] = {1, 2, 3};
constexpr std::vector vec{
    .begin = data, 
    .end = data + 3, 
    .capacity = data + 3
};

其实就是把本来应该指向动态分配的内存的指针改为指向静态内存。原理并不复杂,真正的难点是如何保证程序的正确性。显然上述的vec即使在程序结束的时候也不应该调用析构函数,否则会导致段错误。这个问题要解决很简单,我们可以约定,任何constexpr标记的变量都不会调用析构函数

但是考虑如下情况:

constexpr unique_ptr<unique_ptr<int>> ppi { 
    new unique_ptr<int> { new int { 42 } } 
};

int main(){
    ppi.reset(new int { 43 }); // error, ppi is const
    auto& pi = *ppi;
    pi.reset(new int { 43 }); // ok
}

由于pp1constexpr的,那么它的析构函数不应该调用。对ppi尝试调用reset是不允许的,因为constexpr标记的变量隐含const,而reset并不是一个const方法。但是对pi调用reset是允许的,因为外层const不影响内层指针。

如果允许pi调用reset,显然这是一次运行期调用,会在运行期动态内存分配,而由于ppi不会调用析构函数,里面的pi当然也不会调用析构函数,于是内存就泄露了,显然这种做法不应该被允许。

解决办法自然是想办法禁止pi调用reset,提案提出了propconst关键字,它可以把外层的constexpr传递给内层,这样pi也是const的了,也就不能调用reset了,就不会出现代码逻辑问题了。

可惜的的是暂时还未被标准接受,在那之后还有一些新的的提案希望能够支持这个特性比如 P2670R1,相关的讨论还在继续。

2021:constexpr 类
#

C++ 标准库中的很多类型,比如vector, string, unique_ptr中的所有方法都被标记为 constexpr,并且真正可以在编译期执行。很自然的,我们希望能直接标记整个类为 constexpr,这样可以省去哪些重复的说明符编写。

提案 P2350 希望支持这个特性,constexpr 标记的class中的所有方法都被隐式标记为 constexpr

// before
class struct {
    constexpr bool empty() const { /* */ }

    constexpr auto size() const { /* */ }

    constexpr void clear() { /* */ }
};

// after
constexpr struct SomeType {
    bool empty() const { /* */ }

    auto size() const { /* */ }

    void clear() { /* */ }
};

有一个有趣的故事与这个提案有关 - 在不知道它的存在之前,我(文章原作者)在 stdcpp.ru 提出了同样的想法。

在标准制定过程中,很多几乎相同的提案几乎可以同时出现。这证明了 多重发现理论的正确性:某些思想或概念会在不同的人群中独立地出现,就像它们在空气中漂浮一样,并且谁先发现的并不重要。如果社区的规模足够大,这些思想或概念自然会发生演变。

2023:编译期类型擦除!
#

在常量求值中,一直不允许把void*转换成T*,这样导致诸如std::anystd::function等类型擦除实现的容器无法在常量求值中使用。原因呢,是因为我们可以通过void*来绕过类型系统,把一个类型转换为不相干的类型

int* p = new int(42);
double* p1 = static_cast<float*>(static_cast<void*>(p));

如果对p1解引用实际上是未定义的行为,所以禁止了这种转换(注意 reinterpret_cast 一直在常量求值中禁用)。但是显然这种做法已经误伤了正确的写法了,因为像std::any这种实现,显然不会把一个从void*转换成无关的类型,而是会把它转换回原来的类型,完全不允许这种转换是不合理的。提案 P2738R0 希望在常量求值中支持这种转换,编译器理论上能在编译期记录一个void*指针原本的类型,如果转换的不是原本的类型,就报错。

最终该提案被接受,并且加入 C++26,现在可以进行 T* -> void* -> T* 的转换了

constexpr void f(){
    int x = 42;
    void* p = &x;
    int* p1 = static_cast<int*>(p); // ok
    float* p2 = static_cast<float*>(p); // error
}

2023:支持 placement new?
#

前面我们提到,为了支持vector在常量求值中使用,加入了construct_at用于在常量求值中调用构造函数。它具有如下形式

template<typename T, typename... Args>
constexpr T* construct_at(T* p, Args&&... args);

虽然一定程度上解决了问题,但是它并不能完全提供placement new的功能

  • value initialization
new (p) T(args...) // placement new version
construct_at(p, args...) // construct_at version
  • default initialization
new (p) T // placement new version
std::default_construct_at(p) // P2283R1
  • list initialization
new (p) T{args...} // placement new version
// construct_at version doesn't exist
  • designated initialization
new (p) T{.x = 1, .y = 2} // placement new version
// construct_at version cannot exist

提案 P2747R1 希望在常量求值中直接支持placement new。暂时还未被加入标准。

2024-∞:未来无极限!
#

截止目前,C++ 的常量求值已经支持了非常丰富的功能,支持条件,变量,循环,虚函数调用,动态内存分配等等一系列特性。但是受限于日常开发使用的 C++ 版本,有很多功能可能暂时没法使用,可以在 这里 方便的查看哪个版本支持了什么特性。

未来的 constexpr 中仍然有很多可能性,比如像memcpy这样的函数或许也能在常量求值中使用?又或者目前的small_vector某些实现不能在不改动任何代码的前提下变成 constexpr 的,因为它们使用char数组为栈上的对象提供储存(为了避免默认构造)

constexpr void foo(){
    std::byte buf[100];
    std::construct_at(reinterpret_cast<int*>(buf), 42); // no matter what
}

但是目前在常量求值中无法直接在char数组上构造对象。更进一步,在 C++20 加入的 implicit lifetime 是否可能在常量求值中表现出来呢?这些理论上都是可能实现的,只是要求编译器记录更多的元信息。而在未来,一切皆有可能!最终我们或许真的能 constexpr everything!

Constexpr - This article is part of a series.
Part 2: This Article