《C++Primer》第十三章-複製控制-學習筆記(3)-智能指針&指針成員

《C++Primer》第十三章-複製控制-學習筆記(3)-智能指針&指針成員

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

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

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

管理指針成員

本書始終提倡使用標準庫。這樣做的一個原因是,使用標準庫能夠大大減少現代 C++ 程序中對指針的需要。然而,許多應用程序仍需要使用指針,特別是在類的實現中。包含指針的類需要特別注意複製控制,原因是複製指針時只複製指針中的地址,而不會複製指針指向的對象。

設計具有指針成員的類時,類設計者必須首先需要決定的是該指針應提供什麼行爲。 將一個指針複製到另一個指針時,兩個指針指向同一對象。當兩個指針指向同一對象時,可能使用任一指針改變基礎對象。類似地,很可能一個指針刪除了一對象時,另一指針的用戶還認爲基礎對象仍然存在。
指針成員默認具有與指針對象同樣的行爲。然而,通過不同的複製控制(copy control)策略,可以爲指針成員實現不同的行爲。大多數 C++ 類採用以下三種方法之一管理指針成員

  1. 指針成員採取常規指針型行爲。這樣的類具有指針的所有缺陷但無需特殊的複製控制。
  2. 類可以實現所謂的“智能指針”行爲。指針所指向的對象是共享的,但類能夠防止懸垂指針
  3. 類採取值型行爲。指針所指向的對象是唯一的,由每個類對象獨立管理。

本節中介紹三個類,分別實現管理指針成員的三種不同方法。

一個帶指針成員的簡單類

爲了闡明所涉及的問題,我們將實現一個簡單類,該類包含一個 int 值和一個指針:

// class that has a pointer member that behaves like a plain pointer
class HasPtr {
public:
// copy of the values we're given
	HasPtr(int *p, int i): ptr(p), val(i) { }   //構造函數
// const members to return the value of the indicated data member
	int *get_ptr() const { return ptr; }  //只讀函數
	int get_int() const { return val; }
// non const members to change the indicated data member
	void set_ptr(int *p) { ptr = p; }
	void set_int(int i) { val = i; }
// return or change the value pointed to, so ok for const objects
	int get_ptr_val() const { return *ptr; }
	void set_ptr_val(int val) const { *ptr = val; }
private:
	int *ptr;
	int val;
};

HasPtr 構造函數接受兩個形參,將它們複製到 HasPtr 的數據成員。HasPtr類提供簡單的訪問函數:函數 get_int 和 get_ptr 分別返回 int 成員和指針成員的值:set_int 和 set_ptr 成員則使我們能夠改變這些成員,給 int 成員一個新值或使指針成員指向不同的對象。還定義了 get_ptr_val 和set_ptr_val 成員,它們能夠獲取和設置指針所指向的基礎值。

默認複製/賦值與指針成員

因爲 HasPtr 類沒有定義複製構造函數,所以複製一個 HasPtr 對象將複製兩個成員:

int obj = 0;
HasPtr ptr1(&obj, 42); // int* member points to obj, val is 42
HasPtr ptr2(ptr1); // int* member points to obj, val is 42

複製之後,ptr1 和 ptr2 中的指針指向同一對象且兩個對象中的 int 值相同。但是,因爲指針的值不同於它所指對象的值,這兩個成員的行爲看來非常不同。複製之後,int 值是清楚和獨立的,而指針則糾纏在一起。

具有指針成員且使用默認合成複製構造函數(synthesized copy constructor)的類具有普通指針的所有缺陷。尤其是,類本身無法避免懸垂指針

指針共享同一對象

複製一個算術值時,副本獨立於原版,可以改變一個副本而不改變另一個:

ptr1.set_int(0); // changes val member only in ptr1
ptr2.get_int(); // returns 42
ptr1.get_int(); // returns 0

複製指針時,地址值是可區分的,但指針指向同一基礎對象。如果在任一對象上調用 set_ptr_val,則二者的基礎對象都會改變:

