摘要訊息 : 智慧指標也會導致程式效率低下甚至記憶體流失...

0. 前言

《C++ 學習筆記》中, 我們已經詳細講述過了智慧指標, 這篇文章是針對《C++ 學習筆記》中的智慧指標進行補充. 如果閣下沒有學習過 C++ 中的智慧指標, 那麼建議先閱讀《C++ 學習筆記》.

更新紀錄 :

  • 2022 年 5 月 26 日進行第一次更新和修正.

1. 建構函式

對於 std::shared_ptr, 除了它的建構子之外, 還有兩個建構函式 : std::make_sharedstd::allocate_shared. 第一個大家應該很熟悉了. 第二個和第一個函式比較類似, 但是它並不是使用 new 或者 std::malloc 來配置記憶體, 而是由用戶自訂的空間配置器負責配置記憶體, 類似於容器中的空間配置器. 函式 std::allocate_shared 第一個參數就接受一個空間配置器, 接下來的參數和 std::make_shread 一樣. 針對 std::unique_ptr 來說, C++ 11 並沒有引入 std::make_unique, 它是在 C++ 14 才引入的, 並且不存在 std::allocate_unique.

2. std::shared_ptr

Figure 1. std::shared_ptr 結構

Figure 1 描述了 std::shared_ptr 內部的大致結構. 大家已經知道 std::shared_ptr 自身所維護的指標會在計數器清零的時候回收, 而這個計數器指的一般是上圖中的強計數器. 而計數器使用的空間是動態記憶體配置的, 當強計數和弱計數全部清零的時候, 計數器所配置的記憶體才會被全部回收. 弱計數器是用於計數 std::weak_ptr 的. 因此, 當所有 std::shared_ptr 都被解構的時候, std::shared_ptr 只會去回收自身維護指標對應的記憶體. 但是計數器控制區域並不一定隨著指標本身的回收而回收的. 計數器控制區對應的記憶體一般等到了所有從此 std::shared_ptr 建構的 std::weak_ptr 都被解構的時候才被回收. 通常這個時候, 所有從 std::shared_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/03 的智慧指標, 只不過 C++ 11 的 std::unique_ptr 對其進行了改進, 導致其在 C++ 11 中被遺棄, 在 C++ 17 之後正式從 C++ 標準樣板程式庫中被移除.

2.1 從 this 建構 std::shared_ptr

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

#include <memory>
#include <vector>

class Foo {
public:
    void process(std::vector<std::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> 繼承即可 : class Foo : std::enable_shared_from_this<Foo>.

2.2 std::weak_ptr

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

#include <memory>

int main(int argc, char *argv[]) {
    std::shared_ptr<int> sp {new int};
    std::weak_ptr<int> wp {sp};
    //sp = nullptr;
    if(wp.expired()) {
        // ...
    }else {
        std::shared_ptr<int> sp0 {wp};      // 等價於 std::shared_ptr<int> sp0 {wp.lock()};
    }
}

不過, 直接通過建構子建構 std::shared_ptr 和從 std::weak_ptr 的成員函式 lock 建構 std::shared_ptr 可能有時候存在不同的結果. 若 std::weak_ptr 檢測到 std::shared_ptr 所維護的指標並沒有失效, 那麼上述兩種建構方法都行得通, 而且沒有任何不同或者其它問題. 若 std::weak_ptr 檢測到了 std::shared_ptr 維護的指標已經失效, 那麼成員函式 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 <memory>
#include <map>

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

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

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

2.3 迴圈參照

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

#include <memory>
#include <iostream>

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

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

Figure 2. 記憶體流失

DefinitelyLost 和 IndirectlyLost 說明 Code 4 中確實存在記憶體流失的問題. 然而, Code 4 中採用了智慧指標, 我們來分析出現這種結果的原因.

為了方便區分, 我們把 a 中的 new Foo 稱為 Foo_1, b 中的 new Foo 稱為 Foo_2. 註解為 1 的宣告陳述式運作完成之後, *a 的型別為 Foo, 它管理著 Foo_1->other; *b 的型別也是 Foo, 它管理著 Foo_2->other. 到目前為止, Foo_1->otherFoo_2->other 都是 nullptr, Foo_1 和 Foo_2 的強計數器都為一. 註解為 2 的指派陳述式運作完成之後, 有 Foo_1->other = Foo_2. 註解為 3 的指派陳述式運作完成之後, 有 Foo_2->other = Foo_1. 本來 Foo_1 和 Foo_2 的強計數都是一, 由於有了新的指涉, 所以它們現在的強計數為二.

Figure 3. Code 4 解構

Figure 3 中, 從任意位置出發, 我們總能到達其它三個位置. 這邊出現了迴圈參照. 在註解 3 的指派運算子運作完成之後, 便進入了解構的準備階段. 先宣告的變數先建構的變數在解構的階段會在最後才被解構. 也就是說, 在 Code 4 的主函式 main 中, b 先被解構, Foo_2 的強計數變為一. 此時, 由於 Foo_1->other 仍然有指涉 b, 即 Foo_2, 於是 Foo_2 還不能被 delete. 然後 a 被解構, Foo_1 的強計數變成一. 此時由於 Foo_2->other 仍然有指涉 a, 即 Foo_1, 於是 Foo_1 還不能被 delete. Foo_1Foo_2 這兩個 new 出來的物件到最後因為強計數沒有減為零, 所以智慧指標直到函式 main 終結, 都沒有辦法回收 Foo_1Foo_2 這兩個物件, 從而導致了記憶體流失.

我自己寫了一個指標模擬 std::shared_ptr, 下面程式碼可以重現迴圈參照的問題 :

#include <iostream>

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 {
        std::cout << "Foo destructor" << std::endl;
    }
};
int main() {
    ptr<Foo> p1 {new Foo}, p2 {new Foo};
    p1->other = p2;
    p2->other = p1;
    return 0;
}
// 最後沒有任何輸出

為了解決這個問題 我們要將 Code 4 的類別 Foo 中的成員 other 的型別更正為 std::weak_ptr, 這是因為 std::weak_ptr 不會影響強計數, 自然也就消除了迴圈參照的問題.

3. 再論建構函式

第 1 節中我們對智慧指標的建構函式進行了初步的討論, 這一節中我們進一步討論使用建構函式的過程中可能存在的一些細節問題.

設有函式宣告

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 更少地佔用記憶體.

通過上述分析, 你可能會產生我更建議你使用建構函式, 而不使用 new 的錯覺. 但是建構函式並不總是帶來好處. 首先, 建構函式並不支援自訂刪除器, 這當然也包括 C++ 14 引入的 std::make_unique. 另外, 建構函式也不支援初始化列表, 因為建構函式借助了完美轉遞, 我們在文章《【C++】移動, 通用參考和完美轉遞》中也提到過, 完美轉遞中並不支援初始化列表的引數型別推導. 另外, 完美轉遞的時候, 建構函式使用的是函式風格的初始化, 而並非初始化列表風格的初始化. 例如 std::make_shared<std::vector<int>>({10, 20}) 就會產生編碼錯誤, 而 std::make_shared<std::vector<int>>(10, 20) 就完全沒有問題 (std::vector 中儲存了 1020). 如果非要使用建構函式, 也並非不可 :

#include <memory>
#include <vector>

int main(int argc, char *argv[]) {
    auto init_list = {10, 20};
    auto pv = std::make_shared<std::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 運算子. 由於類別對於記憶體控制有著特殊的需求, 因此使用建構函式所產生的物件, 其帶有的預設刪除器可能並不適用於這個類別. 此時, 應該使用 new 來配置記憶體.

綜上所述, 我給你的建議並非摒棄 new 而擁抱建構函式, 而是優先選用建構函式. 當建構函式不足以滿足我們需求的時候, 或者可能存在細節問題的時候, 才使用 new.

4. std::unique_ptr

PIMPL 手法是通過指標來降低標頭檔依賴性的常用手法. 若有一個類別如下 :

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

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

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

class Foo {
private:
    struct impl;
    impl *p;
public:
    Foo();
    ~Foo();
    //...
};
#include <string>
#include <vector>
#include <bitset>
#include <deque>
#include "Bar.h"

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

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

Code 7 是 C++ 98/03 的寫法. 在 C++ 11 之後, 我們可以使用 std::unique_ptr<impl> 替代 impl *p, 這樣解構子也無需自訂, 我們就不需要宣告 ~Foo(); 了. 然而, 一旦使用了 std::unique_ptr 替換了內建指標, 情況就完全不同了, 即時最簡單的宣告 Foo f; 都會產生編碼錯誤. 這個編碼錯誤是由於編碼器隱含地呼叫了類別 Foo 的解構子造成的. 我們說過因為無需手動接管記憶體, 所以不需要宣告解構子的原因. 我們的本意是想讓編碼器為我們合成, 並採用編碼器為我們產生的版本, 而這正是造成編碼錯誤的原因. 在編碼器為我們產生的解構子中, 呼叫了 std::unique_ptr 的解構子 :

/* 這是編碼器為我們合成的版本 */
Foo::~Foo() {
    this->p.~unique_ptr<Foo::impl>();
    // ...
}

在呼叫 std::unique_ptr<Foo::impl> 的解構子之前, 編碼器會按照解構子中的要求, 進行編碼期檢查, 檢查類別 Foo::impl 是否是一個未實作的型別. 如果是, 那麼擲出編碼錯誤, 就是在此處導致了編碼無法通過. 通常來說, 沒有一定經驗, 這個編碼錯誤的排查相當難. 然而, 解決這個編碼錯誤的方式卻十分簡單, 只需要為類別 Foo 宣告一個解構子即可, 然後在 Foo.cpp 中使用 = default 讓編碼器合成即可 :

#include <memory>

class Foo {
private:
    struct impl;
    std::unique_ptr<impl> p;
public:
    Foo();
    ~Foo();     // 不能省略宣告
    //...
};
#include <string>
#include <vector>
#include <bitset>
#include <deque>
#include "Bar.h"

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

Foo::Foo() : p {new Foo::impl()} {}
Foo::~Foo() = default;      // 明確要求編碼器合成, 而不是隱含地讓編碼器合成
//Foo::~Foo() noexcept {}     // 當然, 自己實作一個也不難
// ...

雖然自己實作一個解構子並不難, 但是能夠讓編碼器生成的情況下, 我們儘量使用 = default 讓編碼器為我們生成. 但是解決了這個問題, 另外一個問題又接踵而來. 一開始, 我們告訴編碼器我們需要自訂解構子來避免編碼錯誤, 但是我們最終仍然採用了編碼器生成的版本. 然而, 當編碼器為類別 Foo 生成移動操作的時候, 編碼器並不知道我們最後仍然採用編碼器生成的版本, 因此編碼器自然認為我們需要自訂解構子, 從而拒絕移動操作的生成. 解決這個的方法和上述問題一模一樣, 我們先告訴編碼器我們要自訂移動操作, 但是最後仍然使用 = default 讓編碼器生成並且採用編碼器生成的版本. 要注意, 我們不能在宣告的時候就是用 = 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::unique_ptr 的第二個樣板引數, 但是對於 std::shared_ptr 來說並不是這樣. std::shared_ptr 的刪除器是通過動態記憶體配置而存儲的, 這就使得 std::shared_ptr 的刪除器可以是未實作的型別. 也就是說, std::shared_ptr 中的計數器控制區域也用了 PIMPL 手法. 針對 std::unique_ptr 就完全相反了, 在 std::unique_ptr 的解構子中, 檢查了型別是否為未實作的型別 (一般通過配合 static_assertsizeof, 如果 sizeof(T) > 0 就說明 T 是一個已經被實作的型別), 因為我們需要對這個型別對應的物件使用 delete 運算子, 而未實作的型別因為不知道它的大小, 所以根本沒有辦法使用 delete 運算子.