C++自修入門實境秀、C++ Primer 5版研讀秀 99-1/ ~12.3.1. Design of the Query Program~練習1...



頁451

shared_ptr的拷貝與指定

第99集開始

一旦我們對shared_ptr這樣型別的智慧指標作拷貝或指定的運算時,每個參與拷貝或指定的shared_ptr都會即時追蹤並記錄目前到底有多少個shared_ptr指向了相同的動態物件:

auto p = make_shared<int> (42) ; // p 所指的物件有一個使用者(即有一個shared_ptr指向它)

auto q(p) ; //用智慧指標p將q初始化,則p和q會同時指向相同的動態物件

//則 p和q所指的物件此時有兩個使用者(即兩個智慧指標指向它) 5:10

頁452

表 12.1 : shared_ptr 和 unique_ptr 共通的運算

表 12.1 : shared_ptr 和 unique_ptr 共通的運算

shared_ptr<T> sp

unique_ptr<T> up 宣告了一個可指向T型別的物件,而其值為null的shared_ptr或unique_ptr智慧指標。

【守真按:可見會預設初始化為空指標nullptr。】

p 在條件句中用p的話,若p指向了一個有效物件,其值就為true。若p為空指標,當然為0=false。

*p 將p解參考來取得p所指向的物件。

p->mem 與(*p).mem等效。【mem表示member】

p.get() 回傳智慧指標P中的一般指標 。使用時請小心,此操作下回傳的指標所指的物件可能會在智慧指標p刪除該物件的時候消失。

swap(p,q) p.swap(q) 將智慧指標p與q中的指標對調。即對調p與q此二變數所載(所存放)的普通指標

可以想像shared_ptr有一個附帶的計數器,這個計數器通常會被稱作指標計數(reference count,參考數量、參考計數)。當一個shared_ptr被拷貝的時候,這個計數就會遞增(+1)。這個shared_ptr的指標計數會在以下情況中遞增其值:

1.當我們用它來初始化另一個shared_ptr的時候、

2.用它來作為指定式的右運算元時,

3.以傳值方式將之傳入函式(§6.2.1)或從函式回傳其值(§6.3.2)時。

總之傳值(pass by value)、複製,指標計數就會遞增。

而這個計數器會在以下的情況下遞減其值:

1.當一個shared_ptr被指定新值時,

2.shared_ptr本身被摧毀時,比如當程式的執行點超出了一個區域性的shared_ptr的範疇(§6.1.1)的時候,這個區域性的shared_ptr的生命週期自然就結束。

只要shared_ptr這個附帶的關聯的計數器其值降為零,最後這個shared_ptr就會自動刪除它所管控的動態物件、並釋放在動態記憶體區中佔用的記憶體資源:

auto r = make_shared<int>(42); //r所指的int有一個使用者,其參考計數(referencecount)此時為1

r = q; //指定值給r,讓它指向一個不同的位址

//這時會遞增q所指物件的使用者數量(參考計數、指標計數)

//而原來r曾指過的那個物件使用者數量則會遞減

//r曾指的那個物件現在沒有使用者了,那個物件就自動會被刪除

這裡我們建置(配置且建構)了一個int,並將對那個int的一個指標儲存到智慧指標shared_ptrr中。接著,我們指定一個新的值給r。而r在被指定前,是指向我們之前配置的那個int物件的唯一shared_ptr。那個int物件會在指定q給r的過程中被自動刪除。

注意:是要採用shared_ptr的計數器還是其他的資料結構來追蹤記錄有多少指標同時指向了相同物件,這種功能是在我們編撰程式碼(簡稱編程)的實作中可自行定義的。但不論如何,關鍵總在於,我們定義的這個類別物件必須要能記下尚有多少shared_ptr指向同一物件,並在適當的時候自動釋放那個物件。

shared_ptr會自動摧毀它們的物件…

33:28

當指向一個物件的最後一個shared_ptr被摧毀時,shared_ptr類別就會自動摧毀那個shared_ptr所指的物件。它會假手它所指類別的另一個特殊的成員函式來進行這樣的操作,這種特殊的成員函式叫作解構器(destructor)。每個類別有建構器,也都會有一個解構器。建構器負責該類別型別的物件要如何被初始化,而解構器則決定了該類別型別的物件在被摧毀時會發生什麼事。

頁453

表12.2 : shared_ptr專屬的運算

表12.2 : shared_ptr專屬的運算

make_shared<T>(args) 回傳一個shared_ptr指向一個動態配置的T型別物件,且使用args來初始化那個物件。

shared_ptr<T> p(q) shared_ptr智慧指標 p是shared_ptr q的一個拷貝,在這樣的操作下會遞增q中的參考計數。q中的一般指標必須能夠轉換為對T型別的指標(即T*) ( §4.11.2,頁162)。

p = q shared_ptr p與q 存放的指標是可以互相轉換的。在這樣的指定式下會遞減p的參考計數並遞增q的;如果p的計數降到了0,就會刪除p指向的物件,而釋放其記憶體。

p.unique() 如果p.use_count()是1,就回傳true,否則為false。

p.use_count() 回傳與p共享物件的指標數量;可能會是冗長的運算,主要是用在除錯上。【守真按:此回傳即是參考計數值。】

類別的解構器負責釋放已配置給其型別物件的資源。比如說,string建構器(以及其他的string成員)會進行記憶體的配置來存放構成string的字元。string的解構器則會釋放那些已配置給該string字元用的記憶體資源。同樣地,有些vector的運算或操作也會配置記憶體來存放vector要用到的元素。而vector的解構器則會摧毀那些元素,並釋放那些元素所佔用的記憶體。

而shared_ptr類別的解構器則負責去遞減shared_ptr附帶的參考計數。如果這個計數降為零,shared_ptr解構器就會調用shared_ptr所指向的型別物件的解構器來摧毀那個shared_ptr所指向的物件,並釋放該物件佔用的記憶體。

…並自動釋放關聯的記憶體

因為「shared_ptr類別會自動釋放不再需要用到的動態物件」,所以在shared_ptr的管控下,動態記憶體的妥善使用就變得容易得多。舉例來說,我們可能會有一個函式回傳一個shared_ptr指向一個動態配置的物件,而這個動態物件(dynamic object)型別為Foo,且可由T型別的引數來將之初始化:

//factory會傳一個shared_ptr指向一個動態配置的物件

shared_ptr<Foo> factory (T arg)

{

//適當地處理arg

//shared_ptr會負責刪除這個記憶體

return make_shared<Foo>(arg);

}

因為factory會回傳一個shared_ptr,我們可以確定factory在「make_shared」這一行中所建置出來的物件會在離開factory後還能在用不到它的時候被清除。如下列的use_factory函式會將factory所回傳的shared_ptr儲存在一個區域變數p中:

void use_factory(T arg)

{

shared_ptr<Foo> p = factory(arg); //使用p

} //p離開了它所生成的範疇,它的生命週期結束了,因此它所指向的記憶體就會自動被釋放

參考計數試算:

shared_ptr<Foo> p

此時p的參考計數為0

shared_ptr<Foo> p = factory(arg);

p就遞增+1,成了1(=0+1)。把factory回傳的參考計數1,賦予(指定)給了p。

頁454

因為智慧指標(smart pointer)p對use_factory來說是區域性的,它會在use_factory結束時被摧毀(§ 6.1.1 ,頁204)。當p被摧毀時,它的參考計數會遞減,並受檢查。在這個例子中,p是指向factory所回傳的動態記憶體區配置物件的唯一指標。1:11:55因為作為shared_ptr智慧指標的p即將消失,這個指標所指向的那個動態配置的物件(dynamically allocated object)也會隨之被銷毀,而那個物件所佔用的記憶體亦將隨著釋放。

但是,若還有其他的shared_ptr仍舊指向著那個物件,那麼它所佔用的記憶體就不會跟著p的銷毀而被釋放:

shared_ptr<Foo> use_factory(T arg)

{

shared_ptr<Foo> p = factory(arg);

//使用p

return p; //因為函式回傳是用傳值的方式——也就是拷貝——所以我們回傳p的時候參考計數是會遞增的

}// p已離開其生命範疇了,然而p所指的記憶體位置卻並未被釋放

在這個版本的use_factory中,其return述句會回傳p的一個拷貝給呼叫use_factory的(§6.3.2)。拷貝一個shared_ptr會遞增對那個物件的參考計數。而如今,p雖然被摧毀了,但是p所指向的記憶體卻仍有另一個sheard_ptr還指向著它——也就是那個在回傳時被拷貝出來的shared_ptr物件還指向著它。而shared_ptr類別是會保證只要還有任何的shared_ptr依附在那個記憶體上,那個記憶體就不會被釋放。

也就是因為直到最後一個shared_ptr消失之前,shared_ptr所指向的記憶體都不會被自動釋放。因此我們在撰寫程式碼時,一定要顧慮到:當不再需要某個動態物件後,切記要把所有指向該物件的shared_ptr清除乾淨。若是忘了徹底清除用不到的shared_ptr,雖然應用程式仍能一如往常地執行,而沒有異狀,但卻會無謂地浪費掉原本可資利用的記憶體資源。會忘了把shared_ptr清除乾淨的情形,最常見的是發生在將shared_ptr作為容器的元素型別使用時。如一旦對該容器元素進行了重新排序,若不再需要使用到容器中所有的元素時,就應該即時把不再用到的元素確實進行容器的erase運算,以徹底清除那些不再會用到的shared_ptr的元素。

頁451

shared_ptr的拷貝與指定

第99集開始

一旦我們對shared_ptr這樣型別的智慧指標作拷貝或指定的運算時,每個參與拷貝或指定的shared_ptr都會即時追蹤並記錄目前到底有多少個shared_ptr指向了相同的動態物件:

auto p = make_shared<int> (42) ; // p 所指的物件有一個使用者(即有一個shared_ptr指向它)

auto q(p) ; //用智慧指標p將q初始化,則p和q會同時指向相同的動態物件

//則 p和q所指的物件此時有兩個使用者(即兩個智慧指標指向它) 5:10

頁452

表 12.1 : shared_ptr 和 unique_ptr 共通的運算

表 12.1 : shared_ptr 和 unique_ptr 共通的運算

shared_ptr<T> sp

unique_ptr<T> up 宣告了一個可指向T型別的物件,而其值為null的shared_ptr或unique_ptr智慧指標。

【守真按:可見會預設初始化為空指標nullptr。】

p 在條件句中用p的話,若p指向了一個有效物件,其值就為true。若p為空指標,當然為0=false。

*p 將p解參考來取得p所指向的物件。

p->mem 與(*p).mem等效。【mem表示member】

p.get() 回傳智慧指標P中的一般指標 。使用時請小心,此操作下回傳的指標所指的物件可能會在智慧指標p刪除該物件的時候消失。

swap(p,q) p.swap(q) 將智慧指標p與q中的指標對調。即對調p與q此二變數所載(所存放)的普通指標

可以想像shared_ptr有一個附帶的計數器,這個計數器通常會被稱作指標計數(reference count,參考數量、參考計數)。當一個shared_ptr被拷貝的時候,這個計數就會遞增(+1)。這個shared_ptr的指標計數會在以下情況中遞增其值:

1.當我們用它來初始化另一個shared_ptr的時候、

2.用它來作為指定式的右運算元時,

3.以傳值方式將之傳入函式(§6.2.1)或從函式回傳其值(§6.3.2)時。

總之傳值(pass by value)、複製,指標計數就會遞增。

而這個計數器會在以下的情況下遞減其值:

1.當一個shared_ptr被指定新值時,

2.shared_ptr本身被摧毀時,比如當程式的執行點超出了一個區域性的shared_ptr的範疇(§6.1.1)的時候,這個區域性的shared_ptr的生命週期自然就結束。

只要shared_ptr這個附帶的關聯的計數器其值降為零,最後這個shared_ptr就會自動刪除它所管控的動態物件、並釋放在動態記憶體區中佔用的記憶體資源:

auto r = make_shared<int>(42); //r所指的int有一個使用者,其參考計數(referencecount)此時為1

r = q; //指定值給r,讓它指向一個不同的位址

//這時會遞增q所指物件的使用者數量(參考計數、指標計數)

//而原來r曾指過的那個物件使用者數量則會遞減

//r曾指的那個物件現在沒有使用者了,那個物件就自動會被刪除

這裡我們建置(配置且建構)了一個int,並將對那個int的一個指標儲存到智慧指標shared_ptrr中。接著,我們指定一個新的值給r。而r在被指定前,是指向我們之前配置的那個int物件的唯一shared_ptr。那個int物件會在指定q給r的過程中被自動刪除。

注意:是要採用shared_ptr的計數器還是其他的資料結構來追蹤記錄有多少指標同時指向了相同物件,這種功能是在我們編撰程式碼(簡稱編程)的實作中可自行定義的。但不論如何,關鍵總在於,我們定義的這個類別物件必須要能記下尚有多少shared_ptr指向同一物件,並在適當的時候自動釋放那個物件。

shared_ptr會自動摧毀它們的物件…

33:28

當指向一個物件的最後一個shared_ptr被摧毀時,shared_ptr類別就會自動摧毀那個shared_ptr所指的物件。它會假手它所指類別的另一個特殊的成員函式來進行這樣的操作,這種特殊的成員函式叫作解構器(destructor)。每個類別有建構器,也都會有一個解構器。建構器負責該類別型別的物件要如何被初始化,而解構器則決定了該類別型別的物件在被摧毀時會發生什麼事。

頁453

表12.2 : shared_ptr專屬的運算

表12.2 : shared_ptr專屬的運算

make_shared<T>(args) 回傳一個shared_ptr指向一個動態配置的T型別物件,且使用args來初始化那個物件。

shared_ptr<T> p(q) shared_ptr智慧指標 p是shared_ptr q的一個拷貝,在這樣的操作下會遞增q中的參考計數。q中的一般指標必須能夠轉換為對T型別的指標(即T*) ( §4.11.2,頁162)。

p = q shared_ptr p與q 存放的指標是可以互相轉換的。在這樣的指定式下會遞減p的參考計數並遞增q的;如果p的計數降到了0,就會刪除p指向的物件,而釋放其記憶體。

p.unique() 如果p.use_count()是1,就回傳true,否則為false。

p.use_count() 回傳與p共享物件的指標數量;可能會是冗長的運算,主要是用在除錯上。【守真按:此回傳即是參考計數值。】

類別的解構器負責釋放已配置給其型別物件的資源。比如說,string建構器(以及其他的string成員)會進行記憶體的配置來存放構成string的字元。string的解構器則會釋放那些已配置給該string字元用的記憶體資源。同樣地,有些vector的運算或操作也會配置記憶體來存放vector要用到的元素。而vector的解構器則會摧毀那些元素,並釋放那些元素所佔用的記憶體。

而shared_ptr類別的解構器則負責去遞減shared_ptr附帶的參考計數。如果這個計數降為零,shared_ptr解構器就會調用shared_ptr所指向的型別物件的解構器來摧毀那個shared_ptr所指向的物件,並釋放該物件佔用的記憶體。

…並自動釋放關聯的記憶體

因為「shared_ptr類別會自動釋放不再需要用到的動態物件」,所以在shared_ptr的管控下,動態記憶體的妥善使用就變得容易得多。舉例來說,我們可能會有一個函式回傳一個shared_ptr指向一個動態配置的物件,而這個動態物件(dynamic object)型別為Foo,且可由T型別的引數來將之初始化:

//factory會傳一個shared_ptr指向一個動態配置的物件

shared_ptr<Foo> factory (T arg)

{

//適當地處理arg

//shared_ptr會負責刪除這個記憶體

return make_shared<Foo>(arg);

}

因為factory會回傳一個shared_ptr,我們可以確定factory在「make_shared」這一行中所建置出來的物件會在離開factory後還能在用不到它的時候被清除。如下列的use_factory函式會將factory所回傳的shared_ptr儲存在一個區域變數p中:

void use_factory(T arg)

{

shared_ptr<Foo> p = factory(arg); //使用p

} //p離開了它所生成的範疇,它的生命週期結束了,因此它所指向的記憶體就會自動被釋放

參考計數試算:

shared_ptr<Foo> p

此時p的參考計數為0

shared_ptr<Foo> p = factory(arg);

p就遞增+1,成了1(=0+1)。把factory回傳的參考計數1,賦予(指定)給了p。

頁454

因為智慧指標(smart pointer)p對use_factory來說是區域性的,它會在use_factory結束時被摧毀(§ 6.1.1 ,頁204)。當p被摧毀時,它的參考計數會遞減,並受檢查。在這個例子中,p是指向factory所回傳的動態記憶體區配置物件的唯一指標。1:11:55因為作為shared_ptr智慧指標的p即將消失,這個指標所指向的那個動態配置的物件(dynamically allocated object)也會隨之被銷毀,而那個物件所佔用的記憶體亦將隨著釋放。

