C++11 使用 emplace 取代 push_back 和 insert

| | 0 Comments| 14:35
Categories:

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()文件)可以使用。


mapemplace()

emplace 這個功能並不是只有 vector 有,其他 STL 的容器也都有提供同類型的函式可供使用。像是在 setmap,則還有另外的 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
}

老實說,要寫成這個樣子…感覺很麻煩啊。


maptry_emplace()

map 也還有一個比較特別 try_emplace()文件)這個函式可以使用;這個函式的特色,是如果對應的 key 已經存在的話,就不會做任何動作。

實際上,mapemplace() 在對應的直(或 key)已經存在的情況下,也不會真的建立新的資料、同時也不會更新既有的資料,其實在結果上和 try_emplace() 好像一樣?

這邊的差別是,在於如果在使用 emplace() 的時候搭配 std::move() 的話,就算沒有真的把資料放到 setmap 裡,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() 更方便的版本,有興趣的話可以自己參考看看。

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *