程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> 關於C語言 >> 《C++ 沉思錄》閱讀筆記——句柄類

《C++ 沉思錄》閱讀筆記——句柄類

編輯:關於C語言

在上一篇博文裡,我介紹了代理類的相關內容,如果記性好的朋友,應該已經對代理類有了比較深入的認識。在設計代理類的過程中,我們遇到的核心問題是:內存的分配和編譯時類型未知對象的綁定我們通過讓所有子類自定義一個 copy 函數,來返回自身的復制,這種方式來解決需要我們自己來管理內存的繁瑣,又通過定義代理類綁定子類的類型,通過一個基類指針來保存子類這種方式來實現運行時綁定。但對代碼的追求是永無止盡的,雖然代理類解決了我們的需求,但是對一些苛刻的程序員來說,復制對象這種行為是讓人無法忍受的,在一個理想的程序世界裡,任何事物如果是指代相同的內容,那麼就應該只保存一份好吧,這是我自己的理想世界)。難道就真的沒有一種方法,能夠讓我們不去復制對象來實現運行時綁定嗎?答案是肯定的。在《C++ 沉思錄》中作者介紹了一個耳熟能詳的名字——句柄

在介紹句柄之前,我們先來看看代理類的一些問題,顯而易見的問題是它需要復制對象,這需要內存的開銷,同時,某些特殊的對象,我們也不一定能夠非常合適的定義出一個優美的 copy 函數,即使是復制自身這種看起來毫無疑問的操作,在程序員的世界裡也不那麼優雅了。

可以想到的一個例子就是如果對象非常大,那麼 copy 必然需要很大一份內存;

同時如果有一個地方需要保存該對象的所有地址,那麼在對象進行復制的時候也必須把新地址添加進去,這其實對於操作這個對象的人來說,很有可能是未知的。舉個例子,就好像公安局要保存每個公民的合法身份,所以每個公民都必須登記到公安局去,但是如果你站在一個家庭的角度來看,如果沒有強制規定或者是法律約束,你很有可能是不知道這件事的, 所以當你生了小寶寶的時候,很有可能忘記了去公安局登記,那麼這個小寶寶就不會被承認,這裡,你通過 copy 出來的那個副本就是這個沒有身份的小寶寶;

除此之外,還有種情況也許你也在寫 copy 函數時不知所措,就是當你的類非常復雜時,你真心是不知道定義在類裡的那些東西,到底該如何進行復制,或者說你也不知道復制了那些內容後會不會對別的地方產生影響。

總而言之,除了內存的問題外,copy 這個函數的實現,看起來不像我們在代理類裡面寫的那樣容易。而且這裡還有一個最為重要的原因,讓我們不去使用這種代理類,因為它需要你去修改基類所有的派生子類,去給它們都添加一個 copy 函數,但這樣通常是不被允許的,因為我們很有可能是在原有代碼的基礎上來增加這個新的需求,當然,這個條件略顯苛刻,但是作為一名IT從業人員,我得說這是一種常態。所以我們得想辦法改進。

如果不去復制對象,我們有什麼現成的武器嗎?有!讓我們想想,在 C++ 中,除了指針,還有什麼東西不需要復制內存,也能夠指向相同的內容。想到了嗎?對了,就是引用。

使用對象的引用,我們可以避免對對象的復制,讓我們想一想,引用我們通常把它用在函數的參數裡,這是為什麼呢?因為在函數裡我有可能需要對函數外的變量進行操作,同時我又不想復制那些變量,因為這有時會非常困難,傳一個引用進去,事情一下子變的簡單很多。

那麼引用在這裡行不行的通呢?很可惜,我們最好不要這麼做,因為如果我們想使用引用來指代對象,我們在很多地方需要返回這個對象的引用,比如在函數的返回值裡,可是如果把引用作為函數返回值,通常是一件危險的事情,因為我不知道我的函數會被誰調用,同時我也就不知道我的函數返回值會被拿來干什麼,要命的是這個返回值居然還是個引用變量,那麼意味著我把家門鑰匙給了別人,甚至我連對方是誰都不知道,當然,你會說可以寫成 const 類型呗,對,是可以,但是把引用作為返回值還有幾個問題,比如無法返回局部變量或者臨時變量的引用,同時,引用作為返回值,意味著這個函數可以被拿做運算符的左值進行調用,還有,如果返回了動態內存的引用,那麼這塊內存就無法釋放了,等等一系列令人頭疼的問題。

