over 2 years ago

唸研究所開始當助教,偶而會有學弟妹問:怎樣寫好程式?老實說,這是個大哉問,連我學開發軟體這麼久,我也只能回答他們:多培養自己釐清問題、拆解問題、解決問題與抽象化的能力。但他們通常只會一臉狐疑看著我,感覺我說的話好抽象。事實上,這也不是我第一個這樣說的,有句軟體工程諺語是這樣說的:

Why is it that some software engineers and computer scientists are able to produce clear, elegant desings and programs, while others cannot? Critical to these questions is the notion of abstraction. (為何有些軟體工程師與電腦科學家能夠產生清楚而且優美的設計與程式,但其他人卻不能?關鍵在於抽象觀念。)

Jeff Kramer CACM 50(4) 2007

釐清問題

釐清問題是讓自己能解決對的問題的第一步,當一開始什麼都不想,根據使用者或顧客一個模糊的需求或是想法就開始埋頭苦寫,即便程式寫好了也不一定解決對方真正的問題,因此有一個說法是找出問題背後的問題,使用者提出一個問題,通常是在現實生活中遇到困難,但在描述時,卻不一定能精確的描述問題(這當然不能當著顧客的面說),或是把背後的問題給描述出來,所以在理解需求的過程中,是要去幫使用者找出真正的問題。不過,我不打算在這裡說明釐清問題的方法。

拆解問題

當釐清真正的問題後,問題有時很大,有時很小,問題小也許就可以開始找尋解法,但問題很大時,會像毛線球般糾結很難好好處理,所以應該先試著將大問題拆解成小的問題,然後再根據每個小的問題去尋找解法。舉個例子,雖然現在網路就像空氣一樣,幾乎成為生活中不可或缺的一個元素,即便如此,我們還是可以問一個問題:當我們輸入一個網址後,電腦是如何呈現這個網頁?

這樣一個大問題可以被拆解成好幾個小問題:

  • 瀏覽器是怎麼知道一個網址對應到網路上哪一個伺服器?
  • 瀏覽器的請求是如何送到伺服器的?
  • 當伺服器知道某人想看某個網頁時,網頁是以什麼形式回到當初請求的電腦?
  • 當瀏覽器收到伺服器的內容又要如何呈現網頁?

而上述這些問題其實都還很大,還可以再被拆成更多小的問題,在過去許多人的努力下,定義成一個七層的OSI網路模型,每一層都提供一個功能(或換個說法解決一個特定問題),例如:

  • 屬於應用層的HTTP協定,讓伺服器能知道使用者想要看什麼文件(網頁)並回傳指定的文件
  • 屬於傳輸層的TCP協定,建立瀏覽器與伺服器之間一個虛擬連線[1],並負責確保傳輸資料的完整性
  • 屬於網路層的IP協定,為網路上每個節點提供地址,並負責將資料在眾多網路節點中繞送到正確的節點
  • 屬於實體層的WiFi協定[2],負責將電腦的數位訊息能夠在空氣中用電波傳輸,並檢查接收的數位訊號完整性

這種divide and conquer正是電腦科學解決問題的方式,但這跟抽象又有什麼關係?事實上每一層都是提供一個抽象,例如虛擬連線就是一個抽象的概念,對於使用TCP的HTTP而言,具體的細節則不需要知道,但HTTP知道,它想要送的訊息在TCP提供的虛擬連線中是保證會送到的,怎麼送達的細節則是TCP的責任(具體化)

解決問題

事實上,如何解決問題或是如何邏輯思考?與電腦無關,在沒有電腦之前,我們有數學公式、物理公式、化學公式與機械等,很多的問題其實也都能被解決,只是可能需要很大量的人,或是無法快速的得到想要的結果,因此,當要用電腦解決問題時,需要的是計算思維(computational thinking),讓解決問題的方法是可以用電腦去計算的,就像前陣子很熱門的Alpha Go圍棋大戰,先要做的是替圍棋找出一個模型讓電腦能夠計算,這其實需要的就是抽象化能力。

當問題能被計算,但可能不夠快,例如下一步棋需要一天或一個小時,這時候需要另一種演算法思維(algorithmic thinking),透過特殊設計的資料結構,以及找出能讓電腦做更少的計算就能得到結果的演算法,讓電腦能更快的解決問題,類似的例子像是影像壓縮和解壓縮是個抽象概念,可以用更少的網路頻寬傳送更高畫質的影片,而具體的演算法,H.265則可以比H.264有更好的效率與影片品質。

抽象化

說了這麼多,終於又回到抽象化這個詞,抽象化可以用在好幾個不同層面,像剛剛提到的OSI網路模型和影片壓縮都是提供抽象概念。但實際寫程式時,抽象化是讓程式容易閱讀的關鍵,畢竟大部分時間讀程式的是人而不是電腦,所以這讓我想起一句話:

Any fool can write code that a computer can understand. Good programmers write code that humans can understand. (隨便找個傻瓜都能寫出電腦能懂的程式碼。好的程式設計師寫人能看得懂的程式碼。)

Martin Fowler

當軟體持續開發,維護程式碼比開發新程式碼要更傷腦筋,如何讓後續的開發者讀程式像是讀文章般容易懂,重要的就是能用問題domain中的術詞(term)來描述程式。舉個例子,假設有一個my-book-store的網路書店,提供若干REST API讓客戶端可以使用:

取得Isaac Asimov的科幻類作品
GET http://my-book-store.com/books?category=science-fiction&author=Isaac%20Asimov

在編號19333910書籍新增一筆評論
POST http://my-book-store.com/books/19333910/comments

修改編號13332144書籍的資訊
PUT http://my-book-store.com/books/13332144

刪除編號19333912的第12筆評論
DELETE http://my-book-store.com/books/19333912/comments/12

以Java來說,想要使用REST API,客戶端可以用Socket建立連線到my-book-store,準備好HTTP協定相關的標頭與內容,然後傳送給伺服器然後再取得結果,但我想很多人都知道:Socket是作業系統或JVM提供給軟體開發者操作TCP的API,對於我們要做的功能來說太低階了。事實上,Java有提供HttpURLConnection讓開發者可以直接建立HTTP連線,因此可以用Listing 1的程式呼叫REST API取得Isaac Asimov的科幻類作品(範例程式中未處理所有可能拋出的例外)。

Listing 1. 用HttpURLConnection呼叫REST API取得Isaac Asimov的科幻類作品
public List<Book> getBooks(String category, String author) {
    List<Book> books = new ArrayList<Book>;
    String urlString = String.format("https://my-book-store.com/books?category=%1$s&author=%2$s", category, author);
    URL url = new URL(URLUtils.encode(urlString));
    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    ObjectMapper mapper = new ObjectMapper();
    try (InputStream stream = connection.getInputStream()) {
        String content = IOUtils.toString(stream);
        books.addAll(mapper.readValue(content, new TypeReference<List<Book>>(){}));
    }
    return books;
}

可以用Listing 2的程式呼叫RES API在編號19333910書籍新增一筆評論,但寫到這,是否發現有太多與Listing 1重複的程式?而這些程式其實還是在處理很多低階的IO處理。也許需要另一層抽象可以跟REST伺服器溝通,而不用去處理實作細節。

Listing 2. 用HttpURLConnection呼叫REST API在編號19333910書籍新增一筆評論
public boolean postComment(int bookId, String comment) {
    String urlString = String.format("https://my-book-store.com/books/%1$s/comments", String.valueOf(bookId));
    URL url = new URL(URLUtils.encode(urlString));
    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    connection.setRequestMethod("POST");
    connection.setDoOutput(true);
    try (OutputStreamWriter writer = new OutputStreamWriter(connection.getOutputStream())) {
        writer.write(content);
        writer.flush();
    }
    return connection.getResponseCode() == 200;
}

所以,我們可以觀察一下上述四個API不同的地方與相同的地方,可以發現如下,我們的抽象層需要能指定伺服器的位置(host),API的路徑(path),路徑上可能有參數可以設定(path parameters),額外可以夾帶查詢參數(query parameters),最後,最重要的是可以指定呼叫的方法(HTTP Method)。

HTTP Mehotd https://host/path/[{path parameters}][?query parameters]

因此,我們可以根據剛剛的描述設計一個RestClient,直接來看例子,Listing 3與Listing 4直接以RestClient改寫getBooks(category, author)postComment(bookId, comment)函式,是否與原先的Listing 1和Listing 2讀起來,在語意上是不是有完全不同的感受?

Listing 3. 以RestClient改寫getBook函式
public List<Book> getBooks(String category, String author) {
    RestClient client = RestClient.hostOn("http://my-book-store.com");
    List<Book> books = client.get("/books")
        .query("category", category)
        .query("author", author)
        .results();
    return books;
}
Listing 4. 以RestClient改寫postComment函式
public boolean postComment(int bookId, String comment) {
    RestClient client = RestClient.hostOn("http://my-book-store.com");
    return client.post("/books/{bookId}/comments")
        .where("bookId", String.valueOf(bookId))
        .content(comment)
        .isSucceeded();
}

有了RestClient,要實作updateBook(bookId, updates)deleteComment(bookId, commentId)是不是也變得很容易,或許,有人只是覺得透過封裝,可以重複使用程式碼,但對我來說,這並不是主要的目的,Listing 5與Listing 6讀起來是否開始有感覺了呢?透過結合domain的術語與Fluent Interface,其實我們已經完成了一個為REST API設計的Domain Specific Language [3]。

Listing 5. 以RestClient實作updateBook函式
public boolean updateBook(int bookId, Map<String, String> updates) {
    RestClient client = RestClient.hostOn("http://my-book-store.com");
    return client.put("/books/{bookId}")
        .where("bookId", String.valueOf(bookId))
        .content(updates)
        .isSucceeded();
}
Listing 6. 以RestClient實作deleteComment函式
public boolean deleteComment(int bookId, int commentId) {
    RestClient client = RestClient.hostOn("http://my-book-store.com");
    return client.delete("/books/{bookId}/comments/{commentId}")
        .where("bookId", String.valueOf(bookId))
        .where("commentId", String.valueOf(commentId))
        .isSucceeded();
}

當軟體越開發越大,為軟體進行模組化是絕對必要的,而抽象化也是模組設計所需要的一項很重要的能力,我們可以將剛剛四個函式包裝成一個MyBookStoreService,因此,使用起來就像Listing 7所示,讀起來語意上又提高一個層級,對使用者來說,也已經不知道底層使用的是REST API。

Listing 7. 使用MyBookStoreService
MyBookStoreService service = new MyBookStoreService();
List<Book> books = service.getBooks("science-fiction", "Isaac Asimov");
service.postComment(19333910, "I like it!");

如同OSI網路模型一樣,軟體的開發是透過一層又一層的抽象堆疊完成,解決各種不同層次的問題(前提是問題已經先被拆解),因此有個說法:

All problems in computer science can be solved by another level of indirection.

David Wheeler

但要怎麼提升抽象化的能力呢?以自己的經驗,首先,先試著讓自己能寫出有條理的文章,因為對現在的我來說,寫程式其實是在寫文章,寫讓其他人看得懂的文章,所以透過寫文章訓練自己的如何思考與如何整理思緒,是非常有幫助的(這真要感謝我的指導教授在我寫碩博士論文時的訓練)。再來,多看別人好的程式碼,瞭解其中使用哪些design principles、design pattern和architecture pattern,以及背後使用的意圖,耳濡目染久了,就會有自己對於事物抽象化的想法(是的,抽象化沒有標準答案的,只有合不合適解決問題與否和是否容易理解的差別)。

結語

語意的抽象化可以說從過去到現在都仍然是進行式,從最早期用打卡機寫程式,到後來可以用組合語言寫程式,到能用C寫程序導向的程式,演變到可以用Java/C#等語言寫物件導向的程式,甚至最近很流行的用functional paradigm的概念寫程式,每次演進都在提高程式語言的抽象程度,到最後每個domain都會有自己的domain specific language,讓原先domain的人可以讀懂程式。

其實要開發大型的軟體,不論是在學界或在業界,真正好的軟體開發者要具備的要素還有很多,像是能與同儕溝通、引領思考、軟體工程實務(開發流程、建構管理等)的實踐,都會讓一個好的軟體開發者與一個差的軟體開發者在效率上差上好幾倍(有一說是10倍,但我找不到出處),但就以平日每天在寫程式的層級來看,抽象化能力是最不可缺少的一項能力。寫了這麼多,我想如果再有人問我:怎樣寫好程式?或是怎麼能成為一個好的軟體開發者?這文章是我目前能給的最好說法,希望對想學習軟體開發的人有幫助。

後話

最近寫程式變得很熱門,連歐巴馬總統都在學寫程式,再過幾年,從國中開始,學生也開始要學寫程式,這讓我想起一句在軟體界的幽默諺語:

如果你想毀掉一個人的一天,就給他一個程式; 如果你想毀掉一個人的一生,就教他寫程式。 (If you give someone a program, you will frustrate them for a day; if you teach them how to program, you will frustrate them for a lifetime.)

如果真是如此,那我們正在進行毀掉全國學生的計畫(笑)?

附註

[1] 實際上瀏覽器的電腦與伺服器之間不可能真的有一條連線,只是透過封包在網路上多個節點轉送達成類似的現象。
[2] 這說法不是很精確,WiFi事實上包含了實體曾與資料鏈結層,但為了方便說明,請體諒一下。
[3] 雖然有人覺得像Objective C或Swift語言寫程式時需要寫出參數名稱很多餘,但在設計DSL時,這卻常常能讓程式更像自然語言,例如:client.where("bookId", is: 19333910)
[4] 文中引用很多諺語主要來自《軟體工程諺語》部落格。

← 用非同步Servlets實現Long Polling 使用WebSockets雙向推播資料 →