潮.C++ | 延遲初始化

TJSW
7 min readDec 17, 2023

--

Photo by insung yoon on Unsplash

同學們大家好,今天要和大家分享的不是語法,而是實際工程上很常遇到的情境:變數、類別成員變數的延遲初始化在 C++ 怎麼實現。

延遲初始化是什麼?

簡單而言就是:變數並不在程式一開始隨著程式邏輯的進行才去初始化或是重新初始化。這樣講實在太抽象了,讓我們直接舉實際情境來看看。

利用程式的視野 (scope)

這應該是大家最熟悉的。當執行了 func() 的時候才會宣告長度為 5 的陣列。沒執行 (可能因為 if 等等流程原因) 就不會占用五個 int 的空間。

void func() {
int arr[5] {1, 2, 3, 4, 5};
// ...
}

int main() {
if (rand() % 2) {
func();
}
return 0;
}

動態宣告變數、陣列 new

通常就是某個指標很早就出現在程式中,因為邏輯或輸入而初始化了不同的空間大小 (或類別)。

int main() {
int n;
std::cin >> n;

int *p = nullptr; // !!!

if (n <= 0) p = new int{n};
else p = new int[n];

// ...

if (n <= 0) delete p;
else delete[] p;

return 0;
}

如上圖,依照使用者輸入的整數是負或正來決定 p 這個早就出現的變數是一個 int陣列還是一個單純的 int

以上相信對本文讀者們都只是一塊蛋糕程度的東西。以下是實際工程中比較容易出現的東西

類別成員有 std::thread

先看這樣的一個類還有一段邏輯

struct S {
~S() {
if (t.joinable()) t.join()
}
std::thread t;
};

void routine1() {}
void routine2() {}

int main() {
S s; // !!!

// ...

if (rand() % 2) {
// 以 s 的 thread 執行 routine1
} else {
// 以 s 的 thread 執行 routine2
}
}

大意上就是,某些原因我們必須先做出 s 變數。但是後面的流程中,依照某些邏輯讓類中的 std::thread執行不同的事。

某些對 C++11 有些認識的同學知道,要讓 thread 做事,必須在 std::thread的建構式 (constructor) 直接傳入可調用物件 (callable) 讓他開始執行。也就是說在初始化 std::thread 的時候就必須賦予他要執行的 function 了。

因此常有人為了這個考量,想:那我就使用 std::thread*或是 std::unique_ptr<std::thread> 這樣類似指標的方式,在要執行任務的時候再 new 給他初始化就好啦。

如下圖,這樣確實運行完全不會有問題,讚。

struct S {
~S() { /* ... */ }
std::unique_ptr<std::thread> t;
};

void routine1() {}
void routine2() {}

int main() {
S s;
if (rand() % 2) {
s.t.reset(new std::thread{routine1});
} else {
s.t.reset(new std::thread{routine2});
}

}

但,以後續維護的角度 (不管是自己或他人在看 S 這個類的宣告),突然冒出一個 std::threadstd::unique_ptr ,語義上不會覺得相當突兀嗎?因為 C++ 的智能指針 (smart pointer) 系列主要主打的都是生命週期、所有權的轉換與管理。以我們的用途而言完全和這些事情無關。

講了這麼多,我個人認為的最佳解法其實很簡單。也是滿多人雖然踏入 C++11 之後卻不常用的:move assignment operator (移動指定)。

struct S {
~S() { /* ... */ }
std::thread t;
};

if (rand() % 2) {
s.t = std::thread{routine1};
} else {
s.t = std::thread{routine2};
}

剛好 std::thread 實作了移動指定,讓我們可以把另個 thread move 到自己身上執行。(前提是自己 s.t 並沒有一個正在執行的任務)。這樣子我們完全不需要在 struct S 裡面引入不必要的 std::unique_ptr 混淆意圖;也省掉 unique_ptr 的存取損耗 (雖然幾近沒有)。

另外一個特別情況就是,你的成員變數真的沒有預設的建構子,也就是沒有不帶參數的 constructor,這時候只好認命了。

類成員變數為引用 (reference)

有些時候某些因素所以我們的類成員並不是普通的變數,而是一個 reference。包含但不限於:

  • 我們不想要擁有或複製變量,而是單純想參照外部內容,並且我沒有多執行緒或異步的生命週期問題,所以不需要 shared_ptr 等方式來管理。
  • 這個類需要時時參照外部的某個變數 (或改變外部的變數),所以用引用的方式參照外部變量。
struct S {
S(int &n) : r{n} {}
void square() { r = r * r; }
int &r;
};

int main() {
int n = 2;
S s{n}; // !!!

s.square();
cout << n << endl; // 4

n = 3;
s.square();
cout << n << endl; // 9
s.square();
cout << n << endl; // 81
}

這是一個比較生硬的例子,但可以看到我們用 S 類計算並更改外部的 n 為其平方。外部再更動 n 值,依然可以用新的值再計算一次。

其中要注意的便是標註 !!! 的該行。我們必須在 s 物件初始時就把 n 給定。因為就 C++ 規範來說 reference 引用必須在初始時就給定參照對象。這也是他優於指標、指針的地方之一。

但如果今天我們想要抽換 s 物件參照的對象呢!?例如我們多了一個 m 變數,依照業務邏輯抽換綁定成 m 然後繼續我們的流程。reference 的好處在這裡反而綁手綁腳了。

這時候突然想到指標的好了,但指標的壞處就是那堆同學們很熟悉的 SIGSEGV, segmentation fault, core file dumped, dereference null pointer (對 nullptr 取值) 這類的致命狀況。所以在 C++11 有一個好工具幫助我們賦予引用具有指針的靈活性又保持安全性。

就是 std::reference_wrapper,又,最常見到的是他的工具函式 std::ref()

struct S {
S(int &n) : r{n} {}
void square() { (int&)r = r * r; }
std::reference_wrapper<int> r;
};

int main() {
int n = 2;
S s{n};

s.square();
cout << n << endl; // 4

int m = 7;
s.r = m; // !!!
s.square();
cout << m << endl; // 49
s.square();
cout << m << endl; // 2401
}

由於這期主題不是解析 std::reference_wrapper 或是 std::ref() 的用法,詳細的用法和限制就留到別期文章來討論。主要是解決原本的 S 類沒辦法抽換綁定的問題。

如上圖,我們發現可以直接使用 s.r = m; 直接抽象綁定的對象從 n 變成 m。然後繼續我們原本的操作。

並且 square() 的實現內容小小的利用了 std::reference_wrapper 的特性更動一下達到和單純的引用一樣的效果。

講完了

本期的內容差不多就到這了,沒什麼很新的 C++ 語法或 STL 容器。只是在工程應用中,把同學們可能遇到的拐手處梳理一下用法。給大家一個比較靈活又不失清晰度的寫法。大家下期再見~

其實寫這篇只是讓 2023 至少有一篇文🤣🤣。看到我的 medium 被別人的網站列為 inactive … 😂

--

--