拜託 C++ 你給我動啊: move semantics

Dboy Liao
7 min readAug 19, 2021

--

我真的很討厭 C++。 (= 皿 = )凸

by Dboy Liao

以後我要寫 C++ 的東西,都用上面這句話開頭 😈

之前就聽過 C++11 加了所謂的 Rvalue reference (T&&) 這種東西,當初跟朋友寫 open source project 就看到他們用了很多。老實說我不是很懂,但也就這樣西哩呼嚕地做下去了。我深深地相信,凡事都會有報應的。當初的西哩呼嚕就這樣在上個禮拜報應到我身上。為了紀念逝去的一個星期青春,只好再來寫一篇筆記,記錄一下看了一星期 Rvalue reference 與其衍生的 universal reference (這篇不會提到) 的心(ㄍㄢˋ)得(ㄏㄨㄚˋ)。

我愛(ㄏㄣˋ)死 C++ 惹 。

這邊是這次筆記會用的範例,有興趣可以先瞄看看,後面會說明。

The Motivation

所以說看這些東西幹嘛咧?

我只能說,我之前跳過沒看的結果就是,寫出來的 C++ code 要嘛亂動 (有試過 debug 不在特定位置 crash 的 binary 嗎 🤪),要嘛不會動。以這次的範例來說,沒有 copy constructor/operator 之後, create_pool 這一類回傳一般物件的函數連 compile 都不能 compile 了。

這樣動機強烈了吧。

言歸正傳,就像上面定義的 DataPool 一樣,常常我們會有一些物件佔用很大的記憶體。因此,copy 這樣一個龐大的物件可能是很昂貴的。但不是說 copy 一定就是不好,有時 copy 反而是必要的手段。當你把“所有權”的概念納入考量時,比較容易去判斷 copy 是否必要。

譬如說上面 DataPool 下的 _data 指標,它是一塊共享的記憶體呢還是具排他性的?這邊 DataPool 我選擇實作出排他性的語意,所以我禁止 copy 的行為。因為如果是 copy 的語意,勢必會允許不同 DataPool 物件共用同一塊 _data 指標的狀況,又或者是會牽涉到大量記憶體複製所造成的效能問題,這都不是我要的語意。

說到底,這對我這種寫 python 開始的人來說, create_pool 為什麼不能動看起來很奇怪,這是因為在 CPython 裡來說,python 在傳遞物件的時候其實都是透過 PyObject* 指標來做,以語意來說就是 pass by reference ,至於物件的壽命是透過 reference count 跟 garbage collection 來決定。

但是對 C++ 來說, 首先在 create_pool 的 function stack 裡,你造出了一個 DataPool 物件,接著要把這個物件搬回 caller 的 stack 裡。這兩個 stack 是不同區塊的記憶體,所以要搬的話,在沒有 C++11 的時候,compiler 唯一能幫你做的就是想辦法從 create_pool 的 stack 裡 copy 一個 DataPool 到 caller 的 stack 中,這就是為什麼 copy constructor/operator 是必須的理由。

這裡我們陷入了一個兩難:

  1. 加上 copy semantics 會讓 DataPool 可能有共享 memory 的問題,又或者會有 copy 一大塊記憶體的效能問題
  2. 不加 copy semantics 會讓 DataPool 不可以被作為 function return value

但以我們的範例來說,共用一下好像無傷大雅嘛。

具體的說,在 create_pool 的 stack 裡的 DataPool 需要傳回 caller stack,但因為它在 create_pool 回傳之後,馬上就會被消滅,所以就算這個時候我們真的 copy 一下指標的值,造成暫時有兩個 DataPool 共用 _data 指標的情形,但其中一方因為馬上就會被消滅,所以暫時的共用指標並不會有問題。

這就帶出了為什麼我們會需要 move semantics,以我們的例子來說,我們就是希望把 _data 指標的所有權,從 create_pool 的 stack 上的 DataPool 物件“轉移”到 caller 的 stack 上的 DataPool,藉此避免 copy semantics 帶來的問題。

因此,我們需要一個跟 copy constructor/operator 中用來代表另一個物件的 const reference (const T&) 的另一種寫法,這樣才可以讓我們辨別什麼時候你拿到的是一個還會繼續存在的物件還是一個短時間存在的物件。這也是為什麼 C++11 加入了 Rvalue reference 這東西,姑且就說它是 T&& 好了。

為什麼說是姑且呢?

因為之前跟朋友聊到這個,當時我以為我已經知道 move semantic 是什麼,但我朋友冷冷地說:哼哼,還有 universal reference 呢 (WTF!???)

這一篇不會說到 universal reference,但有興趣的人可以先看看 Scott Meyers 的 blog post 。這邊不得不吐槽一下 C++,即便是 Scott Meyers 這樣的 C++ 高手,都在他的 blog 裡寫到:

some occurrences of “&&” in source code may actually have the meaning of “&”, i.e., have the syntactic appearance of an rvalue reference (“&&”), but the meaning of an lvalue reference (“&”)

也就是說, T&& 有時候是 Lvalue reference 有時候會是 Rvalue reference (WTFFFFFFF!!!)….

嗯… C++ 你搞得我好亂啊 ( = 皿 = 凸)

不過在這次的範例裡,不會碰到 universal reference 的狀況,就先跳過 www

Move It!

因此,為了順利讓 create_pool 可以回傳 DataPool 又避免 copy,我們需要加上:

  1. DataPool(DataPool&& other) : move constructor
  2. DataPool& operator=(DataPool&& other) : move operator

所以說當程式執行到 DataPool pool = create_pool(100); 的時候,我們即將初始化 pool 這個物件,而被給的是一個 Rvalue (create_pool 的回傳值),因此 DataPool 的 move constructor 被選中執行。

另一方面,除了初始化物件的階段可以使用 move semantics 外,我們也可以在 assignment 的時候去使用 std::move 去標訂出 “我們要轉移資料所有權,所以請幫我使用 move assignment ” 的地方。你可以想像成這是我們給 C++ compiler 的一個 hint (雖說 std::move 其實本質上只是一個 static_cast) 說這個 assignment 會牽涉到所有權轉移,所以不需要 copy 資料。但另一方面,身為寫 code 的人,一但被轉移過的物件你就有責任不再使用它,除此之外,看 code 的人看到 std::move 的時候,也應該要知道被 move 過的變數就不應該再被使用。

所以說,當程式執行到 pool2 = std::move(pool) 的時候,move assignment 被選中執行,之後 pool 下的指標也被轉移到 pool2 底下。

在我的 MacBook Pro 上執行之後會看到:

另外如果你是自己 compile 而不是用我提供的 Makefile 的話,你可能會發現 move constructor 好像從來沒被執行,那是由於 Elide Constructors 的緣故,因此需要加上 -fno-eldie-constructors 去避免掉,才能看到 move constructor 被執行。

更多細節我非常推薦看看 CppConf 2019 的相關影片:

好啦,經過一番掙扎,我專案裡不定位置出現的 memory issue 消失了,C++ 終於又開始動了,感謝 C++11 (才怪)。

References

--

--

Dboy Liao
Dboy Liao

Written by Dboy Liao

Code Writer, Math Enthusiast and Data Scientist, yet.

Responses (1)