現代C++函數式編程


現代C++函數式編程


導讀: 本文作者從介紹函數式編程的概念入手,分析了函數式編程的表現形式和特性,最終通過現代C++的新特性以及一些模板雲技巧實現了一個非常靈活的pipeline,展示了現代C++實現函數式編程的方法和技巧,同時也體現了現代C++的強大威力和無限可能。


1

概述


函數式編程是一種編程範式,它有下面的一些特徵:


  • 函數是一等公民,可以像數據一樣傳來傳去
  • 高階函數
  • 遞歸
  • pipeline
  • 惰性求值
  • 柯里化
  • 偏應用函數


C++98/03中的函數對象,和C++11中的Lambda表達式、std::function和std::bind讓C++的函數式編程變得容易。我們可以利用C++11/14裡的新特性來實現高階函數、鏈式調用、惰性求值和柯理化等函數式編程特性。本文將通過一些典型示例來講解如何使用現代C++來實現函數式編程。


2

高階函數和pipeline的表現形式


高階函數就是參數為函數或返回值為函數的函數,經典的高階函數就是map、filter、fold和compose函數,比如Scala中高階函數:


  • map


numbers.map((i: Int) => i * 2)


對列表中的每個元素應用一個函數,返回應用後的元素所組成的列表。


  • filter


numbers.filter((i: Int) => i % 2 == 0)


移除任何對傳入函數計算結果為false的元素。


  • fold


numbers.fold(0) { (z, i) =>

a + i

}


將一個初始值和一個二元函數的結果累加起來。


  • compose


val fComposeG = f _ compose g _

fComposeG("x")


組合其它函數形成一個新函數f(g(x))。


上面的例子中,有的是參數為函數,有的是參數和返回值都是函數。高階函數不僅語義上更加抽象泛化,還能實現“函數是一等公民”,將函數像data一樣傳來傳去或者組合,非常靈活。其中,compose還可以實現惰性求值,compose的返回結果是一個函數,我們可以保存起來,在後面需要的時候調用。


pipeline把一組函數放到一個數組或是列表中,然後把數據傳給這個列表。數據就像一個鏈條一樣順序地被各個函數所操作,最終得到我們想要的結果。它的設計哲學就是讓每個功能就做一件事,並把這件事做到極致,軟件或程序的拼裝會變得更為簡單和直觀。

Scala中的鏈式調用是這樣的:


s(x) = (1 to x) |> filter (x => x % 2 == 0) |> map (x => x * 2)


用法和Unix Shell的管道操作比較像,|前面的數據或函數作為|後面函數的輸入,順序執行直到最後一個函數。


這種管道方式的函數調用讓邏輯看起來更加清晰明瞭,也非常靈活,允許你將多個高階函數自由組合成一個鏈條,同時還可以保存起來實現惰性求值。現代C++實現這種pipeline也是比較容易的,下面來講解如何充分藉助C++11/14的新特性來實現這些高階函數和pipeline。


3

實現pipeline的關鍵技術


根據前面介紹的pipeline表現形式,可以把pipeline分解為幾部分:高階函數,惰性求值,運算符|、柯里化和pipeline,把這幾部分實現之後就可以組成一個完整的pipeline了。下面來分別介紹它們的實現技術。


➤高階函數


函數式編程的核心就是函數,它是一等公民,最靈活的函數就是高階函數,現代C++的算法中已經有很多高階函數了,比如for_each, transform:


std::vector vec{1,2,3,4,5,6,7,8,9}

//接受一個打印的Lambda表達式

std::for_each(vec.begin(), vec.end(), [](auto i){ std::cout<

//接受一個轉換的Lambda表達式

transform(vec.begin(), vec.end(), vec.begin(), [](int i){ return i*i; });


這些高階函數不僅可以接受Lambda表達式,還能接受std::function、函數對象、普通的全局函數,很靈活。需要注意的是,普通的全局函數在pipeline時存在侷限性,因為它不像函數對象一樣可以保存起來延遲調用,所以我們需要一個方法將普通的函數轉換為函數對象。std::bind也可以將函數轉化為函數對象,但是bind不夠通用,使用的時候它只能綁定有限的參數,如果函數本身就是可變參數的就無法bind了,所以,這個函數對象必須是泛化的,類似於這樣:


class universal_functor

{

public:

template auto operator()(Args&&... args) const ->decltype(globle_func(std::forward(args)...))

{

return globle_func(std::forward(args)...);

}

};


上面的函數對象內部包裝了一個普通函數的調用,當函數調用的時候實際上會調用普通函數globle_func,但是這個代碼不通用,它無法用於其他的函數。為了讓這個轉換變得通用,我們可以藉助一個宏來實現function到functor的轉換。


#define define_functor_type(func_name) class tfn_##func_name {\

public: template auto operator()(Args&&... args) const ->decltype(func_name(std::forward(args)...))\

{ return func_name(std::forward(args)...); } }


//test code

int add(int a, int b)

{

return a + b;

}

int add_one(int a)

{

return 1 + a;

}


define_functor_type(add);

define_functor_type(add_one);


int main()

{

tnf_add add_functor;

add_functor(1, 2); //result is 3


tfn_add_one add_one_functor;

add_one_functor(1); //result is 2


return 0;

}


我們先定義了一個宏,這個宏根據參數來生成一個可變參數的函數對象,這個函數對象的類型名為tfn_加普通函數的函數名,之所以要加一個前綴tfn_,是為了避免類型名和函數名重名。define_functor_type宏只是定義了一個函數對象的類型,用起來略感不便,還可以再簡化一下,讓使用更方便。我們可以再定義一個宏來生成轉換後的函數對象:


#define make_globle_functor(NAME, F) const auto NAME = define_functor_type(F);

//test code

make_globle_functor(fn_add, add);

make_globle_functor(fn_add_one, add_one);


int main()

{

fn_add(1, 2);

fn_add_one(1);


return 0;

}


make_globle_functor生成了一個可以直接使用的全局函數對象,使用起來更方便了。用這個方法就可以將普通函數轉成pipeline中的函數對象了。接下來我們來探討實現惰性求值的關鍵技術。


➤惰性求值


惰性求值是將求值運算延遲到需要值時候進行,通常的做法是將函數或函數的參數保存起來,在需要的時候才調用函數或者將保存的參數傳入函數實現調用。現代C++裡已經提供可以保存起來的函數對象和lambda表達式,因此需要解決的問題是如何將參數保存起來,然後在需要的時候傳給函數實現調用。我們可以藉助std::tuple、type_traits和可變模版參數來實現目標。


template

inline auto tuple_apply_impl(const F& f, const std::index_sequence&, const std::tuple& tp)

{

return f(std::get(tp)...);

}


template

inline auto tuple_apply(const F& f, const std::tuple& tp) -> decltype(f(std::declval()...))

{

return tuple_apply_impl(f, std::make_index_sequence{}, tp);

}


int main()

{

//test code

auto f = [](int x, int y, int z) { return x + y - z; };

//將函數調用需要的參數保存到tuple中

auto params = make_tuple(1, 2, 3);


//將保存的參數傳給函數f,實現函數調用

auto result = tuple_apply(f, params); //result is 0


return 0;

}


上面的測試代碼中,我們先把參數保存到一個tuple中,然後在需要的時候將參數和函數f傳入tuple_apply,最終實現了f函數的調用。tuple_apply實現了一個“魔法”將tuple變成了函數的參數,來看看這個“魔法”具體是怎麼實現的。


tuple_apply_impl實現的關鍵是在於可變模版參數的展開,可變模版參數的展開又藉助了std::index_sequence


➤運算符operator|


pipeline的一個主要表現形式是通過運算符|來將data和函數分隔開或者將函數和函數組成一個鏈條,比如像下面的unix shell命令:


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


C++實現類似的調用可以通過重載運算符來實現,下面是data和函數通過|連接的實現代碼:


template

auto operator|(T&& param, const F& f) -> decltype(f(std::forward(param)))

{

return f(std::forward(param));

}

//test code

auto add_one = [](auto a) { return 1 + a; };

auto result = 2 | add_one; //result is 3


除了data和函數通過|連接之外,還需要實現函數和函數通過|連接,我們通過可變參數來實現:


template

inline auto operator|(fn_chain && chain, F&& f)

{

return chain.add(std::forward(f));

}


//test code

auto chain = fn_chain<>() | (filter >> [](auto i) { return i % 2 == 0; }) | ucount | uprint;


其中fn_chain是一個可以接受任意個函數的函數對象,它的實現將在後面介紹。通過|運算符重載我們可以實現類似於unix shell的pipeline表現形式。


➤柯里化


函數式編程中比較靈活的一個地方就是柯里化(currying),柯里化是把多個參數的函數變換成單參數的函數,並返回一個新函數,這個新函數處理剩下的參數。以Scala的柯里化為例:


  • 未柯里化的函數


def add(x:Int, y:Int) = x + y


add(1, 2) // 3

add(7, 3) // 10


  • 柯里化之後


def add(x:Int) = (y:Int) => x + y


add(1)(2) // 3

add(7)(3) // 10


currying之後add(1)(2)等價於add(1,2),這種currying默認是從左到右的,如果希望從右到左呢,然而大部分編程語言沒有實現更靈活的curring。C++11裡面的std::bind可以實現currying,但要實現向左或向右靈活的currying比較困難,可以藉助tuple和前面介紹的tuple_apply來實現一個更靈活的currying函數對象。


template, typename After = std::tuple<>>

class curry_functor {

private:

F f_; ///< main functor

Before before_; ///< curryed arguments

After after_; ///< curryed arguments


public:


curry_functor(F && f) : f_(std::forward(f)), before_(std::tuple<>()), after_(std::tuple<>()) {}

curry_functor(const F & f, const Before & before, const After & after) : f_(f), before_(before), after_(after) {}


template

auto operator()(Args... args) const -> decltype(tuple_apply(f_, std::tuple_cat(before_, make_tuple(args...), after_)))

{

// execute via tuple

return tuple_apply(f_, std::tuple_cat(before_, make_tuple(std::forward(args)...), after_));

}


// currying from left to right

template

auto curry_before(T && param) const

{

using RealBefore = decltype(std::tuple_cat(before_, std::make_tuple(param)));

return curry_functor(f_, std::tuple_cat(before_, std::make_tuple(std::forward(param))), after_);

}

// currying from righ to left

template

auto curry_after(T && param) const

{

using RealAfter = decltype(std::tuple_cat(after_, std::make_tuple(param)));

return curry_functor(f_, before_, std::tuple_cat(after_, std::make_tuple(std::forward(param))));

}

};


template

auto fn_to_curry_functor(F && f)

{

return curry_functor(std::forward(f));

}


//test code

void test_count()

{

auto f = [](int x, int y, int z) { return x + y - z; };

auto fn = fn_to_curry_functor(f);


auto result = fn.curry_before(1)(2, 3); //0

result = fn.curry_before(1).curry_before(2)(3); //0

result = fn.curry_before(1).curry_before(2).curry_before(3)(); //0


result = fn.curry_before(1).curry_after(2).curry_before(3)(); //2

result = fn.curry_after(1).curry_after(2).curry_before(2)(); //1

}


從測試代碼中可以看到這個currying函數對象,既可以從左邊currying又可以從右邊currying,非常靈活。不過使用上還不太方便,沒有fn(1)(2)(3)這樣方便,我們可以通過運算符重載來簡化書寫,由於C++標準中不允許重載全局的operater()符,並且operater()符無法區分到底是從左邊還是從右邊currying,所以我們選擇重載<>操作符來分別表示從左至右currying和從右至左currying。


// currying from left to right

template

auto operator< decltype(f.template curry_before(std::forward(arg)))

{

return f.template curry_before(std::forward(arg));

}


// currying from right to left

template

auto operator>>(const UF & f, Arg && arg) -> decltype(f.template curry_after(std::forward(arg)))

{

return f.template curry_after(std::forward(arg));

}


有了這兩個重載運算符,測試代碼可以寫得更簡潔了。


void test_currying()

{

auto f = [](int x, int y, int z) { return x + y - z; };

auto fn = fn_to_curry_functor(f);


auto result = (fn << 1)(2, 3); //0

result = (fn << 1 << 2)(3); //0

result = (fn << 1 << 2 << 3)(); //0


result = (fn << 1 >> 2 << 3)(); //2

result = (fn >> 1 >> 2 << 3)(); //1

}


curry_functor利用了tuple的特性,內部有兩個空的tuple,一個用來保存left currying的參數,一個用來保存right currying的參數,不斷地currying時,通過tuple_cat把新currying的參數保存到tuple中,最後調用的時候將tuple成員和參數組成一個最終的tuple,然後通過tuple_apply實現調用。有了前面這些基礎設施之後我們實現pipeline也是水到渠成。


➤pipeline


通過運算符|重載,我們可以實現一個簡單的pipeline:


template

auto operator|(T&& param, const F& f) -> decltype(f(std::forward(param)))

{

return f(std::forward(param));

}

//test code

void test_pipe()

{

auto f1 = [](int x) { return x + 3; };

auto f2 = [](int x) { return x * 2; };

auto f3 = [](int x) { return (double)x / 2.0; };

auto f4 = [](double x) { std::stringstream ss; ss << x; return ss.str(); };

auto f5 = [](string s) { return "Result: " + s; };

auto result = 2|f1|f2|f3|f4|f5; //Result: 5

}


這個簡單的pipeline雖然可以實現管道方式的鏈式計算,但是它只是將data和函數通過|連接起來了,還沒有實現函數和函數的連接,並且是立即計算的,沒有實現延遲計算。因此我們還需要實現通過|連接函數,從而實現靈活的pipeline。我們可以通過一個function chain來接受任意個函數並組成一個函數鏈。利用可變模版參數、tuple和type_traits就可以實現了。


template

class fn_chain {

private:

const std::tuple functions_;

const static size_t TUPLE_SIZE = sizeof...(FNs);


template

auto call_impl(Arg&& arg, const std::index_sequence&) const ->decltype(std::get(functions_)(std::forward(arg)))

{

return std::get(functions_)(std::forward(arg));

}


template

auto call_impl(Arg&& arg, const std::index_sequence&) const ->decltype(call_impl(std::get(functions_)(std::forward(arg)), std::index_sequence{}))

{

return call_impl(std::get(functions_)(std::forward(arg)), std::index_sequence{});

}


template

auto call(Arg&& arg) const-> decltype(call_impl(std::forward(arg), std::make_index_sequence{}))

{

return call_impl(std::forward(arg), std::make_index_sequence{});

}


public:

fn_chain() : functions_(std::tuple<>()) {}

fn_chain(std::tuple functions) : functions_(functions) {}


// add function into chain

template< typename F >

inline auto add(const F& f) const

{

return fn_chain(std::tuple_cat(functions_, std::make_tuple(f)));

}


// call whole functional chain

template

inline auto operator()(Arg&& arg) const -> decltype(call(std::forward(arg)))

{

return call(std::forward(arg));

}

};


// pipe function into functional chain via | operator

template

inline auto operator|(fn_chain && chain, F&& f)

{

return chain.add(std::forward(f));

}


#define tfn_chain fn_chain<>()


//test code

void test_pipe()

{

auto f1 = [](int x) { return x + 3; };

auto f2 = [](int x) { return x * 2; };

auto f3 = [](int x) { return (double)x / 2.0; };

auto f4 = [](double x) { std::stringstream ss; ss << x; return ss.str(); };

auto f5 = [](string s) { return "Result: " + s; };

auto compose_fn = tfn_chain|f1|f2|f3|f4|f5; //compose a chain

compose_fn(2); // Result: 5

}


測試代碼中用一個fn_chain和運算符|將所有的函數組合成了一個函數鏈,在需要的時候調用,從而實現了惰性求值。


fn_chain的實現思路是這樣的:內部有一個std::tuple


template

auto call_impl(Arg&& arg, const std::index_sequence&) const ->decltype(std::get(functions_)(std::forward(arg)))

{

return std::get(functions_)(std::forward(arg));

}


template

auto call_impl(Arg&& arg, const std::index_sequence&) const ->decltype(call_impl(std::get(functions_)(std::forward(arg)), std::index_sequence{}))

{

return call_impl(std::get(functions_)(std::forward(arg)), std::index_sequence{});

}


在調用call_impl的過程中,將std::index_sequence不斷展開,先從tuple中獲取第I個function,然後調用獲得第I個function的執行結果,將這個執行結果作為下次調用的參數,不斷地遞歸調用,直到最後一個函數完成調用為止,返回最終的鏈式調用的結果。


至此我們實現具備惰性求值、高階函數和currying特性的完整的pipeline,有了這個pipeline,我們可以實現經典的流式計算和AOP,接下來我們來看看如何利用pipeline來實現流式的mapreduce和靈活的AOP。


4

實現一個pipeline形式的mapreduce和AOP


前面的pipeline已經可以實現鏈式調用了,要實現pipeline形式的mapreduce關鍵就是實現map、filter和reduce等高階函數。下面是它們的具體實現:


// MAP

template class C, typename F>

auto fn_map(const C& container, const F& f) -> C()))>

{

using resultType = decltype(f(std::declval()));

C result;

for (const auto& item : container)

result.push_back(f(item));

return result;

}


// REDUCE (FOLD)

template class C, typename F>

TResult fn_reduce(const C& container, const TResult& startValue, const F& f)

{

TResult result = startValue;

for (const auto& item : container)

result = f(result, item);

return result;

}


// FILTER

template class C, typename F>

C fn_filter(const C& container, const F& f)

{

C result;

for (const auto& item : container)

if (f(item))

result.push_back(item);

return result;

}


這些高階函數還需要轉換成支持currying的functor,前面我們已經定義了一個普通的函數對象轉換為柯里化的函數對象的方法:


template

auto fn_to_curry_functor(F && f)

{

return curry_functor(std::forward(f));

}


通過下面這個宏讓currying functor用起來更簡潔:


#define make_globle_curry_functor(NAME, F) define_functor_type(F); const auto NAME = fn_to_curry_functor(tfn_##F());


make_globle_curry_functor(map, fn_map);

make_globle_curry_functor(reduce, fn_reduce);

make_globle_curry_functor(filter, fn_filter);


我們定義了map、reduce和filter支持柯里化的三個全局函數對象,接下來我們就可以把它們組成一個pipeline了。


void test_pipe()

{

//test map reduce

vector slist = { "one", "two", "three" };


slist | (map >> [](auto s) { return s.size(); })

| (reduce >> 0 >> [](auto a, auto b) { return a + b; })

| [](auto a) { cout << a << endl; };


//test chain, lazy eval

auto chain = tfn_chain | (map >> [](auto s) { return s.size(); })

| (reduce >> 0 >> [](auto a, auto b) { return a + b; })

| ([](int a) { std::cout << a << std::endl; });


slist | chain;

}


上面的例子實現了pipeline的mapreduce,這個pipeline支持currying還可以任意組合,非常方便和靈活。


有了這個pipeline,實現靈活的AOP也是很容易的:


struct person

{

person get_person_by_id(int id)

{

this->id = id;

return *this;

}


int id;

std::string name;

};

void test_aop()

{

const person& p = { 20, "tom" };

auto func = std::bind(&person::get_person_by_id, &p, std::placeholders::_1);

auto aspect = tfn_chain | ([](int id) { cout << "before"; return id + 1; })

| func

| ([](const person& p) { cout << "after" << endl; });


aspect(1);

}


上面的測試例子中,核心邏輯是func函數,我們可以在func之前或之後插入切面邏輯,切面邏輯可以不斷地加到鏈條前面或者後面,實現很巧妙,使用很常靈活。


5

總結


本文通過介紹函數式編程的概念入手,分析了函數式編程的表現形式和特性,最終通過現代C++的新特性和一些模版元技巧實現了一個非常靈活的pipeline,展示了現代C++實現函數式編程的方法和技巧,同時也提現了現代C++的強大威力和無限的可能性。文中完整的代碼可以從我的GitHub(https://github.com/qicosmos/cosmos/blob/master/modern_functor.hpp)上查看。


本文的代碼和思路參考和借鑑了http://vitiy.info/templates-as-first-class-citizens-in-cpp11/,在此表示感謝。

免費C++ 入門教程:點擊下方瞭解更多


分享到:


相關文章: