C++的常量表达式(constexpr)

Last Updated: 2023-12-25 07:47:28 Monday

-- TOC --

看到constexpr,就应该理解意识到,这个expression必须可以在编译期被执行。C++给我们带来了比conditional inclusion更强大的编译期编程。

理解constexpr

constexpr(constant expression)这个prefix修饰的函数接口,这样的接口必须可以在编译时被执行。

Constant expressions are expressions that must could be evaluated at compile time. For performance and safety reasons, whenever a computation can be done at compile time rather than runtime, you should do it. Simple mathematical operations involving literals are an obvious example of expressions that can be evaluated at compile time.

用constexpr修饰的函数,也完全可以在runtime被调用,当编译器发现某个调用处,无法在编译期处理的时候,就会在代码中保留此函数,使得runtime时可以被调用。

在C语言中,我们常常用#define的方式,来定义一些常量。在定义时,有时也会将常量的计算方法写出来。虽然编译器也会在编译时,将这些常量计算出来,直接用在生成汇编指令中,但这种方式存在一些坑,容易出错,比如下面这个错误:

#define ABC 1024*8  // wrong
a = (b+c) % ABC;  // (b+c) & 1024 * 8 !!
//  #define ABC (1024*8)  // right

C++更近了一步,通过使用constexpr申明,可以用函数接口的方式,在编译时,计算这样的常量。(constexpr在编译期间起作用)

constexpr带来的好处是:

  1. 代码更安全。
  2. 编译后的代码执行速度更快。

请看下面的测试代码:

#include <cstdio>

// return int square root
constexpr int isqrt(int n) {
    int i=1;
    while (i*i<n) ++i;
    return i-(i*i!=n);
}

int main() {
    int x = isqrt(1764);
    int y = isqrt(255);
    printf("%d %d\n", x, y);
    return 0;
}

constexpr int isqrt(int n)这个接口返回输入值的最接近的整数平方根。

运行结果:

$ g++ -Wall -Wextra test2.cpp -o test2
$ ./test2
42 15

我们来看一下编译后的汇编:

$ g++ -c test2.cpp
$ objdump -d test2.o

test2.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 83 ec 10             sub    $0x10,%rsp
   8:   c7 45 fc 2a 00 00 00    movl   $0x2a,-0x4(%rbp)  # 0x2a = 42
   f:   c7 45 f8 0f 00 00 00    movl   $0xf,-0x8(%rbp)   # 0xf  = 15
  16:   8b 55 f8                mov    -0x8(%rbp),%edx
  19:   8b 45 fc                mov    -0x4(%rbp),%eax
  1c:   89 c6                   mov    %eax,%esi
  1e:   bf 00 00 00 00          mov    $0x0,%edi
  23:   b8 00 00 00 00          mov    $0x0,%eax
  28:   e8 00 00 00 00          call   2d <main+0x2d>
  2d:   b8 00 00 00 00          mov    $0x0,%eax
  32:   c9                      leave
  33:   c3                      ret

从编译后的汇编来看,isqrt这个用constexpr申明的接口,在编译的时候,就执行完毕,得到了结果,并将结果直接写入生成的机器码中。

汇编学习资料:x86和x64汇编基础

这个编译结果的另一个重要细节是,isqrt这个函数的机器码,完全没有出现在最后生成的object文件中,因为不需要。

一个用constexpr申明的接口,也可以传入在编译时无法确定值的变量,此时就是运行时的函数调用。我们修改一下测试代码,再看看汇编的情况:

#include <cstdio>
#include <cstdlib>
#include <cassert>

// return int square root
constexpr int isqrt(int n) {
    int i=1;
    while (i*i<n) ++i;
    return i-(i*i!=n);
}

int main(int argc, char **argv) {
    assert(argc == 2);
    int x = isqrt(1764);
    int y = isqrt(255);
    printf("%d %d\n", x, y);
    printf("%d\n", isqrt(atoi(argv[1])));
    return 0;
}

上面的测试代码,最后一次调用isqrt,传入的参数在编译时不能确定,是运行时传入的。此时,代码的汇编情况如下:

$ g++ -c test2.cpp
[xinlin@fedora private_test]$ objdump -d test2.o

test2.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 83 ec 20             sub    $0x20,%rsp
   8:   89 7d ec                mov    %edi,-0x14(%rbp)
   b:   48 89 75 e0             mov    %rsi,-0x20(%rbp)
   f:   83 7d ec 02             cmpl   $0x2,-0x14(%rbp)
  13:   74 19                   je     2e <main+0x2e>
  15:   b9 00 00 00 00          mov    $0x0,%ecx
  1a:   ba 0f 00 00 00          mov    $0xf,%edx
  1f:   be 00 00 00 00          mov    $0x0,%esi
  24:   bf 00 00 00 00          mov    $0x0,%edi
  29:   e8 00 00 00 00          call   2e <main+0x2e>
  2e:   c7 45 fc 2a 00 00 00    movl   $0x2a,-0x4(%rbp)
  35:   c7 45 f8 0f 00 00 00    movl   $0xf,-0x8(%rbp)
  3c:   8b 55 f8                mov    -0x8(%rbp),%edx
  3f:   8b 45 fc                mov    -0x4(%rbp),%eax
  42:   89 c6                   mov    %eax,%esi
  44:   bf 00 00 00 00          mov    $0x0,%edi
  49:   b8 00 00 00 00          mov    $0x0,%eax
  4e:   e8 00 00 00 00          call   53 <main+0x53>
  53:   48 8b 45 e0             mov    -0x20(%rbp),%rax
  57:   48 83 c0 08             add    $0x8,%rax
  5b:   48 8b 00                mov    (%rax),%rax
  5e:   48 89 c7                mov    %rax,%rdi
  61:   e8 00 00 00 00          call   66 <main+0x66>
  66:   89 c7                   mov    %eax,%edi
  68:   e8 00 00 00 00          call   6d <main+0x6d>
  6d:   89 c6                   mov    %eax,%esi
  6f:   bf 00 00 00 00          mov    $0x0,%edi
  74:   b8 00 00 00 00          mov    $0x0,%eax
  79:   e8 00 00 00 00          call   7e <main+0x7e>
  7e:   b8 00 00 00 00          mov    $0x0,%eax
  83:   c9                      leave
  84:   c3                      ret

Disassembly of section .text._Z5isqrti:

0000000000000000 <_Z5isqrti>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d ec                mov    %edi,-0x14(%rbp)
   7:   c7 45 fc 01 00 00 00    movl   $0x1,-0x4(%rbp)
   e:   eb 04                   jmp    14 <_Z5isqrti+0x14>
  10:   83 45 fc 01             addl   $0x1,-0x4(%rbp)
  14:   8b 45 fc                mov    -0x4(%rbp),%eax
  17:   0f af c0                imul   %eax,%eax
  1a:   39 45 ec                cmp    %eax,-0x14(%rbp)
  1d:   7f f1                   jg     10 <_Z5isqrti+0x10>
  1f:   8b 45 fc                mov    -0x4(%rbp),%eax
  22:   0f af c0                imul   %eax,%eax
  25:   39 45 ec                cmp    %eax,-0x14(%rbp)
  28:   0f 95 c0                setne  %al
  2b:   0f b6 c0                movzbl %al,%eax
  2e:   8b 55 fc                mov    -0x4(%rbp),%edx
  31:   29 c2                   sub    %eax,%edx
  33:   89 d0                   mov    %edx,%eax
  35:   5d                      pop    %rbp
  36:   c3                      ret

首先发现,isqrt函数接口的汇编出现在了object文件中,说明需要用到它了。

其次,仔细看汇编代码,还是能够发现0x2a0xf这两个常量。

以上代码执行结果:

$ g++ -Wall -Wextra test2.cpp -o test2
$ ./test2 12345
42 15
111

其它细节不太明显,我们试一下反编译可执行程序,下面值显示main和isqrt函数的反汇编结果:

$ objdump -d test2
...
0000000000401146 <main>:
  401146:       55                      push   %rbp
  401147:       48 89 e5                mov    %rsp,%rbp
  40114a:       48 83 ec 20             sub    $0x20,%rsp
  40114e:       89 7d ec                mov    %edi,-0x14(%rbp)
  401151:       48 89 75 e0             mov    %rsi,-0x20(%rbp)
  401155:       83 7d ec 02             cmpl   $0x2,-0x14(%rbp)
  401159:       74 19                   je     401174 <main+0x2e>
  40115b:       b9 10 20 40 00          mov    $0x402010,%ecx
  401160:       ba 0f 00 00 00          mov    $0xf,%edx
  401165:       be 26 20 40 00          mov    $0x402026,%esi
  40116a:       bf 30 20 40 00          mov    $0x402030,%edi
  40116f:       e8 cc fe ff ff          call   401040 <__assert_fail@plt>
  401174:       c7 45 fc 2a 00 00 00    movl   $0x2a,-0x4(%rbp)
  40117b:       c7 45 f8 0f 00 00 00    movl   $0xf,-0x8(%rbp)
  401182:       8b 55 f8                mov    -0x8(%rbp),%edx
  401185:       8b 45 fc                mov    -0x4(%rbp),%eax
  401188:       89 c6                   mov    %eax,%esi
  40118a:       bf 3a 20 40 00          mov    $0x40203a,%edi
  40118f:       b8 00 00 00 00          mov    $0x0,%eax
  401194:       e8 97 fe ff ff          call   401030 <printf@plt>
  401199:       48 8b 45 e0             mov    -0x20(%rbp),%rax
  40119d:       48 83 c0 08             add    $0x8,%rax
  4011a1:       48 8b 00                mov    (%rax),%rax
  4011a4:       48 89 c7                mov    %rax,%rdi
  4011a7:       e8 a4 fe ff ff          call   401050 <atoi@plt>
  4011ac:       89 c7                   mov    %eax,%edi
  4011ae:       e8 18 00 00 00          call   4011cb <_Z5isqrti> # call isqrt
  4011b3:       89 c6                   mov    %eax,%esi
  4011b5:       bf 41 20 40 00          mov    $0x402041,%edi
  4011ba:       b8 00 00 00 00          mov    $0x0,%eax
  4011bf:       e8 6c fe ff ff          call   401030 <printf@plt>
  4011c4:       b8 00 00 00 00          mov    $0x0,%eax
  4011c9:       c9                      leave
  4011ca:       c3                      ret

00000000004011cb <_Z5isqrti>:
  4011cb:       55                      push   %rbp
  4011cc:       48 89 e5                mov    %rsp,%rbp
  4011cf:       89 7d ec                mov    %edi,-0x14(%rbp)
  4011d2:       c7 45 fc 01 00 00 00    movl   $0x1,-0x4(%rbp)
  4011d9:       eb 04                   jmp    4011df <_Z5isqrti+0x14>
  4011db:       83 45 fc 01             addl   $0x1,-0x4(%rbp)
  4011df:       8b 45 fc                mov    -0x4(%rbp),%eax
  4011e2:       0f af c0                imul   %eax,%eax
  4011e5:       39 45 ec                cmp    %eax,-0x14(%rbp)
  4011e8:       7f f1                   jg     4011db <_Z5isqrti+0x10>
  4011ea:       8b 45 fc                mov    -0x4(%rbp),%eax
  4011ed:       0f af c0                imul   %eax,%eax
  4011f0:       39 45 ec                cmp    %eax,-0x14(%rbp)
  4011f3:       0f 95 c0                setne  %al
  4011f6:       0f b6 c0                movzbl %al,%eax
  4011f9:       8b 55 fc                mov    -0x4(%rbp),%edx
  4011fc:       29 c2                   sub    %eax,%edx
  4011fe:       89 d0                   mov    %edx,%eax
  401200:       5d                      pop    %rbp
  401201:       c3                      ret
...

可以看到对isqrt的调用,只有1次!由于是C++代码,编译器使用了符号修饰

现在可以总结:constexpr是个好东西,灵活高效安全。

In certain contexts, like embedded development, constexpr is indispensable. In general, if an expression can be declared constexpr, you should strongly consider doing so. Using constexpr rather than manually calculated literals can make your code more expressive. Often, it can also seriously boost performance and safety at runtime.

constexpr除了用来申明函数接口,还可以直接定义常量

#include <cstdio>

