C++ 11 引入智慧指標之後, 帶來了一致的好評. 智慧指標允許人們更加 "放肆" 地編寫程式碼, 而不用為手動接管記憶體而煩惱. 但是在你不注意的地方, 智慧指標也會導致程式效率低下甚至記憶體流失...

這篇文章不是面向新人的, 如果你之前沒有學習過智慧指標, 那麼你應該先去學習它們. 你可以在《C++ 學習筆記》中找到它們

對於 std::unique_ptr, 我們暫時沒有太多說的. 唯一需要注意的是, 對於 std::shared_ptr, 除了它的建構子之外, 還有兩個建構函式 : std::make_sharedstd::allocate_shared. 第一個大家應該很熟悉了, 第二個和第一個函式比較類似, 但是它並不是使用 new 或者 malloc 來配置記憶體, 而是由 Allocator 負責配置記憶體. 函式 std::allocate_shared 第一個參數就接受一個 Allocator, 接下來的參數和 std::make_shread 一樣. 針對 std::unique_ptr 來說, C++ 11 中並不存在 std::make_unique, 它是在 C++ 14 才引入的. 而且對於 std::unique_ptr, 並不存在一個 std::allocate_unique. 我們將 std::make_sharedstd::allocate_sharedstd::make_unique 統稱為 make 系列函式

首先, 我們來細說 std::shared_ptr. 首先, 我要向大家展示 std::shared_ptr 有著怎樣的構成 :

大家已經知道, std::shared_ptr 自身所維護的指標會在計數器請領的時候回收, 而這個計數器指的一般是上圖中的 shared_ptr 計數器, 也就是強計數. 而計數器使用的是動態記憶體配置, 當強計數和弱計數全部清零的時候, 計數器所配置的記憶體才會被回收. 弱計數是用於 std::weak_ptr 計數的. 這樣, 就使得 std::weak_ptr 有了計算 std::shared_ptr 所維護的指標是否失效的能力

不過, 計數器控制區域並不是總是創建, 它的創建遵循以下原則 :

  • 使用 std::make_shared 建構 std::shared_ptr 將總是創建計數器控制區域
  • 從具有所有權概念的指標 (一般指的是 std::unique_ptr, 有時候也指 std::auto_ptr) 建構 std::shared_ptr 的時候, 將總是創建計數器控制區域
  • 使用內建指標作為引數傳入 std::shared_ptr 的建構子時, 將總是創建計數器控制區域

上面提到的 std::auto_ptr 是 C++ 98 的產物, 它也屬於智慧指標, 只不過 C++ 11 的 std::unique_ptr 對其進行了改進, 導致其在 C++ 11 中被遺棄, 在 C++ 17 之後正式從 C++ 標準樣板程式庫中被移除

基本的介紹完畢之後, 接下來需要向大家詳細敘述智慧指標的使用過程中需要注意的地方

1. 從 this 建構 std::shared_ptr

C++ 中, 直接 delete this; 是被允許的, 但是要保證 this 的更新或者保證 this 指標對應的記憶體不會再次被解構, 否則就會發生未定行為. 然而, 從 this 直接建構 std::shared_ptr 也可能導致未定行為 :

#include <iostream>
#include <vector>

using namespace std;

class Foo {
public:
    void process(vector<shared_ptr<Foo>> &processed) {
        //...
        processed.emplace_back(this);
    }
};

當類別 Foo 被解構的時候, this 就變成迷途指標, 其指涉的記憶體已經被回收. 若類別 Foo 的解構先發生了, 那麼 processed 中的智慧指標所維護的指標會變成迷途指標. 當強計數清零的時候, 會再次進行記憶體回收, 從而導致未定行為; 弱類別 Foo 的解構暫時沒有發生, 但是 processed 已經率先解構, 導致智慧指標中的強計數清零的時候, 智慧指標就會負責回收 this 對應的記憶體, 此時任何對類別 Foo 的使用都可能導致未定行為. 當類別 Foo 需要解構的時候, 又會發生一次未定行為. 為了解決這個問題, 我們應該考慮讓類別 Foo 本身擁有一個計數器控制區域, 當從 this 創建 std::shared_ptr 的時候, 共享計數器控制區域即可. 為此, C++ 標準樣板程式庫中提供了一個類別 std::enable_shared_from_this. 任何有類似需求的類別之需要從 std::enable_shared_from_this<T> 繼承即可 :

