談談C++新標準帶來的屬性(Attribute)

談談C++新標準帶來的屬性(Attribute)

從C++11開始,標準引入了一個新概念“屬性(attribute)”,本文將簡單介紹一下目前在C++標準中已經新增的各個屬性以及常用屬性的具體應用。

一 屬性(Attribute)的前世今生

其實C++早在[pre03]甚至更早的時候就已經有了屬性的需求。彼時,當程式設計師需要和編譯器溝通,為某些實體新增一些額外的資訊的時候,為了避免“發明”一個新的關鍵詞乃至於引起一些語法更改的麻煩,同時又必須讓這些擴充套件內容不至於“汙染”標準的名稱空間,所以標準保留了一個特殊的使用者名稱空間——“雙下劃線關鍵詞”,以方便各大編譯器廠商能夠根據需要新增相應的語言擴充套件。根據這個標準,各大編譯器廠商都做出了自己的擴充套件實現,目前在業界廣泛使用的屬性空間有GNU和IBM的

__attribute__(())

,微軟的

__declspec()

,甚至C#還引入了獨特的單括號系統(single bracket system)來完成相應的工作。

隨著編譯器和語言標準的發展,尤其是C++多年來也開始逐漸借鑑其他語言中的獨特擴充套件,屬性相關的擴充套件也越來越龐大。但是Attribute的語法強烈依賴於各大編譯器的具體實現,彼此之間並不相容,甚至部分關鍵屬性導致了語言的分裂,最終都會讓使用者的無所適從。所以在C++11標準中,特意提出了C++語言內建的屬性概念。提案大約是在2007年前後形成,2008年9月15日的提案版本n2761被正式接納為C++11標準中的Attribute擴充套件部分(此處歷史略悠久,很可能有不準確的部分,歡迎各位指正)。

二 屬性的語法定義

正如我們在上一節討論的,屬性的關鍵要求就是避免對標準使用者名稱空間的汙染,同時對於未來可能引入的更多屬性,我們需要有一個方式可以避免新加的“屬性關鍵字”破壞當前已有的C++語法。所以新標準採用了“雙方括號”的語法方式引入了屬性說明,比如[[noreturn]]就是一個標準的C++屬性定義。而未來新屬性的新增都被控制在雙方括號範圍之內,不會進入標準的名稱空間。

按照C++語言標準,下列語言實體可以被屬性所定義/並從中獲益:

函式

變數

函式或者變數的名稱

型別

程式塊

Translation Unit (這個不知道用中文咋說)

程式控制宣告

根據C++的標準提案,屬性可以出現在程式中的幾乎所有的位置。當然屬性出現的位置和其修飾的物件是有一定關聯的,屬性僅在合適的位置才能產生效果。比如[[noreturn]必須出現在函式定義的位置才會產生效果,如果出現在某個變數的宣告處則無效。根據C++17的標準,未實現的或者無效的屬性均應該被編譯器忽略且不產生任何錯誤報告(在C++17標準之前的編譯器則參考編譯器的具體實現會有不同的行為)。

由於屬性可以出現在幾乎所有的位置,那麼它是如何關聯到具體的作用物件呢?下面我引用了語言標準提案中的一個例子幫助大家理解屬性是如何作用於語言的各個部分。

[[attr1]

class

C

[[ attr2 ]

] { }

[[ attr3 ]

c

[[ attr4 ]

],

d

[[ attr5 ]

];

attr1 作用於class C的實體定義c和d

attr2 作用於class C的定義

attr3 作用於型別C

attr4 作用於實體c

attr5 作用於實體d

以上只是一個基本的例子,具體到實際的程式設計中,還有有太多的可能,如有具體情況可以參考C++語言標準或者編譯器的相關文件。

三 主流C++編譯器對於屬性的支援情況

目前的主流編譯器對於C++11的支援已經相對很完善了,所以對於屬性的基本語法,大部分的編譯器都已經能夠接納。不過對於在不同標準中引入的各個具體屬性支援則參差不齊,對於相關屬效能否發揮應有的作用更需要具體問題具體分析。當然,在標準中(C++17)也明確了,對於不支援或者錯誤設定的屬性,編譯器也能夠忽略不會報錯。

下圖是目前主流編譯器對於n2761屬性提案的支援情況:

談談C++新標準帶來的屬性(Attribute)

談談C++新標準帶來的屬性(Attribute)

對於未知或不支援的屬性忽略報錯的主流編譯器支援情況:

談談C++新標準帶來的屬性(Attribute)

談談C++新標準帶來的屬性(Attribute)

四 目前C++標準中引入的標準屬性

C++11引入標準:

[[noreturn]]

[[carries_dependency]]

C++14引入標準:

[[deprecated]] 和 [[deprecated(“reason”)]]

C++17引入標準:

[[fallthrough]]

[[nodiscard]] 和 [[nodiscard(“reason”)]] (C++20)

[[maybe_unused]]

C++20引入標準:

[[likely]] 和 [[unlikely]]

[[no_unique_address]]

接下來我將嘗試對已經引入標準的屬性進行進一步的說明,同時對於已經明確得到編譯器支援的屬性,我也會嘗試用例子進行進一步的探索,希望拋磚引玉能夠幫大家更好的使用C++屬性這個“新的老朋友”。

1 [[noreturn]]

從字面意義上來看,noreturn是非常容易理解的,這個屬性的含義就是標明某個函式一定不會返回。

請看下面的例子程式:

// 正確,函式將永遠不會返回。

[[noreturn]]

void

func1

()

{

throw

“error”

; }

// 錯誤,如果用false進行呼叫,函式是會返回的,這時候會導致未定義行為。

[[noreturn]]

void

func2

bool

b)

{

if

(b)

throw

“error”

; }

int

main

()

{

try

{ func1() ; }

catch

char

const

*e)

{

std

::

cout

<<

“Got something: ”

<< e <<

“ \n”

; }

// 此處編譯會有警告資訊。

func2(

false

);

}

這個屬性最容易被誤解的地方是返回值為void的函式不代表著不會返回,它只是沒有返回值而已。所以在例子中的第一個函式func1才是正確的無返回函式的一個例子;而func2在引數值為false的情況下,它還是一個會返回的函式。所以,在編譯的時候,編譯器會針對func2報告如下錯誤:

noreturn。cpp:

In

function

‘void func2(bool)’

noreturn。cpp:

11

1

: warning:

‘noreturn’

function

does

return

11

| }

| ^

而實際執行的時候,func2到底會有什麼樣的表現屬於典型的“未定義行為”,程式可能崩潰也可能什麼都不發生,所以一定要避免這種情況在我們的程式碼中出現。(我在gcc11編譯器環境下嘗試過幾次,情況是什麼都不發生,但是無法保證這是確定的行為。)

另外,[[noreturn]]只要函式最終沒有返回都是可以的,比如用exit()呼叫直接將程式幹掉的程式也是可以被編譯器接受的行為(只是暫時沒想到為啥要這麼幹)。

2 [[carries_dependency]]

這個屬性的作用是允許我們將dependency跨越函式進行傳遞,用於避免在弱一致性模型平臺上產生不必要的記憶體柵欄導致程式碼效率降低。

一般來說,這個屬性是搭配

std::memory_order_consume

來使用的,支援這個屬性的編譯器可以根據屬性的指示生成更合適的程式碼幫助程式線上程之間傳遞資料。在典型的情況下,如果在

memory_order_consume

的情況下讀取一個值,編譯器為了保證合適的記憶體讀取順序,可能需要額外的記憶體柵欄協調程式行為順序,但是如果加上了[[carries_dependency]]的屬性,則編譯器可以保證函式體也被擴充套件包含了同樣的dependency,從而不再需要這個額外的記憶體柵欄。同樣的事情對於函式的返回值也是一致的。

參考如下例子程式碼:

std

::atomic<

int

*> p;

std

::atomic<

int

*> q;

void

func1

int

*val)

{

std

::

cout

<< *val <<

std

::

endl

; }

void

func2

int

* [[carries_dependency]] val)

{ q。store(val,

std

::memory_order_release);

std

::

cout

<< *q <<

std

::

endl

; }

void

thread_job

()

{

int

*ptr1 = (

int

*)p。load(

std

::memory_order_consume);

// 1

std

::

cout

<< *ptr1 <<

std

::

endl

// 2

func1(ptr1);

// 3

func2(ptr1);

// 4

}

程式在1的位置因為ptr1明確的使用了memory_order_consume的記憶體策略,所以對於ptr1的訪問一定會被編譯器排到這一行之後。

因為1的原因,所以這一行在編譯的時候勢必會排列在1後面。

func1並沒有帶任何屬性,而他訪問了ptr1,那麼編譯器為了保證記憶體訪問策略被尊重所以必須在func1呼叫之間構建一個記憶體柵欄。如果這個執行緒被大量的呼叫,這個額外的記憶體柵欄將導致效能損失。

在func2中,我們使用了[[carries_dependency]]屬性,那麼同樣的訪問ptr1,編譯器就知道程式已經處理好了相關的記憶體訪問限制。這個也正如我們再func2中對val訪問所做的限制是一樣的。那麼在func2之前,編譯器就無需再插入額外的記憶體柵欄,提高了效率。

3 [[deprecated]] 和 [[deprecated(“reason”)]]

這個屬性是在C++14的標準中被引入的。被這個屬性加持的名稱或者實體在編譯期間會輸出對應的警告,告訴使用者該名稱或者實體將在未來被拋棄。如果指定了具體的“reason”,則這個具體的原因也會被包含在警告資訊中。

參考如下例子程式:

[deprecated

]]

void

old_hello

{}

[deprecated(

“Use new_greeting() instead。 ”

]]

void

old_greeting

{}

int

main

{

old_hello();

old_greeting();

return

0

}

支援對應屬性的編譯器上,這個例子程式是可以透過編譯並正確執行的,但是編譯的過程中,編譯器會對屬性標誌的函式進行追蹤,並且打印出相應的資訊(如果定義了的話)。

在我的環境中,編譯程式給出了我如下的提示資訊:

deprecated。cpp: In function

‘int main()’

deprecated。cpp:

9

14

: warning:

‘void old_hello()’

is

deprecated [-Wdeprecated-declarations]

9

| old_hello();

| ~~~~~~~~~^~

deprecated。cpp:

2

6

: note: declared here

2

|

void

old_hello

{}

| ^~~~~~~~~

deprecated。cpp:

10

17

: warning:

‘void old_greeting()’

is

deprecated:

Use

new_greeting

) instead。 [-Wdeprecated-declarations]

10 |

old_greeting

| ~~~~~~~~~~~~^~

deprecated。cpp:

5

6

: note: declared here

5

|

void

old_greeting

{}

| ^~~~~~~~~~~~

[[deprecated]]屬性支援廣泛的名字和實體,除了函式,它還可以修飾:

類,結構體

靜態資料成員,非靜態資料成員

聯合體,列舉,列舉項

變數,別名,名稱空間

模板特化

4 [[fallthrough]]

這個屬性只可以用於switch語句中,通常在case處理完畢之後需要按照程式設定的邏輯退出switch塊,通常是新增break語句;或者在某些時候,程式又需要直接進入下一個case的判斷中。而現代編譯器通常會檢測程式邏輯,在前一個case處理完畢不新增break的情況下發出一個警告資訊,讓作者確定是否是他的真實意圖。但是,在case處理部分添加了[[fallthrough]]屬性之後,編譯器就知道這是程式邏輯有意為之,而不再給出提示資訊。

5 [[nodiscard]] 和 [[nodiscard(“reason”)]]

這兩個屬性和前面的[[deprecated]]類似,但是他們是在不同的C++標準中被引入的,[[nodiscard]]是在C++17標準中引入,而[[nodiscard(“reason”)]]是在C++20標準中引入。

這個屬性的含義是明確的告訴編譯器,用此屬性修飾的函式,其返回值(必須是按值返回)不應該被丟棄,如果在實際呼叫中捨棄了返回變數,則編譯器會發出警示資訊。如果此屬性修飾的是列舉或者類,則在對應函式返回該型別的時候也不應該丟棄結果。

參考下面的例子程式:

struct

[[

nodiscard

(“

IMPORTANT

THING

”)]]

important

{

};

important i = important();

important

get_important

()

{

return

i; }

important&

get_important_ref

()

{

return

i; }

important*

get_important_ptr

()

{

return

&i; }

int

a =

42

int

* [[nodiscard]] func() {

return

&a; }

int

main

()

{

get_important();

// 此處編譯器會給出警告。

get_important_ref();

// 此處因為不是按值返回nodiscard型別,不會有警告。

get_important_ptr();

// 同上原因,不會有警告。

func();

// 此處會有警告,雖然func不按值返回,但是屬性修飾的是函式。

return

0

}

在對上述例子進行編譯的時候,我們可以看到如下的警告資訊:

nodiscard。

cpp:

8

25

warning:

‘nodiscard’

attribute can only be applied to functions

or

to

class

or

enumeration

types

[-

Wattributes

8

| int* [[nodiscard]] func() {

return

&a; }

|

^

nodiscard。

cpp:

In function

‘int main()’

nodiscard。

cpp:

12

18

warning:

ignoring returned value of type

‘important’

declared with attribute

‘nodiscard’

‘IMPORTANT THING’

[-Wunused-result]

12

| get_important();

|

~~~~~~~~~~~~~^~

nodiscard。

cpp:

3

11

note:

in

call to

‘important get_important()’

, declared here

3

| important get_important() {

return

i; }

|

^~~~~~~~~~~~~

nodiscard。

cpp:

1

41

note:

‘important’

declared here

1

| struct [[nodiscard(“IMPORTANT THING”)]] important {};

|

^~~~~~~~~

可以看到,編譯器對於按值返回帶屬性的型別被丟棄發出了警告,但是對於非按值返回的呼叫沒有警告。不過如果屬性直接修飾的是函式體,那麼則不受此限制。

在新的C++標準中,除了添加了[[nodiscard]]屬性對應的處理邏輯,同時對於標準庫中的不應該丟棄返回值的操作也新增相應的屬性修飾,包含記憶體分配函式,容器空判斷函式,非同步執行函式等。請參考下面的例子:

#

include

std

::

vector

<

int

> vect;

int

main

()

{ vect。empty(); }

編譯這個例子的時候,我們收到了編譯器的如下警告,可見,新版本的標準庫也已經對[[nodiscard]]屬性提供了支援(不過這個具體要看編譯器和對應庫版本,需要參考編譯器和標準的提供方)。

nodiscard2。cpp: In function

‘int main()’

attibute/nodiscard2。cpp:

5

13

: warning:

ignoring

return

value

of

‘bool std::vector<_Tp, _Alloc>::empty() const [with _Tp = int; _Alloc = std::allocator]’

declared with attribute

‘nodiscard’

[-Wunused-result]

5

| { vect。empty(); }

| ~~~~~~~~~~^~

In file included

from

/usr/local/include/c++/

11。1

。0

/vector:

67

from

attibute/nodiscard2。cpp:

1

/usr/local/include/c++/

11。1

。0

/bits/stl_vector。h:

1007

7

: note: declared here

1007

| empty()

const

_GLIBCXX_NOEXCEPT

| ^~~~~

6 [[maybe_unused]]

通常情況下,對於聲明瞭但是從未使用過的變數會給出警告資訊。但是在宣告的時候添加了這個屬性,則編譯器確認是程式故意為之的邏輯,則不再發出警告。需要注意的是,這個宣告不會影響編譯器的最佳化邏輯,在編譯最佳化階段,無用的變數該幹掉還是會被幹掉的。

7 [[likely]] 和 [[unlikely]]

這一對屬性是在C++20的時候引入標準的,這兩個語句只允許用來修飾標號或者語句(非宣告語句),目的是告訴編譯器,在通常情況下,哪一個分支的執行路徑可能性最大,顯然,他倆也是不能同時修飾同一條語句。

截止我撰寫本文的今天,已經有不少編譯器對於這個屬性提供了支援,包括GCC9,Clang12,MSVC19。26等等。但是結合現代編譯器各種登峰造極的最佳化行為,我們在使用這個屬性的時候也需要有一個合理的期望,不能指望他發揮點石成金的效果。當然,這並不代表我不鼓勵你使用它們,明確的讓編譯器知道你的意圖總歸是一件好事情。

同樣的,我們先來看第一個例子:

談談C++新標準帶來的屬性(Attribute)

我們看到case 1是我們明確用屬性標明的執行時更有可能走到的分支,那麼我們可以看到對應生成的彙編程式碼中,case 1的流程是:首先給eax暫存器賦值5,然後比對輸入值1,如果輸入值為1,則直接返回,eax暫存器包含返回值。但如果這時候輸入值不為1,則需要一次跳轉到。L7去進行下面的邏輯。顯然,在case1的情況下,程式碼是不需要任何跳轉,直接執行的。

我們再看第二個例子:

談談C++新標準帶來的屬性(Attribute)

這次我們將優先順序順序調轉,用屬性標明case 2的是執行時更有可能走到的分支,那麼對應的彙編程式碼中,我們看看case 1的邏輯:首先進來就和1比對,如果相等,跳轉到。L3執行返回5的操作;如果不相等,那麼直接和2比對,同時edx和eax暫存器分別賦值7和1,根據比對的結果確定是否將edx的值賦值到eax(cmove語句),然後返回。似乎上來還是優先比對了1的情況,但是仔細研究我們就會發現,在case 2的邏輯通路上是不存在跳轉指令的,意味著case 2的流程也是需要跳轉可以直接執行下去的,沒有跳轉處理器也就不需要清空流水線(此處簡化理論,不涉及到處理器內部分支預測邏輯),case 2相對於case 1還是更加快速的流程,[[likely]]屬性發揮了它應有的作用。

當然,程式的最佳化涉及到的領域實在太多了,在真實的場景中,[[likely]]和[[unlikely]]屬效能否如我們所願發揮作用是需要具體問題具體分析的。不過正確的使用屬性即便沒有正向收益,也不會有負收益,並且我相信在大部分的場景下這是有好處的,並且在未來編譯器更加最佳化之後,明確意圖的程式碼總是能得到更多最佳化。

8 [[no_unique_address]]

這個屬性也是在C++20中引入的,旨在和編譯器溝通非位域非靜態資料成員不需要具有不同於其相同型別其他非靜態成員不同的地址。帶來的效果就是,如果該成員擁有空型別,則編譯器可以將它最佳化為不佔用空間的部分。

下面也還是用一個例子來演示一下這個屬性吧:

#

include

struct

Empty

{

};

// 空型別

struct

X

{

int

i; };

struct

Y1

{

int

i; Empty e; };

struct

Y2

{

int

i; [[no_unique_address]] Empty e; };

struct

Z1

{

char

c; Empty e1, e2; };

struct

Z2

{

char

c; [[no_unique_address]] Empty e1, e2; };

int

main

()

{

std

::

cout

<<

“空類大小:”

<<

sizeof

(Empty) <<

std

::

endl

std

::

cout

<<

“只有一個int類大小:”

<<

sizeof

(X1) <<

std

::

endl

std

::

cout

<<

“一個int和一個空類大小:”

<<

sizeof

(Y1) <<

std

::

endl

std

::

cout

<<

“一個int和一個[[no_unique_address]]空類大小:”

<<

sizeof

(Y2) <<

std

::

endl

std

::

cout

<<

“一個char和兩個空類大小:”

<<

sizeof

(Z1) <<

std

::

endl

std

::

cout

<<

“一個char和兩個[[no_unique_address]]空類大小:”

<<

sizeof

(Z2) <<

std

::

endl

}

編譯之後,我們執行程式可以得到如下結果(這個例子是在Linux x64 gcc11。1下的結果,不同的作業系統和編譯器可能結果不同):

空類大小:1

只有一個int類大小:4

一個int和一個空類大小:8

一個int和一個[[no_unique_address]]空類大小:4

一個char和兩個空類大小:3

一個char和兩個[[no_unique_address]]空類大小:2

說明:

對於空型別,在C++中也會至少分配一個地址,所以空型別的尺寸大於等於1

如果型別中有一個非空型別,那麼這個類的尺寸等於這個非空型別的大小。

如果型別中有一個非空型別和一個空型別,那麼尺寸一定大於非空型別尺寸,編譯器還需要分配額外的地址給非空型別。具體會需要分配多少大小取決於編譯器的具體實現。本例子中用的是gcc11,我們看到為了對齊,這個型別的尺寸為8,也就是說,空型別分配了一個和int對齊的4的尺寸。

如果空型別用[[no_unique_address]]屬性修飾,那麼這個空型別就可以和其他非同型別的非空型別共享空間,可以看到,這裡編譯器最佳化之後,空型別和int共享了同一塊記憶體空間,整個型別的尺寸就是4。

如果型別中有一個char型別和兩個空型別,那麼編譯器對於兩個空型別都分配了和非空型別char同樣大小的尺寸,整個型別佔用記憶體為3。

同樣的,如果兩個空型別都用[[no_unique_address]]進行修飾的話,我們發現,其中一個空型別可以和char共享空間,但是另外一個空型別無法再次共享同一個地址,又不能和同樣型別的空型別共享,所以整個結構的尺寸為2。

五 總結

以上本文介紹了屬性作為一個新的“舊概念”是如何引入到C++標準的和屬性的基本概念,同時還介紹了已經作為標準引入C++語言特性的部分屬性,包含C++11,14,17和20的部分內容。希望能夠拋磚引玉,和大家更好地理解C++的新功能並讓它落地並服務於我們的產品和專案,初次撰文,如果有錯漏缺失,還請各位讀者斧正。

電子書免費下載

《Java開發手冊(嵩山版)靈魂17問》

《Java開發手冊(嵩山版)靈魂17問》電子書來了!深度剖析Java規約背後的原理,從“問題重現”到“原理分析”再到“問題解決”,

給你不一樣的解讀視角,

是手冊必備的伴讀書目。