24.對象封裝
陰陽地理兩分張,隱者為陰顯者陽 ——《玉髓經.曜星論》
“用廣東話說,真是有型有料又有性格啊!”歎號啧啧連聲,“這哪裡是在設計軟件,分明是在設計心儀的對象嘛。”
“我們可不就是在談對象設計嗎?”冒號笑著反問,“在OOP的世界裡,每位程序員都是造物主。保持熱情、專注力和審美情趣,說不定哪一天就像希臘神話裡的皮格瑪利翁一樣,雕塑的美女變活了。”
“哇,那可就美了!”逗號極盡誇張之調。
全班哄堂大笑。
“剛才提到抽象是OOP三大基本特性的基礎,下面我們逐個剖析。”冒號很快收攏了話題,“首當其沖的是封裝性。記得前面談對象范式時,引號曾試圖為我們解釋封裝性,可惜被我無情地打斷了。現在我們請他繼續講解吧。”
在眾人逗趣式的掌聲中,引號竟有些腼腆了:“所謂封裝性,就是將數據與相關行為包裝在一起以實現信息隱藏。”
“幾乎無懈可擊。”冒號贊揚得有些保守,“那麼封裝(encapsulation)與信息隱藏(information hiding)有區別嗎?”
“應該是一回事吧。”在冒號的逼視下,引號有些猶豫了,“嗯。。。信息隱藏是一種原則,而封裝是實現這種原則的一種方式。”
“言之有理!”冒號這回贊揚得很干脆,“盡管大多數參考書對二者不加區分,我還是要解析一番。其實廣義的封裝僅僅只是一種打包,即package或bundle,是密封的但可以是透明的。或者說,封裝就是把一些數據和方法裝在一個封閉的盒子裡——可能是黑盒子,也可能是白盒子。從語法上說,這是OOP與諸如C之類的過程式語言最大的不同。請問這帶來什麼效果?”
句號反應很快:“這等於引入了一種新的模塊機制,將相關的數據和作用其上的運算捆綁在一起形成被稱為類的模塊。”
“回答正確!”冒號很滿意,“剛才我們用C實現了隊列,但由於C不支持封裝,只能以文件形式來劃分模塊,顯然不如類劃分那麼方便和明晰。此外,封裝還有語法糖(Syntactic sugar)效果。”
問號好奇地問:“什麼是語法糖?是不是很甜?”
“所謂語法糖,就是一些語法上的甜頭。它不是核心語法,並沒有提供任何額外的功能,只是用起來更簡潔實用、更自然方便,看起來更酷、更炫而已。”冒號有意用時髦的詞匯來填補代溝,“我們知道,過程式函數采用謂語(主語,賓語)的形式,而OOP采用主語.謂語(賓語)的形式。”
“哦,就是那個狗吃屎和吃狗屎啊,那可不甜。”逗號又來插科打诨。
眾人笑得前仰後合。
冒號不為所動:“再拿隊列為例,如果增加一個隊列成員,用剛才的C實現,我們需要寫下:queue_add(queue, item)。假如用Java來實現,只需寫queue.add(item)。由於封裝使add綁定在queue上,一方面可以將對象queue前置,既更符合自然語言,又少敲一個字符;另一方面,這種綁定使add局限於Queue類中,因此不必加上‘queue_’的前綴以防與其他類的方法函數名相沖突。這同樣節省了打字,也使接口更簡單。”
句號提出:“如果C支持函數重載(overload),那麼‘queue_’的前綴就可省去。”
“你說的既對也不對。”冒號辯證地評判,“如果C支持重載,該前綴的確能省去;但從另一角度看,即使Java或C++不支持重載,前綴用樣能省去。因為函數add已經不再是全局函數,Queue類就是其上下文(context)。換句話說,分屬不同類的函數是不可能產生歧義(ambiguity)的,哪怕它們的簽名(signature)一模一樣。因此我們要把功勞記在封裝的名下。”
句號心悅誠服。
冒號繼續講解:“狹義的封裝是在打包的基礎上加上訪問控制(access control),以實現信息隱藏。相對於上述廣義的封裝,不妨認為多了一個將白盒子刷成黑盒子的過程。這一過程可以看作對抽象的一種補充:抽象意味著用戶可以從高層的接口來看待或使用一類對象,而不用關心它底層的實現,而黑盒封裝意味著用戶無權訪問底層的實現。”
逗號有點茫然:“那談起封裝,究竟指哪一個?”
“一般所說的封裝大多是狹義的。”冒號回復道,“考試中最無趣的一類試題就是名詞解釋,因為那只能印證記憶,不能印證理解。軟件編程中也有無數的名詞和概念,機械式的記憶沒有任何意義——除了面試時應付某些同樣無趣的考官。我們在這裡著意诠釋封裝的概念,不是出於學術理論的目的,而是為了讓大家深刻體會封裝的目的和意義,以便在實踐中靈活運用。”
問號詢問:“前面提到,代碼既要合法又要合理,那訪問控制還重要嗎?”
“合法合理是對程序員的要求。對於語言,我們還是希望它盡可能地提供更多的保障。這就好比社會和諧不能只靠法律,但法制當然越健全越好。”冒號解答道,“訪問控制不僅是一種語法限制,也是一種語義規范——標有public的公用接口對代碼閱讀者而言,顯然比注釋文檔更正式更直觀。因此,其重要性是不言而喻的。值得一提的是,訪問控制也不是滴水不漏的。C++用戶可以通過指針來間接訪問private成員,Java也可以通過反射機制來訪問。”
見眾人頗有疑義,冒號便寫了一段Java代碼——
// 通過反射機制訪問私有變量
import java.lang.reflect.*;
class Private
{
private String field = "這是私有變量";
private void method()
{
System.out.println("調用私有方法");
}
}
public class AccessTest
{
public static void main(String[] args) throws Exception
{
Private privateObj = new Private();
Field f = Private.class.getDeclaredField("field");
f.setAccessible(true);
System.out.println(f.get(privateObj));
Method m = Private.class.getDeclaredMethod("method", new Class[0]);
m.setAccessible(true);
m.invoke(privateObj, new Object[0]);
}
}
冒號講述道:“運行這段代碼,可以看到privateObj的域成員和方法成員都被訪問了。這是一種hack,僅限於特殊用途,不在我們關心之列。問題是,即使不考慮此類非常規做法,要實現信息隱藏也不是件容易的事。”
歎號不解:“信息隱藏困難在哪裡呢?加上private不就隱藏了成員嗎?”
“如果所有信息都隱藏了,這個對象還有什麼用嗎?”冒號一語破的。
逗號一愣:“可以用getter方法返回信息啊。”
冒號更不答話,投影出一段代碼——
import java.util.Date;
import java.util.Calendar;
class User
{
private Date birthday; /** *//** 生日 */
private boolean sex; /** *//** 性別。true代表男,false代表女 */
public User(Date birthday, boolean sex)
{
this.birthday = birthday;
this.sex = sex;
}
public Date getBirthday()
{
return birthday;
}
public void setBirthday(Date birthday)
{
this.birthday = birthday;
}
public boolean getSex()
{
return sex;
}
public void setSex(boolean sex)
{
this.sex = sex;
}
/** *//** 計算年齡,負數表示未知 */
public int computeAge()
{
if (birthday == null) return -1;
Calendar dob = Calendar.getInstance();
dob.setTime(birthday);
Calendar now = Calendar.getInstance();
int age = now.get(Calendar.YEAR) - dob.get(Calendar.YEAR);
if (now.get(Calendar.DAY_OF_YEAR) < dob.get(Calendar.DAY_OF_YEAR))
--age;
return age;
}
}
冒號提問:“這段代碼簡單得勿需多言,請問它的信息隱藏做得如何?”
眾人目不轉睛地盯了好一陣,無人應答。
冒號突發驚人之語:“如果我說User所有的方法都違背了信息隱藏原則,你們相信嗎?”
直直的眼睛全都變圓了。
引號忽然明白了:“記得書上曾說不能直接返回類的內部對象。GetBirthday返回Date類型的生日,用戶可以在調用此方法後直接對生日進行操作。”
“說得對極了!”冒號誇贊道,“如果一個方法返回了一個可變(mutable)域對象(field object)的引用,無異於前門緊閉而後門洞開。解決的方法是防御性復制(defensive copying),即返回一個clone的對象,以免授人以柄(handle)。”
逗號有些難以置信:“好像這類做法很普通啊。”
冒號耐心詳解:“首先,請注意可變和引用兩個條件,所有基本類型的域不是引用,因而是安全的,而Java中String之類非基本類由於是不可變的(immutable),也是安全的。同樣,在C++和C#中的非基本類的值類型(value type)也不在此列。此外C++中申明了const的指針或引用返回值也能防止客戶修改。其次,普通的做法不代表是正確的。事實上,恕我直言:普通的程序員是不合格的,合格的程序員是不普通的。最後,信息隱藏原則固然極其重要,但也不是金科玉律,在一定條件下也是允許的。比如僅作數據儲存之用的類甚至可以開放所有的域成員,又比如不同類的對象共享同一引用。此外在一定范圍之內為提高效率也可能采取變通之法,當然是在對用戶曉以利害之後。”
問號舉一反三:“同樣道理,setBirthday也會導致信息洩漏。考慮到Date類型如此常用,Java是不是該引入一個不可變的日期類型呢?”
歎號喃喃自語:“getSex和setSex會有什麼問題呢?boolean是基本類型啊。”
冒號提示:“考慮一下性別的可能性。”
歎號訝然道:“難不成還有不男不女型?”
眾皆大笑。
冒號淡淡一笑:“不排除這種可能。更實際的情況是,有時性別是未知的。”
句號建議:“可以將小boolean換成大Boolean,多一個null值。”
冒號進一步指出:“如果想處理三種以上的可能性,可以采用char類型或String類型。總之這是實現細節,最好不要暴露給客戶。因此不妨將getSex換成isMale和isFemale兩個接口。”
引號細細玩味:“如果isMale和isFemale均返回false,那麼性別不是保密就是中性了。至於性別用boolean、Boolean、char還是String來實現,用戶是懵然不知的,這樣比直接了當的getSex更隱蔽也更靈活。”
冒號揭開最後的答案:“方法computeAge的問題不在其實現,而在其命名。該名暗示年齡是計算出來的,這暴露了實現方式,應該改為getAge。請注意,信息隱藏中的信息不僅僅是數據結構,還包括實現方式和策略。試想,如果將來把年齡而不是生日作為User的輸入,用年齡倒推生日,getBirthday是不是要換成computeBirthday呢?”
歎號不禁喟曰:“不想如此簡單的get和set竟如此講究!”
“通,則大處圓融合一而小處各具其妙;不通,則大處千變萬化而小處無所分別。”冒號又打起了禅語,“領會OOP的精髓絕非一年半載之功,但若以抽象與封裝為鑰,必可早日開啟通達之門。封裝的故事遠未結束,下節課繼續。布置一下課後作業,請將示例中的User類按剛才的提示進行改進。”