好吧,放棄引用吧,讓我們還是回到指針上來,指針是另一個可以讓我們不復制內存,而指向同一個對象的方法,當然,指針有指針的問題,比如在代理類中我已經提到過的,關於未初始化指針的復制,以及懸垂指針、重復刪除指針所指對象等等問題,那麼難道我們就不能找到一種方法去避免指針的安全性問題,同時能夠嘗到指針的甜美嗎?畢竟,指針是我認為在 C 系語言中最為重要同時功能最為豐富的概念。《C++ 沉思錄》裡說,對於 C++ 的解決方法是定義一個適當的類,這個類被稱為句柄 handle ),它的另一個名字相信大家早已爛熟於心,也頻繁出現在各大面試題當中,也就是智能指針。提起這個名字大家立馬想到什麼引用計數啦、什麼自動釋放啦。慢著,我們先不要去理會那些概念,事物的產生都是按照一定規律來的,我們要做的不是去死記那些表現,而是去理解背後的規律。討論到這裡,很多人可能已經忘了初衷,讓我們再來回顧一下,我們的目的是要設計出一種方法,能夠讓我不必去復制對象,同時能夠達到運行時綁定對象的方法。我們分析來分析去,最後還是只有指針似乎滿足這個條件,畢竟我們的選擇也不是很多。代理類中我們用交通工具的例子來說明問題,這裡我們也用一個例子,比如說一個平面坐標系上的點作為基類:
class Point {
public:
    Point() : xval(0), yval(0) {  }
    Point(int x, int y) : xval(x), yval(y) {  }
    int x() const { return xval; }
    int y() const { return yval; }
    Point &x(int xv) { xval = xv; return *this; }
    Point &y(int yv) { yval = yv; return *this; }
private:
    int xval, yval;
}


這個類沒什麼高深莫測的東西,非常清晰明了。現在讓我們來定義句柄,如果學習過我前一篇博文代理類的相關內容http://rangercyh.blog.51cto.com/1444712/1291958),那麼你很快便會明白,這裡的句柄其實也是一個概念,我們需要一個概念類來管理 Point ,當然也包括它的子類,為此,我們需要保存一個 Point 的指針,就好像之前代理類保存了 Vehicle 的指針一樣,同時這個句柄也需要像代理類同樣的功能,所以我們還需要默認構造函數、復制構造函數、賦值操作符等等,理由我就不再贅述了,不太清楚的朋友可以去看我關於代理類那篇,那麼我們的句柄看起來應該是這個樣子:

class handle {
public:
    Handle();
    Handle(int, int);
    Handle(const Point &);
    Handle(const Handle &);
    Handle &operator=(const Handle &);
    ~Handle();
private:
    Point *p;
}


我們先不去管如何處理多個句柄綁定相同的對象的問題,單看這兩個類,如果我需要操作句柄來控制 Point ,那麼我似乎還需要一個函數,用來返回 Point 指針給調用者,但是這裡涉及到一個安全問題,如果我並不希望調用者通過句柄去訪問實實在在的 Point 對象呢?既然我們已經增加了一層,那麼就不該把底層再交給調用者,所以我也許不會去定義一個函數來返回 Point 指針,相反,我會增加好幾個函數,去供句柄的使用來調用,這些函數都把 Point 的細節封裝其中,讓調用者只關心自己的內容,而不需要接觸到它不想要的內容,舉個例子,比如有一個調用者需要設置 x 的值,那麼我是否應該把整個 Point 指針交給他,然後讓他來調用 Point 的 Point &x(int xv) 函數呢?當然不行,如果我把 Point 的指針交給他了,誰知道他會怎麼樣去操作 Point ,也許他會直接把 Point 這個對象給刪除掉,那麼我句柄裡保存的指針 p 也就成了懸垂指針了。所以一般來說,我會在句柄裡定義一些外部會使用到的方法,可能和 Point 提供的方法類型,但一般來說不會包含 Point 的全部方法,這裡我就只添加一個設置 x 的值的方法吧,那麼我們的句柄變成這樣了:

class handle {
public:
    Handle();
    Handle(int, int);
    Handle(const Point &);
    Handle(const Handle &);
    Handle &operator=(const Handle &);
    Handle &x(int);
    ~Handle();
private:
    Point *p;
}


OK,這個句柄越來越像樣子了,下面進入正題,我們需要有多個句柄指向同一個 Point 對象,而又不會產生代理類中復制代理類就會多產生一個 Point 副本的內存開銷。要重復,又不要復制,看起來我們只有計數這一條路可以走了,也就是說我們記錄一下指向 Point 對象的相同句柄的數量,如果這個數量為 0 了,我們就可以刪掉這個 Point 的副本了,這樣,我們的句柄就只會在第一次給 p 賦值時產生 Point 對象的唯一副本,之後無論怎麼復制句柄,都不會再產生代理類那樣的多余副本。我們就把可能存在的成百上千的副本內存,壓縮到只有一份。

在你興奮過頭之前,還有一個問題需要解決,就是這個引用計數應該放在什麼地方。首先,它的值肯定不能放在句柄裡,因為如果這樣的話,當你改變這個引用計數的時候,就必須找到所有指向相同 Point 對象的其他句柄,並一起修改它們的引用計數,這明顯是不可能的;然後你的引用計數也不能放在 Point 對象裡,因為這樣的話你就必須修改 Point 對象的代碼,這個在實際中是很難辦到的,原因請參看我上面“IT從業人員”那句話。這樣看來只能在句柄中保存另一個指針,指向引用計數的那塊內存了,比如下面這樣:
class handle {
public:
    Handle();
    Handle(int, int);
    Handle(const Point &);
    Handle(const Handle &);
    Handle &operator=(const Handle &);
    Handle &x(int);
    ~Handle();
private:
    Point *p;
    int *u;
}


這樣我們的問題都迎刃而解了,接著我們來看一下,該如何實現這個句柄類的各個函數。

首先讓我們再明確一下,我們需要把句柄類第一次綁定到 Point 對象的時候復制一個 Point 對象的副本,然後使用引用計數來統計同時有多少個句柄指向了這個相同的對象,然後我們需要在引用計數減為 0 ,也就是這個副本沒有被引用的時候刪除掉這個副本。仔細看看句柄裡定義的函數,我們馬上就能判斷出哪些函數會進行對象的綁定,首先所有帶 Point & 參數的復制構造函數都會進行 Point 對象的綁定,這是顯而易見的;其次,當我們在復制 handle 的時候,也會進行對象的綁定,每進行一次復制就會增加一個指向相同 Point 對象的句柄,此時引用計數就應該增加;析構函數看起來也挺容易,每次析構我們都需要把引用計數減一,然後判斷是否為 0 了,如果為 0 了則刪除 Point 對象的副本就OK了。剩下的一個不起眼的函數卻有大文章可以做,就是我之前增加的那個設置 x 的值的函數,這個函數有些特殊,為什麼這麼說呢?讓我們來看看下面這段代碼:
// 首先定義一個新的句柄,並綁定到一個新的Point對象,x為3,y為4
Handle h(3, 4);
// 然後通過復制構造函數,使h2也綁定到這個對象
Handle h2 = h;
// 這句話值得玩味,我們的目的到底是設置綁定的那個Point對象的x值為5
// 還是說我們只是希望這個句柄的值為5
h2.x(5);
// 這裡取得的值,你究竟希望它是3,還是5呢?
int n = h.(x);


看明白這個問題的人會立刻想到,這裡說的其實就是句柄到底是值語義還是指針語義

如果是指針語義,我們看起來比較好理解,因為這樣句柄就只是控制 Point 對象的控制器,任何對指向對象的句柄的操作,都會實質性的影響到真正的對象本身,這個在單個句柄指向對象的時候是沒有問題的,但是當出現有多個句柄指向同一個對象的時候,就會出現,你無法確定句柄裡保存的對象的真正的值是否還是和綁定時相同,你手裡拿著打開金庫的鑰匙,但是你不確定金庫裡放的東西還是不是當初交給你的了,因為別人也可以打開金庫拿走裡面的東西。如果是值語義,那麼我們就需要在值發生修改時,重新拷貝一份副本保存下來,而不是去修改原對象裡的值。這是一個聽起來高端大氣上檔次的想法,我們稱之為“寫時復制 copy on write )”。指導思想是只有當必要的時候才會進行復制,仔細想想這個必要時候一般來說指的是進行寫操作的時候,因為只有當寫入的值與原值不同,我們才需要復制一份副本,然後進行保存。關於“寫時復制”還有很多內容可以談,這種思想在操作系統內存管理上使用的最為普遍,只在需要時進行內存的分配,聽起來多麼酷啊。舉個例子,比如我們使用的 fork() 函數,在創建子進程的時候,就不會立馬把父進程的進程空間拷貝一份給子進程,而是讓子進程共享父進程的空間,只有當我們向子進程寫入的時候才會進行拷貝父進程空間然後進行寫入,這聽起來和我們這個句柄的行為多麼相像啊。如果你留心,你會在各種技術中發現”寫時復制“的影子。至此,我們已經看到了至少3種方案了,第一種是像代理類那樣,每次拷貝都會復制其所綁定的對象;第二種是我們通過句柄實現指針語義,始終只保持一份對這個對象的句柄,通過引用計數來計算有多少句柄綁定其上;第三種也就是我們剛剛介紹的”寫時復制“技術,句柄的行為在不進行寫操作時和第二種是相同的,只有當進行寫操作才會把綁定的對象再復制一份副本,這樣實現達到值語義。如果已經習慣程序性思維的人可能已經想到了,其實還有另一個分支我們還沒有遍歷到,就是一次也不進行拷貝,句柄指向的就是對象本身,不允許同時有兩個及以上的句柄綁定同一個對象,這種句柄壓根就沒有引用計數,因為不可能同時有多個句柄綁定相同的對象,句柄在被賦值時,就會解除對原對象的綁定,來綁定新對象,這樣每個對象只會被一個句柄綁定,比如:
h1 = h;


這句話就會導致 h 所指的對象被綁定到 h1 ,同時 h 解除與該對象的綁定。這就好像對象就像一個球一樣,在句柄之間傳來傳去。

仔細一想,為什麼要使用這種句柄呢?直接使用對象不就完了嗎,反正對象是唯一的,綁定其上的句柄也是唯一的,那我還要句柄干嘛呢?在《C++ 沉思錄》中指出”使用這種句柄可能會是相當危險的,因為我們可能在沒有意識到的情況下把一個句柄從其所綁定的對象上斷開“。好像扯的有點遠了,讓我們還是回來繼續,分析完了 handle 的各個函數實現,那麼我們要開始寫代碼了,當然關於指針語義和值語義我們會分開來寫,雖然通過上面的分析,值語義和指針語義各有各的優點也各有各的缺點,但是在實際中這兩種語義確實都在被使用,所以這裡我會分開實現兩個版本的 handle 類,其實主要區別在於對原對象的賦值操作,我們先寫出兩種方式的公共操作:
Handle::Handle() : u(new int(1)), p(new Point) {  }
Handle::Handle(int x, int y) : u(new int(1)), p(new Point(x, y)) {  }
Handle::Handle(const Point &p0) : u(new int(1)), p(new Point(p0)) {  }
Handle::Handle(const Handle &h) : u(h.u), p(h.p) { ++*u; }
Handle & Handle::operator=(const handle &h)
{
    // 增加=號右側句柄的引用計數,注意,必須先增加=號右側的引用計數,
    // 否則當把句柄賦值給自己時Point就被刪除了
    ++*h.u;
    // 減少=號左側句柄的引用計數,如果為0了,則刪除綁定的對象副本
    if (--*u == 0) 
    {
        delete u;
        delete p;
    }
    u = h.u;
    p = h.p;
    return *this;
}
Handle::~Handle()
{
     if (--*u == 0)
     {
          delete u;
          delete p;                
     }
}


好了公共操作寫完了,下面我們要來看看不同語義的賦值函數了,首先來看看指針語義

Handle &Handle::x(int x0)
{
    p->x(x0);
    return *this;
}


很簡單是不是,但你也要體會到這個簡單背後可能帶來上面提到的問題。值語義的實現稍微復雜點,畢竟涉及到一個“寫時復制”

Handle &Handle::x(int x0)
{
    /*
    這裡比較的目的是如果引用計數大於1,代表有多個句柄指向該對象,
    所以我們需要減少引用計數,如果引用計數為1,
    代表只有這一個句柄指向這個對象,
    既然,我要修改這個對象的值,那麼直接改原對象就可以了。
    */
    if (*u != 1)
    {
        --*u;
        p = new Point(*p);
    }
    p->x(x0);
    return *this;
}


完美了。兩種語義下的 handle 類我們都設計完成了,而且看起來一切美好。我們設計的這個句柄類能夠在運行時綁定未知類型的 Point 類及其繼承,同時能夠自己處理內存分配的問題,而且我們避免了代理類每次復制都拷貝對象的操作。唯一令我們還不太滿意的地方是對引用計數這個變量的操作穿插在了整個 handle 類的實現當中,而且耦合的非常緊密,我們最好能把引用計數給抽離出來。在《C++ 沉思錄》中給出了一種抽離引用計數的方法,它定義了一個引用計數的類,裡面保存我們上面定義的引用計數變量 int *p ;雖然我並不認為實現的比較完美,但還算是中規中矩,它是這麼干的:

class UseCount
{
public:
    UseCount() : p(new int(1)) {  }
    UseCount(const UseCount &u) : p(u.p) { ++*p; }
    ~UseCount() { if (--*p == 0) delete p; }
    bool only() { return *p == 1; }    // 返回該引用計數是否為1
    bool reattach(const UseCount &u) {
        ++*u.p;
        if (--*p == 0) {
            delete p;
            p = u.p;
            return true;
        }
        p = u.p;
        return false;
    }
    bool makeonly() {    // 用於”寫時復制“,產生一個新的引用計數
         if (*p == 1) {
              return false;
         }
         --*p;
         p = new int(1);
         return true;
    }
private:
    UseCount &operator=(const UseCount &);
private:
    int *p;
}


然後我們可以對應修改我們的 handle 類:

class handle
{
public:
     Handle() : p(new Point) {  }
     Handle(int x, int y) : p(new Point(x, y)) {  }
     Handle(const Point &p0) :  p(new Point(p0)) {  }
    ~Handle() { if (u.only()) delete p; }
     Handle &operator=(const handle &h) {
         if (u.reattach(h.u)) delete p;
         p = h.p;
         return *this;
     }
     Handle &x(int x0) {
          if (u.makeonly()) p = new Point(*p);
          p->x(x0);
          return *this;
     }
private:
    Point *p;
    UseCount u;
}


好了,我們終於要迎來結尾了,雖然上面的引用計數和句柄類的實現現在還印在腦子裡,但我不確定會存多久,但是每當我想起我要解決的問題時,自然而然推出這些結論就讓人很興奮,就像數學證明一樣,從開頭慢慢到了這裡。我是一個粗心的,希望上面的代碼沒有錯誤。現在我們來總結一下:

在代理類中,我們學會了如何設計一種容器來存放編譯時未知類型的對象,並且找到了一種內存分配的方法來做這些。但是代理類有兩個問題,一個是每次復制代理類都會導致對象的復制,另一個是我們必須要從對象的設計開始就想到之後要使用代理類的問題,得為代理類留出接口,比如一個 copy 函數。在句柄這篇文章中,我們學到一種使用引用計數的方式,來避免每次復制都需要拷貝對象的操作,同時不用去修改原始的對象,因此它更加靈活。我們還看到了指針語義和值語義的區別已經如何實現兩種語義的方式,明白了”寫時復制“的含義。在最後我們把引用計數抽離成為一個單獨的類,這樣這個引用計數就可以嵌入到各個不同的句柄設計中,而不需要每個句柄都自己來控制,抽象是循序漸進的,我非常喜歡這種一氣呵成的感覺。

本文出自 “菜鳥浮出水” 博客,請務必保留此出處http://rangercyh.blog.51cto.com/1444712/1293679

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved