C++的營養
莫華楓
上一篇《C++的營養——RAII》中介紹了RAII,以及如何在C#中實現。這次介紹另一個重要的基礎技術——swap手法。
swap手法
swap手法不應當是C++獨有的技術,很多語言都可以實現,并且從中得到好處。只是C++存在的一些缺陷迫使大牛們發掘,并開始重視這種有用的手法。這
個原本被用來解決C++的資源安全和異常保證問題的技術在使用中逐步體現出越來越多的應用,有助于我們編寫更加簡潔、優雅和高效的代碼。
接下來,我們先來和swap打個招呼。然后看看在C#里如何玩出swap。最后展示swap手法的幾種應用,從中我們將看到它是如何的可愛。
假設,我要做一個類,實現統計并保存一個字符串中字母的出現次數,以及總的字母和數字的個數。
class CountStr

...{
public:
explicit CountStr(std::string const& val)

:m_str(val), m_nLetter(0), m_nNumber(0) ...{
do_count(val);
}
CountStr(CountStr const& cs)
:m_str(cs.m_str), m_counts(cs.m_counts)
, m_nLetter(cs.m_nLetter), m_nNumber(cs.m_nNumber)

...{}

void swap(CountStr& cs) ...{
std::swap(m_str, cs.m_str);
m_counts.swap(m_str);
std::swap(m_nLetter, cs.m_nLetter);
std::swap(m_nNumber, cs.m_nNumber);
}
private:
std::string m_str;
std::map<char, int> m_counts;
int m_nLetter;
int m_nNumber;
}
在類CountStr中,定義了swap成員函數。swap接受一個CountStr&類型的參數。在函數中,我們可以看到一組函數調用,每一個
對應一個數據成員,其任務是將相對應的數據成員的內容相互交換。此處,我使用了兩種調用,一種是使用std::swap()標準函數,另一種是通過
swap成員函數執行這個交換。一般情況下,std::swap()通過一個臨時變量實現對象的內容交換。但對于string、map等非平凡的對象,這
種交換會引發至少三次深拷貝,其復雜度將是O(3n)的,性能極差。因此,標準庫為這些類定義了swap成員函數,通過成員函數可以實現O(1)的交換操
作。同時將std::swap()針對這些擁有swap()成員函數的標準容器特化,使其可以直接使用swap()成員函數,而避免性能損失。但是,對于
那些擁有swap()成員,但沒有被特化的用戶定義,或第三方的類,則不應使用std::swap(),而改用swap()成員函數。所以,一般情況下,
為了避免混淆,對于擁有swap()成員函數的類,調用swap(),否則調用標準std::swap()函數。
順便提一下,在未來的C++0x中,由于引入了concept機制,可以允許一個函數模板自動識別出所有“具有swap()成員”的類型,并使用相應的特化版本。這樣便只需使用std::swap(),而不必考慮是什么樣的類型了。
言歸正傳。這里,swap()成員函數有兩個要求,其一是復雜度為O(1),其二是具備無拋擲的異常保證。前者對于性能而言至關重要,否則swap操作將
會由于性能問題而無法在實際項目中使用。對于后者,是確保強異常保證(commit or
rollback語義)的基石。要達到這兩個要求,有幾個關鍵要點:首先,對于類型為內置類型或小型POD(8~16字節以內)的成員數據,可以直接使用
std::swap();其次,對于非平凡的類型(擁有資源引用,復制構造和賦值操作會引發深拷貝),并且擁有符合上述要求的swap()成員函數的,直
接使用swap()成員函數;最后,其余的類型,則保有其指針,或智能指針,以確保滿足上述兩個要求。
聽上去有些復雜,但在實際開發中做到并不難。首先,盡量使用標準庫容器,因為標準庫容器都擁有滿足兩個條件的swap()成員。其次,在編寫的每一個類中
實現滿足兩個條件的swap()成員。最后,對于那些不具備swap()成員函數的第三方類型,則使用指針,最好是智能指針。(也就是Sutter所謂的
PImpl手法)。只要堅持這些方針,必能收到很好的效果。
下面,就來看一下這個swap()的第一個妙用。假設,這個類需要復制。通常可以通過operator=操作符,或者copy(或其他有明確的復制含義
的)成員函數實現,這兩者實際上是等價的,只是形式不同而已。這里選擇operator=,因為它比較C++:)。
最直白的實現方式是這樣:
class CountStr

