前言
C++ 20 也已经了解一段时间了。自己的感觉是:大佬们在推函数式编程和模板元编程。本来自己是 Java 出身,想深入OOP。但是,现在看来,有必要深入了解一下函数式和模板元编程了。先从函数式编程开始。《C++ 函数式编程》正好香雪馆有这本书,近水楼台,就不买了!
- 官方代码:https://www.manning.com/books/functional-programming-in-c-plus-plus
- 官方代码笔记: https://gitee.com/thebigapple/Study_CPlusPlus_20_For_CG.git
第一章 函数式编程简介
1.1 命令式与声明式编程比较
-
函数式编程
- FP (functional programming)
- 强调表达式求值,而不是指令的执行
- 这些表达式由函数和基本的值组合而成
-
命令式实现统计多个文件的行数
std::vector<int> count_lines_in_files_command(const std::vector<std::string>& files) { std::vector<int> results; char c = 0; for (const auto& file : files) { int line_count = 0; std::ifstream in(file); while (in.get(c)) { if (c == '\n') { line_count++; } } results.push_back(line_count); } return results; }
-
声明式实现统计多个文件的行数
int count_lines(const std::string& filename) { std::ifstream in(filename); in.unsetf(std::ios_base::skipws); return (int) std::count( std::istream_iterator<char>(in), std::istream_iterator<char>(), '\n'); } std::vector<int> count_lines_in_files(const std::vector<std::string>& files) { std::vector<int> results; for (const auto& file : files) { results.push_back(count_lines(file)); } return results; }
-
上述声明式实现的优势
- 不需要关心统计是如何进行的,只需要说明在给定的流中统计行数的数目就行
-
函数式编程的主要思想
- 使用抽象来表述用户的目的(Intent)
- 而不是说明如何去做
-
使用 std::transform 进一步抽象
std::vector<int> count_lines_in_files_transform(const std::vector<std::string>& files) { std::vector<int> results(files.size()); std::transform(files.cbegin(), files.cend(), results.begin(), count_lines); return results; }
- transform 前两个参数指定内容,第三个参数指定存放起始位置,第四个参数指定转换函数
-
通过 range 和 range transformations 再次抽象
std::vector<int> count_lines_in_files_range(const std::vector<std::string>& files) { //使用 | 管道操作符表示通过 transform 传递一个集合 auto view = files | std::views::transform(count_lines) ; return { std::ranges::begin(view), std::ranges::end(view) };; }
1.2 纯函数(Pure functions)
- FP的核心思想是纯函数
- 函数只使用(而不修改)传递给它的实际参数计算结果
- 如果使用相同的实参多次调用纯函数,将得到相同的结果,并不会留下调用痕迹(无副作用)
- 纯函数不能修改程序的状态
- 因此,利用真正意义上的纯函数,开发程序就毫无意义
- 纯函数重定义 (放松要求)
- 任何(除了硬件以上的层)没有可见副作用的函数称为纯函数
- 纯函数的调用者除了接收它的返回结果外,看不到任何它执行的痕迹
- 不提倡只使用纯函数,而是限制非纯函数的数量
1.2.1 避免可变状态
- 不纯的第一个方面
- 是不是外部可见的
- 变量是局部变量就对外不可见
- 是不是外部可见的
- 思维路线
- 第一步,判断函数内的变量是不是外部可见。
- 全部都是局部变量对外不可见 (伪纯)
- 第二步,将不纯的地方移到其他函数(局部变量)
- 第三步,判断是否还有局部状态
- 没有可变状态也没有不可变状态,即是一个FP风格代码
- Code_1_1_1
- 第一步,判断函数内的变量是不是外部可见。
1.3 以函数方式思考问题
- 利用 1.1.2 的思维路线编写代码是低效的
- 换一种方式思考问题
- 考虑输入是什么,输出是什么
- 从输入到输出需要什么样的转换
- 而不是去思考算法的步骤
- 思想
- 把一个大的问题分解成小的问题、独立的任务,并且方便地把他们组合起来
1.4 函数式编程的优点
1.4.1 代码简洁易读
1.4.2 并发和同步
- C++ 编译器在检测到循环体是“纯”的时,可以自动进行向量化或进行其他的优化
- 这种优化会对标准代码产生影响
- 因为标准算法的内部是通过循环实现的
- 这种优化会对标准代码产生影响
1.4.3 持续优化
- 使用抽象层更高的STL或其他可信库函数的优点
- 即使不修改任何一行代码,程序也在不断的提高性能
- 很多程序员倾向于手动编写低层次关键性能代码
- 但这种优化只针对特定的平台
- 阻碍了编译器对其他平台代码的优化
1.5 C++ 作为函数式编程语言的进化
- 泛型编程思想
- 可以编写通用概念的代码,并可以把它应用于适合这些概念的任意结构
第二章 函数式编程之旅
2.1 函数使用函数?
- 高阶函数(higher-order function)
- 能够接收函数作为参数或返回函数作为结果的函数称为高阶函数
- 高阶结构
- 过滤结构
- 接收一个集合和一个谓词函数
(T -> bool)
作为参数,并返回一个过滤后的集合 - 写作:
filter: (collection<T>, (T -> bool)) -> cllection<T>
- 接收一个集合和一个谓词函数
- 映射 (map) 或转换 (transform)结构
- 对集合中的每一个元素调用转换函数,并收集结果到新集合中
- 写作:
transform: (collection<In>, (In -> Out)) -> collection<Out>
- 过滤结构
- 过滤和转换是通用的编程模式
2.2 STL 实例
2.2.1 求平均值
- 高阶函数
std::accumulate()
- 参数:一个集合和一个初始值, 第三个参数可以提供一个自定义函数(可以不提供)
- 返回初始值和集合中所有元素的累加和
- 算法比较特别
- 它保证集合中每个元素逐个累加,这使得不改变其行为的情况下不可能将它并行化
- 如果要并行累加所有的元素,可以使用
std::reduce()
- 可以把
std::multiplies<>()
作为最后一个参数将算法改为求乘积
- Code
auto average_score(const std::vector<int>& scores) -> double { return std::accumulate( scores.cbegin(), scores.cend(), 0 ) / (double)scores.size(); }
auto average_score(const std::vector<int>& scores) -> double { return std::reduce( std::execution::par, scores.cbegin(), scores.cend(), 0 ) / (double)scores.size(); }
auto product_score(const std::vector<int>& scores) -> double { return std::accumulate( scores.cbegin(), scores.cend(), 1, std::multiplies<int>() ); }
2.2.2 折叠 (Folding)
std::accumulate()
是折叠的一种实现- 折叠提供了对递归结构,如向量、列表和树的遍历处理,并允许逐步构建自己需要的结果
- 折叠接收一个集合,其中包含 T 类型的条目、R 类型的初始值 (没必要与 T 是相同类型) 和一个函数
f: (R, T) -> R
- 对初始值和集合的第一个元素调用给定的函数
f
- 结果与集合中第二个元素再传递给函数
f
. - 一直重复到集合中所有元素处理完毕
- 算法返回一个 R 类型的值
- 这个值是函数
f
最后一次调用的返回值
- 这个值是函数
- 对初始值和集合的第一个元素调用给定的函数
- 从第一个元素开始处理称为左折叠
- 从集合的最后一个元素开始处理,称右折叠
- C++ 没有提供右折叠算法
- 可以传递反向迭代器实现右折叠效果
crbegin 和 crend
2.2.3 删除字符串空白符
std::find_if()
- 它查找集合中第一个满足指定谓词的元素
- Code
auto trim_left(std::string s) -> std::string { s.erase( s.begin(), std::find_if(s.begin(), s.end(), is_not_space) ); return s; } auto trim_right(std::string s) -> std::string { s.erase( std::find_if(s.rbegin(), s.rend(), is_not_space).base(), s.end() ); return s; } auto trim(std::string s) -> std::string { return trim_left(trim_right(std::move(s))); }
2.2.4 基于谓词分割集合
std::partition()
和std::stable_partition()
- 都接收一个集合和一个谓词
- 他们对原集合中的元素进行重排,把符合条件的与不符合条件的元素分开
- 符合谓词条件的元素移动到集合的前面
- 不符合谓词条件的元素移动到集合的后面
- 算法返回一个迭代器,指向第二部分的第一个元素
- 不符合谓词条件的第一个元素
- 返回的迭代器和原集合首端迭代器配合,获取满足谓词条件的元素
- 返回的迭代器与原集合尾端迭代器配合,获取原集合中不符合谓词条件的元素
- 即使这些集合中存在空集也是正确的
- 两个算法的区别
std::stable_partition()
可以保持集合中原来的顺序
- Code_2_2_4
2.2.5 过滤(Filtering)和转换(Transforming)
std::remove_if()
和std::remove()
- 删除集合中满足谓词或包含特定值的函数
- 但是这个函数是 erase-remove 风格
- erase-remove 风格
- 算法只把不符合删除条件的元素移动到集合的开头部分
- 其他元素则是不确定的状态,算法返回一个指向第一个这样元素的迭代器
- 如果没有元素被删除,则指向集合的末尾
- 需要把这个迭代器传递给集合的成员
erase()
,由它来执行people.erase( std::remove_if(people.begin(), people.end(), is_not_female), people.end());
- 如果不想改变原来的集合可以使用
std::copy_if()
- 前两个参数为待复制的源集合迭代器
- 第三个参数为存放复制元素的集合起始迭代器
- 使用
std::back_inserter()
包裹目标集合即可
- 使用
- 最后一个参数为筛选谓词
std::copy_if(people.cbegin(), people.cend(), std::back_inserter(females), is_female);
std::transform()
转换函数- 参数
- 前两个参数为待转换的源集合迭代器
- 第三个参数为存放转换结果集合的起始迭代器
- 最后一个参数是转换函数
std::transform(females.cbegin(), females.cend(), names.begin(), name);
- 参数
2.3 STL 算法的可组合性和使用性
- STL 算法组合效率不高
- 因为其会生成不必要的副本(有很多拷贝)
- 由于大部分 STL 算法在计算时都会产生拷贝,因此不适合高效编程
- 但是在 C++ 20 部分算法会有所改善(使用移动语义)
- 使用 range 进行组合可以改善算法在执行时产生不必要副本的问题
总结
关键 API
- std::accumulate()
- std::reduce()
- std::multiplies<>()
- std::find_if()
- std::partition()
- std::stable_partition()
- std::remove_if()
- std::remove()
- std::copy_if()
- std::transform()
- std::find_if()
- std::all_of()
- std::any_of()
第三章 函数对象
3.1 函数和函数对象
- 函数是一组命名语句的集合
- 可以被程序的其他部分调用,或在递归中被自己调用
- C++ 提供了几种不同的定义函数方式
- 类 C 语法
int max(int arg) {...}
- 末尾返回类型的格式
auto max(int arg) -> int {...}
- 类 C 语法
3.1.1 自动推断返回值类型
- C++14 开始,完全可以忽略返回值类型,而由编译器根据 return 语句中的表达式进行推断
int answer = 1; auto ask() { return answer; } const auto& ask() { return answer; }
- 还可以使用
decltype(auto)
作为返回值类型decltype(auto) ask() { return answer; }
- 如果想要完美地传递结果,可以使用
- 不加修改地把返回的结果直接返回
template <typename Object, typename Function> decltype(auto) call_on_object(Object&& object, Function func) { return func(std::forward<Object>(object)); }
- 转发引用 (forwading reference)
&&
- 允许接收常对象,也可以接收普通对象和临时值
std::forward
原样传递参数
3.1.2 函数指针
- 是一个存放函数地址的变量,可以通过这个变量调用该函数
- 函数指针(引用)都是函数对象
auto ask() -> int { return 1; } using function_ptr = decltype(ask)*; class convertible_to_function_ptr { public: operator function_ptr() const { return ask; } }; auto ask_ptr = &ask; //函数指针 std::cout << ask_ptr() << std::endl; auto& ask_ref = ask; //函数引用 std::cout << ask_ref() << std::endl; convertible_to_function_ptr ask_weapper; //可以自动转换成函数指针的对象 std::cout << ask_weapper() << std::endl;
3.1.3 调用操作符重载
- 除了创建可以转换成函数指针的类型,C++ 还提供了一个更好的方式创建类似函数的类型
- 创建一个类并重载它们的调用操作符
- 调用操作符可以有任意数目的参数,参数可以是任意类型
- 因此可以创建任意签名的函数对象
class function_object { public: return_type operator()(arguments) const { ... } };
- 函数对象的优点
- 每一个实例都有自己的状态,不论是可变状态还是不可变状态
- 这些状态可以用于自定义函数的行为,而无需调用者指定
//创建一个有内部状态的函数对象类 class older_than { public: older_than(int limit) : m_limit(limit) { } bool operator() (const person_t &person) const { return person.arg() > m_limit; } private: int m_limit; }; ... //这样可以创建不同状态的对象实例 older_than older_than_2(2); older_than_2(person);
3.1.4 创建通用函数对象
- 可以使用模板让函数对象更通用
class older_than { public: older_than(int limit) : m_limit(limit) { } template <typename T> bool operator() (T &&object) const { return std::forward<T>(object).age() > m_limit; } private: int m_limit; }; ... older_than predicate(1); //函数对象可以作为谓词传递给算法 std::count_if(persons.cbegin(), persons.cend(), predicate);
3.2 lambda 和闭包 (Closure)
- lambda
- 是创建匿名函数对象的语法糖
- 允许创建内联函数对象
- 在要使用它们的地方
- 而不是在编写函数之外
3.2.1 lambda 语法
- 由三个主要部分组成
- 头
- 参数列表
- 体
[a, &b](int x, int y) { return a*x + b*y; }
- 头
[a, &b]
指明了包含 lambda 的范围的哪些变量在体中可见- 变量可以作为值也可以作为引用进行捕获
- 如果值捕获,则lambda对象保存这个值的副本
- 如果引用进行捕获,则只保存原来值的引用
- 也可以不声明所需要捕获的变量,由编译器捕获 lambda 体中使用的变量
- 所有变量都作为值进行捕获,可以写作
[=]
- 所有变量都作为引用捕获,可以写作
[&]
- 以值的方式捕获 this 指针,可以写作
[this]
- 除了 a 以外都以值的方式捕获,可以写作
[=, &a]
- 除了 a 以外都以引用的方式捕获,可以写作
[&, a]
3.2.2 lambda 详解
- 示例
std::count_if( m_employees.cbegin(), m_employees.cend(), [this, &team_name] (const person_t &employee) { return team_name_for(employee) == team_name; } );
- C++ 在编译时,lambda 表达式将转换成一个包含两个成员变量的新类
- 这个类包含一个与 lambda 有相同参数和体的调用操作符
- 求解 lambda 表达式时,除了要创建类以外,还要创建一个称作闭包的类实例
- 一个包含某些状态和执行上下文的类对象
- 注意:
- lambda 的调用操作符默认是 const 的
- 如果需要修改捕获变量的值,可以使用 mutable
- 但是应避免这种用法
- lambda 的调用操作符默认是 const 的
3.2.3 在 lambda 中创建任意成员变量
- 可以使用扩展语法
[session = std::move(sessiont), time = current_time()]
3.2.4 通用 lambda 表达式
- 通过指明参数类型为 auto 的方式,lambda 允许创建通用的函数对象
auto predicate = [limit = 42](auto&& object) { return object.age() > limit; }
- 通用 lambda 是一个调用操作符模板化的类,而不是一个包含调用操作符的模板类
- 在创建 lambda 时,如果有多个参数声明为 auto, 这些参数的类型都需要单独进行推断
- 如果通用 lambda 的所有参数同属一种类型,可以使用 decltype 声明后续参数类型
[] (auto first, decltype(first) second) {...}
- C++20 lambda 语法被扩展,允许显示声明模板参数,而不需要声明为 decltype
[] <typename T> (T first, T scend) {...}
3.3 编写比 lambda 更简洁的函数对象
- 可以提供一些方式,让用户编写谓词函数
- 使用一个类重载调用操作符
- 支持比较的谓词函数实现
class error_test_t { public: error_test_t(bool error = true) : error_{ error } { } template<typename T> auto operator()(T&& value) const -> bool { return error_ == (bool)std::forward<T>(value).error(); } error_test_t operator==(bool test) const { //如果 test 为 true,就返回谓词当前的状态 //如果为 false , 就返回状态的逆状态 return error_test_t(test ? error_ : !error_); } error_test_t operator!() const { //返回当前谓词的逆状态 return error_test_t(!error_); } private: bool error_; }; error_test_t error(true); error_test_t not_error(false); ok_responses = filter(responses, not_error); ok_responses = filter(responses, !error); ok_responses = filter(responses, error == false); failed_responses = filter(responses, error); failed_responses = filter(responses, error == true); failed_responses = filter(responses, not_error == false);
3.3.1 STL 中的操作符函数对象
- 算术操作符
- std::plus
- std::minus
- std::multiplies
- std::divides
- std::modulus
- std::negates
- 比较操作符
- std::equal_to
- std::not_equal_to
- std::greater
- std::less
- std::greater_equal
- std::less_equal
- 逻辑操作符
- std::logical_and
- std::logical_or
- std::logical_not
- 位运算操作符
- std::bit_and
- std::bit_or
- std::bit_xor
- 菱形操作符
- C++14开始,在调用标准库中的操作符包装器时,无需指明类型
- 可以写作
std::greater<>()
, 而不用写作std::greater<int>()
- 在调用时会自动推断参数的类型
- 可以写作
- C++14开始,在调用标准库中的操作符包装器时,无需指明类型
3.4 用 std::function 包装函数对象
- 如果要接收函数对象作为参数,或创建变量保存 lambda 表达式,到目前为止只能依赖自动类型检测
- 标准库提供了 std::function 类模板,可以包装任何类型的函数对象
std::function<float(float, float)> test_function;
test_function = std::fmaxf; //普通函数
test_function = std::multiplies<float>(); //含有调用操作符的类
test_function = std::multiplies<>(); //包含通用调用操作符的类
test_function = [x](float a, float b) { return a*x + b; }; // lambda 表达式
test_function = [x](auto a, auto b) { return a*x + b; }; //通用 lambda
- std::function 并不是对包含的类型进行模板化,而是对函数对象的签名进行模板化
- 还可以存储不提供普通调用语法的内容,例如
- 类的成员变量和类的成员函数
- 本书把函数对象连同指向成员变量和函数的指针称为 callables
- 使用 std::function 注意事项
- 不能滥用,因为它有明显的性能问题
- 为了隐藏包含的类型并提供一个对所有可调用类型的通用接口,其使用类型擦除技术
- 它是基于虚成员函数调用,因为在运行时进行,编译器不能在线调用,失去优化机会
- 为了隐藏包含的类型并提供一个对所有可调用类型的通用接口,其使用类型擦除技术
- 虽然调用操作符限定为 const, 但它可以调用非 const 对象。
- 在多线程代码中,容易导致各种问题
- 不能滥用,因为它有明显的性能问题
- 小函数对象优化
- 当包装的对象是函数指针或者 std::reference_wrapper时,小对象优化就会执行
- 这些可调用对象就存储在 std::function 对象中,无须动态分配任何内存
- 优化的最大值
- 与编译器和标准库的实现有关
- 比较大的对象需要动态分配内存,通过指针访问对象 std::function
- 在调用 std::function 的调用操作符时,由于 std::function 对象的创建和析构,可能对性能产生影响。
总结
关键 API
- decltype(auto)
- std::forward()