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

來源:http://www.ituring.com.cn/article/39533

這篇文章討論了一系列所有開發者都應該學習和使用的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

 

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