1. 程式人生 > 其它 >Effective STL~3 關聯容器

Effective STL~3 關聯容器

目錄

第19條:理解相等(equality)和等價(equivalence)的區別

相等關係

相等基於operator。如果表示式“xy”返回true,則x和y值相等;否則,不相等。
相等不一定意味著等價。比如,Widget類內部有一個記錄最近一次被訪問的時間,而operator==可能忽略該域

class Widget {
public:
    ...
private:
    TimeStamp lastAccessed;
    ...
};

bool Widget::operator== (const Widget& lhs, const Widget& rhs)
{
    // 忽略了lastAccessed域的程式碼
}

這樣,2個Widget物件即使lastAccessed域不同,但仍然相等。

等價關係

等價關係是以“在已排序的區間中物件值的相對順序”為基礎的。
對於2個Widget w1和w2,關聯容器set的預設比較函式是less,而less只是簡單呼叫針對Widget的operator<。
如果下面表示式為真,則w1和w2對於operator<具有等價的值:

!(w1 < w2) && !(w2 < w1) // w1 < w2和w2 < w1都不為真

關聯容器的元素比較

一般地,關聯容器的比較函式並不是operator<,也不是less,是用的使用者定義的判別式(predicate,條款39)。每個標準關聯容器都通過key_comp(MSVC STL實現叫key_compare)成員函式使排序判別式可被外部使用。因此,如果下面表示式為true,則按關聯容器c的排序準則,2個物件x和y具有等價的值:

!c.key_comp()(x, y) && !c.key_comp()(y, x) // 在c的排列順序中,x在y之前不為true,y在x之前也不為true

MSVC map的預設key_comp就是less

考慮寫一個不區分大小寫的set,需要自定義一個set的比較函式,比較時忽略字串中字元的大小寫

bool ciStringCompare(const string& s1, const string& s2); // 實現見條款35

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

// 客戶端
{
       set<string, CIStringCompare> ciss;
       ciss.insert("Persephone");
       ciss.insert("persephone");

       if (ciss.find("persephone") != ciss.end())
       { // 檢查成功
              cout << "ciss.find success" << endl;
       }
       if (find(ciss.begin(), ciss.end(), "persephone") != ciss.end())
       {// 檢查失敗
              cout << "find success" << endl;
       }
}

關聯容器set的預設比較函式是less,用來決定如何排序,find成員函式呼叫的是該函式。
示例中,find成員函式呼叫函式物件CIStringCompare,而find演算法通常呼叫operator==(並非equal_to)。因此,find成員函式能按忽略大小寫方式比較2個字串,而find演算法則沒有忽略大小寫。

[======]

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

假設你有一個包含string*的set,插入一些動物名字:

set<string*> ssp;
ssp.insert(new string("Anteater"));
ssp.insert(new string("Wombat"));
ssp.insert(new string("Lemur"));
ssp.insert(new string("Penguin"));

如果想讓集合中的元素,按key字母順序列印怎麼辦?
如果按通常的遍歷方式,會發現只能打印出一連串16進位制資料:

// 期望列印按key的字串順序排列的集合,但實際只會列印一串16進位制地址
for (set<string*>::const_iterator i = ssp.begin(); i != ssp.end(); ++i)
{
       cout << *i << endl;
}

列印結果:

006D9A68
006DF310
006DF3E8
006DF358

為什麼?
因為set中儲存的並非string物件,而是string的指標(string*)。

// 解決不能列印字串問題,但實際並非按key的字串順序排列
// 列印方式1:使用for顯式迴圈遍歷set
for (set<string*>::const_iterator i = ssp.begin(); i != ssp.end(); ++i)
{
       cout << **i << endl;
}

// 列印set方式2:使用for_each + 函式
for_each(ssp.begin(), ssp.end(), print);

void print(const string* ps)
{
       cout << *ps << endl;
}

// 列印set方式3:使用for_each + lambda
for_each(ssp.begin(), ssp.end(), [](const string* ps) { cout << *ps << endl; } );

現在,可以輸出字串了,但,依然沒能解決集合中字串順序問題。

比較型別

問題在於,set預設使用less比較型別對其中string*元素進行比較、排序,而我們需要的是對該指標所指字串進行比較。
因此,我們可以為set自行定義一個比較函式型別(注意不是比較函式):

// 函式物件作為比較型別,用於比較字串大小
struct StringPtrLess
{
       bool operator()(const string* ps1, const string* ps2) const
       {
              return *ps1 < *ps2;
       }
};

set<string*, StringPtrLess> ssp; // set第二個模板引數接受的是一個比較型別,而非比較函式
ssp.insert(new string("Anteater"));
ssp.insert(new string("Wombat"));
ssp.insert(new string("Lemur"));
ssp.insert(new string("Penguin"));

注意:為什麼是比較型別,而非比較函式?因為set模板引數只接受比較型別,不接受比較函式;否則無法通過編譯。

通用模板

為了寫一個通用的解除指標引用的函式子型別,我們可以將StringPtrLess改寫成函式模板Dereference,然後配合transform和ostream_iterator一起使用:

// 當向該型別的函式子傳入T*時,它們返回const T&
struct Dereference
{
    template<typename T>
    const T& operator() (const T* ptr) const
    {
        return *ptr;
    }
};

// 客戶端
...
// 通過解除指標引用,“轉換”ssp中的每個元素,並把結果寫到cout
transform(ssp.begin(), ssp.end(), ostream_iterator<string>(cout, "\n"),  Dereference());

ssp集合中的string*元素,經過transform和Dereference函式子的轉換後,輸出到ostream_iterator迭代器的就是string&。當然,用這種演算法的技巧不是本條款重點,重點是為關聯容器建立比較型別。

我們也可以為比較子函式準備一個通用的模板(就像是less針對指標型別的偏特化版本):

struct DereferenceLess
{
       template<typename PtrType>
       bool operator() (PtrType pT1, PtrType pT2) const
       {
              return *pT1 < *pT2;
       }
};

// 客戶端像這樣定義基於DereferenceLess的set
set<string*, DereferenceLess> ssp;
...

另外,本條款不僅適用於包含指標的關聯容器,也適用於一些其他包含智慧指標和迭代器的容器。也就是說,如果有一個包含智慧指標或迭代器的容器,那麼也要考慮為其指定一個比較型別。

[======]

第21條:總是讓比較函式在等值情況下返回false

一個set,能否用less_equal作為比較型別?
我們先看下面的例子,連續插入2個10

set<int, less_equal<int>> s; // s用 "<=" 來排序
s.insert(10); // 第1次插入10,這裡稱為10(A)
s.insert(10); // 第2次插入10,這裡稱為10(B)

第2次插入10(B)的時候,set必須確定10是否已經存在,而set是通過遍歷內部資料結構,檢查是否存在10(A)與10(B)相同。對於關聯容器,“相同”的定義是等價(條款19),也就是用集合的比較函式。

// 關聯容器元素等價要檢查的表示式
!c.key_comp()(x, y) && !c.key_comp()(y, x)

這裡我們用的比較函式是less_equal即operator<=。因此,set會檢查表示式:

// 使用less_equal<T>作為set比較型別,set會對元素等價做以下檢查,用來判斷2個關鍵字10(A)與10(B)是否等價
!(10(A) <= 10(B)) && !(10(B) <= 10(A))        // 檢查10(A)和10(B)的等價性

=> !(10 <= 10) && !(10 <= 10) // 由於10(A),10(B)都是10
=> !(true) && !(true)
=> false && false
=> false
=> 10(A)和10(B)不等價 這與10(A)、10(B)都是10矛盾

顯然,使用less_equal作為set比較型別,導致set容器破壞。同樣的,如果我們想讓set按關鍵字降序排列,在比較子函式中對 "operator<"取反同樣也是錯誤的。因為"<"求反得到的是">=",也包含了等號("="),而等號會導致關聯容器破壞。

set按關鍵字降序排列,錯誤的比較型別:

//錯誤示範程式碼
struct StringPtrGreater{
        bool operator() (const string* ps1, const string* ps2) const
        {
                return !(*ps1 < *ps2);                // 簡單求反,這是不對的
        }
}

正確的比較型別應該是

// OK
struct StringPtrGreater{
        bool operator() (const string* ps1, const string* ps2) const
        {
                return *ps2 < *ps1; // 返回*ps2是否在*ps1之前
        }
}

因為關聯容器都是用比較型別來判斷元素的“等價”關係的,因此比較函式在等值情況下,不要返回true。

[======]

第22條:切勿直接修改set或multiset中的鍵

不能修改set、multiset,map、multimap中的鍵。

為什麼不能修改map的key?

對於map、multimap<K, V>型別物件,元素型別是pair<const K, V>,鍵的型別是const K,因此不能修改。但如果用const_cast轉型去掉常量性(constness),就可以修改。

map<int, string> m;
m.insert(make_pair(1, "a"));
m.insert(make_pair(2, "b"));
m.insert(make_pair(3, "c"));
m.begin()->first = 10;            // 錯誤:map的鍵不能修改
m.begin()->second = "1a";         // OK:map的值可以修改

multimap<int, string> mm;
mm.insert(make_pair(1, "aa"));
mm.insert(make_pair(1, "bb"));
mm.insert(make_pair(2, "cc"));
mm.begin()->first = 11;           // 錯誤:multimap的鍵不能修改
mm.begin()->second = "11aa";      // OK:map的值可以修改

不建議修改set的key

對於set、multiset型別的物件,容器中元素型別是T,而非const T。因此,只要願意,是可以隨時修改set或multiset中的元素的。
注意:在支援C++11以後編譯器中,STL set/multiset的實現可能通過const限定符,不允許通過operator*和operator->修改容器中的元素了(同map)。
但是,即使編譯器允許,也一定不要改變set/multiset的key part,因為這部分資訊會影響容器的排序性。
不過,也有例外情況,那就是不修改被包含物件的鍵部分,只修改被包含元素的其他部分,則是可以的,因為這有實際應用含義,相應的,你也應該為set設定一個自定義的鍵部分的比較型別。

比如,你可以修改除idNumber(員工ID)以外的所有Employee的資料成員,只要set排序繫結idNumber即可。

// 員工class
class Employee
{
public:
       const string& name() const;
       void setName(const string& name);
       const string& title() const;
       void setTitle(const string& title);
       int idNumber() const;
};
// 員工set的比較型別,專門比較員工ID
struct IDNumberLess
{
       bool operator() (const Employee& lhs, const Employee& rhs) const
       {
              return lhs.idNumber() < rhs.idNumber();
       }
};
int main()
{
       typedef set<Employee, IDNumberLess> EmpIDset;
       EmpIDset se;
       // ...
       Employee selectedID;
       auto i = se.find(selectedID);
       if (i != se.end())
       {
              // 修改員工職位稱號
              it->setTitle("Corporate Deity"); // 有些STL實現認為這不合法
       }
       return 0;
}

C++標準關於是否能通過迭代器呼叫operator->和operator*,修改set容器的key並沒有統一的說法,由此,不同編譯器STL實現可能有的允許,有的不允許。
因此,試圖修改set中的元素的程式碼是不可移植的。

什麼時候可以修改set容器key?

  • 不關心可移植性;
  • 如果重視可移植性,又要修改元素中非鍵部分,可以通過const_cast強轉,然後修改;
// const_cast去掉常量性後,再修改set的key part
auto i = se.find(selectedID);
if (i != se.end())
{
       const_cast<Employee&>(*i).setTitle("Corporate Deity");
}

// 錯誤寫法1
static_cast<Employee>(*i).setTitle("Corporate Deity");
// 錯誤寫法2 <=> 錯誤寫法1,
((Employee)(*i)).setTitle("Corporate Deity");
// 錯誤寫法3 <=> 錯誤寫法1
Employee tempCopy(*i);
tempCopy.setTitle("Corporate Deity");

錯誤寫法1和2都可以通過編譯,但無法修改i所指物件內容,因為型別轉換的結果是產生一個臨時匿名物件,修改的也是這個臨時物件,語句結束後就銷燬了。

[======]

第23條:考慮用排序的vector替代關聯容器

如果查詢速度要求很高,考慮非標準的雜湊容器幾乎總是值得的(條款25)。而如果雜湊函式選擇得不合適,或者表太小,則雜湊表的查詢效能可能會顯著降低,雖然實踐中並不常見。

下面探討一下排序的vector和關聯容器區別:
標準關聯容器通常被實現為平衡的二叉查詢樹,對插入、刪除、查詢的混合操作做了優化。但沒辦法預測出下一個操作是什麼。

應用程式使用資料結構的過程可以明顯分為3個階段:
1)設定階段
建立一個新的資料結構,並插入大量元素。在這個階段,幾乎所有的操作都是插入和刪除操作。幾乎沒有查詢。
2)查詢階段
查詢該資料結構,以找到特定資訊。在這個階段,幾乎所有操作都是查詢,很少插入、刪除。
3)重組階段
改變該資料結構的內容,或許是刪除所有的當前資料,再插入新的資料。在行為上,與階段1類似。該階段結束後,應用程式又回到階段2。

排序的vector和關聯容器的優缺點
對這種方式使用資料結構的應用程式而言,排序的vector比關聯容器提供更好的時間、空間效能。

  • 大小方面
    假設class Widget大小 12byte,1個pointer大小4byte
    如果選擇關聯容器set,使用平衡二叉樹儲存,每個樹節點至少包含:1個Widget,3個指標(1個左兒子指標,1個右兒子指標,通常還有一個父節點指標)。共計24byte;
    如果選擇已排序vector,除了空閒空間(必要時可以通過swap技巧清除,見條款17),沒有額外空間開銷。共計12byte。
    這樣,1個記憶體頁(4096byte),如果用關聯容器,可以儲存170個Widget物件;如果用排序的vector,可以儲存341個Widget物件。顯然,相同記憶體可以存放更多vector元素。

  • 時間方面
    使用關聯容器,其二叉樹節點會散佈在STL實現的記憶體管理器所管理的全部地址空間,查詢時容易導致頁面錯誤;
    使用vector,相鄰元素在實體記憶體是相鄰的,執行二分搜尋時,將最大限度減少頁面錯誤;

排序的vector缺點:元素必須保持有序。這意味著,一旦有一個元素新增、刪除,其後所有元素必須移動,這對於vector是非常昂貴的。

[======]

第24條:當效率至關重要時,請在map::operator[]與map::insert之間謹慎做出選擇

從效率角度看,當向map新增元素時,優先使用insert;當更新map中元素時,優先使用operator[]。

例如,當我們有如下Widget class和map對映關係

class Widget
{
public:
       Widget();
       Widget(double weight);
       Widget& operator=(double weight);
private:
       double weight;
};

map<int, Widget> m;

向map插入資料

通常,如果使用operator[]插入

m[1] = 1.50;
m[2] = 3.67;
m[3] = 10.5;
m[4] = 45.8;
m[5] = 0.0003;

而m[1] = 1.50功能上等同於:

typedef map<int, Widget> IntWidgetMap; // typedef為了方便使用鍵值1和預設構造的值物件建立一個新map條目

pair<IntWidgetMap::iterator, bool> result =
       m.insert(IntWidgetMap::value_type(1, Widget()));

result.first->second = 1.50;

效率低原因在於:先預設構造一個Widget,然後立刻給它賦新值。這樣,多了1次預設構造臨時物件、析構臨時物件、1次operator=運算子的開銷。
而如果直接用insert賦新值,則會省去這些步驟。

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

更新map中的元素

當我們做更新操作時,形勢恰好反過來。operator[]不僅從語法形勢上看,更簡潔,而且不會有構造、析構任何pair或Widget的開銷。

int k = 0;
Widget v;
... // 設定k,v

m[k] = v; // 使用operator[] 把鍵k對應的值改為v

m.insert(IntWidgetMap::value_type(k, v)).first->second = v; // 使用insert把鍵k對應的值改為v

綜合insert和operator[]的優勢

有沒有一種高效方法,既能在插入時使用insert,更新時使用operator[]更新值?
答案是有的,可以先查詢元素是否已存在於map中。如果不存在,就呼叫insert插入元素;如果已存在,就呼叫更新其值。

template<typename MapType, typename KeyArgType, typename ValueArgType>
typename MapType::iterator
efficientAddOrUpdate(MapType& m, const KeyArgType& k, const ValueArgType& v)
{
       // map中查詢lb, 使得lb是第一個滿足 lb鍵 >= k 的迭代器
       typename MapType::iterator lb = m.lower_bound(k);
       if (lb != m.end() && !(m.key_comp()(k, lb->first))) // map::key_comp預設為less<T>
       { // map中已存在k
              lb->second = v;
              return lb;
       }
       else
       { // map中不存在k
              typedef typename MapType::value_type MVT;
              return m.insert(lb, MVT(k, v));
       }
}

// 客戶端
map<int, Widget> m;
efficientAddOrUpdate(m, 1, 1.5);
efficientAddOrUpdate(m, 10, 1.5);
efficientAddOrUpdate(m, 1, 1.5);

KeyArgType和ValueArgType不必是對映表map中的型別,只要能轉換成儲存在對映表中的型別即可。也可以用MapType::key_type和MapType::mapped_type來替代,不過這樣會導致呼叫時不必要的型別轉換。

[======]

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

原文提到的hash_字首的雜湊容器,在C++11及以後,hash_set、hash_multiset、hash_map、hash_multimap已經廢棄,目前已經替換為unordered_字首的版本:unordered_set、unordered_multiset、unordered_map、unordered_multimap。

在已有的幾種雜湊容器的實現中,最常見的2個分別來自於SGI(條款50)和Dinkumware。
雜湊容器是關聯容器,需要知道儲存在容器中的物件型別、用於這種物件的比較函式(型別)、用於這些物件的分配子。雜湊容器也要求指定一個雜湊函式。雜湊容器宣告如下:

// 通用的雜湊容器的類模板宣告式
template<typename T,                      // 儲存在容器中的物件型別
       typename HashFunction,             // 雜湊函式
       typename CompareFunction,          // 比較函式
       typename Allocator = allocator<T>> // 分配子
class hash_container;

SGI為HashFunction和CompareFunction提供了預設型別

template<typename T,
       typename HashFunction = hash<T>,
       typename CompareFunction = equal_to<T>,
       typename Allocator = allocator<T>>
class hash_set;

SGI容器(hash_set、hash_map等)使用equal_to作為預設的比較函式,而標準關聯容器(set、map等)使用less。也就是說,SGI雜湊容器通過測試2個物件是否相等,而不是等價來決定容器中的物件是否有相同的值。因為標準關聯容器通常是用樹來儲存的,而雜湊容器則不是。
Dinkumware雜湊容器則採用了不同策略,雖然仍然可以指定物件型別、雜湊函式型別、比較函式型別、分配子型別,但它把預設的雜湊函式和比較函式放在一個單獨的類似於traits(特性,見Josuttis的The C++ Standard Library)的hash_compare類中,並把hash_compare作為容器模板的HashingInfo引數的預設實參。(traits class技術用於萃取型別)

如Dinkumware的hash_set宣告:

template<typename T, typename CompareFunction>
class hash_compare;

template<typename T,
       typename HashingInfo = hash_compare<T, less<T>>,
       typename Allocator = allocator<T>>
class hash_set;

HashingInfo型別中儲存了容器的雜湊函式和比較函式,同時還有些列舉值,用於控制雜湊表中桶的最小數目,以及容器中元素個數與桶個數的最大允許比率。當超過這個比率時,雜湊表桶數增加,表中某些元素要被重新做雜湊計算。

HashingInfo的預設值hash_compare,看起來像這樣:

template<typename T, typename CompareFunction = less<T>>
class hash_compare
{
public:
       enum
       {
              bucket_size = 4,                      // 元素個數與桶個數的最大比率
              min_buckets = 9                       // 最小的桶數目
       };
       size_t operator()(const T&) const;           // 雜湊函式
       bool operator()(const T&, const T&) const;   // 比較函式
       // ... // 省略了其他細節,包括對CompareFunction的使用
};

過載operator()的做法同時實現了雜湊函式和比較函式,其思想同條款23的一個應用。
Dinkumware的方案允許你編寫自己的類似於hash_compare的類或派生出新的類。只要你的類定義了bucket_size、min_buckets、2個operator()函式(1個帶1個引數用於雜湊函式,另1個帶2個引數用於比較函式)及其他省去的一些東西即可。

SGI實現把表元素放在一個單向連結串列中,以解決雜湊衝突問題;Dinkumware實現把元素放在雙向連結串列中。

[======]