C++語言的15個晦澀特性

jobbole發表於2013-12-30

  這個列表收集了 C++ 語言的一些晦澀(Obscure)特性,是我經年累月研究這門語言的各個方面收集起來的。C++非常龐大,我總是能學到一些新知識。即使你對C++已瞭如指掌,也希望你能從列表中學到一些東西。下面列舉的特性,根據晦澀程度由淺入深進行排序。

  • 1. 方括號的真正含義
  • 2. 最煩人的解析
  • 3.替代運算標記符
  • 4. 重定義關鍵字
  • 5. Placement new
  • 6.在宣告變數的同時進行分支
  • 7.成員函式的引用修飾符
  • 8.轉向完整的模板超程式設計
  • 9.指向成員的指標操作符
  • 10. 靜態例項方法
  • 11.過載++和–
  • 12.操作符過載和檢查順序
  • 13.函式作為模板引數
  • 14.模板的引數也是模板
  • 15.try塊作為函式

  方括號的真正含義

  用來訪問陣列元素的ptr[3]其實只是*(ptr + 3)的縮寫,與用*(3 + ptr)是等價的,因此反過來與3[ptr]也是等價的,使用3[ptr]是完全有效的程式碼。

  最煩人的解析

  “most vexing parse”這個詞是由Scott Meyers提出來的,因為C++語法宣告的二義性會導致有悖常理的行為:

// 這個解釋正確?
// 1) 型別std::string的變數會通過std::string()例項化嗎?
// 2) 一個函式宣告,返回一個std::string值並有一個函式指標引數,
// 該函式也返回一個std::string但沒有引數?
std::string foo(std::string());

// 還是這個正確?
// 1)型別int變數會通過int(x)例項化嗎?
// 2)一個函式宣告,返回一個int值並有一個引數,
// 該引數是一個名為x的int型變數嗎?
int bar(int(x));

  兩種情形下C++標準要求的是第二種解釋,即使第一種解釋看起來更直觀。程式設計師可以通過包圍括號中變數的初始值來消除歧義:

//加括號消除歧義
std::string foo((std::string()));
int bar((int(x)));

  第二種情形讓人產生二義性的原因是int y = 3;等價於int(y) = 3;

  譯者注:這一點我覺得有點迷惑,下面是我在g++下的測試用例:

#include <iostream>
#include <string>
using namespace std;

int bar(int(x));   // 等價於int bar(int x)

string foo(string());  // 等價於string foo(string (*)())

string test() {
    return "test";
}

int main()
{
    cout << bar(2) << endl; // 輸出2
	cout << foo(test); // 輸出test
    return 0;
}

int bar(int(x)) {  
    return x;
}

string foo(string (*fun)()) {
    return (*fun)();
}

  能正確輸出,但如果按作者意思新增上括號後再編譯就會報一堆錯誤:“在此作用域尚未宣告”、“重定義”等,還不清楚作者的意圖。

  替代運算標記符

  標記符and, and_eq, bitand, bitor, compl, not, not_eq, or, or_eq, xor, xor_eq, <%, %>, <: 和 :>都可以用來代替我們常用的&&, &=, &, |, ~, !, !=, ||, |=, ^, ^=, {, }, [ 和 ]。在鍵盤上缺乏必要的符號時你可以使用這些運算標記符來代替。

  重定義關鍵字

  通過前處理器重定義關鍵字從技術上講會引起錯誤,但實際上是允許這樣做的。因此你可以使用類似#define true false 或 #define else來搞點惡作劇。但是,也有它合法有用的時候,例如,如果你正在使用一個很大的庫而且需要繞過C++訪問保護機制,除了給庫打補丁的方法外,你也可以在包含該庫標頭檔案之前關閉訪問保護來解決,但要記得在包含庫標頭檔案之後一定要開啟保護機制!

#define class struct
#define private public
#define protected public

#include "library.h"

#undef class
#undef private
#undef protected

  注意這種方式不是每一次都有效,跟你的編譯器有關。當例項變數沒有被訪問控制符修飾時,C++只需要將這些例項變數順序佈局即可,所以編譯器可以對訪問控制符組重新排序來自由更改記憶體佈局。例如,允許編譯器移動所有的私有成員放到公有成員的後面。另一個潛在的問題是名稱重整(name mangling),Microsoft的C++編譯器將訪問控制符合併到它們的name mangling表裡,因此改變訪問控制符意味著將破壞現有編譯程式碼的相容性。

  譯者注:在C++中,Name Mangling 是為了支援過載而加入的一項技術。編譯器將目標原始檔中的名字進行調整,這樣在目標檔案符號表中和連線過程中使用的名字和編譯目標檔案的源程式中的名字不一樣,從而實現過載。

  Placement new

  Placement new是new操作符的一個替代語法,作用在已分配的物件上,該物件已有正確的大小和正確的賦值,這包括建立虛擬函式表和呼叫建構函式。

  譯者注:placement new就是在使用者指定的記憶體位置上構建新的物件,這個構建過程不需要額外分配記憶體,只需要呼叫物件的建構函式即可。placement new實際上是把原本new做的兩步工作分開來:第一步自己分配記憶體,第二步呼叫類的建構函式在自己已分配的記憶體上構建新的物件。placement new的好處:1)在已分配好的記憶體上進行物件的構建,構建速度快。2)已分配好的記憶體可以反覆利用,有效的避免記憶體碎片問題。

#include <iostream>
using namespace std;

struct Test {
  int data;
  Test() { cout << "Test::Test()" << endl; }
  ~Test() { cout << "Test::~Test()" << endl; }
};

int main() {
  // Must allocate our own memory
  Test *ptr = (Test *)malloc(sizeof(Test));

  // Use placement new
  new (ptr) Test;

  // Must call the destructor ourselves
  ptr->~Test();

  // Must release the memory ourselves
  free(ptr);

  return 0;
}

  當在效能關鍵的場合需要自定義分配器時可以使用Placement new。例如,一個slab分配器從單個的大記憶體塊開始,使用placement new在塊裡順序分配物件。這不僅避免了記憶體碎片,也節省了malloc引起的堆遍歷的開銷。

  在宣告變數的同時進行分支

  C++包含一個語法縮寫,能在宣告變數的同時進行分支。看起來既像單個的變數宣告也可以有if或while這樣的分支條件。

struct Event { virtual ~Event() {} };
struct MouseEvent : Event { int x, y; };
struct KeyboardEvent : Event { int key; };

void log(Event *event) {
  if (MouseEvent *mouse = dynamic_cast<MouseEvent *>(event))
    std::cout << "MouseEvent " << mouse->x << " " << mouse->y << std::endl;

  else if (KeyboardEvent *keyboard = dynamic_cast<KeyboardEvent *>(event))
    std::cout << "KeyboardEvent " << keyboard->key << std::endl;

  else
    std::cout << "Event" << std::endl;
}

  成員函式的引用修飾符

  C++11允許成員函式在物件的值型別上進行過載,this指標會將該物件作為一個引用修飾符。引用修飾符會放在cv限定詞(譯者注:CV限定詞有三種:const限定符、volatile限定符和const-volatile限定符)相同的位置並依據this物件是左值還是右值影響過載解析:

#include <iostream>

struct Foo {
  void foo() & { std::cout << "lvalue" << std::endl; }
  void foo() && { std::cout << "rvalue" << std::endl; }
};

int main() {
  Foo foo;
  foo.foo(); // Prints "lvalue"
  Foo().foo(); // Prints "rvalue"
  return 0;
}

  轉向完整的模板超程式設計

  C++模板是為了實現編譯時超程式設計,也就是該程式能生成其它的程式。設計模板系統的初衷是進行簡單的型別替換,但是在C++標準化過程中突然發現模板實際上功能十分強大,足以執行任意計算,雖然很笨拙很低效,但通過模板特化的確可以完成一些計算:

// Recursive template for general case
template <int N>
struct factorial {
  enum { value = N * factorial<N - 1>::value };
};

// Template specialization for base case
template <>
struct factorial<0> {
  enum { value = 1 };
};

enum { result = factorial<5>::value }; // 5 * 4 * 3 * 2 * 1 == 120

  C++模板可以被認為是一種功能型程式語言,因為它們使用遞迴而非迭代而且包含不可變狀態。你可以使用typedef建立一個任意型別的變數,使用enum建立一個int型變數,資料結構內嵌在型別自身。

// Compile-time list of integers
template <int D, typename N>
struct node {
  enum { data = D };
  typedef N next;
};
struct end {};

