跳转至

item15

条款十五:尽可能的使用 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 对象不需要在编译期初始化它的值。

cpp 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 是对象和函数接口的一部分