条款十五:尽可能的使用constexpr

Item 15: Use constexpr whenever possible

如果要给C++11颁一个“最令人困惑新词”奖,constexpr十有八九会折桂。当用于对象上面,它本质上就是const的加强形式,但是当它用于函数上,意思就大不相同了。有必要消除困惑,因为你绝对会用它的,特别是当你发现constexpr “正合吾意”的时候。

从概念上来说,constexpr表明一个值不仅仅是常量,还是编译期可知的。这个表述并不全面,因为当constexpr被用于函数的时候,事情就有一些细微差别了。为了避免我毁了结局带来的surprise,我现在只想说,你不能假设constexpr函数的结果是const,也不能保证它们的(译注:返回)值是在编译期可知的。最有意思的是,这些是特性。关于constexpr函数返回的结果不需要是const,也不需要编译期可知这一点是良好的行为!

不过我们还是先从constexpr对象开始说起。这些对象,实际上,和const一样,它们是编译期可知的。(技术上来讲,它们的值在翻译期(translation)决议,所谓翻译不仅仅包含是编译(compilation)也包含链接(linking),除非你准备写C++的编译器和链接器,否则这些对你不会造成影响,所以你编程时无需担心,把这些constexpr对象值看做编译期决议也无妨的。)

编译期可知的值“享有特权”,它们可能被存放到只读存储空间中。对于那些嵌入式系统的开发者,这个特性是相当重要的。更广泛的应用是“其值编译期可知”的常量整数会出现在需要“整型常量表达式(integral constant expression)的上下文中,这类上下文包括数组大小,整数模板参数(包括std::array对象的长度),枚举名的值,对齐修饰符(译注:alignas(val)),等等。如果你想在这些上下文中使用变量,你一定会希望将它们声明为constexpr,因为编译器会确保它们是编译期可知的:

int sz;                             //non-constexpr变量
…
constexpr auto arraySize1 = sz;     //错误!sz的值在
                                    //编译期不可知
std::array<int, sz> data1;          //错误!一样的问题
constexpr auto arraySize2 = 10;     //没问题,10是
                                    //编译期可知常量
std::array<int, arraySize2> data2;  //没问题, arraySize2是constexpr

注意const不提供constexpr所能保证之事,因为const对象不需要在编译期初始化它的值。

int sz;                            //和之前一样
…
const auto arraySize = sz;         //没问题,arraySize是sz的const复制
std::array<int, arraySize> data;   //错误,arraySize值在编译期不可知

简而言之,所有constexpr对象都是const,但不是所有const对象都是constexpr。如果你想编译器保证一个变量有一个值,这个值可以放到那些需要编译期常量(compile-time constants)的上下文的地方,你需要的工具是constexpr而不是const

涉及到constexpr函数时,constexpr对象的使用情况就更有趣了。如果实参是编译期常量,这些函数将产出编译期常量;如果实参是运行时才能知道的值,它们就将产出运行时值。这听起来就像你不知道它们要做什么一样,那么想是错误的,请这么看:

  • constexpr函数可以用于需求编译期常量的上下文。如果你传给constexpr函数的实参在编译期可知,那么结果将在编译期计算。如果实参的值在编译期不知道,你的代码就会被拒绝。
  • 当一个constexpr函数被一个或者多个编译期不可知值调用时,它就像普通函数一样,运行时计算它的结果。这意味着你不需要两个函数,一个用于编译期计算,一个用于运行时计算。constexpr全做了。

假设我们需要一个数据结构来存储一个实验的结果,而这个实验可能以各种方式进行。实验期间风扇转速,温度等等都可能导致亮度值改变,亮度值可以是高,低,或者无。如果有n个实验相关的环境条件,它们每一个都有三个状态,最终可以得到的组合有3n个。储存所有实验结果的所有组合需要足够存放3n个值的数据结构。假设每个结果都是int并且n是编译期已知的(或者可以被计算出的),一个std::array是一个合理的选择。我们需要一个方法在编译期计算3n。C++标准库提供了std::pow,它的数学功能正是我们所需要的,但是,对我们来说,这里还有两个问题。第一,std::pow是为浮点类型设计的,我们需要整型结果。第二,std::pow不是constexpr(即,不保证使用编译期可知值调用而得到编译期可知的结果),所以我们不能用它作为std::array的大小。

幸运的是,我们可以应需写个pow。我将展示怎么快速完成它,不过现在让我们先看看它应该怎么被声明和使用:

constexpr                                   //pow是绝不抛异常的
int pow(int base, int exp) noexcept         //constexpr函数
{
 …                                          //实现在下面
}
constexpr auto numConds = 5;                //(上面例子中)条件的个数
std::array<int, pow(3, numConds)> results;  //结果有3^numConds个元素

回忆下pow前面的constexpr不表明pow返回一个const值,它只说了如果baseexp是编译期常量,pow的值可以被当成编译期常量使用。如果base和/或exp不是编译期常量,pow结果将会在运行时计算。这意味着pow不止可以用于像std::array的大小这种需要编译期常量的地方,它也可以用于运行时环境:

auto base = readFromDB("base");     //运行时获取这些值
auto exp = readFromDB("exponent"); 
auto baseToExp = pow(base, exp);    //运行时调用pow函数

因为constexpr函数必须能在编译期值调用的时候返回编译期结果,就必须对它的实现施加一些限制。这些限制在C++11和C++14标准间有所出入。

C++11中,constexpr函数的代码不超过一行语句:一个return。听起来很受限,但实际上有两个技巧可以扩展constexpr函数的表达能力。第一,使用三元运算符“?:”来代替if-else语句,第二,使用递归代替循环。因此pow可以像这样实现:

constexpr int pow(int base, int exp) noexcept
{
    return (exp == 0 ? 1 : base * pow(base, exp - 1));
}

这样没问题,但是很难想象除了使用函数式语言的程序员外会觉得这样硬核的编程方式更好。在C++14中,constexpr函数的限制变得非常宽松了,所以下面的函数实现成为了可能:

constexpr int pow(int base, int exp) noexcept   //C++14
{
    auto result = 1;
    for (int i = 0; i < exp; ++i) result *= base;
    
    return result;
}

constexpr函数限制为只能获取和返回字面值类型,这基本上意味着那些有了值的类型能在编译期决定。在C++11中,除了void外的所有内置类型,以及一些用户定义类型都可以是字面值类型,因为构造函数和其他成员函数可能是constexpr

class Point {
public:
    constexpr Point(double xVal = 0, double yVal = 0) noexcept
    : x(xVal), y(yVal)
    {}

    constexpr double xValue() const noexcept { return x; } 
    constexpr double yValue() const noexcept { return y; }

    void setX(double newX) noexcept { x = newX; }
    void setY(double newY) noexcept { y = newY; }

private:
    double x, y;
};

Point的构造函数可被声明为constexpr,因为如果传入的参数在编译期可知,Point的数据成员也能在编译器可知。因此这样初始化的Point就能为constexpr

constexpr Point p1(9.4, 27.7);  //没问题,constexpr构造函数
                                //会在编译期“运行”
constexpr Point p2(28.8, 5.3);  //也没问题

类似的,xValueyValuegetter(取值器)函数也能是constexpr,因为如果对一个编译期已知的Point对象(如一个constexpr Point对象)调用getter,数据成员xy的值也能在编译期知道。这使得我们可以写一个constexpr函数,里面调用Pointgetter并初始化constexpr的对象:

constexpr
Point midpoint(const Point& p1, const Point& p2) noexcept
{
    return { (p1.xValue() + p2.xValue()) / 2,   //调用constexpr
             (p1.yValue() + p2.yValue()) / 2 }; //成员函数
}
constexpr auto mid = midpoint(p1, p2);      //使用constexpr函数的结果
                                            //初始化constexpr对象

这太令人激动了。它意味着mid对象通过调用构造函数,getter和非成员函数来进行初始化过程就能在只读内存中被创建出来!它也意味着你可以在模板实参或者需要枚举名的值的表达式里面使用像mid.xValue() * 10的表达式!(因为Point::xValue返回doublemid.xValue() * 10也是个double。浮点数类型不可被用于实例化模板或者说明枚举名的值,但是它们可以被用来作为产生整数值的大表达式的一部分。比如,static_cast<int>(mid.xValue() * 10)可以被用来实例化模板或者说明枚举名的值。)它也意味着以前相对严格的编译期完成的工作和运行时完成的工作的界限变得模糊,一些传统上在运行时的计算过程能并入编译时。越多这样的代码并入,你的程序就越快。(然而,编译会花费更长时间)

在C++11中,有两个限制使得Point的成员函数setXsetY不能声明为constexpr。第一,它们修改它们操作的对象的状态, 并且在C++11中,constexpr成员函数是隐式的const。第二,它们有void返回类型,void类型不是C++11中的字面值类型。这两个限制在C++14中放开了,所以C++14中Pointsetter(赋值器)也能声明为constexpr

class Point {
public:
    …
    constexpr void setX(double newX) noexcept { x = newX; } //C++14
    constexpr void setY(double newY) noexcept { y = newY; } //C++14
    …
};

现在也能写这样的函数:

//返回p相对于原点的镜像
constexpr Point reflection(const Point& p) noexcept
{
    Point result;                   //创建non-const Point
    result.setX(-p.xValue());       //设定它的x和y值
    result.setY(-p.yValue());
    return result;                  //返回它的副本
}

客户端代码可以这样写:

constexpr Point p1(9.4, 27.7);          //和之前一样
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);

constexpr auto reflectedMid =         //reflectedMid的值
    reflection(mid);                  //(-19.1, -16.5)在编译期可知

本条款的建议是尽可能的使用constexpr,现在我希望大家已经明白缘由:constexpr对象和constexpr函数可以使用的范围比non-constexpr对象和函数大得多。使用constexpr关键字可以最大化你的对象和函数可以使用的场景。

还有个重要的需要注意的是constexpr是对象和函数接口的一部分。加上constexpr相当于宣称“我能被用在C++要求常量表达式的地方”。如果你声明一个对象或者函数是constexpr,客户端程序员就可能会在那些场景中使用它。如果你后面认为使用constexpr是一个错误并想移除它,你可能造成大量客户端代码不能编译。(为了debug或者性能优化而添加I/O到一个函数中这样简单的动作可能就导致这样的问题,因为I/O语句一般不被允许出现在constexpr函数里)“尽可能”的使用constexpr表示你需要长期坚持对某个对象或者函数施加这种限制。

请记住:

  • constexpr对象是const,它被在编译期可知的值初始化
  • 当传递编译期可知的值时,constexpr函数可以产出编译期可知的结果
  • constexpr对象和函数可以使用的范围比non-constexpr对象和函数要大
  • constexpr是对象和函数接口的一部分