C++ the rule of five

在 C++ 11 之前有所謂 the rule of three,也就是自訂類別有寫自己的複製建構子、複製賦值運算子或解構子其中之一,應該將其他兩個也補上,因為通常編譯器自動產生的不會符合你需要的,不然你也不用自己寫了對吧。

在 C++ 11 後,因為多了移動語意,所以還要加上兩個特別的函式,也就是移動建構子和移動賦值運算子。

那要怎麼寫呢?假設有一個自訂類別 Foo,有兩個成員變數,一個是內建型別 int,另一個是自訂型別 std::vector

class Foo {
private:
    int id_;
    std::vector ticks_;
};

以下示範正確的宣告及實作方法。

首先是建構子,需不需要傳參數是根據此類別的需求而定,請至少寫一種,正確的寫法是使用 initializer list,也就是冒號 (:) 後面接成員變數的初始化,如果是在 body 內用賦值的,其實效率會慢,因為實際行為是先用 defualt 建構子建構出成員變數,再用複製賦值運算子拷貝一次:

class Foo {
public:
    // constructor
    Foo() : id_(0), ticks_() {}

    // constructor
    Foo(int id, const std::vector& ticks) : id_(id), ticks_(ticks) {}

    // constructor 不好的寫法,先用預設建構子建構出 int 及 std::vector,再用 = 賦值一次
    Foo(int id, const std::vector& ticks) {
        id_ = id;
        ticks_ = ticks;
    }

private:
    int id_;
    std::vector ticks_;
};

第二個是解構子,解構子很簡單,首先考慮此函式有沒有指標需要 delete,有的話建議改用 smart point,再來考慮此類別有沒有虛擬函式,有的話就加 virtual,沒有的話就不用加:

class Foo {
public:
    // destructor 沒有其他虛擬函式不用加上 virtual
    ~Foo() {}

    // 或

    // destructor 有其他虛擬函式就加上 virtual
    virtual ~Foo() {}

private:
    int id_;
    std::vector ticks_;
};

第三個是複製建構子和移動建構子,一樣要使用 initializer list 的寫法,要注意的是移動建構子,如果有成員變數不是內建型別,請使用 std::move 包起來,這樣才可以轉成 rvalue,因為具名的右值參考 (named rvalue reference) 其實是左值 (rvalue),也就是程式中的 Foo&& rhs:

class Foo {
public:
    // copy constructor
    Foo(const Foo& rhs) : id_(rhs.id_), ticks_(rhs.ticks_) {}

    // move constructor
    Foo(Foo&& rhs) : id_(rhs.id_), ticks_(std::move(rhs.ticks_)) {} // 雖然 rhs 型別是 Foo&&,但其實是左值,因此要用 std::move 強制轉換成右值

private:
    int id_;
    std::vector ticks_;
};

最後是複製賦值運算子及移動賦值運算子,要注意的是需要先檢查是不是自我賦值,也就是撇除程式中 x = x 或 x = std::move(x) 的寫法,再來移動賦值運算子一樣非內建型別要用 std::move 包起來:

class Foo {
public:
    // copy assignment
    Foo& operator=(const Foo& rhs) {
        if (this != &rhs) { // 檢查自我賦值
            id_ = rhs.id_;
            ticks_ = rhs.ticks_;
        }

        return *this;
    }

    // move assignment
    Foo& operator=(Foo&& rhs) {
        if (this != &rhs) { // 檢查自我賦值
            id_ = rhs.id_;
            ticks_ = std::move(rhs.ticks_); // 非內建型別用 std::move 包起來
        }

        return *this;
    }

private:
    int id_;
    std::vector ticks_;
};

注意 assignment 正確寫法要傳回自己的參考

Foo& operator=(const Foo&);

Foo& operator=(Foo&&);

不然沒辦法這樣賦值

foo1 = foo2 = foo3;

而跟內建型別的賦值行為不同,對使用你自訂類別的使用者來說不是好的使用體驗。

最後,新寫一個類別時不要怕麻煩,養成好習慣一開始寫好就可以避免未來很多 debug 的時間。