...{
public:
...

CountStr& operator=(CountStr& val) ...{
m_str=val.m_str;
m_counts=val.m_counts;
m_nLetter=val.m_nLetter;
m_nNumber=val.m_nNumber;
}
...
}
很簡單,但是不安全,或者說沒有滿足異常保證。
先解釋一下異常保證。異常保證有三個級別:基本保證、強異常保證和無拋擲保證。基本保證是指異常拋出時,程序的各個部分應當處于有效狀態,不能有資源泄
漏。這個級別可以輕而易舉地利用RAII確保,這在前一篇已經展示過了。強異常保證則更加嚴格,要求異常拋出后,程序非但要滿足基本保證,其各個部分的數
據應保持原狀。也就是要滿足“Commit or
Rollback”語義,熟悉數據庫的人,可以聯想一下Transaction的行為。而無拋擲保證要求函數在任何情況下都不會拋出異常。無拋擲保證不是
說用一個catch(...)或throw()把異常統統吞掉。而是說在無拋擲保證的函數中的任何操作,都不會拋出異常。能滿足無拋擲保證的操作還是很多
的,比如內置POD類型(int、指針等等)的復制,swap手法便以此為基礎。(多說一句,用catch(...)吞掉異常來確保無拋擲并非絕對不行,
在特定情況下,還是可以偶爾一用。不過這等爛事也只能在西構函數中進行,而且也只有在迫不得已的情況下用那么一下)。
如果這四個賦值操作
中,任意一個拋出異常,便會退出這個函數(操作符)。此時,至少有一個成員數據沒有正確修改,而其他的則全部或部分地發生改變。于是,一部分成員數據是新
的,另一部分是舊的,甚至還有一些是不完全的。這在軟件中往往會引發很多令人苦惱的bug。無論如何,此時應當運用強異常保證,使得數據要么是新的值,要
么沒有改變。那么如何獲得強異常保證?在swap()的幫助下,驚人的簡單:
class CountStr

...{
public:
...

CountStr& operator=(CountStr& val) ...{
swap(CountStr(val)); // 或者CountStr(val).swap(*this);
raturn *this;
}
...
}
我想世上沒有比這等代碼更加漂亮的了吧!不僅僅具有簡潔動人的外表,而且充滿了豐富的內涵。這就叫優雅。不過,優雅之下還需要一些解釋。在這兩個版本中,
都是先用復制構造創建一個臨時對象,這個臨時對象同傳入的參數對象擁有相同的值。然后用swap()成員函數將this對象的內容與臨時對象交換。于是,
this對象擁有了臨時對象的值,也就是與傳入的實參對象具有相同的值(復制)。當退出函數的時候,臨時對象銷毀,自然而然地釋放了this對象原先的資
源(前提是CountStr類實現了RAII)。
那么拋出異常的情況又是怎樣的呢?
先來看看operator=里執行了哪些步驟,并考察這些步驟的異常拋擲的情況。如果將代碼改寫成另一個等價的形式,就很容易理解了:

CountStr& operator=(CountStr& val) ...{
CountStr t_(val); //此處可能拋出異常,但只有t_的值發生變化
t_.swap(*this); //由于swap擁有無拋擲保證,所以不會拋出異常
return *this;
}
在構造臨時對象的時候,可能會拋出異常,因為此時執行了數據的復制和構造。請注意,這時候this對象的內容沒有改變。如果此時拋出異常,數據發生改變的
只有t_,this對象并未受到影響。而隨著棧清理,t_也將被析構,在RAII的作用下,t_所占用的資源也會依次釋放。而下一步,swap()成員的
調用,則是無拋擲保證的,不會拋出異常,this的內容可以得到充分地、原子地交換,不會發生數據值修改一半的情況。
在C#中,實現swap非常容易,甚至比C++更容易。因為在C#中,大部分對象都在堆上,代碼中定義的所謂對象實際上是引用。對于引用的賦值操作是無拋擲的,因此在C#中可以采用同C++幾乎一樣的代碼實現swap:
class CountStr

