萬字避坑指南!C++的缺陷與思考(下)

 c5da54cf882f01361bc8e62f58a088a8.gif

導讀 | 在萬字避坑指南!C++的缺陷與思考(上)一文中,微信後臺開發工程師胡博豪,分享了C++的發展歷史、右值引用與移動語義、類型說明符等內容,深受廣大開發者喜愛!此篇,我們邀請作者繼續總結其在C++開發過程中對一些奇怪、複雜的語法的理解和思考,分享C++開發的避坑指南。

2d75029d0d8b5ceac853dbece396ef27.jpeg

static

我在前面章節吐槽了const這個命名,也吐槽了“右值引用”這個命名。那麼static就是筆者下一個要重點吐槽的命名了。static這個詞本身沒有什麼問題,其主要的槽點就在於“一詞多用”,也就是說,這個詞在不同場景下表示的是完全不同的含義。(作者可能是出於節省關鍵詞的目的吧,明明是不同的含義,卻沒有用不同的關鍵詞)。

第一,在局部變量前的static,限定的是變量的生命週期。

第二,在全局變量/函數前的static,限定的是變量/函數的作用域。

第三,在成員變量前的static,限定的是成員變量的生命週期。

第四在成員函數前的static,限定的是成員函數的調用方(或隱藏參數)。

上面是static關鍵字的4種不同含義,接下來我會逐一解釋。

1)靜態局部變量

當用static修飾局部變量時,static表示其生命週期:

void f() {
  static int count = 0;
  count++;
}

上述例子中,count是一個局部變量,既然已經是“局部變量”了,那麼它的作用域很明顯,就是f函數內部,而這裏的static表示的是其生命週期。普通的全局變量在其所在函數(或代碼塊)結束時會被釋放,而用static修飾的則不會,我們將其稱爲“靜態局部變量”。靜態局部變量會在首次執行到定義語句時初始化,在主函數執行結束後釋放,在程序執行過程中遇到定義(和初始化)語句時會被忽略。

void f() {
   static int count = 0;
   count++;
   std::cout << count << std::endl;
}
int main(int argc, const char *argv[]) {
  f(); // 第一次執行時count被定義,並且初始化爲0,執行後count值爲1,並且不會釋放
  f(); // 第二次執行時由於count已經存在,因此初始化語句會無視,執行後count值爲2,並且不會釋放
  f(); // 同上,執行後count值爲3,不會釋放
} // 主函數執行結束後會釋放f中的count

例如上面例程的輸出結果會是:

1
2
3

2)內部全局變量/函數

當static修飾全局變量或函數時,用於限定其作用域爲“當前文件內”。同理,由於已經是“全局”變量了,生命週期一定是符合全局的,也就是“主函數執行前構造,主函數執行結束後釋放”。至於全局函數就不用說了,函數都是全局生命週期的。因此,這時候的static不會再對生命週期有影響,而是限定了其作用域。與之對應的是extern。用extern修飾的全局變量/函數作用於整個程序內,換句話說,就是可以跨文件。‍

// a1.cc
int g_val = 4; // 定義全局變量
// a2.cc
extern int g_val; // 聲明全局變量
void Demo() {
  std::cout << g_val << std::endl; // 使用了在另一個文件中定義的全局變量
}

而用static修飾的全局變量/函數則只能在當前文件中使用,不同文件間的static全局變量/函數可以同名,並且互相獨立。

// a1.cc
static int s_val1 = 1; // 定義內部全局變量
static int s_val2 = 2; // 定義內部全局變量
static void f1() {} // 定義內部函數
// a2.cc
static int s_val1 = 6; // 定義內部全局變量,與a1.cc中的互不影響
static int s_val2; // 這裏會視爲定義了新的內部全局變量,而不會視爲“聲明”
static void f1(); // 聲明瞭一個內部函數
void Demo() {
  std::cout << s_val1 << std::endl; // 輸出6,與a1.cc中的s_val1沒有關係
  std::cout << s_val2 << std::endl; // 輸出0,同樣不會訪問到a1.cc中的s_val2
  f1(); // ERR,這裏鏈接會報錯,因爲在a2.cc中沒有找到f1的定義,並不會鏈接到a1.cc中的f1
}

所以我們發現,在這種場景下,static並不表示“靜態”的含義,而是表示“內部”的含義,所以,爲什麼不再引入個類似於inner的關鍵字呢?這裏很容易讓程序員造成迷惑。

3)靜態成員變量

靜態成員變量指的是用static修飾的成員變量。普通的成員變量其生命週期是跟其所屬對象綁定的。構造對象時構造成員變量,析構對象時釋放成員變量。

struct Test {
  int a; // 普通成員變量
};


int main(int argc, const char *argv[]) {
  Test t; // 同時構造t.a
  auto t2 = new Test; // 同時構造t2->a
  delete t2; // t2所指對象析構,同時釋放t2->a
} // t析構,同時釋放t.a

而用static修飾後,其聲明週期變爲全局,也就是“主函數執行前構造,主函數執行結束後釋放”,並且不再跟隨對象,而是全局一份。

struct Test {
  static int a; // 靜態成員變量(基本等同於聲明全局變量)
};


int Test::a = 5; // 初始化靜態成員變量(主函數前執行,基本等同於初始化全局變量)
int main(int argc, const char *argv[]) {
  std::cout << Test::a << std::endl; // 直接訪問靜態成員變量
  Test t;
  std::cout << t.a << std::endl; // 通過任意對象實例訪問靜態成員變量
} // 主函數結束時釋放Test::a

所以靜態成員變量基本就相當於一個全局變量,而這時的類更像一個命名空間了。唯一的區別在於,通過類的實例(對象)也可以訪問到這個靜態成員變量,就像上面的t.a和Test::a完全等價。

4)靜態成員函數

static關鍵字修飾在成員函數前面,稱爲“靜態成員函數”。我們知道普通的成員函數要以對象爲主調方,對象本身其實是函數的一個隱藏參數(this指針):

struct Test {
  int a;
  void f(); // 非靜態成員函數
};


void Test::f() {
  std::cout << this->a << std::endl;
}


void Demo() {
  Test t;
  t.f(); // 用對象主調成員函數
}

上面其實等價於:

struct Test {
  int a;
};


void f(Test *this) {
  std::cout << this->a << std::endl;
}


void Demo() {
  Test t;
  f(&t); // 其實對象就是函數的隱藏參數
}

也就是說,obj.f(arg)本質上就是f(&obj, arg),並且這個參數強制叫做this。這個特性在Go語言中尤爲明顯,Go不支持封裝到類內的成員函數,也不會自動添加隱藏參數,這些行爲都是顯式的:

type Test struct {
  a int
}


func(t *Test) f() {
  fmt.Println(t.a) 
}


func Demo() {
  t := new(Test)
  t.f()
}

回到C++的靜態成員函數這裏來。用static修飾的成員函數表示“不需要對象作爲主調方”,也就是說沒有那個隱藏的this參數。

struct Test {
  int a;
  static void f(); // 靜態成員函數
};


void Test::f() {
  // 沒有this,沒有對象,只能做對象無關操作
  // 也可以操作靜態成員變量和其他靜態成員函數
}

可以看出,這時的靜態成員函數,其實就相當於一個普通函數而已。這時的類同樣相當於一個命名空間,而區別在於,如果這個函數傳入了同類型的參數時,可以訪問私有成員,例如:

class Test {
 public:
   static void f(const Test &t1, const Test &t2); // 靜態成員函數
 private:
   int a; // 私有成員
};


void Test::f(const Test &t1, const Test &t2) {
  // t1和t2是通過參數傳進來的,但因爲是Test類型,因此可以訪問其私有成員
  std::cout << t1.a + t2.a << std::endl;
}

或者我們可以把靜態成員函數理解爲一個友元函數,只不過從設計角度上來說,與這個類型的關聯度應該是更高的。但是從語法層面來解釋,基本相當於“寫在類裏的普通函數”。

5)小結

其實C++中static造成的迷惑,同樣也是因爲C中的缺陷被放大導致的。畢竟在C中不存在構造、析構和引用鏈的問題。說到這個引用鏈,其實C++中的靜態成員變量、靜態局部變量和全局變量還存在一個鏈路順序問題,可能會導致內存重複釋放、訪問野指針等情況的發生。這部分的內容詳見後面“平凡、標準佈局”的章節。總之,我們需要了解static關鍵字有多義性,瞭解其在不同場景下的不同含義,更有助於我們理解C++語言,防止踩坑。

08d6c6e9327c0bb9b487863cae862347.jpeg

平凡、標準佈局

前陣子我和一個同事對這樣一個問題進行了非常激烈的討論:

到底應不應該定義std::string類型的全局變量

這個問題乍一看好像沒什麼值得討論的地方,我相信很多程序員都在不經意間寫過類似的代碼,並且確實沒有發現什麼執行上的問題,所以可能從來沒有意識到,這件事還有可能出什麼問題。我們和我同事之所以激烈討論這個問題,一切的根源來源於谷歌的C++編程規範,其中有一條是:

Static or global variables of class type are forbidden: they cause hard-to-find bugs due to indeterminate order of construction and destruction.
Objects with static storage duration, including global variables, static variables, static class member variables, and function static variables, must be Plain Old Data (POD): only ints, chars, floats, or pointers, or arrays/structs of POD.

大致翻譯一下就是說:不允許非POD類型的全局變量、靜態全局變量、靜態成員變量和靜態局部變量,因爲可能會導致難以定位的bug。 而std::string是非POD類型的,自然,按照規範,也不允許std::string類型的全局變量。(公司編程規範中並沒有直接限制POD類型,而是限制了非平凡析構,它確實會比谷歌規範中用POD一刀砍會合理得多,但筆者仍然覺得其實限制仍然可以繼續再放開些。可以參考公司C++編程規範第3.5條)

但是如果我們真的寫了,貌似也從來沒有遇到過什麼問題,程序也不會出現任何bug或者異常,甚至下面的幾種寫法都是在日常開發中經常遇到的,但都不符合這谷歌的這條代碼規範。

全局字符串

const std::string ip = "127.0.0.1";
const uint16_t port = 80;


void Demo() {
  // 開啓某個網絡連接
  SocketSvr svr{ip, port};
  // 記錄日誌
  WriteLog("net linked: ip:port={%s:%hu}", ip.c_str(), port);
}

靜態映射表

std::string GetDesc(int code) {
  static const std::unordered_map<int, std::string> ma {
    {0, "SUCCESS"},
    {1, "DATA_NOT_FOUND"},
    {2, "STYLE_ILLEGEL"},
    {-1, "SYSTEM_ERR"}
  };
  if (auto res = ma.find(code); res != ma.end()) {
    return res->second;
  }
  return "UNKNOWN";
}

單例模式

class SingleObj {
 public:
  SingleObj &GetInstance();


  SingleObj(const SingleObj &) = delete;
  SingleObj &operator =(const SingleObj &) = delete;
 private:
   SingleObj();
   ~SingleObj();
};


SingleObj &SingleObj::GetInstance() {
  static SingleObj single_obj;
  return single_obj;
}

上面的幾個例子都存在“非POD類型全局或靜態變量”的情況。

1)全局、靜態的生命週期問題

既然谷歌規範中禁止這種情況,那一定意味着,這種寫法存在潛在風險,我們需要搞明白風險點在哪裏。首先明確變量生命週期的問題:

第一,全局變量和靜態成員變量在主函數執行前構造,在主函數執行結束後釋放;

第二,靜態局部變量在第一次執行到定義位置時構造,在主函數執行後釋放。

這件事如果在C語言中,並沒有什麼問題,設計也很合理。但是C++就是這樣悲催,很多C當中合理的問題在C++中會變得不合理,並且缺陷會被放大。

由於C當中的變量僅僅是數據,因此,它的“構造”和“釋放”都沒有什麼副作用。但在C++當中,“構造”是要調用構造函數來完成的,“釋放”之前也是要先調用析構函數。這就是問題所在!照理說,主函數應該是程序入口,那麼在主函數之前不應該調用任何自定義的函數纔對。但這件事放到C++當中就不一定成立了,我們看一下下面例程:

class Test {
 public:
  Test();
  ~Test();
};


Test::Test() {
  std::cout << "create" << std::endl;
}
Test::~Test() {
  std::cout << "destroy" << std::endl;
}


Test g_test; // 全局變量


int main(int argc, const char *argv[]) {
  std::cout << "main function" << std::endl;
  return 0;
}

運行上面程序會得到以下輸出:

create
main function
destroy

也就是說,Test的構造函數在主函數前被調用了。解釋起來也很簡單,因爲“全局變量在主函數執行之前構造,主函數執行結束後釋放”,而因爲Test類型是類類型,“構造”時要調用構造函數,“釋放”時要調用析構函數。所以上面的現象也就不奇怪了。

這種單一個的全局變量其實並不會出現什麼問題,但如果有多變量的依賴,這件事就不可控了,比如下面例程:

test.h

struct Test1 {
  int a;
};
extern Test1 g_test1; // 聲明全局變量

test.cc

Test1 g_test1 {4}; // 定義全局變量

main.cc

#include "test.h"


class Test2 {
 public:
  Test2(const Test1 &test1); // 傳Test1類型參數
 private:
  int m_;
};


Test2::Test2(const Test1 &test1): m_(test1.a) {}


Test2 g_test2{g_test1}; // 用一個全局變量來初始化另一個全局變量


int main(int argc, const char *argv) {
  return 0;
}

上面這種情況,程序編譯、鏈接都是沒問題的,但運行時會概率性出錯,問題

就在於,g_test1和g_test2都是全局變量,並且是在不同文件中定義的,並且

由於全局變量構造在主函數前,因此其初始化順序是隨機的

假如g_test1在g_test2之前初始化,那麼整個程序不會出現任何問題,但如果g_test2在g_test1前初始化,那麼在Test2的構造函數中,得到的就是一個未初始化的test1引用,這時候訪問test1.a就是操作野指針了。

這時我們就能發現,全局變量出問題的根源在於全局變量的初始化順序不可控,是隨機的,因此,如果出現依賴,則會導致問題。同理,析構發生在主函數後,那麼析構順序也是隨機的,可能出問題,比如:

struct Test1 {
  int count;
};


class Test2 {
 public:
  Test2(Test1 *test1);
  ~Test2();
 private:
  Test1 *test1_;  
};


Test2::Test2(Test1 *test1): test1_(test1) {
  test1_->count++;
}
Test2::~Test2() {
  test1_->count--;
}


