17 | 函數語言程式設計:一種越來越流行的程式設計正規化
上一講我們初步介紹了函式物件和 lambda 表示式,今天我們來講講它們的主要用途——函數語言程式設計
一個小例子
如果給定一組檔名,要求數一下檔案裡的總文字行數,你會怎麼做?
函式的原型: 我們期待接受兩個 C 字串的迭代器,用來遍歷所有的檔名;返回值代表檔案中的總行數。
int count_lines(const char** begin,
const char** end);
要測試行為是否正常,我們需要一個很小的 main 函式:
int main(int argc, const char** argv) { int total_lines = count_lines( argv + 1, argv + argc); cout << "Total lines: " << total_lines << endl; }
雛形
最傳統的指令式程式設計大概會這樣寫程式碼:
int count_file(const char* name) { int count = 0; ifstream ifs(name); string line; for (;;) { getline(ifs, line); if (!ifs) { break; } ++count; } return count; } int count_lines(const char** begin, const char** end) { int count = 0; for (; begin != end; ++begin) { count += count_file(*begin); } return count; }
簡化一
int count_file(const char* name)
{
int count = 0;
ifstream ifs(name);
for (auto&& line :
istream_line_reader(ifs)) {
++count;
}
return count;
}
簡化二
使用之前已經出場過的兩個函式,transform和 accumulate,程式碼可以進一步簡化為:
int count_file(const char* name) { ifstream ifs(name); istream_line_reader reader(ifs); return distance(reader.begin(), reader.end()); } int count_lines(const char** begin, const char** end) { vector<int> count(end - begin); transform(begin, end,count.begin(),count_file); return accumulate(count.begin(), count.end(),0); }
這個就是一個非常函式式風格的結果了。上面這個處理方式恰恰就是 map-reduce。transform 對應 map,accumulate 對應 reduce。而檢查有多少行文字,也成了代表檔案頭尾兩個迭代器之間的“距離”(distance)。
函數語言程式設計的特點
函數語言程式設計期望函式的行為像數學上的函式,而非一個計算機上的子程式。這樣的函式一般被稱為純函式(pure function),要點在於:
- 會影響函式結果的只是函式的引數,沒有對環境的依賴
- 返回的結果就是函式執行的唯一後果,不產生對環境的其他影響
這樣的程式碼的最大好處是易於理解和易於推理,在很多情況下也會使程式碼更簡單。在我們上面的程式碼裡,count_file 和 accumulate 基本上可以看做是純函式(雖然前者實際上有著對檔案系統的依賴),但 transform 不行,因為它改變了某個引數,而不是返回一個結果。下一講我們會看到,這會影響程式碼的組合性。
我們的程式碼中也體現了其他一些函數語言程式設計的特點:
- 函式就像普通的物件一樣被傳遞、使用和返回。
- 程式碼為說明式而非命令式。在熟悉函數語言程式設計的基本正規化後,你會發現說明式程式碼的可讀性通常比命令式要高,程式碼還短。
- 一般不鼓勵(甚至完全不使用)可變數。上面程式碼裡只有 count 的內容在執行過程中被修改了,而且這種修改實際是 transform 介面帶來的。如果介面像[第 13 講] 展示的 fmap 函式一樣返回一個容器的話,就可以連這個問題都消除了。(C++ 畢竟不是一門函數語言程式設計語言,對靈活性的追求壓倒了其他考慮。)
高階函式
既然函式(物件)可以被傳遞、使用和返回,自然就有函式會接受函式作為引數或者把函式作為返回值,這樣的函式就被稱為高階函式。我們現在已經見過不少高階函數了,如:
- sort
- transform
- accumulate
- fmap
- adder
事實上,C++ 裡以 algorithm(演算法) 名義提供的很多函式都是高階函式。
許多高階函式在函數語言程式設計中已成為基本的慣用法,在不同語言中都會出現,雖然可能是以不同的名字。我們在此介紹非常常見的三個,map(對映)、reduce(歸併)和 filter(過濾)。
- Map 在 C++ 中的直接對映是 transform(在
標頭檔案中提供) 。它所做的事情也是數學上的對映,把一個範圍裡的物件轉換成相同數量的另外一些物件。這個函式的基本實現非常簡單,但這是一種強大的抽象,在很多場合都用得上。 - Reduce 在 C++ 中的直接對映是 accumulate(在
標頭檔案中提供) 。它的功能是在指定的範圍裡,**使用給定的初值和函式物件,從左到右對數值進行歸併*。在不提供函式物件作為第四個引數時,功能上相當於預設提供了加法函式物件,這時相當於做累加;提供了其他函式物件時,那當然就是使用該函式物件進行歸併了。 - Filter 的功能是進行過濾,篩選出符合條件的成員。它在當前 C++(C++20 之前)裡的對映可以認為有兩個:copy_if 和 partition。這是因為在 C++20 帶來 ranges 之前,在 C++ 裡實現惰性求值不太方便。上面說的兩個函式裡,copy_if 是把滿足條件的元素拷貝到另外一個迭代器裡;partition 則是根據過濾條件來對範圍裡的元素進行分組,把滿足條件的放在返回值迭代器的前面。另外,remove_if 也有點相近,通常用於刪除滿足條件的元素。它確保把不滿足條件的元素放在返回值迭代器的前面(但不保證滿足條件的元素在函式返回後一定存在),然後你一般需要使用容器的 erase 成員函式來將待刪除的元素真正刪除。
指令式程式設計和說明式程式設計
傳統上 C++ 屬於指令式程式設計。指令式程式設計裡,程式碼會描述程式的具體執行步驟。好處是程式碼顯得比較直截了當;缺點就是容易讓人只見樹木、不見森林,只能看到程式碼囉嗦地怎麼做(how),而不是做什麼(what),更不用說為什麼(why)了。
說明式程式設計則相反。以資料庫查詢語言 SQL 為例,SQL 描述的是類似於下面的操作:你想從什麼地方(from)選擇(select)滿足什麼條件(where)的什麼資料,並可選指定排序(order by)或分組(group by)條件。你不需要告訴資料庫引擎具體該如何去執行這個操作。事實上,在選擇查詢策略上,大部分資料庫使用者都不及資料庫引擎“聰明”;正如大部分開發者在寫出優化彙編程式碼上也不及編譯器聰明一樣。
這並不是說說明式程式設計一定就優於指令式程式設計。事實上,對於很多演算法,命令式才是最自然的實現。以快速排序為例,很多地方在講到函數語言程式設計時會給出下面這個 Haskell(一種純函式式的程式語言)的例子來說明函數語言程式設計的簡潔性:
quicksort [] = []
quicksort (p:xs) = (quicksort left)
++ [p] ++ (quicksort right)
where
left = filter (< p) xs
right = filter (>= p) xs
這段程式碼簡潔性確實沒話說,但問題是,上面的程式碼的效能其實非常糟糕。真正接近 C++ 效能的快速排序,在 Haskell 裡寫出來一點不優雅,反而更醜陋。
說明式程式設計跟指令式程式設計可以結合起來產生既優雅又高效的程式碼。對於從指令式程式設計成長起來的大部分程式設計師,我的建議是:
- 寫表意的程式碼,不要過於專注效能而讓程式碼難以維護——記住高德納的名言:“過早優化是萬惡之源。”
- 使用有意義的變數,但儘量不要去修改變數內容——變數的修改非常容易導致程式設計師的思維錯誤。
- 類似地,儘量使用沒有副作用的函式,並讓你寫的程式碼也儘量沒有副作用,用返回值來代表狀態的變化——沒有副作用的程式碼更容易推理,更不容易出錯。
- 程式碼的隱式依賴越少越好,尤其是不要使用全域性變數——隱式依賴會讓程式碼裡的錯誤難以排查,也會讓程式碼更難以測試。
- 使用知名的高階程式設計結構,如基於範圍的 for 迴圈、對映、歸併、過濾——這可以讓你的程式碼更簡潔,更易於推理,並減少類似下標越界這種低階錯誤的可能性。
不可變性和併發
在多核的時代裡,函數語言程式設計比以前更受青睞,一個重要的原因是函數語言程式設計對並行併發天然友好。影響多核效能的一個重要因素是資料的競爭條件——由於共享記憶體資料需要加鎖帶來的延遲。函數語言程式設計強調不可變性(immutability)、無副作用,天然就適合併發。更妙的是,如果你使用高層抽象的話,有時可以輕輕鬆鬆“免費”得到效能提升。
#include <chrono>
#include <execution>
#include <iostream>
#include <numeric>
#include <vector>
using namespace std;
int main()
{
vector<double> v(10000000, 0.0625);
{
auto t1 = chrono::high_resolution_clock::now();
double result = accumulate(v.begin(), v.end(), 0.0);
auto t2 = chrono::high_resolution_clock::now();
chrono::duration<double, milli>ms = t2 - t1;
cout << "accumulate: result "
<< result << " took "
<< ms.count() << " ms\n";
}
{
auto t1 = chrono::high_resolution_clock::now();
double result =reduce(execution::par,v.begin(), v.end()); //和上面唯一不同一行程式碼!
auto t2 = chrono::high_resolution_clock::now();
chrono::duration<double, milli>ms = t2 - t1;
cout << "reduce: result "
<< result << " took "
<< ms.count() << " ms\n";
}
}
Y 組合子
在 C++ 中的實用性非常弱。我們只看它解決的問題:如何在 lambda 表示式中表現遞迴。
回想一下我們用過的階乘的遞迴定義:
int factorial(int n)
{
if (n == 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}
注意裡面用到了遞迴,所以你要把它寫成 lambda 表示式是有點困難的:
auto factorial = [](int n) {
if (n == 0) {
return 1;
} else {
return n * ???(n - 1); //注意這個地方函式名在哪裡呢。我們想應該得在()中傳進來吧!對沒錯,下面有個Y組合子來做這件事
}
}
下面是完整的程式碼實現:
#include <functional>
#include <iostream>
#include <type_traits>
#include <utility>
using namespace std;
// Y combinator as presented by Yegor Derevenets in P0200R0
// <url:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0200r0.html>
template <class Fun>
class y_combinator_result {
Fun fun_;
public:
template <class T>
explicit y_combinator_result(T&& fun)
: fun_(std::forward<T>(fun))
{
}
template <class... Args>
decltype(auto) operator()(Args&&... args)
{
// y(f) = f(y(f))
return fun_(std::ref(*this),std::forward<Args>(args)...);
}
};
template <class Fun>
decltype(auto) y_combinator(Fun&& fun)
{
return y_combinator_result<std::decay_t<Fun>>( //傳入函式物件型別和函式物件本身
std::forward<Fun>(fun));
}
int main()
{
// 上面的那個 F
auto almost_fact =[](auto f, int n) -> int {
if (n == 0)
return 1;
else
return n * f(n - 1);
};
// fact = y(F)
auto fact = y_combinator(almost_fact);
cout << fact(10) << endl;
}
注意大家不要被這個東西嚇住了。它是一個不會變的死東西。我們同樣可以寫一個求斐波那契的lambda來使用它
auto abc = [](auto f, int a)->int {
if (a == 1 || a == 2)
return 1;
else
return f(a - 1)+f(a - 2);
};
auto fact1 = y_combinator(abc);
cout<<fact1(4)<<endl;
同樣是可以工作的!