25.軟件應變
潛其心能觀天下之理,定其心能應天下之變 ——《呂坤·呻吟語》
第七課剛一開堂,冒號就提了一個問題:“如果把一個Java程序中所有的private關鍵字換成public,請問該程序還能工作嗎?”
“應該還能工作,除非——此前不能工作。”問號小心翼翼地回答。
冒號接著問:“既然如此,何必費事區分它們呢?”
歎號嘴一撇:“當然是為了信息隱藏啰。”
冒號步步緊逼:“隱藏什麼信息呢?又為什麼要隱藏?”
歎號應對:“對象的狀態需要隱藏。如果一個對象的狀態直接暴露在外,讓客戶隨意修改,可能會破壞對象的內在邏輯。”
冒號依舊窮追不捨:“那為什麼對象的方法有些也需要隱藏?”
“以前我也有此疑問,看別人代碼時最感興趣的就是那些私有方法。”引號不打自招。
逗號逗他:“看來你患有偷窺癖哦。”
引號暗暗踢了逗號一腳:“現在我明白了,這是為了實現數據抽象,將接口與實現分離開來。”
冒號仍不罷休:“這種抽象究竟有何實際好處?”
句號搶答:“一方面,抽象接口描述了一個類最本質的行為特征;另一方面,具體實現隨時可能變動,隱藏它們可以保證這種變動不會波及客戶代碼。”
“說到點子上了!”冒號終於停止了追問,“軟件與硬件之別,不僅是無形與有形之別,更是變化與固化之別。所謂變化,指源代碼隨時可能因需而變。一個軟件修改維護的時間通常會超過編寫時間,越復雜越成熟的程序越是如此。軟件的難點有二:其一是邏輯的復雜,其二是需求的變化。許多程序員看重前者而看輕後者,大部分時間花在尋求解決方案上,而不是在選擇解決方案上。他們目眩於奇技淫巧卻不解大巧若拙之妙,殊不知充滿技巧的代碼不僅難於理解而易於出錯,且因其普適性低而受變化的沖擊更大。眾所周知,比武時最忌招式用老,老即難以變化,一旦為對手看破則後果不堪設想。同樣,動不動凌空躍起只是影視作品中招徕眼球的花哨場面,實戰中很少出現,蓋因空中不易變招。當然凡事皆有度,無一招用老,便無一招用實,難以完成致命一擊。反映在軟件上,那就是過度設計會帶來不必要的復雜和效率損失。”
眾人均想,又上起久違的武術課了。
冒號滔滔不絕:“一言以蔽之,軟件之軟,體現在適應變化的能力。許多編程設計思想包括OOP的思想都是以此為主題的,抽象與封裝便是典型代表。抽象一個對象模型即是將一類對象最本質因而最不易變化的部分提煉出來,而封裝——准確地說是信息隱藏——則是將非本質、容易變化的部分隱藏起來,從而將一個類劃分為陰陽兩面。由於變化多發生在陰面,對外是屏蔽的,因此修改該面毫無累及客戶之憂,由此提高了軟件的抗變能力。有些人誤認為信息隱藏是出於軟件安全(security)的考慮,實乃是似是而非的皮相之見。”
問號提問:“軟件的變化主要有哪些?”
“軟件的變化大致分兩種:一種是出於內在需求而作的結構性變化,通常以改善軟件質量為目的,即所謂的重構(refactoring);一種是出於外在需求而作的功能性變化,通常以滿足客戶需要為目的。理想的抽象與封裝,應能完全避免第一類變化對於客戶代碼的影響,也能最大限度地降低第二類變化的副作用。只是知易行難,為細微的變化而付出巨大代價的例子比比皆是。‘千年蟲’就是一個最典型的例子,而當32 位的IPv4 全部換成128位的IPv6 ,其代價也不遑多讓。從中可以看出,信息隱藏,尤其是結構性信息隱藏是多麼的重要!下面看一個簡單的例子。”冒號打開幻燈片——
// 用直角坐標實現的復數類
public class Complex
{
private double x;
private double y;
public Complex(double x, double y)
{
this.x = x;
this.y = y;
}
public double real() { return x; }
public double imaginary() { return y; }
public double modulus() { return StrictMath.hypot(x, y); }
public double argument() { return StrictMath.atan2(y, x); }
public Complex add(Complex other)
{
return new Complex(x + other.x, y + other.y);
}
public Complex multiply(Complex other)
{
return new Complex(x * other.x - y * other.y,
x * other.y + y * other.x);
}
}
“這是一個用直角坐標實現的復數Java類,為簡明起見,僅僅實現了實部、虛部、模、輻角、加法和乘法等運算。同樣地,我們也可以用極坐標來實現。”冒號投影出另一段代碼——
// 用極坐標實現的復數類
public class Complex
{
private double r;
private double theta;
public Complex(double x, double y)
{
r = StrictMath.hypot(x, y);
theta = StrictMath.atan2(y, x);
}
public double real() { return r * StrictMath.cos(theta); }
public double imaginary() { return r * StrictMath.sin(theta); }
public double modulus() { return r; }
public double argument() { return theta; }
public Complex add(Complex other)
{
return new Complex
(r * StrictMath.cos(theta) + other.r * StrictMath.cos(other.theta),
r * StrictMath.sin(theta) + other.r * StrictMath.sin(other.theta));
}
public Complex multiply(Complex other)
{
Complex product = new Complex(0, 0);
product.r = r * other.r;
product.theta = theta + other.theta;
return product;
}
}
句號似已深明其意:“這兩個類的接口相同而實現方式不同,它們的區別是結構性的,而不是功能性的。就實現效率而論,直角坐標便於加減運算,而極坐標便於乘除、乘方開方等運算。實現者可能會為采用何種方案而舉棋不定,好在由於隱藏了結構性信息,即使以後修改了實現方案,也不會影響客戶。”
冒號補充道:“如果將代碼移植到C++,修改了實現方案,還是可能在一定程度上影響客戶的。”
歎號有些驚訝:“為什麼?C++不也是OOP語言嗎?”
冒號解釋:“由於C++需要頭文件,即使私有成員也必須在頭文件中聲明。這意味著改動任何私有數據結構甚至私有方法的簽名,所有包含該頭文件的源代碼雖不必改寫,卻需要重新編譯鏈接。這對大型程序來說通常是難以忍受的,同時也說明設計與語言息息相關的。如果一個設計者只是高高在上,完全不考慮語言細節,難免流於紙上談兵。”
逗號問道:“為什麼Java不需要頭文件呢?”
“因為Java、C#包括D語言中類似頭文件的信息,已經在編譯時自動提取並保存了。”冒號道出緣由,“出於歷史原因和效率上的考慮,C++仍沿用C的頭文件用法,成為除指針和內存管理之外最令人頭痛的問題。因此在C++中應盡可能地使用前置聲明(forward declaration),減少包含的(included)頭文件。另外,可以將一些私有靜態(private static)成員從頭文件轉移到實現代碼中,以匿名命名空間(anonymous namespace)的方式來實現完全隱藏。此外還有一個非常有用的技巧——柄/體(handle/body)模式或稱橋梁模式(bridge pattern),可以將接口與實現完全分開。這種模式不僅可以解決C++中的頭文件問題,對Java等不需要頭文件的語言也是有用的。下面我們用這種模式重新實現Complex類。”
幻燈一閃,新的源碼出現在眾人眼前——
// 復數計算接口ComplexImpl
public interface ComplexImpl
{
public double real();
public double imaginary();
public double modulus();
public double argument();
public Complex add(Complex other);
public Complex multiply(Complex other);
}
// 用直角坐標實現的ComplexImpl
public class ComplexCartesianImpl implements ComplexImpl
{
private double x;
private double y;
public ComplexCartesianImpl(double x, double y)
{
this.x = x;
this.y = y;
}
public double real() { return x; }
public double imaginary() { return y; }
public double modulus() { return StrictMath.hypot(x, y); }
public double argument() { return StrictMath.atan2(y, x); }
public Complex add(Complex other)
{
return new Complex(x + other.real(), y + other.imaginary());
}
public Complex multiply(Complex other)
{
return new Complex(x * other.real() - y * other.imaginary(),
x * other.imaginary() + y * other.real());
}
}
// 用極坐標實現的ComplexImpl
public class ComplexPolarImpl implements ComplexImpl
{
private double r;
private double theta;
// 以下省略。。。
}
// 用橋梁模式實現的復數類
public class Complex
{
private ComplexImpl impl;
public Complex(double x, double y)
{
impl = new ComplexCartesianImpl(x, y);
//或者:impl = new ComplexPolarImpl(x, y);
}
public double real() { return impl.real(); }
public double imaginary() { return impl.imaginary(); }
public double modulus() { return impl.modulus(); }
public double argument() { return impl.argument(); }
public Complex add(Complex other) { return impl.add(other); }
public Complex multiply(Complex other) { return impl.multiply(other); }
}
冒號進而指出:“這是橋梁模式的簡化版。稍加改進,我們不僅可以在編譯期間決定具體實現方式,甚至可以讓客戶在運行期間選擇實現方式。你們課後不妨試試。”
引號一拍大腿:“妙!如此既免除了實現者抉擇的煩惱,也給賦予使用者更大的自由,可謂一舉兩得啊。”
句號也道:“信息隱藏雖能將抽象接口與具體實現分離,但仍然封裝在同一類中。橋梁模式則讓二者徹底解耦(decouple),增強了對變化的適應力,具有更大的靈活性和可擴展性。”
“當然這也增加了一定的復雜性和效率上的損失,具體運用時應酌情考量,避免過度設計。”冒號提醒道,“最後,如果Complex類需要功能上的變化,比如增加乘方、開方等運算,只要不修改現有運算的簽名,是不會傷及客戶代碼的。”