#include <iostream>
#include <vector>

using namespace std;

class Foo : enable_shared_from_this<Foo> {
public:
    void process(vector<shared_ptr<Foo>> &processed) {
        //...
        processed.emplace_back(this->shared_from_this());
    }
};

成員函式 shared_from_this 是由 std::enable_shared_from_this 所提供的. 此時, 就不會發生上述的未定行為

2. std::weak_ptr

要說 C++ 11 引入的三個智慧指標中, 最讓人疑惑的就是 std::weak_ptr. 它並不能獨立存在, 依賴於 std::shared_ptr. 一般來說, std::weak_ptr 是通過 std::shared_ptr 建構的. 但是它本身並不提供任何關於指標的操作, 包括解參考等. 由於我們之前已經剖析了 std::shared_ptr 的構成, 已經知道它內部的計數器控制區域存在一個弱計數, 所以自然可以得到 std::weak_ptr 並不影響強計數, 而影響弱計數. 由於 std::shared_ptr 所維護的指標的回收並不受到弱計數的影響, 因此 std::weak_ptr 並不影響 std::shared_ptr 所維護的記憶體, 包括配置和回收的時機

std::weak_ptr 主要用於檢測 std::shared_ptr 是否失效 :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
    shared_ptr<int> sp {new int};
    weak_ptr<int> wp {sp};
    sp = nullptr;
    if(wp.expired()) {
        cout << "expired" << endl;      //輸出結果 : expired
    }
}

如果 std::weak_ptr 檢測到 std::shared_ptr 所維護的指標並沒有失效, 那麼我們可以通過 std::weak_ptr 來創建 std::shared_ptr, 從而進行安全的指標操作 :

auto sp {wp.lock()};

或者

shared_ptr<int> sp {wp};

不過上述兩種從 std::weak_ptr 創建 std::shared_ptr 的操作有時候會有不同的結果. 若 std::weak_ptr 檢測到 std::shared_ptr 所維護的指標並沒有失效, 那麼上述兩種建構方法都行得通, 而且沒有其它問題. 若 std::weak_ptr 檢測到了 std::shared_ptr 維護的指標已經失效, 那麼 wp.lock() 會回傳一個維護空指標的 std::shared_ptr 物件; 而使用 std::weak_ptr 建構 std::shared_ptr 將導致擲出 std::bad_wak_ptr 例外情況

考慮一個關於 std::weak_ptr 的實例 : 設有一個類別 people, 其有衍生類別 admin, teacher, headmasterstudent. 它們的信息通過讀資料庫獲得 (這有一缺陷, 就是程式運作的速度將受限於網路的狀況和資料庫搜尋資料的速度). 我們有一函式 load_information 專門用於讀取信息 :

people *load_information(int id);

load_information 回傳的指標需要自己來回收, 因此我們使用智慧指標 :

std::unique_ptr<people> load_information(int id);

如果部分人員的信息有被共享的需求, 那麼需要使用 std::shared_ptr :

std::shared_ptr<people> load_information(int id);

在信息被頻繁使用的情況下, 一種可能是用完即棄, 這會導致 std::shared_ptr 中的強計數減少. 當 std::shared_ptr 中維護的指標並沒有回收的時候, 我們直接回傳; 否則, 我們重新向資料庫請求信息. 這種情況下, 如果部分信息被頻繁地使用, 那麼可以減少因為網路和資料庫的原因而導致的程式運作效率低下. 於是, 除了函式 load_information, 我們另外實作一個 fast_load_information :

#include <map>

using namespace std;
class people {
    //...
};
class admin : public people {
    //...
};
class teacher : public people {
    //...
};
class headmaster : public teacher {
    //...
};
class student : public people {
    //...
};
shared_ptr<people> load_information(int);
shared_ptr<people> fast_load_information(int id) {
    static map<int, weak_ptr<people>> cache {};
    auto result {cache[id].lock()};
    if(result) {
        return result;
    }
    result = load_information(id);
    cache.erase(id);
    cache[id] = result;
    return result;
}

這樣做的好處有兩個 : 一是如果 std::shared_ptr 中維護的指標仍然有效, 那麼我們無須再次從資料庫中請求信息, 增加了程式的運作時長; 二是使用 std::weak_ptr 而不使用 std::shared_ptr 避免了 局域靜態變數 cache 影響強計數, 這會導致所有指標的回收都在程式終結的那個時候, 影響了指標本身的生存週期, 還可以檢測 std::shared_ptr 是否仍然有效

當然, 函式 fast_load_information 並不只是帶來好處. 我們之前說過, 計數器控制區域並不是隨著智慧指標所維護的記憶體被回收而被回收, 而是等到所有計數清零之後才會被回收. 我們使用了局域靜態變數, 它只有黨程式終結的時候才會被回收. 因此, 所有計數器控制區域的記憶體都只能在程式終結的時候才會被回收. 不過針對現代記憶體, 這一點不算什麼, 因為比起記憶體佔用來說, 我們更希望程式運作效率高

3. 迴圈參照

相比於 std::unique_ptr, std::shared_ptr 還存在一個問題, 這個問題會導致記憶體流失. 你可能覺得不可思議, 智慧指標居然也會導致記憶體流失, 那我還需要用它嗎? 值得慶幸的是, 這個問題只在 std::shared_ptr 上有, 而且是一個不常遇到的問題. 即使某個時候, 你真的遇到了這個問題, 那麼也是有解決方法的. 這個問題就是 std::shared_ptr 的迴圈參照問題. 同樣帶有垃圾回收的程式設計語言, 例如 Java 等都不存在這個問題 (設計者已經幫我們解決了). 考慮下面這個實例 :

#include <iostream>

using namespace std;
struct Foo {
    shared_ptr<Foo> other;
    ~Foo() {
        cout << "Foo destructor" << endl;
    }
};
int main(int argc, char *argv[]) {
    shared_ptr<Foo> a(new Foo), b(new Foo);     //1
    a->other = b;       //2
    b->other = a;       //3
}

上述程式碼運作完畢之後, 使用 Valgrind 檢測記憶體流失的情況, 結果為 :

【C++】智慧指標-Jonny'Blog

我們可以看到確實存在 DefinitelyLost 和 IndirectlyLost. 我們來分析出現這種結果的原因, 為了簡化, 我不將 std::shared_ptr 中所有的信息都標識出來, 僅給出必要的一部分. 陳述式 1 運作完畢之後, 有下圖成立 :

在陳述式 2 和 3 結束之後, 有如下圖成立 :

上圖中, 從任意位置出發, 可以到達任意位置, 即出現了迴圈參照

在所有陳述式運作完畢之後, 開始進行物件解構. 在某一個局域可視範圍之內, 物件的建構是按照物件宣告順序的逆序進行的, 也就是先宣告的物件最後解構. 那麼在上述程式碼中, 首先解構 b, 再解構 a. 我們使用紅色字體的 ab 表示物件已經被解構. 首先解構物件 b, 那麼有

此時, b 已經被解構了, 但是 b 中所維護的指標並沒有回收, 因為強計數此時還沒有清零. 同樣地, 物件 a 也要被解構, 於是有

由於解構之後, 物件 ab 的強計數都沒有清零, 因此他們所維護的指標自然不會被回收, 也就是說, Foo 的解構子不會被呼叫, 那麼也就沒有任何提示留下了

如果大家覺得圖片不夠過癮, 我自己寫了一個指標模擬 std::shared_ptr, 下面程式碼可以重現迴圈參照的問題 :

#include <iostream>

using namespace std;
template <typename T>
class ptr {
private:
    T *p;
    mutable unsigned long count;
public:
    ptr() : p {}, count {1} {}
    explicit ptr(T *p) : p {p}, count {1} {}
    ptr(const ptr &rhs) : p {rhs.p}, count {++rhs.count} {}
    ptr(ptr &&rhs) noexcept : p {rhs.p}, count {rhs.count} {
        rhs.p = nullptr;
    }
    ptr &operator=(const ptr &rhs) {
        if(this == &rhs) {
            return *this;
        }
        this->~ptr();
        this->p = rhs.p;
        this->count = ++rhs.count;
        return *this;
    }
    ptr &operator=(ptr &&rhs) noexcept {
        if(this == &rhs) {
            return *this;
        }
        this->~ptr();
        this->p = rhs.p;
        this->p = rhs.count;
        rhs.~ptr();
        return *this;
    }
    ~ptr() noexcept {
        if(--this->count == 0) {
            delete this->p;
        }
    }
    T *operator->() {
        return this->p;
    }
    T &operator*() {
        return *this->p;
    }
};
struct Foo {
    ptr<Foo> other;
    ~Foo() noexcept {
        cout << "Foo destructor" << endl;
    }
};
int main() {
    ptr<Foo> p1 {new Foo}, p2 {new Foo};
    p1->other = p2;
    p2->other = p1;
    return 0;
}

對於迴圈參照的問題, 我們需要將類別 Foo 內部的 other 成員的型別更正為 std::weak_ptr. 這是因為 std::weak_ptr 不會影響強計數, 自然也就消除了迴圈參照的問題 :

#include <iostream>

using namespace std;
struct Foo {
    weak_ptr<Foo> other;
    ~Foo() {
        cout << "Foo destructor" << endl;
    }
};
int main(int argc, char *argv[]) {
    shared_ptr<Foo> a(new Foo), b(new Foo);     //1
    a->other = b;       //2
    b->other = a;       //3
}

這下使用 Valgrind 檢測就十分正常了 :

【C++】智慧指標-Jonny'Blog

該有的信息也被正常輸出 :

【C++】智慧指標-Jonny'Blog

4. make 系列函式

設有函式

void process(shared_ptr<int>, int);
int compute();

在進行函式呼叫的時候, 假設寫出了 process(new int, compute()) 這樣的程式碼. 一般而言, 好像是沒有什麼問題, 但是其中隱含著例外安全的問題. 函式 compute 並沒有被 noexcept 所標識, 所以函式 compute 可能會擲出例外情況. 對於函式呼叫 process(new int, compute()), 會有如下的步驟 :

  1. 配置一個 int 大小的記憶體 (運作 new int)
  2. 建構一個 std::shared_ptr<int> (運作 std::shared_ptr<int> 的建構子)
  3. 呼叫函式 compute
  4. 將所有引數傳入函式 process

上述步驟中, 有兩個限制 : 步驟 2 必須在步驟 1 之後; 步驟 4 必須位於最後一步. 於是便又了三種順序 : 1234、3124 和 1324

至於順序 3124 沒有什麼問題. 即使函式 compute 擲出例外情況, 此時不存在任何記憶體已經被配置, 所以也不存在記憶體流失的情況. 對於剩下兩種順序, 它們有一個共同點, 就是先進行記憶體配置, 然後才運作函式 compute. 此時, 若函式 compute 擲出例外情況, 當 std::shared_ptr 並沒有建構完成的時候, 例外被捕獲, 於是記憶體流失了. 如果 std::shared_ptr 建構完成了或者 std::shared_ptr 建構過程中出現例外情況, 那麼即使例外情況被捕獲, 也沒有什麼問題. 此時, 我們已經完全將記憶體交管給 std::shared_ptr, 我們自己不用負責

因此, 真正有問題的順序是 1324. 那麼編碼器會故意避開這個順序嗎? 答案是不會. 如何選擇順序, 對於編碼器來說, 只要遵循步驟 2 必須在步驟 1 之後, 步驟 4 必須位於最後一步就可以了. 至於具體的順序是什麼, 要看編碼器覺得哪種順序可能產生更好的程式碼, 運作效率更高, 編碼器就會選擇哪一種. C++ 標準並沒有規範函式呼叫時, 引數產生的順序要遵循什麼限定

要解決這種情況, 我們應該優先選用 std::make_shared 來建構 std::shared_ptr, 而並非使用 new. 這樣呼叫函式就沒有什麼問題 :

process(std::make_shared<int>, compute());

這只是打多了幾個字罷了. 當 std::shared_ptr 完成建構, 即使因為函式 compute 擲出例外情況離開當前可視範圍時, std::shared_ptr 也可以幫助我們回收剛剛配置的記憶體. 當然, 如果由於例外情況沒有被捕獲而導致的程式終結, 那麼此時任何資源都是由作業系統負責回收

上面所提到的不僅僅是針對 std::shared_ptr 的, 並且是針對 std::unique_ptr 和內建指標的. 如果大家看過《Effective C++》, 那麼對上述情況應該是比較熟悉的

使用 std::make_shared 函式而不使用 new 還有另外一個好處 : 程式有機會得到性能的提升. 也就是說, 使用 std::make_shared 來建構 std::shared_ptr 有機會讓編碼器產生更小更快的程式碼

對於程式碼

std::shared_ptr<int> sp(new int);

會發生兩次記憶體配置. 一次是引數的 new int, 另外一次是建構 std::shared_ptr 時的計數器控制區域配置. 但是如果使用 std::make_shared 來建構 std::shared_ptr, 就不存在兩次配置, 而是一步到位的. 因為 std::make_shared 中配置的記憶體既可以為維護的記憶體所服務, 也可以為計數器控制區域所服務. 這種情況下, 通過編碼器優化將減少程式的靜態尺寸, 同時還能使得編碼器有機會產生更高效的程式碼. 由於每一次記憶體的配置都需要使用額外空間存儲配置的大小, 於是使用 std::make_shared 幾乎總比直接使用 new 更少地佔用記憶體

通過上述分析, 你可能會產生我更建議你使用 make 系列函式, 而不使用 new 的錯覺. 但是 make 系列函式並不總是帶來好處.

首先, make 系列函式並不支援自訂刪除器, 這也包括 std::make_unique. 另外, make 系列函式也不支援初始化列表, 因為 make 系列函式借助了完美轉遞, 我們在文章《【C++】移動、通用參考和完美轉遞》中也提到過, 完美轉遞中並不支援初始化列表的引數型別推導. 另外, 完美轉遞的時候, make 系列函式使用的是函式風格的初始化, 而並非初始化列表風格的初始化 :

#include <vector>

using namespace std;
int main(int argc, char *argv[]) {
    auto pv1 {make_shared<vector<int>>({10, 20})};      //Compile Error
    auto pv2 {make_shared<vector<int>>(10, 20)};
}

其中, *pv2 中存儲這 10 個值為 20 的元素. 如果非要使用 make 系列的函式, 也並非不可 :

#include <vector>

using namespace std;
int main(int argc, char *argv[]) {
    auto init_list = {10, 20};
    auto pv {make_shared<vector<int>>(init_list)};      //OK
}

之前我們提到過, 使用 new 而不使用 std::make_shared 來建構 std::shared_ptr 需要兩次記憶體配置, 而使用 std::make_shared 僅僅需要一次. 然而, 這並不只帶來好處. 考慮一個尺寸非常非常大的類別 Foo, 即 sizeof(Foo) 的值非常非常大. 此時, 我們若使用 std::make_shared 進行配置, 那麼記憶體需要在所有計數器清零之後才會進行回收. 因此, std::shared_ptr 所維護的那個指標也要等到這個時候才會被回收, 即使這個維護的指標早就已經沒用了. 這裡要特別指出的是, 弱計數的值並不一定等於有效 std::weak_ptr 的數量, 因為 C++ 標準樣板程式庫的作者們發現了一些方法, 通過向弱計數中添加一些額外的信息使得編碼器產生更好更快的程式碼, 提升程式運作效率. 那麼, 記憶體就必須為那個特別大的 Foo 類別保留至少 sizeof(Foo) 大小的空間, 直到整塊記憶體被回收為止. 但是如果使用 new 配置記憶體就不一樣了. 當強計數清零的時候, 就會回收至少 sizeof(Foo) 大小的空間, 剩下那塊計數器控制區域對應大小的空間, 和類別 Foo 的大小比起來簡直小巫見大巫

