2020年3月31日 星期二
C++自修入門實境秀、C++ Primer 5版研讀秀 104/ ~第13章 拷貝控制13.2.2.定義有著類指標行為的類別 練習13.28-中...
13.2.1. Classes That Act Like Values 有著類值行為的類別 在拷貝、指定時表現得像值的類別
有著類值行為的類別,在拷貝、指定其物件時,那些各個物件都會有它們自己的一個資源副本,且是各自獨立、互不相干的。也就是說,前面那個HasPtr類別的經由拷貝後物件,就必須要有它自己的那個string,以供它的成員ps指標來指向(來用)。要能夠做到以下所舉數項職能,HasPtr才能能夠說是具備了類值的行為:
它的拷貝建構器必須能複製那個拷貝來源物件的string,而不是僅拷貝了來源物件指向這個string的指標。
因為它有了一份string的副本,所以解構器就必須要能解構這個string來釋放其所佔用的資源【中文版這裡「a」都給人家翻譯出來了】
它的拷貝指定運算子必須能夠做到將其右運算元的string拷貝給左運算元,且釋放左運算元原有的那個string。
所以,若HasPtr有著類值的行為,那麼它就會有像這樣的定義:
class HasPtr
{
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
//每個HasPtr物件都有一個ps指標指向的那個string的副本
HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) {}
HasPtr &operator=(const HasPtr &);
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
頁512
我們上列這個類別定義簡單到(simple enough)在類別中除了拷貝指定運算子是只作了宣告,而沒有完整定義外,其他的都定義完了。第一個建構器帶了一個選擇性的string引數,它動態配置了它自己的那份string,且將配置好後回傳回來的指標儲存到ps指標成員中去,作為ps的初始器。而拷貝建構器也動態配置了它自己獨立的string副本。解構器則會藉由對ps執行delete運算子的方式來將建構器配置出來的資源給釋放掉。
Valuelike Copy-Assignment Operator 類值類別的拷貝指定運算子
類值行為的拷貝指定運算子
以傳值方式拷貝指定的拷貝指定運算子
指定運算子一般都結合了解構器與拷貝建構器的功能。指定運算子會將其左運算元的資源照著解構器的方式來解構掉,而再如拷貝建構器的方式來將其右運算元的資料拷貝至其左運算元中。然而非常重要的一點是這些動作的發生是有其先後次序的,即使在一個物件自拷貝時也是如此。9:10 21:50 甚至在,我們應該是要定義我們自己的指定運算子以便在發生例外情形時,能夠將左側運算元保持在可用的狀態。(§5.6.2,頁196)
Moreover, when possible, we should also write our assignment operators so that they will leave the left-hand operand in a sensible state should an exception occur
此外,如果可能,我們也應該將指定運算子撰寫成萬一有例外發生,也會讓左運算元停留在一個合理的狀態。(中文版)
可以藉由先拷貝右運算元來讓我們自定義的指定運算子即使在有例外情形發生時,也能妥當地執行。在拷貝成功後,我們才會釋放左運算元原有的資源,然後再將已經拷貝的右運算元的string副本,交給左運算元的指標成員ps來指向。
27:19
HasPtr &HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps) //拷貝ps指向的string
delete ps; //釋放ps原有的資源
ps = newp; //再將根據rhs複製出來的副本交給ps控管
i = rhs.i;
return *this; //回傳this指向的物件
}
44:34
在這個指定運算子的定義中,我們先照著建構器的工作模式來做,將newp的初始器和HasPtr的拷貝建構器裡的ps的初始器搞成一個樣。又如同HasPtr的解構器的工作模式,我們將ps原來指向的string,藉由對ps進行delete的運算來解構掉它。剩下的就是將ps,以指標拷貝的方式,將它指向new出來的這個新的string,並將rhs的int成員i拷貝給這裡的i成員。
關鍵觀念:指定運算子
在我們自定義自己的指定運算子時有二個重點必須要牢牢記住:
這個指定運算子在自拷貝時也必須能正常執行
大部分指定運算子的工作,都和解構器與拷貝建構器分不開關係
在寫指定運算子時應該是先將其右運算元的值先拷貝到一個區域的暫存物件中,一定要等到這樣的拷貝成功完成後,才能去解構左運算元原有的成員。一旦左運算元解構完成了,才再將那個暫存物件的資料拷貝到左運算元中。
34:35 47:40
沒錄到的部分就看臉書直播第537集
頁513
想要知道為什麼維護自指定或自拷貝是那麼地要緊,我們可以先試著想一想,如果我們自定義的指定運算子像下列這樣的話,那在實際執行起來的話,將會發生什麼事?
//如此定義指定運算子決定是個錯誤(WRONG)!
HasPtr &HasPtr::operator=(const HasPtr &rhs)
{
delete ps; //先釋放左運算元原有的資源,那麼萬一rhs和左運算元物件(即this解參考後的那個物件)竟是同一個物件的話(即在進行自拷貝或自指定的話),接下來的拷貝動作,就會是在對一個已經遭到清除的記憶體區,企圖進行拷貝了!
ps = new string(*(rhs.ps)); //致命的錯誤在這裡!
i = rhs.i; //這個是沒有問題
retrun *this;
}
1:0:28
如果rhs和this指標所代表的物件(即拷貝目的的物件、左運算元的物件)是同一個,對ps下delete運算子就會解構這個rhs的成員ps指標指向的string物件,而這個string物件也正好就是this指標物件下其ps成員指向的那個(因為二者——拷貝、指定的雙方——是同一個物件)。在這個string被刪除後,我們若再想要去將rhs.ps解參考,甚至是去拷貝這個解參考的結果,這個new表述式(expression)回傳的指標當然就會指向一個沒有物件的無效記憶體區域了。在沒有物件的、無效的記憶體區域上做的任何運算,都必然是未定義的(undefined、沒有意義的)。
警告:
非常要緊的是在定義指定運算子時必須要留意即使在做自拷貝或自指定運算時,這個運算子要都能運作正常才行。須養成在摧毀左運算元物件內成員時,先將右運算元的資料給拷貝一份好的習慣
練習13.23
將您在閱讀本區段之前練習的那個程式碼與本段課文的作一比較。要確定,如有任何不同之處,您都清楚明白,它們的問題出在哪裡。
前面練習13.22這樣寫其實是傳址(類指標)的,並不是傳值(類值)的。因為那個string在拷貝、指定前後其實是同一個string,並不是各自獨立的副本。
1:15:58
練習13.24
如果在這裡的HasPtr缺了解構器的定義,或是沒有定義拷貝建構器,會發生什麼事?
那編譯器湊合出來的版本,就只會對指標成員進行拷貝與指定,指標成員指向的物件並不會各自獨立(即各類別物件並不會有其獨立的資料副本),成了類指標的型的類別,而無法成為類值行為的類別。
練習13.25
1:20:20
假設我們要將StrBlob定義成有著類值行為的類別,並且我們也需要繼續用到shared_ptr以便那個StrBlobPtr類別仍然可以用weak_ptr來指向StrBlob物件中的那個vector。這樣重寫的StrBlob就會需要一個拷貝建構器以及拷貝指定運算子,但是並不需要解構器。請說明一下這樣的拷貝建構器與拷貝指定運算子會做些什麼事。而為什麼這樣的類別並不需要解構器?
//類值型的拷貝建構器
StrBlob::StrBlob(const StrBlob& sb) : data(new vector<string>(*sb.data)){}
//類值型的拷貝指定運算子
StrBlob& StrBlob::operator=(const StrBlob& sb) {
vector<string>* vp= new vector<string>(*sb.data);
//reset成員函式同時做了解構與指定
data.reset(vp);//智慧指標shared_ptr在經reset後,其原來指向的物件參考計數就歸零,就自動解構,故不需要解構器;如果用解構器,就會刪除data,參考計數歸零後,連帶的它所指向的物件也被刪除,甚至可能被2次刪除!詳頁465:Table 12.3. Other Ways to Define and Change shared_ptrs
return *this;
}
餘詳 https://github.com/oscarsun72/prog1-C-Primer-5th-Edition-s-Exercises/tree/exercise13_25/prog1
2:20:00
練習13.26
照著前一練習,寫出您自己的StrBlob類別。
已見前練習13.25
2:24:30
13.2.2. Defining Classes That Act Like Pointers
定義有著類指標行為的類別
2:48:43
要讓HasPtr類別行為表現得像個指標,我們就必須將其拷貝建構器及拷貝指定運算子定義成為去拷貝其指標成員的本身,而不是去拷貝它指標成員指向的string物件。這樣的HasPtr類別仍然需要它自己的解構器來解構由其帶了一個string作為引數的建構器所建置出來的資源。(§13.1.4,頁505)然而,解構器在這樣的情況下並不能自行決定釋放那個string的時機。它必須要在最後一個指向那個string的HasPtr物件消失之後,才能將其解構。
要讓一個類別有著類指標的行為,最簡單的方式就是將管控類別成員資源的工作交給shared_ptr來做。對sahred_ptr進行拷貝或指定,其實就是拷貝或指定shared_ptr指向的那個指標。(應即前文所謂shared_ptr底層的指標)
Copying (or assigning) a shared_ptr copies (assigns) the pointer to which the shared_ptr points.
可見shared_ptr的原理是shared_ptr指向一個普通指標,然後這個普通指標再指向那個物件(未必是動態配置物件)。
shared_ptr它自己會追蹤到底有多少個shared_ptr在共享這個它所指向的物件。一旦不再有任何shared_ptr再指向這個物件,它就會自行解構該物件並釋放這物件所佔用的資源。
頁514
有時我們也會傾向於自己來管控類別成員的資源。此時利用reference count(參考計數 §12.1.1,頁452)來管控就是一個好辦法。為了要說明參考計數(reference counting)是怎麼作用的,我們將重新定義HasPtr類別,讓它表現得像個指標,但我們卻不會用shared_ptr來管理HasPtr的資源,而是用我們自己自訂的參考計數來管控。
參考計數(reference counts)
參考計數會有如下的行為:
2:48:30 2:58:20 3:22:44
除了對資料成員作初始化外,類別的每個建構器(除了拷貝建構器)都會創建一個計數器(counter)。這個計數器就是用來專門記錄有多少已被建置出來的類別物件,與現在要建置的這個在共享資源。只要我們建置一個物件,當然就只有一個物件在使用配置出來的資源,因此我們就將這個計數器的值,初始化為1。
拷貝建構器當然就不需要建置這個計數器,它的工作則是負責將建置物件時配置好的計數器連同類別的資料成員一起作拷貝。它會遞增這個計數器來表示已經有另一個物件在共享這個底層的資源。
解構器則是負責遞減這個計數器,來表示資源共享者又少了一員。如果這個計數器的值歸零,解構器才會真的去將共享的資源給清除。
拷貝指定運算子則會將其右運算元的計數器遞增,而將左邊的遞減。一旦遞減至零,拷貝指定運算子就必須將左運算元的資源給清除乾淨。
這一部分比較傷腦筋的是要將這個計數器定義在哪裡才好。這個計數器勢必不能是該類別物件的成員本身。要知道為什麼,只要想想執行像下列的程式碼會發生什麼情形:
HasPtr p1("Hiya!");
HasPtr p2(p1); //此時p1和p2同時指向同一個string
HasPtr p3(p1); //p1、p2、p3全都指向同一個string
3:15:55 3:26:55
如果參考計數是存放在每個類別物件中,那麼在p3被建置時我們又要怎麼去更新這個計數?我們可以遞增p1內的計數器,並把它複製給p3,但我們又要怎麼去更新p2內的計數呢?
解決這個困境的一個辦法是將這個計數器放到動態記憶體(dynamic memory)中。當我們建置一個類別物件時,我們同時也建置出一個新的計數器。而當我們對此類別物件進行拷貝或指定時,我們就拷貝指向這個計數器的指標。這樣一來,拷貝後的指標就會與拷貝來源的指標指向相同的一個計數器了。
Defining a Reference-Counted Class
定義一個有著參考計數的類別
3:22:23 3:28:50 3:37:20
有了參考計數,我們就可以將HasPtr像下面這樣定義成有著類指標的行為:
頁515
class HasPtr
{
public:
//建構器配置出一個新的string和計數器,並將這個計數器的值初始化為1
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
//拷貝建構器則是拷貝此類別中全部的3個資料成員,並會遞增計數器
HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr &operator=(const HasPtr &);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; //這個資料成員是用來追蹤到底有多少物件共享ps解參考後的這個string
};
4:11:32
這裡我們加入了一個新的資料成員叫做use來追蹤有多少類別物件在共用同一個string的物件。而帶了一個string作引數的建構器則會創建出use這個計數器並將其值初始化為1,以表示目前對於這個新物件的ps成員所指向的string唯有一個使用者--就是這個新物件自己。
3:46:35
Pointerlike Copy Members “Fiddle” the Reference Count
類指標的拷貝控制成員會去觸發參考計數
類指標的拷貝成員讓參考計數有點「費勁」(中文版)
當對HasPtr物件進行拷貝或指定時,我們會希望拷貝或指定的雙方能指向同一個string物件。也就是說,拷貝時,我們要拷貝指標成員ps本身,而不是去拷貝它所指向的string。且拷貝的同時,我們也要遞增參考到那個string的參考計數。
3:53:32
拷貝建構器(在類別內已定義完畢了)會從來源物件將其內3個資料成員全部拷貝過來。這個建構器也會遞增計數器use來表示現在又多了一個對這個ps和p.ps同時指向的string的使用者了(其實就是多了ps,與p.ps共用這個string)。
4:16:55
而解構器則不允許恣意對ps進行delete的運算,因為也許還有別的使用者需要用到ps所指向的string物件。因此,解構器必須先遞減use這個參考計數器,來表示共用這個string的類別物件又少了一員。只有到了這個參考計數器的值歸了零,解構器才能去對ps和use作delete運算來釋放string和size_t所佔用的記憶體資源:
4:3:55 4:17:55
HasPtr::~HasPtr()
{
if (--*us == 0)
{ //一旦參考計數歸零
delete ps; //對ps下delete運算子,以釋放ps所指向的string
delete use; //也釋放use所指向的size_t所佔用的記憶體資源
}
}
4:7:40
而拷貝指定運算子通常就是做拷貝建構器與解構器所做的事。也就是說指定運算子必須要能遞增其右運算元的參考計數(一如拷貝建構器所做的那樣)並且能夠遞減其左運算元的,且能在左運算元的參考計數歸零時清除它原有的資源(一如解構器所做的那樣)。
4:29:35
一樣地,指定運算子也必須能夠正確地處理自拷貝或自指定才行。我們是以先將右運算元(即rhs)的參考計數遞增1,然後再遞減左運算元的參考計數。
頁516
這樣一來,若左、右運算元是同一個東西,那麼我們就會在要檢查是否要對ps和use執行delete運算之前,就先遞增參考計數器了:
HasPtr &HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; //遞增右運算元的參考計數
if (--*use == 0)
{ //然後才遞減指定目的地的物件(即左運算元)的參考計數
delete ps; //一旦沒有任何使用者了
delete use; //就解構左運算元中的成員
}
ps = rhs.ps; //拷貝/指定rhs引數的成員給左運算元
i = rhs.i;
use = rhs.use;
return *this; //拷貝/指定完成,就回傳這個左運算元該有的物件
}
4:29:13
練習13.27
4:33:25
請自行定義具備參考計數功能的HasPtr類別。
忘了錄的部分就看臉書直播第541集 https://www.facebook.com/oscarsun72/videos/2659467894164328/
詳:https://github.com/oscarsun72/prog1-C-Primer-5th-Edition-s-Exercises/tree/exercise13_27/prog1
練習13.28
4:29:13
如下類別,請試著實作出它們的預設建構器(default constructor)和必要的拷貝控制成員。
(a)
class TreeNode
{
private:
std::string value;
int count;
TreeNode *left;
TreeNode *right;
};
TreeNode.h
#ifndef TREENODE_H
#define TREENODE_H
#include<string>
class TreeNode
{
public:
TreeNode() : count(0), left(nullptr), right(nullptr), user_cntr(new size_t(1)) {};//預設建構器(default constructor)
TreeNode(const std::string& s) :value(s),
count(s.size()), left(new TreeNode[s.size()]), right(left + s.size() - 1), user_cntr(new size_t(1)) {}//預設建構器
//拷貝建構器
TreeNode(const TreeNode& tn) :
value(tn.value), count(tn.count), left(tn.left), right(tn.right),user_cntr(tn.user_cntr) {
++* user_cntr;
}
~TreeNode() {
if (-- * user_cntr == 0) {
delete[]left;
//left = nullptr; right = nullptr;
delete user_cntr;
}
}
TreeNode& operator=(const TreeNode& rhs) {
++* rhs.user_cntr;
if (--*user_cntr==0)
{
delete[]left;
//left = nullptr; right = nullptr;
delete user_cntr;
}
value = rhs.value;
count = rhs.count;
left = rhs.left;
right = rhs.right;
user_cntr = rhs.user_cntr;
return *this;
}
private:
std::string value;
int count;
TreeNode* left;//由此想到動態陣列(動態配置多個物件);再由new[]想到要解構delete[]
TreeNode* right;
size_t* user_cntr;
};
#endif // !TREENODE_H
.cpp
#include<string>
#include"TreeNode.h"
using namespace std;
int main() {
TreeNode tn,tn1("孫守真"),tn2(string("阿彌陀佛"));
tn = tn1;
TreeNode tn3(tn2);
tn = tn2;
tn1 = tn3;
}
https://github.com/oscarsun72/prog1-C-Primer-5th-Edition-s-Exercises/tree/exercise13_28a/prog1
6:47:30
(b)
class BinStrTree
{
pvivate : TreeNode *root;
};
訂閱:
文章 (Atom)