ptr1.set_ptr_val(42); // sets object to which both ptr1 and ptr2 point
ptr2.get_ptr_val(); // returns 42

兩個指針指向同一對象時,其中任意一個都可以改變共享對象的值。

可能出現懸垂指針

因爲類直接複製指針,會使用戶面臨潛在的問題:HasPtr 保存着給定指針。用戶必須保證只要 HasPtr 對象存在,該指針指向的對象就存在:

int *ip = new int(42); // dynamically allocated int initialized to 42
HasPtr ptr(ip, 10); // Has Ptr points to same object as ip does
delete ip; // object pointed to by ip is freed
ptr.set_ptr_val(0); // disaster: The object to which Has Ptr points was freed!

這裏的問題是 ip 和 ptr 中的指針指向同一對象。刪除了該對象時,ptr 中的指針不再指向有效對象。(懸垂指針 然而,沒有辦法得知對象已經不存在了。

定義智能指針類

上節中我們定義了一個簡單類,保存一個指針和一個 int 值。其中指針成員的行爲與其他任意指針完全相同。對該指針指向的對象所做的任意改變都將作用於共享對象。如果用戶刪除該對象,則類就有一個懸垂指針,指向一個不復存在的對象。
除了使指針成員與指針完全相同之外,另一種方法是定義所謂的智能指針類(smart pointer)智能指針除了增加功能外,其行爲像普通指針一樣。 本例中讓智能指針負責刪除共享對象。用戶將動態分配一個對象並將該對象的地址傳給新的 HasPtr類用戶仍然可以通過普通指針訪問對象,但絕不能刪除指針。HasPtr 類將保證在撤銷指向對象的最後一個 HasPtr 對象時刪除對象。

HasPtr 在其他方面的行爲與普通指針一樣。具體而言,複製對象時,副本和原對象將指向同一基礎對象,如果通過一個副本改變基礎對象,則通過另一對象訪問的值也會改變。
新的 HasPtr 類需要一個析構函數來刪除指針,但是,析構函數不能無條件地刪除指針。如果兩個 HasPtr 對象指向同一基礎對象,那麼,在兩個對象都撤銷之前,我們並不希望刪除基礎對象。 爲了編寫析構函數,需要知道這個 HasPtr對象是否爲指向給定對象的最後一個。

引入使用計數

定義智能指針的通用技術是採用一個使用計數智能指針類將一個計數器與類指向的對象相關聯。 使用計數跟蹤該類有多少個對象共享同一指針(地址)使用計數爲 0 時,刪除對象。使用計數(use count)有時也稱爲引用計數(reference count)

每次創建類的新對象時,初始化指針並將使用計數置爲 1。當對象作爲另一對象的副本而創建時,複製構造函數複製指針並增加與之相應的使用計數的值。
對一個對象進行賦值時,賦值操作符減少左操作數所指對象的使用計數的值(如果使用計數減至 0,則刪除對象),並增加右操作數所指對象的使用計數的值。
最後,調用析構函數時,析構函數減少使用計數的值,如果計數減至 0,則刪除基礎對象。

唯一的創新在於決定將使用計數放在哪裏。計數器不能直接放在 HasPtr 對象中,爲什麼呢?考慮下面的情況:

int obj;
HasPtr p1(&obj, 42);
HasPtr p2(p1); // p1 and p2 both point to same int object
HasPtr p3(p1); // p1, p2, and p3 all point to same int object
//HasPtr有三個對象共享同一個指針

如果使用計數保存在 HasPtr 對象中,創建 p3 時怎樣更新它?可以在 p1 中將計數增量並複製到 p3,但怎樣更新 p2 中的計數?

使用計數類

實現使用計數有兩種經典策略,在這裏將使用其中一種,另一種方法在第15.8.1 節中講述。這裏所用的方法中,需要定義一個單獨的具體類用以封閉使用計數和相關指針

// private class for use by HasPtr only
class U_Ptr {
	friend class HasPtr; //將 HasPtr 類設置爲友元,使其成員可以訪問 U_Ptr 的成員。
	int *ip;   //保留指針
	size_t use;  //使用計數
	U_Ptr(int *p): ip(p), use(1) { }  //構造函數複製指針
	~U_Ptr() { delete ip; }  //析構函數刪除指針
};

這個類的所有成員均爲 private。 我們不希望用戶使用 U_Ptr 類,所以它沒有任何 public 成員。將 HasPtr 類設置爲友元,使其成員可以訪問 U_Ptr 的成員。
儘管該類的工作原理比較難,但這個類相當簡單。

  • U_Ptr 類保存指針和使用計數,每個 HasPtr 對象將指向一個 U_Ptr 對象,使用計數將跟蹤指向每個U_Ptr 對象的 HasPtr 對象的數目。
  • U_Ptr 定義的僅有函數是構造函數和析構函數,構造函數複製指針,而析構函數刪除它。
  • 構造函數還將使用計數置爲 1,表示一個 HasPtr 對象指向這個 U_Ptr 對象。

假定剛從指向 int 值 42 的指針創建一個 HasPtr 對象,可以畫出這些對象,如下圖:
在內存中分配一個對象,int值42,指針p指向它。然後用這個指針p來初始化HasPtr對象。
HasPtr對象中的指針指向U_Ptr對象,U_Ptr對象中的指針指向內存中的int值42。
在這裏插入圖片描述

使用計數類的使用

如果複製這個HasPtr對象,則對象如下圖所示。新的 HasPtr 類保存一個指向 U_Ptr 對象的指針,U_Ptr 對象指向實際的int 基礎對象必須改變每個成員以說明的 HasPtr 類指向一個 U_Ptr 對象而不是一個 int。
在這裏插入圖片描述
先看看構造函數和複製控制成員:

/* smart pointer class: takes ownership of the dynamically allocated object to which it is bound
* 智能指針類,用來管理動態分配的綁定對象
* User code must dynamically allocate an object to initialize a HasPtr  and must not delete that object; the HasPtr class will delete it
* 用戶代碼必須動態分配一個對象來初始化HasPtr類,而且用戶代碼不能刪除這個對象,要讓HasPtr對象來刪除。
*/
class HasPtr {
public:
// HasPtr owns the pointer; pmust have been dynamically allocated
	HasPtr(int *p, int i): ptr(new U_Ptr(p)), val(i) { }//構造函數
// copy members and increment the use count  //複製成員
	HasPtr(const HasPtr &orig):ptr(orig.ptr), val(orig.val) { ++ptr->use; }
	HasPtr& operator=(const HasPtr&);//賦值操作符
// if use count goes to zero, delete the U_Ptr object
	~HasPtr() { if (--ptr->use == 0) delete ptr; } //計數歸零時候,刪除對象
private:
	U_Ptr *ptr; // points to use-counted U_Ptr class  使用計數類
	int val;
};

接受一個指針和一個 int 值的 HasPtr 構造函數使用其指針形參創建一個新的 U_Ptr 對象。HasPtr 構造函數執行完畢後,HasPtr 對象指向一個新分配的 U_Ptr 對象,該 U_Ptr 對象存儲給定指針。新 U_Ptr 中的使用計數爲 1,表示只有一個 HasPtr 對象指向它。
複製構造函數從形參複製成員並增加使用計數的值。複製構造函數執行完畢後,新創建對象與原有對象指向同一 U_Ptr 對象,該 U_Ptr 對象的使用計數加1。
析構函數將檢查 U_Ptr 基礎對象的使用計數。如果使用計數爲 0,則這是最後一個指向該 U_Ptr 對象的 HasPtr 對象,在這種情況下,HasPtr 析構函數刪除其 U_Ptr 指針。刪除該指針將引起對 U_Ptr 析構函數的調用,U_Ptr 析構函數刪除 int 基礎對象。

賦值與使用計數

賦值操作符(assignment operator)比複製構造函數複雜一點:

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	++rhs.ptr->use; // increment use count on rhs first 形參對象的使用計數加一
	if (--ptr->use == 0)  //賦值的時候左操作數對象被覆蓋,原來的值擦掉了,所以使用計數減一
	delete ptr; // if use count goes to 0 on this object, delete it
	ptr = rhs.ptr; // copy the U_Ptr object
	val = rhs.val; // copy the int member
	return *this;
}

