由于我自己也是在学习的过程中,并没有形成具体的函数式思维,因此可能记录的学习过程比较乱
标准库的基本使用
std::pair
std::pair是标准库中所有键值数据(key-value data)的默认返回类型,是快速使用标准库的第一把钥匙
重载输出流
这段代码高亮有点问题,我本地md里正常,修不好……
1 |
|
std::tuple
更一般地,元组(tuple)可以把多个不同数据类型的实例绑定到一个实例上
1 |
|
tuple的组装和拆包任务分别由两个函数来担当
std::make_tuple
创建一个元组,std::get
获取确定位置的数据
注意到,std::tie
既可以组装元组,也可以对一个元组进行拆包
1 | std::tie(std::ignore, std::ignore, xxx) = student0; |
std::ignore
可以作为解包时的占位符
auto[a,b,c]=xxx 是复制一份数据
std::tie()默认会创建原变量的引用
std::vector
基本的使用就不提了,后文会经常用到,考虑一下vector扩充内存的细节
当反复push_back()一个元素时,一旦达到了上限,vector会把可用空间大小扩充为之前的k倍,一般为2或1.5,具体由编译器决定
lambda函数
基本使用
举个两个简单的例子
1 |
|
要注意的是,addition并不是lambda函数的名字,它只是被附身了而已…lambda本身是没有名字的,因此不能简单地实现递归
参数列表中的中括号,名为捕捉列表,用于捕捉当前lambda所在环境的变量
参数列表之后有一个箭头,箭头后跟着返回值的类型
最后,与函数声明完之后直接用大括号收尾不同,在这里可以暂时把lambda函数看做一个特殊的变量,所以最后用分号收尾
auto a_alias = [capture](parameters)->return_type{...};
1 |
|
结果如下
1 | print by val lambda: 2 |
[=]表示按照值传递的方法捕捉父作用域的所有变量
[&]表示按照引用传递的方法捕捉父作用域的所有变量
如果想要捕捉特定的变量,可以直接写[var]或[&var]
甚至可以捕捉this指针,如果父作用域存在的话
lambda函数的出现减少了对命名空间的污染,也可以理解为增加了一种简单的局部函数的实现
可以直接把函数写在main函数里,用函数指针来管理
lambda递归
计算阶乘
1 |
|
lambda实现递归时,可以提前声明fact,然后再用[&fact]捕获
当右值赋给fact时,也就是fact函数指针被lambda”附身”,fact的值改变,那么fact的引用也对应改变
以下为照抄部分:链接
准确地讲,上面根本不是lambda函数的递归调用,只是一般函数的递归而已
想要实现lambda函数的递归调用,必须首先对Y-组合子有一定的了解
简单地说,虽然lambda没有名字,但我们可以把它作为一个参数传递给更上层的函数调用
熟悉函数式编程的话会想起不动点组合子,也就是构造一个函数,返回它的不动点
1 | fix :: (a -> a) -> a |
抄不动了,我也不熟悉函数式啊…..但是直接用C++还是会写的…
函数式编程工具箱
把函数看作一等公民
传统的 C++ 把承载数据的变量作为一等公民,这些变量享有以下权利:
1、可以承载数据,也可以存在于更高级的数据结构中;
2、可以作为右值参与计算、函数调用;
3、可以作为左值参与赋值操作。
那么,如果把函数也当做一等公民,那么,函数也应当享有以下的权利:
1、函数就是数据,一个函数变量可以承载函数,也可以存在于更高级的数据结构中;
2、可以作为右值参与计算,或参与更高级的函数调用;
3、可以作为左值参与赋值操作。
这里给一个简单计算器的例子来理解
1 |
|
这里我们使用了标准库提供的std::map
来存储核心匹配关系,即一个运算符对应一个运算关系
这时map存储的数据不再是数字,而是一个具体的函数,一种具体的运算方法,这就是刚刚提到的公民权利的第一条和第三条(‘+’作为一个函数载体,作为左值)
而第二条:作为右值参与计算或参与更高级的函数调用,根据以下的三个工具来理解
基本工具
map,filter,fold分别表示对集合而言的三种操作:映射、筛选、折叠
映射
映射操作:对一个列表vec
内的所有元素依照某种运算法则f
进行运算,从而得到一个全新的列表[f(x) for x in vec]
,这确实是一个基于列表的循环操作
提到函数式编程时,遵循两个原则:
1、DRY(Don’t Repeat Yourself),一个组件只有一次实现,绝不特化过多的实现
2、重视逻辑推演而非执行过程,侧重如何通过逻辑上的组合、推演,来完备的定义问题的解,执行过程交给计算机即可
STL中提供了两个工具:std::for_each
和std::transform
,后者支持更好甚至支持两个列表同时计算
for_each
写法比for+auto复杂,关键是还保证了顺序执行,相比for优势全无,不过这一点也可以使它应用于一个非纯函数的工具纯函数是指没有副作用、没有后继状态变化,对相同输入总会反馈相同结果的一类特殊函数
某种意义上说,函数式编程(尽量)要求使用纯函数从而方便测试(反馈唯一)、维护(没有副作用)、自动并行化(没有后继的状态变化)
1 |
|
回到transform
,它不保证按序执行,它要求函数不改变迭代器(比如delete)或者修改范围内部元素(比如把下一个元素的值+1)
同时transform
要求有返回值,每执行一次都会把结果存储在一个新的列表中
transform
允许同时接受两个列表,但是此时要求后者的长度必须大于等于前者的长度,否则会产生未定义行为(Undefined Behavior)
实际上transform
也可以实现就地操作,只要把输出的迭代器位置变换成自己的begin()
就可以了
举个简单的例子
1 |
|
筛选
映射是所有操作的根基,因为所有操作都可以用映射进行解释,筛选和折叠也不例外
筛选:对一个列表内的所有元素应用一个谓词(predicate,谓词特指判断事物返回True或False的函数),然后取出所有结果为True的元素,简单地说就是for循环 + if-else判断
STL提供了copy_if
和remove_if
来实现筛选,给出一个实例
1 |
|
需要注意的是,copy_if
需要额外开一块空间出来,remove_if
则返回了需要删掉的元素的迭代器,然后再通过erase
函数彻底删除元素
与transform
类似,这里的谓词函数同样要求不得修改原始数据
折叠
折叠:对一个列表和一个初始值应用一个二元函数,然后依次按某一特定方向将整个列表折叠起来
看完代码就能理解这个描述了
STL中提供了accumulate
和inner_product
,两者都是左折叠函数,分别是执行求和、计算向量内积的函数
后者实际上是先对两个列表应用一个二元运算(默认乘法),把结果存入一个列表中,再对这个列表折叠(默认求和)
1 |
|
简单地说,应用的二元函数的第一个参数是累加值,第二个参数是列表中取出的值,返回值的类型应与初值和累加值的类型相同(熟练以后可以用auto代替)
最后一个反转vector略有一点难度,如果看不懂可以输出中间结果看看
考虑一个问题,由于C++依旧是基于面向过程和面向对象的语言,所以在函数式编程上的优化可能没有那么专业,而且C++会按语句依次执行,如果大量堆叠
transform
会不会造成性能下降呢?这就是之后将要引入的内容:是否引入简单的lazy求值,以及简单的柯里化
题外话:这里我突然想到了一些东西,当看到这句话
期末考完可能得等
,它是很不符合语法的语句,但是我们可以轻易得知语句表达的含义。我猜测,眼睛读取信息时并行的读入这8个汉字,然后由大脑根据词组进行全排列,比如这8个字分成了四个词组,然后匹配最佳语义(又能扯到SFINAE了吗…)
函数的部分应用
在处理柯里化之前,我们先思考这个问题:如何对一个函数进行部分应用
举个例子,比如最常见的除法a/b
,能否只传递其中的一个参数给这个除法运算,从而得到一个接收另一个参数的函数?话很绕口,但是代码不绕口
1 |
|
这只是个简陋的例子,看不出来这有什么用……
再给出使用std::bind()
的更优雅(繁琐)的写法
1 |
|
这个例子其实并没有做到部分应用,只是使用了占位符std::placeholders::_1,_2,...
来实现效果
std::bind()的参数默认是值传递,若需要引用传递,必须使用std::ref()
或std::cref()
更好的写法,一个简单的柯里化尝试
1 | auto c_xor = [](char _1)->auto{ |
可以这样调用 char c = c_xor('a')('b')('c');
柯里化
我们不想为了柯里化而柯里化,把每一个函数的声明都写的十分冗长;也不想用std::bind
每次都把所有参数写出来,形式不够简洁
两个方案
1、需要额外手动维护一个记录参数的容器
2、利用变长模板进行函数包装
把元组应用到函数上
原本函数调用是由编译器来托管所有参数进栈出栈,现在只能一次来一个参数,自然会想到用一个容器来托管所有参数,一旦调用,就把这些参数都喂给原始函数
当然是选择tuple
了…但是如果能直接把tuple
内的参数直接喂给函数就好了
最近的C++17标准中提供了
apply
函数来完成这一操作
这里我们自己写一份
1 |
|
function_traits
如何判断这个函数需要多少个参数呢?
这里我们需要写一个类型判断器,把函数解构,获取一个函数的必要信息:返回类型(return type)是什么,参数(arity)需要多少个,每一个参数的类型(argument type)是什么
我们把这些东西包装到一个function_traits
类型中
并且把函数的类型规范到形如std::function<Return(Args...)>
当然为了确保我们的类型是真实可靠的,借助于C++非常严格的类型检验功能std::is_same<U,V>::value
,同时借助typeid().name()
把这个类型具体是什么输出出来
如果使用的是GCC,
typeid().name()
会得到编译器装饰过的类型别名(decorated name),难以阅读这里使用GCC自带的工具
c++filt
或者__cxa_demagle
来帮助把类型名替换为代码中的原始名称这里使用的是后者,并且用函数
realname()
帮助我们把类型直接转成正常形式
学习于 知乎专栏,遗憾的是我这里g++-7跑不了这个代码,在宏相关的地方有报错,但是我不会debug…
贴一下function_traits.hpp的代码,后面可以正常使用,我并没有尝试理解这段代码…
后面的两段柯里化代码都需要借助这份hpp头文件
1 | // filename: function_traits.hpp |
实现柯里化包装器
我们的要求依旧和之前一样,既要保持函数不必每次重写,也要保证形式上的简洁性
最初已经提到了这两个方案,顺着这个思路往下走
1、需要额外手动维护一个记录参数的容器
2、利用变长模板进行函数包装
带有一定lazy程度的currying
既然已经知道如何把元组应用到函数上了,那么只需要手动维护这个元组就行了
1 |
|
代码中的柯里化是具备了一定的lazy功能的,只有外部显式调用
operator()
的时候才会进行计算但是这样并没有完全lazy计算,因为传参时,我们传递的值,而不是参数的计算定义或计算路径
仍然是看不懂,可能这就是库的作用吧
把函数包装托管给编译器
利用模板特化,把函数包装托管给编译器
但是这里的柯里化就不再是延时求值了,而是每一次柯里化都返回一个真实的函数对象
1 |
|
由于使用了模板特化来实现柯里化,为了保证编译不出错(变长模板展开需要有一个终止条件),所以在特化时我们只处理了没有参数和只有一个参数的情形
这样也严格的执行了柯里化的定义,即函数永远只接收一个参数,同时也确保了返回值是一个函数对象而不是之前那种包装了参数数据的特殊数据结构
函数组合
引入管道概念
管道是linux中一种特殊的文件,程序可以读取管道的数据,也可以把数据写到管道里传递给其他程序
比如ps -aux --sort -pcpu | less
是最经典的检查CPU占用的命令
我们可以粗略的认为,运算符|
接收两个参数lhs
和rhs
,左端提供数据,右端提供函数
重载|
运算符
1 | template <class Arg, class F> |
可以写出这样的代码
1 | int result = 5 | [](int _) { return _ * 2; }; |
如果没有lambda支持,同样的功能可能单是函数声明就十分麻烦,千言万语不及一句随用随写
只要管道不要数据
现在假设我们有这样的一个管道链a | f | g
,我们不再关心数据a
,而关心函数的组合| f | g
其实就是f(g(x))
注意到每一个函数的类型都可能是不同的,而这是一个线性结构,所以选用tuple作为核心容器记录所有的函数;同时,对外公开两个方法
composite()
和operator()
用于处理新函数和执行函数调用,内部则用递归的方法把函数一次调用
代码如下
1 |
|
haskell里用
.
运算符来表示函数组合,因为C++不允许重载这个操作符…….这不是bug,这是feature
于是这里重载了逗号运算符
这种写法…composited_functor<>()太碍眼了,完全可以不需要的,我们换个思路重新考虑一下这个问题
特殊的应用符
Haskell里有一个特殊的运算符$
来消除括号,同样地我们也可以引入C++中,Haskell中$
定义如下
1 | ($) :: (a -> b) -> a -> b |
换句话说就是可以不写括号,直接把数据应用到函数上
由于C++不能创建新的运算符,只好曲线救国,换一个运算符来实现功能
这里选择operator<<
注意需要function_traits.hpp头文件
1 |
|
很像 Haskell 的 f $ g $ h x
,不过得益于 lambda 代码不至于过于冗长
现在我们手上已经有了柯里化和函数组合这两个工具了。借助这两个工具,我们可以非常自然地把逻辑用函数来进行抽象,把参数的传递用柯里化来代替,从而更简单的组合我们的代码,更大限度的减少不良代码。这里需要再次强调一次,那就是DRY 原则,不要反复重复没用的代码。编程的最大乐趣在于一层一层的把逻辑抽象,从而用简洁的代码,通过一层一层的组合、堆叠,完成一个又一个复杂的任务。可能之前我们手上只有一种实现的方法,而现在道路多了之后,就会大大地解放我们的思路,方便我们思考出最适合自己的一条实现方案。
吾头在否?