每個C++開發者都應該使用的十個C++11特性

const_cast發表於2013-05-03

作者 Marius Bancila, 2013年4月2日

這篇文章討論了一系列所有開發者都應該學習和使用的C++11特性,在新的C++標準中,語言和標準庫都加入了很多新屬性,這篇文章只會介紹一些皮毛,然而,我相信有一些特徵用法應該會成為C++開發者的日常用法之一。你也許已經找到很多類似介紹C++11標準特徵的文章,這篇文章可以看成是那些常用特徵描述的一個集合。

目錄:

  • auto關鍵字
  • nullptr關鍵字
  • 基於區間的迴圈
  • Override和final
  • 強型別列舉
  • 智慧指標
  • Lambdas表示式
  • 非成員begin()和end()
  • static_assert巨集和型別萃取器
  • 移動語義

auto關鍵字

在C++11標準之前,auto關鍵字就被用來標識臨時變數語義,在新的標準中,它的目的變成了另外兩種用途。auto現在是一種型別佔位符,它會告訴編譯器,應該從初始化式中推斷出變數的實際型別。當你想在不同的作用域中(例如,名稱空間、函式內、for迴圈中中的初始化式)宣告變數的時候,auto可以在這些場合使用。

auto i = 42;        // i is an int
auto l = 42LL;      // l is an long long
auto p = new foo(); // p is a foo*

使用auto經常意味著較少的程式碼量(除非你需要的型別是int這種只有一個單詞的)。當你想要遍歷STL容器中元素的時候,想一想你會怎麼寫迭代器程式碼,老式的方法是用很多typedef來做,而auto則會大大簡化這個過程。

std::map<std::string, std::vector<int>> map;
for(auto it = begin(map); it != end(map); ++it) 
{
}

你應該注意到,auto並不能作為函式的返回型別,但是你能用auto去代替函式的返回型別,當然,在這種情況下,函式必須有返回值才可以。auto不會告訴編譯器去推斷返回值的實際型別,它會通知編譯器在函式的末段去尋找返回值型別。在下面的那個例子中,函式返回值的構成是由T1型別和T2型別的值,經過+操作符之後決定的。

template <typename T1, typename T2>
auto compose(T1 t1, T2 t2) -> decltype(t1 + t2)
{
   return t1+t2;
}
auto v = compose(2, 3.14); // v's type is double

nullptr關鍵字

0曾經是空指標的值,這種方式有一些弊端,因為它可以被隱式轉換成整型變數。nullptr關鍵字代表值型別std::nullptr_t,在語義上可以被理解為空指標。nullptr可被隱式轉換成任何型別的空指標,以及成員函式指標和成員變數指標,而且也可以轉換為bool(值為false),但是隱式轉換到整型變數的情況不再存在了。

void foo(int* p) {}

void bar(std::shared_ptr<int> p) {}

int* p1 = NULL;
int* p2 = nullptr;   
if(p1 == p2)
{
}

foo(nullptr);
bar(nullptr);

bool f = nullptr;
int i = nullptr; // error: A native nullptr can only be converted to bool or, using reinterpret_cast, to an integral type

為了向下相容,0仍可作為空指標的值來使用。

基於區間的迴圈

C++11加強了for語句的功能,以更好的支援用於遍歷集合的“foreach”正規化。在新的形式中,使用者可以使用for去迭代遍歷C風格的陣列、初始化列表,以及所有非成員begin()和end被過載的容器。

當你僅僅想獲取集合/陣列中的元素來做一些事情,而不關注索引值、迭代器或者元素本身的時候,這種for的形式非常有用。

std::map<std::string, std::vector<int>> map;
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
map["one"] = v;

for(const auto& kvp : map) 
{
  std::cout << kvp.first << std::endl;

  for(auto v : kvp.second)
  {
     std::cout << v << std::endl;
  }
}

int arr[] = {1,2,3,4,5};
for(int& e : arr) 
{
  e = e*e;
}

Override和final

