15.尽量使用constexpr

前言

 
constexpr应用于对象时,其类似于const的加强版,但其应用于函数时的意义却与此大相径庭。从概念而言,constexpr不仅仅表示一个常量,也表示该量在编译期即可明确。然而,在constexpr function中,constexpr未必代表着编译期取值,甚至未必能保证const。


constexpr objects

 
constexpr对象具备const属性,其值在编译期已知。(从技术层面来说,其值决定于translation时期,也就是编译期和链接期,但这一点并不重要,除非你需要自己写一个C++的编译器和链接器,不然你总可以认为constexpr对象的值在编译期即已确定)。

编译期即被明确的值具有一定的特权,例如它们可能会被存于只读存储器中,对于嵌入式开发者来说这或许是一个非常重要的特性,但其更广泛适用于C++编译期需要整型常量表达式的环境中,例如数组长度说明、整型模板参数设定、枚举量设定、齐位说明符设定等等。当你需要将一个变量应用于上述环境时,将其声明为constexpr即等效于向编译器保证其可在编译期求值:

1
2
3
4
5
6
int sz; // non-constexpr variable

constexpr auto arraySize1 = sz; // error! sz's value not known at compilation
std::array<int, sz> data1; // error! same problem
constexpr auto arraySize2 = 10; // fine, 10 is a compile-time constant
std::array<int, arraySize2> data2; // fine, arraySize2 is constexpr

需要注意的是const并不具备编译期明确其值的能力:
1
2
3
4
int sz; // as before

const auto arraySize = sz; // fine, arraySize is const copy of sz
std::array<int, arraySize> data; // error! arraySize's value not known at compilation

简而言之,所有的constexpr对象都是const的,但并不是所有的const对象都是constexpr。仅有constexpr对象具备编译期明确其值的能力。


constexpr function

 
constexpr function的情况与其使用情境相关,如果你以一个compile-time constants调用constexpr function,该函数会产生一个compile-time constants,而如果你以一个runtime values调用它,它则会产生一个runtime value。

constexpr function的用途

  1. 用于必需compile-time constants的环境下(例如数组长度)
    如果你传入的参数可在编译期获得,那么constexpr function会在编译期产生结果,反之只要有一个参数不符合compile-time,则代码将无法通过编译。
  2. 减少代码重复
    当一个constexpr函数被一个或多个runtime values调用时,它会按照普通函数的形式运行。这意味着我们不需要在开发时区分compile-time value与runtime value。

问题实例

 
假设我们需要一个数据结构以保存各种实验条件下的运行结果,实验条件条件可能有光照等级、风扇速度、室内温度等等。如果可变的实验条件共有n个,每个实验条件又存在3种状态,那么本实验共有3^n种组合,因此我们需要一个可以容纳3^n个实验结果的数据结构。假定每一个结果均为int类型且n在编译期已知(或可被计算),那么std::array似乎可以适用于本次设计,但我们还需要一种方式以在编译期计算出3^n具体的大小。std::pow可以完成我们想要的计算,但它存在两个问题:1、工作于浮点数;2、不具备constexpr属性。因此我们无法使用它来在编译期计算std::array的大小。

不过我们可以自己完成pow的编写。在定义它之前,我们先看看它应当被如何声明与使用:

1
2
3
4
5
constexpr int pow(int base, int exp) noexcept{
// impl is below
}
constexpr auto numConds = 5;
std::array<int, pow(3, numConds)> results;

pow前面的constexpr并不表明pow返回一个const值,它表示如果base和exp是编译时常量,那么pow的结果可以用作编译时常量。 如果base或exp不是编译时常量,pow的结果将在运行时计算。这意味着pow不仅用于在编译期计算std::array的大小,还可以在运行时环境中调用它:
1
2
3
auto base = readFromDB("base"); // get these values
auto exp = readFromDB("exponent"); // at runtime
auto baseToExp = pow(base, exp); // call pow function at runtime


constexpr的限制条件

 
当传入参数是一个compile-time value时,constexpr function必然compile-time value,因此它的实现方式存在一些限制,并且在C++11与C++14中这些限制不尽相同。

在C++11中,constexpr函数只允许包含一个可执行语句:return语句,但实际上我们可以用两种方法钻空子。首先,我们可以使用条件”?:”运算符来代替if-else语句;其次,我们可以使用递归来代替循环。因此,pow可被实现为:

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

想必除了函数式语言开发者之外没人会觉得上述代码比较优雅直观。C++14对于constexpr的限制较为宽泛,因此我们可以自由地写出这样的代码:
1
2
3
4
5
constexpr int pow(int base, int exp) noexcept {
auto result = 1;
for (int i = 0; i < exp; ++i) result *= base;
return result;
}

constexpr函数的参数与返回值均限定为literal type,因为仅有该类型可在编译期确定。 在C++11中,除void之外的所有内置类型均为literal type,但用户自定义类型也可能是literal type,因为其构造函数和其他成员函数可能具备constexpr属性:
1
2
3
4
5
6
7
8
9
10
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属性:
1
2
constexpr Point p1(9.4, 27.7); // fine, "runs" constexpr ctor during compilation
constexpr Point p2(28.8, 5.3); // also fine

同样地,xValue和yValue也可以是constexpr,如果在constexpr Point对象上调用getter,则可以在编译期间知道数据成员x和y的值。如此则可以用这些函数的结果初始化constexpr对象:
1
2
3
4
5
constexpr Point midpoint(const Point& p1, const Point& p2) noexcept{
return { (p1.xValue() + p2.xValue()) / 2,
(p1.yValue() + p2.yValue()) / 2 };
}
constexpr auto mid = midpoint(p1, p2);

这种操作手法十分高妙,尽管mid的初始化操作涉及构造函数、getter函数、非成员函数,但最终我们在只读内存中完成了对它的创建。这种技法意味着编译期与运行期的界限开始趋于模糊,并且以往在运行期才能完成的操作如今可以提前至编译期完成。参与提前的代码越多,运行速度越快(当然编译速度会有所降低)。

在C++11中,两个限制阻止了Point的成员函数setX和setY被声明为constexpr。首先,它们能够修改*this,而在C++11中,constexpr成员函数被隐式的认为具备const属性。 其次,它们具有void返回类型,而void在C++11中不属于literal type。这两个限制都在C++14中得到了解除,所以在C++14中,Point的setter也可被声明为constexpr:

1
2
3
4
5
6
7
class Point {
public:

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

};

因此下述函数成为了可能:
1
2
3
4
5
6
7
//return reflection of p with respect to the origin (C++14)
constexpr Point reflection(const Point& p) noexcept{
Point result; // create non-const Point
result.setX(-p.xValue()); // set its x and y values
result.setY(-p.yValue());
return result; // return copy of it
}

客户端程序看起来可能如下所示:
1
2
3
4
constexpr Point p1(9.4, 27.7); // as above
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);
constexpr auto reflectedMid = reflection(mid); // known during compilation


constexpr的意义

 
constexpr对象和constexpr函数的使用范围均大于non-constexpr对象与函数,通过尽可能使用constexpr,对象与函数的使用范围得到了扩大。

constexpr类似于const、noexcept,是对象和函数接口的一部分,如果一个对象或函数被声明为constexpr,则意味着它可以在任何需要常量表达式的环境中使用。如果你将某个对象或函数声明为constexpr后又后会做出了这个决定,并且将constexpr撤销,那么可能会导致无数的客户端程序发生了雪崩。我们这里所说的尽可能,指的是在你愿意付出精力长期维系它们为constexpr的情况下。


总结

  1. constexpr对象具有const属性,并且其值在编译期即可确定。
  2. constexpr函数在其调用对象为compile-time value时会产生compile-time value。
  3. constexpr对象与函数的使用范围大于non-constexpr。
  4. constexpr是对象或函数接口的一部分。