GCC从2.9.3版本开始支持GCC手册的属性语法,后来一些编译器为了兼容以GCC为基础编写的代码也纷纷支持了GCC的属性语法。GCC的属性语法如下:
_attribute__((attribute-list))请注意,GCC添加了一个扩展关键字__attribute__,这个关键字前后都有双下画线并且紧跟着两对括号,用如此烦琐的语法作为说明符的目的一方面是防止入侵C++标准,另一方面是避免和现有代码发生冲突。GCC的属性语法十分灵活,它能够用于结构体、类、联合类型、枚举类型、变量或者函数。比如前面介绍的设置对齐字节长度就是GCC的属性语法:
#include <iostream>
#define PRINT_ALIGN(c, v) \
std::cout << "alignof(" #c ") = " << alignof(c) \
<< ", alignof(" #v ") = " << alignof(v) << std::endl
__attribute__((aligned(16))) class X { int i; } a;
class __attribute__((aligned(16))) X1 { int i; } a1;
class X2 { int i; } __attribute__((aligned(16))) a2;
class X3 { int i; } a3 __attribute__((aligned(16)));
int main()
{
PRINT_ALIGN(X, a);
PRINT_ALIGN(X1, a1);
PRINT_ALIGN(X2, a2);
PRINT_ALIGN(X3, a3);
}以上代码的输出结果如下:
alignof(X) = 4, alignof(a) = 16
alignof(X1) = 16, alignof(a1) = 16
alignof(X2) = 16, alignof(a2) = 16
alignof(X3) = 4, alignof(a3) = 16可以看出,根据__attribute__((aligned(16)))所在语句位置的不同,对类和对象的作用是不同的。首先,放置在用户定义类型开始处的属性是声明类型的变量,而非类型本身,所以__attribute__((aligned(16))) class X { int i; } a;中对象a的对齐字节长度为16字节,而类X的对齐字节长度为默认的4字节。然后,放置在class关键字或者整个类声明之后的属性声明的是类型本身,一旦类型的对齐字节长度确定下来,其对象的对齐字节长度也就确定了下来,所以在class__attribute__((aligned(16))) X1 { int i; } a1;和class X2 { int i; } __attribute__((aligned (16))) a2;中类X1、X2以及对象a1、a2的对齐字节长度都是16字节。最后,放置在声明对象之后的属性声明的是对象本身,所以class X3 { int i; } a3 __attribute__((aligned (16)));中对象a3的对齐字节长度为16字节,而类X3的对齐字节长度为默认的4字节。实际上属性描述的范围非常广,除了刚刚提到的类和对象以外,对联合类型、函数等都可以进行声明,它还有属性覆盖和组合的规则,有兴趣的读者可以阅读GCC手册中关于属性的内容,这里就不再展开介绍了。
MSVC的属性语法和GCC相似,它引入了一个__declspec扩展关键字,不过这个关键字没有以双下画线结尾,后面紧跟的是单对括号:
__declspec(attribute-list)相对于GCC复杂的属性语法规则,MSVC的属性语法规则就简单多了:
__declspec(dllimport) class X {} varX;
class __declspec(dllimport) X {};MSDN的文档中介绍,将__declspec放置在声明对象语句的开头,则属性描述的是对象本身varX,而不是类型X,如果没有声明对象,则忽略属性。而将__declspec放置在class和类型名之间,描述的则是类型。
不管是GCC的属性语法还是MSVC的属性语法,它们都有一个共同的问题——属性声明过于烦琐。为了解决这个问题,以及用标准化的方法统一属性说明符的语法规则,C++11发布了标准的属性说明符语法。
C++11标准的属性表示方法是以双中括号开头、以反双中括号结尾,括号中是具体的属性:
[[attr]] [[attr1, attr2, attr3(args)]] [[namespace::attr(args)]]当需要多属性的时候可以在一个双中括号内用逗号分隔属性,也可以用多个双中括号来描述不同的属性。属性本身还支持命名空间,这样各个编译器或者扩展厂商就可以定义自己的属性命名空间以避免相互冲突。虽然使用双中括号包括属性的语法多多少少看起来有些奇怪,不过也正因如此,无论是编译器还是程序员都能很容易地识别出语句中的属性,方便了属性插入程序中的任何角落。除此之外,使用这样的语法也比GCC和MSVC的属性语法简洁很多。
有了这种“奇怪”语法作为先决条件,C++11标准的属性说明符可用在C++程序中的几乎所有位置,而且可用于几乎所有实体:类型、变量、函数、代码块等。只不过不同的属性本身有特定的声明对象,比如[[noreturn]]只能用于声明函数。在声明中,属性可出现在整个声明之前或直接跟在被声明对象之后,在这种情况下它们将被组合起来。普遍的规则是,属性说明符总是声明位于其之前的对象,而在整个声明之前的属性则会声明语句中所有声明的对象:
[[attr1]] class [[attr2]] X { int i; } a, b[[attr3]];在上面的例子中,attr1声明了对象a和b,attr2声明了类型X,X的属性也会影响到对象a和b,最后attr3只声明了对象b。前面虽然说过,属性可以用于几乎所有的位置,不过到C++20为止,绝大部分标准属性在声明中使用,目前只有fallthrough属性可以用于switch语句。
上文提到过,为了防止不同编译器厂商在扩展属性的时候发生冲突,标准属性的语法支持了命名空间,举个例子(这个例子请使用GCC9.1或者以上的版本编译):
[[gnu::always_inline]] [[gnu::hot]] [[gnu::const]] [[nodiscard]]
inline int f();或者
[[gnu::always_inline, gnu::hot, gnu::const, nodiscard]]
inline int f();在这个例子中,GCC命名空间虽然保护了其属性不会受到其他属性的影响,但是为了声明这些属性,程序员不得不重复指示命名空间,这造成了代码冗余。C++17标准对命名空间属性声明做了优化,它引入了using关键字打开属性命名空间,随后即可直接使用命名空间的属性从而减少代码冗余,其语法如下:
[[ using attribute-namespace : attribute-list ]]其中attribute-namespace是命名空间的名称,attribute-list是命名空间内的属性,它们直接使用冒号分隔,多属性之间使用逗号分隔。现在让我们进一步改写函数f的属性:
[[using gnu: always_inline, hot, const]][[nodiscard]]
inline int f();在这个版本中我们将属性分为了两块,一块是标准属性nodiscard,另一块是带有GCC命名空间的扩展属性always_inline、hot和const。可以看到使用新的语法不仅消除了命名空间的冗余问题,而且很好地对属性进行了分类,让属性的修改和阅读都变得更加方便了。C++17标准还规定,编译器应该忽略任何无法识别的属性。
虽然从语法上来说属性可以出现在程序的任意位置,但是从C++11到C++20标准一共只定义了9种标准属性。这是因为C++标准委员会对于标准属性的定义非常谨慎。一方面他们需要考虑一个语言特性应该定义为关键字还是定义为属性,另一方面还需要谨慎考虑该属性是否是平台通用。举例来说,在标准属性确定之前对齐字节长度一直作为一个扩展属性出现在各种编译器中,但是C++标准并不认可这种情况,于是对齐字节长度作为语言本身的一部分出现在了新的标准当中。
接下来就让我们看一看目前定义的9种标准属性。
noreturn是C++11标准引入的属性,该属性用于声明函数不会返回。注意,这里的所谓函数不返回和函数返回类型为void不同,返回类型为void说明函数还是会返回到调用者,只不过没有返回值;而用noreturn属性声明的函数编译器会认为在这个函数中执行流会被中断,函数不会返回到其调用者。举例来说:
void foo() {}
void bar() {}
int main()
{
foo();
bar();
}在以上代码中foo函数的返回类型为void,但是没有指定noreturn属性,所以函数还是返回。反汇编二进制程序可以得到汇编代码:
foo():
.LFB0:
push rbp
mov rbp, rsp
nop
pop rbp
ret
.LFE0:
bar():
.LFB1:
push rbp
mov rbp, rsp
nop
pop rbp
ret
.LFE1:
main:
.LFB2:
push rbp
mov rbp, rsp
call foo()
call bar()
mov eax, 0
pop rbp
ret
.LFE2:从汇编代码可以看到,在调用foo函数以后执行流会返回到main函数并且再调用bar函数,该流程没有中断。如果我们给foo函数添加noreturn属性,那么这个反汇编代码就会发生变化:
[[noreturn]] void foo() {}
void bar() {}
int main()
{
foo();
bar();
}反汇编代码如下:
foo():
.LFB0:
push rbp
mov rbp, rsp
nop
pop rbp
ret
.LFE0:
bar():
.LFB1:
push rbp
mov rbp, rsp
nop
pop rbp
ret
.LFE1:
main:
.LFB2:
push rbp
mov rbp, rsp
call foo()
.LFE2:观察上面的反汇编代码可以发现,在对foo添加noreturn属性以后,main函数中编译器不再为调用foo后面的过程生成代码了,它不仅忽略了对bar函数的调用,甚至干脆连main函数里的栈平衡以及返回代码都忽略了。因为编译器被告知,调用foo函数之后程序的执行流会被中断,所以生成的代码一定不会被执行,索性也不需要生成这些代码了。
carries_dependency是C++11标准引入的属性,该属性允许跨函数传递内存依赖项,它通常用于弱内存顺序架构平台上多线程程序的优化,避免编译器生成不必要的内存栅栏指令。所谓弱内存顺序架构,简单来说是指在多核心的情况下,一个核心看到共享内存中的值的变化与另一个核心写入它们的顺序不同。IBM的PowerPC就是这样的架构,而Intel和AMD的x86/64处理器系列则并不属于此类。
该属性可以出现在两种情况中。
1.作为函数或者lambda表达式参数的属性出现,这种情况表示调用者不用担心内存顺序,函数内部会处理好这个问题,编译器可以不生成内存栅栏指令。
2.作为函数的属性出现,这种情况表示函数的返回值已经处理好内存顺序,不需要编译器在函数返回前插入内存栅栏指令。
deprecated是在C++14标准中引入的属性,带有此属性的实体被声明为弃用,虽然在代码中依然可以使用它们,但是并不鼓励这么做。当代码中出现带有弃用属性的实体时,编译器通常会给出警告而不是错误。
[[deprecated]] void foo() {}
class [[deprecated]] X {};
int main()
{
X x;
foo();
}在上面的代码中,函数foo和类X带有deprecated属性,所以在main函数被编译的时候,调用foo以及实例化X的行为会被编译器警告。deprecated属性还能接受一个参数用来指示弃用的具体原因或者提示用户使用新的函数,比如:
[[deprecated("foo was deprecated, use bar instead")]] void foo() {}
void bar() {}
int main()
{
foo();
}以上代码用GCC编译时除了会给出常规的弃用警告,还会带上我们指定的字符串:
test.cpp: In function 'int main()':
test.cpp:9:6: warning: 'void foo()' is deprecated: foo was deprecated, use bar instead [-Wdeprecated-declarations]
foo();实际上,deprecated这个属性的使用范围非常广泛,它不仅能用在类、结构体和函数上,在普通变量、别名、联合体、枚举类型甚至命名空间上都可以使用。
fallthrough是C++17标准中引入的属性,该属性可以在switch语句的上下文中提示编译器直落行为是有意的,并不需要给出警告。比如:
void bar() {}
void foo(int a)
{
switch (a)
{
case 0:
break;
case 1:
bar();
[[fallthrough]];
case 2:
bar();
break;
default:
break;
}
}
int main()
{
foo(1);
}在上面这段代码中,foo函数的switch语句里case 1到case 2存在着一个直落的行为,在有的编译器中这种行为会给出警告提示,通过声明fallthrough属性可以消除该警告。不过,在我做实验的编译器中并没有因为直落行为而发出警告的情况,包括GCC、MSVC和CLang都是如此,所以这个属性对于这些主流编译器是没有效果的。最后请注意,fallthrough属性必须出现在case或者default标签之前,上面例子中的fallthrough属性出现在case 2之前,所以没有问题。违反这个规则,GCC和MSVC会给出警告,CLang则是直接报错。
nodiscard是在C++17标准中引入的属性,该属性声明函数的返回值不应该被舍弃,否则鼓励编译器给出警告提示。nodiscard属性也可以声明在类或者枚举类型上,但是它对类或者枚举类型本身并不起作用,只有当被声明为nodiscard属性的类或者枚举类型被当作函数返回值的时候才发挥作用:
class [[nodiscard]] X {};
[[nodiscard]] int foo() { return 1; }
X bar() { return X(); };
int main()
{
X x;
foo();
bar();
}在上面的代码中,函数foo带有nodiscard属性,所以在main函数中忽略foo函数的返回值会让编译器发出警告。类X也被声明为nodiscard,不过该属性对类本身没有任何影响,编译器不会给出警告。但是当类X作为bar函数的返回值时情况就不同了,这时候相当于声明了函数[[nodiscard]] X bar()。在main函数中,忽略bar函数返回值的行为也会引发一个警告。需要注意的是,nodiscard属性只适用于返回值类型的函数,对于返回引用的函数使用nodiscard属性是没有作用的:
class[[nodiscard]] X{};
X& bar(X &x) { return x; };
int main()
{
X x;
bar(x); // bar返回引用,nodiscard不起作用,不会引发警告
}nodiscard属性有几个常用的场合。
1.防止资源泄露,对于像malloc或者new这样的函数或者运算符,它们返回的内存指针是需要及时释放的,可以使用nodiscard属性提示调用者不要忽略返回值。
2.对于工厂函数而言,真正有意义的是回返的对象而不是工厂函数,将nodiscard属性应用在工厂函数中也可以提示调用者别忘了使用对象,否则程序什么也不会做。
3.对于返回值会影响程序运行流程的函数而言,nodiscard属性也是相当合适的,它告诉调用方其返回值应该用于控制后续的流程。
从C++20标准开始,nodiscard属性支持将一个字符串字面量作为属性的参数,该字符串会包含在警告中,可以用于解释返回结果不应被忽略的理由:
[[nodiscard("Memory leak!")]] char* foo() { return new char[100]; }除了给出不该忽略返回值的理由外,也可以在信息中添加使用返回值的建议。总之对于库作者来说,这是一个非常实用的特性。
另外在C++20标准中,nodiscard属性还能用于构造函数,它会在类型构建临时对象的时候让编译器发出警告,这一点非常有趣,请看下面的代码:
class X {
public:
[[nodiscard]] X() {}
X(int a) {}
};
int main()
{
X x;
X{};
X{ 42 };
}观察上面代码中类X的定义,它有两个构造函数,其中一个有nodicard属性[[nodiscard]] X() {},另一个则没有。表现在main函数中就是,因为X x;构造了非临时对象,所以不会有问题;而X{}构造了临时对象,于是编译器给出忽略X::X()返回值的警告;X{ 42 };不会产生编译警告,因为X(int a) {}没有nodicard属性。
maybe_unused是在C++17标准中引入的属性,该属性声明实体可能不会被应用以消除编译器警告。实际上,在我的实验环境中GCC、MSVC和CLang对于未使用的实例默认情况下都不会给出警告,除非有意设置了编译的相关参数,比如在GCC中添加-Wunused-parameter开关以打开对未使用参数的警告(CLang也使用-Wunused-parameter,MSVC则是将警告等级调整到W4或以上):
int foo(int a, int b)
{
return 5;
}
int main()
{
foo(1, 2);
}在上面的代码中,由于foo函数的形参a和b并未使用,因此在-Wunused- parameter开关的作用下GCC给出未使用警告。要消除这种情况下的警告,可以对形参a和b添加maybe_unused属性,比如:
int foo(int a [[maybe_unused]], int b [[maybe_unused]])
{
return 5;
}
int main()
{
foo(1, 2);
}请注意,maybe_unused属性除作为函数形参属性外,还可以用在很多地方,比如类、结构体、联合类型、枚举类型、函数、变量等,读者可以根据具体情况对代码添加属性。
likely和unlikely是C++20标准引入的属性,两个属性都是声明在标签或者语句上的。其中likely属性允许编译器对该属性所在的执行路径相对于其他执行路径进行优化;而unlikely属性恰恰相反。通常,likely和unlikely被声明在switch语句:
int f(int i) {
switch(i) {
case 1: return 1;
[[unlikely]] case 2: return 2;
}
return 3;
}no_unique_address是C++20标准引入的属性,该属性指示编译器该数据成员不需要唯一的地址,也就是说它不需要与其他非静态数据成员使用不同的地址。注意,该属性声明的对象必须是非静态数据成员且不为位域:
struct Empty {};
struct X {
int i;
Empty e;
};
// main函数
std::cout << "sizeof(X) = " << sizeof(X) << std::endl
<< "X::i address = " << &((X*)0)->i << std::endl
<< "X::e address = " << &((X*)0)->e;以上代码的输出结果如下:
sizeof(X) = 8
X::i address = 0
X::e address = 0x4由此可见,即使结构体Empty为空,但是在X中依然也占据了唯一地址。现在让我们给Empty e添加no_unique_address属性:
struct X {
int i;
[[no_unique_address ]]Empty e;
};有了这个属性,编译器得知e不需要独立地址,于是将数据成员i和e编译在了同样的地址:
sizeof(X) = 4
X::i address = 0
X::e address = 0值得注意的是,如果存在两个相同的类型且它们都具有no_unique_address属性,那么编译器不会重复地将其堆在同一地址,例如:
struct X {
int i;
[[no_unique_address]] Empty e, e1;
};
std::cout << "sizeof(X) = " << sizeof(X) << std::endl
<< "X::i address = " << &((X*)0)->i << std::endl
<< "X::e address = " << &((X*)0)->e << std::endl
<< "X::e1 address = " << &((X*)0)->e1 << std::endl;以上代码的输出结果如下:
sizeof(X) = 8
X::i address = 0
X::e address = 0
X::e1 address = 0x4e和e1虽然都是带有no_unique_address属性的Empty类型,但是无法使用同一地址。当然,如果e和e1不是同一类型,那么它们是可以共用同一地址的:
struct Empty {};
struct Empty1 {};
struct X {
int i;
[[no_unique_address]] Empty e;
[[no_unique_address]] Empty1 e1;
};输出结果如下:
sizeof(X) = 4
X::i address = 0
X::e address = 0
X::e1 address = 0最后解释一下no_unique_address这个属性的使用场景。读者一定写过无状态的类,这种类不需要有数据成员,唯一需要做的就是实现一些必要的函数,常见的是STL中一些算法函数所需的函数对象(仿函数)。而这种类作为数据成员加入其他类时,会占据独一无二的内存地址,实际上这是没有必要的。所以,在C++20的环境下,我们可以使用no_unique_address属性,让其不需要占用额外的内存地址空间。
从1998年C++的第一个标准(我们常说的C++98标准)发布之后,C++标准的制定进入了一个冰川期,在长达十几年的时间里C++标准只是在2003年做过一个简单的修改(C++的第二个标准,C++03),这样的沉寂直到2011年才被C++11标准打破。C++标准缓慢的发展速度显然无法跟上计算机世界里日新月异的开发需求,于是各大编译器厂商在C++03标准的基础上开始添加自己的扩展功能以满足语言特性和平台的需求,而属性说明符和属性就是这些扩展中重要的一环,这导致为了兼容性对于跨平台项目不得不通过预处理宏编写繁多的适配代码。
标准属性语法的出现在一定程度上给解决这类问题带来了希望,虽然它包含的属性远远达不到各大编译器厂商提供的属性功能,但是这给了这些厂商将属性加入标准扩展属性的机会,相信在不久的将来各大编译器厂商会将自己特有的属性添加到扩展属性当中。