我經常會發現虛擬函式在C++中會引起很多問題,因為沒有一個強制的機制來標識虛擬函式在派生類中被重寫了。virtual關鍵字並不是強制性的,這給程式碼的閱讀增加了一些困難,因為你可能不得不去看繼承關係的最頂層以確認這個方法是不是虛方法。我自己經常鼓勵開發者在派生類中使用virtual關鍵字,我自己也是這麼做的,這可以讓程式碼更易讀。然而,有一些不明顯的錯誤仍然會出現,下面這段程式碼就是個例子。

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

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

D::f本應該重寫B::f,但是這兩個函式的簽名並不相同,一個引數是short,另一個則是int,因此,B::f僅僅是另外一個和D::f命名相同的函式,是過載而不是重寫。你有可能會通過B型別的指標呼叫f(),並且期盼輸出D::f的結果,但是列印出來的結果卻是B::f。

這裡還有另外一個不明顯的錯誤:引數是相同的,但是在基類中的函式是const成員函式,而在派生類中則不是。

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

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

又一次,這兩個函式的關係是過載而非重寫,因此,如果你想通過B型別的指標來呼叫f(),程式會列印出B::f,而不是D::f。

幸運的是,有一種方法可以來描述你的意圖,兩個新的、專門的識別符號(不是關鍵字)新增進了C++11中:override,可以指定在基類中的虛擬函式應該被重寫;final,可以用來指定派生類中的函式不會重寫基類中的虛擬函式。第一個例子會變成:

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

class D : public B
{
public:
   virtual void f(int) override {std::cout << "D::f" << std::endl;}
};

這段程式碼會觸發一個編譯錯誤(如果你使用override識別符號嘗試第二個例子,也會得到相同的錯誤。):

'D::f': 有override識別符號的函式並沒有重寫任何基類函式

另一方面,如果你想要一個函式永遠不能被重寫(順著繼承層次往下都不能被重寫),你可以把該函式標識為final,在基類中和派生類中都可以這麼做。如果實在派生類中,你可以同時使用override和final識別符號。

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

class D : public B
{
public:
   virtual void f(int) override final {std::cout << "D::f" << std::endl;}
};

class F : public D
{
public:
   virtual void f(int) override {std::cout << "F::f" << std::endl;}
};

用'final'宣告的函式不能被'F::f'重寫。

強型別列舉

“傳統”的C++列舉型別有一些缺點:它會在一個程式碼區間中丟擲列舉型別成員(如果在相同的程式碼域中的兩個列舉型別具有相同名字的列舉成員,這會導致命名衝突),它們會被隱式轉換為整型,並且不可以指定列舉的底層資料型別。

通過引入一種新的列舉型別,這些問題在C++11中被解決了,這種新的列舉型別叫做強型別列舉。這種型別用enum class關鍵字來標識,它永遠不會在程式碼域中丟擲列舉成員,也不會隱式的轉換為整形,同時還可以具有使用者指定的底層型別(這個特徵也被加入了傳統列舉型別中)。

enum class Options {None, One, All};
Options o = Options::All;

智慧指標

有大量的文章介紹過智慧指標,因此,我僅僅想提一提智慧指標的引用計數和記憶體自動釋放相關的東西:

  • unique_ptr:當一塊記憶體的所有權並不是共享的時候(它並不具有拷貝建構函式),可以使用,但是,它可以被轉換為另外一個unique_ptr(具有移動建構函式)。

  • shared_ptr:當一塊記憶體的所有權可以被共享的時候,可以使用(這就是為什麼它叫這個名)。

  • weak_ptr:具有一個shared_ptr管理的指向一個實體物件的引用,但是並沒有做任何引用計數的工作,它被用來打破迴圈引用關係(想象一個關係樹,父節點擁有指向子節點的引用(shared_ptr),但是子節點也必須持有指向父節點的引用;如果第二個引用也是一個獨立的引用,一個迴圈就產生了,這會導致任何物件都永遠無法釋放)。

換句話說,auto_ptr已經過時了,應該不再被使用了。

