以 vector 來說,以往要插入新的資料,大多會使用 push_back() 這個函式。
但是實際上,使用 push_back() 的時候,大部分的時候、我們會需要先建立一個臨時性的物件,然後在把他丟到 vector 裡面;實際上,在這邊也會產生額外的成本的~
像是下面這段程式碼:
#include <iostream> #include <vector> #include <string> class Test { public: Test(std::string_view s) { iIdx = ++iCounter; sData = s; std::cout << name() << "Constructor" << std::endl; } Test(const Test& src) : sData(src.sData) { iIdx = ++iCounter; std::cout << name() << "Copy constructor" << std::endl; } ~Test() { std::cout << name() << "Destructor" << std::endl; } protected: std::string name() const { return "[" + std::to_string(iIdx) + "] "; } protected: int iIdx; std::string sData; inline static int iCounter = 0; }; int main() { std::vector<Test> v; v.reserve(8); v.push_back(Test("Hello")); std::cout << "Finish" << std::endl; }
他的執行結果會是:
[1] Constructor [2] Copy constructor [1] Destructor Finish [2] Destructor
在這邊,基本上會先透過建構子建立好一個 Test 的臨時性物件(編號 1)、然後再透過 copy constructor 複製一份到 vector 裡面(編號 2),之後再把本來的臨時性物件(編號 1)刪除。
而如果這邊複製的成本很高的話,其實就會變得很沒有效率。
透過 Move 減少深層複製
當然,這邊也可以透過 C++11 的 move constructor、來做一定程度的簡化。也就是在定義 Test 這的類別的時候,多定義一個 move constructor:
Test(Test&& src) noexcept : sData(std::move(src.sData)) { iIdx = ++iCounter; std::cout << name() << "Move constructor" << std::endl; }
在這邊,他會把類別裡的資料(sData)透過 std::move()(文件)「轉移」給新建立的物件、避免做資料深層的複製、進而增加效能。
而在幫 Test 這個加上 move constructor 之後,在使用上面的形式來插入資料的時候,他就不會去使用 copy constructor、而是會改用 move constructor 來做複製了~這個時候,理論上效率是會比較好的。
透過 emplace 直接在容器內建立物件
但是,就算是使用 move 的形式,他還是弊免不了會產生一個臨時物件。為了解決這個問題,C++11 另外也針對容器加入了一種「emplace」的函式,讓使用者可以不需要建立臨時性的物件、直接在容器中新增一個物件。
以 vector 來說,對應 push_back() 的函式就是 emplace_back()(文件);它的使用方法也很簡單,就是將本來要用來建構 Test 這個物件的引數、直接拿來當作 emplace_back() 的引數就好了!
v.push_back(Test("Hello")); v.emplace_back("Hello");
像上面兩行程式碼,在結果上會是一樣的。而如果建構子的引數不只一個,也都是直接造本來的方法給就好。
但是如果以前面 Test 的定義,那麼使用 emplace_back() 來加入資料的時候,程式會變成:
int main() { std::vector<Test> v; v.reserve(8); v.emplace_back("Hello"); std::cout << "Finish" << std::endl; }
而他輸出的結果會是:
[1] Constructor Finish [1] Destructor
可以看到,他從頭到尾都只建立出一個 Test 的物件,所以也不需要 copy、或是 move!在這的狀況下,效率當然會是最好的了!
而如果要在特定位置插入資料,對應本來的 insert(),C++11 也有提供新的 emplace()(文件)可以使用。
map 的 emplace()
emplace 這個功能並不是只有 vector 有,其他 STL 的容器也都有提供同類型的函式可供使用。像是在 set 和 map,則還有另外的 emplace_hint()(文件)可以使用。
不過,由於 map 本來的 insert() 就是要給一個 pair,函式的引數會需要同時包含 key 和 value 的建構參數;所以如果物件的建構子的引數超過一個的話,要使用 emplace() 這個函式其實還滿麻煩的…畢竟,他也沒辦法知道那些引數是屬於 key、哪些是屬於 value 的。
真的要用的話,這邊應該是會需要用到 piecewise constructor(文件)、然後搭配 std::forward_as_tuple()(文件)把 key 和 value 都轉換成 tuple(參考)後,再傳給 emplace() 使用。
下面就是一個範例:
class T { public: T(std::string s1, std::string s2) {} }; int main() { std::map<int, T> v; v.emplace(std::piecewise_construct, std::forward_as_tuple(1), // key std::forward_as_tuple("a", "b")); // value }
老實說,要寫成這個樣子…感覺很麻煩啊。
map 的 try_emplace()
map 也還有一個比較特別 try_emplace()(文件)這個函式可以使用;這個函式的特色,是如果對應的 key 已經存在的話,就不會做任何動作。
實際上,map 的 emplace() 在對應的直(或 key)已經存在的情況下,也不會真的建立新的資料、同時也不會更新既有的資料,其實在結果上和 try_emplace() 好像一樣?
這邊的差別是,在於如果在使用 emplace() 的時候搭配 std::move() 的話,就算沒有真的把資料放到 set 或 map 裡,std::move() 可能還是會執行的。
下面是一個例子(來源):
std::map<std::string, std::string> m; m["Hello"] = "World"; std::string s = "C++"; m.emplace("Hello", std::move(s)); std::cout << "string s = " << s << '\n'; std::cout << "m[\"Hello\"] = " << m["Hello"] << '\n';
這樣的程式執行結果會是:
string s = m["Hello"] = World
由於 s 的資料已經被轉移給別人了,所以裡面是空的;但是又因為沒有真正完成資料的插入,所以這邊的「C++」這個字串就被釋放掉了。
不過基本上,這個應該算是沒有定義在規格上的行為,而在呼叫過 std::move() 後再去存取他感覺本來就也有問題就是了…
總之,後來在 C++17 裡面,應該是為了解決這個問題,又另外加入了 try_emplace()(文件)。
他除了將回傳的結果改成 pair<iterator, bool>、讓使用者可以透過裡面的 bool 更明確地知道是否有真的進行資料的插入外,在實作上也將函式的第一個引數(key)拆出來另外處理。
像上面的例子,如果改用 try_emplace() 的話,就可以寫成:
std::map<std::string, std::string> m; m["Hello"] = "World"; std::string s = "C++"; auto [pos, inserted] = m.try_emplace("Hello", std::move(s)); if (inserted) std::cout << "Inserted\n"; else std::cout << "Ignored\n"; std::cout << "string s = " << s << '\n'; std::cout << "m[\"Hello\"] = " << m["Hello"] << '\n';
這樣執行的結果會變成:
Ignored string s = C++ m["Hello"] = World
在 Heresy 來看,try_emplace() 還有另一個好處,就是因為他把第一個引數獨立出來當作 key 了,所以後面的其他引數就會都變成是 value 的參數了~
也因此,前面需要使用 piecewise constructor 的狀況,在某些情況下就可以不需要封裝成 tuple 了~下面就是一個例子:
class T { public: T(std::string s1, std::string s2) {} }; int main() { std::map<int, T> v; v.try_emplace(1, "a", "b"); }
但是相對地,由於他的第一個引數的型別就是 key 的型別,所以會變成這部分沒辦法透過 emplace 的概念來產生了。
比如說上面的 map 把 key 也改成用 T 的話(T 得補上 operator<()),就得寫成:
std::map<T, T> v; v.try_emplace({ "a", "b"}, "c", "d");
這時候,key 會透過 { “a”, “b” } 來建構,然後再透過 Move constructor 搬到 map 裡面。
針對 emplace 的東西大概就先這樣了。
這邊附帶一提,在 C++17 的時候,另外也替 map 加入了一個 insert_or_assign() 的函式(文件),在個人來看算是一個比本來的 insert() 更方便的版本,有興趣的話可以自己參考看看。