附录一:高级 constexpr

优质
小牛编辑
140浏览
2023-12-01

C++中,编译时和运行时之间的边界是模糊的,这在C++14中引入泛化常量表达式时更是如此。 然而,能够操纵异构对象就意味着要能深刻理解边界的含义,让代码按自己的意图来运行。 本节的目标是使用constexpr来设置一些东西; 以了解哪些问题可以解决,哪些不能。 本节涵盖了关于常量表达式的高级概念; 只有对constexpr有很好理解的读者才应该尝试阅读。

Constexpr stripping

让我们开始一个具有挑战性的问题。 下面的代码可编译吗?

template <typename T>
void f(T t) {
  static_assert(t == 1, "");
}
constexpr int one = 1;
f(one);

答案是不能,由Clang给出的错误就像:

error: static_assert expression is not an integral constant expression
  static_assert(t == 1, "");
                ^~~~~~

对出错的解释是,在f的函数体内,t不是常数表达式,因此不能用作static_assert的操作数。 原因是这样的函数根本不能由编译器生成。 要理解这个问题,考虑当我们使用具体类型实例化f模板时发生了什么:

// Here, the compiler should generate the code for f<int> and store the
// address of that code into fptr.
void (*fptr)(int) = f<int>;

显然,编译器不能生成f<int>的代码,如果t!= 1,它应该触发一个static_assert,因为我们还没有指定t的值。 更糟的是,生成的函数应该适用于常量和非常量表达式:

void (*fptr)(int) = f<int>; // assume this was possible
int i = ...; // user input
fptr(i);

显然,不能生成fptr的代码,因为它需要能够对运行时值进行static_assert,这是没有意义的。 此外,注意,无论你是否使用constexpr函数都没关系; 使f constexpr只声明f的结果是一个常量表达式,只要它的参数是一个常量表达式,但它仍然不能让你知道你是否使用fbody中的常量表达式调用。 换句话说,我们想要的是:

template <typename T>
void f(constexpr T t) {
  static_assert(t == 1, "");
}
constexpr int one = 1;
f(one);

在这个假设情况下,编译器将知道t是来自f的主体的常量表达式,并且可以使static_assert起作用。 然而,当前语言还不constexpr参数,并且添加它们将带来非常具有挑战性的设计和实现问题。 这个小实验的结论是参数传递剥离了constexpr-ness。 现在可能不清楚的是这种剥离的后果,接下来解释。

Constexpr 保存

参数不是常量表达式意味着我们不能将其用作非类型模板参数,数组绑定,static_assert或需要常量表达式的任何其他地方。 此外,这意味着函数的返回类型不能取决于参数的值,如果你想以这样的形式得到一个新类型:

template <int i>
struct foo { };
auto f(int i) -> foo<i>; // obviously won't work

显然,这行不通。事实上,函数的返回类型只能取决于它的参数的类型,而constexpr不能改变这个事实。 但根据函数的参数返回具有不同类型的对象对我们至关重要,因为我们对操作异构对象感兴趣。 例如,一个函数可能希望在一种情况下返回类型T的对象,在另一种情况下返回类型U的对象; 从以上分析来看,我们现在知道这些“情况”将必须依赖于参数类型编码的信息,而不是它们的值。

为了通过参数传递来保留constexpr,我们必须将constexpr值编码为一个类型,然后将一个不一定是该类型的constexpr对象传递给函数。 该函数必须是模板,然后可以访问在该类型内编码的constexpr值。

TODO: 改进这个解释,并谈论包装成类型的非整数常量表达式。

副作用

让我提一个棘手的问题。 以下代码是否有效?

template <typename T>
constexpr int f(T& n) { return 1; }
int n = 0;
constexpr int i = f(n);

答案是肯定的,但原因可能不明显。 这里发生的是,我们有一个非常量的值n和一个constexpr函数f和它的引用参数。 大多数人认为它不应该工作的原因是n不是constexpr。 但是,我们不在f内部做任何事情,所以没有什么实质的理由解释它不应该工作! 这有点像在内部的一个constexpr函数:

constexpr int sqrt(int i) {
  if (i < 0) throw "i should be non-negative";
  return ...;
}
constexpr int two = sqrt(4); // ok: did not attempt to throw
constexpr int error = sqrt(-4); // error: can't throw in a constant expression

只要throw出现的代码路径不被执行,调用的结果可以是常量表达式。 同样,我们可以在f中做任何我们想要的事,只要我们不执行需要访问它的参数n的代码,因为这不是一个常量表达式:

template <typename T>
constexpr int f(T& n, bool touch_n) {
  if (touch_n) n + 1;
  return 1;
}
int n = 0;
constexpr int i = f(n, false); // ok
constexpr int j = f(n, true); // error

Clang给出的第二次调用的错误是:

error: constexpr variable 'j' must be initialized by a constant expression
constexpr int j = f(n, true); // error
              ^   ~~~~~~~~~~
note: read of non-const variable 'n' is not allowed in a constant expression
  if (touch_n) n + 1;
               ^

让我们现在停下来看看它的游戏规则,并考虑一个更微妙的例子。 以下代码是否有效?

template <typename T>
constexpr int f(T n) { return 1; }
int n = 0;
constexpr int i = f(n);

与我们的初始场景唯一的区别是,f现在的参数按值而不是按引用传递。 然而,这与上一个函数有所不同。 事实上,我们现在要求编译器创建一个n的副本,并将此副本传递给f。 然而,n不是constexpr,所以它的值只在运行时知道。 编译器要怎么操作编译一个变量的副本(在编译时),但该变量的值只在运行时才知道? 当然,它不能。 事实上,Clang给出的错误信息对于发生了什么很清楚:

error: constexpr variable 'i' must be initialized by a constant expression
constexpr int i = f(n);
              ^   ~~~~
note: read of non-const variable 'n' is not allowed in a constant expression
constexpr int i = f(n);
                    ^

TODO: 解释在常量表达式中不会出现副作用,即使它们产生的表达式不被访问。