RAII與三五零法則

紫冰凌發表於2024-04-27

RAII與三/五/零法則

RAII

什麼是RAII?


RAII的全稱是資源獲取即初始化 (Resource Acquisition Is Initialization)

​ 它的核心思想是將資源的管理與物件的生命週期繫結在一起。當一個物件被建立時,它自動獲取資源;當物件被銷燬時,它負責釋放資源。這種方法的優勢在於可以確保資源在適當的時候被正確釋放,從而避免了資源洩漏。

為什麼需要RAII?

void array_test(const size_t size)
{
    int* arr = new int[size];
    /* Process the array */
    delete[] arr;
}
void process(int* arr) noexcept(false);

void array_test(const size_t size)
{
    int* arr = new int[size];
    process(arr);
    delete[] arr;
}

如果上面的例子中process函式丟擲異常會導致delete[] arr;無法執行造成記憶體洩漏。可以用try-catch結構捕獲異常之後釋放記憶體。

void array_test(const size_t size)
{
    int* arr = new int[size];
    try
    {
        process(arr);
    }
    catch (...)
    {
        delete[] arr;
        return;
    }
    delete[] arr;
}

但是在有多個資源的情況下這樣手動管理會很麻煩,程式碼邏輯也會變得複雜。

我們可以利用RAII將資源獲取也就是申請記憶體的操作放到物件的初始化中,相應地,把資源回收也就是釋放記憶體的操作放到解構函式中。

struct dyn_array
{
    size_t size = 0;
    int* ptr = nullptr;

    dyn_array() = default; // Default(empty) initialization
    explicit dyn_array(const size_t size) :size(size), ptr(new int[size]) {} // Initialize with a given size
    ~dyn_array() noexcept { delete[] ptr; } // Free the memory
};

void array_test(const size_t size)
{
    dyn_array darr(size);
    process(darr.ptr);
} // Memory freed no matter how this function exits

​ 這樣就可以簡化資源管理的操作。因為C++物件有明確的生存週期期,這個函式無論是正常退出還是因丟擲異常而退出,物件darr的作用域(生存期)都將結束,導致其解構函式被呼叫,資源成功釋放,省去了各種手動判斷釋放資源的步驟。

三法則和零法則

什麼是三法則?

​ 如果你需要定義一個類的解構函式,那麼你可能也需要定義它的複製建構函式和複製賦值運算子。因為這些都是涉及到資源管理的操作,需要確保資源的正確釋放和管理。如果你手動管理資源,而沒有定義複製建構函式和複製賦值運算子,那麼可能會導致資源的淺複製問題。

為什麼不能用編譯器自帶的複製建構函式和複製賦值運算子?

void array_test(const size_t size)
{
    dyn_array darr(size); // Allocated memory (darr.ptr)
    {
        // Copy initialization, copy.ptr = darr.ptr
        dyn_array copy = darr;
        process(copy.ptr);
    } // copy.ptr is freed
    process(darr.ptr); // Oops, now darr.ptr points to garbage
} // Double oops, the pointer is doubly freed...

​ 複製品copy跟原陣列darr指向了同一片記憶體,當複製品的生存期結束時,複製品將那個陣列銷燬了,因此process(darr.ptr);會出現異常。在darr的生存期結束時,它又會釋放它保留的指標,這導致了記憶體的二次釋放。

​ 所以在這種情況下編譯器自帶的複製建構函式和複製賦值運算子並不靠譜,需要自己實現

dyn_array(const dyn_array& other) :size(other.size)
{
        ptr = new int[other.size];
        std::copy_n(other.ptr, size, ptr);
}

dyn_array& operator=(const dyn_array& other)
{
        if (&other != this)
        {
            delete[] ptr;
            size = other.size;
            ptr = new int[other.size];
            std::copy_n(other.ptr, size, ptr);
        }
        return *this;
}

​ 與其對應的就是零法則,如果你並不需要自己實現解構函式,那麼就一個特殊函式都不要實現,讓編譯器幫你做完所有事情。

什麼時候需要手動實現解構函式?

類涉及到動態分配的資源,比如記憶體、檔案控制代碼、資料庫連線等,要手動編寫解構函式來確保這些資源在物件被銷燬時被正確釋放。##

五法則

在三法則的基礎上加入移動建構函式和移動賦值運算子。這是因為在 C++11 引入了右值引用和移動語義,透過移動資源可以避免不必要的複製操作,提高效能。

void array_test(const size_t size)
{
    dyn_array darr;
    darr = dyn_array(size);
}

函式的第二行構造了一個dyn_array臨時量,之後把這個臨時量賦值給darr。如果僅有前面的三法則定義的話,這會呼叫複製賦值運算子。可是,既然我們知道賦的值是一個臨時量,我們為什麼非要重新開闢一片記憶體空間再把臨時陣列裡面的資料複製一遍呢?如果能有一種辦法把臨時量裡面存的指標“偷過來”就好了。

當然有這種方法,這裡就要用到移動賦值運算子了。

dyn_array& operator=(dyn_array&& other) noexcept
{
        delete[] ptr;
        size = other.size;
        // Following line is equivalent to
        //     ptr = other.ptr;
        //     other.ptr = nullptr;
        ptr = std::exchange(other.ptr, nullptr);
        return *this;
}
dyn_array(dyn_array&& other) noexcept :
        size(other.size),
        ptr(std::exchange(other.ptr, nullptr)) {}

原部落格

相關文章