1. 程式人生 > >Effective STL 學習筆記

Effective STL 學習筆記

條款1 : 仔細選擇你的容器

  • 標準STL序列容器:vector、string、deque和list.
  • 標準STL關聯容器:set、multiset、map和multimap.
  • 非標準序列容器slist和rope.slist是一個單向連結串列,rope本質上是一個重型字串
  • 非標準關聯容器hash_set、hash_multiset、hash_map和hash_multimap
  • vector可以作為string的替代品
  • vector作為標準關聯容器的替代品
  • 幾種標準非STL容器,包括陣列、bitset、valarray、stack、queue和priority_queue
  • 連續記憶體容器
    • 如果一個新元素被插入或者已存元素被刪除,其他在同一個記憶體塊的元素就必須向上或者向下移動來為新元素提供空間或者填充原來被刪除的元素所佔的空間.這種移動影響了效率和異常安全.標準的連續記憶體容器是vector,string和deque.非標準的rope也是連續記憶體容器.
  • 基於節點的容器
    • 在每個記憶體塊 (動態分配) 中只儲存一個元素.容器元素的插入或刪除隻影響指向節點的指標,而不是節點自己的內容.所以當有東西插入或刪除時,元素值不需要移動,表現為連結串列的容器.list和slist——是基於節點的,所有的標準關聯容器也是,它們的典型實現是平衡樹,非標準的雜湊容器使用不同的基於節點的實現
  • 容器選擇的一些標準
    • 是否需要可以在容器的任意位置插入一個新元素?如果是選擇序列容器,關聯容器做不到.
    • 是否關心元素在容器中的順序?如果不雜湊容器就是可行的選擇.否則要避免使用雜湊容器.
    • 必須使用標準C++中的容器嗎?如果是,就可以除去雜湊容器、slist和rope.
    • 你需要哪一類迭代器?如果必須是隨機訪問迭代器,你就只能限於vector、deque和string如果需要雙向迭代器,
      你就用不了slist和雜湊容器的一般實現
    • 當插入或者刪除資料時,是否非常在意容器內現有元素的移動?如果是,你就必須放棄連續記憶體容器
    • 容器中的資料的記憶體佈局需要相容C嗎?如果是,你就只能用vector
    • 查詢速度很重要嗎?如果是你就應該看看雜湊容器,排序的vector和標準的關聯容器,大概是這個順序.
    • 是否介意容器的底層使用引用計數?如果是你就得避開string,因為很多string的實現是基於引用計數的,可以考慮使用vector
    • 是否需要把迭代器、指標和引用的失效次數減到最少?如果是就應該使用基於節點的容器,因為在這些容器上進行插入和刪除不會使迭代器、指標和引用失效(除非它們指向你刪除的元素).一般來說,在連續記憶體容器上插入和刪除會使所有指向容器的迭代器、指標和引用失效.

條款2:小心對“容器無關程式碼”的幻想

  • 不要寫容器無關的程式碼,這根本沒有必要.不同的容器是不同的,而且它們的優點和缺點有重大不同.它們並不被設計成可互換的,而且你做不了什麼包裝的工作

條款3:使容器裡物件的拷貝操作輕量而正確

  • 容器容納了物件,但不是你給它們的那個物件,當你從容器中獲取一個物件時,你所得到的物件不是容器裡的那個物件.取而代之的是,當你向容器中新增一個物件,進入容器的是你指定的物件的拷貝.拷進去,拷出來.這就是STL的方式.
  • 由於繼承的存在,拷貝會導致分割,如果你以基類物件建立一個容器,而你試圖插入派生類物件,那麼當物件拷入容器的時候物件的派生部分會被刪除:
  • 一個使拷貝更高效、正確而且對分割問題免疫的簡單的方式是建立指標的容器而不是物件的容器

條款4:用empty來代替檢查size()是否為0

  • 對於所有的標準容器,empty是一個常數時間的操作,但對於一些list實現,size花費線性時間.

條款5:儘量使用區間成員函式代替它們的單元素兄弟

  • 一般來說使用區間成員函式可以輸入更少的程式碼.
  • 區間成員函式會導致程式碼更清晰更直接了當.
  • 當處理標準序列容器時,應用單元素成員函式比完成同樣目的的區間成員函式需要更多地記憶體分配,更頻繁地拷貝物件或者造成多餘操作

條款6:警惕C++最令人惱怒的解析

下面是引數為函式指標的三種宣告形式:

int g(double (*pf)());//最一般的方式
int g(double pf());   // 同上;pf其實是一個指標
int g(double ());     // 同上;引數名省略

假設你有一個int的檔案,你想要把那些int拷貝到一個list中.這看起來像是一個合理的方式:

ifstream dataFile("ints.dat");
list<int> data(istream_iterator<int>(dataFile),istream_iterator<int>());

這聲明瞭一個函式data,它的返回型別是list.這個函式data帶有兩個引數:
* 第一個引數叫做dataFile.它的型別是istream_iterator.dataFile左右的括號是多餘的而且被忽略.
* 第二個引數沒有名字.它的型別是指向一個沒有引數而且返回istream_iterator的函式的指標.
這符合C++裡的一條通用規則—幾乎任何東西都可能被分析成函式宣告
例如:

class Widget {...};
Widget w();

這並沒有宣告一個叫做w的Widget,它聲明瞭一個叫作w的沒有引數且返回Widget的函式
解決方法:通過增加一對括號,我們強迫編譯器以我們的方式看事情:

list<int> data((istream_iterator<int>(dataFile)),istream_iterator<int>());

條款7:當使用new得指標的容器時,記得在銷燬容器前delete那些指標

下面程式碼直接導致一個記憶體洩漏

void doSomething()
{
    vector<Widget*> vwp;
    for (int i = 0; i < SOME_MAGIC_NUMBER; ++i)
    vwp.push_back(new Widget);
    ... // 使用vwp
} // Widgets在這裡洩漏!

可以簡單這樣

void doSomething()
{
    vector<Widget*> vwp;
    ... // 同上
    for (vector<Widget*>::iterator i = vwp.begin();i != vwp.end(),++i) 
    {
        delete *i;
    }
}

也可以呼叫for_each把delete轉入一個函式物件中

template<typename T>
struct DeleteObject :public unary_function<const T*, void> 
{
    void operator()(const T* ptr) const
    {
        delete ptr;
    }
};
void doSomething()
{
    ... // 同上
    for_each(vwp.begin(), vwp.end(), DeleteObject<Widget>);
}

不幸的是,這讓你指定了DeleteObject將會刪除的物件的型別那是很討厭的,vwp是一個vector

struct DeleteObject 
{   // 刪除這裡的模板化和基類
    template<typename T> // 模板化加在這裡
    void operator()(const T* ptr) const
    {
        delete ptr;
    }
};
void doSomething()
{
    deque<SpecialString*> dssp;
    //...
    for_each(dssp.begin(), dssp.end(),DeleteObject());
}

但不是異常安全的,要異常安全可以使用智慧指標下面是使用boost庫shared_ptr的程式碼

void doSomething()
{
    typedef boost::shared_ ptr<Widget> SPW; //SPW = "shared_ptr
    // to Widget"
    vector<SPW> vwp;
    for (int i = 0; i < SOME_MAGIC_NUMBER; ++i)
    vwp.push_back(SPW(new Widget)); // 從一個Widget建立SPW,
    // 然後進行一次push_back
    ... // 使用vwp
} // 這裡沒有Widget洩漏,甚至在上面程式碼中丟擲異常

條款8:永不建立auto_ptr的容器

auto_ptr的特性:
當你拷貝一個auto_ptr時,auto_ptr所指向物件的所有權被轉移到拷貝的auto_ptr,而被拷貝的auto_ptr被設為NULL

條款9:在刪除選項中仔細選擇

假定你有一個標準STL容器,c,容納int,Container c;而你想把c中所有值為1963的物件都去掉.
如果你有一個連續記憶體容器(vector、deque或string),最好的方法是erase-remove

c.erase(remove(c.begin(), c.end(), 1963),c.end());

這方法也適合於list,list的成員函式remove更高效:c.remove(1963);
當c是標準關聯容器(即,set、multiset、map或multimap)時,使用任何叫做remove的東西都是完全錯誤的.這樣的容器沒有叫做remove的成員函式
對於關聯容器,解決問題的適當方法是呼叫erase:

c.erase(1963); // 當c是標準關聯容器時erase成員函式是去除特定值的元素的最佳方法