Test1 g_test1 {0}; // 全局變量


void Demo() {
  static Test2 t2{&g_test1}; // 靜態局部變量
}


int main(int argc, const char *argv[]) {
  Demo(); // 構造了t2
  return 0;
}

在上面示例中,構造t2的時候使用了g_test1,由於t2是靜態局部變量,因此是在第一個調用時(主函數中調用Demo時)構造。這時已經是主函數執行過程中了,因此g_test1已經構造完畢的,所以構造時不會出現問題。

但是,靜態成員變量是在主函數執行完成後析構,這和全局變量相同,因此,t2和g_test1的析構順序無法控制。如果t2比g_test1先析構,那麼不會出現任何問題。但如果g_test1比t2先析構,那麼在析構t2時,對test1_訪問count成員這一步,就會訪問野指針。因爲test1_所指向的g_test1已經先行析構了。

那麼這個時候我們就可以確定,全局變量、靜態變量之間不能出現依賴關係,否則,由於其構造、析構順序不可控,因此可能會出現問題。

2)谷歌標準中的規定

回到我們剛纔提到的谷歌標準,這裏標準的制定者正是因爲擔心這樣的問題發生,才禁止了非POD類型的全局或靜態變量。但我們分析後得知,也並不是說所有的類類型全局或靜態變量都會出現問題。

而且,谷歌規範中的“POD類型”的限定也過於廣泛了。所謂“POD類型”指的是“平凡”+“標準內存佈局”,這裏我來解釋一下這兩種性質,並且分析分析爲什麼谷歌標準允許POD類型的全局或靜態變量。

3)平凡

“平凡(trivial)”指的是:

擁有默認無參構造函數;

擁有默認析構函數;

擁有默認拷貝構造函數;

擁有默認移動構造函數;

擁有默認拷貝賦值函數;

擁有默認移動賦值函數。

換句話說,六大特殊函數都是默認的。這裏要區分2個概念,我們要的是“語法上的平凡”還是“實際意義上的平凡”。語法上的平凡就是說能夠被編譯期識別、認可的平凡。而實際意義上的平凡就是說裏面沒有額外操作。比如說:

class Test1 {
 public:
  Test1() = default; // 默認無參構造函數
  Test1(const Test1 &) = default; // 默認拷貝構造函數
  Test &operator =(const Test1 &) = default; // 默認拷貝賦值函數
  ~Test1() = default; // 默認析構函數
};


class Test2 {
 public:
  Test2() {} // 自定義無參構造函數,但實際內容爲空
  ~Test2() {std::printf("destory\n");} // 自定義析構函數,但實際內容只有打印
};

上面的例子中,Test1就是個真正意義上的平凡類型,語法上是平凡的,因此編譯器也會認爲其是平凡的。我們可以用STL中的工具來判斷一個類型是否是平凡的:

bool is_test1_tri = std::is_trivial_v<Test1>; // true

但這裏的Test2,由於我們自定義了其無參構造函數和析構函數,那麼對編譯器來說,它就是非平凡的,我們用std::is_trivial來判斷也會得到false_value。但其實內部並沒有什麼外鏈操作,所以其實我們把Test2類型定義全局變量時也不會出現任何問題,這就是所謂“實際意義上的平凡”。

C++對“平凡”的定義比較嚴格,但實際上我們看看如果要做全局變量或靜態變量的時候,是不需要這樣嚴格定義的。對於全局變量來說,只要定義全局變量時,使用的是“實際意義上平凡”的構造函數,並且擁有“實際意義上平凡”的析構函數,那這個全局變量定義就不會有任何問題。而對於靜態局部變量來說,只要擁有“實際意義上平凡”的析構函數的就一定不會出問題。

4)標準內存佈局

標準內存佈局的定義是:

所有成員擁有相同的權限(比如說都public,或都protected,或都private);

不含虛基類、虛函數;

如果含有基類,基類必須都是標準內存佈局;

如果函數成員變量,成員的類型也必須是標準內存佈局。

我們同樣可以用STL中的std::is_standard_layout來判斷一個類型是否是標準內存佈局的。這裏的定義比較簡單,不在贅述。

  • POD(Plain Old Data)類型

所謂POD類型就是同時符合“平凡”和“標準內存佈局”的類型。符合這個類型的基本就是基本數據類型,加上一個普通C語言的結構體。換句話說,符合“舊類型(C語言中的類型)行爲的類型”,它不存在虛函數指針、不存在虛表,可以視爲普通二進制來操作的。

因此,在C++中,只有POD類型可以用memcpy這種二進制方法來複制而不會產生副作用,其他類型的都必須用用調用拷貝構造。

以前有人向筆者提出疑問,爲何vector擴容時不直接用類似於memcpy的方式來複制,而是要以此調用拷貝構造。原因正是在此,對於非POD類型的對象,其中可能會包含虛表、虛函數指針等數據,複製時這些內容可能會重置,並且內部可能會含有一些類似於“計數”這樣操作其他引用對象的行爲,因爲一定要用拷貝構造函數來保證這些行爲是正常的,而不能簡單粗暴地用二進制方式進行拷貝。

STL中可以用std::is_pod來判斷是個類型是否是POD的。

  • 小結

我們再回到谷歌規範中,POD的限制比較多,因此,確實POD類型的全局/靜態變量是肯定不會出問題的,但直接將非POD類型的一棍子打死,筆者個人認爲有點過了,沒必要。

所以,筆者認爲更加精確的限定應該是:對於全局變量、靜態成員變量來說,初始化時必須調用的是平凡的構造函數,並且其應當擁有平凡的析構函數,而且這裏的“平凡”是指實際意義上的平凡,也就是說可以自定義,但是在內部沒有對任何其他的對象進行操作;對於靜態局部變量來說,其應當擁有平凡的析構函數,同樣指的是實際意義上的平凡,也就是它的析構函數中沒有對任何其他的對象進行操作。

最後舉幾個例子:

class Test1 {
 public:
  Test1(int a): m_(a) {}
  void show() const {std::printf("%d\n", m_);}
 private:
  int m_;
};


class Test2 {
 public:
  Test2(Test1 *t): m_(t) {}
  Test2(int a): m_(nullptr) {}
  ~Test2() {}
 private:
  Test1 *m_;
};


class Test3 {
  public:
   Test3(const Test1 &t): m_(&t) {}
   ~Test3() {m_->show();}
  private:
   Test1 *m_;
};


class Test4 {
 public:
  Test4(int a): m_(a) {}
  ~Test4() = default;
 private:
  Test1 m_;
};

Test1是非平凡的(因爲無參構造函數沒有定義),但它仍然可以定義全局/靜態變量,因爲Test1(int)構造函數是“實際意義上平凡”的。

Test2是非平凡的,並且Test2(Test1 *)構造函數需要引用其他類型,因此它不能通過Test2(Test1 *)定義全局變量或靜態成員變量,但可以通過Test2(int)來定義全局變量或靜態成員變量,因爲這是一個“實際意義上平凡”的構造函數。而且因爲它的析構函數是“實際意義上平凡”的,因此Test2類型可以定義靜態局部變量。

Test3是非平凡的,構造函數對Test1有引用,並且析構函數中調用了Test1::show方法,因此Test3類型不能用來定義局部/靜態變量。

Test4也是非平凡的,並且內部存在同樣非平凡的Test1類型成員,但是因爲m1_不是引用或指針,一定會隨着Test4類型的對象的構造而構造,析構而析構,不存在順序依賴問題,因此Test4可以用來定義全局/靜態變量。

  • 所以全局std::string變量到底可以不可以?

最後回到這個問題上,筆者認爲定義一個全局的std::string類型的變量並不會出現什麼問題,在std::string的內部,數據空間是通過new的方式申請的,並且一般情況下都不會被其他全局變量所引用,在std::string對象析構時,對這片空間會進行delete,所以並不會出現析構順序問題。

但是,如果你用的不是默認的內存分配器,而是自定義了內存分配器的話,那確實要考慮構造析構順序的問題了,你要保證在對象構造前,內存分配器是存在的,並且內存分配器的析構要在所有對象之後。

當然了,如果你僅僅是想給字符串常量起個別名的話,有一種更好的方式:

constexpr const char *ip = "127.0.0.1";

畢竟指針一定是平凡類型,而且用constexpr修飾後可以變爲編譯期常量。這裏詳情可以在後面“constexpr”的章節瞭解。

而至於其他類型的靜態局部變量(比如說單例模式,或者局部內的map之類的映射表),只要讓它不被析構就好了,所以可以用堆空間的方式:

static Test &Test::GetInstance() {
  static Test &inst = *new Test;
  return inst;
}
std::string GetDesc(int code) {
  static const auto &desc = *new std::map<int, std::string> {
    {1, "desc1"},
  {2, "desc2"},
  };
  auto iter = desc.find(code);
  return iter == desc.end() ? "no_desc" : iter->second;
}

5)非平凡析構類型的移動語義

在討論完平凡類型後,我們發現平凡析構其實是更加值得關注的場景。這裏就引申出非平凡析構的移動語義問題,請看例程:

class Buffer {
 public:
  Buffer(size_t size): buf(new int[size]), size(size) {}
  ~Buffer() {delete [] buf;}
  Buffer(const Buffer &ob): buf(new int[ob.size]), size(ob.size) {}
  Buffer(Buffer &&ob): buf(ob.buf), size(ob.size) {}
 private:
  int *buf;
  size_t size;
};


void Demo() {
  Buffer buf{16};
  Buffer nb = std::move(buf);
} // 這裏會報錯

還是這個簡單的緩衝區的例子,如果我們調用Demo函數,那麼結束時會報重複釋放內存的異常。

那麼在上面例子中,buf和nb中的buf指向的是同一片空間,當Demo函數結束時,buf銷燬會觸發一次Buffer的析構,nb析構時也會觸發一次Buffer的析構。而析構函數中是delete操作,所以堆空間會被釋放兩次,導致報錯。

這也就是說,對於非平凡析構類型,其發生移動語義後,應當放棄對原始空間的控制。

如果我們修改一下代碼,那麼這種問題就不會發生:

class Buffer {
 public:
  Buffer(size_t size): buf(new int[size]), size(size) {}
  ~Buffer();
  Buffer(const Buffer &ob): buf(new int[ob.size]), size(ob.size) {}
  Buffer(Buffer &&ob): buf(ob.buf), size(ob.size) {ob.buf = nullptr;} // 重點在這裏
 private:
  int *buf;
};


Buffer::~Buffer() {
  if (buf != nullptr) {
    delete [] buf;
  }
}


void Demo() {
  Buffer buf{16};
  Buffer nb = std::move(buf);
} // OK,沒有問題

由於移動構造函數和移動賦值函數是我們可以自定義的,因此,可以把重複析構產生的問題在這個裏面考慮好。例如上面的把對應指針置空,而析構時再進行判空即可。

因此,我們得出的結論是並不是說非平凡析構的類型就不可以使用移動語義,而是非平凡析構類型進行移動構造或移動賦值時,要考慮引用權釋放問題

bb2eef5592b24ab4b1509a687e2e9fe6.jpeg

私有繼承和多繼承

1)C++是多範式語言

在講解私有繼承和多繼承之前,筆者要先澄清一件事:C++不是單純的面相對象的語言。同樣地,它也不是單純的面向過程的語言,也不是函數式語言,也不是接口型語言……

真的要說,C++是一個多範式語言,也就是說它並不是爲了某種編程範式來創建的。C++的語法體系完整且龐大,很多範式都可以用C++來展現。因此,不要試圖用任一一種語言範式來解釋C++語法,不然你總能找到各種漏洞和奇怪的地方。

舉例來說,C++中的“繼承”指的是一種語法現象,而面向對象理論中的“繼承”指的是一種類之間的關係。這二者是有本質區別的,請讀者一定一定要區分清楚。

以面向對象爲例,C++當然可以面向對象編程(OOP),但由於C++並不是專爲OOP創建的語言,自然就有OOP理論解釋不了的語法現象。比如說多繼承,比如說私有繼承。

C++與java不同,java是完全按照OOP理論來創建的,因此所謂“抽象類”,“接口(協議)類”的語義是明確可以和OOP對應上的,並且,在OOP理論中,“繼承”關係應當是"A is a B"的關係,所以不會存在A既是B又是C的這種情況,自然也就不會出現“多繼承”這樣的語法。

但是在C++中,考慮的是對象的佈局,而不是OOP的理論,所以出現私有繼承、多繼承等這樣的語法也就不奇怪了。

筆者曾經聽有人持有下面這樣類似的觀點:

虛函數都應該是純虛的;

含有虛函數的類不應當支持實例化(創建對象);

能實例化的類不應當被繼承,有子類的類不應當被實例化;

一個類至多有一個“屬性父類”,但可以有多個“協議父類”。

等等這些觀點,它們其實都有一個共同的前提,那就是“我要用C++來支持OOP範式”。如果我們用OOP範式來約束C++,那麼上面這些觀點都是非常正確的,否則將不符合OOP的理論,例如:

class Pet {};
class Cat : public Pet {};
class Dog : public Pet {};


void Demo() {
  Pet pet; // 一個不屬於貓、狗等具體類型,僅僅屬於“寵物”的實例,顯然不合理
}

Pet既然作爲一個抽象概念存在,自然就不應當有實體。同理,如果一個類含有未完全實現的虛函數,就證明這個類屬於某種抽象,它就不應該允許創建實例。而可以創建實例的類,一定就是最“具象”的定義了,它就不應當再被繼承。

在OOP的理論下,多繼承也是不合理的:

class Cat {};
class Dog {};
class SomeProperty : public Cat, public Dog {}; // 啥玩意會既是貓也是狗?

但如果是“協議父類”的多繼承就是合理的:

class Pet { // 協議類
 public:
  virtual void Feed() = 0; // 定義了餵養方式就可以成爲寵物
};


class Animal {};
class Cat : public Animal, public Pet { // 遵守協議,實現其需方法
 public:
  void Feed() override; // 實現協議方法
};

上面例子中,Cat雖然有2個父類,但Animal纔是真正意義上的父類,也就是Cat is a (kind of) Animal的關係,而Pet是協議父類,也就是Cat could be a Pet,只要一個類型可以完成某些行爲,那麼它就可以“作爲”這樣一種類型。

在java中,這兩種類型是被嚴格區分開的:

interface Pet { // 接口類
  public void Feed();
}


abstract class Animal {} // 抽象類,不可創建實例


class Cat extends Animal implements Pet {
  public void Feed() {}
}

