擁抱變化,從函數指標到函數個體
我來說個關於函數指標(在 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)。因為函數個體具有三個優點。
The C++ Standard Library - A Tutorial and Reference, Nicolai M. Josuttis
- A function object might be smarter because it may have a state.
- 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.
- A function object is usually faster than a function pointer.
「改變設計方式」真是每個程序員最討厭聽到的一句話。但是使用者的需求是會改變的,所以 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 語言讓我們體會這個道理。
樂多舊回應