Effective STL~3 關聯容器
- 第19條:理解相等(equality)和等價(equivalence)的區別
- 第20條:為包含指標的關聯容器指定比較型別
- 第21條:總是讓比較函式在等值情況下返回false
- 第22條:切勿直接修改set或multiset中的鍵
- 第23條:考慮用排序的vector替代關聯容器
- 第24條:當效率至關重要時,請在map::operator[]與map::insert之間謹慎做出選擇
- 第25條:熟悉非標準的雜湊容器
第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
如果下面表示式為真,則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
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成員函式呼叫函式物件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
因此,我們可以為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
// 使用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
注意:在支援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實現把元素放在雙向連結串列中。
[======]