還有一種比較少見的情況, 就是某些類別多載了 operator newoperator delete 運算子. 由於類別對於記憶體控制有著特殊的需求, 因此使用 make 系列函式所產生的物件, 其帶有的預設刪除器可能並不適用於這個類別. 此時, 應該使用 new 來配置記憶體

綜上所述, 我給你的建議並非摒棄 new 而擁抱 make 系列函式, 而是 : 優先選用 make 系列函式. 當 make 系列函式不足以滿足我們需求的時候, 才使用 new

4. std::unique_ptr

如果你閱讀過《Effective C++》或者有著豐富的 C++ 撰寫經驗, 那麼你應該熟悉 PIMPL 手法. 這個手法主要用於降低編碼依賴. 若有一個類別如下 :

#include <vector>
#include <string>
#include <bitset>
#include <deque>
#include "Bar.h"

using namespace std;
class Foo {
    string s;
    vector<double> v;
    bitset<256> set;
    Bar<Foo> b;
    deque<Bar<int>> d;
};

其中, 標頭檔 Bar.h 中包含了類別 Bar 的信息. 為了使得 Foo 類別可以使用, 你至少要添加 5 個前處理指令 (當然, 有些標頭檔可能已經包含了另外一個標頭檔, 但是嚴格來說卻是是 5 個). C++ 標準樣板程式庫的標頭檔 <vector><string><bitset><deque> 不經常發生變化, 它們只在新的 C++ 標準發布的時候才可能會更新. 但是標頭檔 Bar.h 可能經常發生變化. 於是, 每一次當標頭檔 Bar.h 發生變化的時候, 類別 Foo 的使用者不得不進行重新編碼. 為此, 我們使用指標和未完成型別, 即 PIMPL 手法減少編碼依賴 :

Foo.h :

class Foo {
private:
    struct impl;
    impl *p;
public:
    Foo();
    ~Foo();
    //...
};

Foo.cpp :

#include <string>
#include <vector>
#include <bitset>
#include <deque>
#include "Bar.h"

using namespace std;
struct Foo::impl {
    string s;
    vector<double> v;
    bitset<256> set;
    Bar<Foo> b;
    deque<Bar<int>> d;
};

Foo::Foo() : p {new Foo::impl()} {}
Foo::~Foo() {
    delete this->p;
}
//...

這是舊版準下的寫法. 在 C++ 11 之後, 我們使用 std::unique_ptr 替代內建指標 :

Foo.h :

#include <memory>

class Foo {
private:
    struct impl;
    std::unique_ptr<impl> p;
public:
    Foo();
    //...
    //此處由於 std::unique_ptr 接管了記憶體, 因此我們無須自訂解構子
};

Foo.cpp :

//...

Foo::Foo() : p {make_unique<Foo::impl>()} {}
//...

我們撰寫 Foo 的時候沒有任何問題, 可以通過編碼, 但是 Foo 類別的使用者就沒有那麼幸運了, 即使最簡單的程式碼都會出現編碼錯誤 :

#include "Foo.h"

Foo f;      //Compile Error

這個編碼錯誤是由於編碼器隱含地呼叫了類別 Foo 的解構子造成的. 在程式碼中, 我們在註解中說明了為什麼沒有宣告解構子的原因. 我們的本意是想讓編碼器為我們合成, 並採用編碼器為我們產生的版本, 而這正是造成編碼錯誤的原因. 在編碼器為我們產生的解構子中, 呼叫了 std::unique_ptr 的解構子 :

Foo::~Foo() {
    this->p.~unique_ptr<Foo::impl>();
    //...
}

在呼叫 std::unique_ptr<Foo::impl> 的解構子之前, 編碼器會按照解構子中的要求, 進行編碼期檢查, 檢查類別 Foo::impl 是否是一個未實作的型別. 如果是, 那麼擲出編碼錯誤, 就是在此處導致了編碼無法通過. 通常來說, 沒有一定經驗, 這個編碼錯誤相當難排查

這個編碼錯誤難排查, 但解決這個編碼錯誤的方式卻十分簡單, 只需要為類別 Foo 宣告一個解構子即可. 我們告訴編碼器, 我們暫時不需要編碼器生成的版本 :

Foo.h :

#include <memory>

class Foo {
private:
    struct impl;
    std::unique_ptr<impl> p;
public:
    Foo();
    ~Foo();
    //...
};

我剛才只是說暫時不需要, 實際上我是需要的, 因此我只要在 Foo.cpp 中委託給編碼器即可 :

Foo.cpp :

//...

Foo::~Foo() = default;
//...

當然, 自己實作一個也不難 :

//...

Foo::~Foo() {}
//...

但是, 這樣的實作方式可能使得編碼器無法更優化我們的程式碼. 比如, 我們有時候忘了寫一些東西, 導致出現這樣那樣的問題, 或者說我們寫的並沒有編碼器產生的版本要好. 這裡特別提醒一下, 在 C++ 11 之後, 不論是否手動地為解構子標識 noexcept, 解構子預設情況下都帶有不擲出例外情況的保證. 當然, 所有躲在的 operator delete 也隱含地具備了 noexcept 屬性

這些實作在 Foo.cpp 中的解構子, 我們仍然讓編碼器為我們生成. 但是解決了這個問題, 另外一個問題又接踵而來. 一開始, 我們告訴編碼器我們需要自訂解構子來避免編碼錯誤, 但是我們最終仍然採用了編碼器生成的版本. 然而, 當編碼器為類別 Foo 生成移動操作的時候, 編碼器並不知道我們最後仍然採用編碼器生成的版本, 因此編碼器自然認為我們需要自訂解構子, 從而拒絕移動操作的生成. 解決這個的方法和上述問題一模一樣, 我們先告訴編碼器我們要自訂移動操作, 但是最後仍然採用編碼器生成的版本 :

#include <memory>

class Foo {
private:
    struct impl;
    std::unique_ptr<impl> p;
public:
    Foo();
    ~Foo();
    Foo(Foo &&);
    Foo &operator=(Foo &&);
    //...
};

但是你不能寫成 :

#include <memory>

class Foo {
private:
    struct impl;
    std::unique_ptr<impl> p;
public:
    Foo();
    ~Foo();
    Foo(Foo &&) = default;
    Foo &operator=(Foo &&) = default;
    //...
};

因為這樣就是告訴編碼器立即判斷移動操作是否可以生成, 當編碼器剛剛處理到這裡的時候肯定是無法生成的

然而使用 std::unique_ptr 實作的類別 Foo 預設情況下是沒有複製操作的, 因為 std::unique_ptr 並不能進行複製, 只能進行移動. 所以, 要想類別 Foo 帶有複製操作, 需要自訂複製操作或者採用 std::shared_ptr 來替換 std::unique_ptr. 那麼 std::shared_ptrstd::unique_ptr 在 PIMPL 手法中的表現是否相同呢? 答案是否定的. 雖然刪除器的型別是 std::unique_ptr 型別的一部分, 但是對於 std::shared_ptr 來說並不是這樣. std::shared_ptr 的刪除器是通過動態記憶體配置而存儲的, 這就使得 std::shared_ptr 的刪除器可以是未實作的型別. 也就是所, std::shared_ptr 中的計數器控制區域也用了 PIMPL 手法. 針對 std::unique_ptr 就完全相反了, 在 std::unique_ptr 的解構子中, 檢查了型別是否為未實作的型別, 因為我們需要對這個型別對應的物件使用 delete 運算子, 而未實作的型別因為不知道它的大小, 所以根本沒有辦法使用 delete 運算子

未實作的型別檢驗 : 我們可以通過 sizeof 運算子檢測一個型別是否未實作, 因為 sizeof 運算子要求給定的型別引數必須實作, 否則沒有辦法計算其大小. 因此, 我們可以配合 C++ 11 的靜態斷言 static_assert :

static_assert(sizeof(T) > 0, "The type T is unimplemented!");

如果型別已經實作, 由於任何型別的靜態尺寸都大於 0, 因此上述靜態斷言不會有任何錯誤. 由於靜態斷言的判斷在編碼器, 所以對運作期的運作效率沒有任何影響

《C++ Standard Template Library》中, 我會講述 C++ 標準樣板程式庫中 std::shared_ptrstd::unique_ptr 的實作, 到時候你會對此有著更加清晰的認識