重複性管理——抽象的重要性(下) 原

接着上一篇的談論,繼續談論抽象在重複性管理中的重要作用。

好的抽象與糟糕的抽象?

通過前面的一些例子,你可能形成了一個印象:所謂抽象很多時候就是把一些代碼封裝到一個方法中。

不過事實上並不是這麼簡單的。抽象的結果確實很多時候產生了一個方法,但不是說我把一堆代碼整在一塊就是一個抽象,又或者說,即便它是一個抽象,但可能卻不是一個好的抽象,而是一個糟糕的抽象。

假如你看到一段代碼很長,然後你“啪”的一聲把它從中間攔腰截斷,劃分出兩個方法來,一個叫 firstPart(),一個叫 secondPart(),那麼這算是怎樣的一個抽象呢?

我們還是回到前面的例子,假如把它分成兩個部分,

image

那麼,這兩個抽象是存在問題的。如果我們足夠誠實的話,就應該把前面的方法命名爲 makeRandomArrayAndPrint,因爲這樣纔算較好的概括了它的行爲。但這樣一來這個名字就顯得很彆扭了。

別人看到這個名字可能就不想調用它了,雖然他可能需要一個隨機數組,但他也許並不需要把它打印出來。如果看到你的方法不能滿足需要,自然他又只能是重新發明一個更爲“純粹的”makeRandomArray,重複又產生了。

另一方面,像代碼中那樣沒有暗示存在打印,那別人看到這個方法名覺得 OK,然後使用它,結果卻出現了副作用,輸出中多了一些莫名其妙的東西,而這是他不希望出現的。

如果生成的數組很大,這些輸出甚至會導致嚴重的性能問題,最終可能還是導致別人放棄調用你的方法。

所以問題出在哪裏呢?其實就在於我們做了一個錯誤的,糟糕的抽象。生成隨機數組的方法中是不應該包含打印的,這實際是兩件不同的事,但我們卻把它們整合在一起,然後起了一個不適當的名字。

這樣一來代碼的靈活性就下降了。以 sort 爲例。有些初學者可能在寫完排序的代碼後就順手也寫個打印在裏邊,這樣一來,sort 實際就變成了 sortAndPrint。

一個方法越大越長,自然是離具體越近而離抽象越遠。

是的,其它地方會有很多對單獨的 sort 的需求,也會有很多對單純的 print 的需求;但同時需要 sort 跟 print 的需求有多少呢?恐怕就不多了。

越是具體,能恰好匹配的需求就越少,所以,硬是把 sort 和 print 封裝在一起,這樣的代碼能被複用的機率就小很多了,或者在勉強複用它的情況下,不得不忍受它所帶來的副作用,而最終,別人可能還是覺得重複發明一個“純粹的”輪子用得舒服些。

所以,有意無意地往 sort 裏增加一個打印,就屬於畫蛇添足,破壞了抽象,好心辦了壞事。

所謂的內聚性

綜上,代碼不是機械地把它們分開就是抽象。被一個方法所封裝在一起的一系列語句它們應該是緊密圍繞一個主題的,這就是所謂的“內聚性(Cohesion)”。只有當把彼此關係非常緊密的一系列語句封裝在一起,這樣才能構成一個好的抽象。

如前面把打印的功能跟生成隨機數組的代碼整合在一塊,但它們彼此的關係卻是疏遠的,不是非得要在一起的。硬是把它們的綁在一塊,那就成了強扭的瓜,成了拉郎配。它們在一起擦出的不是火花而是火光,也因此阻礙了複用,因爲很可能會帶來各種副作用。

在前面小程序中的大道理之四也曾經介紹過所謂的“單一職責原則(SRP:Single Responsibility Principle.)”,簡單地講,那就是:

一個方法只做好一件事。

而很多時候,命名是一件非常重要的事,因爲命名本身就是一個對事物進行抽象的過程。如果你的一個方法做了太多的事,你爲它取名時就會面臨很大的困難。

正如前面的 makeRandomArrayAndPrint 這個方法名會很長很彆扭那樣。

反之,如果你面對一個別扭的名字,你應該想到這可能是個糟糕的抽象。很不幸,我們可能會時不時看到諸如:

doSomeThing,handle,process,execute,apply

等這樣特別抽象的名字,而這倒不是說這些方法中乾的是多麼抽象的事,

畢竟你並不是在寫那些特別抽象的如解析器之類的,

最大可能其實是方法中做了太多的事,以至於不知道要怎樣給它取名了,最後只好取一個特別抽象的名,這樣僅從方法名中就基本得不到什麼有效的信息了。

當別人想複用你的代碼時,基本都會先從方法名入手,而不會深入到裏面去看。太抽象的名字讓人不知所云,最終別人會放棄嘗試你的方法,哪怕你的方法確實能解決他的問題。當然,如果名字特別抽象,更大概率是它沒有被複用的可能性。

所以,一個好的名字是特別重要的,因爲它是一種適當的,良好的抽象的暗示,而這樣的抽象是正是可複用性的關鍵,也只有這樣才能更好的管理系統中的重複。

在編程中,我們可能不自覺地就會在一個方法中塞入太多的語句,或在一個類中塞入太多的方法,或在一個模塊或 jar 塞入太多的類,這樣它們的主題必然就是模糊的,而沒有聚焦到“儘量做好唯一的一件事情”上。

這樣的東西即便你把打成一個 jar 包,別人可能還是不願意去使用,因爲他也許只想使用其中一小塊功能,卻必須引入一個龐大的 jar 包(還可能潛在地傳遞性地依賴更多的其它 jar),其中可能有一堆的東西他都是不需要的,導致系統特別臃腫。

所以更可能的情況就是他說,算了,別引入了,還是自己發明輪子吧。

如果你看現在的有些框架,比如 spring,你會發現它現在分得很細,

比如 IoC 成爲一個模塊,打包成一個單獨的 jar,AOP 可能成爲另一模塊,另一個 jar;而早期這些東西可能都是在一起的,一個單獨的巨大的 spring.jar

那麼這些細分的抽象自然有它的好處,比如我只要 IoC 的功能,那我就只引入 IoC 的,AOP 我則已有其它的解決方案,我不要它的。這樣一來我就能避免引入重複的解決方案,系統也不用那麼臃腫。

甚至說,這還不單單是重複性的問題。類似的東西太多還可能存在潛在的衝突,給未來帶來潛在的不確定性。畢竟,代碼越多,就越容易出錯,這是一條最基本的原理。

概念層次上的匹配

最後,回到概念層次的匹配這個話題上來。在前面的例子中談到,當沒有抽象時,是用“具體對抽象”,而只有建立了抽象後,纔能有“抽象對抽象”:

image

而這點爲什麼特別重要呢?有人可能會說:“你怎麼知道有人可能要複用它呢?你是不是過度抽象了?這也沒幾行代碼呀?”

是的,在這裏,你沒抽象出來一個 makeRandomArray 來,也許可能並不是很大的問題,但有時候事情不是這樣的。

我們再舉一個例子,下面的一段程序,計算二月份的天數和一年的天數:

 image

那麼其中的“((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)”,我相信你也清楚,就是判斷一個年份是否是閏年(leap year)。

那麼這段程序存在什麼問題呢?我們經常說程序是對現實世界的一個映射,如果把這段程序跟現實對比一下:

image

會發現,現實世界的一個“概念”(也就是“閏年”)在程序中沒有對應!現實世界的一處“具體的細節”則在程序中出現了兩次。

我們的程序中缺失了一個概念!

而所謂“概念”的“概”,也就是“概括的”的意思,其實也就是“抽象的”的一個近義詞,也就是說我們的程序實際缺少了一個“抽象”。

現在把這個抽象補上,增加一個叫“isLeapYear”的判斷:

image

現在再來看對應關係:

image

是不是合理了很多呢?現實中你有一個概念“閏年”,在程序中我則有一個抽象“isLeapYear”跟你對應;

因爲在程序中主要用於判斷,所以變成了 isXXX 這樣的形式。

現實中你有一個具體的含義“年份能被4整除且不能被100整除;或年份能被400整除”,在程序中我則有一段具體的代碼“((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)”跟你對應。

而且這段具體的代碼只出現一次!

現在,我們的程序也擁有了一套完整的詞彙,無論是抽象的還是具體的,無論在哪一個層次上,我都能跟現實對應上,達到了層次與概念上的匹配。

這種匹配特別重要,特別是抽象層次上的匹配,它爲程序中的其它部分構築了一道抽象的屏障(abstract barrier)

image

現在,getDayCountOfFeb 和 getDayCountOfYear 只依賴於抽象的“isLeapYear”,細節被隔絕了,被隱藏了,被壓制在了抽象之下。

它們不需要也不應該知道那些細節。

爲什麼這些細節的隱藏很重要呢?首先,正如前面引入“環比”和“同比”這些概念類似,抽象的“isLeapYear”要比具體的“((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)”的更爲簡短。

更爲重要的則是,細節是很可能會發生變化的。你可能有所不知的是,在早期,閏年的具體含義僅僅是“年份能被4整除。”

但按照每四年一個閏年計算,結合地球公轉的實際情況,平均每年就要多算出0.0078 天,經過四百年就會多出大約 3 天來,這個誤差不斷累積,所以後來就把閏年的具體含義調整爲現在這樣。

可是你注意到一個事實沒有,那就是“閏年”這個概念本身是穩定的,不管具體含義怎麼變,閏年還是叫閏年。

名字是我們取的,變不變我們說了算,況且本身就是抽象的,也沒什麼可變的;具體含義則不受我們控制,而且可能經常發生變化。

說到二月份的天數,我們還是這麼說:

如果是閏年,則是 29 天,否則是 28 天。

說到一年的天數,我們還是這麼說:

如果是閏年,則是 366 天,否則是 365 天。

當我們引入一個概念時,後續的很多敘述就會建立在這個抽象的概念之上,具體含義的變化不會影響到這些敘述;

與此類似,建立在抽象屏障之上的代碼也不會受到這些具體細節調整變化的衝擊:

image

不難看出,代碼中的敘述跟現實中的是匹配的,都是構築在抽象的基礎上,根本就不知道細節是怎樣的,自然也不會受到細節變化的衝擊。

反之,如果早期只是簡單地用具體的 “if (year % 4 == 0)” 來做判斷,那麼現在就要一一調整爲 “if ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)”。如果有兩處,就要做兩處調整;如果有 N 處,就要做 N 處調整。顯然,這種重複性的勞動是不受歡迎的。

更爲嚴重的是,參數名可能有的地方叫 year,而有的地方則叫 y;而做具體判斷時,有的地方可能把 (year % 400 == 0) 寫在前面,種種情況下使得你很難一一找出那些需要更改的地方!你可能要寫點複雜的正則表達式而不是一個簡單的查找才能把那些地方一一揪出來。

那麼這種情況就屬於細節沒有被壓制,它逃逸了,泄露了,具體的形式上也可能出現了一些變形,這些細節可能散落在系統的各個角落,最終在面臨調整變化時,我們可能需要大量的重複性勞動,甚至可能無法確保每一處都得到了調整。真是糟糕!

而有了抽象屏障,並把一切都建立在這個屏障之上後呢,一切就輕鬆很多了:

image

因爲抽象讓細節集中到了一處,現在只要簡單改改具體定義即可,一切就調整過來了。

而且能很清楚知道哪些地方會受到調整的衝擊,因爲只需看看什麼地方調用了它即可。簡單粗暴的方法就是暫時刪掉方法定義,看哪些地方報編譯的錯誤就知道了;也可以簡單地查找方法名,這比查找那些具體定義簡單穩定多了,也不需要什麼高超的正則表達式技巧;又或者,有了現代 IDE 的幫助,比如在 Eclipse 中,你只需簡單選中方法名,然後選擇“菜單--Navigate—Open Call Hierarchy”,也就是“打開調用層級”即可查看有哪些地方調用了它。IDEA 中也有類似操作,具體細節此處從略。

有了這些在概念層次上的良好匹配,現實中的一處改動,系統中也只需要一處改動。

在軟件的開發活動中,你可能會經常碰到這樣的情景:需求方說,某某地方我想做個小調整,這時程序員則連連擺手,“不行不行,改動太大了”,那麼需求方可能會想“這只是一個小調整呀,爲什麼會改動很大呢?”他想不明白。自然,有些小調整確實可能會造成大改動,這時你可以甩鍋給需求方:“怎麼不早說呢?”但是有時,正如現在舉的這個例子這樣,這個鍋也許需要我們程序員自己來背。我們需要經常反思所寫的代碼,概念層次跟現實是否形成了良好的匹配?是否做到了足夠的抽象?重複的細節被管理起來了嗎?很多時候,變化根本不受我們的控制,我們唯一能做的,就是使所寫的代碼保持足夠的彈性,能夠面對各種變化的衝擊。

在未來,當誤差不斷累積,閏年具體含義還可能會再次變化,但有了抽象屏障的保駕護航,我們不需要擔心太多。從某種意義上說,當建立好適當的抽象後,我們不但消除了現在的重複,甚至也消除了未來的重複。我們的代碼能夠抵禦未來未知變化的衝擊,使得在未來變化來臨時,無需那些重複性的折騰。

所以,這就是抽象在重複性管理中的巨大作用,關於這個主題就暫時討論到這裏。

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