關聯容器的erase成員函式有基於等價而不是相等的優勢
讓我們現在稍微修改一下這個問題.不是從c中除去每個有特定值的物體,讓我們消除下面判斷式返回真的每個物件:

bool badValue(int x); // 返回x是否是“bad”

對於序列容器(vector、string、deque和list),我們要做的只是把每個remove替換為remove_if,然後就完成了:
// 當c是vector、string或deque時這是去掉badValue返回真的物件的最佳方法

// 當c是list時這是去掉badValue返回真的物件的最佳方法
c.erase(remove_if(c.begin(), c.end(), badValue), c.end()); 
c.remove_if(badValue); 

對於標準關聯容器,它不是很直截了當.有兩種方法處理該問題,一個更容易編碼,另一個更高效.“更容
易但效率較低”的解決方案用remove_copy_if把我們需要的值拷貝到一個新容器中,然後把原容器的內容和
新的交換
方法1:

AssocContainer<int> c; // c現在是一種標準關聯容器
//... 
AssocContainer<int> goodValues; // 用於容納不刪除的值的臨時容器
//從c拷貝不刪除的值到goodValues
remove_copy_if(c.begin(), c.end(),inserter(goodValues,goodValues.end()),badValue);
c.swap(goodValues); // 交換c和goodValues

方法2:

AssocContainer<int> c;
//…
// for迴圈的第三部分是空的;i現在在下面自增
for (AssocContainer<int>::iterator i = c.begin();i != c.end();)
{
    if (badValue(*i)) 
        c.erase(i++);//注意在這裡為了避免迭代器失效使用i++
    else 
        ++i; 
} 

總結
* 去除一個容器中有特定值的所有物件:
如果容器是vector、string或deque,使用erase-remove慣用法.
如果容器是list,使用list::remove.
如果容器是標準關聯容器,使用它的erase成員函式.
* 去除一個容器中滿足一個特定判定式的所有物件:
如果容器是vector、string或deque,使用erase-remove_if慣用法.
如果容器是list,使用list::remove_if.
如果容器是標準關聯容器,使用remove_copy_if和swap,或寫一個迴圈來遍歷容器元素,當你把迭代
器傳給erase時記得後置遞增它.
* 在迴圈內做某些事情(除了刪除物件之外):
如果容器是標準序列容器,寫一個迴圈來遍歷容器元素,每當呼叫erase時記得都用它的返回值更新你
的迭代器.
如果容器是標準關聯容器,寫一個迴圈來遍歷容器元素,當你把迭代器傳給erase時記得後置遞增它.

條款10:注意分配器的協定和約束

標準允許STL實現認為所有相同型別的分配器物件都是等價的而且比較起來總是相等
考慮這段程式碼:

template<typename T> // 一個使用者定義的分配器
class SpecialAllocator {...}; // 模板
typedef SpecialAllocator<Widget> SAW; // SAW = “SpecialAllocator
// for Widgets”
list<Widget, SAW> L1;
list<Widget, SAW> L2;
...
L1.splice(L1.begin(), L2); // 把L2的節點移到
// L1前端

記住當list元素從一個list被接合到另一個時,沒有拷貝什麼.取而代之的是,調整了一些指標,曾經在一個list
中的節點發現他們自己現在在另一個list中.這使接合操作既迅速又異常安全.在上面的例子裡,接合前在L2
裡的節點接合後出現在L1中.當L1被銷燬時,當然,它必須銷燬它的所有節點(以及回收它們的記憶體),而因為它現在包含最初是L2一部分的節點,L1的分配器必須回收最初由L2的分配器分配的節點.現在清楚為什麼標準允許STL實現認為相同型別的分配器等價.所以由一個分配器物件(比如L2)分配的記憶體可以安全地被另一個分配器物件(比如L1)回收.如果沒有這樣的認為,接合操作將更難實現.顯然它們不能像現在一樣高效.
STL實現可以認為相同型別的分配器等價意味著可移植的分配器不能有任何非靜態資料成員
allocator::allocate分配n個T物件大小的記憶體,這對於記憶體連續容器是試用的,但對於list和標準關聯容器這些基於結點的容器是不適用的,因為關聯容器分配的記憶體除了T外還有其他指標,對於list來說它需要的分配器不是allocator而是allocator
如果你想要寫自定義分配器,讓我們總結你需要記得的事情.
* 把你的分配器做成一個模板,帶有模板引數T,代表你要分配記憶體的物件型別.
* 提供pointer和reference的typedef,但是總是讓pointer是T*,reference是T&.
* 決不要給你的分配器每物件狀態.通常,分配器不能有非靜態的資料成員.
* 記得應該傳給分配器的allocate成員函式需要分配的物件個數而不是位元組數.也應該記得這些函式返回
T*指標(通過pointer typedef),即使還沒有T物件被構造.
* 一定要提供標準容器依賴的內嵌rebind模板.

