闭关之 C++ 函数式编程笔记(一):函数式编程与函数对象

前言

   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 {...}

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
      • 但是应避免这种用法

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>()
      • 在调用时会自动推断参数的类型

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()
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值