函數語言程式設計的技術、理念和優點的總結

coolshell發表於2013-12-27

  當我們說起函數語言程式設計來說,我們會看到如下函數語言程式設計的長相:

  • 函數語言程式設計的三大特性:
    • immutable data 不可變資料:像Clojure一樣,預設上變數是不可變的,如果你要改變變數,你需要把變數copy出去修改。這樣一來,可以讓你的程式少很多Bug。因為,程式中的狀態不好維護,在併發的時候更不好維護。(你可以試想一下如果你的程式有個複雜的狀態,當以後別人改你程式碼的時候,是很容易出bug的,在並行中這樣的問題就更多了)
    • first class functions:這個技術可以讓你的函式就像變數一樣來使用。也就是說,你的函式可以像變數一樣被建立,修改,並當成變數一樣傳遞,返回或是在函式中巢狀函式。這個有點像Javascript的Prototype
    • 尾遞迴優化:我們知道遞迴的害處,那就是如果遞迴很深的話,stack受不了,並會導致效能大幅度下降。所以,我們使用尾遞迴優化技術——每次遞迴時都會重用stack,這樣一來能夠提升效能,當然,這需要語言或編譯器的支援。Python就不支援。
  • 函數語言程式設計的幾個技術
    • map & reduce :這個技術不用多說了,函數語言程式設計最常見的技術就是對一個集合做Map和Reduce操作。這比起過程式的語言來說,在程式碼上要更容易閱讀。(傳統過程式的語言需要使用for/while迴圈,然後在各種變數中把資料倒過來倒過去的)這個很像C++中的STL中的foreach,find_if,count_if之流的函式的玩法。
    • pipeline:這個技術的意思是,把函式例項成一個一個的action,然後,把一組action放到一個陣列或是列表中,然後把資料傳給這個action list,資料就像一個pipeline一樣順序地被各個函式所操作,最終得到我們想要的結果。
    • recursing 遞迴 :遞迴最大的好處就簡化程式碼,他可以把一個複雜的問題用很簡單的程式碼描述出來。注意:遞迴的精髓是描述問題,而這正是函數語言程式設計的精髓。
    • currying:把一個函式的多個引數分解成多個函式, 然後把函式多層封裝起來,每層函式都返回一個函式去接收下一個引數這樣,可以簡化函式的多個引數。在C++中,這個很像STL中的bind_1st或是bind2nd。
    • higher order function 高階函式:所謂高階函式就是函式當引數,把傳入的函式做一個封裝,然後返回這個封裝函式。現象上就是函式傳進傳出,就像物件導向物件滿天飛一樣。
  • 還有函式式的一些好處
    • parallelization 並行:所謂並行的意思就是在並行環境下,各個執行緒之間不需要同步或互斥。
    • lazy evaluation 惰性求值:這個需要編譯器的支援。表示式不在它被繫結到變數之後就立即求值,而是在該值被取用的時候求值,也就是說,語句如x:=expression; (把一個表示式的結果賦值給一個變數)明顯的呼叫這個表示式被計算並把結果放置到 x 中,但是先不管實際在 x 中的是什麼,直到通過後面的表示式中到 x 的引用而有了對它的值的需求的時候,而後面表示式自身的求值也可以被延遲,最終為了生成讓外界看到的某個符號而計算這個快速增長的依賴樹。
    • determinism 確定性:所謂確定性的意思就是像數學那樣 f(x) = y ,這個函式無論在什麼場景下,都會得到同樣的結果,這個我們稱之為函式的確定性。而不是像程式中的很多函式那樣,同一個引數,卻會在不同的場景下計算出不同的結果。所謂不同的場景的意思就是我們的函式會根據一些執行中的狀態資訊的不同而發生變化。

  上面的那些東西太抽象了,還是讓我們來循序漸近地看一些例子吧。

  我們先用一個最簡單的例子來說明一下什麼是函數語言程式設計。

  先看一個非函式式的例子:

int cnt;
void increment(){
    cnt++;
}

  那麼,函式式的應該怎麼寫呢?

int increment(int cnt){
    return cnt+1;
}

  你可能會覺得這個例子太普通了。是的,這個例子就是函數語言程式設計的準則:不依賴於外部的資料,而且也不改變外部資料的值,而是返回一個新的值給你

  我們再來看一個簡單例子:

def inc(x):
    def incx(y):
        return x+y
    return incx

inc2 = inc(2)
inc5 = inc(5)

print inc2(5) # 輸出 7
print inc5(5) # 輸出 10

  我們可以看到上面那個例子inc()函式返回了另一個函式incx(),於是我們可以用inc()函式來構造各種版本的inc函式,比如:inc2()和inc5()。這個技術其實就是上面所說的Currying技術。從這個技術上,你可能體會到函數語言程式設計的理念:把函式當成變數來用,關注於描述問題而不是怎麼實現,這樣可以讓程式碼更易讀。

  Map & Reduce

  在函數語言程式設計中,我們不應該用迴圈迭代的方式,我們應該用更為高階的方法,如下所示的Python程式碼

 name_len = map(len, ["hao", "chen", "coolshell"])
print name_len
# 輸出 [3, 4, 9] 

  你可以看到這樣的程式碼很易讀,因為,這樣的程式碼是在描述要幹什麼,而不是怎麼幹

  我們再來看一個Python程式碼的例子:

def toUpper(item):
      return item.upper()

upper_name = map(toUpper, ["hao", "chen", "coolshell"])
print upper_name
# 輸出 ['HAO', 'CHEN', 'COOLSHELL']

  順便說一下,上面的例子個是不是和我們的STL的transform有些像?

#include <iostream>
#include <algorithm>
#include <string>
using namespace std;

int main() {
  string s="hello";
  string out;
  transform(s.begin(), s.end(), back_inserter(out), ::toupper);
  cout << out << endl;
  // 輸出:HELLO
}

  在上面Python的那個例子中我們可以看到,我們寫義了一個函式toUpper,這個函式沒有改變傳進來的值,只是把傳進來的值做個簡單的操作,然後返回。然後,我們把其用在map函式中,就可以很清楚地描述出我們想要幹什麼。而不會去理解一個在迴圈中的怎麼實現的程式碼,最終在讀了很多迴圈的邏輯後才發現原來是這個或那個意思。 下面,我們看看描述實現方法的程式式程式設計是怎麼玩的(看上去是不是不如函式式的清晰?):

upname =['HAO', 'CHEN', 'COOLSHELL']
lowname =[] 
for i in range(len(upname)):
    lowname.append( upname[i].lower() )

  對於map我們別忘了lambda表示式:你可以簡單地理解為這是一個inline的匿名函式。下面的lambda表示式相當於:def func(x): return x*x

squares = map(lambda x: x * x, range(9))
print squares
# 輸出 [0, 1, 4, 9, 16, 25, 36, 49, 64]

  我們再來看看reduce怎麼玩?(下面的lambda表示式中有兩個引數,也就是說每次從列表中取兩個值,計算結果後把這個值再放回去,下面的表示式相當於:((((1+2)+3)+4)+5) )

print reduce(lambda x, y: x+y, [1, 2, 3, 4, 5])
# 輸出 15

  Python中的除了map和reduce外,還有一些別的如filter, find, all, any的函式做輔助(其它函式式的語言也有),可以讓你的程式碼更簡潔,更易讀。 我們再來看一個比較複雜的例子:

num =[2, -5, 9, 7, -2, 5, 3, 1, 0, -3, 8]
positive_num_cnt = 0
positive_num_sum = 0
for i in range(len(num)):
    if num[i] > 0:
        positive_num_cnt += 1
        positive_num_sum += num[i]

if positive_num_cnt > 0:
    average = positive_num_sum / positive_num_cnt

print average
# 輸出 5

  如果用函數語言程式設計,這個例子可以寫成這樣:

positive_num = filter(lambda x: x>0, num)
average = reduce(lambda x,y: x+y, positive_num) / len( positive_num )

  C++11玩的法:

#include <iostream>
#include <algorithm>
#include <numeric>
#include <string>
#include <vector>
using namespace std;

vector num {2, -5, 9, 7, -2, 5, 3, 1, 0, -3, 8};
vector p_num;
copy_if(num.begin(), num.end(), back_inserter(p_num), [](int i){ return (i>0);} );
int average = accumulate(p_num.begin(), p_num.end(), 0) / p_num.size();
cout << "averge: " << average << endl;

  我們可以看到,函數語言程式設計有如下好處:

1)程式碼更簡單了。
2)資料集,操作,返回值都放到了一起。
3)你在讀程式碼的時候,沒有了迴圈體,於是就可以少了些臨時變數,以及變數倒來倒去邏輯。
4)你的程式碼變成了在描述你要幹什麼,而不是怎麼去幹。

  最後,我們來看一下Map/Reduce這樣的函式是怎麼來實現的(下面是Javascript程式碼)

var map = function (mappingFunction, list) {
  var result = [];
  forEach(list, function (item) {
    result.push(mappingFunction(item));
  });
  return result;
};

  下面是reduce函式的javascript實現(謝謝 @下雨在家 修正的我原來的簡單版本)

function reduce(actionFunction, list, initial){
    var accumulate;
    var temp;
    if(initial){
        accumulate = initial;
    }
    else{
        accumulate = list.shfit();
    }
    temp = list.shift();
    while(temp){
        accumulate = actionFunction(accumulate,temp);
        temp = list.shift();
    }
    return accumulate;
};

  Declarative Programming vs Imperative Programming

  前面提到過多次的函數語言程式設計關注的是:describe what to do, rather than how to do it. 於是,我們把以前的過程式的程式設計正規化叫做 Imperative Programming – 指令式程式設計,而把函式式的這種正規化叫做 Declarative Programming – 宣告式程式設計。

  下面我們看一下相關的示例(本示例來自這篇文章 )。

  比如,我們有3輛車比賽,簡單起見,我們分別給這3輛車有70%的概率可以往前走一步,一共有5次機會,我們打出每一次這3輛車的前行狀態。

  對於Imperative Programming來說,程式碼如下(Python):

from random import random

time = 5
car_positions = [1, 1, 1]

while time:
    # decrease time
    time -= 1

    print ''
    for i in range(len(car_positions)):
        # move car
        if random() > 0.3:
            car_positions[i] += 1

        # draw car
        print '-' * car_positions[i]

  我們可以把這個兩重迴圈變成一些函式模組,這樣有利於我們更容易地閱讀程式碼:

from random import random

def move_cars():
    for i, _ in enumerate(car_positions):
        if random() > 0.3:
            car_positions[i] += 1

def draw_car(car_position):
    print '-' * car_position

def run_step_of_race():
    global time
    time -= 1
    move_cars()

def draw():
    print ''
    for car_position in car_positions:
        draw_car(car_position)

time = 5
car_positions = [1, 1, 1]

while time:
    run_step_of_race()
    draw()

  上面的程式碼,我們可以從主迴圈開始,我們可以很清楚地看到程式的主幹,因為我們把程式的邏輯分成了幾個函式,這樣一來,我們的程式碼邏輯也會變得幾個小碎片,於是我們讀程式碼時要考慮的上下文就少了很多,閱讀程式碼也會更容易。不像第一個示例,如果沒有註釋和說明,你還是需要花些時間理解一下。而把程式碼邏輯封裝成了函式後,我們就相當於給每個相對獨立的程式邏輯取了個名字,於是程式碼成了自解釋的

  但是,你會發現,封裝成函式後,這些函式都會依賴於共享的變數來同步其狀態。於是,我們在讀程式碼的過程時,每當我們進入到函式裡,一量讀到訪問了一個外部的變數,我們馬上要去檢視這個變數的上下文,然後還要在大腦裡推演這個變數的狀態, 我們才知道程式的真正邏輯。也就是說,這些函式間必需知道其它函式是怎麼修改它們之間的共享變數的,所以,這些函式是有狀態的

  我們知道,有狀態並不是一件很好的事情,無論是對程式碼重用,還是對程式碼的並行來說,都是有副作用的。因此,我們要想個方法把這些狀態搞掉,於是出現了我們的 Functional Programming 的程式設計正規化。下面,我們來看看函式式的方式應該怎麼寫?

from random import random

def move_cars(car_positions):
    return map(lambda x: x + 1 if random() > 0.3 else x,
               car_positions)

def output_car(car_position):
    return '-' * car_position

def run_step_of_race(state):
    return {'time': state['time'] - 1,
            'car_positions': move_cars(state['car_positions'])}

def draw(state):
    print ''
    print '\n'.join(map(output_car, state['car_positions']))

def race(state):
    draw(state)
    if state['time']:
        race(run_step_of_race(state))

race({'time': 5,
      'car_positions': [1, 1, 1]})

  上面的程式碼依然把程式的邏輯分成了函式,不過這些函式都是functional的。因為它們有三個症狀:

1)它們之間沒有共享的變數。
2)函式間通過引數和返回值來傳遞資料。
3)在函式裡沒有臨時變數。

  我們還可以看到,for迴圈被遞迴取代了(見race函式)—— 遞迴是函數語言程式設計中帶用到的技術,正如前面所說的,遞迴的本質就是描述問題是什麼。

  Pipeline

  pipeline 管道借鑑於Unix Shell的管道操作——把若干個命令串起來,前面命令的輸出成為後面命令的輸入,如此完成一個流式計算。(注:管道絕對是一個偉大的發明,他的設哲學就是KISS – 讓每個功能就做一件事,並把這件事做到極致,軟體或程式的拼裝會變得更為簡單和直觀。這個設計理念影響非常深遠,包括今天的Web Service,雲端計算,以及大資料的流式計算等等)

  比如,我們如下的shell命令:

ps auwwx | awk '{print $2}' | sort -n | xargs echo

  如果我們抽象成函式式的語言,就像下面這樣:

xargs(  echo, sort(n, awk('print $2', ps(auwwx)))  )

  也可以類似下面這個樣子:

pids = for_each(result, [ps_auwwx, awk_p2, sort_n, xargs_echo]) 

  好了,讓我們來看看函數語言程式設計的Pipeline怎麼玩?

  我們先來看一個如下的程式,這個程式的process()有三個步驟:

1)找出偶數。
2)乘以3
3)轉成字串返回

def process(num):
    # filter out non-evens
    if num % 2 != 0:
        return
    num = num * 3
    num = 'The Number: %s' % num
    return num

nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

for num in nums:
    print process(num)

# 輸出:
# None
# The Number: 6
# None
# The Number: 12
# None
# The Number: 18
# None
# The Number: 24
# None
# The Number: 30

  我們可以看到,輸出的並不夠完美,另外,程式碼閱讀上如果沒有註釋,你也會比較暈。下面,我們來看看函式式的pipeline(第一種方式)應該怎麼寫?

def even_filter(nums):
    for num in nums:
        if num % 2 == 0:
            yield num
def multiply_by_three(nums):
    for num in nums:
        yield num * 3
def convert_to_string(nums):
    for num in nums:
        yield 'The Number: %s' % num

nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
pipeline = convert_to_string(multiply_by_three(even_filter(nums)))
for num in pipeline:
    print num
# 輸出:
# The Number: 6
# The Number: 12
# The Number: 18
# The Number: 24
# The Number: 30

  我們動用了Python的關鍵字 yield,這個關鍵字主要是返回一個Generator,yield 是一個類似 return 的關鍵字,只是這個函式返回的是個Generator-生成器。所謂生成器的意思是,yield返回的是一個可迭代的物件,並沒有真正的執行函式。也就是說,只有其返回的迭代物件被真正迭代時,yield函式才會正真的執行,執行到yield語句時就會停住,然後等下一次的迭代。(這個是個比較詭異的關鍵字)這就是lazy evluation。

  好了,根據前面的原則——“使用Map & Reduce,不要使用迴圈”,那我們用比較純樸的Map & Reduce吧。

def even_filter(nums):
    return filter(lambda x: x%2==0, nums)

def multiply_by_three(nums):
    return map(lambda x: x*3, nums)

def convert_to_string(nums):
    return map(lambda x: 'The Number: %s' % x,  nums)

nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
pipeline = convert_to_string(
               multiply_by_three(
                   even_filter(nums)
               )
            )
for num in pipeline:
    print num

  但是他們的程式碼需要巢狀使用函式,這個有點不爽,如果我們能像下面這個樣子就好了(第二種方式)。

pipeline_func(nums, [even_filter,
                     multiply_by_three,
                     convert_to_string])

  那麼,pipeline_func 實現如下:

def pipeline_func(data, fns):
    return reduce(lambda a, x: x(a),
                  fns,
                  data)

  好了,在讀過這麼多的程式後,你可以回頭看一下這篇文章的開頭對函數語言程式設計的描述,可能你就更有感覺了。

  最後,我希望這篇淺顯易懂的文章能讓你感受到函數語言程式設計的思想,就像OO程式設計,泛型程式設計,程式式程式設計一樣,我們不用太糾結是不是我們的程式就是OO,就是functional的,我們重要的品味其中的味道。

相關文章