...{

public CountStr(string val) ...{
m_str=val;
m_nLetter=0;
m_nNumber=0;
do_count(val);
}

public CountStr(CountStr cs) ...{
m_str=new string(cs.m_str);
m_counts=new Dictionary<char, int>(cs.m_counts);
m_nLetter=cs.m_nLetter;
m_nNumber=cs.m_nNumber
}


public void swap(CountStr& cs) ...{
utility.swap(ref m_str, ref cs.m_str);
utility.swap(ref m_counts, ref cs.m_counts);
utility.swap(ref m_nLetter, ref cs.m_nLetter);
utility.swap(ref m_nNumber, ref cs.m_nNumber);
}

public void copy(CountStr& cs) ...{
this.swap(new CountStr(cs));
}

private string m_str;
private Dictionary<char, int> m_counts;
private int m_nLetter;
private int m_nNumber;
}
這里utility.swap()是一個泛型函數,作用是交換兩個參數:
class utility

...{

public static void swap<T>(ref T lhs, ref T rhs) ...{
T t_=lhs;
lhs=rhs;
rhs=t_;
}
}
如果類有關鍵性的資源需要釋放,那么可以實現IDisposable接口,然后在copy()中使用using:

public void copy(CountStr& cs) ...{
using(CountStr t_=new CountStr(cs))

...{
t_.swap(this);
}
}
如此,對象原有的數據和資源被交換到臨時對象t_中之后,在退出using作用域的時候,會立即得到釋放。這是RAII的一個應用,詳細內容參見本系列的前一篇《C++的營養——RAII》。
swap的基本作用是維持強異常保證語義。但是,作為一種基礎性的技術,它還可以擁有更多的用途。下面介紹幾種主要的應用,為了節省篇幅,案例直接使用C#,不再給出C++的代碼。
在我們的開發過程中,有時需要是一些對象復位,即回復對象的初始狀態。一般情況下,我們會在類中增加一個reset()之類的成員,在這個函數中釋放資源,恢復各成員數據的初值。但是,在擁有swap的情況下,這種操作變得非常容易:
class X

...{

public X() ...{
... //初始化對象
}

public X(int v) ...{
... //以v初始化對象
}

public void swap(X val) ...{...}

public void reset() ...{
this.swap(new X());
}
...
}
reset()用X的默認構造函數創建了一個臨時對象,將其內容與this交換,this的內容便成為了初始值。重要的是,這個成員函數也是
強異常保證的。如果需要通過一些參數復位,那么同樣可以做到:
class X

...{
...

public void reset(int v) ...{
this.swap(new X(v));
}
...
}
有時甚至可以不需要reset這個成員,而直接在代碼中使用swap復位一個對象:
X x=new X();
... //對x的操作,改變了內容
x.swap(new X()); //復位了
如果X有資源需要釋放,那么只需實現IDispose,然后使用using:
class X : IDisposable

...{
...

public void reset() ...{
using(X t=new X())

...{
this.swap(t);
}
}

public void Dispose() ...{...}
...
}
上面這些應用都有一個共同點,即重新初始化一個對象,使其恢復到一個初始狀態。下面的應用,則反其道而行之,將一個對象切換到另一個狀態。
有時,我們會做一些類,在構造函數中執行一些復雜的操作,比如解析一個文本文件,然后向外公布解析后的結果。之后,我們需要在這個對象上load另一個文
件,那么通常都寫一個load成員函數,先釋放掉原先占用的資源,然后再加載新的文件。如果有了swap,那么這個load函數同樣極其簡單:
class Y : IDisposable

...{

public Y(string filename) ...{
... //打開文件,執行解析
}

public void swap(Y val) ...{...}

public load(string filename) ...{
using(Y t=new Y(filename))

...{
this.swap(t);
}
}

public void Dispose() ...{
... //關閉文件,釋放資源
}
}
還有一種情況,有一些類,通過一些數據創建,創建之后在絕大多數的情況下都是只讀的,但偶爾會需要改變其內部數據。為了代碼的可靠性,我們可以把類寫成只讀的。但是如何修改其內部的數據呢?也可以通過swap:
class Z

...{

public Z(int a, float b) ...{
m_a=a;
m_b=b;
}

public void swap(Z val) ...{...}

public int a ...{ get...{return m_a;}}

public float b ...{ get...{return m_b;}}
private int m_a;
private float m_b;
}

Z z=new Z(3, 4.5);
z.swap(new Z(5, 5.4)); //z的值已修改
這樣便可避免對Z的實例的隨意修改。但是,這種修改方式會造成性能損失,特別是數據成員存在非O(1)復制的情況下(如有字符串、數組等),只有在修改偶爾發生的情況下才能使用。
有些類,構造函數需要一些數據初始化對象,并且會創建的過程中會驗證其有效性,和執行一些計算。也就是構造函數存在一定的數據邏輯。如果需要修改對象的某
些值,會牽涉到相應的復雜數據邏輯。通常都是把這些邏輯獨立在private成員函數中,由構造函數和數據修改操作共享。這樣的做法往往不能帶來強異常保
證,在構造函數里的數據驗證往往會拋出異常。因此,如果使用swap,便可以消除這類問題,并且使代碼簡化:
class A

...{

public A(int a, string b, Rectangle c) ...{
... //數據邏輯、計算等
}

public int a ...{

set...{ this.swap(new A(value, m_b, m_c));}
}

public string b ...{

set...{ this.swap(new A(m_a, value, m_c));}
}

public Rectangle c ...{

set...{ this.swap(new A(m_a, m_b, value));}
}
...
}
當然,也可以在類外直接進行這樣的數據設置:
A a=new A(2, "zzz", Rectangle(1,1, 10,10));
a.swap(new A(3, "zzz", Rectangle(1,1, 10,10)));
這種用法可以用于某些只保存對構造函數參數的計算結果,而不需要保存這些參數的類(m_a,m_b,m_c都不需要了),只是使用上過于瑣碎。
所有這些與對象狀態設置有關的swap用法,都集中表現了一個特性,即使得我們可以將對象的初始化代碼集中在構造函數中,數據和資源清理的代碼集中在
Dispose()中。這種做法可以大大提高代碼的可維護性。如果一個軟件項目中,每個類都實現swap和復制構造函數(除非該類不允許復制),并盡可能
集中數據邏輯代碼,那么會使得代碼質量有答復的提高。
在上一篇《C++的營養——RAII》中,我提到守衛一個結構復雜的類:在代碼中修改一個對象,然后再回復原來的狀態。如果單純手工地保存對象數據,通常
很困難(有時甚至是不可能的),而且也難以維持異常安全性(強異常保證)。但是如果使用了swap,那么將會易如反掌:

void ScopeObject(MyCls obj) ...{
using(MyCls t_=new MyCls(obj))

...{
... //操作obj,改變其狀態或數據
obj.swap(t_); //恢復原來的狀態
}
}
當然,也可以直接使用t_執行操作,這就不需要執行swap。在一般情況下兩者是等價的。但是,在某些特殊情況下,比如類持有特殊資源,或者obj是并發
中的共享對象的時候,兩種方法有可能不等價。swap方案使用上更全面些。總的來說相差不多,放在這里僅供參考。
作為更進一步的發展,可以構造一個ISwapable泛型接口:
interface ISwapable<T>

...{
void swap(T v);
}
對于需要實現swap手法的類,實現這個接口:
class B : ISwapable<B>

...{

public B() ...{...}

public void swap(B v) ...{...}
...
}
這將會帶來一個好處,通過泛型算法實現某些特定的操作:
class utility

...{
public static void reset<T>(T obj)
where T : ISwapable
where T : new()

...{
obj.swap(new T());
}
}
這樣便無須為每一個類編寫reset成員函數,只需這一個泛型算法即可:
X x;
Y y;
utility.reset(x);
utility.reset(y);
...
swap手法可能在存在其他諸多應用,在編碼的時候可以不斷地發掘。只需要抓住一個原則:swap可以無拋擲,簡潔地修改一個對象的值。swap所帶來的
一個問題主要是性能方面。swap通常伴隨著臨時對象的構造,多數情況下,這種構造不會引發更多的性能損失,但在某些數據修改的應用中,會比直接的數據修
改損失更多的性能。如何取舍,需要根據具體情況分析和權衡。總的來說,swap手法所帶來的好處是顯而易見的,特別是強異常保證,往往是至關重要的。而諸
如簡化代碼等的作用,則無需多言,一用便知。
或許swap手法非常基礎,非常細小,而且很多人不用swap也過來了。但是,聚沙成塔,每一處細小的優化,積累起來則是巨大的進步。還是劉皇叔說得好:“勿以善小而不為,勿以惡小而為之”。