C++11-語言可用性的強化

一、本節內容

本節內容包括:

  • 語言可用性的強化
    • nullptr 與 constexpr
    • 類型推導
      • auto
      • decltype
      • 尾返回類型、auto 與 decltype 配合
    • 區間迭代
      • 基於範圍的 for 循環
    • 初始化列表
      • std::initializer_list
      • 統一初始化語法
    • 模板增強
      • 外部模板
      • 尖括號 >
      • 類型別名模板
      • 變長參數模板
    • 面向對象增強
      • 委託構造
      • 繼承構造
      • 顯式虛函數重載
        • override
        • final
      • 顯式禁用默認函數
    • 強類型枚舉
    • 總結

二、nullptr 與 constexpr

nullptr

nullptr 出現的目的是爲了替代 NULL。在某種意義上來說,傳統 C++ 會把 NULL0 視爲同一種東西,這取決於編譯器如何定義 NULL,有些編譯器會將 NULL 定義爲 ((void*)0),有些則會直接將其定義爲 0

C++ 不允許直接將 void * 隱式轉換到其他類型,但如果 NULL 被定義爲 ((void*)0),那麼當編譯

char *ch = NULL;

時,NULL 只好被定義爲 0。而這依然會產生問題,將導致了 C++ 中重載特性會發生混亂,考慮:

void foo(char *);
void foo(int);

對於這兩個函數來說,如果 NULL 又被定義爲了 0 那麼 foo(NULL); 這個語句將會去調用 foo(int),從而導致代碼違反直觀。

爲了解決這個問題,C++11 引入了 nullptr 關鍵字,專門用來區分空指針、0。nullptr 的類型爲 nullptr_t,能夠隱式的轉換爲任何指針或成員指針的類型,也能和他們進行相等或者不等的比較。

你可以嘗試使用 g++ 兩個編譯器同時編譯下面的代碼:

#include <iostream>
void foo(char *);
void foo(int);
int main() {

    if(NULL == (void *)0) std::cout << "NULL == 0" << std::endl;
    else std::cout << "NULL != 0" << std::endl;

    foo(0);
    // foo(NULL); // 編譯無法通過
    foo(nullptr);

    return 0;
}
void foo(char *ch) {
    std::cout << "call foo(char*)" << std::endl;
}
void foo(int i) {
    std::cout << "call foo(int)" << std::endl;
}

將輸出:

NULL == 0
call foo(int)
call foo(char*)

所以,當需要使用 NULL 時候,請養成直接使用 nullptr的習慣。

constexpr

C++ 本身已經具備了常數表達式的概念,比如 1+2, 3*4 這種表達式總是會產生相同的下結果並且沒有任何副作用。如果編譯器能夠在編譯時就把這些表達式直接優化並植入到程序運行時,將能增加程序的性能。一個非常顯著的例子就是在數組的定義階段:

#define LEN 10

int len_foo() {
    return 5;
}

int main() {
    char arr_1[10];
    char arr_2[LEN];
    int len = 5;
    char arr_3[len+5];          // 非法
    const int len_2 = 10;
    char arr_4[len_2];        // 合法
    char arr_5[len_foo()+5];  // 非法

    return 0;
}

在 C++11 之前,可以在常量表達式中使用的變量必須被聲明爲 const,在上面代碼中,len_2 被定義成了常量,因此 len+5 是一個常量表達式,所以能夠合法的分配一個數組;

而對於 arr_5 來說,C++98 之前的編譯器無法得知 len_foo() 在運行期實際上是返回一個常數,這也就導致了非法的產生。

C++11 提供了 constexpr 讓用戶顯式的聲明函數或對象構造函數在編譯器會成爲常數,這個關鍵字明確的告訴編譯器應該去驗證 len_foo 在編譯器就應該是一個常數。

此外,constexpr 的函數可以使用遞歸:

constexpr int fibonacci(const int n) {
    return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
}

從 C++14 開始,constexptr 函數可以在內部使用局部變量、循環和分支等簡單語句,例如下面的代碼在 C++11 的標準下是不能夠通過編譯的:

constexpr int fibonacci(const int n) {
    if(n == 1) return 1;
    if(n == 2) return 1;
    return fibonacci(n-1)+fibonacci(n-2);
}

三、類型推導

在傳統 C 和 C++中,參數的類型都必須明確定義,這其實對我們快速進行編碼沒有任何幫助,尤其是當我們面對一大堆複雜的模板類型時,必須明確的指出變量的類型才能進行後續的編碼,這不僅拖慢我們的開發效率,也讓代碼變得又臭又長。

C++11 引入了 auto 和 decltype 這兩個關鍵字實現了類型推導,讓編譯器來操心變量的類型。這使得 C++ 也具有了和其他現代編程語言一樣,某種意義上提供了無需操心變量類型的使用習慣。

auto

auto 在很早以前就已經進入了 C++,但是他始終作爲一個存儲類型的指示符存在,與 register 並存。在傳統 C++ 中,如果一個變量沒有聲明爲 register 變量,將自動被視爲一個 auto 變量。而隨着 register 被棄用,對 auto 的語義變更也就非常自然了。

使用 auto 進行類型推導的一個最爲常見而且顯著的例子就是迭代器。在以前我們需要這樣來書寫一個迭代器:

for(vector<int>::const_iterator itr = vec.cbegin(); itr != vec.cend(); ++itr)

而有了 auto 之後可以:

// 由於 cbegin() 將返回 vector<int>::const_iterator 
// 所以 itr 也應該是 vector<int>::const_iterator 類型
for(auto itr = vec.cbegin(); itr != vec.cend(); ++itr);

一些其他的常見用法:

auto i = 5;             // i 被推導爲 int
auto arr = new auto(10) // arr 被推導爲 int *

注意auto 不能用於函數傳參,因此下面的做法是無法通過編譯的(考慮重載的問題,我們應該使用模板):

int add(auto x, auto y);

此外,auto 還不能用於推導數組類型:

#include <iostream>

int main() {
 auto i = 5;

 int arr[10] = {0};
 auto auto_arr = arr;
 auto auto_arr2[10] = arr;

 return 0;
}

decltype

decltype 關鍵字是爲了解決 auto 關鍵字只能對變量進行類型推導的缺陷而出現的。它的用法和 sizeof 很相似:

decltype(表達式)

有時候,我們可能需要計算某個表達式的類型,例如:

auto x = 1;
auto y = 2;
decltype(x+y) z;

尾返回類型、auto 與 decltype 配合

你可能會思考,auto 能不能用於推導函數的返回類型。考慮這樣一個例子加法函數的例子,在傳統 C++ 中我們必須這麼寫:

template<typename R, typename T, typename U>
R add(T x, U y) {
    return x+y
}

typename 和 class 在模板中沒有區別,在 typename 這個關鍵字出現之前,都是使用 class 來定義模板參數的

這樣的代碼其實變得很醜陋,因爲程序員在使用這個模板函數的時候,必須明確指出返回類型。但事實上我們並不知道 add() 這個函數會做什麼樣的操作,獲得一個什麼樣的返回類型。

在 C++11 中這個問題得到解決。雖然你可能馬上回反應出來使用 decltype 推導 x+y 的類型,寫出這樣的代碼:

decltype(x+y) add(T x, U y);

但事實上這樣的寫法並不能通過編譯。這是因爲在編譯器讀到 decltype(x+y) 時,x 和 y 尚未被定義。爲了解決這個問題,C++11 還引入了一個叫做尾返回類型(trailing return type),利用 auto 關鍵字將返回類型後置:

template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y) {
    return x+y;
}

令人欣慰的是從 C++14 開始是可以直接讓普通函數具備返回值推導,因此下面的寫法變得合法:

template<typename T, typename U>
auto add(T x, U y) {
    return x+y;
}

四、區間迭代

基於範圍的 for 循環

終於,C++11 引入了基於範圍的迭代寫法,我們擁有了能夠寫出像 Python 一樣簡潔的循環語句:

int array[] = {1,2,3,4,5};
for(auto &x : array) {
    std::cout << x << std::endl;
}

最常用的 std::vector 遍歷將從原來的樣子:

std::vector<int> arr(5, 100);
for(std::vector<int>::iterator i = arr.begin(); i != arr.end(); ++i) {
    std::cout << *i << std::endl;
}

變得非常的簡單:

// & 啓用了引用, 如果沒有則對 arr 中的元素只能讀取不能修改
for(auto &i : arr) {    
    std::cout << i << std::endl;
}

五、初始化列表

初始化是一個非常重要的語言特性,最常見的就是對對象進行初始化。在傳統 C++ 中,不同的對象有着不同的初始化方法,例如普通數組、POD (plain old data,沒有構造、析構和虛函數的類或結構體)類型都可以使用 {} 進行初始化,也就是我們所說的初始化列表。而對於類對象的初始化,要麼需要通過拷貝構造、要麼就需要使用 () 進行。這些不同方法都針對各自對象,不能通用。

int arr[3] = {1,2,3};   // 列表初始化

class Foo {
private:
    int value;
public:
    Foo(int) {}
};

Foo foo(1);             // 普通構造初始化

爲了解決這個問題,C++11 首先把初始化列表的概念綁定到了類型上,並將其稱之爲 std::initializer_list,允許構造函數或其他函數像參數一樣使用初始化列表,這就爲類對象的初始化與普通數組和 POD 的初始化方法提供了統一的橋樑,例如:

#include <initializer_list>

class Magic {
public:
    Magic(std::initializer_list<int> list) {}
};

Magic magic = {1,2,3,4,5};
std::vector<int> v = {1, 2, 3, 4};

這種構造函數被叫做初始化列表構造函數,具有這種構造函數的類型將在初始化時被特殊關照。

初始化列表除了用在對象構造上,還能將其作爲普通函數的形參,例如:

void func(std::initializer_list<int> list) {
    return;
}

func({1,2,3});

其次,C++11 提供了統一的語法來初始化任意的對象,例如:

struct A {
    int a;
    float b;
};
struct B {

    B(int _a, float _b): a(_a), b(_b) {}
private:
    int a;
    float b;
};

A a {1, 1.1};    // 統一的初始化語法
B b {2, 2.2};

六、模板增強

外部模板

傳統 C++ 中,模板只有在使用時纔會被編譯器實例化。換句話說,只要在每個編譯單元(文件)中編譯的代碼中遇到了被完整定義的模板,都會實例化。這就產生了重複實例化而導致的編譯時間的增加。並且,我們沒有辦法通知編譯器不要出發模板實例化。

C++11 引入了外部模板,擴充了原來的強制編譯器在特定位置實例化模板的語法,使得能夠顯式的告訴編譯器何時進行模板的實例化:

template class std::vector<bool>;            // 強行實例化
extern template class std::vector<double>;  // 不在該編譯文件中實例化模板

尖括號 ">"

在傳統 C++ 的編譯器中,>>一律被當做右移運算符來進行處理。但實際上我們很容易就寫出了嵌套模板的代碼:

std::vector<std::vector<int>> wow;

這在傳統C++編譯器下是不能夠被編譯的,而 C++11 開始,連續的右尖括號將變得合法,並且能夠順利通過編譯。

類型別名模板

在瞭解類型別名模板之前,需要理解『模板』和『類型』之間的不同。仔細體會這句話:模板是用來產生類型的。在傳統 C++中,typedef 可以爲類型定義一個新的名稱,但是卻沒有辦法爲模板定義一個新的名稱。因爲,模板不是類型。例如:

template< typename T, typename U, int value>
class SuckType {
public:
    T a;
    U b;
    SuckType():a(value),b(value){}
};
template< typename U>
typedef SuckType<std::vector<int>, U, 1> NewType; // 不合法

C++11 使用 using 引入了下面這種形式的寫法,並且同時支持對傳統 typedef 相同的功效:

通常我們使用 typedef 定義別名的語法是:typedef 原名稱 新名稱;,但是對函數指針等別名的定義語法卻不相同,這通常給直接閱讀造成了一定程度的困難。

typedef int (*process)(void *);  // 定義了一個返回類型爲 int,參數爲 void* 的函數指針類型,名字叫做 process
using process = void(*)(void *); // 同上, 更加直觀

template <typename T>
using NewType = SuckType<int, T, 1>;    // 合法

默認模板參數

我們可能定義了一個加法函數:

template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y) {
    return x+y
}

但在使用時發現,要使用 add,就必須每次都指定其模板參數的類型。

在 C++11 中提供了一種便利,可以指定模板的默認參數:

template<typename T = int, typename U = int>
auto add(T x, U y) -> decltype(x+y) {
    return x+y;
}

變長參數模板

模板一直是 C++ 所獨有的黑魔法(一起念:Dark Magic)之一。在 C++11 之前,無論是類模板還是函數模板,都只能按其指定的樣子,接受一組固定數量的模板參數;而 C++11 加入了新的表示方法,允許任意個數、任意類別的模板參數,同時也不需要再定義時將參數的個數固定。

template<typename... Ts> class Magic;

模板類 Magic 的對象,能夠接受不受限制個數的 typename 作爲模板的形式參數,例如下面的定義:

class Magic<int, 
            std::vector<int>, 
            std::map<std::string, 
                     std::vector<int>>> darkMagic;

既然是任意形式,所以個數爲0的模板參數也是可以的:class Magic<> nothing;

如果不希望產生的模板參數個數爲0,可以手動的定義至少一個模板參數:

template<typename Require, typename... Args> class Magic;

變長參數模板也能被直接調整到到模板函數上。傳統 C 中的 printf 函數,雖然也能達成不定個數的形參的調用,但其並非類別安全。而 C++11 除了能定義類別安全的變長參數函數外,還可以使類似 printf 的函數能自然地處理非自帶類別的對象。除了在模板參數中能使用 ... 表示不定長模板參數外,函數參數也使用同樣的表示法代表不定長參數,這也就爲我們簡單編寫變長參數函數提供了便捷的手段,例如:

template<typename... Args> void printf(const std::string &str, Args... args);

那麼我們定義了變長的模板參數,如何對參數進行解包呢?

首先,我們可以使用 sizeof... 來計算參數的個數,:

template<typename... Args>
void magic(Args... args) {
    std::cout << sizeof...(args) << std::endl;
}

我們可以傳遞任意個參數給 magic 函數:

magic();        // 輸出0
magic(1);       // 輸出1
magic(1, "");   // 輸出2

其次,對參數進行解包,到目前爲止還沒有一種簡單的方法能夠處理參數包,但有兩種經典的處理手法:

1. 遞歸模板函數

遞歸是非常容易想到的一種手段,也是最經典的處理方法。這種方法不斷遞歸的向函數傳遞模板參數,進而達到遞歸遍歷所有模板參數的目的:

#include <iostream>
template<typename T>
void printf(T value) {
    std::cout << value << std::endl;
}
template<typename T, typename... Args>
void printf(T value, Args... args) {
    std::cout << value << std::endl;
    printf(args...);
}
int main() {
    printf(1, 2, "123", 1.1);
    return 0;
}

2. 初始化列表展開

這個方法需要之後介紹的知識,讀者可以簡單閱讀以下,將這個代碼段保存,在後面的內容瞭解過了之後再回過頭來閱讀此處方法會大有收穫。

遞歸模板函數是一種標準的做法,但缺點顯而易見的在於必須定義一個終止遞歸的函數。

這裏介紹一種使用初始化列表展開的黑魔法:

// 編譯這個代碼需要開啓 -std=c++14
template<typename T, typename... Args>
auto print(T value, Args... args) {
    std::cout << value << std::endl;
    return std::initializer_list<T>{([&] {
        std::cout << args << std::endl;
    }(), value)...};
}
int main() {
    print(1, 2.1, "123");
    return 0;
}

在這個代碼中,額外使用了 C++11 中提供的初始化列表以及 Lambda 表達式的特性(下一節中將提到),而 std::initializer_list 也是 C++11 新引入的容器(以後會介紹到)。

通過初始化列表,(lambda 表達式, value)... 將會被展開。由於逗號表達式的出現,首先會執行前面的 lambda 表達式,完成參數的輸出。唯一不美觀的地方在於如果不使用 return 編譯器會給出未使用的變量作爲警告。

事實上,有時候我們雖然使用了變參模板,卻不一定需要對參數做逐個遍歷,我們可以利用 std::bind 及完美轉發等特性實現對函數和參數的綁定,從而達到成功調用的目的。

關於這方面的使用技巧,可以通過項目課:100 行 C++ 代碼實現線程池 進行進一步鞏固學習。

七、面向對象增強

委託構造

C++11 引入了委託構造的概念,這使得構造函數可以在同一個類中一個構造函數調用另一個構造函數,從而達到簡化代碼的目的:

class Base {
public:
    int value1;
    int value2;
    Base() {
        value1 = 1;
    }
    Base(int value) : Base() {  // 委託 Base() 構造函數
        value2 = 2;
    }
};

int main() {
    Base b(2);
    std::cout << b.value1 << std::endl;
    std::cout << b.value2 << std::endl;
}

繼承構造

在傳統 C++ 中,構造函數如果需要繼承是需要將參數一一傳遞的,這將導致效率低下。C++11 利用關鍵字 using 引入了繼承構造函數的概念:

class Base {
public:
    int value1;
    int value2;
    Base() {
        value1 = 1;
    }
    Base(int value) : Base() {                          // 委託 Base() 構造函數
        value2 = 2;
    }
};
class Subclass : public Base {
public:
    using Base::Base;  // 繼承構造
};
int main() {
    Subclass s(3);
    std::cout << s.value1 << std::endl;
    std::cout << s.value2 << std::endl;
}

顯式虛函數重載

在傳統 C++中,經常容易發生意外重載虛函數的事情。例如:

struct Base {
    virtual void foo();
};
struct SubClass: Base {
    void foo();
};

SubClass::foo 可能並不是程序員嘗試重載虛函數,只是恰好加入了一個具有相同名字的函數。另一個可能的情形是,當基類的虛函數被刪除後,子類擁有舊的函數就不再重載該虛擬函數並搖身一變成爲了一個普通的類方法,這將造成災難性的後果。

C++11 引入了 override 和 final 這兩個關鍵字來防止上述情形的發生。

override

當重載虛函數時,引入 override 關鍵字將顯式的告知編譯器進行重載,編譯器將檢查基函數是否存在這樣的虛函數,否則將無法通過編譯:

struct Base {
    virtual void foo(int);
};
struct SubClass: Base {
    virtual void foo(int) override; // 合法
    virtual void foo(float) override; // 非法, 父類沒有此虛函數
};

final

final 則是爲了防止類被繼續繼承以及終止虛函數繼續重載引入的。

struct Base {
        virtual void foo() final;
};
struct SubClass1 final: Base {
};                  // 合法

struct SubClass2 : SubClass1 {
};                  // 非法, SubClass 已 final

struct SubClass3: Base {
        void foo(); // 非法, foo 已 final
};

顯式禁用默認函數

在傳統 C++ 中,如果程序員沒有提供,編譯器會默認爲對象生成默認構造函數、複製構造、賦值算符以及析構函數。另外,C++ 也爲所有類定義了諸如 new delete 這樣的運算符。當程序員有需要時,可以重載這部分函數。

這就引發了一些需求:無法精確控制默認函數的生成行爲。例如禁止類的拷貝時,必須將賦值構造函數與賦值算符聲明爲 private。嘗試使用這些未定義的函數將導致編譯或鏈接錯誤,則是一種非常不優雅的方式。

並且,編譯器產生的默認構造函數與用戶定義的構造函數無法同時存在。若用戶定義了任何構造函數,編譯器將不再生成默認構造函數,但有時候我們卻希望同時擁有這兩種構造函數,這就造成了尷尬。

C++11 提供了上述需求的解決方案,允許顯式的聲明採用或拒絕編譯器自帶的函數。例如:

class Magic {
public:
    Magic() = default;  // 顯式聲明使用編譯器生成的構造
    Magic& operator=(const Magic&) = delete; // 顯式聲明拒絕編譯器生成構造
    Magic(int magic_number);
}

八、強類型枚舉

在傳統 C++中,枚舉類型並非類型安全,枚舉類型會被視作證書,則會讓兩種完全不同的枚舉類型可以進行直接的比較(雖然編譯器給出了檢查,但並非所有),甚至枚舉類型的枚舉值名字不能相同,這不是我們希望看到的結果。

C++11 引入了枚舉類(enumaration class),並使用 enum class 的語法進行聲明:

enum class new_enum : unsigned int {
    value1,
    value2,
    value3 = 100,
    value4 = 100
};

這樣定義的枚舉實現了類型安全,首先他不能夠被隱式的轉換爲整數,同時也不能夠將其與整數數字進行比較,更不可能對不同的枚舉類型的枚舉值進行比較。但相同枚舉值之間如果指定的值相同,那麼可以進行比較:

if (new_enum::value3 == new_enum::value4) {
    // 會輸出
    std::cout << "new_enum::value3 == new_enum::value4" << std::endl;
}

在這個語法中,枚舉類型後面使用了冒號及類型關鍵字來指定枚舉中枚舉值的類型,這使得我們能夠爲枚舉賦值(未指定時將默認使用 int)。

而我們希望獲得枚舉值的值時,將必須顯式的進行類型轉換,不過我們可以通過重載 << 這個算符來進行輸出,可以收藏下面這個代碼段:

#include <iostream>
template<typename T>
std::ostream& operator<<(typename std::enable_if<std::is_enum<T>::value, std::ostream>::type& stream, const T& e)
{
    return stream << static_cast<typename std::underlying_type<T>::type>(e);
}

這時,下面的代碼將能夠被編譯:

std::cout << new_enum::value3 << std::endl

總結

本節介紹了 C++11/14 中對語言可用性的增強,其中筆者認爲最爲重要的幾個特性是幾乎所有人都需要了解並熟練使用的:

  1. auto 類型推導
  2. 範圍 for 迭代
  3. 初始化列表
  4. 變參模板
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章