什麼時候該使用unique_ptr,什麼時候該使用shared_ptr,取決於程式對記憶體所有權的需求,我推薦你讀一讀這裡的討論

下面第一個例子演示了unique_ptr的用法,如果你想要把物件的控制權轉交給另一個unique_ptr,請使用std::move(我將會在最後一段討論這個函式)。在控制權交接後,讓出控制權的智慧指標會變成null,如果呼叫get(),會返回nullptr。

void foo(int* p)
{
   std::cout << *p << std::endl;
}
std::unique_ptr<int> p1(new int(42));
std::unique_ptr<int> p2 = std::move(p1); // transfer ownership

if(p1)
  foo(p1.get());

(*p2)++;

if(p2)
  foo(p2.get());

第二個例子演示了shared_ptr的用法。儘管語義不同,因為所有權是共享的,但用法都差不多。

void foo(int* p)
{
}
void bar(std::shared_ptr<int> p)
{
   ++(*p);
}
std::shared_ptr<int> p1(new int(42));
std::shared_ptr<int> p2 = p1;

bar(p1);   
foo(p2.get());

第一個宣告等價於這個。

auto p3 = std::make_shared<int>(42);

make_shared是一個非成員函式,具有給共享物件分配記憶體,並且只分配一次記憶體的優點,和顯式通過建構函式初始化的shared_ptr相比較,後者需要至少兩次分配記憶體。這些額外的開銷有可能會導致記憶體溢位的問題,在下一個例子中,如果seed()丟擲一個異常,則表示發生了記憶體溢位。

void foo(std::shared_ptr<int> p, int init)
{
   *p = init;
}
foo(std::shared_ptr<int>(new int(42)), seed());

如果使用make_shared,則可以避開類似問題。第三個例子展示了weak_ptr的用法,注意,你必須通過呼叫lock()來獲取shared_ptr中指向物件的引用,以此來訪問物件。

auto p = std::make_shared<int>(42);
std::weak_ptr<int> wp = p;

{
  auto sp = wp.lock();
  std::cout << *sp << std::endl;
}

p.reset();

if(wp.expired())
  std::cout << "expired" << std::endl;

如果你試圖在一個已經過期的weak_ptr上呼叫lock(被弱引用的物件已經被釋放了),你會得到一個空的shared_ptr。

Lambdas表示式

匿名的方法,也叫做lambda表示式,被加進了C++11標準裡,並且立刻得到了開發者們的重視。這是一個從函式式語言中借鑑來的,非常強大的特徵,它讓一些其他的特徵和強大的庫得以實現。在任何函式物件、函式、std::function中出現的地方,你都可以用lambda表示式,你可以在這裡閱讀一下lambda的語法。

std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);

std::for_each(std::begin(v), std::end(v), [](int n) {std::cout << n << std::endl;});

auto is_odd = [](int n) {return n%2==1;};
auto pos = std::find_if(std::begin(v), std::end(v), is_odd);
if(pos != std::end(v))
  std::cout << *pos << std::endl;

有一點複雜的是遞迴lambda表示式。想象一個代表斐波那契函式的lambda表示式,如果你試圖用auto來寫這個函式,你會得到編譯錯誤:

auto fib = [&fib](int n) {return n < 2 ? 1 : fib(n-1) + fib(n-2);};

error C3533: 'auto &': a parameter cannot have a type that contains 'auto'
error C3531: 'fib': a symbol whose type contains 'auto' must have an initializer
error C3536: 'fib': cannot be used before it is initialized
error C2064: term does not evaluate to a function taking 1 arguments

這個問題是由於auto會根據初始化式來推斷物件型別,而初始化式卻包含了一個引用自己的表示式,因此,仍然需要知道它的型別,這是一個迴圈問題。為了解決這個問題,必須打破這個無限迴圈,顯式的用std::function來指定函式型別。

std::function<int(int)> lfib = [&lfib](int n) {return n < 2 ? 1 : lfib(n-1) + lfib(n-2);};

非成員begin()和end()

你也許已經注意到了,我在上面的例子中已經使用了非成員begin()和end()函式,這些是新加到STL中的東西,提升了語言的標準性和一致性,也使更多的泛型程式設計變成了可能,它們和所有的STL容器都是相容的,但卻不僅僅是簡單的過載,因此你可以隨意擴充套件begin()和end(),以便相容任何型別,針對C型別陣列的過載也一樣是支援的。

讓我們舉一個前面寫過的例子,在這個例子中,我試圖列印輸出一個vector,並且找到它的第一個奇數值的元素。如果std::vector用C風格陣列來代替的話,程式碼可能會像如下這樣:

int arr[] = {1,2,3};
std::for_each(&arr[0], &arr[0]+sizeof(arr)/sizeof(arr[0]), [](int n) {std::cout << n << std::endl;});

auto is_odd = [](int n) {return n%2==1;};
auto begin = &arr[0];
auto end = &arr[0]+sizeof(arr)/sizeof(arr[0]);
auto pos = std::find_if(begin, end, is_odd);
if(pos != end)
  std::cout << *pos << std::endl;

如果你使用非成員begin()和end(),程式碼可以這樣寫:

int arr[] = {1,2,3};
std::for_each(std::begin(arr), std::end(arr), [](int n) {std::cout << n << std::endl;});

auto is_odd = [](int n) {return n%2==1;};
auto pos = std::find_if(std::begin(arr), std::end(arr), is_odd);
if(pos != std::end(arr))
  std::cout << *pos << std::endl;

這段程式碼基本上和使用std::vector那段程式碼一樣,這意味著我們可以為所有支援begin()和end()的型別寫一個泛型函式來達到這個目的。

template <typename Iterator>
void bar(Iterator begin, Iterator end) 
{
   std::for_each(begin, end, [](int n) {std::cout << n << std::endl;});

   auto is_odd = [](int n) {return n%2==1;};
   auto pos = std::find_if(begin, end, is_odd);
   if(pos != end)
      std::cout << *pos << std::endl;
}

template <typename C>
void foo(C c)
{
   bar(std::begin(c), std::end(c));
}

template <typename T, size_t N>
void foo(T(&arr)[N])
{
   bar(std::begin(arr), std::end(arr));
}

int arr[] = {1,2,3};
foo(arr);

std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
foo(v);

static_assert巨集和型別萃取器

static_assert會執行一個編譯器的的斷言,如果斷言為真,什麼都不會發生,如果斷言為假,編譯器則會顯示一些特定的錯誤資訊。

template <typename T, size_t Size>
class Vector
{
   static_assert(Size < 3, "Size is too small");
   T _points[Size];
};

int main()
{
   Vector<int, 16> a1;
   Vector<double, 2> a2;
   return 0;
}

error C2338: Size is too small
see reference to class template instantiation 'Vector<T,Size>' being compiled
   with
   [
      T=double,
      Size=2
   ]

當和型別萃取一起使用的時候,static_assert會變得更加有用,這些是一系列可以在編譯期提供額外資訊的類,它們被封裝在了標頭檔案裡面,在這個標頭檔案裡,有若干分類:用來建立編譯期常量的helper類,用來編譯期獲取型別資訊的型別萃取類,為了可以把現存型別轉換為新型別的型別轉換類。

在下面那個例子裡,add函式被設計成只能處理基本型別。

template <typename T1, typename T2>
auto add(T1 t1, T2 t2) -> decltype(t1 + t2)
{
   return t1 + t2;
}

然而,如果你這麼寫的話,並不會出現編譯錯誤。

std::cout << add(1, 3.14) << std::endl;
std::cout << add("one", 2) << std::endl;

程式實際列印了4.14和“e”,但是如果我們新增一些編譯器斷言,這兩行程式碼都會產生編譯錯誤。

template <typename T1, typename T2>
auto add(T1 t1, T2 t2) -> decltype(t1 + t2)
{
   static_assert(std::is_integral<T1>::value, "Type T1 must be integral");
   static_assert(std::is_integral<T2>::value, "Type T2 must be integral");

   return t1 + t2;
}

error C2338: Type T2 must be integral
see reference to function template instantiation 'T2 add<int,double>(T1,T2)' being compiled
   with
   [
      T2=double,
      T1=int
   ]
error C2338: Type T1 must be integral
see reference to function template instantiation 'T1 add<const char*,int>(T1,T2)' being compiled
   with
   [
      T1=const char *,
      T2=int
   ]

移動語義

這又是一個很重要,並且涉及到很多C++11技術特徵的話題,關於這個話題不僅僅能寫一段,更能寫一系列文章。因此,我在這裡並不會描述太多技術細節,如果你還沒有對這個話題很熟悉,我會鼓勵你去翻閱一些額外的資料。

為了區分指向左值的引用和指向右值的引用,C++11引入了右值引用(用&&來表示)的概念。左值是指一個有名字的物件,而右值則是一個沒有名字的物件(臨時物件)。移動語義允許修改右值(之前考慮到它的不可改變性,因此和const T& types的概念有些混淆)。

一個C++類/結構體有一些隱式成員函式:預設建構函式(當且僅當另外一個建構函式沒有被顯式的定義),拷貝建構函式,一個解構函式,以及一個拷貝賦值操作符。拷貝建構函式和拷貝賦值操作符一般會執行按位拷貝(或者淺拷貝),例如,逐一按位拷貝變數。這意味著如果你有一個包含指向某個物件的指標的類,它們只會把指標的地址進行拷貝,並不會拷貝指標指向的物件。這在某些情況下是可以的,但是對於絕大多數情況,你需要的是深拷貝,也就是對指標指向的物件進行拷貝,而不是指標本身的值,在這種情況下你不得不顯式的寫一個拷貝建構函式和拷貝賦值操作符來執行深拷貝。

那麼,如果你想要初始化或者複製的源資料是個右值型別(臨時的)會怎麼樣?你仍然不得不拷貝它的值,但是很快,這個右值就會消失,這意味著一些操作的開銷,包括分配記憶體以及最後拷貝資料,這些都是不必要的。

我們引入了移動建構函式和移動賦值操作符,這兩個特殊的函式接受一個T&&型別的右值引數,這兩個函式可以修改物件,類似於把引用指向的物件“偷”來。舉一個例子,一個容器的具體實現(例如vector或者queue)可能會包含一個指向陣列元素的指標,我們可以為這些元素分配另一個陣列空間,從臨時空間中拷貝資料,然後當臨時資料失效的時候再刪除這段記憶體,我們也可以直接用這個臨時的資料來例項化,我們只是拷貝指向陣列元素的指標地址,於是,這節省了一次分配記憶體的開銷,拷貝一系列元素並且稍後釋放掉的開銷。

下面這個例子展示了一個虛擬緩衝區的實現,這段緩衝區由一個名字標識(只是為了能更好的解釋),有一個指標(用std::unique_ptr封裝起來),指向一個型別為T的陣列,也有一個儲存陣列大小的變數。

template <typename T>
class Buffer 
{
   std::string          _name;
   size_t               _size;
   std::unique_ptr<T[]> _buffer;

public:
   // default constructor
   Buffer():
      _size(16),
      _buffer(new T[16])
   {}

   // constructor
   Buffer(const std::string& name, size_t size):
      _name(name),
      _size(size),
      _buffer(new T[size])
   {}

   // copy constructor
   Buffer(const Buffer& copy):
      _name(copy._name),
      _size(copy._size),
      _buffer(new T[copy._size])
   {
      T* source = copy._buffer.get();
      T* dest = _buffer.get();
      std::copy(source, source + copy._size, dest);
   }

   // copy assignment operator
   Buffer& operator=(const Buffer& copy)
   {
      if(this != ©)
      {
         _name = copy._name;

         if(_size != copy._size)
         {
            _buffer = nullptr;
            _size = copy._size;
            _buffer = _size > 0 > new T[_size] : nullptr;
         }

         T* source = copy._buffer.get();
         T* dest = _buffer.get();
         std::copy(source, source + copy._size, dest);
      }

      return *this;
   }

   // move constructor
   Buffer(Buffer&& temp):
      _name(std::move(temp._name)),
      _size(temp._size),
      _buffer(std::move(temp._buffer))
   {
      temp._buffer = nullptr;
      temp._size = 0;
   }

   // move assignment operator
   Buffer& operator=(Buffer&& temp)
   {
      assert(this != &temp); // assert if this is not a temporary

      _buffer = nullptr;
      _size = temp._size;
      _buffer = std::move(temp._buffer);

      _name = std::move(temp._name);

      temp._buffer = nullptr;
      temp._size = 0;

      return *this;
   }
};

template <typename T>
Buffer<T> getBuffer(const std::string& name) 
{
   Buffer<T> b(name, 128);
   return b;
}
int main()
{
   Buffer<int> b1;
   Buffer<int> b2("buf2", 64);
   Buffer<int> b3 = b2;
   Buffer<int> b4 = getBuffer<int>("buf4");
   b1 = getBuffer<int>("buf5");
   return 0;
}  

預設拷貝建構函式和複製賦值操作符應該看起來很類似,對於C++11標準來說,新的東西是根據移動語義設計的移動建構函式和移動賦值操作符。如果你執行這段程式碼,你會看到,當b4被構造的時候,呼叫了移動建構函式。而當b1被分配一個值的時候,移動賦值操作符被呼叫了,原因則是getBuffer()返回的值是一個臨時的右值。

你可能注意到了一個細節,當初始化name變數和指向buffer的指標的時候,我們在移動建構函式中使用了std::move。name變數是一個字串型別,std::string支援移動語義,unique_ptr也是一樣的,然而,如果我們使用_name(temp._name),複製建構函式將會被呼叫,但對於_buffer來說,這卻是不可能的,因為std::unique_ptr並沒有拷貝建構函式,但是為什麼std::string的移動建構函式在這種情況下沒有被呼叫?因為即使為Buffer呼叫移動建構函式的物件是一個右值型別,在建構函式的內部卻實際是個左值型別,為什麼?因為他有一個名字“temp”,而一個有名字的物件是左值型別。為了讓它再一次變成右值型別(也為了可以恰當的呼叫移動建構函式),我們必須使用std::move。這個函式的作用只是把一個左值型別的引用轉換成右值型別引用。

更新:雖然這個例子的目的是展示下如何實現移動建構函式和移動賦值操作符,但實現的具體細節可能會有所不同,另外一個實現的方案是7805758成員在評論中提到的方法,為了能讓大家更容易看到,我把它寫在了正文中。

template <typename T>
class Buffer
{
   std::string          _name;
   size_t               _size;
   std::unique_ptr<T[]> _buffer;

public:
   // constructor
   Buffer(const std::string& name = "", size_t size = 16):
      _name(name),
      _size(size),
      _buffer(size? new T[size] : nullptr)
   {}

   // copy constructor
   Buffer(const Buffer& copy):
      _name(copy._name),
      _size(copy._size),
      _buffer(copy._size? new T[copy._size] : nullptr)
   {
      T* source = copy._buffer.get();
      T* dest = _buffer.get();
      std::copy(source, source + copy._size, dest);
   }

   // copy assignment operator
   Buffer& operator=(Buffer copy)
   {
       swap(*this, copy);
       return *this;
   }

   // move constructor
   Buffer(Buffer&& temp):Buffer()
   {
      swap(*this, temp);
   }

   friend void swap(Buffer& first, Buffer& second) noexcept
   {
       using std::swap;
       swap(first._name  , second._name);
       swap(first._size  , second._size);
       swap(first._buffer, second._buffer);
   }
};

結論

C++11包含了很多內容,以上內容只是一部分初步介紹,這篇文章文章展示了一系列C++核心技術以及標準庫特徵的用法,但是,我推薦你至少對其中一些特徵去做一些額外、深入的閱讀。

出處:Ten C++11 Features Every C++ Developer Should Use

相關文章