《C++Primer》第十五章-面向對象編程-學習筆記(3)

《C++Primer》第十五章-面向對象編程-學習筆記(3)

日誌:
1,2020-03-10 筆者提交文章的初版V1.0

作者按:
最近在學習C++ primer,初步打算把所學的記錄下來。

傳送門/推廣
《C++Primer》第二章-變量和基本類型-學習筆記(1)
《C++Primer》第三章-標準庫類型-學習筆記(1)
《C++Primer》第八章-標準 IO 庫-學習筆記(1)
《C++Primer》第十二章-類-學習筆記(1)

純虛函數

在第 15章所編寫的 Disc_item 類提出了一個有趣的問題:該類從Item_base 繼承了 net_price 函數但沒有重定義該函數。因爲對 Disc_item 類而言沒有可以給予該函數的意義,所以沒有重定義該函數。在我們的應用程序中,Disc_item 不對應任何折扣策略,這個類的存在只是爲了讓其他類繼承
我們不想讓用戶定義 Disc_item 對象,相反,Disc_item 對象只應該作爲Disc_item 派生類型的對象的一部分而存在。但是,正如已定義的,沒有辦法防止用戶定義一個普通的 Disc_item 對象。這帶來一個問題:如果用戶創建一個Disc_item 對象並調用該對象的 net_price 函數,會發生什麼呢?從前面章節的討論中瞭解到,結果將是調用從 Item_base 繼承而來的 net_price 函數,該函數產生的是不打折的價格。
很難說用戶可能期望調用 Disc_item 的 net_price 會有什麼樣的行爲。真正的問題在於,我們寧願用戶根本不能創建這樣的對象。可以使 net_price 成爲純虛函數,強制實現這一設計意圖並正確指出 Disc_item 的 net_price 版本沒有意義的。在函數形參表後面寫上 = 0 以指定純虛函數

class Disc_item : public Item_base {
public:
double net_price(std::size_t) const = 0;
};

將函數定義爲純虛能夠說明,該函數爲後代類型提供了可以覆蓋的接口,但是這個類中的版本決不會調用重要的是,用戶將不能創建 Disc_item 類型的對象。試圖創建抽象基類的對象將發生編譯時錯誤

// Disc_item declares pure virtual functions
Disc_item discounted; // error: can't define a Disc_item object
Bulk_item bulk; // ok: Disc_item subobject within Bulk_item

含有(或繼承)一個或多個純虛函數的類抽象基類。除了作爲抽象基類的派生類的對象的組成部分,不能創建抽象類型的對象。

容器與繼承

我們希望使用容器(或內置數組)保存因繼承而相關聯的對象。但是,對象不是多態的,這一事實對將容器用於繼承層次中的類型有影響。
例如,書店應用程序中可能有購物籃,購物籃代表顧客正在購買的書。我們希望能夠在 multiset中存儲儲購買物,要定義 multiset,必須指定容器將保存的對象的類型。將對象放進容器時,複製元素。如果定義 multiset 保存基類類型的對象:

multiset<Item_base> basket;  //購物籃是multiset
Item_base base;
Bulk_item bulk;
basket.insert(base); // ok: add copy of base to basket
basket.insert(bulk); // ok: but bulk sliced down to its base part

加入派生類型的對象時,只將對象的基類部分保存在容器中。記住,將派生類對象複製到基類對象時,派生類對象將被切掉
容器中的元素是 Item_base 對象,無論元素是否作爲 Bulk_item 對象的副本而建立,當計算元素的 net_price 時,元素將按不打折定價。一旦對象放入了 multiset,它就不再是派生類對象了。
因爲在容器中派生類對象在賦值給基類對象時會被“切掉”,所以容器與通過繼承相關的類型不能很好地融合。
不能通過定義容器保存派生類對象來解決這個問題。在這種情況下,不能將Item_base 對象放入容器——沒有從基類類型到派生類型的標準轉換。可以顯式地將基類對象強制轉換爲派生類對象並將結果對象加入容器,但是,如果這樣做,當試圖使用這樣的元素時,會產生大問題:在這種情況下,元素可以當作派生類對象對待,但派生類部分的成員將是未初始化的。
唯一可行的選擇可能是使用容器保存對象的指針。這個策略可行,但代價是需要用戶面對管理對象和指針的問題,用戶必須保證只要容器存在,被指向的對象就存在。如果對象是動態分配的,用戶必須保證在容器消失時適當地釋放對象。
下一節將介紹對這個問題更好更通用的解決方案。

句柄類與繼承

C++ 中面向對象編程的一個頗具諷刺意味的地方是,不能使用對象支持面向對象編程,相反,必須使用指針或引用。例如,下面的代碼段中:

void get_prices(Item_base object,const Item_base *pointer,const Item_base &reference)
{
// which version of net_price is called is determined at run time
cout << pointer->net_price(1) << endl;
cout << reference.net_price(1) << endl;
// always invokes Item_base::net_price
cout << object.net_price(1) << endl;
}

通過 pointer 和 reference 進行的調用在運行時根據它們所綁定對象的動態類型而確定。
但是,使用指針或引用會加重類用戶的負擔。在前一節中討論繼承類型對象與容器的相互作用時,已經碰到了一種這樣的負擔。
C++ 中一個通用的技術是定義包裝(cover)類句柄(handle)類句柄類存儲和管理基類指針。指針所指對象的類型可以變化,它既可以指向基類類型對象又可以指向派生類型對象。用戶通過句柄類訪問繼承層次的操作。因爲句柄類使用指針執行操作,虛成員的行爲將在運行時根據句柄實際綁定的對象的類型而變化。因此,句柄的用戶可以獲得動態行爲但無須操心指針的管理
包裝了繼承層次的句柄有兩個重要的設計考慮因素:
• 像對任何保存指針(第 13 章)的類一樣,必須確定對複製控制做些什麼。包裝了繼承層次的句柄通常表現得像一個智能指針(第 13 章)或者像一個值(第 13章)。
• 句柄類決定句柄接口屏蔽還是不屏蔽繼承層次,如果不屏蔽繼承層次,用戶必須瞭解和使用基本層次中的對象。
對於這些選項沒有正確的選擇,決定取決於繼承層次的細節,以及類設計者希望程序員如何與那些類相互作用。下面兩節將實現兩種不同的句柄,用不同的方式解決這些設計問題。

指針型句柄

像第一個例子一樣,我們將定義一個名爲 Sales_item 的指針型句柄類,表示 Item_base 層次。Sales_item 的用戶將像使用指針一樣使用它:用戶將Sales_item 綁定到 Item_base 類型的對象並使用 * 和 -> 操作符執行Item_base 的操作:

// bind a handle(連結句柄) to a Bulk_item object
Sales_item item(Bulk_item("0-201-82470-1", 35, 3, .20));
item->net_price(); // virtual call to net_price function

但是,用戶不必管理句柄指向的對象,Sales_item 類將完成這部分工作。當用戶通過 Sales_item 類對象調用函數時,將獲得多態行爲。

定義句柄

Sales_item 類有三個構造函數:默認構造函數複製構造函數接受Item_base 對象的構造函數。第三個構造函數將複製 Item_base 對象,並保證:
只要 Sales_item 對象存在副本就存在。當複製 Sales_item 對象或給Sales_item 對象賦值時,將複製指針而不是複製對象。像對其他指針型句柄類一樣,將用使用計數來管理副本。
迄今爲止,我們已經使用過的使用計數式類,都使用一個夥伴類來存儲指針和相關的使用計數。這個例子將使用不同的設計,如圖 所示。Sales_item類將有兩個數據成員,都是指針:一個指針將指向 Item_base 對象,而另一個將指向使用計數。Item_base 指針可以指向 Item_base 對象也可以指向
Item_base 派生類型的對象。通過指向使用計數,多個 Sales_item 對象可以共享同一計數器。

 Sales_item 句柄類的使用計數策略

圖1 . Sales_item 句柄類的使用計數策略

除了管理使用計數之外,Sales_item 類還將定義解引用操作符箭頭操作符

// use counted handle class for the Item_base hierarchy
class Sales_item {
public:
// default constructor: unbound handle  
	Sales_item(): p(0), use(new std::size_t(1)) { }
// attaches a handle(柄) to a copy of the Item_base object
	Sales_item(const Item_base&);
// copy control members to manage the use count and pointers   複製控制成員
	Sales_item(const Sales_item &i):
	p(i.p), use(i.use) { ++*use; }
	~Sales_item() { decr_use(); }
	Sales_item& operator=(const Sales_item&);
// member access operators
	const Item_base *operator->() const { if (p) return p;
	else throw std::logic_error("unbound Sales_item"); }
	const Item_base &operator*() const { if (p) return *p;
	else throw std::logic_error("unbound Sales_item"); }
private:
	Item_base *p; // pointer to shared item
	std::size_t *use; // pointer to shared use count 使用計數
// called by both destructor and assignment operator to free pointers
	void decr_use()
	{ if (--*use == 0) { delete p; delete use; } }
};

使用計數式複製控制

複製控制成員適當地操縱使用計數和 Item_base 指針。複製 Sales_item對象包括複製兩個指針和將使用計數加 1。析構函數將使用計數減 1,如果計數減至 0 就撤銷指針。因爲賦值操作符需要完成同樣的工作,所以在一個名爲decr_use 的私有實用函數中實現析構函數的行爲。
賦值操作符比複製構造函數複雜一點:

// use-counted assignment operator; use is a pointer to a shared use count
Sales_item& Sales_item::operator=(const Sales_item &rhs)
{
	++*rhs.use;
	decr_use();
	p = rhs.p;
	use = rhs.use;
	return *this;
}

賦值操作符像複製構造函數一樣,將右操作數的使用計數加 1 並複製指針
它也像析構函數一樣,首先必須將左操作數的使用計數減 1,如果使用計數減至0 就刪除指針。
像通常對賦值操作符一樣,必須防止自身賦值。這個操作符通過首先將右操作數的使用計數減 1 來處理自身賦值。如果左右操作數相同,則調用 decr_use時使用計數將至少爲 2。該函數將左操作數的使用計數減 1 並進行檢查,如果使用計數減至 0,則 decr_use 將釋放該對象中的 Item_base 對象和 use 對象。剩下的是從右操作數向左操作數複製指針,像平常一樣,我們的賦值操作符返回左操作數的引用。
除了複製控制成員以外,Sales_item 定義的其他函數是是操作函數operator* 和 operator->,用戶將通過這些操作符訪問 Item_base 成員。因爲這兩個操作符分別返回指針和引用,所以通過這些操作符調用的函數將進行動態綁定。
我們只定義了這些操作符的 const 版本,因爲基礎 Item_base 層次中的成員都是 const 成員。
//這一段暫時沒看懂

構造句柄

我們句柄有兩個構造函數:默認構造函數創建未綁定的 Sales_item 對象,第二個構造函數接受一個對象,將句柄與其關聯。
第一個構造函數容易定義:將 Item_base 指針置 0 以指出該句柄沒有關聯任何對象上。構造函數分配一個新的計數器並將它初始化爲 1。
第二個構造函數難一點,我們希望句柄的用戶創建自己的對象,在這些對象上關聯句柄。構造函數將分配適當類型的新對象並將形參複製到新分配的對象中,這樣,Sales_item 類將擁有對象並能夠保證在關聯到該對象的最後一個Sales_item 對象消失之前不會刪除對象。

複製未知類型

