當程序員具備了抽象思維

若想捉大魚,就得潛入深淵。深淵裏的魚更有力,也更純淨。碩大而抽象,且非常美麗。——大衛·林奇

前言

抽象思維是我們工程師最重要的思維能力。因爲軟件技術 本質上就是一門抽象的藝術。我們的工作是存思維的“遊戲”,雖然我們在使用鍵盤、顯示器,打開電腦可以看到主板、硬盤等硬件。但我們即看不到程序如何被執行,也看不到 0101 是如何被 CPU 處理的。

我們工程師每天都要動用抽象思維,對問題域進行分析、歸納、綜合、判斷、推理。從而抽象出各種概念,挖掘概念和概念之間的關係,對問題域進行建模,然後通過編程語言實現業務功能。所以,我們大部分的時間並不是在寫代碼,而是在梳理需求,理清概念。當然,也包括嘗試看懂那些“該死的、別人寫的”代碼。

在我接觸的工程師中,能深入理解抽象概念的並不多,能把抽象和麪向對象、架構設計進行有機結合,能用抽象思維進行問題分析、化繁爲簡的同學更是鳳毛麟角。

對於我本人而言,每當我對抽象有進一步的理解和認知,我都能切身感受到它給我在編碼和設計上帶來的質的變化。同時,感慨之前對抽象的理解爲什麼如此膚淺。如果時間可以倒流的話,我希望我在我職業生涯的早期,就能充分意識到抽象的重要性,能多花時間認真的研究它,深刻的理解它,這樣應該可以少走很多彎路。

什麼是抽象

關於抽象的定義,百度百科是這樣說的:

抽象是從衆多的事物中抽取出共同的、本質性的特徵,而捨棄其非本質的特徵的過程。具體地說,抽象就是人們在實踐的基礎上,對於豐富的感性材料通過去粗取精、去僞存真、由此及彼、由表及裏的加工製作,形成概念、判斷、推理等思維形式,以反映事物的本質和規律的方法。實際上,抽象是與具體相對應的概念,具體是事物的多種屬性的總和,因而抽象亦可理解爲由具體事物的多種屬性中捨棄了若干屬性而固定了另一些屬性的思維活動。

Wikipedia 的解釋是:

抽象是指爲了某種目的,對一個概念或一種現象包含的信息進行過濾,移除不相關的信息,只保留與某種最終目的相關的信息。例如,一個皮質的足球,我們可以過濾它的質料等信息,得到更一般性的概念,也就是球。從另外一個角度看,抽象就是簡化事物,抓住事物本質的過程。[

簡單而言,“抽”就是抽離,“象”就是具象,字面上理解抽象,抽象的過程就是從“具象”事物中歸納出共同特徵,“抽取”得到一般化(Generalization)的概念的過程。英文的抽象——abstract 來自拉丁文 abstractio,它的原意是排除、抽出。

爲了更好的方便你理解抽象,讓我們先來看一幅畢加索的畫,如下圖所示,圖的左邊是一頭水牛,是具象的;右邊是畢加索畫,是抽象的。怎麼樣,是不是感覺自己一下子理解了抽象畫的含義。

可以看到,抽象牛隻有幾根線條,不過這幾根線條是做了高度抽象之後的線條,過濾了水牛的絕大部分細節,保留了牛最本質特徵,比如牛角,牛頭,牛鞭、牛尾巴等等。這種對細節的捨棄使得“抽象牛”具有更好的泛化(Generalization)能力。

可以說,抽象更接近問題的本質,也就是說所有的牛都逃不過這幾根線條。

抽象和語言是一體的

關於抽象思維,我們在百度百科上可以看到如下的定義:

抽象思維,又稱詞(概念)的思維或者邏輯思維,是指用詞(概念)進行判斷、推理並得出結論的過程。抽象思維以詞(概念)爲中介來反映現實。這是思維的最本質特徵,也是人的思維和動物心理的根本區別。

之所以把抽象思維稱爲詞思維或者概念思維,是因爲語言和抽象是一體的。當我們說“牛”的時候,說的就是“牛”的抽象,他代表了所有牛共有的特徵。同樣,當你在程序中創建 Cow 這個類的時候,道理也是一樣。在生活中,我們只見過一頭一頭具象的牛,“牛”作爲抽象的存在,即看不見也摸不着。

這種把抽象概念作爲世界本真的看法,也是古希臘哲學家柏拉圖的最重要哲學思想。柏拉圖認爲,我們所有用感覺感知到的事物,都源於相應的理念。他認爲具體事物的“名”,也就是他說的“理念世界”纔是本真的東西,具體的一頭牛,有大有小,有公有母,顏色、性情、外形各自不同。因此我們不好用個體感覺加以概括,但是這些牛既然都被統稱爲“牛”,則說明它們必然都源於同一個“理念”,即所謂“牛的理念”或者“理念的牛”,所以它們可以用“牛”加以概括。尚且不論“理念世界”是否真的存在,這是一個哲學問題,但有一點可以確定,我們的思考和對概念的表達都離不開語言。[4]

這也是爲什麼,我在做設計和代碼審查(Code Review)的時候,會特別關注命名是否合理的原因。因爲命名的好壞,在很大程度上反映了我們對一個概念的思考是否清晰,我們的抽象是否合理,反應在代碼上就是,代碼的可讀性、可理解性是不是良好,以及我們的設計是不是到位。

有人做過一個調查,問程序員最頭痛的事情是什麼,通過 Quora 和 Ubuntu Forum 的調查結果顯示,程序員最頭疼的事情是命名。如果你曾經爲了一個命名而絞盡腦汁,就不會對這個結果感到意外。

就像 Stack Overflow 的創始人 Joel Spolsky 所說的:“起一個好名字應該很難,因爲,一個好名字需要把要義濃縮在一到兩個詞。(Creating good names is hard, but it should be hard, because a great name captures essential meaning in just one or two words)。”

是的,這個濃縮的過程就是抽象的過程。我不止一次的發現,當我覺得一個地方的命名有些彆扭的時候,往往就意味着要麼這個地方我沒有思考清楚,要麼是我的抽象弄錯了。

關於如何命名,我在《代碼精進之路》裏已經有比較詳盡的闡述,這裏就不贅述了。

我想強調的是,語言是明晰概念的基礎,也是抽象思維的基礎,在構建一個系統時,值得我們花很多時間去斟酌、去推敲語言。在我做過的一個項目中,就曾爲一個關鍵實體討論了兩天,因爲那是一個新概念,嘗試了很多名字,始終感覺到彆扭、不好理解。隨着我們討論的深入,對問題域理解的深入,我們最終找到了一個相對比較合適的名字,才肯罷休。

這樣的斟酌是有意義的,因爲明晰關鍵概念,是我們設計中的重要工作。雖然不合理的命名、不合理的抽象也能實現業務功能。但其代價就是維護系統時需要極高的認知負荷。隨着時間的推移,就沒人能搞懂系統的設計了。

抽象的層次性

回到畢加索的抽象畫,如下圖所示,如果映射到面向對象編程,抽象牛就是抽象類(Abstract Class),代表了所有牛的抽象。抽象牛可以泛化成更多的牛,比如水牛、奶牛、犛牛等。每一種牛都代表了一類(Class)牛,對於每一類牛,我們可以通過實例化,得到一頭具體的牛實例(Instance)。

從這個簡單的案例中,我們可以到抽象的三個特點:

1. 抽象是忽略細節的。抽象類是最抽象的,忽略的細節也最多,就像抽象牛,只是幾根線條而已。在代碼中,這種抽象可以是 Abstract Class,也可以是 Interface。

2. 抽象代表了共同性質。類(Class)代表了一組實例(Instance)的共同性質,抽象類(Abstract Class)代表了一組類的共同性質。對於我們上面的案例來說,這些共同性質就是抽象牛的那幾根線條。

3. 抽象具有層次性。抽象層次越高,內涵越小,外延越大,也就是說它的涵義越小,泛化能力越強。比如,牛就要比水牛更抽象,因爲它可以表達所有的牛,水牛隻是牛的一個種類(Class)。

抽象的這種層次性,是除了抽象概念之外,另一個我們必須要深入理解的概念,因爲小到一個方法要怎麼寫,大到 一個系統要如何架構,以及我們後面第三章要介紹的結構化思維,都離不開抽象層次的概念。

在進一步介紹抽象層次之前,我們先來理解一下外延和內涵的意思:

抽象是以概念(詞語)來反映現實的過程,每一個概念都有一定的外延和內涵。概念的外延就是適合這個概念的一切對象的範圍,而概念的內涵就是這個概念所反映的對象的本質屬性的總和。例如“平行四邊形”這個概念,它的外延包含着一切正方形、菱形、矩形以及一般的平行四邊形,而它的內涵包含着一切平行四邊形所共有的“有四條邊,兩組對邊互相平行”這兩個本質屬性。

一個概念的內涵愈廣,則其外延愈狹;反之,內涵愈狹,則其外延愈廣。例如,“平行四邊形”的內涵是“有四條邊,兩組對邊互相平行”,而“菱形”的內涵除了這兩條本質屬性外,還包含着“四邊相等”這一本質屬性。“菱形”的內涵比“平行四邊形”的內涵廣,而“菱形”的外延要比“平行四邊形”的外延狹。

所謂的抽象層次就體現在概念的外延和內涵上,這種層次性,基本可以體現在任何事物上,比如一份報紙就存在多個層次上的抽象,“出版品”最抽象,其內涵最小,但外延最大,“出版品”可以是報紙也可以是期刊雜誌等。

  1. 一個出版品
  2. 一份報紙
  3. 《舊金山紀事報》
  4. 5 月 18 日的《舊金山紀事報》

當我要統計美國有多少個出版品,那麼就要用到最上面第一層“出版品”的抽象,如果我要查詢舊金山 5月18日當天的新聞,就要用到最下面第四層的抽象。

每一個抽象層次都有它的用途,對於我們工程師來說,如何拿捏這個抽象層次是對我們設計能力的考驗,抽象層次太高和太低都不行。

比如,現在要寫一個水果程序,我們需要對水果進行抽象,因爲水果裏面有紅色的蘋果,我們當然可以建一個 RedApple 的類,但是這個抽象層次有點低,只能用來表達“紅色的蘋果”。來一個綠色的蘋果,你還得新建一個 GreenApple 類。

爲了提升抽象層次,我們可以把 RedApple 類改成 Apple 類,讓顏色變成 Apple 的屬性,這樣紅色和綠色的蘋果就都能表達了。再繼續往上抽象,我們還可以得到水果類、植物類等。再往上抽象就是生物、物質了。

你可以看到,抽象層次越高,內涵越小,外延越大,泛化能力越強。然而,其代價就是業務語義表達能力越弱。

具體要抽象到哪個層次,要視具體的情況而定了,比如這個程序是專門研究蘋果的可能到 Apple 就夠了,如果是賣水果的可能需要到 Fruit,如果是植物研究的可能要到 Plant,但很少需要到 Object。

我經常開玩笑說,你把所有的類都叫 Object,把所有的參數都叫 Map 的系統最通用,因爲 Object 和 Map 的內涵最小,其延展性最強,可以適配所有的擴展。從原理上來說,這種抽象也是對的,萬物皆對象嘛。但是這種抽象又有什麼意義呢?它沒有表達出任何想表達的東西,只是一句正確的廢話而已。

越抽象,越通用,可擴展性越強,然而其語義的表達能力越弱。越具體,越不好延展,然而其語義表達能力很強。所以,對於抽象層次的權衡,是我們系統設計的關鍵所在,也是區分普通程序員和優秀程序員的關鍵所在。

軟件中的分層抽象無處不在

越是複雜的問題越需要分層抽象,分層是分而治之,抽象是問題域的合理劃分和概念語義的表達。不同層次提供不同的抽象,下層對上層隱藏實現細節,通過這種層次結構,我們纔有可能應對像網絡通信、雲計算等超級複雜的問題。

網絡通信是互聯網最重要的基礎實施,但同時它又是一個很複雜的過程,你要知道把數據包傳給誰——IP協議,你要知道在這個不可靠的網絡上出現狀況要怎麼辦——TCP 協議。有這麼多的事情需要處理,我們可不可以在一個層次中都做掉呢?當然是可以的,但顯然不科學。因此,ISO制定了網絡通信的七層參考模型,每一層只處理一件事情,低層爲上層提供服務,直到應用層把HTTP、FTP等方便理解和使用的協議暴露給用戶。

編程語言的發展史也是一個典型的分層抽象的演化史。

機器能理解的只有機器語言,即各種二進制的 01 指令。如果我們採用 01 的輸入方式,其編程效率極低(學過數字電路的同學,體會下用開關實現加減法)。所以我們用匯編語言抽象了二進制指令。

然而彙編還是很底層,於是我們用 C 語言抽象了彙編語言。而高級語言 Java 是類似於 C 這樣低級語言的進一步抽象,這種逐層抽象極大的提升了我們的編程效率。

重複代碼是抽象的缺失

如果說抽象的本質是共性的話,那麼我們代碼中的重複代碼,是不是就意味着抽象的缺失呢?

//取默認搜索條件
List<String> defaultConditions = searchConditionCacheTunnel.getJsonQueryByLabelKey(labelKey);
for(String jsonQuery : defaultConditions){
  jsonQuery = jsonQuery.replaceAll(SearchConstants.SEARCH_DEFAULT_PUBLICSEA_ENABLE_TIME, String.valueOf(System.currentTimeMillis() / 1000));
  jsonQueryList.add(jsonQuery);
}
//取主搜索框的搜索條件
if(StringUtils.isNotEmpty(cmd.getContent())){
    List<String> jsonValues = searchConditionCacheTunnel.getJsonQueryByLabelKey(SearchConstants.ICBU_SALES_MAIN_SEARCH);
    for (String value : jsonValues) {
    String content = StringUtil.transferQuotation(cmd.getContent());
    value = StringUtil.replaceAll(value, SearchConstants.SEARCH_DEFAULT_MAIN, content);
      jsonQueryList.add(value);
  }
}

是這樣的,重複代碼是典型的代碼壞味道,其本質問題就是抽象的缺失。因爲我們 Ctrl+C 加 Ctrl+V 的工作習慣,導致沒有對共性代碼進行抽取;或者雖然抽取了,只是簡單的用了一個 Util 名字,沒有給到一個合適的名字,沒有正確的反應這段代碼所體現的抽象概念,都屬於抽象不到位。

有一次,我在 Review 團隊代碼的時候,發現有一段組裝搜索條件的代碼,在幾十個地方都有重複。這個搜索條件還比較複雜,是以元數據的形式存在數據庫中,因此組裝的過程是這樣的:

  • 首先,我們要從緩存中把搜索條件列表取出來;
  • 然後,遍歷這些條件,將搜索的值填充進去;

簡單的重構無外乎就是把這段代碼提取出來,放到一個Util類裏面給大家複用。然而我認爲這樣的重構只是完成了工作的一半,我們只是做了簡單的歸類,並沒有做抽象提煉。

簡單分析,不難發現,此處我們是缺失了兩個概念:一個是用來表達搜索條件的類——SearchCondition;另一個是用來組裝搜索條件的類——SearchConditionAssembler。只有配合命名,顯性化的將這兩個概念表達出來,纔是一個完整的重構。

重構後,搜索條件的組裝會變成一種非常簡潔的形式,幾十處的複用只需要引用SearchConditionAssembler就好了。

public class SearchConditionAssembler {
    public static SearchCondition assemble(String labelKey){
        String jsonSearchCondition =  getJsonSearchConditionFromCache(labelKey);
        SearchCondition sc = assembleSearchCondition(jsonSearchCondition);
        return sc;
    }
}

由此可見,提取重複代碼只是我們重構工作的第一步。對重複代碼進行概念抽象,尋找有意義的命名纔是我們工作的重點。

因此,每一次遇到重複代碼的時候,你都應該感到興奮,想着這是一次鍛鍊抽象能力的絕佳機會,當然,測試代碼除外。

強制類型轉換是抽象層次有問題

面向對象設計裏面有一個著名的 SOLID 原則是由 Bob 大叔(Robert Martin)提出來的,其中的 L 代表 LSP,就是 Liskov Substitution Principle(里氏替換原則)。簡單來說,里氏替換原則就是子類應該可以替換任何父類能夠出現的地方,並且經過替換以後,代碼還能正常工作。

思考一下,我們在寫代碼的過程中,什麼時候會用到強制類型轉換呢?當然是 LSP 不能被滿足的時候,也就是說子類的方法超出了父類的類型定義範圍,爲了能使用到子類的方法,只能使用類型強制轉換將類型轉成子類類型。

舉個例子,在蘋果(Apple)類上,有一個 isSweet() 方法是用來判斷水果甜不甜的;西瓜(Watermelon)類上,有一個 isJuicy() 是來判斷水分是否充足的;同時,它們都共同繼承一個水果(Fruit)類。

此時,我們需要挑選出甜的水果和有水分的習慣,我們會寫一個如下的程序:

public class FruitPicker {

    public List<Fruit> pickGood(List<Fruit> fruits){
        return fruits.stream().filter(e -> check(e)).
                collect(Collectors.toList());
    }

    private boolean check(Fruit e) {
        if(e instanceof Apple){
            if(((Apple) e).isSweet()){
                return true;
            }
        }
        if(e instanceof Watermelon){
            if(((Watermelon) e).isJuicy()){
                return true;
            }
        }
        return false;
    }
}

因爲pick方法的入參的類型是 Fruit,所以爲了獲得 Apple 和 Watermelon 上的特有方法,我們不得不使用 instanceof 做一個類型判斷,然後使用強制類型轉換轉成子類類型,以便獲得他們的專有方法,很顯然,這是違背了裏式替換原則的。

這裏問題出在哪裏?對於這樣的代碼我們要如何去優化呢?仔細分析一下,我們可以發現,根本原因是因爲 isSweet 和 isJuicy 的抽象層次不夠,站在更高抽象層次也就是 Fruit 的視角看,我們挑選的就是可口的水果,只是具體到蘋果我們看甜度,具體到西瓜我們看水分而已。

因此,解決方法就是對 isSweet 和 isJuicy 進行抽象,並提升一個層次,在 Fruit 上創建一個 isTasty() 的抽象方法,然後讓蘋果和西瓜類分別去實現這個抽象方法就好了。

下面是重構後的代碼,通過抽象層次的提升我們消除了 instanceof 判斷和強制類型轉換,讓代碼重新滿足了裏式替換原則。抽象層次的提升使得代碼重新變得優雅了。

public class FruitPicker {    
public List<Fruit> pickGood(List<Fruit> fruits){        
return fruits.stream().filter(e -> check(e)).                collect(Collectors.toList());    }    
//不再需要instanceof和強制類型轉換    p
rivate boolean check(Fruit e) {        
return e.isTasty();    }}

所以,每當我們在程序中準備使用 instanceof 做類型判斷,或者用 cast 做強制類型轉換的時候。每當我們的程序不滿足 LSP 的時候。你都應該警醒一下,好傢伙,這又是一次鍛鍊抽象能力的絕佳機會。

如何提升抽象思維能力

抽象思維能力是我們人類特有的、與生俱來的能力,除了上面說的在編碼過程中可以鍛鍊抽象能力之外,我們還可以通過一些其他的練習,不斷的提升我們的抽象能力。

多閱讀

爲什麼閱讀書籍比看電視更好呢?因爲圖像比文字更加具象,閱讀的過程可以鍛鍊我們的抽象能力、想象能力,而看畫面的時候會將你的大腦鋪滿,較少需要抽象和想象。

這也是爲什麼我們不提倡讓小孩子過多的暴露在電視或手機屏幕前的原因,因爲這樣不利於他抽象思維的鍛鍊。

抽象思維的差別讓孩子們的學習成績從初中開始分化,許多不能適應這種抽象層面訓練的,就去讀技校了,因爲技校比大學會更加具象:車銑刨磨、零部件都能看得見摸得着。體力勞動要比腦力勞動來的簡單。

多總結沉澱

小時候不理解,語文老師爲什麼總是要求我們總結段落大意、中心思想什麼的。現在回想起來,這種思維訓練在基礎教育中是非常必要的,其實質就是幫助學生提升抽象思維能力。

記錄也是很好的總結習慣。就拿讀書筆記來說,最好不要原文摘錄書中的內容,而是要用自己的話總結歸納書中的內容,這樣不僅可以加深理解,而且還可以提升自己的抽象思維能力。

我從四年前開始系統的記錄筆記,做總結沉澱,構建自己的知識體系。這種思維訓練的好處顯而易見,可以說我之前寫的《從碼農到工匠》和現在正在寫的《程序員必備的思維能力》都離不開我總結沉澱的習慣。

命名訓練

每一次的變量命名、方法命名、類命名都是一次難得的抽象思維訓練機會,前面已經說過了,語言和抽象是一體的,命名的好壞直接反應了我們的問題域思考的是否清晰,反映了我們抽象的是否合理。

現實情況是,我們很多的工程師常常忽略了命名的重要性,只要能實現業務功能,名字從來就不是重點。

實際上,這是對系統的不負責任,也是對自己的不負責任,更是對後期維護系統的人不負責任。寫程序和寫文章有很大的相似性,本質上都是在用語言闡述一件事情。試想下,如果文章中用的都是些詞不達意的句子,這樣的文章誰能看得懂,誰又願意去看呢。

同樣,我一直強調代碼要顯性化的表達業務語義,其中命名在這個過程中扮演了極其重要的角色。爲了代碼的可讀性,爲了系統的長期可維護性,爲了我們自身抽象思維的訓練,我們都不應該放過任何一個帶有歧義、表達模糊、意不清的命名。

領域建模訓練

對於技術同學,我們還有一個非常好的提升抽象能力的手段——領域建模。當我們對問題域進行分析、整理和抽象的時候,當我們對領域進行劃分和建模的時候,實際上也是在鍛鍊我們的抽象能力。

我們可以對自己工作中的問題域進行建模,當然也可以通過閱讀一些優秀源碼背後的模型設計來學習如何抽象、如何建模。比如,我們知道 Spring 的核心功能是 Bean 容器,那麼在看Spring源碼的時候,我們可以着重去看它是如何進行Bean管理的?它使用的核心抽象是什麼?不難發現,Spring 是使用了 BeanDefinition、BeanFactory、BeanDefinitionRegistry、BeanDefinitionReader 等核心抽象實現了 Bean 的定義、獲取和創建。抓住了這些核心抽象,我們就抓住了 Spring 設計主脈。

除此之外,我們還可以進一步深入思考,它爲什麼要這麼抽象?這樣抽象的好處是什麼?以及它是如何支持 XML 和 Annotation(註解)這兩種關於 Bean 的定義的。

這樣的抽象思維鍛鍊和思考,對提升我們的抽象能力和建模能力非常重要。關於這一點,我深有感觸,初入職場的時候,當我嘗試對問題域進行抽象和建模的時候,會覺得無從下手,建出來的模型也感覺很彆扭。

然而,經過長期的、刻意的學習和鍛鍊之後,很明顯可以感覺到我的建模能力和抽象能力都有很大的提升。不但分析問題的速度更快了,而且建出來的模型也更加優雅了。

小結

  • 抽象思維是程序員最重要的思維能力,抽象的過程就是尋找共性、歸納總結、綜合分析,提煉出相關概念的過程。
  • 語言和抽象是一體的,抽象思維也叫詞思維,因爲抽象的概念只能通過語言才能表達出來。
  • 抽象是有層次性的,抽象層次越高,內涵越小,外延越大,擴展性越好;反之,抽象層次越低,內涵越大,外延越小,擴展性越差,但語義表達能力越強。
  • 對抽象層次的拿捏,體現了我們的設計功力,視具體情況而定,抽象層次既不能太高,也不能太低。
  • 重複代碼意味着抽象缺失,強制類型轉換意味着抽象層次有問題,我們可以利用這些信號來重構代碼,讓代碼重新變的優雅。
  • 我們可以通過刻意練習來提升抽象能力,這些練習包括閱讀、總結、命名訓練、建模訓練等。

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

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