【C++ 模板编程 实用手段】深入理解 C++ 中的 packaged_task、invoke_result_t、bind、result_of 和 Lambda


1. 引言

在编程的世界中,理解和掌握核心概念至关重要。正如Bjarne Stroustrup在《The C++ Programming Language》中所说:“C++ 是一种直接和高效的语言,它提供了对硬件的强大控制。” 本文将深入探讨C++中的 packaged_taskinvoke_result_tbindresult_of 和 Lambda,这些都是编程中常用的强大工具。

每当我们站在知识的十字路口时,都会面临选择。每种方法、技术或工具都有其独特的优点和适用场景。我们选择的方法不仅仅是基于它的功能性,还基于我们的心态和对问题的理解。在我们探索这些C++特性时,让我们不仅仅从技术的角度来看,还要从心理的角度来了解为什么某些特性会更受欢迎,为什么它们的设计哲学是这样的。

1.1 C++的快速发展

C++ 是一个不断发展的语言。自从它诞生以来,C++ 一直在进化,为开发者提供更多的工具和更高的效率。但随着工具和技术的增加,也带来了更多的复杂性。正如一个古老的智慧所说:“简单性不是简化,而是在混乱的复杂性中找到平衡。”

在C++的早期版本中,我们使用的工具和技术可能现在看起来有些过时或不够优雅。但正如每一代的工匠都会根据他们手头的工具来创造,我们也应该尊重这些早期的方法,因为它们为今天的进步奠定了基础。

1.2 本文的主题与重要性

本文主要探讨的五个C++特性是现代C++开发中的核心概念。不仅仅是因为它们提供了强大的功能,更重要的是,它们代表了C++的哲学和设计原则。

例如,Lambda 表达式允许我们以简洁、直观的方式表示复杂的操作,而不需要定义额外的函数或类。而 std::bindstd::packaged_task 则为异步编程和函数式编程提供了强大的工具。

在深入探讨这些特性之前,我们需要理解它们的起源、设计目标和如何在实际编程中使用它们。通过这种方式,我们不仅可以更好地理解这些特性,还可以学习如何更有效地使用C++。

2. 理解 std::packaged_task

std::packaged_task 是 C++11 引入的一个强大工具,允许我们将任何可调用对象(函数、lambda、成员函数指针等)包装起来,并与 std::future 配合,从而执行异步任务并检索结果。

2.1 定义和主要用途

std::packaged_task 本质上是一个包装器,它将任务与一个 std::future 对象关联在一起。当任务完成执行后,其结果(或异常)会存储在与之关联的 std::future 对象中。这意味着我们可以在任务完成后的任何时刻,从任何线程获取结果。

这种设计与人类的习惯性思维模式相契合。当我们向他人委托一个任务时,我们常常会想知道任务何时完成,以及最终的结果是什么。正如 Confucius 在《论语》中所说:“三人行,必有我师。” 我们可以从任何情境中学到知识,就像我们可以从任何线程中获取 std::packaged_task 的结果一样。

2.2 如何使用它与 std::future 配合执行异步任务

让我们通过一个简单的示例来展示这一点。

#include <iostream>
#include <future>
#include <thread>

int sum(int a, int b) {
    
    
    return a + b;
}

int main() {
    
    
    // 将函数包装到packaged_task中
    std::packaged_task<int(int, int)> task(sum);
    
    // 获取与packaged_task关联的future
    std::future<int> result = task.get_future();
    
    // 在另一个线程上执行任务
    std::thread(std::move(task), 5, 3).detach();

    // 在主线程上获取结果
    std::cout << "Sum: " << result.get() << std::endl;  // 输出 "Sum: 8"

    return 0;
}

在上述代码中,我们定义了一个简单的函数 sum,然后创建了一个 std::packaged_task 来包装这个函数。我们还创建了一个与这个任务关联的 std::future 对象,以便稍后检索结果。

2.3 实际示例

考虑一个复杂的场景,例如计算一个大数组的和。假设我们想把这个大数组分成小块,然后在多个线程上并行计算每个小块的和。最后,我们将所有这些小块的和加在一起,得到整个数组的和。

这种情况下,std::packaged_taskstd::future 就非常有用了。我们可以为每个小块创建一个 std::packaged_task,然后在不同的线程上执行它们。当所有这些任务都完成后,我们可以简单地从每个 std::future 对象中获取结果,并将它们加在一起。

正如 Bjarne Stroustrup 在《The C++ Programming Language》中所说:“C++ 的主要目的是使抽象成为现实。” 在这种情境下,std::packaged_taskstd::future 允许我们将多线程编程的复杂性抽象出来,使其变得更加简单和直观。

此外,如果我们深入到 GCC 的源码中,可以看到 std::packaged_task 是如何实现的。在 libstdc++ 的实现中,std::packaged_task 是定义在 future 头文件中的。它的主要功能是通过 _M_invoke 方法来执行存储的任务。这一切都是在 bits/future.h 文件中实现的。

3. 从 std::result_ofstd::invoke_result_t

在早期的 C++ 标准中,std::result_of 是一个非常有用的工具,它可以帮助我们得知一个函数调用的返回类型。但是,随着时间的推移,我们发现它有一些不足之处,尤其是在新标准的上下文中。因此,C++17 引入了一个新的、更强大的工具:std::invoke_result_t

3.1 为什么需要这些工具?

在模板编程中,我们经常需要知道某个函数或可调用对象的返回类型。这些信息可以帮助我们为函数的输出创建适当的存储、做类型检查或决定如何进一步处理这些输出。

例如,当你有一个函数模板,它的返回类型取决于它的参数,或者当你有一个返回类型是 lambda 的函数。在这些情况下,你不能简单地查看函数的签名来确定它的返回类型,因为这个类型是动态的,取决于实际传递给函数的参数。

这正是 std::result_ofstd::invoke_result_t 发挥作用的地方。

3.2 std::result_of 的工作方式

std::result_of 是一个模板类,它接受一个函数类型 F(Args...) 作为参数。其中,F 是可调用对象的类型,Args... 是一系列参数类型。这个模板类有一个名为 ::type 的嵌套类型,它表示调用该函数时的返回类型。

例如:

double foo(int, float);
std::result_of<decltype(foo)(int, float)>::type // 这是 double 类型

但是,std::result_of 有一些局限性,需要使用者非常小心。首先,你必须确保你提供的函数类型是有效的,否则你会得到一个编译错误。其次,如果函数不接受任何参数,那么你必须为它提供 void 类型的参数。

这种语法可能会对初学者造成困惑,并导致一些难以诊断的编译错误。

3.3 介绍 std::invoke_result_t

为了解决上述问题,C++17 引入了 std::invoke_result_t。它的工作原理类似于 std::result_of,但提供了更清晰、更直观的语法。

std::result_of 不同,std::invoke_result_t 直接接受函数和参数类型作为模板参数,并返回相应的返回类型。

例如:

double foo(int, float);
using ReturnType = std::invoke_result_t<decltype(foo), int, float>; // 这是 double 类型

这种新的语法清晰明了,很少有出错的机会。

3.4 示例对比两者的使用

让我们通过一个简单的示例来比较这两种工具的使用。

首先,使用 std::result_of

template<typename Func, typename Arg1, typename Arg2>
void print_result(Func f, Arg1 a1, Arg2 a2) {
    
    
    using ResultType = typename std::result_of<Func(Arg1, Arg2)>::type;
    ResultType result = f(a1, a2);
    std::cout << "Result: " << result << std::endl;
}

现在,使用 std::invoke_result_t

template<typename Func, typename Arg1, typename Arg2>
void print_result(Func f, Arg1 a1, Arg2 a2) {
    
    
    using ResultType = std::invoke_result_t<Func, Arg1, Arg2>;
    ResultType result = f(a1, a2);
    std::cout << "Result: " << result << std::endl;
}

正如Bjarne Stroustrup在《The C++ Programming Language》

中所说:“选择正确的工具是至关重要的,它不仅可以简化你的工作,还可以提高你的工作效率。”

3.5 总结

std::result_ofstd::invoke_result_t 都是为了解决同一个问题而设计的:确定给定函数和参数的返回类型。但随着 C++ 的发展,我们发现 std::result_of 的语法和用法可能会导致错误和困惑。因此,C++17 引入了 std::invoke_result_t,它提供了更清晰、更直观的语法,并减少了出错的机会。

4. 探索 std::bind 和 Lambda 表达式

在现代 C++ 编程中,函数对象和可调用实体扮演着非常重要的角色。它们为编程带来了巨大的灵活性,尤其是在高阶函数、多线程和异步编程中。这一章,我们将深入探讨两个非常有用的工具:std::bind 和 Lambda 表达式。

4.1 std::bind 的定义和主要用途

std::bind 是一个强大的函数模板,它返回一个可调用对象来“绑定”一个或多个参数。简而言之,它的主要作用是将给定的参数与函数或可调用对象绑定在一起,以产生一个新的无参数或减少参数的函数。

例如,假设我们有一个函数:

int add(int a, int b) {
    
    
    return a + b;
}

使用 std::bind,我们可以创建一个新的无参数函数,该函数在被调用时总是返回 3 + 4 的结果:

auto bound_add = std::bind(add, 3, 4);
std::cout << bound_add();  // 输出 7

正如 Bjarne Stroustrup 在《The C++ Programming Language》中所说:“使用 std::bind 可以非常灵活地组合函数和参数。”

但是,随着 C++11 的发展,引入了一个新的、更简洁的方式来实现相同的功能:Lambda 表达式。

4.2 Lambda 表达式的简介和其强大之处

Lambda 表达式(或简称 Lambda)是一种匿名函数对象。它为 C++ 添加了闭包的功能,闭包是一个可调用的实体,可以访问其创建位置的局部变量。这为 C++ 带来了巨大的编程灵活性。

例如,上面的 add 函数也可以使用 Lambda 表达式重写为:

auto lambda_add = [](int a, int b) {
    
    
    return a + b;
};
std::cout << lambda_add(3, 4);  // 输出 7

而要达到 std::bind 的效果,我们可以这样做:

auto bound_lambda = []() {
    
    
    return lambda_add(3, 4);
};
std::cout << bound_lambda();  // 输出 7

Lambda 表达式的主要优点在于其简洁性和直观性。正如某位心理学家所说:“简洁性和直观性是有效沟通的关键。” 当我们阅读代码时,Lambda 表达式往往更容易理解,因为它们直接在使用的地方定义,而不需要查找其他地方的函数定义。

4.3 如何用 Lambda 替代 std::bind

从上面的例子中,我们可以看到,Lambda 表达式为我们提供了一种非常简洁的方式来绑定函数和参数。但在更复杂的情境下,Lambda 表达式和 std::bind 之间有什么区别呢?

考虑下面的例子:

void print_sum(int a, int b, int c) {
    
    
    std::cout << a + b + c << std::endl;
}

// 使用 std::bind
auto bound_print = std::bind(print_sum, 1, 2, std::placeholders::_1);
bound_print(3);  // 输出 6

// 使用 Lambda 表达式
auto lambda_print = [](int c) {
    
    
    print_sum(1, 2, c);
};
lambda_print(3);  // 输出 6

在这个例子中,我们使用了 std::placeholders,它是 std::bind 的一部分,允许我们在调用绑定的函数时传递参数。与之相对,Lambda 表达式提供了一个更直接的方式来定义参数。

在源码级别,Lambda 表达式和 `std

::bind都会生成函数对象。例如,在 GCC 编译器中,Lambda 表达式会被转换为匿名结构,其中重载了operator()方法。这可以在 GCC 源码的` 头文件中找到。

4.4 对比 std::bind 和 Lambda 的示例

为了进一步理解两者的差异和优势,让我们考虑以下情况:

特性 std::bind Lambda 表达式
语法简洁性 较为复杂,需要使用 std::placeholders 更加简洁,直观
性能 在某些编译器上可能稍慢,因为它可能产生额外的函数调用 通常更快,因为它在很多情况下可以被内联
可读性 可能需要查阅文档来理解绑定的参数和占位符 更容易阅读,因为它直接在使用的地方定义
在编译器中的实现 通常作为函数模板实现 作为匿名结构实现,重载了 operator() 方法

正如某位心理学家所言:“人们对直观和简洁的信息有天生的偏好。” 当我们在选择使用 std::bind 还是 Lambda 表达式时,考虑到这种天生的偏好,以及每种方法的优点和局限性,可以帮助我们做出明智的决策。

4.5 总结

在现代 C++ 编程中,std::bind 和 Lambda 表达式都为我们提供了强大的工具来创建和使用函数对象。虽然 std::bind 在某些情况下可能仍然有其用处,但 Lambda 表达式因其简洁性、直观性和性能优势而越来越受欢迎。通过理解这两个工具的工作原理和用途,我们可以更加有效地使用 C++ 为我们提供的功能。

5. 深入 enqueue 函数:综合应用

在程序设计中,我们常常面临选择如何最有效地组织和执行任务的问题。这是一个不仅涉及技术,还涉及人的思维和决策过程的问题。在此章节中,我们将深入探讨 enqueue 函数,并了解如何利用 C++ 的高级特性对其进行改进。

5.1 回顾原始的 enqueue 函数

首先,我们回顾一下之前的 enqueue 函数实现。它的目的是将一个任务添加到线程池中。

template<class F, class... Args>
auto enqueue(F&& f, Args&&... args) 
    -> std::future<typename std::result_of<F(Args...)>::type>
{
    
    
    using return_type = typename std::result_of<F(Args...)>::type;

    auto task = std::make_shared< std::packaged_task<return_type()> >(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...)
        );

    std::future<return_type> res = task->get_future();

    {
    
    
        std::unique_lock<std::mutex> lock(queue_mutex);

        if(stop) {
    
    
            throw std::runtime_error("enqueue on stopped ThreadPool");
        }

        tasks.emplace([task](){
    
     (*task)(); });
    }

    condition.notify_one();

    return res;
}

这个函数的核心是创建一个任务,并将其添加到一个待执行的任务队列中。任务是使用 std::bind 创建的,这样,当这个任务在稍后被执行时,它将调用函数 f 并传入参数 args

5.2 结合 std::invoke 和 Lambda 的改进

正如Bjarne Stroustrup在《The C++ Programming Language》中所说:“我们不应该因为某样东西是新的、不同的或时髦的而去使用它,而是应该考虑它是否更合适。” 结合这个观点,我们看到 std::bind 在很多情况下实际上并不是最佳选择。与之相反,C++14 和 C++17 提供了更先进的特性,如 lambda 和 std::invoke,它们为我们提供了更清晰、更直观的工具。

我们先看下使用 lambda 和 std::invoke 重写的 enqueue 函数:

template<class F, class... Args>
auto enqueue(F&& f, Args&&... args) 
    -> std::future<std::invoke_result_t<F, Args...>>
{
    
    
    using return_type = std::invoke_result_t<F, Args...>;

    auto task = std::make_shared<std::packaged_task<return_type()>>(
        [f = std::forward<F>(f), ...args = std::forward<Args>(args)]() mutable {
    
    
            return std::invoke(f, args...);
        }
    );

    std::future<return_type> res = task->get_future();

    {
    
    
        std::unique_lock<std::mutex> lock(queue_mutex);

        if(stop) {
    
    
            throw std::runtime_error("enqueue on stopped ThreadPool");
        }

        tasks.emplace([task](){
    
     (*task)(); });
    }

    condition.notify_one();

    return res;
}

对比两个版本的 enqueue 函数,我们看到 lambda 结合 std::invoke 提供了一个更清晰和直观的方式来捕获和调用函数。特别是,我们使用了 generalized lambda capture 来捕获函数和参数,然后在 lambda 的主体中使用 std::invoke 来调用函数。这不仅简化了代码,还使其更易于阅读和维护。

5.3 优势与实际应用

5.3.1 更直观的语法

使用 std::invoke 和 lambda,我们可以更自然地表示函数调用,而不需要涉及复杂的 std::bind 语法。对于读者和维护者来说,这意味着更少的认知负担。

5.3.2 更强的类型安全

std::invoke_result_t 提供了一个强类型的方式来确定函数的返回类型,这使我们能够在编译时捕获更多的错误,而不是在运行时。

5.3.3 更好的性能

在某些编译器和设置下,使用 std::invoke 和 lambda 可能比 std::bind 提供更好的性能,尤其是在涉及大量函数调用的情况下。

结论:改进后的 enqueue 函数不仅更清晰、更简洁,而且在某些情况下还可能更高效。这是一个典型的例子,说明了如何通过使用 C++ 的新特性来改进旧代码,使其更易于维护和扩展。

在下一章中,我们将探讨如何进一步优化和扩展 enqueue 函数,以支持更多的用例和功能。

结语

在我们的编程学习之旅中,理解是我们迈向更高层次的重要一步。然而,掌握新技能、新理念,始终需要时间和坚持。从心理学的角度看,学习往往伴随着不断的试错和调整,这就像是我们的大脑在逐渐优化其解决问题的“算法”。

这就是为什么当我们遇到错误,我们应该将其视为学习和进步的机会,而不仅仅是困扰。通过理解和解决这些问题,我们不仅可以修复当前的代码,更可以提升我们的编程能力,防止在未来的项目中犯相同的错误。

我鼓励大家积极参与进来,不断提升自己的编程技术。无论你是初学者还是有经验的开发者,我希望我的博客能对你的学习之路有所帮助。如果你觉得这篇文章有用,不妨点击收藏,或者留下你的评论分享你的见解和经验,也欢迎你对我博客的内容提出建议和问题。每一次的点赞、评论、分享和关注都是对我的最大支持,也是对我持续分享和创作的动力。


阅读我的CSDN主页,解锁更多精彩内容:泡沫的CSDN主页
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_21438461/article/details/132850229