Effective C++讀書筆記


讓自己習慣C++

視C++為一個語言聯邦

  1. C語言
  2. 面對對象
  3. C++模板
  4. STL容器

盡量以const,enum,inline替換#define

  1. const的好處:
    1. define直接常量替換,出現編譯錯誤不易定位(不知道常量是哪個變量)
    2. define沒有作用域,const有作用域提供了封裝性
  2. enum的好處:
    1. 提供了封裝性
    2. 編譯器肯定不會分配額外內存空間(其實const也不會)
  3. inline的好處:
    1. 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

宏實現工廠模式

  1. 需要一個全局的map用於存儲類的信息以及創建實例的函數
  2. 需要調用全局對象的構造函數用於注冊
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

  1. const定義接口,防止誤用
  2. const成員函數,代表這個成員函數承諾不會改變對象值
    1. const成員只能調用const成員函數(加-fpermissive編譯選項就可以了)
    2. 非const成員可以調用所有成員函數

確定對象使用前已被初始化

  1. 內置類型需要定義時初始化
  2. 最好使用初始化序列(序列順序與聲明順序相同),而不是在構造函數中賦值
  3. 跨編譯單元定義全局對象不能確保初始化順序
    1. 將static對象放入一個函數
Fuck& fuck(){
    static Fuck f;
    return f;
}

構造/析構/賦值運算

了解C++默默編調用了哪些函數

如果類中沒有定義,程序卻調用了,編譯器會產生一些函數

  1. 一個 default 構造函數
  2. 一個 copy 構造函數
  3. 一個 copy assignment 操作符
  4. 一個析構函數(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析構函數

  1. 給多態基類應該主動聲明virtual析構函數
  2. 非多態基類,沒有virtual函數,不要聲明virtual析構函數

別讓異常逃離析構函數

構造函數可以拋出異常,析構函數不能拋出異常。

因為析構函數有兩個地方可能被調用。一是用戶調用,這時拋出異常完全沒問題。二是前面有異常拋出,正在清理堆棧,調用析構函數。這時如果再拋出異常,兩個異常同時存在,異常處理機制只能terminate().

  1. 構造函數拋出異常,會有內存泄漏嗎?
    不會
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
    
    ···
}

復制對象時務忘其每一個成分

  1. 記得實現拷貝構造函數和賦值操作符的時候,調用base的相關函數
  2. 可以讓拷貝構造函數和賦值操作符調用一個共同的函數,例如init

資源管理

以對象管理資源

  1. 為了防止資源泄漏,請使用RAII對象,在構造函數里面獲得資源,在析構函數里面釋放資源
  2. shared_ptr,unique_lock都是RAII對象

在資源管理類小心copy行為

  • 常見的RAII對象copy行為
    • 禁止copy
    • 引用計數
    • 深度復制
    • 轉移資源擁有權

在資源管理類中提供對原始資源的訪問

用戶可能需要原始資源作為參數傳入某個接口。有兩種方式:

  1. 提供顯示調用接口
  2. 提供隱式轉換接口(不推薦)

成對使用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);

設計與聲明

讓接口容易被正確使用,不易被誤用

  1. 好的接口很容易被正確使用,不容易被誤用。努力達成這些性質(例如 explicit關鍵字)
  2. “促進正確使用”的辦法包括接口的一致性,以及與內置類型的行為兼容
  3. “防治誤用”b包括建立新類型,限制類型上的操作,束縛對象值,以及消除用戶的資源管理責任
  4. shared_ptr支持定制deleter,需要靈活使用

設計class猶如設計type

寧以pass-by-refrence-to-const替換pass-by-value

  1. 盡量以pass-by-reference-to-const替換pass-by-value,比較高效,並且可以避免切割問題
  2. 以上規則並不使用內置類型,以及STL迭代器,和函數對象。它們采用pass-by-value更合適(其實采用pass-by-reference-to-const也可以)

必須返回對象時,別妄想返回其reference

  1. 不要返回pointer或者reference指向一個on stack對象(被析構)
  2. 不要返回pointer或者reference指向一個on heap對象(需要用戶delete,我覺得必要的時候也不是不可以)
  3. 不要返回pointer或者reference指向local static對象,卻需要多個這樣的對象(static只能有一份)

