首页 > 技术文章 > 浅析C++的函数式编程

richenyunqi 2021-06-17 16:29 原文

前言

Java8在Java中通过lambda表达式、Stream API引入了函数式编程,那么C++中是否也支持函数式编程呢?答案是肯定的。目前关于C++进行函数式编程的语法探究的相关博客、文章并不多,本篇博客的目的就是阐述利用C++进行函数式编程的几种方法。
为了避免本博客篇幅过大,本博客中只涉及C++函数式编程的语法,不对函数式编程的相关思想和关键概念进行详细阐述,但本博客的最后会给出笔者认为比较好的关于函数式编程的相关参考书籍。如有问题或发现本博客中涉及的bug可以在评论区中提出,谢谢!

阅读完本博客你将会理解和掌握:

  • 函数式编程简化代码、复用已有函数、降低编码复杂度的优点
  • 函数指针的声明和使用方法
  • lambda表达式的形式和用法
  • 利用函数指针和lambda表达式实现C++的函数式编程
  • 利用fuction<T>模板的声明和用法,并用fuction<T>模板替代函数指针
  • functional头文件中的函数对象和用法
  • 利用函数式编程对已有代码进行重构

一个简单的编程问题

我想大家在刚刚开始接触编程的时候应该都遇到过这样一个简单的编程问题:
给出3个int型整数abc,当:

  • c0时,求出a+b
  • c1时,求出a-b
  • c2时,求出a*b
  • c3时,求出a/b

看到这个问题,你会想到怎么实现呢?如果有了想法快去自己实现一下呀(~ ̄▽ ̄)~
我们的第一想法(好吧,只是我的第一想法)应该是用一个mapc值和对应的计算方式建立映射关系,显然这个方法目前不可行,因为C++中不支持定义“函数类型”。我们只能退而求其次,使用if-else语句或者switch-case-break语句来完成该程序,代码清单1如下:

//代码清单1
#include<iostream>
using namespace std;
int Plus(int a,int b){//加法
    return a+b;
}
int Minus(int a,int b){//减法
    return a-b;
}
int Multiplies(int a,int b){//乘法
    return a*b;
}
int Divides(int a,int b){//除法
    return a/b;
}
int main(){
    int a,b,c;
    cin>>a>>b>>c;
    if(c==0)
        cout<<Plus(a,b)<<endl;
    else if(c==1)
        cout<<Minus(a,b)<<endl;
    else if(c==2)
        cout<<Multiplies(a,b)<<endl;
    else if(c==3)
        cout<<Divides(a,b)<<endl;
    return 0;
}

显然这样的代码过于臃肿,不够简洁,如果我们可以用mapc值和对应的计算方式建立起映射关系,输入c,直接调用对应的函数,代码会简洁很多,那么我们有什么办法去实现这样的map吗?(当然有!不然我写这篇博客干啥︿( ̄︶ ̄)︿)

函数指针

C++中没有函数类型,但是有函数指针的概念,函数指针指向的是函数而非对象。和其他指针一样,函数指针也具有类型,其类型由指向函数的形参类型和返回类型共同决定。函数指针声明方式是函数返回类型 (*函数指针名) (形参类型列表),我们可以按照下面的声明方式声明一个函数指针:

//定义指向包含两个int形参,返回类型为int的函数的函数指针
int (*f)(int,int)=nullptr;//当前该函数指针没有指向任何一个函数

当我们把函数名作为一个值使用时,该函数名自动转换成指针,例如,我们可以这样让f指针指向我们上一个程序中定义的Plus函数(注意函数指针与函数的类型必须匹配):

f=Plus;//f指向名为Plus的函数
f=&Plus;//等价的赋值语句,取地址符是可选的

我们可以直接使用函数指针调用一个函数,无须解引用:

int t1=Plus(1,2);//调用Plus函数
int t2=f(1,2);//等价的调用语句

虽然我们不能将多个函数存放到一个数组中,但我们可以将多个函数指针存放在一个数组中,语法是函数返回类型 (*函数指针名[数组维度]) (形参类型列表),我们可以按照下面的声明方式声明一个4维的函数指针数组`:

int(*v[4])(int,int);

如何使用标准库中的容器,例如vectormap,可以按照下面的声明方式声明一个存放函数指针类型的vector以及存放int->函数指针键值对的map

vector<int(*)(int,int)>v;
map<int,int(*)(int,int)>m;

哇,好复杂的声明,难道我需要定义函数指针的地方,都要这么声明吗?当然不是,我们可以利用C++中的类型别名机制using来简化声明:

using pf=int(*)(int,int);//pf是该函数指针类型的一个别名
vector<pf>v;//存放函数指针的vector
map<int,pf>m;//存放`int->函数指针`键值对的`map`
pf f[4];//函数指针数组

因此我们可以利用下面的代码清单2解决刚刚提出的加、减、乘、除的编程问题:

//代码清单2
#include<iostream>
#include<map>
using namespace std;
int Plus(int a,int b){//加法
    return a+b;
}
int Minus(int a,int b){//减法
    return a-b;
}
int Multiplies(int a,int b){//乘法
    return a*b;
}
int Divides(int a,int b){//除法
    return a/b;
}
int main(){
    int a,b,c;
    cin>>a>>b>>c;
    using pf=int(*)(int,int);//pf是该函数指针类型的一个别名
    map<int,pf>m={
        {0,Plus},{1,Minus},{2,Multiplies},{3,Divides}
    };
    cout<<m[c](a,b)<<endl;
    return 0;
}

哇,我们实现了按照c值直接调用相应的函数,你已经掌握了很多码农没有掌握的实现方法!快给自己鼓个掌!ㄟ(≧◇≦)ㄏ
兴奋完我们冷静一下,还要学习。我们继续看代码清单2,虽然它实现了按照c值直接调用相应的函数,但是如果需要增加一个新的计算方式,例如求余运算,还是需要预先定义一个新函数,再在主函数中调用它,这显然很不方便,而且定义新函数容易引发命名冲突。有没有什么不需要预先定义函数,而是按需要在main函数体内“定义函数”的即需即用的方法吗?(想知道吗?想知道快往下看,不许开小差(๑╹◡╹)ノ""")

lambda表达式

C++11中引入了lambda表达式的概念。一个lambda表达式标识一个可调用的代码单元,我们可以将其理解为一个未命名内联函数。你可能会问内联是啥?自己查书,别问我,这篇博客不负责解释这个问题,我奏是这么傲娇(`へ´*)ノ。
一个lambda表达式具有如下形式:
[捕获列表](参数列表)->返回类型{函数体}
我们可以忽略参数列表、箭头和返回类型,但必须永远包含捕获列表和函数体。我们可以按照下面的方式定义一个实现两个数加法的lambda表达式:

int(*f)(int,int)=[](int a,int b){return a+b;};//lambda表达式的类型是函数指针类型

我们可以使用auto关键字进行类型自动推导,

auto f=[](int a,int b){return a+b;};//与上面lambda表达式的声明是等价的
cout<<f(1,2)<<endl;//输出3

因此我们可以利用下面的代码清单3解决加、减、乘、除的编程问题:

//代码清单3
#include<iostream>
#include<map>
using namespace std;
int main(){
    int a,b,c;
    cin>>a>>b>>c;
    using pf=int(*)(int,int);//f是该函数指针类型的一个别名
    map<int,pf>m={
        {0,[](int a,int b){return a+b;}},
        {1,[](int a,int b){return a-b;}},
        {2,[](int a,int b){return a*b;}},
        {3,[](int a,int b){return a/b;}}
    };
    m.insert({4,[](int a,int b){return a%b;}});//假设增加了一个求余运算
    cout<<m[c](a,b)<<endl;
    return 0;
}

显然,这份代码就显得简洁多了。我们通过用户指定的不同的c值触发了不同的计算行为。相比于代码清单2,使用了lambda表达式的代码清单3做到了即需即用,即我什么时候需要一个函数,我就可以在main函数体内临时定义它,而不是需要预先定义在main函数体外,这是lambda表达式的一个非常重要的优势。
事实上,我们在这里才真正接触到函数式编程的思想。什么是函数式编程?简而言之,函数式编程就是把函数当作普通的数值一样使用。就像刚才,我们把一个函数(lambda表达式)赋值给了一个变量f,也把一个函数当作参数传递给了mapinsert函数,从而插入到了map的键值对中。将一个函数行为当作参数传递给另一个函数的方式,在函数式编程的思想中有一个专门术语,称为行为参数化(behavior parameterization)。函数式编程往往都是通过lambda表达式来实现的,这不仅仅局限于C++语言,在Java、Scala等等已经引入函数式编程思想的语言中,都是如此。