子類與父類的關係叫“繼承”,與協議(或者叫接口)的關係叫“實現”。

與C++同源的Objective-C同樣是C的超集,但從名稱上就可看出,這是“面向對象的C”,語法自然也是針對OOP理論的,所以OC仍然只支持單繼承鏈,但可以定義協議類(類似於java中的接口類),“繼承”和“遵守(類似於java中的實現語義)”仍然是兩個分離的概念:

@protocol Pet <NSObject> // 定義協議
- (void)Feed;
@end


@interface Animal : NSObject
@end


@interface Cat : Animal<Pet> // 繼承自Animal類,遵守Pet協議
- (void)Feed;
@end


@implementation Cat
- (void)Feed {
  // 實現協議接口
}
@end

相比,C++只能說“可以”用做OOP編程,但OOP並不是其唯一範式,也就不會針對於OOP理論來限制其語法。這一點,希望讀者一定要明白。

2)私有繼承與EBO

  • 私有繼承本質不是「繼承」

在此強調,這個標題中,第一個“繼承”指的是一種C++語法,也就是class A : B {};這種寫法。而第二個“繼承”指的是OOP(面向對象編程)的理論,也就是A is a B的抽象關係,類似於“狗”繼承自“動物”的這種關係。

所以我們說,私有繼承本質是表示組合的,而不是繼承關係,要驗證這個說法,只需要做一個小實驗即可。我們知道最能體現繼承關係的應該就是多態了,如果父類指針能夠指向子類對象,那麼即可實現多態效應。請看下面的例程:

class Base {};
class A : public Base {};
class B : private Base {};
class C : protected Base {};


void Demo() {
  A a;
  B b;
  C c;


  Base *p = &a; // OK
  p = &b; // ERR
  p = &c; // ERR
}

這裏我們給Base類分別編寫了A、B、C三個子類,分別是public、private和protected繼承。然後用Base *類型的指針去分別指向a、b、c。發現只有public繼承的a對象可以用p直接指向,而b和c都會報這樣的錯:

Cannot cast 'B' to its private base class 'Base'
Cannot cast 'C' to its protected base class 'Base'

也就是說,私有繼承是不支持多態的,那麼也就印證了,他並不是OOP理論中的“繼承關係”,但是,由於私有繼承會繼承成員變量,也就是可以通過b和c去使用a的成員,那麼其實這是一種組合關係。或者,大家可以理解爲,把b.a.member改寫成了b.A::member而已。

那麼私有繼承既然是用來表示組合關係的,那我們爲什麼不直接用成員對象呢?爲什麼要使用私有繼承?這是因爲用成員對象在某種情況下是有缺陷的。

  • 空類大小

在解釋私有繼承的意義之前,我們先來看一個問題,請看下面例程

class T {};
// sizeof(T) = ?

T是一個空類,裏面什麼都沒有,那麼這時T的大小是多少?照理說,空類的大小就是應該是0,但如果真的設置爲0的話,會有很嚴重的副作用,請看例程:

class T {};
void Demo() {
  T arr[10];
  sizeof(arr); // 0
  T *p = arr + 5;
  // 此時p==arr
  p++; // ++其實無效
}

發現了嗎?假如T的大小是0,那麼T指針的偏移量就永遠是0,T類型的數組大小也將是0,而如果它成爲了一個成員的話,問題會更嚴重:

struct Test {
  T t;
  int a;
};
// t和a首地址相同

由於T是0大小,那麼此時Test結構體中,t和a就會在同一首地址。所以,爲了避免這種0長的問題,編譯器會針對於空類自動補一個字節的大小,也就是說其實sizeof(T)是1,而不是0

這裏需要注意的是,不僅是絕對的空類會有這樣的問題,只要是不含有非靜態成員變量的類都有同樣的問題,例如下面例程中的幾個類都可以認爲是空類:

class A {};
class B {
  static int m1;
  static int f();
};
class C {
public:
  C();
  ~C();
  void f1();
  double f2(int arg) const;
};

有了自動補1字節,T的長度變成了1,那麼T*的偏移量也會變成1,就不會出現0長的問題。但是,這麼做就會引入另一個問題,請看例程:

class Empty {};
class Test {
  Empty m1;
  long m2;
};
// sizeof(Test)==16

由於Empty是空類,編譯器補了1字節,所以此時m1是1字節,而m2是8字節,m1之後要進行字節對齊,因此Test變成了16字節。如果Test中出現了很多空類成員,這種問題就會被繼續放大。

這就是用成員對象來表示組合關係時,可能會出現的問題,而私有繼承就是爲了解決這個問題的。

  • 空基類成員壓縮(EBO,Empty Base Class Optimization)

在上一節最後的歷程中,爲了讓m1不再佔用空間,但又能讓Test中繼承Empty類的其他內容(例如函數、類型重定義等),我們考慮將其改爲繼承來實現,EBO就是說,當父類爲空類的時候,子類中不會再去分配父類的空間,也就是說這種情況下編譯器不會再去補那1字節了,節省了空間。但如果使用public繼承會怎麼樣?

class Empty {};
class Test : public Empty {
  long m2;
};


// 假如這裏有一個函數讓傳Empty類對象
void f(const Empty &obj) {}
// 那麼下面的調用將會合法
void Demo() {
  Test t;
  f(t); // OK
}

Test由於是Empty的子類,所以會觸發多態性,t會當做Empty類型傳入f中。這顯然問題很大呀!如果用這個例子看不出問題的話,我們換一個例子:

class Alloc {
public:
  void *Create();
  void Destroy();
};


class Vector : public Alloc {
};


// 這個函數用來創建buffer
void CreateBuffer(const Alloc &alloc) {
  void *buffer = alloc.Create(); // 調用分配器的Create方法創建空間
}


void Demo() {
  Vector ve; // 這是一個容器
  CreateBuffer(ve); // 語法上是可以通過的,但是顯然不合理
}

內存分配器往往就是個空類,因爲它只提供一些方法,不提供具體成員。Vector是一個容器,如果這裏用public繼承,那麼容器將成爲分配器的一種,然後調用CreateBuffer的時候可以傳一個容器進去,這顯然很不合理呀!那麼此時,用私有繼承就可以完美解決這個問題了

class Alloc {
public:
  void *Create();
  void Destroy();
};


class Vector : private Alloc {
private:
  void *buffer;
  size_t size;
  // ...
};


// 這個函數用來創建buffer
void CreateBuffer(const Alloc &alloc) {
  void *buffer = alloc.Create(); // 調用分配器的Create方法創建空間
}


void Demo() {
  Vector ve; // 這是一個容器
  CreateBuffer(ve); // ERR,會報錯,私有繼承關係不可觸發多態
}

此時,由於私有繼承不可觸發多態,那麼Vector就並不是Alloc的一種,也就是說,從OOP理論上來說,他們並不是繼承關係。而由於有了私有繼承,在Vector中可以調用Alloc裏的方法以及類型重命名,所以這其實是一種組合關係。而又因爲EBO,所以也不用擔心Alloc佔用Vector的成員空間的問題。

谷歌規範中規定了繼承必須是public的,這主要還是在貼近OOP理論。另一方面就是說,雖然使用私有繼承是爲了壓縮空間,但一定程度上也是犧牲了代碼的可讀性,讓我們不太容易看得出兩種類型之間的關係,因此在絕大多數情況下,還是應當使用public繼承。不過筆者仍然持有“萬事皆不可一棒子打死”的觀點,如果我們確實需要EBO的特性否則會大幅度犧牲性能的話,那麼還是應當允許使用私有繼承。

3)多繼承

與私有繼承類似,C++的多繼承同樣是“語法上”的繼承,而實際意義上可能並不是OOP中的“繼承”關係。再以前面章節的Pet爲例:

class Pet {
 public:
  virtual void Feed() = 0;
};


class Animal {};


class Cat : public Animal, public Pet {
 public:
  void Feed() override;
};

從形式上來說,Cat同時繼承自Anmial和Pet,但從OOP理論上來說,Cat和Animal是繼承關係,而和Pet是實現關係,前面章節已經介紹得很詳細了,這裏不再贅述。

但由於C++並不是完全針對OOP的,因此支持真正意義上的多繼承,也就是說,即便父類不是這種純虛類,也同樣支持集成,從語義上來說,類似於“交叉分類”。請看示例:

class Organic { // 有機物
};
class Inorganic { // 無機物
};
class Acid { // 酸
};
class Salt { // 鹽
};


class AceticAcid : public Organic, public Acid { // 乙酸
};
class HydrochloricAcid : public Inorganic, public Acid { // 鹽酸
};
class SodiumCarbonate : public Inorganic, public Salt { // 碳酸鈉
};

上面就是一個交叉分類法的例子,使用多繼承語法合情合理。如果換做其他OOP語言,可能會強行把“酸”或者“有機物”定義爲協議類,然後用繼承+實現的方式來完成。但如果從化學分類上來看,無論是“酸鹼鹽”還是“有機物無機物”,都是一種強分類,比如說“碳酸鈉”,它就是一種“無機物”,也是一種“鹽”,你並不能用類似於“貓是一種動物,可以作爲寵物”的理論來解釋,不能說“碳酸鈉是一種鹽,可以作爲一種無機物”。

因此C++中的多繼承是哪種具體意義,取決於父類本身是什麼。如果父類是個協議類,那這裏就是“實現”語義,而如果父類本身就是個實際類,那這裏就是“繼承”語義。當然了,像私有繼承的話表示是“組合”語義。不過C++本身並不在意這種語義,有時爲了方便,我們也可能用公有繼承來表示組合語義,比如說:

class Point {
 public:
  double x, y;
};


class Circle : public Point {
 public:
  double r; // 半徑
};

這裏Circle繼承了Point,但顯然不是說“圓是一個點”,這裏想表達的就是圓類“包含了”點類的成員,所以只是爲了複用。從意義上來說,Circle類中繼承來的x和y顯然表達的是圓心的座標。不過這樣寫並不符合設計規範,但筆者用這個例子希望解釋的是C++並不在意類之間實際是什麼關係,它在意的是數據複用,因此我們更需要了解一下多繼承體系中的內存佈局。

對於一個普通的類來說,內存佈局就是按照成員的聲明順序來佈局的,與C語言中結構體佈局相同,例如:

class Test1 {
 public:
  char a;
  int b;
  short c;
};

那麼Test1的內存佈局就是

字節編號 內容
0 a
1~3 內存對齊保留字節
4~7 b
8~9 c
9~11 內存對齊保留字節

但如果類中含有虛函數,那麼還會在末尾添加虛函數表的指針,例如:

class Test1 {
 public:
  char a;
  int b;
  short c;


  virtual void f() {}
};
字節編號 內容
0 a
1~3 內存對齊保留字節
4~7 b
8~9 c
9~15 內存對齊保留字節
16~23 虛函數表指針

多繼承時,第一父類的虛函數表會與本類合併,其他父類的虛函數表單獨存在,並排列在本類成員的後面。

4)菱形繼承與虛擬繼承

C++由於支持“普適意義上的多繼承”,那麼就會有一種特殊情況——菱形繼承,請看例程:

struct A {
  int a1, a2;
};


struct B : A {
  int b1, b2;
};


struct C : A {
  int c1, c2;
};


struct D : B, C {
  int d1, d2;
};

根據內存佈局原則,D類首先是B類的元素,然後D類自己的元素,最後是C類元素:

字節序號 意義
0~15 B類元素
16~19 d1
20~23 d2
24~31 C類元素

如果再展開,會變成這樣:

字節序號 意義
0~3 a1(B類繼承自A類的)
4~7 a2(B類繼承自A類的)
8~11 b1
12~15 b2
16~19 d1
20~23 d2
24~27 a1(C類繼承自A類的)
28~31 a2(C類繼承自A類的)
32~35 c1
36~39 c2

可以發現,A類的成員出現了2份,這就是所謂“菱形繼承”產生的副作用。這也是C++的內存佈局當中的一種缺陷,多繼承時第一個父類作爲主父類合併,而其餘父類則是直接向後擴寫,這個過程中沒有去重的邏輯(詳情參考上一節)。這樣的話不僅浪費空間,還會出現二義性問題,例如d.a1到底是指從B繼承來的a1還是從C裏繼承來的呢?

C++引入虛擬繼承的概念就是爲了解決這一問題。但怎麼說呢,C++的複雜性往往都是因爲爲了解決一種缺陷而引入了另一種缺陷,虛擬繼承就是非常典型的例子,如果你直接去解釋虛擬繼承(比如說和普通繼承的區別)你一定會覺得莫名其妙,爲什麼要引入一種這樣奇怪的繼承方式。所以這裏需要我們瞭解到,它是爲了解決菱形繼承時空間爆炸的問題而不得不引入的。

首先我們來看一下普通的繼承和虛擬繼承的區別:普通繼承:

struct A {
  int a1, a2;
};


struct B : A {
  int b1, b2;
};

B的對象模型應該是這樣的: 

a547f257e64281d1ad3d9097ee3e8630.jpeg

 而如果使用虛擬繼承:

struct A {
  int a1, a2;
};


struct B : virtual A {
  int b1, b2;
};

對象模型是這樣的: 

2c14abaaf3f858896a670565440ce020.jpeg

虛擬繼承的排布方式就類似於虛函數的排布,子類對象會自動生成一個虛基表來指向虛基類成員的首地址。

就像剛纔說的那樣,單純的虛擬繼承看上去很離譜,因爲完全沒有必要強行更換這樣的內存佈局,所以絕大多數情況下我們是不會用虛擬繼承的。但是菱形繼承的情況,就不一樣了,普通的菱形繼承會這樣:

struct A {
  int a1, a2;
};


struct B : A {
  int b1, b2;
};


struct C : A {
  int c1, c2;
};


struct D : B, C {
  int d1, d2;
};

D的對象模型:  

5213cd6c3b5e06e6b46a3bd6cf80db6e.jpeg

但如果使用虛擬繼承,則可以把每個類單獨的東西抽出來,重複的內容則用指針來指向:

struct A {
  int a1, a2;
};


struct B : virtual A {
  int b1, b2;
};


struct C : virtual A {
  int c1, c2;
};


struct D : B, C {
  int d1, d2;
};

D的對象模型將會變成:  

8f515c7ecde4ba9cd7f3589422cd1331.jpeg

也就是說此時,共有的虛基類只會保存一份,這樣就不會有二義性,同時也節省了空間。

但需要注意的是,D繼承自B和C時是普通繼承,如果用了虛擬繼承,則會在D內部又額外添加一份虛基表指針。要虛擬繼承的是B和C對A的繼承,這也是虛擬繼承語法非常迷惑的地方,也就是說,菱形繼承的分支處要用虛擬繼承,而匯聚處要用普通繼承。所以我們還是要明白其底層原理,以及引入這個語法的原因(針對解決的問題),才能更好的使用這個語法,避免出錯。

82845254a1b85a56189f48f59dabc0d3.jpeg

隱式構造

隱式構造指的就是隱式調用構造函數。換句話說,我們不用寫出類型名,而是僅僅給出構造參數,編譯期就會自動用它來構造對象。舉例來說:

class Test {
 public:
  Test(int a, int b) {}
};


void f(const Test &t) {
}


void Demo() {
 f({1, 2}); // 隱式構造Test臨時對象,相當於f(Test{a, b})
}

上面例子中,f需要接受的是Test類型的對象,然而我們在調用時僅僅使用了構造參數,並沒有指定類型,但編譯器會進行隱式構造。

尤其,當構造參數只有1個的時候,可以省略大括號:

class Test {
 public:
  Test(int a) {}
  Test(int a, int b) {}
};


void f(const Test &t) {
}


void Demo() {
  f(1); // 隱式構造Test{1},單參時可以省略大括號
  f({2}); // 隱式構造Test{2}
  f({1, 2}); // 隱式構造Test{1, 2}
}

這樣做的好處顯而易見,就是可以讓代碼簡化,尤其是在構造string或者vector的時候更加明顯:

void f1(const std::string &str) {}
void f2(const std::vector<int> &ve) {}


void Demo() {
  f1("123"); // 隱式構造std::string{"123"},注意字符串常量是const char *類型
  f2({1, 2, 3}); // 隱式構造std::vector,注意這裏是initialize_list構造
}

當然,如果遇到函數重載,原類型的優先級大於隱式構造,例如:

class Test {
public:
  Test(int a) {}
};


void f(const Test &t) {
  std::cout << 1 << std::endl;
}


void f(int a) {
  std::cout << 2 << std::endl;
}


void Demo() {
  f(5); // 會輸出2
}

但如果有多種類型的隱式構造則會報二義性錯誤:

class Test1 {
public:
  Test1(int a) {}
};


class Test2 {
public:
  Test2(int a) {}
};


void f(const Test1 &t) {
  std::cout << 1 << std::endl;
}


void f(const Test2 &t) {
  std::cout << 2 << std::endl;
}


void Demo() {
  f(5); // ERR,二義性錯誤
}

在返回值場景也支持隱式構造,例如:

struct err_t {
  int err_code;
  const char *err_msg;
};


err_t f() {
  return {0, "success"}; // 隱式構造err_t
}

但隱式構造有時會讓代碼含義模糊,導致意義不清晰的問題(尤其是單參的構造函數),例如:

class System {
 public:
  System(int version);
};


void Operate(const System &sys, int cmd) {}


void Demo() {
  Operate(1, 2); // 意義不明確,不容易讓人意識到隱式構造
}

上例中,System表示一個系統,其構造參數是這個系統的版本號。那麼這時用版本號的隱式構造就顯得很突兀,而且只通過Operate(1, 2)這種調用很難讓人想到第一個參數竟然是System類型的。

因此,是否應當隱式構造,取決於隱式構造的場景,例如我們用const char *來構造std::string就很自然,用一組數據來構造一個std::vector也很自然,或者說,代碼的閱讀者非常直觀地能反應出來這裏發生了隱式構造,那麼這裏就適合隱式構造,否則,這裏就應當限定必須顯式構造。用explicit關鍵字限定的構造函數不支持隱式構造:

class Test {
 public:
  explicit Test(int a);
  explicit Test(int a, int b);
  Test(int *p);
};


void f(const Test &t) {}


void Demo() {
  f(1); // ERR,f不存在int參數重載,Test的隱式構造不允許用(因爲有explicit限定),所以匹配失敗
  f(Test{1}); // OK,顯式構造
  f({1, 2}); // ERR,同理,f不存在int, int參數重載,Test隱式構造不許用(因爲有explicit限定),匹配失敗
  f(Test{1, 2}); // OK,顯式構造


  int a;
  f(&a); // OK,隱式構造,調用Test(int *)構造函數 
}

還有一種情況就是,對於變參的構造函數來說,更要優先考慮要不要加explicit,因爲變參包括了單參,並且默認情況下所有類型的構造(模板的所有實例,任意類型、任意個數)都會支持隱式構造,例如:

class Test {
 public:
  template <typename... Args>
  Test(Args&&... args);
};


void f(const Test &t) {}


void Demo() {
  f(1); // 隱式構造Test{1}
  f({1, 2}); // 隱式構造Test{1, 2}
  f("abc"); // 隱式構造Test{"abc"}
  f({0, "abc"}); // 隱式構造Test{0, "abc"}
}

所以避免爆炸(生成很多不可控的隱式構造),對於變參構造最好還是加上 explicit,如果不加的話一定要慎重考慮其可能實例化的每一種情況。

在谷歌規範中,單參數構造函數必須用explicit限定(公司規範中也是這樣的,可以參考公司C++編程規範第4.2條),但筆者認爲這個規範並不完全合理,在個別情況隱式構造意義非常明確的時候,還是應當允許使用隱式構造。另外,即便是多參數的構造函數,如果當隱式構造意義不明確時,同樣也應當用explicit來限定。所以還是要視情況而定。C++支持隱式構造,自然考慮的是一些場景下代碼更簡潔,但歸根結底在於C++主要靠STL來擴展功能,而不是語法。舉例來說,在Swift中,原生語法支持數組、map、字符串等:

let arr = [1, 2, 3] // 數組
let map = [1 : "abc", 25 : "hhh", -1 : "fail"] // map
let str = "123abc" // 字符串

因此,它並不需要所謂隱式構造的場景,因爲語法本身已經表明了它的類型。

而C++不同,C++並沒有原生支持std::vector、std::map、std::string等的語法,這就會讓我們在使用這些基礎工具的時候很頭疼,因此引入隱式構造來簡化語法。所以歸根結底,C++語言本身考慮的是語法層面的功能,而數據邏輯層面靠STL來解決,二者並不耦合。但又希望程序員能夠更加方便地使用STL,因此引入了一些語言層面的功能,但它卻像全體類型開放了。

舉例來說,Swift中,[1, 2, 3]的語法強綁定Array類型,[k1:v1, k2,v2]的語法強綁定Map類型,因此這裏的“語言”和“工具”是耦合的。但C++並不和STL耦合,他的思路是{x, y, z}就是構造參數,哪種類型都可以用,你交給vector時就是表示數組,你交給map時就是表示kv對,並不會將“語法”和“類型”做任何強綁定。因此把隱式構造和explicit都提供出來,交給開發者自行處理是否支持。

這是我們需要體會的C++設計理念,當然,也可以算是C++的缺陷。

f6cb33761ea37c2bbced2e491e108f99.jpeg

C風格字符串

字符串同樣是C++特別容易踩坑的位置。出於對C語言兼容、以及上一節所介紹的C++希望將“語言”和“類型”解耦的設計理念的目的,在C++中,字符串並沒有映射爲std::string類型,而是保留C語言當中的處理方式。編譯期會將字符串常量存儲在一個全局區,然後再使用字符串常量的位置用一個指針代替。所以基本可以等價認爲,字符串常量(字面量)是const char *類型。

但是,更多的場景下,我們都會使用std::string類型來保存和處理字符串,因爲它功能更強大,使用更方便。得益於隱式構造,我們可以把一個字符串常量輕鬆轉化爲std::string類型來處理。

但本質上來說,std::string和const char *是兩種類型,所以一些場景下它還是會出問題。

1)類型推導問題

在進行類型推導時,字符串常量會按const char *來處理,有時會導致問題,比如:

template <typename T>
void f(T t) {
  std::cout << 1 << std::endl;
}


template <typename T>
void f(T *t) {
  std::cout << 2 << std::endl;
}


void Demo() {
  f("123");
  f(std::string{"123"});
}

代碼的原意是將“值類型”和“指針類型”分開處理,至於字符串,照理說應當是一個“對象”,所以要按照值類型來處理。但如果我們用的是字符串常量,則會識別爲const char *類型,直接匹配到了指針處理方式,而並不會觸發隱式構造。

2)截斷問題

C風格字符串有一個約定,就是以0結尾。它並不會去單獨存儲數據長度,而是很暴力地從首地址向後查找,找到0爲止。但std::string不同,其內部有統計個數的成員,因此不會受0值得影響:

std::string str1{"123\0abc"}; // 0處會截斷
std::string str2{"123\0abc", 7}; // 不會截斷

截斷問題在傳參時更加明顯,比如說:

void f(const char *str) {}


void Demo() {
  std::string str2{"123\0abc", 7}; 
  // 由於f只支持C風格字符串,因此轉化後傳入
  f(str2.c_str()); // 但其實已經被截斷了
}

前面的章節曾經提到過,C++沒有引入額外的格式符,因此把std::string傳入格式化函數的時候,也容易發生截斷問題:

std::string MakeDesc(const std::string &head, double data) {
  // 拼湊一個xxx:ff%的形式
  char buf[128];
  std::sprintf(buf, "%s:%lf%%", head.c_str(), data); // 這裏有可能截斷
  return buf; // 這裏也有可能截斷
}

總之,C風格的字符串永遠難逃0值截斷問題,而又因爲C++中仍然保留了C風格字符串的所有行爲,並沒有在語言層面直接關聯std::string,因此在使用時一定要小心截斷問題。

3)指針意義不明問題

由於C++保留了C風格字符串的行爲,因此在很多場景下,把const char *就默認爲了字符串,都會按照字符串去解析。但有時可能會遇到一個真正的指針,那麼此時就會有問題,比如說:

void Demo() {
  int a;
  char b;
  std::cout << &a << std::endl; // 流接受指針,打印指針的值
  std::cout << &b << std::endl; // 流接收char *,按字符串處理
}

STL中所有流接收到char *或const char *時,並不會按指針來解析,而是按照字符串解析。在上面例子中,&b本身應當就是個單純指針,但是輸出流卻將其按照字符串處理了,也就是會持續向後搜索找到0值爲止,那這裏顯然是發生越界了。

因此,如果我們給char、signed char、unsigned char類型取地址時,一定要考慮會不會被識別爲字符串。

4)int8_t和uint8_t

原本int8_t和uint8_t是用來表示“8位整數”的,但是不巧的是,他們的定義是:

using int8_t = signed char;
using uint8_t = unsigned char;

由於C語言歷史原因,ASCII碼只有7位,所以“字符”類型有無符號是沒區別的,而當時沒有定製規範,因此不同編譯器可能有不同處理。到後來乾脆把char當做獨立類型了。所以char和signed char以及unsigned char是不同類型。這與其他類型不同,例如int和signed int是同一類型。

但是類似於流的處理中,卻沒有把signed char和unsigned char單獨拿出來處理,都是按照字符來處理了(這裏筆者也不知道什麼原因)。而int8_t和uint8_t又是基於此定義的,所以也會出現奇怪問題,比如:

uint8_t n = 56; // 這裏是單純想放一個整數
std::cout << n << std::endl; // 但這裏會打印出8,而不是56

原本uint8_t是想屏蔽掉char這層含義,讓它單純地表示8位整數的,但是在STL的解析中,卻又讓它有了“字符”的含義,去按照ASCII碼來解析了,讓uint8_t的定義又失去了原本該有的含義,所以這裏也是很容易踩坑的地方。

(這一點筆者真的沒想明白爲什麼,明明是不同類型,但爲什麼沒有區分開。可能同樣是歷史原因吧,總之這個點可以算得上真正意義上的“缺陷”了。)

f2f8b6d642c87c884c35f87ff635e76c.jpeg

new和delete

new這個運算符相信大家一定不陌生,即便是非C++系其他語言一般都會保留new這個關鍵字。而且這個已經成爲業界的一個哏了,比如說“沒有對象怎麼辦?不怕,new一個!”

從字面意思就能看得出,這是“新建”的意思,不過在C++中,new遠不止字面看上去這麼簡單。而且,delete關鍵字基本算得上是C++的特色了,其他語言中基本見不到。

1)分配和釋放空間

“堆空間”的概念同樣繼承自C語言,它是提供給程序手動管理、調用的內存空間。在C語言中,malloc用於分配堆空間,free用於回收。自然,在C++中仍然可以用malloc和free

但使用malloc有一個不方便的地方,我們來看一下malloc的函數原型:

void *malloc(size_t size);

malloc接收的是字節數,也就是我們需要手動計算出我們需要的空間是多少字節。它不能方便地通過某種類型直接算出空間,通常需要sizeof運算。malloc返回值是void *類型,是一個泛型指針,也就是沒有指定默認解類型的,使用時通常需要類型轉換,例如:

int *data = (int *)malloc(sizeof(int));

而new運算符可以完美解決上面的問題,注意,在C++中new是一個運算符

int *data = new int;

同理,delete也是一個運算符,用於釋放空間:

delete data;

2)運算符本質是函數調用

熟悉C++運算符重載的讀者一定清楚,C++中運算符的本質其實就是一個函數的語法糖,例如a + b實際上就是operator +(a, b),a++實際上就是a.operator++(),甚至仿函數、下標運算也都是函數調用,比如f()就是f.operator()(),a[i]就是a.operator

既然new和delete也是運算符,那麼它就應當也符合這個原理,一定有一個operator new的函數存在,下面是它的函數原型:

void *operator new(size_t size);
void *operator new(size_t size, void *ptr);

這個跟我們直觀想象可能有點不一樣,它的返回值仍然是void *,也並不是一個模板函數用來判斷大小。所以,new運算符跟其他運算符並不一樣,它並不只是單純映射成operator new,而是做了一些額外操作。

另外,這個擁有2個參數的重載又是怎麼回事呢?這個等一會再來解釋。

系統內置的operator new本質上就是malloc,所以如果我們直接調operator new和operator delete的話,本質上來說,和malloc和free其實沒什麼區別:

int *data = static_cast<int *>(operator new(sizeof(int)));
operator delete(data);

而當我們用運算符的形式來書寫時,編譯器會自動處理類型的大小,以及返回值。new運算符必須作用於一個類型,編譯器會將這個類型的size作爲參數傳給operator new,並把返回值轉換爲這個類型的指針,也就是說:

new T;
// 等價於
static_cast<T *>(operator new(sizeof(T)))

delete運算符要作用於一個指針,編譯器會將這個指針作爲參數傳給operator delete,也就是說:

delete ptr;
// 等價於
operator delete(ptr);

3)重載new和delete

之所以要引入operator new和operator delete還有一個原因,就是可以重載。默認情況下,它們操作的是堆空間,但是我們也可以通過重載來使得其操作自己的內存池。

std::byte buffer[16][64]; // 一個手動的內存池
std::array<void *, 16> buf_mark {nullptr}; // 統計已經使用的內存池單元


struct Test {
  int a, b;
  static void *operator new(size_t size) noexcept; // 重載operator new
  static void operator delete(void *ptr); // 重載operator delete
};


void *Test::operator new(size_t size) noexcept {
  // 從buffer中分配資源
  for (int i = 0; i < 16; i++) {
    if (buf_mark.at(i) == nullptr) {
      buf_mark.at(i) = buffer[i];
      return buffer[i];
    }
  }
  return nullptr;
}


void Test::operator delete(void *ptr) {
  for (int i = 0; i < 16; i++) {
    if (buf_mark.at(i) == ptr) {
      buf_mark.at(i) = nullptr;
    }
  }
}


void Demo() {
  Test *t1 = new Test; // 會在buffer中分配
  delete t1; // 釋放buffer中的資源
}

另一個點,相信大家已經發現了,operator new和operator delete是支持異常拋出的,而我們這裏引用直接用空指針來表示分配失敗的情況了,於是加上了noexcept修飾。而默認的情況下,可以通過接收異常來判斷是否分配成功,而不用每次都對指針進行判空。

4)構造函數和placement new

malloc的另一個問題就是處理非平凡構造的類類型。當一個類是非平凡構造時,它可能含有虛函數表、虛基表,還有可能含有一些額外的構造動作(比如說分配空間等等),我們拿一個最簡單的字符串處理類爲例:

class String {
 public:
  String(const char *str);
  ~String();
 private:
  char *buf;
  size_t size;
  size_t capicity;
};


String::String(const char *str):
    buf((char *)std::malloc(std::strlen(str) + 1)), 
    size(std::strlen(str)), 
    capicity(std::strlen(str) + 1) {
  std::memcpy(buf, str, capicity);
}
String::~String() {
  if (buf != nullptr) {
    std::free(buf);
  }
}


void Demo() {
  String *str = (String *)std::malloc(sizeof(String)); 
  // 再使用str一定是有問題的,因爲沒有正常構造
}

上面例子中,String就是一個非平凡的類型,它在構造函數中創建了堆空間。如果我們直接通過malloc分配一片String大小的空間,然後就直接用的話,顯然是會出問題的,因爲構造函數沒有執行,其中buf管理的堆空間也是沒有進行分配的。所以,在C++中,創建一個對象應該分2步:

第一步,分配內存空間

第二步,調用構造函數

同樣,釋放一個對象也應該分2步:

第一步,調用析構函數

第二步,釋放內存空間

這個理念在OC語言中貫徹得非常徹底,OC中沒有默認的構造函數,都是通過實現一個類方法來進行構造的,因此構造前要先分配空間:

NSString *str = [NSString alloc]; // 分配NSString大小的內存空間
[str init]; // 調用初始化函數
// 通常簡寫爲:
NSString *str = [[NSString alloc] init];

但是在C++中,初始化方法並不是一個普通的類方法,而是特殊的構造函數,那如何手動調用構造函數呢?

我們知道,要想調用構造函數(構造一個對象),我們首先需要一個分配好的內存空間。因此,要拿着用於構造的內存空間,以構造參數,才能構造一個對象(也就是調用構造函數)。C++管這種語法叫做就地構造(placement new)

String *str = static_cast<String *>(std::malloc(sizeof(String))); // 分配內存空間
new(str) String("abc"); // 在str指向的位置調用String的構造函數

就地構造的語法就是new(addr) T(args...),看得出,這也是new運算符的一種。這時我們再回去看operator new的一個重載,應該就能猜到它是幹什麼的了:

void *operator new(size_t size, void *ptr);

就是用於支持就地構造的函數。要注意的是,如果是通過就地構造方式構造的對象,需要再回收內存空間之前進行析構。以上面String爲例,如果不析構直接回收,那麼buf所指的空間就不能得到釋放,從而造成內存泄漏:

str->~String(); // 析構
std::free(str); // 釋放內存空間

5)new = operator new + placement new

看到本節的標題,相信讀者會恍然大悟。C++中new運算符同時承擔了“分配空間”和“構造對象”的任務。上一節的例子中我們是通過malloc和free來管理的,自然,通過operator new和operator delete也是一樣的,而且它們還支持針對類型的重載。

因此,我們說,一次new,相當於先operator new(分配空間)加placement new(調用構造函數)。

String *str = new String("abc"); 
// 等價於
String *str = static_cast<String *>(operator new(sizeof(String)));
new(str) String("abc");

同理,一次delete相當於先“析構”,再operator delete(釋放空間)

delete str;
// 等價於
str->~String();
operator delete(str);

這就是new和delete的神祕面紗,它確實和普通的運算符不一樣,除了對應的operator函數外,還有對構造、析構的處理。但也正是由於C++總是進行一些隱藏操作,纔會複雜度激增,有時也會出現一些難以發現的問題,所以我們一定要弄清楚它的本質。

6)new []和delete []

new []和delete []的語法看起來是“創建/刪除數組”的語法。但其實它們也並不特殊,就是封裝了一層的new和delete

void *operator new[](size_t size);
void operator delete[](void *ptr);

可以看出,operator new[]和operator new完全一樣,opeator delete[]和operator delete也完全一樣,所以區別應當在編譯器的解釋上。operator new T[size]的時候,會計算出size個T類型的總大小,然後調用operator new[],之後,會依次對每個元素進行構造。也就是說:

String *arr_str = new String [4] {"abc", "def", "123"};
// 等價於
String *arr_str = static_cast<String *>(opeartor new[](sizeof(String) * 3));
new(arr_str) String("abc");
new(arr_str + 1) String("def");
new(arr_str + 2) String("123");
new(arr_str + 3) String; // 沒有寫在列表中的會用無參構造函數

同理,delete []會首先依次調用析構,然後再調用operator delete []來釋放空間:

delete [] arr_str;
// 等價於
for (int i = 0; i < 4; i++) {
  arr_str[i].~String();
}
operator delete[] (arr_str);

總結下來new []相當於一次內存分配加多次就地構造,delete []運算符相當於多次析構加一次內存釋放。

266d4986fe4c9c580aefc87c8bcb497e.jpeg

constexpr

constexpr全程叫“常量表達式(constant expression)”,顧名思義,將一個表達式定義爲“常量”。

關於“常量”的概念筆者在前面“const引用”的章節已經詳細敘述過,只有像1,'a',2.5f之類的纔是真正的常量。儲存在內存中的數據都應當叫做“變量”。

但很多時候我們在程序編寫的時候,會遇到一些編譯期就能確定的量,但不方便直接用常量表達的情況。最簡單的一個例子就是“魔鬼數字”:

using err_t = int;
err_t Process() {
  // 某些錯誤
  return 25;
  // ...
  return 0;
}

作爲錯誤碼的時候,我們只能知道業界約定0表示成功,但其他的錯誤碼就不知道什麼含義了,比如這裏的25號錯誤碼,非常突兀,根本不知道它是什麼含義。

C中的解決的辦法就是定義宏,又有宏是預編譯期進行替換的,因此它在編譯的時候一定是作爲常量存在的,我們又可以通過宏名稱來增加可讀性:

#define ERR_DATA_NOT_FOUNT 25
#define SUCC 0


using err_t = int;
err_t Process() {
  // 某些錯誤
  return ERR_DATA_NOT_FOUNT;
  // ...
  return SUCC;
}

(對於錯誤碼的場景當然還可以用枚舉來實現,這裏就不再贅述了。

用宏雖然可以解決魔數問題,但是宏本身是不推薦使用的,詳情大家可以參考前面“宏”的章節,裏面介紹了很多宏濫用的情況。

不過最主要的一點就是宏不是類型安全的。我們既希望定義一個類型安全的數據,又不希望這個數據成爲“變量”來佔用內存空間。這時,就可以使用C++11引入的constexpr概念。

constexpr double pi = 3.141592654;
double Squ(double r) {
  return pi * r * r;
}

這裏的pi雖然是double類型的,類型安全,但因爲用constexpr修飾了,因此它會在編譯期間成爲“常量”,而不會佔用內存空間。

用constexpr修飾的表達式,會保留其原有的作用域和類型(例如上面的pi就跟全局變量的作用域是一樣的),只是會變成編譯期常量。

1)constexpr可以當做常量使用

既然constexpr叫“常量表達式”,那麼也就是說有一些編譯期參數只能用常量,用constexpr修飾的表達式也可以充當。

舉例來說,模板參數必須是一個編譯期確定的量,那麼除了常量外,constexpr修飾的表達式也可以:

template <int N>
struct Array {
  int data[N];
};


constexpr int default_size = 16;
const int g_size = 8;
void Demo() {
  Array<8> a1; // 常量OK
  Array<default_size> a2; // 常量表達式OK
  Array<g_size> a3; // ERR,非常量不可以,只讀變量不是常量
}

至於其他類型的表達式,也支持constexpr,原則在於它必須要是編譯期可以確定的類型,比如說POD類型:

constexpr int arr[] {1, 2, 3}; 
constexpr std::array<int> arr2 {1, 2, 3};


void f() {}


constexpr void (*fp)() = f;
constexpr const char *str = "abc123";


int g_val = 5;
constexpr int *pg = &g_val;

這裏可能有一些和直覺不太一樣的地方,我來解釋一下。首先,數組類型是編譯期可確定的(你可以單純理解爲一組數,使用時按對應位置替換爲值,並不會真的分配空間)。

std::array是POD類型,那麼就跟普通的結構體、數組一樣,所以都可以作爲編譯期常量。

後面幾個指針需要重點解釋一下。用constexpr修飾的除了可以是絕對的常量外,在編譯期能確定的量也可以視爲常量。比如這裏的fp,由於函數f的地址,在運行期間是不會改變的,編譯期間儘管不能確定其絕對地址,但可以確定它的相對地址,那麼作爲函數指針fp,它就是f將要保存的地址,所以,這就是編譯期可以確定的量,也可用constexpr修飾。

同理,str指向的是一個字符串常量,字符串常量同樣是有一個固定存放地址的,位置不會改變,所以用於指向這個數據的指針str也可以用constexpr修飾。要注意的是:constexpr表達式有固定的書寫位置,與const的位置不一定相同。比如說這裏如果定義只讀變量應該是const char *const str,後面的const修飾str,前面的const修飾char。但換成常量表達式時,constexpr要放在最前,因此不能寫成const char *constexpr str,而是要寫成constexpr const char *str。當然,少了這個const也是不對的,因爲不僅是指針不可變,指針所指數據也不可變。這個也是C++中推薦的定義字符串常量別名的方式,優於宏定義。

最後的這個pg也是一樣的道理,因爲全局變量的地址也是固定的,運行期間不會改變,因此pg也可以用常量表達式。

當然,如果運行期間可能發生改變的量(也就是編譯期間不能確定的量)就不可以用常量表達式,例如:

void Demo() {
  int a;
  constexpr int *p = &a; // ERR,局部變量地址編譯期間不能確定
  static int b;
  constexpr int *p2 = &b; // OK,靜態變量地址可以確定


  constexpr std::string str = "abc"; // ERR,非平凡POD類型不能編譯期確定內部行爲
}

2)constexpr表達式也可能變成變量

希望讀者看到這一節標題的時候不要崩潰,C++就是這麼難以捉摸。

沒錯,雖然constexpr已經是常量表達式了,但是用constexpr修飾變量的時候,它仍然是“定義變量”的語法,因此C++希望它能夠兼容只讀變量的情況。

當且僅當一種情況下,constexpr定義的變量會真的成爲變量,那就是這個變量被取址的時候:

void Demo() {
  constexpr int a = 5;
  const int *p = &a; // 會讓a退化爲const int類型
}

道理也很簡單,因爲只有變量才能取址。上面例子中,由於對a進行了取地址操作,因此,a不得不真正成爲一個變量,也就是變爲const int類型。

那另一個問題就出現了,如果說,我對一個常量表達式既取了地址,又用到編譯期語法中了怎麼辦?

template <int N>
struct Test {};


void Demo() {
  constexpr int a = 5;
  Test<a> t; // 用做常量
  const int *p = &a; // 用做變量
}

沒關係,編譯器會讓它在編譯期視爲常量去給那些編譯期語法(比如模板實例化)使用,之後,再把它用作變量寫到內存中。

換句話說,在編譯期,這裏的a相當於一個宏,所有的編譯期語法會用5替換a,Test< a >就變成了Test< 5>。之後,又會讓a成爲一個只讀變量寫到內存中,也就變成了const int a = 5;那麼const int *p = &a;自然就是合法的了。

5b6346a4929644c47eebad112108ff79.jpeg

就地構造

“就地構造”這個詞本身就很C++。很多程序員都能發現,到處糾結對象有沒有拷貝,糾結出參還是返回值的只有C++程序員。

無奈,C++確實沒法完全擺脫底層考慮,C++程序員也會更傾向於高性能代碼的編寫。當出現嵌套結構的時候,就會考慮複製問題了。舉個最簡單的例子,給一個vector進行push_back操作時,會發生一次複製:

struct Test {
  int a, b;
};


void Demo() {
  std::vector<Test> ve;
  ve.push_back(Test{1, 2}); // 用1,2構造臨時對象,再移動構造
}

原因就在於,push_back的原型是:

template <typename T>
void vector<T>::push_back(const T &);
template <typename T>
void vector<T>::push_back(T &&);

如果傳入左值,則會進行拷貝構造,傳入右值會移動構造。但是對於Test來說,無論深淺複製,都是相同的複製。這多構造一次Test臨時對象本身就是多餘的。

既然,我們已經有{1, 2}的構造參數了,能否想辦法跳過這一次臨時對象,而是直接在vector末尾的空間上進行構造呢?這就涉及了就地構造的問題。我們在前面“new和delete”的章節介紹過,“分配空間”和“構造對象”的步驟可以拆解開來做。首先對vector的buffer進行擴容(如果需要的話),確定了要放置新對象的空間以後,直接使用placement new進行就地構造。

比如針對Test的vector我們可以這樣寫:

template <>
void vector<Test>::emplace_back(int a, int b) {
  // 需要時擴容
  // new_ptr表示末尾爲新對象分配的空間
  new(new_ptr) Test{a, b};
}

STL中把容器的就地構造方法叫做emplace,原理就是通過傳遞構造參數,直接在對應位置就地構造。所以更加通用的方法應該是:

template <typename T, typename... Args>
void vector<T>::emplace_back(Args &&...args) {
  // new_ptr表示末尾爲新對象分配的空間
  new(new_ptr) T{std::forward<Args>(args)...};
}

1)嵌套就地構造

就地構造確實能在一定程度上解決多餘的對象複製問題,但如果是嵌套形式就實則沒辦法了,舉例來說:

struct Test {
  int a, b;
};


void Demo() {
  std::vector<std::tuple<int, Test>> ve;
  ve.emplace_back(1, Test{1, 2}); // tuple嵌套的Test沒法就地構造
}

也就是說,我們沒法在就地構造對象時對參數再就地構造。

這件事情放在map或者unordered_map上更加有趣,因爲這兩個容器的成員都是std::pair,所以對它進行emplace的時候,就地構造的是pair而不是內部的對象:

struct Test {
  int a, b;
};


void Demo() {
  std::map<int, Test> ma;
  ma.emplace(1, Test{1, 2}); // 這裏emplace的對象是pair<int, Test>
}

不過好在,map和unordered_map提供了try_emplace方法,可以在一定程度上解決這個問題,函數原型是:

template <typename K, typename V, typename... Args>
std::pair<iterator, bool> map<K, V>::try_emplace(const K &key, Args &&...args);

這裏把key和value拆開了,前者還是隻能通過複製的方式傳遞,但後者可以就地構造。(實際使用時,value更需要就地構造,一般來說key都是整數、字符串這些。)那麼我們可用它代替emplace:

void Demo() {
  std::map<int, Test> ma;
  ma.try_emplace(1, 1, 2); // 1, 2用於構造Test
}

但看這個函數名也能猜到,它是“不覆蓋邏輯”。也就是如果容器中已有對應的key,則不會覆蓋。返回值中第一項表示對應項迭代器(如果是新增,就返回新增這一條的迭代器,如果是已有key則放棄新增,並返回原項的迭代器),第二項表示是否成功新增(如果已有key會返回false)。

void Demo() {
  std::map<int, Test> ma {{1, Test{1, 2}}};
  auto [iter, is_insert] = ma.try_emplace(1, 7, 8);
  auto &current_test = iter->second;
  std::cout << current_test.a << ", " << current_test.b << std::endl; // 會打印1, 2
}

不過有一些場景利用try_emplace會很方便,比如處理多重key時使用map嵌套map的場景,如果用emplace要寫成:

void Demo() {
  std::map<int, std::map<int, std::string>> ma;
  // 例如想給key爲(1, 2)新增value爲"abc"的
  // 由於無法確定外層key爲1是否已經有了,所以要單獨判斷
  if (ma.count(1) == 0) {
    ma.emplace(1, std::map<int, std::string>{});
  }
  ma.at(1).emplace(1, "abc");
}

但是利用try_emplace就可以更取巧一些:

void Demo() {
  std::map<int, std::map<int, std::string>> ma;
  ma.try_emplace(1).first->second.try_emplace(1, "abc");
}

解釋一下,如果ma含有key爲1的項,就返回對應迭代器,如果沒有的話則會新增(由於沒指定後面的參數,所以會構造一個空map),並返回迭代器。迭代器在返回值的第一項,所以取first得到迭代器,迭代器指向的是map內部的pair,取second得到內部的map,再對其進行一次try_emplace插入內部的元素。

當然了,這麼做確實可讀性會下降很多,具體使用時還需要自行取捨。

6016ddd685df2a09c278d650886bf3c2.jpeg

模板的全特化

先跑個小題~,模板的「模」正確發音應該是「mú」,原本是工程上的術語,生產一種工件可能需要一種樣本,但它和實際生產出的工件可能並不相同。所以說,「模板」本身並不是實際的工件,但可以用於生產出工件。更通俗來說,可以理解成一個澆注用的殼,比如說是圓柱形狀,如果你往裏灌鐵水,那出來的就是鐵柱;如果你灌鋁水出來的就是鋁柱;如果你灌水泥,那出來的就是水泥柱……

所以C++中用“模板”這個詞特別貼切,它本身並不是實際代碼,而在實例化的時候纔會生成對應的代碼。

而模板又存在“特化”的問題,分爲“偏特化”和“全特化”。偏特化也就是部分特化,也就是半成品,本質上來說仍然屬於“模板”。但全特化就很特殊了,全特化的模板就已經不是模板了,而是真正的代碼了,因此這裏的行爲也會和模板有所不同,而更加接近普通代碼。

最簡單的例子就是,模板的聲明和實現一般都會寫在頭文件中(除非僅在某個源文件中使用)。這是由於模板是編譯期代碼,在編譯期會生成實際代碼,而“編譯”過程是單文件行爲,因此你必須保證每個獨立的源文件都能找到這段模板定義。(include頭文件本質就是文件內容的複製,所以還是相當於每個使用的源文件都獲取了一份模板定義)。而如果拆開則會在編譯期間找不到而報錯:

demo.h

template <typename T>
void f(T t);

demo.cpp

template <typename T>
void f(T t) {
// ...
}

main.cpp

#include "demo.h" // 這裏只獲得了聲明


int main() {
  f<int>(5); // ERR,鏈接報錯,因爲只有聲明而沒有實現
  return 0;
}

上例中,main.cpp包含了demo.h,因此獲得的是f函數的聲明。當main.cpp在編譯期間,是不會去關聯demo.cpp的,在主函數中調用了f<int>,因此會標記f<int>函數已經聲明。

而編譯demo.cpp的時候,由於f並沒有任何實例化,因此不會產生任何代碼。

此後鏈接main.cpp和demo.cpp,發現main.cpp中的f<int>沒有實現,因此鏈接階段報錯。

所以,我們纔要求模板的實現也要寫在頭文件中,也就是變成:

demo.h

// 聲明
template <typename T>
void f(T t);


// ...其他內容


// 定義
template <typename T>
void f(T t) {
}

main.cpp

#include "demo.h"


int main() {
  f<int>(5); // OK
  return 0;
}

由於實現也寫在了demo.h中,因此當主函數中調用了f<int>時,既會用模板f的聲明生成出f<int>的聲明,也會用模板f的實現生成出f<int>的實現。

但是對於全特化的模板,情況將完全不同。因爲全特化的模板已經不是模板了,而是一個確定的函數,編譯期不會再用它來生成代碼,因此,這時如果你把實現也寫在頭文件裏,就會出現重定義錯誤:

demo.h

template <typename T>
void f(T t) {}


// f<int>全特化
template <>
void f<int>(int t) {}

src1.cpp

#include "demo.h" // 這裏有一份f<int>的實現

main.cpp

#include "demo.h" // 這裏也有一份f<int>的實現


int main() {
  f<int>(a); // ERR, redefine f<int>
  return 0;
}

這時會報重定義錯誤,因爲f<int>的實現寫在了demo.h中,那麼src.cpp包含了一次,相當於實現了一次,然後main.cpp也包含了一次,相當於又實現了一次,所以報重定義錯誤。

因此,正確的做法是把全特化模板當做普通函數來對待,只能在源文件中定義一次:

demo.h

template <typename T>
void f(T t) {}


// 特化f<int>的聲明
template <>
void f<int>(int t);

demo.cpp

#include "demo.h"
// 特化f<int>的定義
template <>
void f<int>(int t) {}

src1.cpp

#include "demo.h" // 只得到了聲明,沒有重複實現

main.cpp

#include "demo.h" // 只得到了聲明,沒有重複實現


int main() {
  f<int>(5); // OK,全局只有一份實現
  return 0;
}

所以在使用模板特化的時候,一定要小心,如果是全特化的話,就要按照普通函數/類來對待,聲明和實現需要分開。

當然了,硬要把實現寫在頭文件裏也是可以的,只不過要用inline修飾,防止重定義。

demo.h
template <typename T>
void f(T t) {}


// 特化f<int>聲明
template <>
void f<int>(int t);


// 特化f<int>內聯定義
template <>
inline void f<int>(int t) {}

8abcf0bc980c3ccb8ed0646e4f441bd4.png

構造/析構函數調用虛函數

我們知道C++用來實現“多態”的語法主要是虛函數。當調用一個對象的虛函數時,會根據對象的實際類型來調用,而不是根據引用/指針的類型。

class Base {
 public:
  virtual void f() {std::cout << "Base::f" << std::endl;}
};


class Child1 : public Base {
 public:
  void f() override {std::cout << "Child1::f" << std::endl;}
};


class Child2 : public Base {
 public:
  void f() override {std::cout << "Child2::f" << std::endl;}
};


void Demo() {
  Base *obj1 = new Child1;
  Child2 ch;
  Base &obj2 = ch;
  Base obj3;


  obj1->f(); // Child1::f
  obj2.f(); // Child2::f
  obj3.f(); // Base::f
}

但有一種特殊情況,會讓多態性失效,請看下面例程:

class Base {
 public:
  Base() {f();} // 構造函數調用虛函數
  virtual void f() {std::cout << "Base::f" << std::endl;}
};


class Child : public Base {
 public:
  Child() {}
  void f() override {std::cout << "Child::f" << std::endl;}
};


void Demo() {
  Child ch; // Base::f
}

我們知道子類構造時需要先調用父類構造函數。這裏由於Child中沒有指定Base的構造函數,因此會調用無參的構造。在Base的無參構造函數中調用了虛函數f。照理說,我們是在構造Child的過程中調用了f,那麼應該調用的是Child的f,但實際調的是Base的f,也就是多態性失效了。

究其原因,我們就要知道C++構造的模式了。由於Child是Base的子類,因此會含有Base類的成員,並且構造時也要先構造。在構造Child的Base部分時,先初始化了虛函數表,由於此時還屬於Base的構造函數,因此虛函數表中指向的是Base::f。虛函數表初始化後開始構造Base的成員,示例中由於是空的所以跳過。再執行Base構造函數的函數體,函數體裏調用了f。以上都屬於Base的構造,完成後纔會繼續Child獨有部分的構造。首先會構造虛函數表,把f指向Child::f。然後是初始化成員,示例中爲空所以跳過。最後執行Child構造函數函數體,示例中是空的。

所以,我們看到,這裏調用f的時機,是在Base構造的過程中。f由於是虛函數,因此會通過虛函數表來訪問,但又因爲此時虛函數表裏指向的就是Base::f,所以會調用到Base類的f。

同理,如果在析構函數中調用虛函數的話,同樣會失去多態性。原則就是哪個類裏調用的,實際就會調用哪個類的實現

bfc0a4310a1daac9a63b6a82ef26a4c0.png

經典二義性問題

C++中存在3個非常經典的二義性問題,並且他們的默認含義都是反直覺的。

1)臨時對象傳參時的二義性

請看下面的代碼:

struct Test {};


struct Data {
 explicit Data(const Test &test);
};


void Demo() {
  Data data(Test()); // 這句是什麼含義?
}

上面這種類型的代碼確實有時會一不留神就寫出來。我們願意是想創建一個Data類型的對象叫做data,構造參數是一個Test類型,這裏我們直接創建了一個臨時對象作爲構造參數。

但如果你真的這樣寫的話,會得到一個warning,並且data這個對象並沒有創建成功。爲什麼呢?因爲編譯期把它誤以爲是函數聲明瞭。這裏首先需要了解一個語法糖:

void f(void d(int));
// 等價於
void f(void (*d)(int));

C++中允許參數爲“函數類型”,又因爲函數並不是一種存儲類型,因此這種語法會當做“函數指針類型”來處理。所以說當函數參數是一個函數的時候,本質上是讓傳一個函數指針進去。

與此同時,C++也支持了“函數取地址”和“解函數指針”的操作。函數取地址後仍然是函數指針,解函數指針後仍然是函數指針:

void f() {}


void Demo() {
  void (*p1)() = f; // 函數類型轉化爲函數指針(C語言只支持這種寫法)
  void (*p2)() = &f; // 函數類型取地址還是函數指針類型
  p2(); // 函數指針直接調用相當於函數調用
  (*p2)(); // 函數指針解指針後仍然是函數指針
  auto p3 = *p2; // 同上,p3仍然是void (*)()類型
  (*************p2)(); // 逐漸離譜,但確實是合法的
}

再回到一開始的例子,假如我們要聲明一個函數名爲data,返回值是Data類型,參數是一個函數類型,一個返回值爲Test,空參類型的函數。那麼就是:

Data data(Test());
// 或者是
Data data(Test (*)());

第一種寫法正好和我們剛纔想表示“定義Data類型的對象名爲data,參數是一個Test類型的臨時對象”給撞臉了。引發了二義性。

解決方法也很簡單,我們知道表示“值”的時候,套一層或者多層括號是不影響“值”的意義的:

// 下面都等價
a;
(a);
((a));

那麼表示“函數調用”時,傳值也是可以套多層括號的:

f(a);
f((a));
f(((a)));

但是當你表示函數聲明的時候,你就不能套多層括號了:

void f(int); // 函數聲明
void f((int)); // ERR,錯誤語法

所以,第一種解決方法就是,套一層括號,那麼就只能解釋爲“函數調用”而不是“函數聲明”了:

Data data((Test())); // 定義對象data,不會出現二義性

第二種方法就是不要用小括號表示構造參數,而是換成大括號:

Data data{Test{}}; // 大括號表示構造參數列表,不能表示函數類型

在要不就不要用臨時對象,改用普通變量:

Test t;
Data data{t};

2)模板參數嵌套時的二義性

當兩個模板參數套在一起的時候,兩個>會碰在一起:

std::vector<std::vector<int>> ve; // 這裏出現了一個>>

而這和參數中的右移運算給撞臉了:

std::array<int, 1 >> 5> arr; // 這裏也出現了一個>>

在C++11以前,>>會優先識別爲右移符號,因此對於模板嵌套,就必須加空格:

std::vector<std::vector<int> > ve; // 加空格避免歧義

但可能是因爲模板參數右移的情況遠遠少過模板嵌套的情況,因此在C++11開始,把這種默認情況改了過來,遇見>>會識別爲模板嵌套:

std::vector<std::vector<int>> ve; // OK

但相對的,如果要進行右移運算的話,就會識別錯誤,解決方法是加括號

std::array<int, 1 >> 5> arr; // ERR
std::array<int, (1 >> 5)> arr; // OK,要通過加小括號避免歧義

3)模板中類型定義和靜態變量二義性

直接上代碼:

template <typename T>
struct Test {
  void f() {
    T::abc *p;
  }
};


struct T1 {
  static int abc;
};


struct T2 {
  using abc = int;
};


void Demo() {
  Test<T1> t1;
  Test<T2> t2;
}

Test是一個模板類,裏面取了參數T的成員abc。對於T1的實例化來說,T1::abc是一個整型變量,所以T::abc *p相當於兩個變量相乘,*會理解爲“乘法”。

而對於T2來說,T2::abc是一個類型重命名,那麼T::abc *p相當於定義一個int類型的指針,*會理解爲指針類型。

所以,對於模板Test來說,由於T還沒有實例化,所以不能確定T::abc到底是靜態變量還是類型重命名。因此會出現二義性。

解決方式是用typename關鍵字,強制表名這裏T::abc是一個類型:

template <typename T>
struct Test {
  void f() {
    typename T::abc *p; // 一定表示指針定義
  }
};

typename關鍵字大家應該並不陌生,但一般都是在模板參數中見到的。其實在C++11以前,模板參數中表示“類型”參數的關鍵字是class,但用這個關鍵字會對人產生誤導,其實這裏不一定非要傳類類型,傳基本類型也是OK的,因此C++11的時候讓typename可以承擔這個責任,因爲它更能表示“類型名稱”這種含義。但其實在此之前typename僅僅是爲了解決上面二義性問題的。

另外值得說明的一點是,C++17以前,模板參數是模板的情況時仍然只能用class:

// 要求參數要傳一個模板類型,其含有兩個類型參數
// C++14及以前版本這裏必須用class
template <template <typename, typename> class Temp>
struct Test {}


template <typename T, typename R>
struct T1 {}


void Demo() {
  Test<T1>; // 模板參數是模板的情況實例化
}

C++17開始才允許這個class替換爲typename:

// C++17後可以用typename
template <template <typename, typename> typename Temp>
struct Test {}

b7bea8d68ea285c4ca9d5291540964a2.png

語言、STL、編譯器、編程規範

筆者認爲,C++跟一些新興語言最大的不同就在於將「語言」、「標準庫」、「編譯器」這三個概念劃分爲了三個領域。在前面章節提到的一系列所謂“缺陷”其實都跟這種領域劃分有非常大的關係。

舉例來說,處理字符串相關問題,但是使用std::string就已經可以避免踩非常多的坑了。它不會出現0值截斷問題;不會出現拷貝時緩衝區溢出問題;配合流使用時不會出現%s不安全的問題;傳參不必在意數組退化指針問題;不必擔心複製時的淺複製問題……

但問題就在於,std::string屬於STL的領域,它的出現並沒有改變C++本身,最直觀地來講,字符串常量"abc"並沒有映射到std::string類型,它仍然會按照C風格字符串來處理。它就有可能導致重載、導致模板參數識別不符合預期。除非我們將其轉換爲std::string。

所以說,雖然std::string解決了絕大對數原始字符串可能出現的問題,但它是在STL的維度來解決的,並不是在C++語言的維度來解決的。接下來我會詳細介紹這三種領域之間的關係,以及我個人的一些思考。

1)C++與STL的關係

雖說STL是“C++的標準庫”,但C++和STL的關係是不如C和C標準庫的關係的。主要的區別是:

C標準庫的實現基本是用匯編寫的,而STL是完全用C++寫的。

聽上去可能不足爲奇,但仔細想想這種差異可謂天壤之別。C庫用匯編實現,也就意味着OS要原生支持這種功能,不同架構下的彙編是不同的。比如說Intel芯片的Mac電腦,它自帶的C庫就要用x86彙編(準確來說是AMD64彙編)來實現,而M系列芯片的Mac電腦,它自帶的C庫就要用ARM彙編來實現。

用C語言開發OS的時候確實沒法使用標準庫,但同時,我們沒法做到僅用C語言來開發OS,它不可避免地要和彙編進行聯動。而在用C開發應用程序的時候,OS就會提供C標準庫的對應實現,也就是說在編譯C程序的時候,標準庫的內容是不用編譯的,一遍都是作爲靜態鏈接庫直接參與鏈接。(還有一些可能是動態鏈接庫,運行是調用,但這個就跟OS和架構有關了。)

但STL不同,STL我們可以輕鬆看到其源碼,它就是用C++來實現的。在C++工程編譯時,STL要全程參與編譯。

再說得籠統一點:你沒法用C語言實現C標準庫,但完全可以用C++實現STL,與此同時,如果你要用C++來實現STL的時候,你也不能沒有C標準庫。所以STL單純是一些功能、工具的封裝,它並沒有對語言本身進行任何擴展和改變。

在C++誕生的時候,並沒有所謂標準庫,那個時候的C++其實就是給C做了一些擴充,所以用的仍然是C的標準庫。只不過後來有位蘇聯的大神利用C++寫了一個工具庫,所以準確地來說,STL原本就只是個第三方庫,是跟C++語言本身沒什麼關係的,只不過後來語言標準協會把它納入了C++標準的一部分,讓它成爲了標準庫。

所以“容器”“迭代器”“內存分配器”等等這些概念都是STL領域的,並不跟C++語言強綁定。另一方面,到後來STL其實是一套規定的標準,比如說規定要實現哪些容器,這些容器裏應當有哪些功能。但其實實現方法是沒有規定的,也就是說不同的人可以有不同的實現方法,它們的性能問題、設計的側重點可能也不一樣。歷史上真實出現過某個版本的STL實現,由於設計缺陷導致求size時時間複雜度是O(n)的情況。

之前有讀者讀過我的文章後有發出質疑,類似於「如果你這麼擔心內存泄漏問題的話,爲什麼不用智能指針?」或者「如果你覺得C風格字符串存在各種問問題爲什麼不用string和string_view」這樣的問題。那麼這裏的問題點就在於,無論是string也好,還是智能指針也好,這些都是STL領域的,並不是C++語言本身領域的。所以一來,我希望讀者能夠明白STL提供這些工具是爲了解決哪些問題,爲什麼我們使用了STL的這個工具就不會踩坑,工具內部是怎麼避坑的;二來,給一些C++的新人解開疑惑,他們可能會奇怪,明明直接打一個雙引號就是字符串了,爲什麼還要用string或者string_view。

明明打一顆星就是指針了,爲什麼還要用shared_ptr、weak_ptr等等;三來,也是倡導大家儘可能使用STL提供的工具,而不是自行使用底層語法

我曾經有過一個疑問,就是說爲什麼C++不能在語言層面上支持STL。舉例來說,"abc"爲什麼不乾脆直接映射成std::string類型?而是非要通過隱式構造的方式。爲什麼不能直接引入類似於{k1:v1, k2:v2}的語法來映射std::map?而是非要通過嵌套構造的方式。後來我大概猜到了原因,其實就是爲了兼容性。設想,如果突然引入一種類型的強綁定,那麼現有代碼的行爲會發生很大的變化,大量的團隊將不敢升級到這個新標準。另一方面,有些特殊的項目其實是對STL不信任的,比如說內核開發,嵌入式開發。他們對性能要求很高,所以類似於內存的分配、釋放等等這些操作,都必須非常小心,都必須完全在自己的掌控之中。如果使用STL則不能保證內部操作完全符合預期,但與此同時又不想使用純C,因爲還希望能使用一些C++的特性(比如說引用、類封裝、函數重載等等)。那他們的選擇就是使用C++但禁用STL。一旦C++語法和STL強綁定的話,也會勸退這些團隊。

所以,這就是一個取捨問題,C語言保留着最基礎、最底層的功能。而需要快速迭代、屏蔽底層細節又不是特別在乎性能的項目則可以選擇更高級的語言。而C++的定位就是在他們之間搭一座橋,如果你是寫底層而會的C++,你也可以轉型上層軟件而不用學習新的語言,反之亦然。總之,C++定位就是全能,可上可下。但正猶如細胞分化一樣,越全能的細胞就越不專一,當你讓它去做一種比較專一的事情的時候,它可能就顯得臃腫了。但其實,C++提供龐大而複雜的功能後,我們完全可以根據情況使用它的一個子集,完成自己的需求就好,而不用過分糾結C++本身的複雜性。

2)編譯器優化

編譯器的優化又屬於另一個維度的事情了。所謂編譯器的優化就是指,從代碼字面上脫離出來,理解其含義,然後優化成更高性能的方式。

舉個前面章節提到過的例子來說:

struct Test {
  int a, b;
};
Test f() {
  Test t {1, 2};
  return t;
};


void Demo() {
  Test t = f();
}

如果按照語言本意來說,這裏就是會發生2次複製,f內部的局部變量複製給臨時區域(拷貝構造),再臨時區域複製給Demo中的變量(移動構造)。

但是編譯器就可以對這種情況進行優化,它會直接拿着Demo中的t進到f中構造,也就是說,編譯器“理解”了這段代碼的含義,然後改寫成了更高性能的方式:

struct Test {
  int a, b;
};
void f(Test *t) {
  new(t) Test {1, 2};
}


void Demo() {
  Test t = *(Test *)operator new(sizeof(Test));
  f(&t);
}

這也就是編譯器的RVO(Return Value Optimization,返回值優化)。

當然,編譯器不止這一種優化,還會有很多優化,對於gcc來說,有3種級別的優化編譯選項,-O1、-O2、-O3。會對很多情況進行優化。這麼做的意義也很顯而易見,就是說讓程序員可以儘可能屏蔽這些底層語法對程序行爲(或者說性能)的影響,而可以更多聚焦在邏輯含義上。

但筆者希望傳達的意思是,“語言”、“庫”、“編譯器”是不同維度的事情。針對同一個語言“缺陷”,庫可能有庫的解決方法,編譯器有編譯器的優化方案,但是不同的庫實現可能傾向性不同,不同的編譯器優化程度也不同。

3)編程規範

筆者認爲,編程規範主要是要考慮項目或者團隊的實際情況,從而制定的一種標準。除了一些格式、代碼風格上的統一以外,其他任意一條規範都一定有其擔憂道理。可能是團隊以前在這個點上踩過坑,也可能是以團隊的平均水平來說很容易踩這個坑,而同時又有其他避坑的方式,因此乾脆規定不許怎麼怎麼樣,必須怎麼怎麼樣。對於個人來說,有時可能確實難以理解和接受,甚至覺得有些束手束腳。但畢竟人心都向自由,但對於團隊來說,要找到的是讓團隊更加高效、不易出錯的方式。

有人說小白都不會質疑規則,大佬纔會看得出規則中有哪些不合理。從某種角度來說,筆者認爲這種說法是對的,但還應該補充一句“真正的大佬則是能看得出這裏爲什麼不合理”。如果你能看得出制定這條規則的人在擔心些什麼,爲什麼要做這樣約束的時候,那我相信你的視野會更寬,心也會更寬。

因此,如果你認爲你所在團隊的編程規範中槽點很多,那筆者認爲,最好的方式就是提升團隊整體的水平,就拿C++來說,如果多數人都能意識到這個位置有坑,應當注意些什麼,並且都可以很好的處理這部分問題的話,那我相信,規範的制定者並不會再去出於擔心,而強行對大家進行束縛了。

4)思考

儘管C++語言由於歷史原因留下不少缺陷,但隨着版本迭代,STL和編譯器都在做着非常多的優化,所以其實對於程序員來說,日常開發真的不用太在意太糾結這些細枝末節的東西,把更多底層的事情交給底層的工具來完成,何苦要勉強自己?

但筆者覺得,這個道理就像“我會自己做飯,但我可以不用做(有人給我做)”,和“我不會做飯,只能指望別人給我做”是完全不同的兩種狀態。儘管工具可以提供優化,但“我很清楚底層原理,瞭解他們是如何優化的,然後我可以屏蔽很多底層的東西,使用方便的工具來提升我的工作效率”和“我根本不知道底層原理,只能沒心沒肺地用工具”也是不同的狀態。筆者希望把這些告訴讀者,這樣即便工具出現一些問題的時候,我們也能有一個定位思路,而不會束手無策。

61c0c3168eb20cc73b495802ef39f8bb.png

C++11和C++20

前面章節中筆者提到,C++的迭代過程中,主要是通過STL提供更方便的工具來解決原始缺陷的。但也有例外,C++11和C++20就是非常具有代表性的2次更新。

C++11引入的「自動類型推導」「右值引用」「移動語義」「lambda表達式」「強枚舉」「基於範圍的for循環」「變參模板」「常量表達式」等等的特性,其實都是對C++語言的一種擴充。C++11推出後,立刻讓人感覺C++不再是C的感覺了。

只不過,兼容性是C++更多用於考慮的,一方面是出於對老項目遷移的門檻考慮,另一方面是對編譯器運行方式的考慮,它並沒有做過多的“改正”,而是以“修補”爲主。舉例來說,雖然引入了lambda表達式,但並沒有用它代替函數指針,代替仿函數類型。再比如雖然引入了常量表達式,但仍然保留了const關鍵字的性質,甚至還做了向下兼容(比如前面章節提到的給常量表達式取地址後,會變爲只讀變量)。

之後的C++14、C++17更多的是在C++11的基礎上進行了完善,因爲你能夠感覺到,這兩個標準雖然提供了新的內容,但從根本上來說,它仍然是C++11的理念。比如C++14可以用auto推導函數返回值,但它並沒有改變“函數返回值必須確定”這一理念,所以返回多種類型的時候只會以第一個爲準。再比如C++17中引入了「摺疊表達式」以及由「合併using」所誕生的很多奇技淫巧,讓模板元編程更上一層樓,但它並沒有解決模板元編程的本質是利用「SFINAE」,所以如果匹配失敗,編譯器報錯會充斥非常複雜的SFINAE過程,導致開發者沒法快速獲取核心信息。

在這裏舉個小例子,假如我想判斷某個類中是否含有名爲Find、空參且返回值爲int的方法,如果有就可以傳入Process函數中,那麼用C++17的方法應該這樣寫:

template <typename T, typename R = void>
struct HasFind : std::false_value {};


template <typename T>
struct HasFind<T, typename = std::void_t<decltype(&T::Find)>>
: std::disjunction<
     std::is_same<decltype(&T::Find), int (T::*)(void)>,
      std::is_same<decltype(&T::Find), int (T::*)(void) const>,
      std::is_same<decltype(&T::Find), int (T::*)(void) noexcept>,
      std::is_same<decltype(&T::Find), int (T::*)(void) const noexcept>
    > {};


