C++ 引用在函数式编程中的应用
Posted BOTManJL
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++ 引用在函数式编程中的应用相关的知识,希望对你有一定的参考价值。
微信限制:不能放置链接、公式;代码排版、高亮样式存在问题;发布后不能更新。。。
推荐 阅读原文:
最近要用 C++ 实现 PageRank 算法;于是趁着这个机会,搭上现代 C++ 的快车,体验了一把现代 C++ 函数式编程。
由于上一篇 高阶函数简介 提到的 range-v3 没有被 C++ 17 纳入标准,所以本次需求使用的还是 C++ 11 提供的函数式编程能力。
传递引用,避免拷贝
代码链接
PageRank 算法基于网页之间的链接关系,需要定义一个 邻接矩阵 (adjacency matrix) 数据结构 Matrix
,存储页面链接关系的有向图:
template <typename K>
using Matrix = std::multimap<K, K>;
template <typename K>
using Edge = typename Matrix<K>::value_type;
Matrix
定义为 多重映射 (multimap),可以实现同一个 key 映射到多个 value,即给定某个节点,可以映射到链接的多个节点上Edge
定义为映射的元素类型(序对 (pair),即std::pair<K, K>
),每一个序对表示一组节点之间的链接关系,即图的一条有向边数学上表示:,其中 表示有向图, 表示邻接矩阵
Matrix
PageRank 算法需要在有向图中,找出链接到给定网页的所有网页,即 反向链接 (backlink)。为了更方便的查询某个节点的入边,我们可以通过 转置(transpose) 原始邻接矩阵,得到 反向连接的邻接矩阵 (transpose graph):
0 1 0 0 0 1
0 0 1 -> 1 0 0
1 0 0 0 1 0
将原矩阵 的每个有向边反转,得到新矩阵
数学上表示:
如何跟踪对象生命周期
在实现矩阵转置算法前,我们定义 CopyGuard
来跟踪对象的生命周期,观察 Matrix
及其元素的拷贝情况:
class CopyGuard {
public:
explicit CopyGuard(int flag) : flag_(flag) {
std::cout << "ctor" << flag_ << std::endl;
}
CopyGuard(const CopyGuard& rhs) {
flag_ = rhs.flag_;
std::cout << "copy" << flag_ << std::endl;
}
CopyGuard(CopyGuard&& rhs) {
flag_ = rhs.flag_;
std::cout << "move" << flag_ << std::endl;
}
private:
int flag_;
};
对象显式构造时,输出
ctor
对象拷贝构造时,输出
copy
对象移动构造时,输出
move
由于 Matrix
是一个 std::multimap
容器,我们可以使用 Matrix<CopyGuard>
跟踪 Matrix
在转置过程中的生命周期变化,即当 Matrix
或内部元素被拷贝时,会触发 CopyGuard::CopyGuard(const CopyGuard& rhs)
,输出 copy
。
实验代码使用
clang++/g++ -std=c++11
编译,默认开启 返回值优化(return value optimization, RVO)(参考:拷贝省略)。
命令式实现
用命令式的方法实现矩阵转置 TransposeImperative
:
template <typename K>
Matrix<K> TransposeImperative(const Matrix<K>& matrix) {
Matrix<K> transposed{};
for (const Edge<K>& edge : matrix) {
transposed.emplace(edge.second, edge.first);
}
return transposed;
}
定义一个 新矩阵
transposed
作为返回值遍历 原矩阵
matrix
的 有向边edge
将 有向边 反转为 ,并插入 新矩阵
transposed
函数式实现
用函数式的方法 —— 折叠 (fold) 操作 —— 实现矩阵转置 TransposeFunctional
:
template <typename K>
Matrix<K> TransposeFunctional(const Matrix<K>& matrix) {
return std::accumulate(
std::begin(matrix), std::end(matrix), Matrix<K>{},
[](const Matrix<K>& transposed, const Edge<K>& edge) -> Matrix<K> {
Matrix<K> new_transposed = transposed;
new_transposed.emplace(edge.second, edge.first);
return new_transposed;
});
}
使用
std::accumulate
遍历 原矩阵matrix
,累加到 新矩阵Matrix<K>{}
上迭代过程中,每次在 上一轮迭代得到的矩阵
transposed
的基础上,插入反转后的有向边,得到new_transposed
为下一轮迭代的输入矩阵
根据实验结果,上述代码有 2 处拷贝:
拷贝上一轮迭代得到的矩阵
Matrix<K> new_transposed = transposed;
插入反转后的有向边
new_transposed.emplace(edge.second, edge.first);
假设矩阵里有 条边:
操作 | 触发 CopyGuard 拷贝的次数 |
是否必需 |
---|---|---|
拷贝上一轮迭代得到的矩阵 |
|
非必需,不需要每次迭代都生成新矩阵 |
插入反转后的有向边 |
|
必需,转置得到的矩阵和原矩阵独立 |
为了使用函数式编程,而又不因为过度拷贝影响性能,我们可以将不必要的 Matrix<K> new_transposed = transposed;
操作优化掉。
函数式优化:传递左值引用
为了避免额外的拷贝,我们可以从模板 std::accumulate
的实现寻找灵感:
// accumulate: (first, last, init, op) -> init
for (; first != last; ++first)
init = op(init, *first); return init;
op
的第一个参数可以接受init
的 左值引用,避免传入init
时发生拷贝op
的返回值可以返回init
的 左值引用,避免传出init
时发生拷贝
根据上述思路,我们可以将 TransposeFunctional
改写为 TransposeLref
:
参考:Efficient accumulate - Stack Overflow
template <typename K>
Matrix<K> TransposeLref(const Matrix<K>& matrix) {
return std::accumulate(
std::begin(matrix), std::end(matrix), Matrix<K>{},
[](Matrix<K>& transposed, const Edge<K>& edge) -> Matrix<K>& {
transposed.emplace(edge.second, edge.first);
return transposed;
});
}
std::accumulate
使用的 lambda 表达式传递 左值引用:参数类型
const Matrix<K>&
改为Matrix<K>&
返回值类型
Matrix<K>
改为Matrix<K>&
迭代过程中,每次插入反转后的有向边,操作的都是 同一个 新矩阵
Matrix<K>{}
函数式优化:传递右值引用
实际上,std::accumulate
在迭代时直接传递 init
的缺陷很早前已经被发现了(C++ 20 可能会修复)。
正确的做法是现有的 op(init, *first)
改用 移动语义 op(std::move(init), *first)
传递每轮迭代产生的局部变量 init
:
参考:c++ accumulate with move instead of copy - Stack Overflow
// accumulate: (first, last, init, op) -> init
for (; first != last; ++first)
init = op(std::move(init), *first); // std::move since C++20
return init;
op 传入参数
init
的 右值引用,由init
的类型实现移动语义,从而避免对象 深拷贝 (deep copy)op 传出的返回值直接通过 拷贝省略 优化
基于修改后的 cpp20::accumulate
,我们可以将 TransposeFunctional
改写为 TransposeRref
:
参考:Efficient Pure Functional Programming in C++ Using Move Semantics – C++ on a Friday
template <typename K>
Matrix<K> TransposeRref(const Matrix<K>& matrix) {
return cpp20::accumulate(
std::begin(matrix), std::end(matrix), Matrix<K>{},
[](Matrix<K>&& transposed, const Edge<K>& edge) -> Matrix<K> {
transposed.emplace(edge.second, edge.first);
return std::move(transposed);
});
}
std::accumulate
使用的 lambda 表达式传递 右值引用:参数类型
const Matrix<K>&
改为Matrix<K>&&
传入的 右值引用 会 在函数内退化为 左值引用
在返回时强制转回 右值引用
return std::move(transposed);
迭代过程中,虽然生成了很多
Matrix
对象,但在 移动语义 和 拷贝省略 下,这些临时的Matrix
对象做的都是 浅拷贝 (shallow copy),不会触发CopyGuard
双缓冲优化迭代过程
代码链接
PageRank 算法可以通过 迭代的方法 实现(伪代码):
IteratePageRank: (ranks) -> ranks
next_ranks <- StepPageRank(ranks)
return <- IsConvergent(ranks, next_ranks)
? next_ranks
: IteratePageRank(next_ranks)
每轮迭代调用
StepPageRank
生成next_ranks
然后比较
ranks
和next_ranks
,判断是否收敛:如果收敛了,迭代结束,返回
next_ranks
如果未收敛,递归调用
IteratePageRank
,传递next_ranks
进入下一轮迭代
IteratePageRank
每次迭代都会生成一个新的 next_ranks
,如果不通过传递引用优化,会导致严重的拷贝问题。
如果使用传递引用的优化方法,每次 原地 (in-place) 修改 ranks
得到 next_ranks
,会引入另一个问题:在 IsConvergent(ranks, next_ranks)
判断是否收敛时,原来的 ranks
对象已经失效:
左值引用优化的情况下,
ranks
被更新为next_ranks
右值引用优化的情况下,
ranks
变成了一个空对象
另外,在 StepPageRank
计算 next_ranks
的过程中,需要使用参数 ranks
的完整数据;对于原地修改的方法,一边读取、一边修改同一个对象,会导致数据不完整的问题。
这里可以引入 双缓冲 (double buffering) 机制:
Vector<K, V> StepPageRank(const Vector<K, V>& ranks,
Vector<K, V>&& temp_ranks) {
// ... update |temp_ranks| according to |ranks|
return std::move(temp_ranks);
}
Vector<K, V> IteratePageRank(Vector<K, V>&& ranks,
Vector<K, V>&& temp_ranks) {
return [&ranks](Vector<K, V>&& next_ranks) -> Vector<K, V> {
return IsConvergent(ranks, next_ranks)
? std::move(next_ranks)
: IteratePageRank(std::move(next_ranks),
std::move(ranks));
}(StepPageRank(ranks, std::move(temp_ranks)));
}
StepPageRank
每次读取ranks
的完整数据,将新的数据写入并返回temp_ranks
IteratePageRank
每次将StepPageRank
返回的temp_ranks
作为next_ranks
,然后检查是否收敛IteratePageRank
如果需要进入下一轮迭代,就将新生成的next_ranks
作为用于读取的数据,而已经过期的ranks
作为用于写入的缓存
基于双缓冲的方法,迭代过程只需要构造两个 Vector<K, V>
对象即可 —— 一方面避免了额外的拷贝,另一方面不会导致读写冲突的问题。
向左值引用参数传递右值对象
代码链接
在 C++ 中,许多和 IO 相关的接口继承于 std::istream
/std::ostream
;而对输入输出流的操作往往需要修改流的本身,所以很多输入输出流相关的操作接口,只接受 左值引用 作为输入:
std::ifstream ifs = argc >= 2 ? std::ifstream(argv[1]) : std::ifstream();
std::ofstream ofs = argc >= 3 ? std::ofstream(argv[2]) : std::ofstream();
std::copy(std::istream_iterator<std::string>(ifs),
std::istream_iterator<std::string>(),
std::ostream_iterator<std::string>(ofs, "\n"));
以参数
argv[1]
构造文件输入流ifs
,参数argv[2]
构造文件输出流ofs
使用
ifs
/ofs
的左值引用构造迭代器stream_iterator
从
ifs
读取std::string
,直接输出到ofs
中
右值 -> 左值引用
为了消除局部变量 ifs
/ofs
,我们可以直接在 stream_iterator
构造函数的参数里构造 fstream
对象:
std::copy(std::istream_iterator<std::string>(
argc >= 2 ? std::ifstream(argv[1]) : std::ifstream()),
std::istream_iterator<std::string>(),
std::ostream_iterator<std::string>(
argc >= 3 ? std::ofstream(argv[2]) : std::ofstream(), "\n"));
但代码无法编译:
candidate constructor not viable: no known conversion
from 'std::ifstream' to 'istream_type &' for 1st argument
candidate constructor not viable: no known conversion
from 'std::ofstream' to 'ostream_type &' for 1st argument
在表达式里构造
fstream
对象是一个 右值而
stream_iterator
构造函数只接受stream
的 左值引用这里无法将 右值 直接转换成 左值引用
右值 -> 右值引用 -> 左值引用
为了解决这个问题,我们可以使用 右值引用的技巧 进行优化:
[](std::ifstream&& ifs, std::ofstream&& ofs) -> void {
std::copy(std::istream_iterator<std::string>(ifs),
std::istream_iterator<std::string>(),
std::ostream_iterator<std::string>(ofs, "\n"));
}(argc >= 2 ? std::ifstream(argv[1]) : std::ifstream(),
argc >= 3 ? std::ofstream(argv[2]) : std::ofstream());
使用一个参数为
ifs
/ofs
右值引用 的 lambda 表达式将临时构造出的
fstream
右值 对象传入 lambda 表达式而 右值引用 参数会 在函数内退化为左值引用
所以在 lambda 表达式内,
ifs
/ofs
会被当成 左值引用 看待,可以直接构造stream_iterator
基于这个技巧,我们就离 告别局部变量 更近了一步~
以上是关于C++ 引用在函数式编程中的应用的主要内容,如果未能解决你的问题,请参考以下文章