條款13:儘量使用vector和string來代替動態分配的陣列

使用new來進行動態分配,你需要肩負下列職責:
1. 你必須確保有的人以後會delete這個分配.如果後面沒有delete,你的new就會產生一個資源洩漏.
2. 你必須確保使用了delete的正確形式.對於分配一個單獨的物件,必須使用“delete”.對於分配一個
陣列,必須使用“delete []”.如果使用了delete的錯誤形式,結果會未定義.在一些平臺上,程式在
執行期會當掉.另一方面,它會默默地走向錯誤,有時候會造成資源洩漏,一些記憶體也隨之而去.
3. 你必須確保只delete一次.如果一個分配被刪除了不止一次,結果也會未定義.
vector和string消除了上面的負擔,因為
它們管理自己的記憶體.當元素新增到那些容器中時它們的記憶體會增長,而且當一個vector或string銷燬時,它
的解構函式會自動銷燬容器中的元素,回收存放那些元素的記憶體

條款14:使用reserve來避免不必要的重新分配

有vector和string提供的幾個函式
* size()告訴你容器中有多少元素.它沒有告訴你容器為它容納的元素分配了多少記憶體.
* capacity()告訴你容器在它已經分配的記憶體中可以容納多少元素.那是容器在那塊記憶體中總共可以容納
多少元素,而不是還可以容納多少元素.如果你想知道一個vector或string中有多少沒有被佔用的內
存,你必須從capacity()中減去size().如果size和capacity返回同樣的值,容器中就沒有剩餘空間了,而
下一次插入(通過insert或push_back等)會引發上面的重新分配步驟.
* resize(Container::size_type n)強制把容器改為容納n個元素.呼叫resize之後,size將會返回n.如果n小於
當前大小,容器尾部的元素會被銷燬.如果n大於當前大小,新預設構造的元素會新增到容器尾部.
如果n大於當前容量,在元素加入之前會發生重新分配.
* reserve(Container::size_type n)強制容器把它的容量改為至少n,提供的n不小於當前大小.這一般強迫
進行一次重新分配,因為容量需要增加.(如果n小於當前容量,vector忽略它,這個呼叫什麼都不
做,string可能把它的容量減少為size()和n中大的數,但string的大小沒有改變.在我的經驗中,使用
reserve來從一個string中修整多餘容量一般不如使用“交換技巧”
這個簡介明確表示了只要有元素需要插入而且容器的容量不足時就會發生重新分配避免重新分配的關鍵是使用reserve儘快把容器的容量設定為足夠大,最好在容器被構造之後立刻進行

條款16: 如何將vector和string的資料傳給遺留的API

給定一個vector v;
如果我們想要傳遞v給這樣的C風格的API:void doSomething(const int* pInts, size_t numInts);
我們可以這麼做:

if (!v.empty()) 
{
    doSomething(&v[0], v.size());
}

類似從vector上獲取指向內部資料的指標的方法,對string不是可靠的,因為
(1)string中的資料並沒有保證被儲存在獨立的一塊連續記憶體中
(2)string的內部表示形式並沒承諾以一個null字元結束.這解釋了string的成
員函式c_str存在的原因,它返回一個按C風格設計的指標,指向string的值.因此我們可以這樣傳遞一個string物件s給這個函式,

void doSomething(const char *pString);

像這樣:

doSomething(s.c_str());

條款17:使用“交換技巧”來修整過剩容量

要避免你的vector持有它不再需要的記憶體,你需要有一種方法來把它從曾經最大的容量減少到它現在需要的
容量例如

vector<Contestant>(contestants).swap(contestants);

表示式建立一個臨時vector,它是contestants的一份拷貝:vector的拷貝建構函式做了這個工作.但是,vector的拷貝建構函式只分配拷貝的元素需要的記憶體,所以這個臨時vector沒有多餘的容量.然後我們讓臨時vector和contestants交換資料,這時我們完成了,contestants只有臨時變數的修整過的容量,而這個臨時變數則持有了曾經在contestants中的發脹的容量.在這裡(這個語句結尾),臨時vector被銷燬,因此釋放了以前contestants使用的記憶體.同樣的技巧可以應用於string:同樣方法可以用來清空vector和string

條款18:避免使用vector

vector不是stl容器,不容納bool
一個東西要成為STL容器就必須滿足所有在C++標準列出的容器必要條件如果c是一個T型別物件的容器,且c支援operator[],
那麼以下程式碼必須能夠編譯:

T *p = &c[0]; // 無論operator[]返回什麼 都可以用這個地址初始化一個T*

換句話說,如果你使用operator[]來得到Container中的一個T物件,你可以通過取它的地址而獲得指向那個物件的指標
所以如果vector是一個容器,這段程式碼必須能夠編譯:

vector<bool> v;
bool *pb = &v[0]; // 用vector<bool>::operator[]返回的東西的地址初始化一個bool*

但它不能編譯.因為vector是一個偽容器,並不儲存真正的bool,而是打包bool以節省空間.在一個典型的實現中,每個儲存在“vector”中的“bool”佔用一個單獨的位元,而一個8位元的位元組將容納8個“bool”.在內部,vector使用了與位域(bitfield)等價的思想來表示它假裝容納的bool.正如bool,位域也只表現為兩種可能的值,但真的bool和化裝成bool的位域之間有一個重要的不同:你可以建立指向真的bool的指標,但卻禁止有指向單個位元的指標.
vector::operator[]需要返回指向 一個位元的引用,而並不存在這樣的東西.為了解決這個難題vector::operator[]返回一個物件,其行為類似於位元的引用,也稱為代理物件
vector看起來像這樣:

template <typename Allocator>
vector<bool, Allocator> 
{
    public:
    class reference {...}; // 用於產生引用獨立位元的代理類
    reference operator[](size_type n); // operator[]返回一個代理
    ...
}

現在,這段程式碼不能編譯的原因就很明顯了:

vector<bool> v;
bool *pb = &v[0]; // 錯誤!右邊的表示式是vector<bool>::reference*型別,不是bool*

條款19:瞭解相等和等價的區別

相等的概念是基於operator==的,如果表示式x==y返回true,那麼x和y有相等的值
等價是基於在一個有序區間中物件值的相對位置.等價一般在每種標準關聯容器(比如,set、multiset、map和multimap)的一部分排序順序方面有意義.兩個物件x和y如果在關聯容器c的排序順序中沒有哪個排在另一個之前,那麼它們關於c使用的排序順序有等價的值.在一般情況下用於關聯容器的比較函式是使用者定義的判斷式,每個標準關聯容器通過它的key_comp成員函式來訪問排序判斷式,所以如果下式求值為真,兩個物件x和y關於一個關聯容器c的排序標準有等價的值:

//在排序標準key_comp條件下x和y都不在彼此之前
!c.key_comp()(x, y) && !c.key_comp()(y, x)

例如考慮一個忽略大小寫的set這樣的比較函式會認為“STL”和“stL”是等價的,為此我們寫一個比較的仿函式類

struct CIStringCompare:public binary_function<string, string, bool> 
{ 
    bool operator()(const string& lhs,const string& rhs) const
    {
        return ciStringCompare(lhs, rhs); 
    } 
};

給定CIStringCompare,要建立一個忽略大小寫的set就很簡單了:

set<string, CIStringCompare> ciss; // ciss = “case-insensitive string set”

如果我們向這個set中插入“Persephone”和“persephone”,只有第一個字串加入了,因為第二個等價於第一個:

ciss.insert("Persephone"); // 一個新元素新增到set中
ciss.insert("persephone"); // 沒有新元素新增到set中

如果我們現在使用set的find成員函式搜尋字串“persephone”,搜尋會成功

if (ciss.find("persephone") != ciss.end())// 這個測試會成功
//但如果我們用非成員的find演算法,搜尋會失敗:
//下面這個測試會失敗
if (find(ciss.begin(), ciss.end(),"persephone") != ciss.end())

那是因為“persephone”等價於“Persephone”(關於比較仿函式CIStringCompare),但不等於它(因為string(“persephone”) != string(“Persephone”)).這個例子演示了為什麼你應該優先選擇成員函式(就像set::find)而不是非成員兄弟(就像find)的一個理由.

條款20:為指標的關聯容器指定比較型別

無論何時你建立一個指標的標準關聯容器,你必須記住容器會以指標的值排序.這基本上不是你想要的,所以你幾乎總是需要建立自己的仿函式類作為比較型別
條款21: 永遠讓比較函式,對相等的值返回false

條款22:避免原地修改set和multiset的鍵

所有標準關聯容器,set和multiset,map和multimap保持它們的元素有序,這些容器的正確行為依賴於它們保持有序. 如果你改了關聯容器裡的一個元素的值,新值可能不在正確的位置,而且那將破壞容器的有序性,這對於map和multimap特別簡單,因為試圖改變這些容器裡的一個鍵值的程式將不能編譯,那是因為map

條款23:考慮用有序vector代替關聯容器

標準關聯容器的典型實現是平衡二叉查詢樹.一個平衡二叉查詢樹是一個對插入、刪除和查詢的混合操作優
化的資料結構
在很多應用中,使用資料結構並沒有那麼混亂.它們對資料結構的使用可以總結為這樣的三個截然不同的階段:
* 建立.通過插入很多元素建立一個新的資料結構.在這個階段,幾乎所有的操作都是插入和刪除.幾乎沒有或根本沒有查詢.
* 查詢.在資料結構中查詢指定的資訊片.在這個階段,幾乎所有的操作都是查詢.幾乎沒有或根本沒有插入和刪除.
* 重組.修改資料結構的內容,也許通過刪除所有現有資料和在原地插入新資料.從動作上說,這個階段等價於階段1.一旦這個階段完成,應用程式返回階段2.
對於這麼使用它們的資料結構的應用來說,一個vector可能比一個關聯容器能提供更高的效能(時間和空間上都是).但不是任意的vector都會,只有有序vector.因為只有有序容器才能正確地使用查詢演算法binary_search lower_bound equal_range
但為什麼一個(有序的)vector的二分法查詢比一個二叉樹的二分法查詢提供了更好的效能?
1.關聯容器佔用更多的記憶體空間,不僅存放物件還存放相關的指標,造成記憶體空間浪費如果使用虛擬記憶體會造成更多的頁面錯誤
2.關聯容器是基於結點的,記憶體空間不是連續的,分散的結點不具有引用區域性性,因此會造成更多的頁面錯誤

條款24:當關乎效率時應該在map::operator[]和map-insert之間仔細選擇

當要插入元素時使用insert,當要更新元素值時使用operator[]函式
map的operator[]函式與vector、deque和string的operator[]函式無關,也和內建的陣列operator[]無關相反map::operator[] 被設計為簡化“新增或更新”功能.即,給定map

map<int, Widget> m;
m[1] = 1.50;

在這裡,m裡面還沒有任何東西,所以鍵1在map裡沒有入口.因此operator[]預設構造一個Widget來作為關聯到1的值,然後返回到那個Widget的引用.最後,Widget成為賦值目標:被賦值的值是1.50.
功能上等價於

typedef map<int, Widget> IntWidgetMap; // 方便的typedef
pair<IntWidgetMap::iterator, bool> result = m.insert(IntWidgetMap::value_type(1, Widget())); 
result.first->second = 1.50;

使用insert來插入元素

m.insert(IntWidgetMap::value_type(1, 1.50));

這與上面的那些程式碼有相同的最終效果,除了它通常節省了三次函式呼叫:一個建立臨時的預設構造Widget物件,一個銷燬那個臨時的物件和一個對Widget的賦值操作.那些函式呼叫越昂貴,你通過使用map-insert代替map::operator[]就能節省越多

//使用operator[]更新元素
m[k] = v;
//使用insert來更新元素
m.insert(IntWidgetMap::value_type(k, v)).first->second = v;

insert的呼叫需要IntWidgetMap::value_type型別的實參(即pair

條款25:熟悉非標準雜湊容器

非標準雜湊容器:hash_set、hash_multiset、hash_map和hash_multimap
標準雜湊容器:unordered_set、unordered_multiset、unordered_map和unordered_multimap雜湊關聯容器,不像它們在標準中的(通常基於樹)兄弟,不需要保持有序