std::unique_lock 使用場景 1

std::unique_lock 是一種可以轉移所有權 (move constructor and move assignment) 的智慧指標 (smart pointer)。

假設在需要 thread-safe 的情況下,有一個大函式做了很多事,如類別成員函式 void BigFunc()。

class LockDemo
{
public:
	void BigFunc()
	{
		std::unique_lock<std::mutex> lock(m_mutex);
		// prepare data
		// process data
		// clean data
	}

private:
	std::mutex m_mutex;
};

經過分析,發現此函式做了三件事 prepare data、process data 及 clean data。而根據單一職責原則 (Single Responsibility Principle),我們希望一個函式只做一件事,因此我們將此函式拆分為三個意義明確的小函式 (PrepareData, ProcessData and CleanData),並且 BigFunc只是轉呼叫此三個函式。

class LockDemo
{
public:
	void BigFunc()
	{
		std::unique_lock<std::mutex> lock(m_mutex);
		PrepareData();
		ProcessData();
		CleanData();
	}

private:
	void PrepareData()
	{
		// ...
	}

	void ProcessData()
	{
		// ...
	}

	void CleanData()
	{
		// ...
	}

private:
	std::mutex m_mutex;
};

又如果我們希望此三個函式可以由 caller 自由決定呼叫組合,我們會將此三個函式從 private function 改為 public function。

但如此一來,為了保證 thread-safe,必須將此三個函式都加上 std::unique_lock,而同一條 thread 鎖住自己兩次會造成 deadlock,因此 BigFunc 的使用就會有問題。

class LockDemo
{
public:
	void BigFunc()
	{
		std::unique_lock<std::mutex> lock(m_mutex); // lock 一次
		PrepareData(); // 呼叫此函式會再 lock 一次,造成 deadlock
		ProcessData();
		CleanData();
	}

public:
	void PrepareData()
	{
		std::unique_lock<std::mutex> lock(m_mutex);
		// ...
	}

	void ProcessData()
	{
		std::unique_lock<std::mutex> lock(m_mutex);
		// ...
	}

	void CleanData()
	{
		std::unique_lock<std::mutex> lock(m_mutex);
		// ...
	}

private:
	std::mutex m_mutex;
};

看來為了解決這個問題,我們只能將 BigFunc 的 std::unique_lock 移除。
P.S. 這裡不考慮使用 recursive_mutex,因為使用 recursive_mutex 通常表示設計上有問題,這會在別篇做解釋。

然而稍微不幸的是,如果在非常重視效率的情況下,此重構方式會讓呼叫 BigFunc 從建構及解構一次 std::unique_lock 變成了三次,而這可能是無法接受的效率損失,那麼還有其他方法嗎?有的,我們可以再次修改如下:

class LockDemo
{
public:
	std::unique_lock<std::mutex> GetLock()
	{
		std::unique_lock<std::mutex> lock(m_mutex);
		return lock;
	}

public:
	void BigFunc()
	{
		auto lock = GetLock();
		CleanData(ProcessData(PrepareData(std::move(lock))));
	}

public:
	std::unique_lock<std::mutex> PrepareData(std::unique_lock<std::mutex> lock)
	{

		// ...
		return lock;
	}

	std::unique_lock<std::mutex> ProcessData(std::unique_lock<std::mutex> lock)
	{
		// ...
		return lock;
	}

	std::unique_lock<std::mutex> CleanData(std::unique_lock<std::mutex> lock)
	{
		// ...
		return lock;
	}

private:
	std::mutex m_mutex;
};

利用 std::unique_lock 支援 move constructor,使用所有權轉移 (move constructor) 來取代建構子 (constructor) 呼叫,且解構子 (destructor) 的呼叫成本也有所降低 (因為是所有權轉移,中間的解構子呼叫不會執行 unlock)。

當然,此方法將使 caller 的呼叫變得複雜,另一個可以重構的方向是同時提供 non-thread-safe 及 thread-safe 版本的類別或函式,而這可以帶出裝飾者模式 (Decorate pattern) 的使用,將會在別篇介紹。