// Compile-time sum function
template <typename L>
struct sum {
  enum { value = L::data + sum<typename L::next>::value };
};
template <>
struct sum<end> {
  enum { value = 0 };
};

// Data structures are embedded in types
typedef node<1, node<2, node<3, end> > > list123;
enum { total = sum<list123>::value }; // 1 + 2 + 3 == 6

  當然這些例子沒什麼用,但模板超程式設計的確可以做一些有用的事情,比如可以操作型別列表。但是,使用C++模板的程式語言可用性極低,因此請謹慎和少量使用。模板程式碼很難閱讀,編譯速度慢,而且因其冗長和迷惑的錯誤資訊而難以除錯。

  指向成員的指標操作符

  指向成員的指標操作符可以讓你在一個類的任何例項上描述指向某個成員的指標。有兩種pointer-to-member操作符,取值操作符*和指標操作符->:

#include <iostream>
using namespace std;

struct Test {
  int num;
  void func() {}
};

// Notice the extra "Test::" in the pointer type
int Test::*ptr_num = &Test::num;
void (Test::*ptr_func)() = &Test::func;

int main() {
  Test t;
  Test *pt = new Test;

  // Call the stored member function
  (t.*ptr_func)();
  (pt->*ptr_func)();

  // Set the variable in the stored member slot
  t.*ptr_num = 1;
  pt->*ptr_num = 2;

  delete pt;
  return 0;
}

  該特徵實際上十分有用,尤其在寫庫的時候。例如,Boost::Python, 一個用來將C++繫結到Python物件的庫,就使用成員指標操作符,在包裝物件時很容易的指向成員。

#include <iostream>
#include <boost/python.hpp>
using namespace boost::python;

struct World {
  std::string msg;
  void greet() { std::cout << msg << std::endl; }
};

BOOST_PYTHON_MODULE(hello) {
  class_<World>("World")
    .def_readwrite("msg", &World::msg)
    .def("greet", &World::greet);
}

  記住使用成員函式指標與普通函式指標是不同的。在成員函式指標和普通函式指標之間casting是無效的。例如,Microsoft編譯器裡的成員函式使用了一個稱為thiscall的優化呼叫約定,thiscall將this引數放到ecx暫存器裡,而普通函式的呼叫約定卻是在棧上解析所有的引數。

  而且,成員函式指標可能比普通指標大四倍左右,編譯器需要儲存函式體的地址,到正確父地址(多個繼承)的偏移,虛擬函式表(虛繼承)中另一個偏移的索引,甚至在物件自身內部的虛擬函式表的偏移也需要儲存(為了前向宣告型別)。

#include <iostream>

struct A {};
struct B : virtual A {};
struct C {};
struct D : A, C {};
struct E;

int main() {
  std::cout << sizeof(void (A::*)()) << std::endl;
  std::cout << sizeof(void (B::*)()) << std::endl;
  std::cout << sizeof(void (D::*)()) << std::endl;
  std::cout << sizeof(void (E::*)()) << std::endl;
  return 0;
}

// 32-bit Visual C++ 2008:  A = 4, B = 8, D = 12, E = 16
// 32-bit GCC 4.2.1:        A = 8, B = 8, D = 8,  E = 8
// 32-bit Digital Mars C++: A = 4, B = 4, D = 4,  E = 4

  在Digital Mars編譯器裡所有的成員函式都是相同的大小,這是源於這樣一個聰明的設計:生成“thunk”函式來運用右偏移而不是儲存指標自身內部的偏移。

  靜態例項方法

  C++中可以通過例項呼叫靜態方法也可以通過類直接呼叫。這可以使你不需要更新任何呼叫點就可以將例項方法修改為靜態方法。

struct Foo {
  static void foo() {}
};

// These are equivalent
Foo::foo();
Foo().foo();

  過載++和–

  C++的設計中自定義操作符的函式名稱就是操作符本身,這在大部分情況下都工作的很好。例如,一元操作符的-和二元操作符的-(取反和相減)可以通過引數個數來區分。但這對於一元遞增和遞減操作符卻不奏效,因為它們的特徵似乎完全相同。C++語言有一個很笨拙的技巧來解決這個問題:字尾++和–操作符必須有一個空的int引數作為標記讓編譯器知道要進行字尾操作(是的,只有int型別有效)。

struct Number {
  Number &operator ++ (); // Generate a prefix ++ operator
  Number operator ++ (int); // Generate a postfix ++ operator
};

  操作符過載和檢查順序

  過載,(逗號),||或者&&操作符會引起混亂,因為它打破了正常的檢查規則。通常情況下,逗號操作符在整個左邊檢查完畢才開始檢查右邊,|| 和 &&操作符有短路行為:僅在必要時才會去檢查右邊。無論如何,操作符的過載版本僅僅是函式呼叫且函式呼叫以未指定的順序檢查它們的引數。

  過載這些操作符只是一種濫用C++語法的方式。作為一個例項,下面我給出一個Python形式的無括號版列印語句的C++實現:

#include <iostream>

namespace __hidden__ {
  struct print {
    bool space;
    print() : space(false) {}
    ~print() { std::cout << std::endl; }

    template <typename T>
    print &operator , (const T &t) {
      if (space) std::cout << ' ';
      else space = true;
      std::cout << t;
      return *this;
    }
  };
}

#define print __hidden__::print(),

int main() {
  int a = 1, b = 2;
  print "this is a test";
  print "the sum of", a, "and", b, "is", a + b;
  return 0;
}

  函式作為模板引數

  眾所周知,模板引數可以是特定的整數也可以是特定的函式。這使得編譯器在例項化模板程式碼時內聯呼叫特定的函式以獲得更高效的執行。下面的例子裡,函式memoize的模板引數也是一個函式且只有新的引數值才通過函式呼叫(舊的引數值可以通過cache獲得):

#include <map>

template <int (*f)(int)>
int memoize(int x) {
  static std::map<int, int> cache;
  std::map<int, int>::iterator y = cache.find(x);
  if (y != cache.end()) return y->second;
  return cache[x] = f(x);
}

int fib(int n) {
  if (n < 2) return n;
  return memoize<fib>(n - 1) + memoize<fib>(n - 2);
}

  模板的引數也是模板

  模板引數實際上自身的引數也可以是模板,這可以讓你在例項化一個模板時可以不用模板引數就能夠傳遞模板型別。看下面的程式碼:

template <typename T>
struct Cache { ... };

template <typename T>
struct NetworkStore { ... };

template <typename T>
struct MemoryStore { ... };

template <typename Store, typename T>
struct CachedStore {
  Store store;
  Cache<T> cache;
};

CachedStore<NetworkStore<int>, int> a;
CachedStore<MemoryStore<int>, int> b;

  CachedStore的cache儲存的資料型別與store的型別相同。然而我們在例項化一個CachedStore必須重複寫資料型別(上面的程式碼是int型),store本身要寫,CachedStore也要寫,關鍵是我們這並不能保證兩者的資料型別是一致的。我們真的只想要確定資料型別一次即可,所以我們可以強制其不變,但是沒有型別引數的列表會引起編譯出錯:

// 下面編譯通不過,因為NetworkStore和MemoryStore缺失型別引數
CachedStore<NetworkStore, int> c;
CachedStore<MemoryStore, int> d;

  模板的模板引數可以讓我們獲得想要的語法。注意你必須使用class關鍵字作為模板引數(他們自身的引數也是模板)

template <template <typename> class Store, typename T>
struct CachedStore2 {
  Store<T> store;
  Cache<T> cache;
};

CachedStore2<NetworkStore, int> e;
CachedStore2<MemoryStore, int> f;

  try塊作為函式

  函式的try塊會在檢查建構函式的初始化列表時捕獲丟擲的異常。你不能在初始化列表的周圍加上try-catch塊,因為其只能出現在函式體外。為了解決這個問題,C++允許try-catch塊也可作為函式體:

int f() { throw 0; }

// 這裡沒有辦法捕獲由f()丟擲的異常
struct A {
  int a;
  A::A() : a(f()) {}
};

// 如果try-catch塊被用作函式體並且初始化列表移至try關鍵字之後的話,
// 那麼由f()丟擲的異常就可以捕獲到
struct B {
  int b;
  B::B() try : b(f()) {
  } catch(int e) {
  }
};

  奇怪的是,這種語法不僅僅侷限於建構函式,也可用於其他的所有函式定義。

  原文連結: Evan Wallace   翻譯: 伯樂線上 - 敏敏

相關文章