何时以及为什么要在constexpr中使用static

When and why would you use static with constexpr?

本文关键字:static constexpr 为什么 何时      更新时间:2023-10-16

作为免责声明,我在提出这个问题之前做了我的研究。我发现了一个类似的问题,但答案感觉有点"稻草人",并没有真正回答我个人的问题。我也参考了我方便的cppreference页面,但大多数时候它并没有提供一个非常"简化"的解释。

基本上我仍然在提高constexpr,但目前我的理解是,它需要表达式在编译时进行评估。因为它们可能只在编译时存在,所以它们在运行时不会真正拥有内存地址。所以当我看到人们使用static constexpr(比如在课堂上)时,我很困惑……static在这里是多余的,因为它只对运行时上下文有用。

我在"constexpr不允许除了编译时表达式之外的任何东西"语句中看到了矛盾(特别是在SO这里)。然而,Bjarne Stroustrup页面上的一篇文章通过各种示例解释了事实上constexpr确实要求在编译时对表达式求值。如果没有,将生成编译器错误。

我的前一段似乎有点偏离主题,但这是一个必要的基线,以理解为什么static可以或应该与constexpr一起使用。不幸的是,这个基线有很多相互矛盾的信息。

谁能帮我把所有这些信息整合成纯粹的事实,用例子和有意义的概念?除了理解constexpr的真正行为之外,为什么要使用static呢?如果它们可以一起使用,static constexpr在哪些范围/场景下是有意义的?

函数级静态变量有一个显著的区别,这与lambda-capture有关:

void odr_use(int const&);
int main() {
int non_static = 42;
static int is_static = 42;
[]{
odr_use(non_static); // error
odr_use(is_static);  // OK
}();
}

允许在lambdas中使用函数局部静态变量而不捕获它们。这与constexpr没有任何关系——然而,强制捕获constexpr变量通常没有什么意义。因此,static+constexpr在从lambdas访问常量时提供了一些安慰,考虑:

#include <string_view>
int main()
{
constexpr std::string_view x = "foo";
[]{ x.data(); }; // error: odr-use of non-captured variable
}

在这个例子中,字符串视图和它的内容是常量。然而,使用成员函数会触发odr-use,这需要我们捕获变量。或者,使用static+constexpr

多用途意思是"根据单一定义规则使用",它可以归结为"是该操作所需对象的地址"。对于成员函数,需要该地址来形成this-指针。


下面你会发现概念上的差异,这也解释了上面提到的效果。

constexpr变量不是编译时值

一个值是不可变的,不占用存储空间(没有地址),然而,声明为constexpr的对象可以是可变的,并且确实占用存储空间(在as-if规则下)。

可变性

大多数声明为constexpr的对象都是不可变的,但是可以定义一个(部分)可变的constexpr对象,如下:

struct S {
mutable int m;
};
int main() {
constexpr S s{42};
int arr[s.m];       // error: s.m is not a constant expression
s.m = 21;           // ok, assigning to a mutable member of a const object
}

<<h3>存储/h3>在as-if规则下,编译器可以选择分配任何存储空间来存储声明为constexpr的对象的值。类似地,它也可以对非constexpr变量进行这样的优化。但是,考虑一下我们需要将对象的地址传递给非内联函数的情况;例如:

struct data {
int i;
double d;
// some more members
};
int my_algorithm(data const*, int);
int main() {
constexpr data precomputed = /*...*/;
int const i = /*run-time value*/;
my_algorithm(&precomputed, i);
}

编译器需要为precomputed分配存储空间,以便将其地址传递给一些非内联函数。编译器可以为precomputedi连续分配存储空间;可以想象这种情况可能会影响性能(见下文)。

Standardese

变量可以是对象或引用[basic]/6。让我们关注对象。

constexpr int a = 42;这样的声明在语法上是简单声明;它由decl- specific -seqinit-declarator-list;

组成从(dcl。Dcl]/9,我们可以得出结论(但不是严格地),这样的声明声明了一个对象。具体来说,我们可以(严格地)断定它是一个对象声明,但这包括引用声明。参见是否可以有void类型的变量的讨论。

对象声明中的constexpr暗示对象的类型是const[dcl.constexpr]/9。对象是存储[intro.object]/1的一个区域。我们可以从[引言]中推断。[对象]/6和[介绍]。Memory]/1表示每个对象都有一个地址。注意,我们可能不能直接获取这个地址,例如,如果对象是通过右值引用的。(甚至还有不是对象的前值,比如字面量42。)两个完全不同的对象必须有不同的地址[introobject]/6.

从这里,我们可以得出结论,声明为constexpr的对象必须具有相对于的唯一地址任何其他(完整)对象

进一步,我们可以得出结论,声明constexpr int a = 42;声明了一个具有唯一地址的对象。

static和constexpr

我认为唯一有趣的问题是"按功能static",

void foo() {
static constexpr int i = 42;
}

据我所知——但这似乎仍然不完全清楚——编译器可能在运行时计算constexpr变量的初始化项。但这似乎是病态的;让我们假设没有这样做,也就是说,它会在编译时预先计算初始化式。

static constexpr局部变量的初始化是在静态初始化期间完成的。这必须在任何动态初始化[basic.start.init]/2之前执行。虽然不能保证,但我们可以假设这不会造成运行时/加载时成本。此外,由于常量初始化没有并发问题,我认为我们可以安全地假设这不需要线程安全的运行时检查static变量是否已经初始化。(查看clang和gcc的源代码应该会对这些问题有所启发。)

对于非静态局部变量的初始化,在某些情况下,编译器无法在常量初始化期间初始化变量:

void non_inlined_function(int const*);
void recurse(int const i) {
constexpr int c = 42;
// a different address is guaranteed for `c` for each recursion step
non_inlined_function(&c);
if(i > 0) recurse(i-1);
}
int main() {
int i;
std::cin >> i;
recurse(i);
}
结论

看起来,在某些极端情况下,我们可以从static constexpr变量的静态存储持续时间中获益。然而,我们可能会失去这个局部变量的局部性,如"storage"一节所示。这个答案。直到我看到一个基准,表明这是一个真实的效果,我将假定这是无关的。

如果staticconstexpr对象只有这两种效果,我会默认使用static:我们通常不需要保证constexpr对象的唯一地址。

对于可变constexpr对象(包含mutable成员的类类型),本地static和非静态constexpr对象之间的语义明显不同。同样,如果地址本身的值是相关的(例如,用于哈希映射查找)。

仅供示例。社区维基。

static== per-function(静态存储时长)

声明为constexpr的对象和其他对象一样有地址。如果出于某种原因,使用了对象的地址,编译器可能必须为它分配存储空间:

constexpr int expensive_computation(int n); // defined elsewhere
void foo(int const p = 3) {
constexpr static int bar = expensive_computation(42);
std::cout << static_cast<void const*>(&bar) << "n";
if(p) foo(p-1);
}

变量的地址对于所有调用都是相同的;每个函数调用都不需要堆栈空间。比较:

void foo(int const p = 3) {
constexpr int bar = expensive_computation(42);
std::cout << static_cast<void const*>(&bar) << "n";
if(p) foo(p-1);
}

这里,对于foo的每次(递归)调用,地址将是不同的

这很重要,例如,如果对象很大(例如数组),并且我们需要在需要常量表达式(需要编译时常量)并且需要获取其地址的上下文中使用它。

注意,由于地址必须不同,对象可能在运行时被初始化;例如,如果递归深度取决于运行时参数。初始化器仍然可以预先计算,但是对于每个递归步骤,结果可能必须复制到新的内存区域中。在这种情况下,constexpr只保证初始化式可以在编译时求值,并且初始化可以在编译时对该类型的变量执行

static== per-class

template<int N>
struct foo
{
static constexpr int n = N;
};

与往常一样:为foo的每个模板特化(实例化)声明一个变量,例如foo<1>,foo<42>,foo<1729>。如果你想公开非类型模板形参,你可以使用静态数据成员。它可以是constexpr,这样其他人就可以从编译时已知的值中受益。

static==内部联动

// namespace-scope
static constexpr int x = 42;

相当冗余;默认情况下,constexpr变量具有内部链接。在这种情况下,我看不出有任何理由使用static

我使用static constexpr作为未命名枚举的替代,在我不知道确切的类型定义,但希望查询有关类型的一些信息(通常在编译时)的地方。

编译时未命名枚举还有一些额外的好处。更容易调试(值在调试器中显示为"正常"变量)。此外,您可以使用任何可以constexpr构造的类型(不仅仅是数字),而不仅仅是带有enum的数字。

例子:

template<size_t item_count, size_t item_size> struct item_information
{
static constexpr size_t count_ = item_count;
static constexpr size_t size_ = item_size;
};
现在,您可以在编译时访问这些变量:
using t = item_information <5, 10>;
constexpr size_t total = t::count_ * t::size_;

选择:

template<size_t item_count, size_t item_size> struct item_information
{
enum { count_ = item_count };
enum { size_ = item_size };
};
template<size_t item_count, size_t item_size> struct item_information
{
static const size_t count_ = item_count;
static const size_t size_ = item_size;
};

替代方法不具有静态constexpr的所有优点——您可以保证编译时处理、类型安全以及(潜在的)更低的内存使用(constexpr变量不需要占用内存,除非可能,它们是有效的硬编码)。

除非你开始获取constexpr变量的地址(即使你仍然这样做),否则你的类不会像标准静态const那样增加大小。