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

這個列表收集了 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<intint> cache;
std::map<intint>::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 <typenameclass 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) {
}
};

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

發佈了15 篇原創文章 · 獲贊 1 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章