要實現接受 Item_base 對象的構造函數,必須首先解決一個問題:我們不知道給予構造函數的對象的實際類型。我們知道它是一個 Item_base 對象或者是一個 Item_base 派生類型的對象。句柄類經常需要在不知道對象的確切類型時分配書籍對象的新副本。Sales_item 構造函數是個好例子。
解決這個問題的通用方法是定義虛操作進行復制,我們稱將該操作命名爲clone
爲了句柄類,需要從基類開始,在繼承層次的每個類型中增加 clone,基類必須將該函數定義爲虛函數:

class Item_base {
public:
virtual Item_base* clone() const  //定義虛操作進行復制,我們稱將該操作命名爲 clone
{ return new Item_base(*this); }
};

每個類必須重定義該虛函數。因爲clone函數的存在是爲了生成類對象的新副本,所以定義返回類型爲類本身:

class Bulk_item : public Item_base {
public:
Bulk_item* clone() const
{ return new Bulk_item(*this); }
};

第 15.2.3 節介紹過,對於派生類的返回類型必須與基類實例的返回類型完全匹配的要求,但有一個例外。這個例外支持像這個類這樣的情況。如果虛函數的基類實例返回類類型的引用或指針,則該虛函數的派生類實例可以返回基類實例返回的類型的派生類(或者是類類型的指針或引用)。

定義句柄構造函數

一旦有了 clone 函數,就可以這樣編寫 Sales_item 構造函數:

Sales_item::Sales_item(const Item_base &item):
p(item.clone()), use(new std::size_t(1)) { }

像默認構造函數一樣,這個構造函數分配並初始化使用計數,它調用形參的clone 產生那個對象的(虛)副本。如果實參是 Item_base 對象,則運行Item_base 的 clone 函數;如果實參是 Bulk_item 對象,則執行 Bulk_item 的clone 函數。

句柄的使用

使用 Sales_item 對象可以更容易地編寫書店應用程序。代碼將不必管理Item_base 對象的指針,但仍然可以獲得通過 Sales_item 對象進行的調用的虛行爲。
例如,可以使用 Item_base 對象解決第 15.7 節提出的問題。可以使用Sales_item 對象跟蹤顧客所做購買,在 multiset 中保存一個對象表示一次購買,當顧客完成購買時,可以計算銷售總數。

比較兩個Sales_item 對象

在編寫函數計算銷售總數之前,需要定義比較 Sales_item 對象的方法。要用 Sales_item 作爲關聯容器的關鍵字,必須能夠比較它們(第 10.3.1 節)。
關聯容器默認使用關鍵字類型的小於操作符,但是,基於第 14.3.2 節討論過的有關原始 Sales_item 類型的同樣理由,爲 Sales_item 句柄類定義operator >可能是個壞主意:當使用 Sales_item 作關鍵字時,只想考慮 ISBN,但確定相等時又想要考慮所有數據成員。
幸好,關聯容器使我們能夠指定一個函數或函數對象用作比較函數,這樣做類似於第 11.2.3 節中將單獨函數傳給 stable_sort 算法的方式。在那種情況下,只需要將附加的實參傳給 stable_sort 以提供比較函數,代替 < 操作符的。覆蓋關聯容器的比較函數有點複雜,因爲,正如我們將看到的,在定義容器對象時必須提供比較函數。
讓我們比較容易的部分開始,定義一個函數用於比較 Sales_item 對象:

// compare defines item ordering for the multiset in Basket 
inline bool compare(const Sales_item &lhs, const Sales_item &rhs)
{
	return lhs->book() < rhs->book();
}
//我們的 compare 函數與小於操作符有兩樣的接口,它接受兩個 Sales_item對象的 const 引用,通過比較 ISBN 而比較形參,返回一個 book 值。

該函數使用 Sales_item 的 -> 操作符,該操作符返回 Item_base 對象的指針,那個指針用於獲取並運行成員 book,該成員返回 ISBN。

使用帶關聯容器的比較器

如果考慮一下如何使用比較函數,就會認識到,它必須作爲容器的部分而存儲。任何在容器中增加或查找元素的操作都要使用比較函數。原則上,每個這樣的操作可以接受一個可選的附加實參,表示比較函數。但是,這種策略容易導致出錯:如果兩個操作使用不同的比較函數,順序可能會不一致。不可能預測實際上會發生什麼。
要有效地工作,關聯容器需要對每個操作使用同一比較函數。然而,期望用戶每次記住比較函數是不合理的,尤其是,沒有辦法檢查每個調用使用同一比較函數。因此,容器記住比較函數是有意義的。通過將比較器存儲在容器對象中,可以保證比較元素的每個操作將一致地進行。
基於同樣的理由,容器需要知道元素類型,爲了存儲比較器,它需要知道比較器類型。原則上,通過假定比較器是一個函數指針,該函數接受兩個容器的key_type 類型的對象並返回 bool 值,容器可以推斷出這個類型。不幸的是,這個推斷出的類型可能限制太大。首先,應該允許比較器是函數對象或是普通函數。即使我們願意要求比較器爲函數,這個推斷出的類型也可能仍然太受限制了,畢竟,比較函數可以返回 int 或者其他任意可用在條件中的類型。同樣,形參類型也不需要與 key_type 完全匹配,應該允許可以轉換爲 key_type 的任意形參類型。所以,要使用 Sales_item 的比較函數,在定義 multiset 時必須指定比較器類型。在我們的例子中,比較器類型是接受兩個 const Sales_item 引用並返回 bool 值的函數。
首先定義一個類型別名,作爲該類型的同義詞(第 7.9 節):

// type of the comparison function used to order the multiset
typedef bool (*Comp)(const Sales_item&, const Sales_item&);

這個語句將 Comp 定義爲函數類型指針的同義詞,該函數類型與我們希望用來比較 Sales_item 對象的比較函數相匹配。
接着需要定義 multiset,保存 Sales_item 類型的對象並在它的比較函數中使用這個 Comp 類型。關聯容器的每個構造函數使我們能夠提供比較函數的名字。可以這樣定義使用 compare 函數的空multiset:

std::multiset<Sales_item, Comp> items(compare);

這個定義是說,items 是一個 multiset,它保存 Sales_item 對象並使用Comp 類型的對象比較它們。multiset 是空的——我們沒有提供任何元素,但我們的確提供了一個名爲 compare 的比較函數。當在 items 中增加或查找元素時,將用 compare 函數對 multiset 進行排序。

容器與句柄類

既然知道了怎樣提供比較函數,我們將定義名爲 Basker 的類,以跟蹤銷售並計算購買價格:

class Basket {
// type of the comparison function used to order the multiset
	typedef bool (*Comp)(const Sales_item&, const Sales_item&);
public:
// make it easier to type the type of our set
	typedef std::multiset<Sales_item, Comp> set_type;
// typedefs modeled after corresponding container types
	typedef set_type::size_type size_type;
	typedef set_type::const_iterator const_iter;
	Basket(): items(compare) { } // initialze the comparator 傳遞函數,上面定義那個
	void add_item(const Sales_item &item)
	{ items.insert(item); }
	size_type size(const Sales_item &i) const
	{ return items.count(i); }
	double total() const; // sum of net prices for all items in the basket
private:
	std::multiset<Sales_item, Comp> items;
};

這個類在 Sales_item 對象的 multiple 中保存顧客購買的商品,用multiple 使顧客能夠購買同一本書的多個副本。
該類定義了一個構造函數,即 Basket 默認構造函數。該類需要自己的默認構造函數,以便將compare 傳給建立 items 成員的 multiset 構造函數。Basket 類定義的操作非常簡單:add_item 操作接受 Sales_item 對象引用並將該項目的副本放入 multiset;對於給定 ISBN,size 操作返回購物籃中該ISBN 的記錄數。除了操作,Basket 還定義了三個類型別名,這樣使用它的multiset 成員就比較容易了。