template <typename T>
auto Process(const T &t) -> std::enable_if_t<HasFind<std::remove_reference_t<T>>::value, void> {
}

首先要想着把T::Find摳出來,對它進行decltype,如果這個操作是合法的,就說明T中含有這個成員,因此就能利用SFINAE原則匹配到下面HasFind的特例,否則匹配通用模板(也就是false_value了)。

其次,針對含有成員Find的類型再繼續進行其類型判斷,讓它必須是一個返回值爲int且空參的非靜態成員函數,此時還不得不考慮const和noexcept的問題。

最後再利用std::enable_if進行判斷類型是否匹配,在其內部其實仍然利用的是SFINAE原則,對於匹配不上的類型通過“只聲明,不定義”的方式讓它不能通過編譯。

template <bool conj, typename T>
struct enable_if {}; // 沒有實現type,所以取type會編譯不通過


template <typename T>
struct enable_if<true, T> {
  using type = T;
}; // 當第一個參數是true的時候才能編譯通過,並且把T傳遞出來

用上例是想表明,儘管C++17提供了方便的工具,但依然逃不過“利用SFINAE匹配原則”來實現功能的理念,這一點就是從C++11繼承來的。

而C++20的誕生又是一次顛覆性的,它引入的「concept」則是徹徹底底改變了這一行爲,讓類似於“限定模板類型”的工作不再依靠SFINAE匹配。比如上面用於判斷Find方法的功能,在C++20時可以寫成這樣:

template <typename T>
requires requires (T t) {
    {t.Find()} -> std::same_as<int>;
}
void Process(const T &t) {
    std::cout << 123 << std::endl;
}

其中的類型約束條件就可以定義成一個concept,所以還可以改寫成這樣:

template <typename T>
concept HasFind = requires (T t) {
    {t.Find()} -> std::same_as<int>;
};


template <typename T>
requires HasFind<T>
void Process(const T &t) {
    std::cout << 123 << std::endl;
}

可以看出,這樣就是徹底在“語言”層面解決“模板類型限制”的問題。這樣一來語法表達更加清晰,報錯信息也更加純粹(不會出現一大堆SFINAE過程)。

因此我們說,C++20是C++的又一次顛覆,就是在於C++20不再是一味地通過擴充STL的功能來“找補”,而是從語言維度出發,真正地“進化”C++語言。

除了concept外,C++20還提供了「module」概念,用於優化傳承已久的頭文件編譯方式,這同樣也是從語言的層面來解決問題。

由於C++20在業內並沒有普及,因此本文主要介紹C++17下的C++缺陷和思考,並且以“思考”和“底層原理”爲主,因此不再過多介紹語言特性。如果有讀者希望瞭解各版本C++新特性,以及C++20提出的新理念,那麼可以期待筆者後續將會編寫的其他系列的文章。

一些方便的工具

【說明:其實我本來沒想寫這一章,因爲主要本文以“思考”和“底層原理”爲主,但鑑於讀者們強烈要求,最終決定在截稿前補充這一章,介紹一些用於避坑的工具,還有一些觸發缺陷的代替寫法,但僅做非常的簡單介紹,有詳細需求的讀者可以期待我其他系列文章。

1)智能指針

智能指針是一個用來代替new和delete的方案,本質是一個引用計數器。shared_ptr會在最後一個指向對象的指針釋放時析構對象。

void Demo() {
  auto p = std::make_shared<Test>(1, 2);
  {
    auto p2 = p; // 引用計數加1
  } // p2釋放,引用計數減1
} // p釋放,p目前是最後一個指針了,會析構對象

unique_ptr就是獨立持有,只支持轉交,不支持複製:

void Demo() {
  auto p = std::make_unique<Test>(1, 2);
  auto p2 = p; // ERR,unique指針不能複製
  auto p3 = std::move(p); // OK,可以轉交,轉交後p變爲nullptr,不再控制對象
}

weak_ptr主要解決循環引用問題:

struct Test2;
struct Test1 {
  std::shared_ptr<Test2> ptr;
};


struct Test2 {
  std::shared_ptr<Test1> ptr;
};


void Demo() {
  auto p1 = std::make_shared<Test1>();
  auto p2 = std::make_shared<Test2>();
  p1->ptr = p2;
  p2->ptr = p1;
}; // p1和p2釋放了,但是Test1對象內部的ptr和Test2對象內部的ptr還在互相引用,所以這兩個對象都不能被釋放

因此要將其中一個改爲weak_ptr,它不會對引用計數產生作用:

struct Test2;
struct Test1 {
  std::shared_ptr<Test2> ptr;
};


struct Test2 {
  std::weak_ptr<Test1> ptr;
};


void Demo() {
  auto p1 = std::make_shared<Test1>();
  auto p2 = std::make_shared<Test2>();
  p1->ptr = p2;
  p2->ptr = p1;
}; // 可以正常釋放

2)string_view

使用string主要遇到的問題是複製,尤其是獲取子串的時候,一定會發生複製:

std::string str = "abc123";
auto substr = str.substr(2); // 生成新串

另外就是string是非平凡的,因此C++17引入了string_view,用於獲取字符串的一個切片,它是平凡的,並且不會發生文本的複製:

std::string_view sv = "abc123"; // 數據會保留在全局區,string_view更像是一組指針
auto substr = sv.substr(2); // 新的視圖不會複製原本的數據

3)tuple

tuple可以理解爲元組,或者是成員匿名的C風格結構體。可以比較方便地綁定一組數據。

std::tuple tu(1, 5.0, std::string("abc"));
// 獲取內部成員
auto &inner = std::get<1>(tu);
// 全量解開
int m1;
double m2;
std::string m3;
std::tie(m1, m2, m3) = tu;
// 結構化綁定
auto [d1, d2, d3] = tu;

用做函數返回值也可以間接做到“返回多值”的作用:

using err_t = std::tuple<int, std::string>;


err_t Process() {
  if (err) {
    return {err_code, "err msg"};
  }
  return {0, ""};
};

這裏比較期待的是能用原生語法支持,比如說像Swift中,括號表示元組:

// 定義元組
let tup1 = (1, 4.5, "abc")
var tup2: (Int, String)
tup2.1 = "123"
let a = tup2.1


// 函數返回元組
func Process() -> (Int, String) {
  return (0, "")
}

4)optional

optional用於表示“可選”量,內含“存在”語義,不用單獨選一個量來表示空:

void Demo() {
  std::optional<int> oi; // 定義
  oi = 5; // 賦值
  oi.emplace(8); // 賦值
  oi.reset(); // 置空
  if (io.has_value()) { // 判斷有無
    int val = oi.value(); // 獲取內部值
  }
}

還是跟Swift比較一下,因爲Swift原生支持可選類型,語法非常整潔:

var oi : Int? // 定義可選Int類型
oi = 5 // 賦值
oi = nil // 置空
if (oi == nil) {
  let val = oi! // 解包
}


class Test {
  func f() -> Int {}
}
var obj: Test!
let i = obj?.f() // i是可選Int型,如果obj爲空則返回nil,否則解包後調用f函數
let obj2 = obj ?? Test() // obj2是Test類型,如果obj爲空則返回新Test對象,否則返回obj的解包

所以同樣期待可選類型能夠被原生語法支持。

總結與感悟

1)與C++的初見

想先聊聊筆者個人的經歷,當年我上大學的時候一心想做iOS方向,所以我的啓蒙語言是OC。曾經的我還用OC去批判過C++的不合理。

後來我想做一個小型的手遊,要用到cocos2d遊戲引擎,cocos2d原本就是OC寫的,但由於OC僅僅能用在iOS上,不能移植到Android,因此國內幾乎找不到OC版cocos2d的任何資料。唯一可用的就是官方文檔,但官方文檔的缺點就是,它是一個類似於字典的資料,你首先要知道你要查什麼,才能上去查。但是對於一個新手來說,更需要的是一個嚮導,告訴你怎麼上手,怎麼寫個hello world,有哪些基礎組件分別怎麼用,展示幾個demo這種的資料。但OC版的恰好沒有,有入門資料的只有cocos2d-x(C++移植版)、cocos2d-js和cocos2d-lua。其中C++版的資料最多,於是我當時就只能讀C++版的資料。

但早期版本的cocos2d-x屬於OC向C++的移植版,命名、設計理念等都是跟OC保持一致的,所以那時候你讀cocos2d-x的資料,然後再去做OC版原生cocos2d的開發是沒什麼問題的。但我當年非常不趕巧,我正好趕上那一版的cocos2d-x做C++化的改造。比如引入命名空間,把CCLayer變成了cocos2d::layer;比如做STL移植,把CCString遷移成std::string,把CCMap遷移成std::map;再比如設計方式上,把原本OC的init函數改成了C++構造函數,selector改成了std::function,諸多仿函數工具都轉換爲了lambda展現。所以那一版本的cocos2d-x我根本讀不懂,要想讀懂,就得先學會C++。後來考慮到反正C++和OC是可以混編的,乾脆直接用C++版的cocos2d來做開發算了。我就這樣糊里糊塗地學起了C++。

但這種孽緣一旦開始,就很難再停下來了。隨着我對C++的不斷深入學習,我逐漸發現C++很有趣,而且正是因爲它的複雜,讓我有了持續學下去的動力。每當我以爲我差不多征服了C++的時候,我就總能再發現一些我沒見過的語法、沒踩過的坑,然後就會促使我繼續深入研究它。

2)一段優越感極強的階段

我在上一家公司曾經做過一段時間的交換機嵌入式開發,原本那就是純C的開發(而且還是C89標準),後來公司全面普及編程能力,成立了一個先鋒隊,嘗試向C++轉型。我當時參與並且主導了其中一個領域,把C89改造成C++14。

那時的一段時間,我對“自己會使用C++”這件事有着非常強的優越感,而且,時不時會炫耀自己掌握的C++的奇技淫巧

。而且那段時間我掛在嘴邊最多的一句話就是“不是這玩意不合理,是你不會用! ”。那個時候根本不想承認C++存在缺陷,或者哪裏設計不合理。在我心目中,C++就是最合理的,世界上最好的編程語言。其他人覺得有問題無非就是他沒有掌握,而自己掌握了其他人覺得複雜的事情,就不得不產生了非常強的優越感。

所以我曾經覺得C++就是我的信仰,只有C++程序員纔是真正的程序員,你們其他語言的懂指針嗎?懂模板嗎?看到那一大串模板套模板的時候你不暈菜嗎?哈哈!我不僅能看懂,我還能自己手擼type_traits,了不起吧?

所以那個時期,其實是自己給自己設置了一道屏障,讓自己不再去接觸其他領域的內容,得意洋洋地滿足於一個狹窄的領域中。可能人就是這樣,會有一段新鮮時期,過後就是一段浮躁期,但最後還是會沉下來,進入冷靜期。而到了冷靜期,你又會有非常不同的視野。

3)冷靜期後

我逐漸發現,身邊很多同學、朋友都“叛逃”了C++,轉向了其他的(比如說Go),或許確實是因爲C++的複雜造成了勸退,但我覺得,需要思考一下,爲什麼會這樣。

他們很多人都說Go是“下一個C++”,我原本並不認同,我認爲C++永遠都會作爲一個長老的形象存在,其他那些“年輕人(新語言)”還沒有經歷時間的打磨,所以不以爲然。但後來我慢慢發現,這話雖然不全對,但在一些情況下是有道理的。比如互聯網公司與傳統軟件公司不同,更多的項目都是沒有特別久的分析和設計時間,所以要求快速迭代。但C++其實並不是特別適合這種場景,儘管語言只是語言,設計纔是關鍵,但語言也是一種工具,也有更合適的場景。

而對於Go來說,似乎更適合這種微服務的領域,我就是開發一個領域內的功能,然後對外一共一個rpc接口。那其實這種模式下,我似乎並不需要太多的OOP設計,也不需要過分考慮比如一個字符串複製所帶來的性能損耗。但如果使用了C++,你不得不去考慮複製問題、平凡析構問題、內存泄漏問題等等的事情,我們能專心投在覈心領域的精力就會分散。

所以之後的一段時間我學習了一些其他的語言,尤其是Go語言,我當時看的那本Go語言的資料,滿篇都在有意無意地跟C++進行比較,有的時候還用C++代碼來解釋Go的語言現象。那個時候我就思考,Go的這種設計到底是爲了什麼?它比C++強在哪裏?又弱在哪裏?

其實結論也是很簡單的,就是說,C++是一種全能語言,而針對於某個更專精的領域,把這部分的功能加強,受影響的缺陷減弱或消除,然後去創造一個新的語言,更加適合這種場景的語言,那自然優勢就是在這種場景下更加高效便捷。缺點也是顯而易見的,換個領域它的特長就發揮不出來了。說通俗一點就是,C++能寫OS、能寫後端、還能寫前端(Qt瞭解一下!),寫後臺可能拼不過Go,但Go你就寫不了OS,寫不了前端。所以這就是一個「通用」和「專精」的問題。

4)總結

曾經有很多朋友問過我,C++適不適合入門?C++適不適合幹活?我學C++跟我學java哪個更賺錢啊?筆者持有這樣的觀點:C++並不是最適合生產的語言,但C++一定是最值得學習的語言。如果說你單純就是想幹活,享受產出的快樂,那我不建議你學C++,因爲太容易勸退,找一些新語言,語法簡單清晰容易上手,自然幹活效率會高很多;但如果你希望更多地理解編程語言,全面瞭解一些自底層到上層的原理和進程,希望享受研究和開悟的快樂,那非C++莫屬了。掌握了C++再去看其他語言,相信你一定會有不同的見解的。

所以到現在這個時間點,C++仍然還是我的信仰。我認爲C++將會在將來很長一段時間存在,並且以一個長老的身份發揮其在業界的作用和價值,但同時也會有越來越多新語言的誕生,他們在自己適合的地方發揮着不一樣的光彩。 我也不再會否認C++的確有設計不合理的地方,不會否認其存在不擅長的領域,也不會再去鄙視那些吐槽C++複雜的人。當然,對於每個開發者來說,都不該拒絕涉足其他的領域。只有不斷學習比較,不斷總結沉澱,才能持續進步。

公衆號後臺回覆C++避坑 ,獲得更多相關技術精品。

騰訊工程師技術乾貨直達:

1、H5開屏從龜速到閃電,企微是如何做到的

2、只用2小時,開發足球射門遊戲

3、閏秒終於要取消了!一文詳解其來源及影響

4、發佈變更又快又穩?騰訊運維工程師經驗首發

image.png

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章