在這裏,首先將右操作數中的使用計數加 1,然後將左操作數對象的使用計數減 1 並檢查這個使用計數。像析構函數中那樣,如果這是指向 U_Ptr 對象的最後一個對象,就刪除該對象,這會依次撤銷 int 基礎對象。將左操作數中的當前值減 1(可能撤銷該對象)之後,再將指針從 rhs 複製到這個對象。賦值照常返回對這個對象的引用。
這個賦值操作符在減少左操作數的使用計數之前使 rhs 的使用計數加 1,從而防止自身賦值。(如果左右操作數相同,賦值操作符的效果將是 U_Ptr 基礎對象的使用計數加 1 之後立即減 1。)

改變其他成員

現在需要改變訪問 int* 的其他成員,以便通過 U_Ptr 指針間接獲取 int

class HasPtr {
public:
// copy control and constructors as before
// accessors must change to fetch value from U_Ptr object
	int *get_ptr() const { return ptr->ip; }
	int get_int() const { return val; }
// change the appropriate data member
	void set_ptr(int *p) { ptr->ip = p; }
	void set_int(int i) { val = i; }
// return or change the value pointed to, so ok for const objects
// Note: *ptr->ip is equivalent to *(ptr->ip)
	int get_ptr_val() const { return *ptr->ip; }
	void set_ptr_val(int i) { *ptr->ip = i; }
private:
	U_Ptr *ptr; // points to use-counted U_Ptr class
	int val;
};

獲取和設置 int 成員的函數不變。那些使用指針操作的函數必須對 U_Ptr解引用,以便獲取 int* 基礎對象。
複製 HasPtr 對象時,int 成員的行爲與第一個類中一樣。所複製的是 int成員的值,各成員是獨立的,副本和原對象中的指針仍指向同一基礎對象,對基礎對象的改變將影響通過任一 HasPtr 對象所看到的值。 然而,HasPtr 的用戶無須擔心懸垂指針。只要他們讓 HasPtr 類負責釋放對象,HasPtr 類將保證只要有指向基礎對象的 HasPtr 對象存在,基礎對象就存在。

定義值型類

處理指針成員的另一個完全不同的方法,是給指針成員提供值語義(value semantics)
具有值語義的類(值型類)所定義的對象,其行爲很像算術類型的對象:複製值型對象時,會得到一個不同的新副本。對副本所做的改變不會反映在原有對象上,反之亦然。 string類是值型類的一個例子。
要使指針成員表現得像一個值,複製 HasPtr 對象時必須複製指針所指向的對象:

/*
* Valuelike behavior even though HasPtr has a pointer member:
* Each time we copy a HasPtr object, we make a new copy of the
* underlying int object to which ptr points.
*/
class HasPtr {
public:
// no point to passing a pointer if we're going to copy it anyway
// store pointer to a copy of the object we're given
	HasPtr(const int &p, int i): ptr(new int(p)), val(i) {}
// copy members and increment the use count  
	HasPtr(const HasPtr &orig):ptr(new int (*orig.ptr)), val(orig.val) { }
	//複製構造函數不再複製指針,它將分配一個新的 int 對象,並初始化該對象以保存與被複制對象相同的值
	HasPtr& operator=(const HasPtr&);
	~HasPtr() { delete ptr; }
// accessors must change to fetch value from Ptr object
	int get_ptr_val() const { return *ptr; }
	int get_int() const { return val; }
// change the appropriate data member
	void set_ptr(int *p) { ptr = p; }
	void set_int(int i) { val = i; }
// return or change the value pointed to, so ok for const objects
	int *get_ptr() const { return ptr; }
	void set_ptr_val(int p) const { *ptr = p; }
private:
	int *ptr; // points to an int  //這裏沒有定義計數類
	int val;
};