使用句柄執行虛函數

Basket 類唯一的複雜成員是 total 函數,該函數返回購物籃中所有物品的價格:

double Basket::total() const
{
	double sum = 0.0; // holds the running total
/* find each set of items with the same isbn and calculate
* the net price for that quantity of items
* iter refers to first copy of each book in the set
* upper_bound refers to next element with a different isbn
*/
	for (const_iter iter = items.begin();iter != items.end(); iter =items.upper_bound(*iter))
{
//for 循環中的“增量”表達式很有意思。與讀每個元素的一般循環不同,我們推進 iter 指向下一個鍵。
// we know there's at least one element with this key in the Basket
// virtual call to net_price applies appropriate discounts,if any
	sum += (*iter)->net_price(items.count(*iter));
}
	return sum;
}

total 函數有兩個有趣的部分:對 net_price 函數的調用,以及 for 循環結構。我們逐一進行分析。
調用 net_price 函數時,需要告訴它某本書已經購買了多少本,net_price函數使用這個實參確定是否打折。這個要求暗示着我們希望成批處理multiset——處理給定標題的所有記錄,然後處理下一個標題的所有記錄,以此類推。幸好,multiset 非常適合處理這個問題。
for 循環開始於定義 iter 並將 iter 初始化爲指向 multiset 中的第一個元素。我們使用 multiset 的 count 成員(第 10.3.6 節)確定 multiset 中的多少成員具有相同的鍵(即,相同的 isbn),並且使用該數目作爲實參調用net_price 函數。
**for 循環中的“增量”表達式很有意思。與讀每個元素的一般循環不同,我們推進 iter 指向下一個鍵。**調用 upper_bound 函數以跳過與當前鍵匹配的所有元素,upper_bound 函數的調用返回一個迭代器,該迭代器指向與 iter 鍵相同的最後一個元素的下一元素,即,該迭代器指向集合的末尾或下一本書。測試iter 的新值,如果與 items.end() 相等,則跳出 for 循環,否則,就處理下一本書。
for 循環的循環體調用 net_price 函數,閱讀這個調用需要一點技巧:

sum += (*iter)->net_price(items.count(*iter));
//對 iter 解引用獲得基礎 Sales_item 對象
//對該對象應用 Sales_item 類重載的箭頭操作符,該操作符返回句柄所關聯的基礎 Item_base 對象

對 iter 解引用獲得基礎 Sales_item 對象,對該對象應用 Sales_item 類重載的箭頭操作符,該操作符返回句柄所關聯的基礎 Item_base 對象,用該Item_base 對象調用 net_price 函數,傳遞具有相同 isbn 的圖書的 count 作爲實參。net_price 是虛函數,所以調用的定價函數的版本取決於基礎Item_base 對象的類型。

總結

繼承和動態綁定的思想,簡單但功能強大。
繼承使我們能夠編寫新類,新類與基類共享行爲但重定義必要的行爲
動態綁定使編譯器能夠在運行時根據對象的動態類型確定運行函數的哪個版本
繼承和動態綁定的結合使我們能夠編寫具有特定類型行爲而又獨立於類型的程序。

在 C++ 中,動態綁定僅在通過引用或指針調用時才能應用於聲明爲虛的函數。C++ 程序定義繼承層次接口的句柄類很常見,這些類分配並管理指向繼承層次中對象的指針,因此能夠使用戶代碼在無須處理指針的情況下獲得動態行爲。

繼承對象由基類部分和派生類部分組成。繼承對象是通過在處理派生部分之前對對象的基類部分進行構造、複製和賦值,而進行構造、複製和賦值的。因爲派生類對象包含基類部分,所以可以將派生類型的引用或指針轉換爲基類類型的引用或指針。
即使另外不需要析構函數,基類通常也應定義一個虛析構函數。如果經常會在指向基類的指針實際指向派生類對象時刪除它,析構函數就必須爲虛函數

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章