C++11:constexpr & 编译期性质

C++11:constexpr & 编译期性质


常量表达式 constexpr

const expression常量表达式,是C++11引入的新特性,用于将表达式提前到编译期进行计算,从而减少运行时的重复计算,提高运行时效率。该特性基于关键字constexpr,可以用于修饰变量,函数等。

变量

其实在C++11之前,C++默认就会把 const int 进行编译期计算,从而优化运行时效率,但是这个过程是隐式的。

例如以下代码:

cpp 复制代码
int c1 = 1 + 20 * 5;
const int a = 500;
int c2 = a + 1 * 2;
int c3 = c2 + 1;

此处对于c1ac2都是在编译期就可以确认值的,但是c3需要运行时才会确认值。

  • c1:由于表达式中所有值都是字面量,因此在编译期就可以确定整个表达式的值,从而初始化c1

    汇编代码如下:

    cpp 复制代码
    C7 45 04 65 00 00 00 mov         dword ptr [c1],65h  

    此处65h就是1 + 20 * 5的十六进制值,汇编阶段直接计算出来并且赋值到变量c1了。

  • a:表达式直接赋值字面量500,同理编译期就可以确定值

    汇编代码如下:

    cpp 复制代码
    C7 45 24 F4 01 00 00 mov         dword ptr [a],1F4h  

    c1同理,1F4h就是十六进制的500

  • c2:表达式中a是一个const int变量,说明后续不会发生修改,又在编译期就已经确认初始值,所以整个表达式 a + 1 * 2 也是可以在编译期确认值的。

    汇编代码如下:

    cpp 复制代码
    C7 45 44 F6 01 00 00 mov         dword ptr [c2],1F6h  

    这里也是直接计算了500 + 1 * 2 的十六进制,赋值到c2

  • c3:此处表达式中,c2不是一个const变量,因此就算编译期确定它的初始值,也无法保证后续不会被修改,因此需要运行时推断。

    汇编代码如下:

    cpp 复制代码
    8B 45 44             mov         eax,dword ptr [c2]  
    FF C0                inc         eax  
    89 45 64             mov         dword ptr [c3],eax

    首先把c2变量的值加载到eax寄存器里面,然后inc指令对eax里面的值加一,最后把eax自增后的值赋值到c3,整个过程是运行时完成的。

以上四行代码的解析,可以得出C++本身是会对表达式进行优化的,如果表达式在编译期就可以求出结果,编译器就有可能在编译阶段求出其具体值,而不是在运行时计算。

并且对于const int(整形,枚举,且有初始值),编译器本身会尽可能让其在编译期进行计算,成为一个常量表达式,但是对于const修饰的其他类型不做此要求,有可能在编译期也有可能在运行时。

在C++11,拓宽了对这个编译期计算特性的普适性,引入了constexpr关键字,被其修饰的变量,一定会在编译期得到结果,如果无法得到结果,那么就会报错。

  • 修饰变量语法:
cpp 复制代码
constexpr type name = expression;

此处的expression必须满足:

  1. 编译期可以确定值
  2. 确定后不会再修改
  3. 不为空,即变量必须初始化

例如:

cpp 复制代码
constexpr int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // success

此处的c1c2都是合法的常量表达式,在编译期可以进行求值。

此外,const int 也是可以初始化 constexpr int 的:

cpp 复制代码
const int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // success

因为刚刚提到,const int(整形家族,枚举)是会隐式在编译期就尽可能求值的,那么它满足 编译期求值 + 后续不修改 两大特性,就可以初始化constexpr

但是以下代码又是错误的:

cpp 复制代码
int a = 1;
const int c1 = a + 20 * 5;
constexpr int c2 = c1 + 500; // error

此处代码相比之前,就是c1初始化的时候依赖了普通变量a,但是a无法在编译期求值,自然c1也就无法在编译期求值,进而导致c2无法在编译期求值,constexpr报错。

刚才高亮语句也说明了,const int 只是尽可能在编译期进行求值,如果不行,那么const int 就无法进一步初始化constexpr int

但是对于整形家族和枚举以外的类型,const是绝不能修饰constexpr的,例如:

cpp 复制代码
const double d1 = 3.14;
constexpr double d2 = d1 * 3; // error

此处就算d1可以在编译期确定值,并且const特性保证后续不会修改。但是C++标准并没有对此明确规定,d1也有可能会在运行时得到值,这取决于编译器,所以const double 无法初始化constexpr double

最后,就算是const int,如果被volatile修饰,也不能用于初始化constexpr,例如:

cpp 复制代码
volatile const int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // error

因为volatile const表示,这个变量就算是const,也是有可能被修改的,那么就不满足constexpr的第二个要求。

前面这一大段,主要阐述了constexpr初始化的几个场景,总结一下:

  1. const int(整型家族,枚举):在编译期可以确定值,并且不被volatile修饰
  2. const 其它:不论如何都不能拿来初始化constexpr
  3. constexpr 任意:都可以拿来初始化其他的constexpr表达式,也是最佳实践

IiteralType

并不是所有的变量都可以被constexpr修饰,并在编译期求值的,比如:

cpp 复制代码
constexpr std::string str = "hello";

这行代码是会报错的,因为std::string需要动态内存管理,无法编译期求值。

只有一个类型为 IiteralType,才能被constexpr修饰。

此处的IiteralType称为字面量类型,包含五种情况:

  1. void类型(C++14后支持,C++11不允许返回void
  2. 标量类型
  3. 引用类型
  4. 字面类型数组
  5. 类类型,且满足:
    • 有一个平凡的 constexpr 析构函数
    • 所有非静态非变体数据成员和基类都是字面类型
    • 满足以下之一:
      • lambda 类型(C++17后)
      • 至少有一个 constexpr修饰的构造函数,且该构造不是拷贝构造或移动构造
      • 聚合 union 类型, 没有变体成员 或者 至少有一个非易失性字面类型的变体成员
      • 聚合 非union 类型,且如果有内置匿名联合成员,该联合必须满足上一条

这一大段着实让人看着头大了,引入了非常多从未接触过的定义。但这已经是我简化过的版本,可参考官网描述:C++ 命名要求: LiteralType (自 C++11 起),官网对这一部分的描述,可以说是非常晦涩。

简单来说,总共有五种情况满足IiteralType,其中第五种是针对类类型。

对于类类型,要求满足三个要求,而第三条要求内部,又需要满足四个条件之一。

首先解释部分专有名词:

  • 字面类型数组:此处专指T arr[n]这种形式,也就是C风格的数组,且数组的元素类型必须是字面类型

  • 非静态:也就是没有被static修饰的类成员

  • 非易失性:被volatile修饰的变量称为易失性变量,非易失性即没有被volatile修饰

  • 平凡析构函数:一个析构函数被认为是平凡的,当它同时满足以下所有条件:

    • 隐式定义或默认定义 :没有用户提供的析构函数定义(即使用= default或完全不声明析构函数)。
    • 非虚析构函数 :析构函数没有被声明为virtual虚函数。
    • 所有基类和非静态数据成员都有平凡的析构函数:类的直接基类和所有非静态数据成员的析构函数也必须是平凡的。
  • 聚合类型:满足以下要求:

    • 无用户提供或继承的构造函数 :聚合类型可以有默认构造函数,但必须是编译器自动生成的,不能有用户显式定义的构造函数(除了= default形式显式要求编译器生成默认构造函数的情况 )。

    • 无私有或保护的非静态数据成员:所有非静态数据成员都是公有的,可以直接访问。

    • 无虚函数和虚基类:聚合类型不包含虚函数,因为虚函数涉及到运行时多态,会增加类型的复杂性。

    可以说,当一个类属于聚合类型,几乎就退化到了C语言的struct的状态,不含复杂的构造,内存模型简单,可以在编译期完成求值。

  • 变体成员

    引入一个概念,类联合体类,是联合体union 或 至少有一个匿名联合体作为成员的非联合体类。

    简而言之,就是一个联合体union本身算作类联合体类,或者当一个类内部包含了匿名union,也叫做类联合体类

    例如:

    cpp 复制代码
    union my_union
    {
    	int x;
    	int y;
    };
    
    class my_class
    {
    	union
    	{
    		int a;
    		int b;
    	} _u;
    };

    代码中的联合体my_union和类my_class都算 类联合体类。对于my_class,他内部有一个成员_u,是一个匿名联合体,因为union关键字后面没有联合体的名称。

    对于类联合体类,内部的联合体的成员,叫做变体成员。即以上的xyab都叫做变体成员。

现在回看之前的IiteralType要求,我做出一定修改:

  1. void类型 (C++14后)
  2. 标量类型
  3. 引用类型
  4. C风格数组,且数组成员都是字面类型
  5. 类类型,且满足:
    • 有一个平凡的 constexpr 析构函数
    • 所有 不被staticvolatile修饰的成员 和 基类 都是字面类型
    • 满足以下之一:
      • lambda 类型(C++17后)
      • 至少有一个 constexpr修饰的构造函数,且该构造不是拷贝构造或移动构造
      • 它是聚合类型,且不是类联合体类
      • 它是聚合类型,且是类联合体类:联合体内不存在变体成员,或者至少存在一个不被volatile修饰的变体成员

接下来,我再对部分条目做出一定解释。

回到 IiteralType 的本质,它就是一个constexpr修饰类型的要求。既然要在编译期完成计算,那么这个类型要在编译期就可以确认,以上五条性质就是在要求IiteralType是在编译期可计算的。

首先是C风格数组,它在定义时确定了数组长度,以及每个元素类型,那么编译期就可以确定它的内存布局。

例如以下 constexpr 是合法的:

cpp 复制代码
constexpr int arr[] = { 1, 2, 3 };

如果要用一个constexpr修饰类类型:

  1. 首先要有一个平凡的析构函数,这个在之前讲了,也就是析构函数是默认的,并且不被viture修饰,所有成员和基类也都有平凡的析构。这一步保证了这个类没有复杂的内部实现,可以迅速销毁,也反映其内存模型简单。
  2. 所有的不被staticvolatile修饰的成员 和 基类 都是 IteralType

这两个条件,基本限制了类的模型不会非常复杂,几乎不可能存在类似于动态内存管理这样的操作,在编译期可能完成求值。

  1. 满足四个条件之一
  • lambda,其实不是所有lambda都可以,这个不深入讲,属于C++17的特性
  • 至少有一个constexpr修饰的构造,并且这个构造不是拷贝构造或移动构造

这个是下一段内会讲到的,constexpr可以修饰函数,那么这个函数就可以在编译期完成调用得到结果。被constexpr修饰的函数,内部会有很多限制,无法完成太过于复杂的操作。因此当constexpr修饰构造,说明构造不会太复杂,可以在编译期完成构造函数的调用,那么也就可以在编译期初始化出对象。

但是这个构造不能是拷贝构造或者移动构造,这其实是一个先有鸡还是先有蛋的问题。想一想,如果某个类只有拷贝构造或者移动构造被constexpr修饰。现在在编译期一个该类型的对象C要完成求值,C的拷贝/移动构造必须传入另外一个同类型的对象B,但是B从哪里来呢?要么是从一个普通的构造函数,但是刚才说了,普通的构造不被constexpr修饰,无法在编译期求到值。另外一种就是也通过拷贝或者移动而来,那么B又要再去依赖另外一个同类对象A,以此类推,编译期根本不可能完成构造对象的任务。因此这个构造不能是拷贝构造或移动构造。

  • 如果这个类型是一个聚合类型,且不是类联合体类

那么这个类本身就非常简单,满足聚合类型就说明内存模型不会太复杂了,在编译期可以完成求值。

  • 如果这个类型是一个聚合类型,且是类联合体类,联合体内不存在变体成员,或者至少存在一个不被volatile修饰的变体成员

也就是说,它是一个union或者类内包含匿名union,此时要考虑变体成员的问题。

第一种是不存在变体成员

例如:

cpp 复制代码
union my_union
{
};

class my_class
{
	union
	{
	} _u;
};

my_unionmy_class::_u都不含任何变体成员,那么编译期对于这两个内容,完全就不用考虑内存如何分配的问题,因此可以在编译期完成计算。

第二种是至少存在一个不被volatile修饰的变体成员

例如:

cpp 复制代码
union my_union
{
	int x;
	volatile int y; 
};

此处,就算yvolatile修饰,有可能会在后续发生改变。但是x是没有被修饰的变体成员,这个my_union依然可以被constexpr修饰。

但是以下情况就不行了:

cpp 复制代码
union my_union
{
	volatile int x;
	volatile int y;
};

这个类联合体类内,所有变体成员都被volatile修饰,那么编译期就算可以确认其值,但是无法保证其后续不会发生改变,不符合constexpr的要求。

没想到一个小小的constexpr能牵扯到这么多乱七八糟的知识点,也算是在C++庞大的知识图谱中拓展了一块未踏足版图。


函数

constexpr 也可以修饰函数,让一个函数在编译期进行计算得到结果。在C++11,这个函数只能包含一个return语句,在C++14及C++17拓展后,允许支持复杂的逻辑以及递归。不过递归是有层数限制的,不同编译器取值不同,在256 ~ 2048之间不等。

  • 语法:
cpp 复制代码
constexpr ret_type name(...)
{
	// 函数体
}

只需要在函数返回值左侧加上一个constexpr关键字,这个函数就变成了constexpr函数,可以在编译期进行求值。

例如:

cpp 复制代码
constexpr int fibonacci(int n) 
{
    if (n <= 0)
        return 0;
    else if (n == 1)
        return 1;
    else
        return fibonacci(n - 1) + fibonacci(n - 2);
}

int main()
{
	constexpr int constexpr_ret = fibonacci(5);
	return 0;
}

这个函数用于在编译期求出一个斐波那契的第n个元素。函数内部有递归,分支语句,当使用一个constexpr变量接受返回值,那么整个函数就会在编译期进行运算,并得到结果。

只有满足以下要求,一个函数才会有机会成为constexpr

  1. 返回值和参数都是 IiteralType(字面量类型)
  2. 函数内不能调用其他非 constexpr 函数
  3. 不抛异常

其中IiteralType刚才讲过了,在C++11之前,constexpr是不允许返回void类型,也就是必须有返回值的。

但是在刚刚的IiteralType的要求中,第一条就是C++14新增的,void也算IiteralType了,因此C++14后constexpr函数可以没有返回值。

但是,一个constexpr修饰的函数,不一定在编译期得到返回值。

还记得之前提到,constexpr修饰一个变量,要求初始化这个变量,并且在编译期就可以拿到值。

cpp 复制代码
int a = 2;
constexpr int b = a + 1;

以上代码就是错误的,因为b依赖了一个运行时求值的a

对于函数也是同理的,函数的参数就算是IteralType,也不能保证函数参数就在编译期可以求出值,这些参数的实参也必须是constexpr

例如:

cpp 复制代码
constexpr int add(int x, int y)
{
	return x + y;
}

以下是正确的调用:

cpp 复制代码
constexpr int a = 1;
constexpr int ret1 = add(a, 10);

int b = 20;
int ret2 = add(b, b + 10);

ret1的调用中,add的参数a10都是编译期确定的,调用成功。在ret2的调用中,尽管b是运行时确定的,但是返回值ret2也没有被constexpr修饰,不要求其在编译期求值,那么此时就当做一个普通函数在运行时调用。

以下是错误的调用:

cpp 复制代码
int a = 1;
constexpr int ret = add(a, 10);

此处a是一个运行时求值的变量,但是ret要求编译期求值。尽管add确实是被constexpr修饰的函数,函数参数也满足IteralType,但是实参a编译期拿不到值,最后代码就会报错。


自定义字面量

C++11 引入了用户自定义字面量,允许程序员为自定义类型或特殊语义赋予类似内置字面量的直观写法。通过自定义字面量后缀,可以让代码更具可读性和表达力。

自定义字面量的本质是重载 operator"",即字面量运算符。其语法如下:

cpp 复制代码
返回类型 operator "" _后缀(参数列表)
{
    // 实现
}
  • _后缀必须以下划线开头,且不能包含双下划线(__),也不能以下划线+大写字母开头,如 _A,这些都被保留给标准库使用。
  • 参数类型由字面量的类型决定,常见有:
    • unsigned long long:用于整数字面量
    • long double:用于浮点字面量
    • const char*:用于字符串字面量
    • const char*, std::size_t:字符串字面量 + 长度

例如:

cpp 复制代码
using second = unsigned long long;

second operator""_h(unsigned long long h) 
{ 
    return h * 3600; 
}

second operator""_m(unsigned long long m) 
{ 
    return m * 60; 
}

second operator""_s(unsigned long long s) 
{ 
    return s; 
}

void show_time(second s) 
{
    std::cout << "second: " << s << std::endl;
}

int main() 
{
    show_time(2_h + 30_m + 15_s);
}

通过自定义字面量,2_h30_m15_s 直观表达了时间单位,避免了数字和单位混淆。

参数匹配与重载规则

自定义字面量的参数类型由字面量本身决定,常见匹配规则如下:

参数列表 适用场景 示例
(unsigned long long) 整数字面量 123_后缀
(long double) 浮点字面量 3.14_后缀
(const char*) 字符串字面量 "abc"_后缀
(const char*, std::size_t) 字符串字面量 "abc"_后缀
(char) 字符字面量 'a'_后缀

首先,对于算数类型unsigned long longdouble,直接数值_后缀即可,对于字符串则需要加双引号"字符串"_后缀,字符使用单引号'字符'_后缀,这是调用字面量重载的语法。

对于字符串,有两种函数参数形式,两者调用的方法是相同的,只是两个参数的版本,可以operator ""函数体内部拿到字符串的长度,更加方便。

例如:

cpp 复制代码
const char* operator""_x(const char* str)
{ }

const char* operator""_x(const char* str, size_t len) 
{ }

两个_x的后缀重载都是针对字符串类型,但是如果两个都实现了,优先调用带len的版本。

此外,后缀重载允许拼接字符串,例如:

cpp 复制代码
"123""hello"_x;

这种调用方式是合法的,最后"123hello"作为参数。

如果使用整形,比如123_x,它的行为也比较特殊:

  1. 如果有unsigned long long,优先匹配
  2. 如果没有前者,整形会转化为字符串匹配const char*
  3. 如果前两者都没有,不会匹配const char*, size_tlong double,字面量报错

此外,像这种字面量的计算一般不会很复杂,后缀重载常常被声明为constexpr函数。


静态断言

C++11 引入了 static_assert,用于在编译期进行条件检查。与传统的C风格 assert不同,static_assert 能在编译阶段捕获错误,提升类型安全和模板编程的健壮性。

在 C++11 之前,常用的静态断言方式是利用非法类型或非法表达式强制编译器报错。

例如:

cpp 复制代码
#define STATIC_ASSERT(expr) \
    do { enum { _assert_ = 1 / (expr) }; } while(0)

template<class T, class U>
void var_copy(T& a, const U& b) 
{
    STATIC_ASSERT(sizeof(a) == sizeof(b));
    memcpy(&a, &b, sizeof(b));
}

以上代码实现两个不同类型之间的按字节拷贝。但是拷贝之前要保证两者类型的字节长度相同。此时使用自己定义的STATIC_ASSERT宏函数,expr表达式返回一个布尔值,如果为true,那么1 / 1合法,断言通过。但是如果expr返回false,那么1 / 0,发生错误,直接终止程序。

这种方式确实可以实现断言,但是错误信息不明确,无法知道具体是哪一个错误。

C++11 提供了原生的 static_assert 关键字,语法如下:

cpp 复制代码
static_assert(常量表达式, "错误信息");
  • 第一个参数必须是编译期可确定的常量表达式。
  • 第二个参数是自定义的错误信息,编译失败时会输出。

示例:

cpp 复制代码
template<class T, class U>
void var_copy(T& a, const U& b) 
{
    static_assert(sizeof(a) == sizeof(b), "params must have same width.");
    memcpy(&a, &b, sizeof(b));
}

如果 ab 类型不同,编译器会在编译阶段报错,并输出自定义信息"params must have same width."

相比于C风格的assertstatic_assert可以携带错误信息,并且可以在编译期就完成判断。


相关推荐
每一天都要努力^26 分钟前
C++函数指针
开发语言·c++
刚入门的大一新生29 分钟前
C++进阶-多态2
开发语言·c++
会唱歌的小黄李2 小时前
【算法】贪心算法:最大数C++
c++·算法·贪心算法
NuyoahC2 小时前
笔试——Day8
c++·算法·笔试
NuyoahC3 小时前
笔试——Day9
数据结构·c++·笔试
mit6.8243 小时前
[Meetily后端框架] 多模型-Pydantic AI 代理-统一抽象 | SQLite管理
c++·人工智能·后端·python
雨落倾城夏未凉3 小时前
从零构建INI配置工具的分步指南
c++·后端·qt
cpp_learners4 小时前
QML与C++相互调用函数并获得返回值
c++·qt·qml
l1t5 小时前
借助DeepSeek编写输出漂亮表格的chdb客户端
开发语言·数据库·c++·github
lzb_kkk6 小时前
【C++】多线程同步三剑客介绍
c语言·c++·条件变量·互斥锁·信号量