但是,若還有其他的shared_ptr仍舊指向著那個物件,那麼它所佔用的記憶體就不會跟著p的銷毀而被釋放:

shared_ptr<Foo> use_factory(T arg)

{

shared_ptr<Foo> p = factory(arg);

//使用p

return p; //因為函式回傳是用傳值的方式——也就是拷貝——所以我們回傳p的時候參考計數是會遞增的

}// p已離開其生命範疇了,然而p所指的記憶體位置卻並未被釋放

在這個版本的use_factory中,其return述句會回傳p的一個拷貝給呼叫use_factory的(§6.3.2)。拷貝一個shared_ptr會遞增對那個物件的參考計數。而如今,p雖然被摧毀了,但是p所指向的記憶體卻仍有另一個sheard_ptr還指向著它——也就是那個在回傳時被拷貝出來的shared_ptr物件還指向著它。而shared_ptr類別是會保證只要還有任何的shared_ptr依附在那個記憶體上,那個記憶體就不會被釋放。

也就是因為直到最後一個shared_ptr消失之前,shared_ptr所指向的記憶體都不會被自動釋放。因此我們在撰寫程式碼時,一定要顧慮到:當不再需要某個動態物件後,切記要把所有指向該物件的shared_ptr清除乾淨。若是忘了徹底清除用不到的shared_ptr,雖然應用程式仍能一如往常地執行,而沒有異狀,但卻會無謂地浪費掉原本可資利用的記憶體資源。會忘了把shared_ptr清除乾淨的情形,最常見的是發生在將shared_ptr作為容器的元素型別使用時。如一旦對該容器元素進行了重新排序,若不再需要使用到容器中所有的元素時,就應該即時把不再用到的元素確實進行容器的erase運算,以徹底清除那些不再會用到的shared_ptr的元素。


我們一樣會指定我們的StrBlob類別對於vector提供了/能支援怎樣的運算。但現在,我們只會實作vector運算中的一部分,而不是全部。我們也會稍微更動對vector元素存取的相關運算(諸如front與back這樣的成員函式):在即將定義的StrBlob類別中,如果有人企圖存取不存在的元素,那麼這些運算就會先行攔查,並丟出一個例外的錯誤訊息(throw an exception)。

除了上述的運算外,這個StrBlob類別還會定義一個預設建構器,以及一個參數型別為initializer_list<string>(§6.2.6,頁220,初始器串列(initializer list))的建構器;這個建構器會接受一個大括號圍起的初始器串列作為它的引數。

class StrBlob {

public:

typedef std::vector<std::string>::size_type size_type;

StrBlob();

StrBlob(std::initializer_list<std::string> il);

size_type size() const{return data->size();}

bool empty() const{return data->empty();}

//新增和移除元素的成員函式

void push_back(const std::string &t){data->push_back(t);}

void pop_back();

//存取元素的成員函式

std::string& front ();

std::string& back();

private:

std::shared_ptr<std::vector<std::string>> data;

//如果對data指向的vector以[i]下標運算無效,就會丟出msg這樣的例外錯誤

void check(size_type i, const std::string &msg) const;

};

在這個StrBlob類別中,我們實作了size、empty與push_back這些成員函式。這些成員會將它們的工作透過data這個智慧指標轉派(委派)給它們指向的(底層的underlying)vector來運作。也就是,在一個StrBlob上呼叫size成員函式,會將其data解參考(dereference)後來呼叫vector的size成員函式以進行實際的運算工作(即「data->size()」所做的工作);餘者以此類推。

StrBlob的建構器

而我們定義的這個StrBlob類別,其建構器都應該要用到它的建構器初始器串列(constructor initializer list,§7.1.4,頁265;即建構器名稱「:」與「{」之間的部分)來初始化它的data這個資料成員,以便讓這個智慧指標data指向一個經過動態配置的vector。StrBlob的預設建構器會配置一個空的vector來讓data這個智慧指標成員指向它:

StrBlob::StrBlob():data(make_shared<vector<string>>()){}

StrBlob::StrBlob(initializer_list<string> il):

data(make_shared<vector<string>>(il)){}

而接受一個initializer_list(初始器串列)作為引數的建構器,則會將其參數傳給make_shared,作為make_shared回傳的智慧指標所指向的vector用來初始化的串列(§ 2.2.1,頁43、97、98。)。這個vector的建構器就會藉由拷貝這個被傳入串列的值來初始化其本身的元素。即所謂的串列初始化(list initialization)。就是用串列初始化來初始化這個make_shared所回傳的智慧指標指向的vector。

頁457

3:21:55

存取StrBlob元素的成員函式(Element Access Members)

對於存取vector元素的成員函式要怎麼重新定義

在我們定義的StrBlob類別中,pop_back、front與back這三個成員函式都必須用到vector中的成員函式。這些運算都必須在試著存取一個元素之前,先行檢查該元素是否存在。因為有好幾個成員函式都需要用到這樣的檢查,我們就可以對我們的StrBlob類別另外獨立定義一個叫做check的私有(private)成員函式,來提供這些存取元素的成員函式使用。這個check會檢查要用來下標的索引值「i」是否在vector的範圍中。除了需要這個索引值作為其引數,check還需要一個string型別的引數,來傳給例外處理器(exception handler)以傳達相關的錯誤訊息:

void StrBlob::check(size_type i, const string &msg) const

{

if (i >= data->size())//以引數i為基準來檢查

throw out_of_range(msg);

}

pop_back和其他StrBlob用來存取元素的成員函式都會先調用這個check來檢查它們想要存取的索引值位置是否確有元素存在。如果check沒丟出例外,這些成員函式就會把它們的工作轉給底層的vector運算來進行操作:

string& StrBlob::front ()

{

//如果vector是空的,check就會丟出例外

check (0, "front on empty StrBlob");

return data->front();

}

string& StrBlob::back()

{

check(0, "back on empty StrBlob");

return data->back();

}

void StrBlob::pop_back()

{

check (0, "pop_back on empty StrBlob);

data->pop_back();

}

我們還應該對front與back這兩個成員函式,用const來加以重載(§ 7.3.2,頁276)。定義這樣的const常值或唯讀的成員函式就留給各位讀者來作練習了。3:34:59

定義StrBlob物件的拷貝、指定和摧毀運算

就像我們自定義的Sales_data類別,這裡的StrBlob類別也會沿用預設版本的運算來進行它型別物件的拷貝、指定和摧毀(§7.1.5,頁267)。預設情況是,這些運算會拷貝、指定和摧毀StrBlob類別的資料成員。我們的StrBlob只有一個資料成員——就是data,它是一個shared_ptr型別的智慧指標。因此,當我們對StrBlob物件進行拷貝、指定或摧毀,那麼它的這個data成員就會被拷貝、指定或摧毀。

如我們在前面所見,如果對一個shared_ptr型別的物件進行拷貝的話,就會遞增它的參考計數。而將一個shared_ptr指定給另外一個,則會遞增指定運算子(assignment operator,即「=」)右運算元的參考計數,並遞減左運算元的計數。至於摧毀一個shared_ptr則會遞減這個shared_ptr的參考計數。如果一個shared_ptr的參考計數降到零,shared_ptr所指的物件就會自動銷毀。因此,StrBlob建構器所配置的vector,就會在參考到那個vector的最後一個StrBlob物件被摧毀時,自動摧毀。

頁458

練習12.1

在這段程式碼結束後,b1和b2分別會有多少個元素。

StrBlob b1;

{

StrBlob b2 = {"a", "an", "the"};

b1 = b2;

b2.push_back("about");

}

因為StrBlob物件間共用了底層容器資源,故b1會有4個元素,而區域變數b2會失效。

練習12.2

3:46:44

寫出您自己版本的StrBlob類別,且包含了常值(const)版的front與back成員函式。

練習12.3

這樣的類別是否需要常值版的push_back和pop_back?如果是,就加入到這個類別裡;若不,請說明之。

在某物件上呼叫的成員函式,要不會動到此物件,才會需要用到常值版。而push_back與pop_back一定會動到它所在的物件,怎麼可能用常值版呢?

練習12.4

在我們定義的check成員函式中,我們並沒有檢查傳入的索引值i是否大於0,為什麼略過這樣的檢查是無妨的?

練習12.5

我在定義StrBlob帶有一個initializer_list型別參數的建構器時並沒有指定它是explicit的(§7.5.4,頁296),這樣的安排有什麼優缺點?


12.1.2不藉由智慧指標而直接來管理記憶體(其實就是new與delete運算子的作用)

C++本身定義了兩個運算子來配置(allocate)和釋放(free)動態記憶體。new運算子是用來配置且建構初始化記憶體的,而delete則會解構且解配置釋放由new所配置的記憶體。4:3:34

當我們了解了這兩個運算子到底是如何地運作之後,我們就更能明白:使用new和delete來管控記憶體,為什麼會比使用智慧指標來管控,更容易出錯;甚至,那些自行用new和delete來直接管控記憶體的類別,就不能像使用智慧指標來管控的那樣,直接利用預設的拷貝、指定或摧毀的定義,來對其類別或型別物件的資料成員(§7.1.5,頁267)進行拷貝、指定或摧毀。也就是因為這樣,使用智慧指標來管控記憶體的程式,就更加容易(likely to be easier to)撰寫與除錯了。

警告:在還沒學到第13章的本事前,自訂的類別應該只有在使用智慧指標管控記憶體的情況下,才考慮去對動態記憶體進行配置。

使用new來動態配置和初始化物件

因為在自由存放區(free store,自由存放的記憶體區,或「記憶體的自由存放區」)配置的物件是不具名的(unnamed),所以new就無法為它配置出來的物件命名,在這種情況下,要調用(呼叫,具名才能呼叫啊)那個物件,new就只能藉由回傳一個指向它所配置出來物件的指標,進行對該物件的操控:

int *pi = new int; // pi指向一個動態配置的、不具名的、未初始化的int

這個new運算式會在自由存放區建構一個型別為int的物件,並回傳對那個物件的一個普通指標。4:7:50

預設情況下,動態配置的物件是預設初始化的(§2.2.1,頁43),這表示動態配置的內建或複合型別的物件具有未定義的值;而類別型別的物件則是由它們的預設建構器來初始化的:

頁459

string *ps = new string; //初始化為空 string

int *pi = new int; //pi指向一個未初始化的int

我們可以使用直接初始化(§3.2.1)去初始化一個由new建置出來的動態配置物件。我們可以使用傳統的建構方式(construction)——即使用小括弧(using parentheses);而在新標準底下,我們也可以使用串列初始化(即使用大括號):

int *pi = new int(1024); // pi所指的物件具有 1024 這個值

string *ps = new string(10, '9' ); // 解參考ps後,得到的是"9999999999"——10個9

//pv指向的是具有十個元素的vector,存放從0到9的值

vector<int> *pv = new vector<int>{0,1,2,3,4,5,6,7,8,9};

我們也可以值初始化(§3.3.1,頁98)一個動態配置的物件,方法是在其型別名稱後面接著一對空的小括弧:

string *ps1 = new string; // 預設初始化為空的 string

string *ps = new string(); // 值初始化為空的 string

int *pi1 = new int; //預設初始化;*pi1是未定義

int *pi2 = new int(); // 值初始化為 0 ; 解參考pi2(*pi2)得到的是0

對於定義了它們自己建構器(§7.1.4,頁262)的類別型別(例如string)而言,對其作值初始化的操作與預設初始化的結果並沒有什麼區別;不管對這樣的類別物件加不加上小括號,它們都是由它們類別的預設建構器來初始化的,加不加上小括號對它們初始化的結果並沒有任何影響。然而對內建型別來說,加不加上小括號的差別就很大了:一個經過值初始化的內建型別物件會有一個定義好的值,但若是藉由預設初始化的物件就是未定義的(undefined)。同樣地,如果一個類別是藉由合成預設建構器(synthesized default constructor,即編輯器拼湊成、湊合著用的建構器)來初始化其內建型別的資料成員,而沒有定義自己的建構器來初始化這些成員,那麼沒在這樣的類別主體中被初始化的資料成員就會有未初始化的值(§7.1.4,頁263。也就是:它們將會是未定義的(undefined))。

養成好習慣(Best Practices):就像我們為什麼通常會將要用到的變數進行適當地初始化一樣,將要用到的動態配置物件執行初始化,當然也是一個好的編程習慣。

當我們在小括弧內提供一個初始器,我們可以用auto( §2.5.2,頁68)來從這個初始器推出我們想要配置的物件型別。然而,因為這樣的推測是編譯器根據初始器的型別來達成的,因此當我們想要用auto來臆測物件建構初始化後會回傳的型別,那麼在小括弧內就只能提供一個初始器:

auto p1 = new auto(obj); // p1指向型別為obj的一個物件

//那個物件會用obj來初始化

auto p2 = new auto{a,b,c}; //錯誤:必須為初始器使用小括孤

p1的型別是一個指標,指向的是從obj自動推斷出來的型別。如果obj是一個int,那麼p1就是對int的指標(int*);如果obj是一個string,那麼p1就是一個對string型別的指標(string*),依此類推。而新配置出來的物件也會以obj的值來進行初始化。【守真按:可見值初始化有2種,一種是隱含其值的,即C++默認的值初始化(如前()空括號對int就是以「0」來將int物件初始化為值=0;當有加「()」表示要請C++進行值初始化;而若省略/沒有「()」,則表示要C++編譯器進行預設初始化);而一種則是明確指定其值的,即若這裡的obj的值】

動態配置的唯讀(const)物件

4:23:10

在C++中,使用new來配置const物件也是可以的:

//配置並初始化一個唯讀的(const)int

const int *pci = new const int(1024);

//配置一個經過預設初始化的、唯讀的(const)、空的string

const string *pcs = new const string;

頁460

就像其他的任何const,一個動態配置的const(唯讀)物件也必須被初始化。一個類別型別只要定義了預設建構器(§7.1.4),那麼它的const動態物件就可以被隱含地初始化。其他未定義預設建構器的型別的物件則必須明確加以初始化。因為所配置的物件是唯讀的(const),new所回傳的指標就會是對const(唯讀物件)的指標(§2.4.2)。

記憶體不足(記憶體耗盡)

即使當今的電腦大多都配備了很大的記憶體容量,但自由存放區(或堆積區、堆置區)記憶體不足的情況,仍然是非常可能會發生的。一旦應用程式用盡了作業系統配給它的所有記憶體,那麼進行new的運算就會失敗。在預設情況下,只要new沒辦法配置到所需的記憶體(儲存區),就會丟出型別為bad_alloc(§5.6.3頁197)的一個例外錯誤(§5.6,頁193)。然而我們也可以使用另一種new的寫法(語法)來避免new的運算在發生錯誤時丟出例外情形:

int *p1 = new int; // 如果配置失敗,new 會擲出 std::bad_alloc

int *p2 = new (nothrow) int; //如果配置失敗,new會回傳一個null指標

由於在後面§ 19.1.2(頁823)中會提到的原因,這個形式的new就被稱作placement new (放置型的 new)。一個placement new運算式(expression,表達式,即「語法」)讓我們可以傳入額外的引數給new。在這裡,我們傳入了一個名為nothrow的物件,這是由程式庫所定義的。當我們傳入nothrow給new,就等於告訴new 它決定不要發出例外情形。只要這種語法形式(form)的new無法配置到所需的記憶體(儲存區),它就會回傳一個null指標,而不會丟出例外情形。bad_alloc和nothrow都是定義在new標頭檔中。


如何釋放動態記憶體(delete運算子)

為了避免記憶體耗盡,我們就必須在動態配置的物件使用完畢後,將其記憶體歸還給作業系統。可以透過一個delete運算式(表達式,delete expression)來將記憶體資源歸還給系統。一個delete運算式接受一個指標引數,這個指標指向的就是我們想要釋放的物件:

delete p; // p這個指標必須指向一個動態配置的物件或者是null

指標值和delete的關係

我們傳入給delete的指標一定要是指向動態配置的記憶體的指標,或者是一個null指標(§ 2.3.2 )。delete一個不是由new配置出來的動態記憶體指標,或者對相同的指標值進行多過一次的delete,都將會是未定義的(毫無意義的)行為:

int i, *pi1 = &i, *pi2 = nullptr;

double *pd = new double(33), *pd2 = pd;

delete i; //錯誤:i不是一個指標,而是一個區域變數(local)

delete pi1; //未定義:pi1指向一個區域變數(local),而不是new出來的動態記憶體位置(物件),也不是null指標

delete pd; //ok

delete pd2; //未定義:pd2所指向的動態記憶體區已經被釋放了,其物件已然銷毀了

delete pi2;//ok: delete null指標永遠都是ok的(對null指標進行delete運算永遠是ok的)

4:37:39

頁461

編譯器會發現「delete i;」是一個錯誤,因為它知道i並不是一個指標,然而在pi1和pd2上執行delete,就比較不容易被編譯器偵察出錯誤。編譯器通常沒有辦法去分辨一個指標到底是指向靜態配置的物件、或是動態配置的物件(亦即「是編譯器控管的,抑或是程式自行控管的」)。同樣地,編譯器也無法知道一個指標它指向的記憶體位址是否已經被釋放了。大多數的編譯器面對這樣的delete表達式(運算式,expressions)都無法辨析出它們是對是錯;而即使它們是錯的,編譯器也沒辦法在編譯期間就偵測出來。

一個唯讀的(const)物件其值雖然無法修改,但它本身卻是可以被摧毀的。就跟其他任何的動態物件一樣,要摧毀一個唯讀的(const)動態物件、並釋放它所佔用的記憶體,也須藉由在指向它的指標上執行delete表達式的運算來進行這樣的操作:

const int *pci = new const int(1024);

delete pci; //ok:刪除一個const物件

動態配置的物件會持續存在,直到它們被明確釋放為止

如我們在前面§12.1.1(頁452)見識到的,若是透過智慧指標shared_ptr來控管記憶體的話,那麼那個記憶體位置上的動態配置物件就會在最後一個指向它的shared_ptr摧毀時同時被刪除。然而若僅僅使用內建型別的指標來管理記憶體的話,就沒有這樣的優勢了。因為透過一個內建指標來管理的動態物件,會一直存在著,佔住該記憶體的位置,直到它被明確刪除delete為止。

因此,若有一個函式其回傳值是一個指向動態配置物件的普通指標(而非智慧指標)的話,那麼呼叫它的使用者,就必須負責刪除這個函式所配置出來的動態物件,以釋放其記憶體資源:4:57:56

// factory回傳一個普通指標指向一個經由new而動態配置出來的物件

Foo* factory(T arg)

{

//適當地處理arg

return new Foo(arg); //呼叫factory函式者必須負責刪除這個動態記憶體區配置的物件,以釋放其記憶體資源

}

就像我們前面的factory函式(§ 12.1.1,頁453),5:27:30 這個版本的factory會用new來動態配置一個物件,但並沒有用上delete表達式,也就是在factory執行期間並不會刪除它。因此呼叫factory函式的人或者程式碼就必須在這個new出來的物件不再有用時負責清除它,並釋放它所佔用的記憶體資源。可是事與願違,我們卻常常可見到在呼叫這樣函式的人或者程式碼,往往都會忘了這麼做:

void use_factory(T arg)

{

Foo *p = factory(arg);

//使用了p,卻忘了刪除它所指向的物件,讓p死掉後,無法再管控它所指向的那個物件了

}//p離開範疇,死掉了(生命週期結束了),但p所指向的記憶體位置卻沒有得到釋放!

像這樣,我們的use_factory函式呼叫了factory,而factory會用new來動態配置一個型別為Foo的新物件。當use_factory執行結束後,它的區域變數p會被摧毀,但這個p僅只是一個內建型別的指標,也就是普通指標(plain pointer、ordinary pointer),並不是一個智慧指標,所以即使指向它所指的物件的所有指標都銷毀了,但那個物件並不會隨之銷毀,而是會繼續存在,佔用著當初配置給它的記憶體資源。

不同於類別型別,內建型別的物件被摧毀時,沒有什麼連帶的動作或影響會發生。(大概指內建型別不具備解構器)特別是,當一個指標離開了它生命週期的範疇,而死掉了,5:42:14但它生前所指向的物件,卻不會跟著死掉,且在這個物件身上,什麼事也不會發生。(你指標死掉,那是你家的事。)指標與所指之物件之間,並沒有連動(連坐、陪葬、殉葬)的關係,在這種情況下(指標要死掉的情況下),它倆是互不相干的。因此,如果那個指標指向的是一個動態記憶體的位址,那麼那個記憶體位址就不會因其消亡而得到釋放。因為記憶體區所存放的物件,其生命週期是獨立存在、而不受任何干擾與影響的。

警告:如果沒有透過智慧指標,而是透過內建型別的指標或普通指標來控管動態記憶體的話,那麼在明確釋放其記憶體資源前,那個佔住記憶體位址的物件都會持續存在。

頁462

在這個例子中,p是指向factory在記憶體區配置資源的唯一指標。一旦use_factory函式結束,在整個應用程式執行期間就再也沒有辦法去釋放那個由use_factory呼叫factory而配置出來的記憶體資源。因為整個應用程式的執行流程(logic)都不會再有指標能夠指向那個由factory new出來的物件,也就沒有辦法藉由指標來釋放這個物件所佔用的記憶體資源,這個物件成了脫韁野馬(而指標就好像馬絡頭、馬鞭,用來控制馬匹的),再也不受整個應用程式的管束了。所以我們必須要在執行use_factory函式的期間,就刪除這個物件以釋放它佔用的記憶體資源,才能夠有效避免記憶體耗漏(leak)的缺失(臭蟲):

void use_factory(T arg)

{

Foo *p = factory(arg);

//使用p

delete p; //只要整個應用程式後來的執行都不會再用到這個p所指向的物件(記憶體資源),那麼我們就該在離開use_factory前(也就是p指標死掉前),自行釋放p指向的物件所佔用的記憶體資源

}

或者,如果這個應用程式系統的其他地方(程式碼)還有需要用到經由use_factory運算而配置出來的物件,那麼我們就應該將use_factory函式改寫成能回傳一個指標指向它所配置的記憶體位址:

Foo* use_factory(T arg)

{

Foo *p = factory(arg);

//使用p

return p; //調用use_factory的指令端,必須在用不著p指向的物件時,負責清除的它

}

注意:對動態記憶體的控管是非常容易出錯的

使用new和delete來控管動態記憶體常須面臨三種難題:

1. 忘記清除記憶體(忘了對佔用的記憶體執行delete運算)。忘了將無用的動態記憶體資源清理乾淨,這樣的編程缺失,被稱作是「記憶體耗漏(memory leak)」或者說「記憶體浪費」;因為那些無用的記憶體資源在整個應用程式執行期間將不再有機會歸還給記憶體的自由存放區(free store)了。若想要藉由檢測程式碼,來找出這樣的錯誤(記憶體耗漏)是很困難的,因為這種記憶體耗漏的錯誤通常是無法偵測出來的,除非應用程式執行得夠久,久到應用程式的這個耗漏不斷地重複、而耗盡了所有的記憶體資源,這個時候才有機會發現這個耗漏的問題所在。

2. 在物件被刪除後還企圖使用它。這種錯誤有時候可以藉由以下的方式來彌補:就是在刪除該物件後隨即將指向它的指標設為null,以便之後要用到它前,先判斷它是否為nullptr空指標。(對一個指標下達delete運算後,即將該指標指定(assign,=)為nullptr。下文「懸置指標」的部分就是在談這個課題!)

3. 重複刪除同一個記憶體位址。當有兩個以上指標指向(定址)同一個動態配置的物件的時候,這種錯誤就很可能發生。只要對其中一個指標進行delete運算,那麼該指標所指的物件其記憶體資源就會歸還給自由存放區(free store)。如果我們還對第二個指標進行delete,那麼這個自由存放區就有可能毀損(may be corrupted)。

以上錯誤是很容易犯的,但要發現這些錯誤或修正它們卻是非常地困難。


養成好習慣:可以利用智慧指標來全權控管動態記憶體,以避免以上諸多問題。智慧指標只會在沒有其他的智慧指標指向該記憶體位址時,它才會刪除它所指向的物件,並釋放物件所佔用的記憶體資源。

在對一個指標執行delete運算後,切記要重置它的值…

第99集6:43:20 當對一個指標進行了delete運算後,那個指標所指的物件就被刪除了,該指標也就隨之無效(invalid)。雖然那個指標已經無效——即不再指向原來那個存在的物件,但在許多電腦系統裡,該指標並不會因此消失,而仍會持續存在並依然指向那個早已被釋放的動態記憶體位址。在這種情形下,該指標就會變成所謂的懸置指標(dangling pointer)。6:58:05只要一個指標曾指向某個物件佔用的記憶體位址,但在該物件被清除後,這個指標卻依然指向它曾佔用的記憶體位址,這種已然無效的指標(invalid),就叫作「懸置指標」。

頁463

未初始化的指標會有的問題(§2.3.2,頁54),懸置指標也都有。因為一個未經初始化的指標即尚未指向一個有效物件的指標。如何才能避免一個指標淪為懸置指標?我們可以盡量待到指標就要離開它的生命範疇前(just before),才對它執行delete運算,來清除與它關聯的記憶體,不要太早對它執行delete運算,這樣就可以避免該指標還有被用到的機會,也就比較不會淪為懸置指標了。如果我們非得、或想要保留那個指標,我們也可以在對它使用了delete後,將它馬上指定為nullptr,這樣就能讓我們整個的應用程式系統明白那個指標已不再指向任何存在的物件了,它就不會淪為懸置指標了。這種原理類似一種標註的動作,將指標標識為nullptr才不會誤以為它還指向一個有效的物件。

…即使這樣做(將delete後的指標即刻指定為nullptr),也只能提供有限的防護

動態記憶體的一個根本問題就在於:同時可能會有好幾個指標指向相同的記憶體位址(因為要達成資源共用,才會有這個問題)。因此,即使我們重置了一個經過delete後的指標,但這樣的防範,依然可能只是顧此失彼、掛一漏萬的;因為這樣的操作,只能保證「那個」經過delete運算的指標,不會淪為懸置指標,但對於「那些」仍然指向「那個」(已清除的)記憶體位址的其他指標而言,卻沒有任何連帶的影響,它們,依然會在它們所指向的物件消失後,淪為懸置指標。比如:

int *p(new int(42));//p這個指標指向了動態記憶體,是由直接初始化(direct initialize)的方式來設定其值的

auto q = p; // p和q都指向了相同的記憶體位址

delete p; //對p做delete,就使得p和q同時都成了無效的指標了

p =nullptr; //將p之值指定(重置)為null,即指明標註p這個指標不再指向一個存在的物件。但q呢?並沒有任何影響,依然還是指向那個記憶體位址,q也就淪為了懸置指標(dangling pointer)

7:9:15在此,p和q兩個指標都指向了同一個動態配置的物件。我們對p執行了delete運算,清除了被佔用的記憶體,隨即將p設為nullptr,指明了p指標不再指向一個存在的物件。然而,對p這樣的重置作業,對q並沒有任何影響;q早在我們對p進行delete運算時,就成為一個無效的指標了,因為它所指向的記憶體位址和p是完全相同的!在實務上,要找出所有指向相同的指標,幾乎是不可能的事(那種困難度,不是我們所能想像的surprisingly difficult)。

留言

熱門文章