解構式、複製與移動


在〈類別模版〉中的 LinkedList 範例,每個元素都由內部類別 Node 實例保存,而 Node 是以 new 動態配置,若不再使用 LinkedList 實例,應該清除這些 new 出來的 Node 實例,這可以藉由定義解析式(destructor)來實作,例如:

#include <iostream>
using namespace std;

template <typename T>
class LinkedList {
    class Node {
    public:
        Node(T value, Node *next) : value(value), next(next) {}
        T value;
        Node *next;
    };

    Node *first = nullptr;

public:
    ~LinkedList(); // 解構式
    ...略
};

...略

template <typename T>
LinkedList<T>::~LinkedList() {
    if(this->first == nullptr) {
        return;
    }

    Node *last = this->first;
    do {
        Node *next = last->next;
        delete last;
        last = next;
    } while(last != nullptr);
}

...略

解析式是由 ~ 開頭,不用指定傳回型態,與類別名稱空間的成員函式,當實例被清除時,就會執行解構式,可以在解構式中實作清除資源的動作,在這邊用來 delete 每個 new 出來的 Node 實例。

如果沒有定義解構式,那麼編譯器會自行建立一個本體為空的解構式。

如果 LinkedList 實例被建構出來之後,不會被用來建構另一個 LinkedList 實例,那麼以上的實作是不會有什麼問題,然而若是如下就會出問題:

...略

int main() {
    LinkedList<int> *lt1 = new LinkedList<int>();
    (*lt1).append(1).append(2).append(3);

    LinkedList<int> lt2 = *lt1;   // 複製初始化

    delete lt1;

    cout << lt2.get(2) << endl;   // 不可預期的結果

    return 0;
}

若使用一個類別實例來建構另一類別實例,預設會發生值域的複製,複製的行為視型態而定,以指標類型來說,會是複製位址,也就是淺複製(shallow copy),就上例來說,*lt1 實例的 first 位址值會複製給 lt2first,在 delete lt1 後,*lt1 實例的 first 位址處之物件被 delete,因此透過 lt2first 存取的位址值就無效了。

若使用一個類別實例來建構另一類別實例,可以定義複製建構式(copy constructor)來實現自定義的複製行為。例如:

#include <iostream>
using namespace std;

template <typename T>
class LinkedList {
    class Node {
    public:
        Node(T value, Node *next) : value(value), next(next) {}
        T value;
        Node *next;
    };

    Node *first = nullptr;

public:
    LinkedList() = default;               // 預設建構式
    LinkedList(const LinkedList<T> &lt);  // 複製建構式
    ~LinkedList();
    ...略
};

template <typename T>
LinkedList<T>::LinkedList(const LinkedList<T> &lt) {
    // 逐一複製 Node 實例(而不是複製位址值)
    if(lt.first != nullptr) {
        this->first = new Node(lt.first->value, nullptr);
    }

    Node *thisLast = this->first;
    Node *srcNext = lt.first->next;
    while(srcNext != nullptr) {
        thisLast->next = new Node(srcNext->value, nullptr);
        thisLast = thisLast->next;
        srcNext = srcNext->next;
    }
}

...

template <typename T>
LinkedList<T>::~LinkedList() {
    if(this->first == nullptr) {
        return;
    }

    Node *last = this->first;
    do {
        Node *next = last->next;
        delete last;
        last = next;
    } while(last != nullptr);
}

...

跟預設建構式不同的是,無論有沒有定義其他建構式,若沒有定義複製建構式,那編譯器一定會生成一個複製建構式,預設會發生值域的複製,複製的行為視型態而定,基本型態的話就是複製值,指標的話是複製位址值,陣列的話,會逐一複製每個元素,類別型態的話,視各類別定義的複製建構式而定。

也就是說,在沒有自定義 LinkedList 的複製建構式前,編譯器產生的預設建構式,相當於有以下的內容:

template <typename T>
LinkedList<T>::LinkedList(const LinkedList<T> &lt) : first(lt.first) {}

在定義了複製建構式,方才的 main 執行上沒問題了,然而以下還是會有問題:

...略

int main() {
    LinkedList<int> *lt1 = new LinkedList<int>();
    LinkedList<int> lt2;
    (*lt1).append(1).append(2).append(3);

    lt2 = *lt1;                 // 指定時會發生複製

    delete lt1;

    cout << lt2.get(2) << endl; // 不可預期的結果

    return 0;
}

在指定時預設也是會發生複製行為,指定時預設的行為類似預設的複製建構式,若要避免問題發生,得自定義複製指定運算子(copy assignment operator):

#include <iostream>
using namespace std;

template <typename T>
class LinkedList {
    class Node {
    public:
        Node(T value, Node *next) : value(value), next(next) {}
        T value;
        Node *next;
    };

    Node *first = nullptr;

    void copy(const LinkedList<T> &lt);

public:
    LinkedList() = default;
    LinkedList(const LinkedList<T> &lt);
    ~LinkedList();
    LinkedList<T>& operator=(const LinkedList<T> &lt);  // 定義複製指定運算子
    ...略
};

template <typename T>
void LinkedList<T>::copy(const LinkedList<T> &lt) {
    // 逐一複製 Node 實例(而不是複製位址值)
    if(lt.first != nullptr) {
        this->first = new Node(lt.first->value, nullptr);
    }

    Node *thisLast = this->first;
    Node *srcNext = lt.first->next;
    while(srcNext != nullptr) {
        thisLast->next = new Node(srcNext->value, nullptr);
        thisLast = thisLast->next;
        srcNext = srcNext->next;
    }
}

