最近更新: 2009-11-14

擁抱變化,從函數指標到函數個體

我來說個關於函數指標(在 C 語言中,function 就是一個 function pointer)與函數個體(function object)的小故事。這個故事還有一個 Java 版的,在本文的最後一節。只對 Java 語言有興趣的,可以先看後面的 Java 版,再回頭看前面。

有一天,"偉大的"架構設計師交代甲和乙兩位程序員共同負責撰寫一個小程式。這個程式配置了兩個陣列,一個是傳統的整數陣列,一個是整數vector。這個程式要將這兩個陣列的內容傾印出來。

開始設計

甲、乙兩人將程式分成兩段分別撰寫。甲撰寫傾印動作;乙撰寫配置動作,再透過for_each調用甲撰寫的傾印行為輸出陣列內容。

傾印動作第一版

甲先用函數指標完成了他負責的程式碼: pv.h (revision.1)。

/**
 * pv header
 *
 * revision: 1
 */

void pv_revision() {
    std::cout << "revision: 1" << std::endl;
}

void pv(int v) {
    std::cout << v << std::endl;
}
配置與輸出

乙所撰寫的部份,則是: fpv.cpp

#include <iostream>
#include <vector>
#include <algorithm>

#include "pv.h"

using namespace std;

int main() {
    int a1[] = {11, 22, 33, 44 ,55};

    pv_revision();

    vector<int> a2;
    for (int i=0; i < 5; ++i) {
        a2.push_back(i);
    }

    //for_each(a1, a1 + sizeof(*a1) + 1, pv); //fix bug. thank you, miki
    for_each(a1, a1 + sizeof(a1)/sizeof(*a1), pv);

    for_each(a2.begin(), a2.end(), pv);

    return 0;
}

for_each 是 STL 提供的演算法樣板。它的結構大致如下所示。

template<class T, class F>
void my_foreach(T* begin, T* end , F(f)(T v)) {
    T* i = begin;

    while(i < end) {
        f( *i );
        ++i;
    }

    return;
}

這個 my_foreach 函數樣板只是用來說明 STL for_each 樣板的演算法, 實際可套用的場所有限。底下還是會用 for_each 樣板。

編譯與輸出結果相當順利。


$> g++ -o fpv fpv.cpp

$> ./fpv
revision: 1
11
22
33
44
55
0
1
2
3
4

中途改變設計方式

"偉大的"架構設計師檢視兩人的工作結果後,說不要用函數指標,要將設計變更為函數個體(function object)。因為函數個體具有三個優點。

  1. A function object might be smarter because it may have a state.
  2. Each function object has its own type. Thus, you can pass the type of a function object to a template to specify a certain behavior.
  3. A function object is usually faster than a function pointer.
The C++ Standard Library - A Tutorial and Reference, Nicolai M. Josuttis

「改變設計方式」真是每個程序員最討厭聽到的一句話。但是使用者的需求是會改變的,所以 eXtreme Programming 告訴我們要「擁抱變化」(Embrace Change)。所幸 C++ 的彈性為我們提供相當大的幫助。甲程序員只需要作一點小小的修改,就可以應付這項設計變更工作。

傾印動作第二版

甲定義一個類別 PV,並重載運算子() 以包裹函數 pv 原本的動作,實現函數個體的功能。最後,甲定義一個靜態的函數體 pv 替代原本的函數指標,供乙的程式調用。甲修改後的程式碼: pv.h (revision.2)。

/**
 * pv header
 *
 * revision: 2
 */

void pv_revision() {
    std::cout << "revision: 2" << std::endl;
}

// 重載 () 運算子,將原函數內容包裹為函數個體。
class PV {
  public:
    // 原函數
    //void pv(int v) {
    void operator() (int v) {
        std::cout << v << std::endl;
    }
};

static PV pv = PV();

那麼乙程序員需要修改什麼?答案是「什麼都不必改」。只需要重新編譯程式。


$> g++ -o fpv fpv.cpp

$> ./fpv
revision: 2
11
22
33
44
55
0
1
2
3
4

當我們要改變設計方式,將函數指標包裹為函數個體時,我們會改變原本的函數名稱,另外定義一個靜態的同名函數體 ( 靜態的函數體雖然會喪失函數個體的第二項優點,無法保有獨立的狀態。但是卻可以相容既有的程式碼。而日後在撰寫新的程式碼時,程序員只要自己配置一個新的實例,仍然可以享有函數個體的完整優點。 )。 藉由同名函數體,可以不必修改已經寫好的程式碼(只需要重新編譯),就可讓原有的程式碼透過函數體調用函數,而不是透過函數指標。整個轉移過程相當平滑,已經寫好的程式碼不必跟隨設計變更而修改。

在當年(大約十五到十年前),大量的 C 語言程式碼轉換到 C++ 語法時,許許多多的程序員就是透過這種運算子重載(overload)的技巧,將基本型別資料與參考型別資料的差異消弭於無形。

就算是今天我們在撰寫全新的 C++ 程式碼時,這種彈性還是相當地重要,因為這代表了「擁抱變化」的彈性。假設有一天我們採用了不良的設計方式,使用基本型別而不是參考型別, C++ 仍然提供我們消耗最少成本彌補不良設計的途徑。

Java 語言的故事

同樣的故事,如果發生在 Java 世界,會出現完全不同的情節...

首先,Java 語言不支援函數指標或函數委派。所以甲程序員員必須先定義一個類別(PV),具有一個方法 pv。再告訴乙程序員去配置一個PV的實例與調用方法 pv ,例如 PV pv = new PV(); pv.pv(v);

其次,"偉大的"架構設計師看完後,會要求採用 interface ,讓乙的程式碼相依於具有 pv 方法的介面,而不是相依於具有 pv 方法的類別 (在 Java 世界中,真的很常聽到「相依於介面,不要相依於類別」這句話,我熟知的其他程式語言倒是很少強調這個"重點")。

於是甲要多定義一個 interface ,而乙也要修改配置 PV 實例的方式。例如:

import java.util.*;

public class Main {
    public static void
    main(String[] args) {
        //PV pv = new PV();
        // ERROR! PV 現在是interface,不能直接 new PV().
        
        PVInterface pv = new PV(); //介面PVInterface,類別PV

        PV pv = new PVImpl(); //介面PV,類別PVImpl

        PV pv = PVImpl.factory(); //介面PV,類別PVImpl,Factory pattern

        int[] a1 = {11, 22, 33, 44, 55};

        List<Integer> a2 = new ArrayList<Integer>();
        for (int i = 0; i < 5; ++i) {
            a2.add(i);
        }

        for (int v : a1) {
            pv.pv(v);
        }

        for (int v : a2) {
            pv.pv(v);
        }
    }
}

Anyway,現在一個簡單的設計變更,要兩個人都動手修改自己的程式碼。如果甲寫的 PV 類別已經被許多其他程序員使用在自己的程式碼中,其他人也要跟著修改。 Java 語言本身並沒有提供什麼方法降低我們彌補這個不良設計的成本。

Java 語言嚴格控管程序員的行為,希望減少程序員犯錯的機會。但是程序員會犯的錯誤,又豈止在指標越界存取、記憶體未釋放這些呢?不要忘記,我們總是會犯錯的。

Java 語言沒有實現讓普通的程序員表現更好的承諾,現在反而要一堆優秀的程序員與架構師,跳過一堆可笑的圈圈,如神一般地事先規劃好設計內容,才能讓大家用起來不那麼痛苦。

例如"全知的"架構設計師一開始就規劃要用介面,"全知的"架構設計師一開始就設計出依賴注射的框架,那麼上面變更設計的故事就不會發生。俗話說「千金難買早知道」, Java 語言讓我們體會這個道理。

樂多舊網址: http://blog.roodo.com/rocksaying/archives/10700785.html

樂多舊回應
edwardsayer@gmail.com(Edward) (#comment-20081243)
Thu, 19 Nov 2009 00:15:31 +0800
java client 端程式也是可以不必改寫的。還是一樣 PV pv = new PV(); 即可。舉例而言,Cat雖然繼承 Pet,但在建構單一實例時,其實很少會用 Pet cat = new Cat(); 因為這樣寫,會使這隻 cat 喪失除了 Pet 之外的其他特性,如 catchMouse()。會用介面/父類別來宣告,通常用在類別階層體系中,應用上僅著重共同的行為或特徵,如下:
Pet[] pets = new Pet[]{new Dog(), new Cat()};
for (Pet pet : pets) pet.cry();
其實Java語言看來中規中矩,但它卻可讓錯誤機率降到極低。當我跟用 C++ 的同事講說,用 java 寫程式之後,幾乎不用 debug,這是他很難理解的…
未留名 (#comment-20084399)
Thu, 19 Nov 2009 21:25:54 +0800
上面故事的重點在於「一開始的不良設計」與「中途變更設計」這兩點。

確實,只以這個範例程式的內容來說,我們沒有必要修改設計方式。

但是我們在實務開發時,總是碰到比上述範例更複雜、更多行的程式。也肯定會碰到有人一時粗心,就寫出直接相依,形成耦合度過高的程式碼。而且又會不可避免的碰到變更設計或是擴充功能的情形。一但碰到這種情形,勢必要做上面故事中說的事。

經驗豐富的編程人員,一定要有兩個想法常駐心中:
1.人總是會犯錯。
2.設計總是會中途變更。


我當初寫 C 語言也很少用 debug。只有錯誤發生在複數指標處 (例如指標的指標的指標, ***point) 才祭出 debug 觀察指標到底指到哪去了。既然 Java 不讓你用指標,那用 debug 的必要性也不存在。

我更常使用的是測試程式。我一向只把 compiler 當成語法檢查工具。單元測試工具才是真正可信賴的除錯工具。

像我也很難理解許多 Java 程序員以為 compile 過就安心的想法。

Java 在語法層級上就把程序員可做的事掐得死死的。你在用 IDE 和 javac 時,就已經花上許多時間去修正錯誤,甚至比你真正的設計時間還多。就算如此,若我沒有先寫 test case ,我仍然覺得替 Java 程式除錯是舉步唯艱。

我今天正好就犯了忘了寫 test case 的錯。我重構了一個 controller 與 model ,結果 junit 報了一個錯誤。一追下去,發現錯誤不在我剛重構的 controller 與 model 中,而是發生在它們調用的另一個類別的靜態方法之內。

偏偏那另一個類別是我在一時偷懶沒寫 test case 的情形下寫出來的(因為只有2個靜態方法)。當場給我難堪。

我老老實實地補上 test case 抓出那個靜態方法的錯誤後,再回頭去跑 AllTests ,一路綠燈,痛快到底。
mikimotoh@gmail.com(Miki) (#comment-21131955)
Tue, 24 Aug 2010 16:39:50 +0800
fpv.cpp的這一行:
for_each(a1, a1 + sizeof(*a1) + 1, pv);

參數2的是要給sequence end。可是sizeof()裡面怎麼是放 *a1,也就是 a1[0]的型別,integer (4 bytes) 。最後又要再加 1。

通常來說,都會用
#define ARRAY_SIZE(a) sizeof(a)/sizeof(a[0])

for_each(a1, a1 + ARRAY_SIZE(a1), pv);

sizeof是傳回byte count,不是element count。

再來,fpv的那個例子,我看不懂從function pointer轉換到function object有何好處。還是說這文章是要示範std::for_each的好處?
未留名 (#comment-21132181)
Tue, 24 Aug 2010 18:53:39 +0800
sizeof(*a1)+1的問題,是我計算錯誤。因為4+1剛好是陣列大小,所以沒跳錯誤,我也就沒有仔細檢查。

function pointer 轉 function object 的優點,我已經引用 Nicolai M. Josuttis 的話說明了。當然啦,在這個例子中是看不出有這個必要的。但你總不能指望我為了說明這麼一件小事,就寫幾百行程式碼出來突顯 function pointer 轉 function object 的優點吧。其實只要玩過 JavaScript, Ruby 或 Python,就知道 function object 擁有哪些好處了。

此外,本文的重點也不是 for_each()。這篇文章是要把 C++ 與 Java 這兩個版本一起看完之後,讓讀者自己的體會 C++ 提供了什麼樣的彈性,而 Java 又失去了什麼。

由於Java 語言不支援函數指標或函數委派,也不支援運算子覆載,所以 function pointer 和 function object 都是不存在的字眼。我認識的 Java 程序員中,幾乎沒有第一次接觸 function object (例如 JavaScript 中的 function object) 就能理解的。所以我才以這些為例寫了這篇故事。

mikimotoh@gmail.com(Miki) (#comment-21134095)
Wed, 25 Aug 2010 11:44:37 +0800
我去google了一下「java function pointer」,發現有60%的人都要你改成用interface,而你要輸入一個method時用一個anonymous class包裝起來。
另有30%是建議用 reflection。另10%是用一些新的JSR功能。

但不管是用interface或是reflection,寫起來都比function pointer複雜,不易懂。C#都至少有給delegate。

我認為這是因為java把method當成「動詞」來想,而不是名詞。只有名詞才可當物件來操作,而動詞不可以。
未留名 (#comment-21134741)
Wed, 25 Aug 2010 16:32:01 +0800
某人(似乎是 Steve Yegge) 調侃 Java 語言是充滿名詞的世界。Joel Spolsky 在《約耳續談軟體》中抱怨 Java 不具有將函數視為第一級物件的能力。指的就是 Java 在這方面的重大缺陷。