练习1

在这里,我想留给大家一个练习。
假设不使用map,仍使用if-else的语句,你能通过传递lambda表达式作函数参数的形式,在main函数外,仅实现一个函数compute来实现加减乘除求余这5种运算吗?(你当然可以的啊,是不是~(。≧3≦)ノ⌒☆棒棒哒)
为了简单起见,我希望你能通过代码填空的形式完成该练习。我给出了一份缺失部分代码的代码清单4,由你来填写空白部分,以使整个程序能够顺利运行并实现相应的功能。缺失部分代码的代码清单4如下:

//代码清单4
#include<iostream>
#include<map>
using namespace std;
//这里写你自己的compute函数
int main(){
    int a,b,c;
    cin>>a>>b>>c;
    if(c==0)
        cout<</*填写你对compute函数的调用代码*/<<endl;
    else if(c==1)
        cout<</*填写你对compute函数的调用代码*/<<endl;
    else if(c==2)
        cout<</*填写你对compute函数的调用代码*/<<endl;
    else if(c==3)
        cout<</*填写你对compute函数的调用代码*/<<endl;
    else if(c==4)
        cout<</*填写你对compute函数的调用代码*/<<endl;
    return 0;
}

函数指针更好的替代选择——fuction标准库类型

类模版fuction<T>是一种通用、多态的函数封装,封装的实体包括普通函数、Lambda表达式、函数指针、以及其它函数对象等。fuction<T>对象是对C++中现有的可调用实体的一种类型安全的包裹,而函数指针这类可调用实体,是类型不安全的。所以任何用到函数指针的地方,我们都应该尽可能使用fuction<T>来替代。fuction<T>很棒哦,往下看你就知道了(^-^)V
fuction<T>是一个模板,定义在头文件functional中,和我们使用过的其他模板一样,当创建一个具体的fuction<T>类型时,我们需要提供额外的类型信息。我们可以按照下面的代码声明一个接受两个int,返回一个int的可调用对象:

function<int(int,int)>f1=[](int a,int b){return a+b;};//注意没有*
function<int(int,int)>f2=Plus;//注意没有*

因此你完全可以将代码清单3中的函数指针全部替换成fuction<T>,并进行细微修改,即可完成和代码清单3一样的功能,如代码清单5所示:

//代码清单5
#include<iostream>
#include<map>
#include<functional>
using namespace std;
int main(){
    int a,b,c;
    cin>>a>>b>>c;
    using pf=int(int,int);//注意没有*
    map<int,function<pf>>m={//注意变为了function模板
        {0,[](int a,int b){return a+b;}},
        {1,[](int a,int b){return a-b;}},
        {2,[](int a,int b){return a*b;}},
        {3,[](int a,int b){return a/b;}}
    };
    m.insert({4,[](int a,int b){return a%b;}});//假设增加了一个求余运算
    cout<<m[c](a,b)<<endl;
    return 0;
}

返回一个函数

那么C++函数式编程的语法到这里就结束了吗?当然不是,对于我们开头提出的编程问题而言,代码清单5的确已经相当完美了,但是对函数式编程的思想来说,还远远不够。我们已经知道可以传递一个函数行为当作另一个函数的参数,但是考虑另一个问题,我们可以返回一个函数行为吗?答案自然是可以。代码清单6实现了返回一个函数的方法。

//代码清单6
#include<iostream>
#include<functional>
using namespace std;
using pf=function<int(int,int)>;//f是该函数指针类型的一个别名
int Plus(int a,int b){//加法
    return a+b;
}
pf g(){
    return Plus;//返回预先定义的函数
}
pf h(){
    return [](int a,int b){return a+b;};//返回lambda表达式
}
int main(){
    auto i=g();
    cout<<i(1,2)<<endl;//输出3
    auto j=h();
    cout<<j(1,2)<<endl;//输出3
    return 0;
}

functional头文件中的函数对象

functional头文件中还定义了许多函数对象,其中有一些你可能使用过(比如lessgreater),在这做一下总结:

模板关键字 作用
plus<T> (T t1,T t2)->(t1+t2)
minus<T> (T t1,T t2)->(t1-t2)
multiplies<T> (T t1,T t2)->(t1*t2)
divides<T> (T t1,T t2)->(t1/t2)
modulus<T> (T t1,T t2)->(t1%t2)
negate<T> (T t1)->(-t1)
equal_to<T> (T t1,T t2)->(t1==t2)
not_equal_to<T> (T t1,T t2)->(t1!=t2)
greater<T> (T t1,T t2)->(t1>t2)
greater_equal<T> (T t1,T t2)->(t1>=t2)
less<T> (T t1,T t2)->(t1<t2)
less_equal<T> (T t1,T t2)->(t1<=t2)
logical_and<T> (T t1,T t2)->(t1&t2)
logical_or<T> `(T t1,T t2)->(t1
logical_not<T> (T t1)->(~t1)
那么我们可以用以下语法表示两数之和(注意导入functional头文件)
//代码清单7
#include<iostream>
#include<functional>
using namespace std;
int main(){
    int a,b;
    cin>>a>>b;
    auto i=plus<int>();
    cout<<i(a,b)<<endl;
    cout<<plus<int>()(a,b)<<endl;//与上面的两个语句等价
    return 0;
}

练习2

请你仅使用mapfunctional头文件中的函数对象,不再使用任何函数和lambda表达式对代码清单3进行重构,并和代码清单3实现加减乘除求余这5种运算。

函数式编程的简单应用

本博客所要介绍的C++中实现函数式编程的全部语法已经介绍完了,下面我们看一下函数式编程的一些简单应用。

自定义排序

假设我们有一个vector<int>v变量,我们想用algorithm头文件中的sort函数对变量v中的元素进行从大到小排序,应该怎么做呢?
在C++11以前,可以这样进行排序:

#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
bool cmp(int a,int b){//自定义的排序函数
    return a>b;
}
int main(){
    vector<int>v={1,2,3,4,5};
    sort(v.begin(),v.end(),cmp);//进行从大到小排序
    for(int i:v)
    	cout<<i<<" ";
    return 0;
}

显然,这种方法并不完美,因为你每需要一个排序规则,就要预先定义一个排序函数。在C++11以后我们可以使用lambda表达式进行排序,代码如下:

#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
int main(){
    vector<int>v={1,2,3,4,5};
    sort(v.begin(),v.end(),[](int a,int b){
        return a>b;
    });
    for(int i:v)
        cout<<i<<" ";
    return 0;
}

这种代码要好得多,但是我们还可以直接利用functional头文件中的函数对象greater直接取代lambda表达式进行从大到小排序,代码如下:

#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
int main(){
    vector<int>v={1,2,3,4,5};
    sort(v.begin(),v.end(),greater<int>());//进行从大到小排序
    for(int i:v)
        cout<<i<<" ";
    return 0;
}

线性函数的定积分

我们在数学上学习过如何求解一个函数的定积分,那么如何编程实现求解任意函数在某个区间上的定积分呢?为了简单起见,这里只求解线性函数(一次函数)的定积分。在看下面的分析之前你可以自己先手动实现一下(o゜▽゜)o☆,不要犯懒,动一下手啊~~
假定一元函数表达式为f(x)=kx+b,我们需要两个参数kb确定一个一次函数,求解其在区间[a,b](b>a)上的定积分可以用公式\((f(b)+f(a))×(b-a)/2\)来表示。关键在于如何保证线性函数是任意的呢?
这里就必须要把线性函数当作参数传递给计算定积分的函数了,一元函数需要针对每个给定的参数x,应该有一个确定的y值,所以该一元函数类型应该为(double)(*)(double),即接受一个参数x,返回一个函数值y,而参数kb由传入的实参确定。
这里直接给出实现代码:

#include<iostream>
#include<functional>
using namespace std;
double compute(double a,double b,function<double(double)>f){
    return (f(b)+f(a))*(b-a)/2;
}
int main(){
    cout<<compute(3.0,7.0,[](double x){//求出y=x+10在[3,7]上的定积分
        return x+10.0;
    })<<endl;
    cout<<compute(-3.0,7.0,[](double x){//求出y=-3x+5在[-3,7]上的定积分
        return -3.0*x+5.0;
    })<<endl;
    return 0;
}

科里化

假设我们有如下一个涉及三个参数的函数,代码如下:

int test(int a,int b,int c){
    return (a+b)*c;
}

假设你有许多个应用,其中每个应用涉及的ab参数都是一致的,所以每个应用中,只提供参数c就可以得到结果。
你可以在每次调用test函数时都使用3个参数,但是每次都提供ab参数比较繁琐,并且你还极有可能输入错误。
你也可以为每一个应用编写一个新方法,将test方法中的所有ab参数替换成默认值,类似下面的代码这样:

int testForApplication(int c){
    return test(1,2,c);//假设该应用的a、b 参数分别为1、2
}

这样做的确复用了底层代码,但是针对每一个应用你都需要定义一个这样的函数,应用有多少个,你就需要写多少个函数,假如应用有很多,带来的编码代价也很大。
这里提供一种使用函数式编程的更简洁的解法,它既能充分利用已有的逻辑,又能让test函数针对每个应用进行定制,代码如下:

function<int(int)>testForApplication(int a,int b){
    return [a,b](int c){return test(a,b,c);};
}

testForApplication函数返回一个函数,它只需要一个参数c就可以运行。因此针对每个应用,你只需要先提供该应用的两个参数ab,获取针对该应用只需输入参数c的函数,然后针对该应用下的所有数据,输入一个参数c就可以得到相应结果了。像这样:

auto i=testForApplication(1,2);//获取参数a、b是1、2的应用对应的只需一个参数的函数
cout<<i(3);//获取该应用下参数c为3的最终结果
cout<<i(4);//获取该应用下参数c为4的最终结果

该应用中涉及到两个概念,即
高阶函数(Higher-order function):如果一个函数接受至少一个函数作为参数或返回的结果是一个函数,该函数就可以称为高阶函数。
科里化(Currying):将具备2个参数(比如,xy)的函数f转化为使用一个参数的函数g,并且这个函数的返回值也是一个函数,它会作为新函数的一个参数。后者的返回值和初始函数的返回值相同,即f(x,y) = (g(x))(y)。当然,我们可以由此推出:你可以将一个使用了6个参数的函数科里化成一个接受第2、4、6号参数,并返回一个接受5号参数的函数,这个函数又返回一个接受剩下的第1号和第3号参数的函数。

总结

本博客的主要目的在于介绍C++中实现函数式编程的语法,对函数式编程的主要概念和思想没有详细展开,在此做一下总结。

  • 本博客涉及到的函数式编程的概念:
    1. 函数式编程:把函数当作普通的数值一样使用,包括可以用函数进行赋值、传参、从函数中返回等操作。
    2. 行为参数化:将一个函数行为当作参数传递给另一个函数的方式。
    3. 高阶函数:如果一个函数接受至少一个函数作为参数或返回的结果是一个函数,该函数就可以称为高阶函数。
    4. 科里化:将具备2个参数(比如,xy)的函数f转化为使用一个参数的函数g,并且这个函数的返回值也是一个函数,它会作为新函数的一个参数。后者的返回值和初始函数的返回值相同,即f(x,y) = (g(x))(y)
  • 本博客涉及的C++语法:
    1. 函数指针:声明方式是函数返回类型 (*函数指针名) (形参类型列表)。我们可以按照下面的方式定义指向包含两个int形参,返回类型为int的函数的函数指针int (*f)(int,int)
    2. lambda表达式:声明方式是[捕获列表](参数列表)->返回类型{函数体}。我们可以忽略参数列表、箭头和返回类型,但必须永远包含捕获列表和函数体。我们可以按照下面的方式定义一个实现两个数加法的lambda表达式:[](int a,int b){return a+b;};
    3. fuction<T>:模板,定义在头文件functional中,当创建一个具体的fuction<T>类型时,我们需要提供额外的类型信息。我们可以按照下面的代码声明一个接受两个int,返回一个int的可调用对象:function<int(int,int)>f
    4. functional头文件中的函数对象
  • 相关技巧:
    1. 函数指针声明比较复杂,可以用using声明简化。
    2. 尽可能用 fuction<T>模板代替函数指针,因为函数指针是类型不安全的。
    3. 函数指针和 fuction<T>模板类型名都很长,应尽可能用auto关键字进行自动推导。
  • 参考及推荐书籍:
    1. 《C++ Primer》第5版:该书纸质书在中国有中文版和英文版
    2. 《Java 8 in Action》:该书纸质书在中国只有中文版
    3. 《Modern Java in Action》:该书是《Java 8 in Action》的第2版,目前在中国没有上市,也没有中文版,但是网上有英文版的pdf

推荐阅读