template <typename T>
LinkedList<T>::LinkedList(const LinkedList<T> &lt) {
    this->copy(lt);
}

template <typename T>
LinkedList<T>& LinkedList<T>::operator=(const LinkedList<T> &lt) {
    this->copy(lt);
    return *this;
}

...略

template <typename T>
LinkedList<T>::~LinkedList() {
    if(this->first == nullptr) {
        return;
    }

    Node *last = this->first;
    do {
        Node *next = last->next;
        delete last;
        last = next;
    } while(last != nullptr);
}

...略

如果定義類別時,需要考慮到要不要自定義解構式、複製建構式、複製指定運算子其中之一,幾乎就是三個都要定義了,這就是 Rule of three。

如果某個類別不希望被複製、指定等,C++ 11 以後可以如下:

struct Foo {
    Foo() = default;                     // 採用預設建構式行為
    Foo(const Foo&) = delete;            // 刪除此函式(不定義此函式)
    ~Foo() = default;                    // 採用預設解構式行為
    Foo& operator=(const Foo&) = delete; // 刪除此函式(不定義此函式)
};

在過去的話,會將複製建構式、複製指定運算子設為 private

class Foo {
    Foo(const Foo&);
    Foo& operator=(const Foo&);
public:
    Foo() = default;                  
    ~Foo();           
};

另外,在〈rvalue 參考〉中看過 std::move 用來實現移動語義,而建構式、指定運算子也可以實現移動語義,也就是移動建構式(move constructor)、移動指定運算子(move assignment operator),如果考慮要在類別上實現移動語義,解構式、複製/移動建構式、複製/移動指定運算子幾乎就都要全部出現,這就是 Rule of Five。

例如,可以為 LinkedList 加上移動建構式、移動指定運算子:

#include <iostream>
#include <utility>
using namespace std;

template <typename T>
class LinkedList {
    class Node {
    public:
        Node(T value, Node *next) : value(value), next(next) {}
        T value;
        Node *next;
    };

    Node *first = nullptr;

    void copy(const LinkedList<T> &lt);
    void move(LinkedList<T> &lt);

public:
    LinkedList() = default;
    LinkedList(const LinkedList<T> &lt);                // 複製建構式
    LinkedList(LinkedList<T> &&lt);                     // 移動建構式
    ~LinkedList();                                      // 解構式

    LinkedList<T>& operator=(const LinkedList<T> &lt);  // 複製指定運算子
    LinkedList<T>& operator=(LinkedList<T> &&lt);       // 移動指定運算子

    LinkedList<T>& append(T value);
    T get(int i);
};

template <typename T>
void LinkedList<T>::copy(const LinkedList<T> &lt) {
    // 逐一複製 Node 實例(而不是複製位址值)
    if(lt.first != nullptr) {
        this->first = new Node(lt.first->value, nullptr);
    }

    Node *thisLast = this->first;
    Node *srcNext = lt.first->next;
    while(srcNext != nullptr) {
        thisLast->next = new Node(srcNext->value, nullptr);
        thisLast = thisLast->next;
        srcNext = srcNext->next;
    }
}

template <typename T>
void LinkedList<T>::move(LinkedList<T> &lt) {
    if(lt.first != nullptr) {
        this->first = lt.first;
        lt.first = nullptr;
    }
}

template <typename T>
LinkedList<T>::LinkedList(const LinkedList<T> &lt) {
    this->copy(lt);
}

template <typename T>
LinkedList<T>::LinkedList(LinkedList<T> &&lt) {
    this->move(lt);
}

template <typename T>
LinkedList<T>& LinkedList<T>::operator=(const LinkedList<T> &lt) {
    this->copy(lt);
    return *this;
}

template <typename T>
LinkedList<T>& LinkedList<T>::operator=(LinkedList<T> &&lt) {
    this->move(lt);
    return *this;
}

template <typename T>
LinkedList<T>& LinkedList<T>::append(T value) {
    Node *node = new Node(value, nullptr);
    if(first == nullptr) {
        this->first = node; 
    }
    else {
        Node *last = this->first;
        while(last->next != nullptr) {
            last = last->next;
        }
        last->next = node;
    }
    return *this;
}

template <typename T>
T LinkedList<T>::get(int i) {
    Node *last = this->first;
    int count = 0;
    while(true) {
        if(count == i) {
            return last->value;
        }
        last = last->next;
        count++;
    }
}

template <typename T>
LinkedList<T>::~LinkedList() {
    if(this->first == nullptr) {
        return;
    }

    Node *last = this->first;
    do {
        Node *next = last->next;
        delete last;
        last = next;
    } while(last != nullptr);
}

int main() {
    LinkedList<int> lt1;
    lt1.append(1).append(2).append(3);

    LinkedList<int> lt2 = std::move(lt1); // 將 lt1 的資料移動給 lt2
    cout << lt2.get(2) << endl;

    return 0;
}

記得移動之後,因為資料轉移出去了,對目前 lt1 的狀態不能有任何的假設,只能銷毀 lt1,或者重新指定實例給 lt1

具有解構式、複製/移動建構式、複製/移動指定運算子的類別,要全權負責管理自身資源;至於其他類別,就完全不需要其中之一,這就是 Rule of zero。

有關 Rule of three、Rule of Five、Rule of zero,可進一步參考〈The rule of three/five/zero〉。