複製構造函數不再複製指針,它將分配一個新的 int 對象,並初始化該對象以保存與被複制對象相同的值。每個對象都保存屬於自己的 int 值的不同副本。因爲每個對象保存自己的副本,所以析構函數將無條件刪除指針。
賦值操作符不需要分配新對象,它只是必須記得給其指針所指向的對象賦新值,而不是給指針本身賦值

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
// Note: Every HasPtr is guaranteed to point at an actual int;
// We know that ptr cannot be a zero pointer
	*ptr = *rhs.ptr; // copy the value pointed to
	val = rhs.val; // copy the int
return *this;
}

換句話說,改變的是指針所指向的值,而不是指針。

即使要將一個對象賦值給它本身,賦值操作符也必須總是保證正確。本例中,即使左右操作數相同,操作本質上也是安全的,因此,不需要顯式檢查自身賦值。

建議:管理指針成員

具有指針成員的對象一般需要定義複製控制成員。如果依賴合成版本,會給類的用戶增加負擔。用戶必須保證成員所指向的對象存在,只要還有對象指向該對象。

爲了管理具有指針成員的類,必須定義三個複製控制成員:複製構造函數賦值操作符析構函數。這些成員可以定義指針成員的指針型行爲值型行爲
值型類將指針成員所指基礎值的副本給每個對象。複製構造函數分配新元素並從被複制對象處複製值,賦值操作符撤銷所保存的原對象並從右操作數向左操作數複製值,析構函數撤銷對象。

作爲定義值型行爲或指針型行爲的另一選擇,是使用稱爲“智能指針”的一些類。這些類在對象間共享同一基礎值,從而提供了指針型行爲
但它們使用複製控制技術以避免常規指針的一些缺陷。爲了實現智能指針行爲,類需要保證基礎對象一直存在,直到最後一個副本消失。使用計數(第 13.5.1 節)是管理智能指針類的通用技術。同一基礎值的每個副本都有一個使用計數。複製構造函數將指針從舊對象複製到新對象時,會將使用計數加 1。賦值操作符將左操作數的使用計數減 1 並將右操作數的使用計數加 1,如果左操作數的使用計數減至 0,賦值操作符必須刪除它所指向的對象,最後,賦值操作符將指針從右操作數複製到左操作數。析構函數將使用計數減 1,並且,如果使用計數減至 0,就刪除基礎對象。

管理指針的這些方法用得非常頻繁,因此使用帶指針成員類的程序員必須充分熟悉這些編程技術。

小結

類除了定義該類型對象上的操作,還需要定義複製、賦值或撤銷該類型對象的含義。特殊成員函數(複製構造函數、賦值操作符和析構函數)可用於定義這些操作。這些操作統稱爲“複製控制”函數
如果類沒有定義這些操作中的一個或多個,編譯器將自動定義它們。合成操作執行逐個成員初始化(memberwise initialization)、賦值或撤銷:合成操作依次取得每個成員,根據成員類型進行成員的複製、賦值或撤銷。

  • 如果成員爲類類型的,合成操作調用該類的相應操作(即,複製構造函數調用成員的複製構造函數,析構函數調用成員的析構函數,等等)。
  • 如果成員爲內置類型或指針,則直接複製或賦值,析構函數對撤銷內置類型或指針類型的成員沒有影響。
  • 如果成員爲數組,則根據元素類型以適當方式複製、賦值或撤銷數組中的元素。

與複製構造函數和賦值操作符不同,無論類是否定義了自己的析構函數,都會創建和運行合成析構函數。如果類定義了析構函數,則在類定義的析構函數結束之後運行合成析構函數。定義複製控制函數最爲困難的部分通常在於認識到它們的必要性。

分配內存或其他資源的類幾乎總是需要定義複製控制成員來管理所分配的資源。
如果一個類需要析構函數,則它幾乎也總是需要定義複製構造函數和賦值操作符。

參考資料

【1】C++ Primer 中文版(第四版·特別版)

註解

【2】關於“指針成員的指針型行爲或值型行爲”

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