將成員變量申明為private

  1. 切記將成員變量申明為private
  2. protected並不比public更有封裝性(用戶可能繼承你的base class)

寧以non-member,non-friend替換member

作者說多一個成員函數,就多一分破壞封裝性,好像有點道理,但是我們都沒有這樣遵守。直接寫member函數方便一些。

若所有參數都需要類型轉換,請為此采用non-member函數

如果調用member函數,就使得第一個參數的類失去一次類型轉換的機會。

考慮寫一個不拋出異常的swap函數

  1. 當std::swap效率不高(std::swap調用拷貝構造函數和賦值操作符,如果是深拷貝,效率不會高),提供一個swap成員函數,並確定不會拋出異常。
class Obj{
    Obj(const Obj&){//深拷貝}
    Obj& operator= (const Obj&){深拷貝
private:
    OtherClass *p;
};
  1. 如果提供一個member swap,也該提供一個non-member swap用來調用前者
  2. 調用swap時應該針對std::swap使用using聲明式,然后調用swap不帶任何"命名空間修飾”
void doSomething(Obj& o1, Obj& o2){
    //這樣可以讓編譯器自己決定調用哪個swap,萬一用戶沒有實現針對Obj的swap,還能調用std::swap
    using std::swap;
    
    swap(o1, o2);
}
  1. 不要往std命名空間里面加東西

實現

盡可能延后變量定義式出現的時間

C語言推薦在函數開始的時候定義所有變量(最開始的C語言編譯器要求,現在並不需要),C++推薦在使用對象前才定義對象

盡量少做轉型動作

  1. 如果可以,盡量避免轉型,特別是在注重效率的代碼中避免dynamic_cast。
  2. 如果轉型是必要的,試着將它隱藏於某個函數后。客戶可以隨時調用該函數,而不需要將轉型放入自己的代碼。
  3. 使用C++風格的轉型。

避免返回handles指向對象內部成分

簡單說,就是成員函數返回指針或者非const引用不要指向成員變量,這樣會破壞封裝性

為“異常安全”而努力是值得的

  1. "異常安全函數"承諾即使發生異常也不會有資源泄漏。在這個基礎下,它有3個級別
    1. 基本保證:拋出異常,需要用戶處理程序狀態改變(自己寫代碼保證這個級別就行了把)
    2. 強烈保證:拋出異常,程序狀態恢復到調用前
    3. 不拋異常:內置類型的操作就絕不會拋出異常
  2. "強烈保證"往往可以通過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
  1. inline應該限制在小的,頻繁調用的函數上
  2. inline只是給編譯器的建議,編譯器不一定執行

將文件的編譯依存關系降到最低

  1. 支持"編譯依存最小化"的一般構想是:相依於聲明式,不要相依於定義式。基於此構想的兩個手段是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(){
    
}
  1. 對於STL的對象不需要前置聲明。

繼承與面對對象設計

確定你的public繼承塑模出is-a模型

public繼承意味着is-a。適用於base class身上的每一個函數也一定適用於derived class。

避免遮掩繼承而來的名稱

子作用域會遮掩父作用域的名稱。一般來講,我們可以有以下幾層作用域

  1. global作用域
  2. namespace作用域
    1. Base class作用域
      1. Drive class作用域
        • 成員函數
          • 控制塊作用域
    2. 非成員函數作用域
      • 控制塊作用域

注意:遮掩的是上一層作用域的名稱,重載(不同參數)的函數也會直接遮掩

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);
}

區分接口繼承和實現繼承

  1. 純虛函數:提供接口繼承
    1. Drived class必須實現純虛函數
    2. 不能構造含有純虛函數的類
    3. 純虛函數可以有成員變量
    4. 可以給純虛函數提供定義(wtf)
  2. 虛函數:提供接口繼承和默認的實現繼承
  3. 非虛函數:提供了接口繼承和強制的實現繼承(最好不要在Drived class重新定義非虛函數)

考慮virtual函數以外的選擇

non-virtual interface:提供非虛接口

class Object{
public:
    void Interface(){
        ···
        doInterface();
        ···
    }
private/protected:
    virtual doInterface(){}
}

優點:

  1. 可以在調用虛函數的前后,做一些准備工作(抽出一段重復代碼)
  2. 提供良好的ABI兼容性

聊一聊ABI兼容性

我們知道,程序庫的優勢之一是庫版本升級,只要保證借口的一致性,用戶不用修改任何代碼。

一般一個設計完好的程序庫都會提供一份C語言接口,為什么呢,我們來看看C++ ABI有哪些脆弱性。

  1. 虛函數的調用方式,通常是 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個虛函數
···
}
  1. name mangling 名字粉碎實現重載

C++沒有為name mangling制定標准。例如void fun(int),有的編譯器定為fun_int_,有的編譯器指定為fun%int%。

因此,C++接口的庫要求用戶必須和自己使用同樣的編譯器(這個要求好過分)

  1. 其實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

  1. 缺省參數值是靜態綁定
  2. 虛函數是動態綁定
  3. 遵守這條規定防止出錯

通過復合塑模出has-a或者"根據某物實現出"

  1. 復合的意義和public完全不一樣
  2. 根據某物實現出和is-a的區別:

這個也是什么時候使用繼承,什么時候使用復合。復合代表使用了這個對象的某些方法,但是卻不想它的接口入侵。

明智而審慎地使用private繼承

  1. private繼承是”根據某物實現出“
  2. 唯一一個使用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繼承。(還是沒什么用啊,干嘛不適用復合)

模板與泛型編程

了解隱式接口和編譯期多態

  • 接口:強制用戶實現某些函數
  • 多態:相同的函數名,卻有不同的實現
  1. 繼承和模板都支持接口和多態
  2. 對繼承而言,接口是顯式的,以函數為中心,多態發生在運行期;
  3. 對模板而言,接口是隱式的,多態表現在template具象化和函數重載
//這里接口要求T必須實現operator >
template<typename T>
T max(T a, T b){
    return (a > b) ? a : b;
}

了解typename的雙重意義

  1. 聲明template參數時,前綴關鍵字class和typename可以互換
  2. 使用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

  1. 非類型模板參數造成的代碼膨脹:以函數參數或者成員變量替換
  2. 類型模板參數造成的代碼膨脹:特化它們,讓含義相近的類型模板參數使用同一份底層代碼。例如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;
}
  1. 使用成員函數模版生成“可接受所有兼容類型”的函數
  2. 即使有了“泛化拷貝構造函數”和“泛化的賦值操作符”,仍然需要聲明正常的拷貝構造函數和賦值操作符
  3. 在一個類模版內,template名稱可被用來作為作為“template和其參數”的簡略表達式

所有參數需要類型轉換的時候請為模版定義非成員函數

  1. 當我們編寫一個模版類,某個相關函數都需要類型轉換,需要把這個函數定義為非成員函數
  2. 但是模版的類型推到遇見了問題,需要把這個函數聲明為友元函數幫助推導
  3. 模版函數只有聲明編譯器不會幫忙具現化,所以我們需要實現的是友元模版函數
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;
}
  1. traits采用類模版和特化的方式,為不同的類型提供了相同的類型抽象(都由size)
  2. 為某些類型提供編譯期測試,例如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;
};
  1. C++元編程可以將計算轉移到編譯期,執行速度迅速(缺陷?)

定制new和delete

了解new-handler的行為

new和malloc對比:

  1. new構造對象,malloc不會
  2. new分配不出內存會拋異常,malloc返回NULL
  3. 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++中對象的構造和析構經歷了都兩個階段

  1. operator new, operator delete:分配和釋放內存
  2. 調用構造函數,調用析構函數

替換new和delete的理由,就是需要收集分配內存的資源信息

編寫符合常規的new和delete

  1. operator new應該內含一個無窮循環嘗試分配內存,如果無法滿足,就調用new-handler。class版本要處理“比正確大小更大的(錯誤)申請”
  2. 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;
}    


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM