天天看點

談談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 <vector>
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<int>]', 
    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 <iostream>
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. 空類大小:1
  2. 隻有一個int類大小:4
  3. 一個int和一個空類大小:8
  4. 一個int和一個[[no_unique_address]]空類大小:4
  5. 一個char和兩個空類大小:3
  6. 一個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規約背後的原理,從“問題重制”到“原理分析”再到“問題解決”,給你不一樣的解讀視角,是手冊必備的伴讀書目。

點選這裡

,立即下載下傳吧~