loop是每個程式設計師每天都會寫上好幾遍的program construct(好吧,Lisp programmer是例外 :P),loop也是structured programming的基本要素之一,現今的計算機都是遵循著von Neumann架構,所以使用loop是相當地貼近機器、有效率。有趣的是,每個程式語言都往往有數種不同的loop方式,並且不同的程式語言對於loop的設計也往往不同,這篇文章以C語言的幾種loop方式作為討論對象,希望促使自己思考不同的loop實作背後的考量。有機會再針對C++/Python/Lisp等語言作討論。
C語言或許是高階語言中最接近von Neumann架構的一種,所以其內建的for loop機制幾乎只是薄薄的一層機器指令的包裝,在接近機器層面的程式碼,這樣的作法幾乎可以肯定帶來最高效的對應。現在我們來想想一個問題:如何找出一個陣列中的最大值?很簡單,程式碼大概會像這樣:
但是C語言內建的for loop卻沒有提供some container與do something with the element的概念,所以一旦這兩個東西發生了變化,程式設計師就必須再手工寫一遍for loop。所以問題轉變為:要如何提供一個機制,讓程式設計師即使改變了資料結構或對每一個結構中的元素的處理方式時,仍能保持for loop不需更改?
讓我們先從簡單的開始:當元素都只在陣列中,但對元素的處理方式不同,要如何不改變for loop呢?在C語言中,慣用的手法有兩種,第一種是透過callback function:
typedef void (*callback)(void *e, void *priv);
void *e參數是for_each給callback的,而void *priv則是使用者自己的資訊,可以在for_each運行時改變狀態以進行不同判斷。這種設計方式給了我們設計介面時的一個方向:將不變的抽取出來,將會易變的提供出介面供外部使用者修改。如果這件事可以確實作的好,自然就會符合所謂的模組化設計。著名軟體開發先驅Jim Coplien在其博士論文中花了很大篇幅介紹如何進行"共通性/相異性"的分析與設計,是很好的參考資料。
(練習題:如果想要將"元素是位於陣列中"這個假設從for_each去除,需要如何修改for_each的實作?提示:引入iterator以隔離資料結構與演算法")
第二種抽象化loop的手法則是透過macro。手法如下:
1. 可以放在.c中。我們這邊處理的是for loop的化簡,但如果你要提供的API是不想公開內部實作的話,放在.c中,然後開出可改變的部份給使用者定義,才是比較好的選擇。
2. 可以避免複雜的macro實作。這邊只實作了for loop,但其實該macro實作是有一些問題的(練習題:什麼情況下會有問題?),而更複雜的API如果用macro實作,在除錯上會較為不易,畢竟大部份的debugger都無法觀察preprocess時的錯誤。
3. macro會使得程式碼膨脹。一旦程式碼膨脹,造成cache miss增加,很可能將callback的開銷打平,並且如果產品是用在小型的嵌入式系統,大量的macro很可能造成需要更大的flash size,增加...嗯,製造成本。
為了讓for loop可以變得通用,所以需要付出一點代價,這個代價不只出現在代碼的效能損失,也出現在觀察共通概念與流程的時間,也出現在使用何種手法抽取出共通程式碼的設計上(當然,這個例子對絕大多數的程式設計師都微乎其微,而且可以藉由學習以縮短分析與設計時間),這些代價在開發時是否值得?端看情況而定。尤其當愈複雜的概念與流程要變得通用時,付出的代價會更高(試試看讓binary search tree的操作通用化),所以這時越要思考是否划算。概念或程式碼有所重複不總是罪惡,有時僅僅是工程上的設計選擇(不過我們要先知道有哪些選擇,對吧?)。
當然啦,很多時候我們都是在開發了一段時間後,發現了一些共同的流程、概念,然後才進行整理,而無法在事前預知。雖然loop的抽象化手法在coding上是很簡單的一件事情,但唯有當我們了解不同的手法的優缺點後,面對我們自身更複雜的應用時才有可能作出有把握的選擇。Keep walking!!
首先,先來看看C語言中最基本常用的loop - for loop:
- #include <stdio.h>
- void foo(void)
- {
- printf("do something\n");
- }
- int main(void)
- {
- int i = 0;
- for (i = 0; i < 10 ; ++i) {
- foo();
- }
- }
- #include <stdio.h>
- int main(void)
- {
- int i = 0;
- int max = 0;
- int arr[] = {8, -9, 10, 11, 99, 100, 23};
- max = arr[0];
- for (i = 1; i < sizeof(arr)/sizeof(arr[0]) ; ++i) {
- if (max < arr[i]) { max = arr[i]; }
- }
- printf("max = %d\n", max);
- }
- int main(void)
- {
- int i = 0;
- int arr[] = {8, -9, 10, 11, 99, 100, 23};
- for (i = 0; i < sizeof(arr)/sizeof(arr[0]) ; ++i) {
- arr[i] += 2;
- }
- }
for each element in some container:
do something with the element
但是C語言內建的for loop卻沒有提供some container與do something with the element的概念,所以一旦這兩個東西發生了變化,程式設計師就必須再手工寫一遍for loop。所以問題轉變為:要如何提供一個機制,讓程式設計師即使改變了資料結構或對每一個結構中的元素的處理方式時,仍能保持for loop不需更改?
讓我們先從簡單的開始:當元素都只在陣列中,但對元素的處理方式不同,要如何不改變for loop呢?在C語言中,慣用的手法有兩種,第一種是透過callback function:
- #include <stdio.h>
- typedef void (*callback)(void *e, void *priv);
- void for_each(void *arr, int e_size, int arr_size, callback do_something, void *priv)
- {
- int i = 0;
- char *arr_imp = arr;
- for (;i < arr_size; ++i) {
- do_something((arr_imp + (e_size*i)), priv);
- }
- }
- void swap_max(void *e, void *priv)
- {
- int *max = priv;
- int *ele = e;
- if (*max < *ele) { *max = *ele; }
- }
- void plus_two(void *e, void *priv)
- {
- *(int *)e += 2;
- }
- void print(void *e, void *priv)
- {
- printf("%d ", *(int *)e);
- }
- int main(void)
- {
- int arr[] = {8, -9, 10, 11, 99, 100, 23};
- int max = 0;
- // find max
- max = arr[0];
- for_each(arr, sizeof(arr[0]), sizeof(arr)/sizeof(arr[0]), swap_max, &max);
- printf("max = %d\n", max);
- // plus 2 to every element
- for_each(arr, sizeof(arr[0]), sizeof(arr)/sizeof(arr[0]), print, NULL);
- printf("\n");
- for_each(arr, sizeof(arr[0]), sizeof(arr)/sizeof(arr[0]), plus_two, NULL);
- for_each(arr, sizeof(arr[0]), sizeof(arr)/sizeof(arr[0]), print, NULL);
- printf("\n");
- }
typedef void (*callback)(void *e, void *priv);
void *e參數是for_each給callback的,而void *priv則是使用者自己的資訊,可以在for_each運行時改變狀態以進行不同判斷。這種設計方式給了我們設計介面時的一個方向:將不變的抽取出來,將會易變的提供出介面供外部使用者修改。如果這件事可以確實作的好,自然就會符合所謂的模組化設計。著名軟體開發先驅Jim Coplien在其博士論文中花了很大篇幅介紹如何進行"共通性/相異性"的分析與設計,是很好的參考資料。
(練習題:如果想要將"元素是位於陣列中"這個假設從for_each去除,需要如何修改for_each的實作?提示:引入iterator以隔離資料結構與演算法")
第二種抽象化loop的手法則是透過macro。手法如下:
- #include <stdio.h>
- #define for_each(cur, arr, size) cur = &arr[0]; for (int i = 0; i < size ; ++i, cur = &arr[i])
- int main(void)
- {
- int arr[] = {8, -9, 10, 11, 99, 100, 23};
- int *cur = NULL;
- int max = 0;
- // find max
- max = arr[0];
- for_each(cur, arr, sizeof(arr)/sizeof(arr[0])) {
- if (max < *cur) { max = *cur; }
- }
- printf("max = %d\n", max);
- // plus two to every element
- for_each(cur, arr, sizeof(arr)/sizeof(arr[0])) {
- printf("%d ", *cur);
- }
- printf("\n");
- for_each(cur, arr, sizeof(arr)/sizeof(arr[0])) {
- *cur += 2;
- }
- for_each(cur, arr, sizeof(arr)/sizeof(arr[0])) {
- printf("%d ", *cur);
- }
- printf("\n");
- }
1. 可以放在.c中。我們這邊處理的是for loop的化簡,但如果你要提供的API是不想公開內部實作的話,放在.c中,然後開出可改變的部份給使用者定義,才是比較好的選擇。
2. 可以避免複雜的macro實作。這邊只實作了for loop,但其實該macro實作是有一些問題的(練習題:什麼情況下會有問題?),而更複雜的API如果用macro實作,在除錯上會較為不易,畢竟大部份的debugger都無法觀察preprocess時的錯誤。
3. macro會使得程式碼膨脹。一旦程式碼膨脹,造成cache miss增加,很可能將callback的開銷打平,並且如果產品是用在小型的嵌入式系統,大量的macro很可能造成需要更大的flash size,增加...嗯,製造成本。
為了讓for loop可以變得通用,所以需要付出一點代價,這個代價不只出現在代碼的效能損失,也出現在觀察共通概念與流程的時間,也出現在使用何種手法抽取出共通程式碼的設計上(當然,這個例子對絕大多數的程式設計師都微乎其微,而且可以藉由學習以縮短分析與設計時間),這些代價在開發時是否值得?端看情況而定。尤其當愈複雜的概念與流程要變得通用時,付出的代價會更高(試試看讓binary search tree的操作通用化),所以這時越要思考是否划算。概念或程式碼有所重複不總是罪惡,有時僅僅是工程上的設計選擇(不過我們要先知道有哪些選擇,對吧?)。
當然啦,很多時候我們都是在開發了一段時間後,發現了一些共同的流程、概念,然後才進行整理,而無法在事前預知。雖然loop的抽象化手法在coding上是很簡單的一件事情,但唯有當我們了解不同的手法的優缺點後,面對我們自身更複雜的應用時才有可能作出有把握的選擇。Keep walking!!
留言
張貼留言