讓自己習慣C++
視C++為一個語言聯邦
- C語言
- 面對對象
- C++模板
- STL容器
盡量以const,enum,inline替換#define
- const的好處:
- define直接常量替換,出現編譯錯誤不易定位(不知道常量是哪個變量)
- define沒有作用域,const有作用域提供了封裝性
- enum的好處:
- 提供了封裝性
- 編譯器肯定不會分配額外內存空間(其實const也不會)
- inline的好處:
- define宏函數容易造成誤用(下面有個例子)
//define誤用舉例
#define MAX(a, b) a > b ? a : b
int a = 5, b = 0;
MAX(++a, b) //a++調用2次
MAX(++a, b+10) //a++調用一次
然而,了解宏的機制以后,我們也可以用宏實現特殊的技巧。例如:C++反射,TEST
宏實現工廠模式
- 需要一個全局的map用於存儲類的信息以及創建實例的函數
- 需要調用全局對象的構造函數用於注冊
using namespace std;
typedef void *(*register_fun)();
class CCFactory{
public:
static void *NewInstance(string class_name){
auto it = map_.find(class_name);
if(it == map_.end()){
return NULL;
}else
return it->second();
}
static void Register(string class_name, register_fun func){
map_[class_name] = func;
}
private:
static map<string, register_fun> map_;
};
map<string, register_fun> CCFactory::map_;
class Register{
public:
Register(string class_name, register_fun func){
CCFactory::Register(class_name, func);
}
};
#define REGISTER_CLASS(class_name); \
const Register class_name_register(#class_name, []()->void *{return new class_name;});
盡可能使用const
- const定義接口,防止誤用
- const成員函數,代表這個成員函數承諾不會改變對象值
- const成員只能調用const成員函數(加-fpermissive編譯選項就可以了)
- 非const成員可以調用所有成員函數
確定對象使用前已被初始化
- 內置類型需要定義時初始化
- 最好使用初始化序列(序列順序與聲明順序相同),而不是在構造函數中賦值
- 跨編譯單元定義全局對象不能確保初始化順序
- 將static對象放入一個函數
Fuck& fuck(){
static Fuck f;
return f;
}
構造/析構/賦值運算
了解C++默默編調用了哪些函數
如果類中沒有定義,程序卻調用了,編譯器會產生一些函數
- 一個 default 構造函數
- 一個 copy 構造函數
- 一個 copy assignment 操作符
- 一個析構函數(non virtual)
- 如果自己構造了帶參數的構造函數,編譯器不會產生default構造函數
- base class如果把拷貝構造函數或者賦值操作符設置為private,不會產生這兩個函數
- 含有引用成員變量或者const成員變量不產生賦值操作符
class Fuck{
private:
std::string& str;//引用定義后不能修改綁定對象
const std::string con_str;//const對象定義后不能修改
};
若不想使用編譯器自動生成的函數,就該明確拒絕
將默認生成的函數聲明為private,或者C++ 11新特性"=delete"
class Uncopyable{
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator= (const Uncopyable&);
}
為多態基類聲明virtual析構函數
- 給多態基類應該主動聲明virtual析構函數
- 非多態基類,沒有virtual函數,不要聲明virtual析構函數
別讓異常逃離析構函數
構造函數可以拋出異常,析構函數不能拋出異常。
因為析構函數有兩個地方可能被調用。一是用戶調用,這時拋出異常完全沒問題。二是前面有異常拋出,正在清理堆棧,調用析構函數。這時如果再拋出異常,兩個異常同時存在,異常處理機制只能terminate().
- 構造函數拋出異常,會有內存泄漏嗎?
不會
try {
// 第二步,調用構造函數構造對象
new (p)T; // placement new: 只調用T的構造函數
}
catch(...) {
delete p; // 釋放第一步分配的內存
throw; // 重拋異常,通知應用程序
}
絕不在構造和析構過程中調用virtual函數
構造和析構過程中,虛表指針指向的虛表在變化。調用的是對應虛表指針指向的函數。
令operator= 返回一個reference to *this
沒什么理由,照着做就行
在operator= 里處理自我賦值
Widget& Widget::operator== (const Widget& rhs){
if(this == &rhs) return *this
···
}
復制對象時務忘其每一個成分
- 記得實現拷貝構造函數和賦值操作符的時候,調用base的相關函數
- 可以讓拷貝構造函數和賦值操作符調用一個共同的函數,例如init
資源管理
以對象管理資源
- 為了防止資源泄漏,請使用RAII對象,在構造函數里面獲得資源,在析構函數里面釋放資源
- shared_ptr,unique_lock都是RAII對象
在資源管理類小心copy行為
- 常見的RAII對象copy行為
- 禁止copy
- 引用計數
- 深度復制
- 轉移資源擁有權
在資源管理類中提供對原始資源的訪問
用戶可能需要原始資源作為參數傳入某個接口。有兩種方式:
- 提供顯示調用接口
- 提供隱式轉換接口(不推薦)
成對使用new和delete要采用相同的格式
new和delete對應;new []和delete []對應
//前面還分配了4個字節代表數組的個數
int *A = new int[10];
//前面分配了8個字節,分別代表對象的個數和Object的大小
Object *O = new Object[10];
以獨立的語句將newd對象置入智能指針
調用std::make_shared,而不要調用new,防止new Obeject和傳入智能指針的過程產生異常
process(new Widget, priority);
//其實這樣也可以,獨立的語句
shard_ptr<Widget> p(new Widget);
process(p, priority);
設計與聲明
讓接口容易被正確使用,不易被誤用
- 好的接口很容易被正確使用,不容易被誤用。努力達成這些性質(例如 explicit關鍵字)
- “促進正確使用”的辦法包括接口的一致性,以及與內置類型的行為兼容
- “防治誤用”b包括建立新類型,限制類型上的操作,束縛對象值,以及消除用戶的資源管理責任
- shared_ptr支持定制deleter,需要靈活使用
設計class猶如設計type
寧以pass-by-refrence-to-const替換pass-by-value
- 盡量以pass-by-reference-to-const替換pass-by-value,比較高效,並且可以避免切割問題
- 以上規則並不使用內置類型,以及STL迭代器,和函數對象。它們采用pass-by-value更合適(其實采用pass-by-reference-to-const也可以)
必須返回對象時,別妄想返回其reference
- 不要返回pointer或者reference指向一個on stack對象(被析構)
- 不要返回pointer或者reference指向一個on heap對象(需要用戶delete,我覺得必要的時候也不是不可以)
- 不要返回pointer或者reference指向local static對象,卻需要多個這樣的對象(static只能有一份)
將成員變量申明為private
- 切記將成員變量申明為private
- protected並不比public更有封裝性(用戶可能繼承你的base class)
寧以non-member,non-friend替換member
作者說多一個成員函數,就多一分破壞封裝性,好像有點道理,但是我們都沒有這樣遵守。直接寫member函數方便一些。
若所有參數都需要類型轉換,請為此采用non-member函數
如果調用member函數,就使得第一個參數的類失去一次類型轉換的機會。
考慮寫一個不拋出異常的swap函數
- 當std::swap效率不高(std::swap調用拷貝構造函數和賦值操作符,如果是深拷貝,效率不會高),提供一個swap成員函數,並確定不會拋出異常。
class Obj{
Obj(const Obj&){//深拷貝}
Obj& operator= (const Obj&){深拷貝
private:
OtherClass *p;
};
- 如果提供一個member swap,也該提供一個non-member swap用來調用前者
- 調用swap時應該針對std::swap使用using聲明式,然后調用swap不帶任何"命名空間修飾”
void doSomething(Obj& o1, Obj& o2){
//這樣可以讓編譯器自己決定調用哪個swap,萬一用戶沒有實現針對Obj的swap,還能調用std::swap
using std::swap;
swap(o1, o2);
}
- 不要往std命名空間里面加東西
實現
盡可能延后變量定義式出現的時間
C語言推薦在函數開始的時候定義所有變量(最開始的C語言編譯器要求,現在並不需要),C++推薦在使用對象前才定義對象
盡量少做轉型動作
- 如果可以,盡量避免轉型,特別是在注重效率的代碼中避免dynamic_cast。
- 如果轉型是必要的,試着將它隱藏於某個函數后。客戶可以隨時調用該函數,而不需要將轉型放入自己的代碼。
- 使用C++風格的轉型。
避免返回handles指向對象內部成分
簡單說,就是成員函數返回指針或者非const引用不要指向成員變量,這樣會破壞封裝性
為“異常安全”而努力是值得的
- "異常安全函數"承諾即使發生異常也不會有資源泄漏。在這個基礎下,它有3個級別
- 基本保證:拋出異常,需要用戶處理程序狀態改變(自己寫代碼保證這個級別就行了把)
- 強烈保證:拋出異常,程序狀態恢復到調用前
- 不拋異常:內置類型的操作就絕不會拋出異常
- "強烈保證"往往可以通過copy-and-swap實現,但是"強烈保證"並非對所有函數都具有實現意義
//我反正從來沒有這樣寫過
void doSomething(Object& obj){
Object new_obj(obj);
new_obj++;
swap(obj, new_obj);
}
透徹了解inline函數的里里外外
這里插播一個C++處理定義的重要原則,一處定義原則:
- 全局變量,靜態數據成員,非內聯函數和成員函數只能整個程序定義一次
- 類類型(class,struct,union),內聯函數可以每個翻譯單元定義一次
- template類的成員函數或者template函數,定義在頭文件中,編譯器可以幫忙去重
- 普通類的template函數,定義在頭文件中,需要加inline
- inline應該限制在小的,頻繁調用的函數上
- inline只是給編譯器的建議,編譯器不一定執行
將文件的編譯依存關系降到最低
- 支持"編譯依存最小化"的一般構想是:相依於聲明式,不要相依於定義式。基於此構想的兩個手段是Handle classes(impl對象提供服務)和Interface classes。
其實就是使用前置聲明,下面有個需要注意的點
//Obj.h
class ObjImpl;
class Obj{
public:
private:
std::shared_ptr<ObjImpl> pObjImpl;
};
//上面的寫法會報錯,因為編譯器會再.h文件里面產生默認的析構函數,
//析構函數要調用ObjImpl的析構函數,然后我們現在只有聲明式,不能調用ObjImpl的實現。
//下面的實現才是正確的
//Obj.h
class ObjImpl;
class Obj{
public:
//聲明
~Obj();
private:
std::shared_ptr<ObjImpl> pObjImpl;
};
//Obj.cpp
//現在可以看到ObjImpl的實現
#include<ObjImpl>
Obj::~Obj(){
}
- 對於STL的對象不需要前置聲明。
繼承與面對對象設計
確定你的public繼承塑模出is-a模型
public繼承意味着is-a。適用於base class身上的每一個函數也一定適用於derived class。
避免遮掩繼承而來的名稱
子作用域會遮掩父作用域的名稱。一般來講,我們可以有以下幾層作用域
- global作用域
- namespace作用域
- Base class作用域
- Drive class作用域
- 成員函數
- 控制塊作用域
- 成員函數
- Drive class作用域
- 非成員函數作用域
- 控制塊作用域
- Base class作用域
注意:遮掩的是上一層作用域的名稱,重載(不同參數)的函數也會直接遮掩
class Base{
public:
void f1();
}
class Drive{
public:
//會遮掩f1(),子類並沒有繼承f1()
void f1(int);
}
Drive d;
d.f1(); //錯誤
d.f1(3); //正確
可以通過using聲明式或者inline轉交解決這一問題
class Base{
public:
void f1();
}
//using 聲明式
class Drive{
public:
using Base::f1;
void f1(int);
}
//inline轉交
class Drive{
public:
void f1(){
Base::f1();
}
void f1(int);
}
區分接口繼承和實現繼承
- 純虛函數:提供接口繼承
- Drived class必須實現純虛函數
- 不能構造含有純虛函數的類
- 純虛函數可以有成員變量
- 可以給純虛函數提供定義(wtf)
- 虛函數:提供接口繼承和默認的實現繼承
- 非虛函數:提供了接口繼承和強制的實現繼承(最好不要在Drived class重新定義非虛函數)
考慮virtual函數以外的選擇
non-virtual interface:提供非虛接口
class Object{
public:
void Interface(){
···
doInterface();
···
}
private/protected:
virtual doInterface(){}
}
優點:
- 可以在調用虛函數的前后,做一些准備工作(抽出一段重復代碼)
- 提供良好的ABI兼容性
聊一聊ABI兼容性
我們知道,程序庫的優勢之一是庫版本升級,只要保證借口的一致性,用戶不用修改任何代碼。
一般一個設計完好的程序庫都會提供一份C語言接口,為什么呢,我們來看看C++ ABI有哪些脆弱性。
- 虛函數的調用方式,通常是 vptr/vtbl 加偏移量調用
//Object.h
class Object{
public:
···
virtual print(){}//第3個虛函數
···
}
//用戶代碼
int main(){
Object *p = new Object;
p->print(); //編譯器:vptr[3]()
}
//如果加了虛函數,用戶代碼根據偏移量找到的是newfun函數
//Object.h
class Object{
public:
···
virtual newfun()//第3個虛函數
virtual print(){}//第4個虛函數
···
}
- name mangling 名字粉碎實現重載
C++沒有為name mangling制定標准。例如void fun(int),有的編譯器定為fun_int_,有的編譯器指定為fun%int%。
因此,C++接口的庫要求用戶必須和自己使用同樣的編譯器(這個要求好過分)
- 其實C語言接口也不完美
例如struct和class。編譯階段,編譯器將struct或class的對象對成員的訪問通過偏移量來實現
使用std::fun提供回調
class Object{
public:
void Interface(){
···
doInterface();
···
}
private/protected:
std::function<void()> doInterface;
}
古典策略模式
用另外一個繼承體系替代
class Object{
public:
void Interface(){
···
p->doInterface();
···
}
private/protected:
BaseInterface *p;
}
class BaseInterface{
public:
virtual void doInterface(){}
}
絕不重新定義繼承而來的non-virtual函數
記住就行
絕不重新定義繼承而來的缺省參數值
class Base{
public:
virtual void print(int a = 1) {cout <<"Base "<< a <<endl;};
int a;
};
class Drive : public Base{
public:
void print(int a = 2){cout << "Drive " << a <<endl;}
};
int main(){
Base *b = new Drive;
b->print(); // vptr[0](1)
}
//Drive 1
- 缺省參數值是靜態綁定
- 虛函數是動態綁定
- 遵守這條規定防止出錯
通過復合塑模出has-a或者"根據某物實現出"
- 復合的意義和public完全不一樣
- 根據某物實現出和is-a的區別:
這個也是什么時候使用繼承,什么時候使用復合。復合代表使用了這個對象的某些方法,但是卻不想它的接口入侵。
明智而審慎地使用private繼承
- private繼承是”根據某物實現出“
- 唯一一個使用private繼承的理由就是,可以使用空白基類優化技術,節約內存空間
C++對空類的處理
C++ 設計者在設計這門語言要求所有的對象必須要有不同的地址(C語言沒有這個要求)。C++編譯器的實現方式是給讓空類占據一個字節。
class Base{
public:
void fun(){}
}
//8個字節
class Object{
private:
int a;
Base b;
};
//4個字節
class Object : private Base{
private:
int a;
}
明智而審慎地使用多重繼承
首先我們來了解一下多重繼承的內存布局。
//包含A對象
class A{
};
//包含A,B對象
class B:public A{
};
//包含A,C對象
class C:public A{
};
//包含A,A,B,C,D對象
class D:public B, public C{
}
由於菱形繼承,基類被構造了兩次。其實,C++也提供了針對菱形繼承的解決方案的
//包含A對象
class A{
};
//包含A,B對象
class B:virtual public A{
};
//包含A,C對象
class C:virtual public A{
};
//包含A,B,C,D對象
class D:public B, public C{
}
使用虛繼承,B,C對象里面會產生一個指針指向唯一一份A對象。這樣付出的代價是必須再運行期根據這個指針的偏移量尋找A對象。
多重繼承唯一的那么一點點用就是一個Base class提供public繼承,另一個Base class提供private繼承。(還是沒什么用啊,干嘛不適用復合)
模板與泛型編程
了解隱式接口和編譯期多態
- 接口:強制用戶實現某些函數
- 多態:相同的函數名,卻有不同的實現
- 繼承和模板都支持接口和多態
- 對繼承而言,接口是顯式的,以函數為中心,多態發生在運行期;
- 對模板而言,接口是隱式的,多態表現在template具象化和函數重載
//這里接口要求T必須實現operator >
template<typename T>
T max(T a, T b){
return (a > b) ? a : b;
}
了解typename的雙重意義
- 聲明template參數時,前綴關鍵字class和typename可以互換
- 使用typename表明嵌套類型(防止產生歧義)
學習處理模板化基類內的名稱
template <typename T>
class Base{
public:
void print(T a) {cout <<"Base "<< a <<endl;};
};
template<typename T>
class Drive : public Base<T>{
public:
void printf(T a){
//error 編譯器不知道基類有print函數
print(a);
}
};
//解決方案
//this->print();
//using Base<T>::print
//base<T>::print直接調用
將參數無關代碼抽離template
- 非類型模板參數造成的代碼膨脹:以函數參數或者成員變量替換
- 類型模板參數造成的代碼膨脹:特化它們,讓含義相近的類型模板參數使用同一份底層代碼。例如int,long, const int
運用成員函數模版接收所有兼容類型
我們來考慮一下智能指針的拷貝構造函數和賦值操作符怎么實現。它需要子類的智能指針能夠隱式轉型為父類智能指針
template<typename T>
class shared_ptr{
public:
//拷貝構造函數,接受所有能夠從U*隱式轉換到T*的參數
template<typename U>
shared_ptr(shared_ptr<U> const &rh):p(rh.get()){
...
}
//賦值操作符,接受所有能夠從U*隱式轉換到T*的參數
template<typename U>
shared_ptr& operator= (shared_ptr<U> const &rh):p(rh.get()){
...
}
//聲明正常的拷貝構造函數
shared_ptr(shared_ptr const &rh);
shared_ptr& operator= (shared_ptr const &rh);
private:
T *p;
}
- 使用成員函數模版生成“可接受所有兼容類型”的函數
- 即使有了“泛化拷貝構造函數”和“泛化的賦值操作符”,仍然需要聲明正常的拷貝構造函數和賦值操作符
- 在一個類模版內,template名稱可被用來作為作為“template和其參數”的簡略表達式
所有參數需要類型轉換的時候請為模版定義非成員函數
- 當我們編寫一個模版類,某個相關函數都需要類型轉換,需要把這個函數定義為非成員函數
- 但是模版的類型推到遇見了問題,需要把這個函數聲明為友元函數幫助推導
- 模版函數只有聲明編譯器不會幫忙具現化,所以我們需要實現的是友元模版函數
template <class T>
class Rational
{
…
friend Rational operator* (const Rational& a, const Rational& b)
{
return Rational (a.GetNumerator() * b.GetNumerator(),
a.GetDenominator() * b.GetDenominator());
}
…
}
請使用traits classes表現類型信息
template<typename T>
class type_traits;
template<>
class type_traits<int>{
public:
static int size = 4;
}
template<>
class type_traits<char>{
public:
static int size = 1;
}
template<>
class type_traits<double>{
static int size = 8;
}
template<typename T>
int ccSizeof(T){
return type_traits<T>::size;
}
- traits采用類模版和特化的方式,為不同的類型提供了相同的類型抽象(都由size)
- 為某些類型提供編譯期測試,例如is_fundamental
(是否為內置類型)
模版元編程
本質上就是函數式編程
//上樓梯,每次上一步或者兩步,有多少種
int climb(int n){
if(n == 1)
return 1;
if(n == 2)
return 2;
return climb(n - 1) + climb(n - 2);
}
//元編程,采用類模版
template<int N>
class Climb{
public:
const static int n = Climb<N-1>::n + Climb<N-2>::n;
};
template<>
class Climb<2>{
public:
const static int n = 2;
};
template<>
class Climb<1>{
public:
const static int n = 1;
};
- C++元編程可以將計算轉移到編譯期,執行速度迅速(缺陷?)
定制new和delete
了解new-handler的行為
new和malloc對比:
- new構造對象,malloc不會
- new分配不出內存會拋異常,malloc返回NULL
- new分配不出內存可以調用用戶設置的new-handler,malloc沒有
namespace std{
typedef void (*new_handler)();
//返回舊的handler
new_handler set_new_handler(new_handler p) throw();
}
- 可以為每個類設置專屬new handler
了解new和delete合理的替換時機
C++中對象的構造和析構經歷了都兩個階段
- operator new, operator delete:分配和釋放內存
- 調用構造函數,調用析構函數
替換new和delete的理由,就是需要收集分配內存的資源信息
編寫符合常規的new和delete
- operator new應該內含一個無窮循環嘗試分配內存,如果無法滿足,就調用new-handler。class版本要處理“比正確大小更大的(錯誤)申請”
- operator deleter應該處理Null。classz專屬版本還要處理“比正確大小更小的(錯誤)申請”
寫了operator new也要寫相應的operator delete
我們知道,new一個對象要經歷兩步。如果在調用構造函數失敗,編譯器會尋找一個“帶相同額外參數”的operator delete,否則就不調用,造成資源泄漏
STL使用小細節
為不同的容器選擇不同刪除方式
刪除連續容器(vector,deque,string)的元素
// 當c是vector、string,刪除value
c.erase(remove(c.begin(), c.end(), value), c.end());
// 判斷value是否滿足某個條件,刪除
bool assertFun(valuetype);
c.erase(remove_if(c.begin(), c.end(), assertFun), c.end());
// 有時候我們不得不遍歷去完成,並刪除
for(auto it = c.begin(); it != c.end(); ){
if(assertFun(*it)){
···
it = c.erase(it);
}
else
++it;
}
刪除list中某個元素
c.remove(value);
// 判斷value是否滿足某個條件,刪除
c.remove(assertFun);
刪除關聯容器(set,map)中某個元素
c.erase(value)
for(auto it = c.begin(); it != c.end(); ){
if(assertFun(*it)){
···
c.erase(it++);
}
else
++it;
}