C++11:constexpr & 編譯期性質
- 常量表達式 constexpr
- 變量
- IiteralType
- 函數
- 自定義字面量
- 參數匹配與重載規則
- 靜態斷言
常量表達式 constexpr
const expression
常量表達式,是C++11引入的新特性,用于將表達式提前到編譯期進行計算,從而減少運行時的重復計算,提高運行時效率。該特性基于關鍵字constexpr
,可以用于修飾變量,函數等。
變量
其實在C++11之前,C++默認就會把 const int
進行編譯期計算,從而優化運行時效率,但是這個過程是隱式的。
例如以下代碼:
int c1 = 1 + 20 * 5;
const int a = 500;
int c2 = a + 1 * 2;
int c3 = c2 + 1;
此處對于c1
、a
、c2
都是在編譯期就可以確認值的,但是c3
需要運行時才會確認值。
-
c1
:由于表達式中所有值都是字面量,因此在編譯期就可以確定整個表達式的值,從而初始化c1
匯編代碼如下:
C7 45 04 65 00 00 00 mov dword ptr [c1],65h
此處
65h
就是1 + 20 * 5
的十六進制值,匯編階段直接計算出來并且賦值到變量c1
了。 -
a
:表達式直接賦值字面量500
,同理編譯期就可以確定值匯編代碼如下:
C7 45 24 F4 01 00 00 mov dword ptr [a],1F4h
與
c1
同理,1F4h
就是十六進制的500
。 -
c2
:表達式中a
是一個const int
變量,說明后續不會發生修改,又在編譯期就已經確認初始值,所以整個表達式a + 1 * 2
也是可以在編譯期確認值的。匯編代碼如下:
C7 45 44 F6 01 00 00 mov dword ptr [c2],1F6h
這里也是直接計算了
500 + 1 * 2
的十六進制,賦值到c2
。 -
c3
:此處表達式中,c2
不是一個const
變量,因此就算編譯期確定它的初始值,也無法保證后續不會被修改,因此需要運行時推斷。匯編代碼如下:
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
關鍵字,被其修飾的變量,一定會在編譯期得到結果,如果無法得到結果,那么就會報錯。
- 修飾變量語法:
constexpr type name = expression;
此處的expression
必須滿足:
- 編譯期可以確定值
- 確定后不會再修改
- 不為空,即變量必須初始化
例如:
constexpr int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // success
此處的c1
和c2
都是合法的常量表達式,在編譯期可以進行求值。
此外,const int
也是可以初始化 constexpr int
的:
const int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // success
因為剛剛提到,const int
(整形家族,枚舉)是會隱式在編譯期就盡可能求值的,那么它滿足 編譯期求值
+ 后續不修改
兩大特性,就可以初始化constexpr
。
但是以下代碼又是錯誤的:
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
的,例如:
const double d1 = 3.14;
constexpr double d2 = d1 * 3; // error
此處就算d1
可以在編譯期確定值,并且const
特性保證后續不會修改。但是C++標準并沒有對此明確規定,d1
也有可能會在運行時得到值,這取決于編譯器,所以const double
無法初始化constexpr double
。
最后,就算是const int
,如果被volatile
修飾,也不能用于初始化constexpr
,例如:
volatile const int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // error
因為volatile const
表示,這個變量就算是const
,也是有可能被修改的,那么就不滿足constexpr
的第二個要求。
前面這一大段,主要闡述了constexpr
初始化的幾個場景,總結一下:
const int
(整型家族,枚舉):在編譯期可以確定值,并且不被volatile
修飾const 其它
:不論如何都不能拿來初始化constexpr
constexpr 任意
:都可以拿來初始化其他的constexpr
表達式,也是最佳實踐
IiteralType
并不是所有的變量都可以被constexpr
修飾,并在編譯期求值的,比如:
constexpr std::string str = "hello";
這行代碼是會報錯的,因為std::string
需要動態內存管理,無法編譯期求值。
只有一個類型為 IiteralType
,才能被constexpr
修飾。
此處的IiteralType
稱為字面量類型,包含五種情況:
void
類型(C++14后支持,C++11不允許返回void
)- 標量類型
- 引用類型
- 字面類型數組
- 類類型,且滿足:
- 有一個平凡的
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
,也叫做類聯合體類
。例如:
union my_union {int x;int y; };class my_class {union{int a;int b;} _u; };
代碼中的聯合體
my_union
和類my_class
都算類聯合體類
。對于my_class
,他內部有一個成員_u
,是一個匿名聯合體,因為union
關鍵字后面沒有聯合體的名稱。對于類聯合體類,內部的聯合體的成員,叫做
變體成員
。即以上的x
、y
、a
、b
都叫做變體成員。
現在回看之前的IiteralType
要求,我做出一定修改:
void
類型 (C++14后)- 標量類型
- 引用類型
- C風格數組,且數組成員都是字面類型
- 類類型,且滿足:
- 有一個平凡的
constexpr
析構函數- 所有 不被
static
和volatile
修飾的成員 和 基類 都是字面類型- 滿足以下之一:
lambda
類型(C++17后)- 至少有一個
constexpr
修飾的構造函數,且該構造不是拷貝構造或移動構造- 它是聚合類型,且不是類聯合體類
- 它是聚合類型,且是類聯合體類:聯合體內不存在變體成員,或者至少存在一個不被
volatile
修飾的變體成員
接下來,我再對部分條目做出一定解釋。
回到 IiteralType
的本質,它就是一個constexpr
修飾類型的要求。既然要在編譯期完成計算,那么這個類型要在編譯期就可以確認,以上五條性質就是在要求IiteralType
是在編譯期可計算的。
首先是C風格數組
,它在定義時確定了數組長度,以及每個元素類型,那么編譯期就可以確定它的內存布局。
例如以下 constexpr
是合法的:
constexpr int arr[] = { 1, 2, 3 };
如果要用一個constexpr
修飾類類型:
- 首先要有一個平凡的析構函數,這個在之前講了,也就是析構函數是默認的,并且不被
viture
修飾,所有成員和基類也都有平凡的析構。這一步保證了這個類沒有復雜的內部實現,可以迅速銷毀,也反映其內存模型簡單。 - 所有的不被
static
和volatile
修飾的成員 和 基類 都是IteralType
這兩個條件,基本限制了類的模型不會非常復雜,幾乎不可能存在類似于動態內存管理這樣的操作,在編譯期可能完成求值。
- 滿足四個條件之一
- 是
lambda
,其實不是所有lambda
都可以,這個不深入講,屬于C++17的特性 - 至少有一個
constexpr
修飾的構造,并且這個構造不是拷貝構造或移動構造
這個是下一段內會講到的,constexpr
可以修飾函數,那么這個函數就可以在編譯期完成調用得到結果。被constexpr
修飾的函數,內部會有很多限制,無法完成太過于復雜的操作。因此當constexpr
修飾構造,說明構造不會太復雜,可以在編譯期完成構造函數的調用,那么也就可以在編譯期初始化出對象。
但是這個構造不能是拷貝構造或者移動構造,這其實是一個先有雞還是先有蛋的問題。想一想,如果某個類只有拷貝構造或者移動構造被constexpr
修飾。現在在編譯期一個該類型的對象C
要完成求值,C
的拷貝/移動構造必須傳入另外一個同類型的對象B
,但是B
從哪里來呢?要么是從一個普通的構造函數,但是剛才說了,普通的構造不被constexpr
修飾,無法在編譯期求到值。另外一種就是也通過拷貝或者移動而來,那么B
又要再去依賴另外一個同類對象A
,以此類推,編譯期根本不可能完成構造對象的任務。因此這個構造不能是拷貝構造或移動構造。
- 如果這個類型是一個聚合類型,且不是類聯合體類
那么這個類本身就非常簡單,滿足聚合類型就說明內存模型不會太復雜了,在編譯期可以完成求值。
- 如果這個類型是一個聚合類型,且是類聯合體類,聯合體內不存在變體成員,或者至少存在一個不被
volatile
修飾的變體成員
也就是說,它是一個union
或者類內包含匿名union
,此時要考慮變體成員的問題。
第一種是不存在變體成員
例如:
union my_union
{
};class my_class
{union{} _u;
};
my_union
和my_class::_u
都不含任何變體成員,那么編譯期對于這兩個內容,完全就不用考慮內存如何分配的問題,因此可以在編譯期完成計算。
第二種是至少存在一個不被volatile
修飾的變體成員
例如:
union my_union
{int x;volatile int y;
};
此處,就算y
被volatile
修飾,有可能會在后續發生改變。但是x
是沒有被修飾的變體成員,這個my_union
依然可以被constexpr
修飾。
但是以下情況就不行了:
union my_union
{volatile int x;volatile int y;
};
這個類聯合體類內,所有變體成員都被volatile
修飾,那么編譯期就算可以確認其值,但是無法保證其后續不會發生改變,不符合constexpr
的要求。
沒想到一個小小的constexpr
能牽扯到這么多亂七八糟的知識點,也算是在C++龐大的知識圖譜中拓展了一塊未踏足版圖。
函數
constexpr
也可以修飾函數,讓一個函數在編譯期進行計算得到結果。在C++11,這個函數只能包含一個return
語句,在C++14及C++17拓展后,允許支持復雜的邏輯以及遞歸。不過遞歸是有層數限制的,不同編譯器取值不同,在256 ~ 2048
之間不等。
- 語法:
constexpr ret_type name(...)
{// 函數體
}
只需要在函數返回值左側加上一個constexpr
關鍵字,這個函數就變成了constexpr
函數,可以在編譯期進行求值。
例如:
constexpr int fibonacci(int n)
{if (n <= 0)return 0;else if (n == 1)return 1;elsereturn fibonacci(n - 1) + fibonacci(n - 2);
}int main()
{constexpr int constexpr_ret = fibonacci(5);return 0;
}
這個函數用于在編譯期求出一個斐波那契的第n
個元素。函數內部有遞歸,分支語句,當使用一個constexpr
變量接受返回值,那么整個函數就會在編譯期進行運算,并得到結果。
只有滿足以下要求,一個函數才會有機會成為constexpr
:
- 返回值和參數都是
IiteralType
(字面量類型) - 函數內不能調用其他非
constexpr
函數 - 不拋異常
其中IiteralType
剛才講過了,在C++11之前,constexpr
是不允許返回void
類型,也就是必須有返回值的。
但是在剛剛的IiteralType
的要求中,第一條就是C++14新增的,void
也算IiteralType
了,因此C++14后constexpr
函數可以沒有返回值。
但是,一個constexpr
修飾的函數,不一定在編譯期得到返回值。
還記得之前提到,constexpr
修飾一個變量,要求初始化這個變量,并且在編譯期就可以拿到值。
int a = 2;
constexpr int b = a + 1;
以上代碼就是錯誤的,因為b
依賴了一個運行時求值的a
。
對于函數也是同理的,函數的參數就算是IteralType
,也不能保證函數參數就在編譯期可以求出值,這些參數的實參也必須是constexpr
。
例如:
constexpr int add(int x, int y)
{return x + y;
}
以下是正確的調用:
constexpr int a = 1;
constexpr int ret1 = add(a, 10);int b = 20;
int ret2 = add(b, b + 10);
在ret1
的調用中,add
的參數a
和10
都是編譯期確定的,調用成功。在ret2
的調用中,盡管b
是運行時確定的,但是返回值ret2
也沒有被constexpr
修飾,不要求其在編譯期求值,那么此時就當做一個普通函數在運行時調用。
以下是錯誤的調用:
int a = 1;
constexpr int ret = add(a, 10);
此處a
是一個運行時求值的變量,但是ret
要求編譯期求值。盡管add
確實是被constexpr
修飾的函數,函數參數也滿足IteralType
,但是實參a
編譯期拿不到值,最后代碼就會報錯。
自定義字面量
C++11 引入了用戶自定義字面量,允許程序員為自定義類型或特殊語義賦予類似內置字面量的直觀寫法。通過自定義字面量后綴,可以讓代碼更具可讀性和表達力。
自定義字面量的本質是重載 operator""
,即字面量運算符。其語法如下:
返回類型 operator "" _后綴(參數列表)
{// 實現
}
_后綴
必須以下劃線開頭,且不能包含雙下劃線(__
),也不能以下劃線+大寫字母開頭,如_A
,這些都被保留給標準庫使用。- 參數類型由字面量的類型決定,常見有:
unsigned long long
:用于整數字面量long double
:用于浮點字面量const char*
:用于字符串字面量const char*, std::size_t
:字符串字面量 + 長度
例如:
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_h
、30_m
、15_s
直觀表達了時間單位,避免了數字和單位混淆。
參數匹配與重載規則
自定義字面量的參數類型由字面量本身決定,常見匹配規則如下:
參數列表 | 適用場景 | 示例 |
---|---|---|
(unsigned long long) | 整數字面量 | 123_后綴 |
(long double) | 浮點字面量 | 3.14_后綴 |
(const char*) | 字符串字面量 | "abc"_后綴 |
(const char*, std::size_t) | 字符串字面量 | "abc"_后綴 |
(char) | 字符字面量 | 'a'_后綴 |
首先,對于算數類型unsigned long long
和double
,直接數值_后綴
即可,對于字符串則需要加雙引號"字符串"_后綴
,字符使用單引號'字符'_后綴
,這是調用字面量重載的語法。
對于字符串,有兩種函數參數形式,兩者調用的方法是相同的,只是兩個參數的版本,可以operator ""
函數體內部拿到字符串的長度,更加方便。
例如:
const char* operator""_x(const char* str)
{ }const char* operator""_x(const char* str, size_t len)
{ }
兩個_x
的后綴重載都是針對字符串類型,但是如果兩個都實現了,優先調用帶len
的版本。
此外,后綴重載允許拼接字符串,例如:
"123""hello"_x;
這種調用方式是合法的,最后"123hello"
作為參數。
如果使用整形,比如123_x
,它的行為也比較特殊:
- 如果有
unsigned long long
,優先匹配 - 如果沒有前者,整形會轉化為字符串匹配
const char*
- 如果前兩者都沒有,不會匹配
const char*, size_t
和long double
,字面量報錯
此外,像這種字面量的計算一般不會很復雜,后綴重載常常被聲明為constexpr
函數。
靜態斷言
C++11 引入了 static_assert
,用于在編譯期進行條件檢查。與傳統的C風格 assert
不同,static_assert
能在編譯階段捕獲錯誤,提升類型安全和模板編程的健壯性。
在 C++11 之前,常用的靜態斷言方式是利用非法類型或非法表達式強制編譯器報錯。
例如:
#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
關鍵字,語法如下:
static_assert(常量表達式, "錯誤信息");
- 第一個參數必須是編譯期可確定的常量表達式。
- 第二個參數是自定義的錯誤信息,編譯失敗時會輸出。
示例:
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));
}
如果 a
和 b
類型不同,編譯器會在編譯階段報錯,并輸出自定義信息"params must have same width."
。
相比于C風格的assert
,static_assert
可以攜帶錯誤信息,并且可以在編譯期就完成判斷。