constexpr unsigned int usi = 12345;
constexpr float abc = 1.2345f;
constexpr float gg = usi * abc; // calucale gg in compile time

int main(void){
    printf("%u * %f = %f\n", usi, abc, gg);
    return 0;
}

这在使用上,依然比#define定义宏替换要更安全。

从我看到的资料来理解:C++中的constexpr修饰,随着C++版本的推进,也变得越来越强大,越来越多的计算被编译器考虑纳入编译期完成!

C++20把对constexpr的限制又放宽很多了,可以 new delete try catch 了,虚函数析构函数也能是 constexpr 了...

constexpr与const的区别

下面摘一段《A Tour of C++》的原文:

const: meaning roughly I promise not to change this value. This is used primarily to specify interfaces so that data can be passed to functions using pointers and references without fear of it being modified. The compiler enforces the promise made by const. The value of a const can be calculated at run time.

constexpr: meaning roughly to be evaluated at compile time. This is used primarily to specify constants, to allow placement of data in read-only memory (where it is unlikely to be corrupted), and for performance. The value of a constexpr must be calculated by the compiler.

constexpr if(C++17)

constexpr if让以前本应被写在一起,却在C++17前没法写在一起的代码得到了改善。

如果我们写一个支持多种类型的输入和输出的toStr接口,在C++17之前,要这样:

#include <iostream>
#include <string>
using namespace std;


template<typename T>
constexpr enable_if_t<is_same_v<T,string>,string> toStr(T t){
    return t;
}

template<typename T>
constexpr enable_if_t<!is_same_v<T,string>,string> toStr(T t){
    return to_string(t);
}


int main(){
    int a {123};
    string b {"abc"};
    double c {1.234};
    cout << toStr(a) << endl;
    cout << toStr(b) << endl;
    cout << toStr(c) << endl;
    return 0;
}

如果将上面两个toStr定义合并在一个template中,使用if判断,会导致编译错误。因为用string类型实例化模板之后,虽然存在一个死分支,但编译器不管,死分支也会编译,此时给to_string传入string对象,导致编译错误。

enable_if_t是C++ SFINAE(Substitution Failure Is Not An Error)的应用。在编译期,enable_if_t只有在第一个参数为true的时候,才存在,当它不存在的时候,就是一个substitution failure,此时编译器就会去选择其它的匹配模板。

从C++17开始,上面的代码可以写成这样:

template<typename T>
constexpr auto toStr(T t){
    if constexpr(is_same_v<T,string>)
        return t;
    else
        return to_string(t);
}

在实例化模板接口的时候,编译器在面对if constexpr的时候,只会实例化那个为true的分支,解决上之前C++版本一定要分开写的问题,同时也让实例化后的代码size变小了。

下面是经典的编译期sum接口:

#include <iostream>
#include <string>
using namespace std;


/*template<typename T>
constexpr T sum(T t){
    return t;
}

template<typename T, typename... Ts>
constexpr T sum(T t, Ts... args){
    return t + sum(args...);
}*/


// C++17
template<typename T, typename... Ts>
constexpr T sum(T t, Ts... args){
    if constexpr(sizeof...(args) == 0)
        return t;
    else
        return t + sum(args...);
}


int main(){
    cout << sum(1) << endl;
    cout << sum(1,2,3) << endl;
    cout << sum(1.0,2.0,3.0) << endl;
    cout << sum(1.0,2,3) << endl;
    return 0;
}

sizeof...(args)是variadic template参数特有的,用来获得参数的个数。

constexpr lambda(C++17)

从C++17开始,lambda函数接口也可以被声明为constexpr了。也就是说,他们可以被用在任何constexpr的上下文中。同样的,对一个lambda而言,只要被捕获的变量是字面量类型(lieteral type),那么整个lambda也将表现为字面量类型。

#include <iostream>
#include <string>
using namespace std;

template<typename T>
constexpr auto addTo(T t){
    return [t](T i){return t+i;};
}

int main(){
    constexpr auto add8 { addTo(8) };
    cout << add8(1) << endl;
    constexpr auto add8f { addTo(8.0) };
    cout << add8f(2.1) << endl;
    return 0;
}

本文链接:https://cs.pynote.net/sf/c/cpp/202208181/

-- EOF --

-- MORE --