C++ Lambda

第四章 Lambda in C++17

预计阅读时间10 分钟 18 views

C++ 17 中对 Lambda 表达式的增强:

  1. 'constexpr' Lambdas: 在 C++17 中引入了 'constexpr' Lambdas,使其在编译期就可以进行计算,从而提高性能并支持更多的编译时计算。
  2. 捕获 '*this': C++17 允许显式捕获 '*this' 指针,使其能够直接访问封闭类型实例的成员。

类型系统的更新:

  • C++17 更新了类型系统,增加了异常规范,这一变化也适用于 Lambda 表达式,使得可以指定 Lambda 是否会抛出异常。

涉及的其他技术

  • 改进 IIFE 模式:C++17 提供了对立即调用函数表达式(IIFE)模式的增强,使其在使用 Lambdas 时更加高效且易于操作。
  • 使用折叠表达式改进可变数参数泛型 Lambdas: C++17 引入了折叠表达式,这简化了泛型 Lambda 中可变参数模板的使用,可以使代码看起来更加简洁。
  • 从多个 Lambdas 派生:新特性允许从多个 Lambdas 派生,这提供了更灵活的设计模式,使 Lambda 组合更强大且多功能。
  • Lambdas 与异步执行: C++17 还改进了 Lambdas 与异步执行之间的交互,促进了更高效和有效的并发编程。

Lambda 语法的更新

在 C++17 中,Lambda 表达式的语法发生了一些改变:

  • 在参数列表后可以添加 'constexpr'
  • dynamic exception specification 在 C++11 中被弃用,在 C++17 中被移除,所以在实际使用中,应该只使用 'noexcept'
可选参数捕捉列表                        Lambda 函数体
|                                           |
|                                           |
v                                           v
[]() specifiers exception attr -> ret { /* code; */ }
   ^                ^               ^
   |                |               |
   |                |               |
   参数列表(可选)    |            指定尾返回类型
                    |
                    |
             修饰符:mutable, constexpr, attributes(可选)
C++

C++17 中的异常规范与类型系统

在 C++17 中,异常规范(如'noexcept')成为了函数类型的一部分,这就意味着可以为函数重载提供不同的异常规范:

using TNoexceptVoidFunc = void(*)() noexcept;
void SimpleNoexceptCall(TNoexceptVoidFunc f) { f(); }

using TVoidFunc = void (*)();
void SimpleCall(TVoidFunc f) { f(); }

void fNoexcept() noexcept { }
void Regular() { }

int main(){
    SimpleNoexceptCall(fNoexcept);
    SimpleNoexceptCall([]() noexcept { });
    // SimpleNoexceptCall(fRegular);    // regular -> noexcept failed
    // SimpleNoexceptCall([]() { });    // regualr -> noexcept failed

    SimpleCall(fNoexcept);
    SimpleCall(fRegular);
    SimpleCall([]() noexcept { });
    SimpleCall([]() { });
C++

在上面的代码中,指向'noexcept'函数的指针可以转换为指向普通函数的指针(包括成员函数指针和 Lambda 表达式),反之,则不行(即普通函数指针转换为'noexcept'函数指针)。

性能优化

引入'noexcept'的一个原因是为了优化代码。如果编译器能够确定函数不会抛出异常,那么它就会生成更高效的代码。 在标准库中,'noexcept'被广泛应用,允许库根据函数是否抛出异常来进行优化:例如,'std::vector'可以区分存储的类型是否能在不抛出异常的情况下移动,从而优化操作。

使用'noexcept'优化调用

#include <iostream>
#include <type_traits>

template<typename Callable>
void CallWith10(Callable&& fn){
    if constexpr (std::is_nothrow_invocable_v<Callable, int>){
        std::cout << "Calling fn(10) with optimisation\n";
        fn(10);
    }else{
        std::cout << "Calling fn(10) normally\n";
        fn(10);
    }
}

int main(){
    int x{ 10 };
    const auto lam = [&x](int y) noexcept { x += y; };
    CallWith10(lam);

    const auto lamEx = [&x)(int y) noexcept {
        std::cout << "LamEx with x = " << x << '\n';
        x += y;
    };
    CallWith10(lamEx);
}
/* output:
 * Calling fn(10) with optimisation
 * Calling fn(10) normally
 * lamEx with x = 20
 */
C++

上述例子中,使用'std::is_nothrow_invocable_v'来检查可调用对象是否标记了'noexcept',并根据结果选择不同的调用方式。

C++17 中的异常规范清理

在 C++17 中,异常规范得到了简化。实际上,只能使用'noexcept'来声明函数不会抛出异常,而动态异常规范('throw(X, Y, Z)')已经被移除。

C++17 中的 'constexpr' Lambda 表达式

自 C++11 以来,'constexpr' 允许越来越多的代码在编译时进行计算,这不仅提升了性能,还使得编译时的代码编写更加灵活和强大, C++17 引入了将 'constexpr' 应用于 Lambda 表达式的能力。

关键点:

  • 如果 Lambda 表达式的主体符合'constexpr'函数的规则,那么其闭包类型的'operator()'将被隐式转换为'constexpr'。
  • C++17 中'constexpr'函数的规则包括:
    • 不是虚函数;
    • 返回类型必须是字面量类型;
    • 每个参数类型必须是字面量类型;
    • 函数体不能包含'asm'定义、'goto'语句、标识符标签,'try'块,或者是非字面量类型变量、静态变量或线程存储变量的定义。

简单的'constexpr' Lambda 表达式

constexpr auto Square = [](int n) { return n * n; };
static_assert(Square(2) == 4);
C++
  • 上述例子中,'Square' Lambda 表达式因为其主体简单且符合'constexpr'的规则,因此被隐式标记为'constexpr',使其可以在编译时被调用。

'constexpr' Lambda 表达式示例

#include <array>
template<typename Range, typename Func, typename T>
constexpr T SimpleAccumulate(Range&& range, Func func, T init){
  for(auto&& elem: range) init += func(elem);
  return init;
}

int main(){
  constexpr std::array arr{1, 2, 3};
  constexpr auto sum =
  SimpleAccumulate(arr,
              [](auto i) { return i * i; },
              0};
  static_assert(sum == 14);
}
C++
  • 上述代码中使用了'constexpr'Lambda表达式并将其传递给'SimpleAccumulate'函数,由于Lambda表达式体内的计算简单并且符合'constexpr'规则,因此编译器会将其隐式标记为'constexpr',从而允许在编译期执行整个代码。

递归调用'constexpr' Lambda

使用递归'constexpr'Lambda 表达式计算阶乘。

int main(){
  constexpr auto factorial = [](int n){
    constexpr auto fact_impl = [](int n, const auto& impl) -> int{
      return n > 1 ? n * impl(n - 1, impl) : 1;
    }
    return fact_impl(n, fact_impl);
  }
  static_assert(factorial(5) == 120);
  /* use static_assert to verify the output */
}
C++

捕获变量

在 C++ 中,可以在 Lambda 表达式中捕获'constexpr'变量。 这些变量通常是常量表达式,编译器可以对其进行优化。

示例

constexpr int add(int const& t, int const& u) { return t + u; }

int main(){
  constexpr int x = 0;
  constexpr auto lam = [x](int n){ return add(x, n); };
  static_assert(lam(10) == 10);
}
C++

在上述例子中,'x'被捕获并用于 Lambda 表达式中。
然而,在某些情况下,即时捕获了变量,编译器可能发出警告,提示捕获变量并非必要,例如当捕获的变量仅用于加法操作且其值在编译时已知时。

编译器优化

如果变量是 'constexpr' 且类型为内置类型,编译器可以在编译时优化掉这些变量。 因此,对于编译器已知值的 'constexpr' 变量,可能不需要显式捕获。

补充:

  • 当变量是 'const' 非易失性的整型或枚举类型,并且已用于常量表达式初始化时,Lambda 表达式可以在不捕获该变量的情况下读取其值。
  • 如果变量时 'constexpr' 并且没有可变成员,Lambda 也可以在捕获该变量的情况下使用它。

捕获与优化

在某些情况下,例如当函数返回变量的地址并与捕获的变量比较时,编译器需要在闭包中存储捕获的变量副本,这样的捕获无法被优化掉。

'constexpr' 总结

  • 'constexpr' Lambda 可以与模板相结合,减少代码长度。
  • 在 C++20 中,随着大量 'constexpr' 标准算法和容器的引入,'constexpr' Lambda 将变得非常有用,允许代码在运行时和编译时都保持相同的形式。

捕获 '*this'

在 C++ 中,捕获类成员时默认会捕获'this'指针。然而,如果临时对象在 Lambda 表达式执行之前就已经销毁,可能会导致未定义行为。为了解决这个问题,C++17 引入了一种新方式以捕获 '*this' 的副本:

#include <iostream>

struct Baz{
  auto foo(){
    return [*this]() { std::cout << s << '\n'; }
    /* caputure *this(s) in Lambda scope */
  }
  std::string s;
};

int main(){
  const auto f1 = Baz{"QwQ"}.foo();
  const auto f2 = Baz{"qwq"}.foo();
  f1();
  f2();
}
/* output:
 * QwQ
 * qwq
 */
C++

在上述代码中,'[*this]' 使得 Lambda 捕获 '*this' 的副本,并保存到闭包对象中。这样,在 Lambda 执行时,即使原始对象已经销毁,仍然不会出现未定义行为。

捕获 '[this]' 与 '[*this]'

  • 捕获 '[this]':适用于大多数场景,尤其是在对象较大时,避免不必要的副本创建。
  • 捕获 '[*this]':适用于需要复制对象的情况,特别是在异步或并行执行时, Lambda 可能会比对象存活更久,此时使用 '[*this]' 可以避免数据竞争和未定义行为。

总结: 使用 '[this]' 还是 '[*this]' 取决于具体需求。如果 Lambda 的生命周期可能超出对象的的生命周期,最好使用 '[*this]' 来避免潜在的错误。否则,使用 '[this]' 可以避免不必要的性能开销。

IIFE(立即调用函数表达式)的更新

在 C++11 中,引入了 IIFE 技术,通过立即调用 Lambda 表达式来进行初始化。然而,这种写法的可读性较差,容易忽略函数调用的部分:

const auto var = [&]{
  if(TheFirstCondition()) return 1st_value;
  if(TheSecondCondition())  return 2nd_value;
  return default_val;
}();
C++

在这种写法中,变量'var'的定义可能会让人误解'var'是一个闭包对象,而不是立即调用表达式的结果。

IIFE 在C++17 中的改进

C++17 引入了一个新的模板函数 'std::invoke()',使得 IIFE 的表达方式更加清晰,避免了语法上不容易察觉的函数调用:

const auto var = std::invoke([&] {
  if(TheFirstCondition()) return 1st_val;
  if(TheSecondCondition()) return 2nd_val;
  return defualt_val;
});
C++

另一个示例:

#include <functional>
#include <iostream>
#include <string>

int main(){
  const std::string in { "Hello World"; };
  const std::string out = std::invoke([copy = in]() mutable{
    copy.replace(copy.find("World"), 5, "C++");
    return copy;
  }
  std::cout << out;
}
/* output:
 * Hello C++
 */
C++

在上述代码中,'std::invoke()'用于立即调用一个 Lambda 表达式,并将计算结果赋值给一个 'const' 对象。

总结

C++17 的 'std::invoke' 提升了 IIFE 的可读性,使代码的意图的更加明确,特别是在涉及复杂逻辑或避免闭包误解时非常有用。

使用 IIFE 提取函数

通过 Lambda 表达式和 IIFE(立即调用表达式),可以将代码块提取为更小的函数,确保代码安全且能够正确编译。
这种技术能够帮助开发者在进行代码重构操作时能够安全地提取函数,避免遗漏必要的输入输出变量。

步骤:

  1. 使用 IIFE 包裹代码:首先,将要提取的代码块使用 IIFE 包裹,并通过 '&' 捕获所有变量。
  2. 编译并检查错误: 编译代码,编译器会提示 IIFE 中的变量在外部不可见,这些变量通常就是需要返回的值。
  3. 移除捕获并检查输入:移除 '&' 捕获,编译器会提示需要传递给 Lambda 的参数。这些参数都是函数的输入。
  4. 命名并提取函数:将 IIFE 的代码复制出来,命名为一个新函数,并在适当的地方调用它。

示例:设现在有一段计算学生平均分数的代码,现在要将计算平均分数的逻辑提取为一个独立函数:

std::vector<std::pair<double, std::string>> averages;
for(auto& [marks, name]: db){
  double avg = std::accumulate(marks.begin(), marks.end(), 0.0) / marks.size();
  averages.push_back({avg, name})
}
C++

使用 IIFE 提取

  1. 用 IIFE 包裹计算逻辑:auto averages = [&](){ std::vector<std::pair<double, std::string>> out; /* calculating process */ return out; }();这一步会暴露需要返回的变量 'averages'。
  2. 确定输入参数:auto averages = [](const std::vector<Student>& db){ std::vector<std::pair<double, std::string>> out; /* calcalulating process */ return out; }(db);
  3. 提取为独立函数:auto ComputeAverages(const std::vector<Student>& db){ std::vector<std::pair<double, std::string>> out; /* calculating process */ return out; }调用时:auto averages = ComputeAverages(db);

优势

这种技术确保了在编写代码时不会遗漏提取函数的必要输入和输出。此外,提取后的函数使得代码更简洁,并且可以进一步优化。

可变参数泛型 Lambda 的更新

在 C++14 中,可以在泛型 Lambda 中使用可变参数列表,但在 C++17 中,由于折叠表达式的引入,使得代码更加简洁和直观, 使用折叠表达式进行求和计算:

#include <iostream>

int main(){
    const auto sumLambda = [](auto... args){
        std::cout << "sum of: " << sizeof...(args) << " numbers\n";
        return (args + ... + 0);
    }
    std::cout << sumLambda(1, 2, 3, 4, 5);
}
/* output:
 * sum of: 5 numbers
 * 15
 */
C++

与 C++14 的递归实现相比,这里不再需要递归调用。折叠表达式为组合可变参数提供了简单且相对直观的语法。

使用折叠表达式实现可变参数的打印输出:

#include <iostream>

int main(){
    const auto printer = [](auto... args) { (std::cout << ... << args) << '\n'; }
    printer(1, 2, 3, "hello", 10.5f);
}
/* output:
 * 123hello10.5
 */
C++

然而这段代码的输出没有任何分隔符,为了解决这个问题,可以引入一个辅助 Lambda 表达式,并在逗号 ',' 操作符上进行折叠:

#include <iostream>

int main(){
    const auto printer = [](auto... args){
        const auto printElem = [](auto elem){
            std::cout << elem;
        }/* define a assistant function */
        (printElem(args), ...);
        std::cout << '\n';
    }
    printer(1, 2, 3, "hello", 10.5f);
}
/* output:
 * 1, 2, 3, hello, 10.5,
 */
C++

为了去掉最后一个元素的逗号 ',',修改后的代码如下:

#include <iostream>

int main(){
    const auto printer [](auto... args){
        std::cout << first;
        ((std::cout << ", " << args), ...);
        std::cout << '\n';
    };
    printer(1, 2, 3, "hello", 10.5f);
}
/* output:
 * 1, 2, 3, hello, 10.5
 */
C++

先输出第一个元素,然后再在每个元素的前面添加一个逗号','。

多个 Lambda 表达式的继承

在 C++11 中,可以从一个 Lambda 表达式派生类,但是该技术应用场景有限,主要是因为只能处理固定数量的 Lambda。

Lambda 表达式的继承在 C++17 中的改进

  • 在 C++17 中,引入了一种新的模式,使得可以从可变数量的 Lambda 表达式派生,这通过使用可变参数模板来实现,允许我们指定可变数量的基类(Lambda)。
template<class... Ts> struct overloaded: Ts { using Ts::operator()...; }
template<class... Ts> overloaded(Ts...)-> overloaded<Ts...>;
C++

这个模板允许将多个 Lambda 表达式组合到一个类中,并能够根据传入参数的类型自动选择合适的 Lambda 表达式:

#include <iostream>

template<class... Ts> struct overloaded: Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...)-> overloaded<Ts...>;

int main(){
    const auto test = overloaded{
        [](const int& i) { std::cout << "int: " << i << '\n'; },
        [](const float& i) { std::cout << "float: " << i << '\n'; },
        [](const std::string& i) { std::cout << "string: " << i << '\n'; }
    };
    test("10.0f");
}
/* output:
 * string: 10.0f
 */
C++

在上面的代码中,创建了一个 test 对象,它是由三个接受不同类型参数的 Lambda 表达式组成的, 使用 test 时调用哪个 Lambda 表达式取决于 test 接受的参数的类型。

上面效果的实现主要得益于 C++17 的三个特性:

  1. 在 using 声明中展开包:允许使用简洁的语法结合可变模板。
  2. 自定义模板参数推导规则:能够将 Lambda 对象列表转换为 'overloaded' 类的基类列表。
  3. 扩展的聚合初始化:在 C++17 之前,无法对从其他类型派生的类型进行聚合初始化。

自定义模板参数推到规则

在 C++ 17 之前,模板参数推导在 C++17 引入了新的规则,使得创建一些常见的模板类型变得更加简单。在此之前,对于 Lambda 表达式, 由于编译器会为每个 Lambda 生成一个唯一的类型名称,无法按直接推导出 Lambda 的类型,因此必须使用 'make' 函数来创建对象:

template<typename... T>
constexpr auto make_overloader(T&&... t){
    return overloaded<T...> { std::forward<T>(t)...};
}
C++

然而,在 C++17 中,引入了模板参数的推导规则,可以不再需要 'make_overloader'函数:

std::pair strDouble { std::string{"Hello"}, 10.0 };
/* strDouble 的类型为 std::pair<std::string, double> */
C++

此外,还可以自定义推导规则,例如,对于 'std::array' 可以这样定义:

template<class T, class... U>
array(T, U...)-> array<T, 1 + sizeof...(U)>;
C++

这个规则允许这样初始化 'std::array':

array test{1, 2, 3, 4, 5};
C++

对于 'overloaded' 模式,同样也可以自定义一个推到规则:

template<class... Ts> overloaded(Ts...)-> overloaded<Ts...>;
C++

此时,使用两个 Lambda 初始化 'overloaded' 对象时,编译器可以正确推导出 'overloaded' 的模板参数类型,不再需要显式指定:

overloaded myOverload { [](int) { }, [](double) { } };
C++

扩展到聚合初始化

  • 功能概述:现在可以对继承自其他类型的类型进行聚合初始化。
  • 聚合类型的定义包括:
    • 没有用户提供的、显式的或继承的构造函数
    • 没有私有或保护的非静态数据成员
    • 没有虚函数
    • 没有虚拟、私有或保护的基类
    • struct base1 { int b1, b2 = 32; } struct base2 { base2() { b3 = 64; } int b3; }; struct derived: base1, base2{ int d; }; derived d1 {{1, 2}, {}, 4}; derived d2 {{}, {}, 4};
    • 对象 'd1' 初始化为:'d1.b1' = 1, 'd1.b2 = 2', 'd1.b3' = 64, 'd1.t' = 4。
    • 对象 'd2' 初始化为:'d2.b1' = 0, 'd2.b2' = 32, 'd2.b3' = 64', 'd2.t' = 4。
  • 影响:
    • 在没有聚合初始化的情况下,需要实现复杂的构造函数,例如:
    • struct overloaded: Fs...{ template<class... Ts> overloaded(Ts&&... ts): Fs(std::forward<Ts>(ts)}... {} };
    • 聚合初始化使得可以直接从基类列表中调用构造函数,无需显式编写和转发参数。
  • 应用:
    • 这种模式在 'std::variant' 的访问处理中可能特别有用,是的代码更加简洁和易于维护。

使用 'std::variant' 和 'std::visit' 的示例

利用继承和重载在实际应用中处理 'std::variant'

#include <iostream>
#include <variant>

template<class... Ts>
struct overloaded: Ts...{
    using Ts::operator()...;
}

template<class... Ts>
overloaded(Ts...)-> overloaded<Ts...>;

int main(){
    const auto PrintVisitor = [](const auto& t){ std::cout << t << '\n'; }

    std::variant<int, float, std::string> intFloatString { "Hello" };

    std::visit(PrintVisitor, intFloatString);

    std::visit(overloaded{
        [](int& i) { i *= 2; },
        [](float& f) { f *= 2.0; }
        [](std::string& s) { s = s + s; }
    }, intFloatString);

    std::visit(PrintVisitor, intFloatString);
}
C++

解释:

  • 'std::variant' 是一个可以保存不同类型值的类,这里使用的是 'int', 'float' 和 'std::string'。
  • 'PrintVisitor' 是一个通用的 Lambda 表达式,用于输出 'variant' 当前值,支持所有实现了 '<<' 运算符的类型。
  • 第二次调用 'std::visit' 使用了一个 'overloaded' 的结构体,它包含了处理 'variant' 中每种类型的 Lambda 表达式:
    • 对 int 类型的值进行 '*2' 的操作。
    • 对 float 类型的值进行 '*2.0' 的操作。
    • 对 std::string 类型的值进行重复连接的操作。
  • 最后再次调用 'PrintVisitor' 来输出处理后的 'variant' 值。

并发操作与 Lambda 表达式

使用 std::thread 的 Lambda 表达式

从 C++11 就已经存在 'std::thread',它允许在构造函数中传递可调用对象,这个对象可以是函数指针、函数对象或 Lambda 表达式:

#include <iostream>
#include <thread>
#include <vector>
#include <numeric>

int main(){
    const auto printThreadID = [](const char* str){
        std::cout << str << ": " <<
        std::this_thread::get_id() << " thread id\n";
    };

    std::vector<int> numbers(100);
    std::thread iotaThread([&numbers, &printThreadID](int startArg){
        std::iota(numbers.begin(), numbers.end(), startArg);
        printThreadID("iota in");
    }, 10);

    iotaThread.join();
    printThreadID("printing numbers in");
    for(const auto& num: numbers) std::cout << num << ", ";
}
C++
  • 解释:上述代码中,创建了一个带有 Lambda 表达式的线程,并传递了一个整数参数 '10'给 Lambda 表达式。通过 'join',完成线程操作。此处的 Lambda 表达式虽然简化了线程创建,但其仍是异步执行的,因此仍可能遇到线程竞争和阻塞问题。

线程竞争问题

多个线程共享同一个变量 'counter' 可能会遇到的线程竞争情况:

#include <iostream>
#include <thread>
#include <vector>

int main(){
    int counter = 0;
    /* get the maxThreads on this machine */
    const auto maxThreads = std::thread::hardware_concurrency();
    std::vector<std::thread> threads;
    threads.reserve(maxThreads);

    for(size_t tCounter = 0; tCounter < maxThreads; ++tCounter){
        threads.push_back(std::thread([&counter]() noexcept{
            for(int i = 0; i < 1000; ++i){
                ++counter; --counter;
                ++counter; --countet;
            }
        }));
    }

    for(auto& thread: threads) thread.join();
    std::cout << counter << '\n';
}
C++
  • 解释:在上述代码中,创建了多个线程并对共享变量 'counter' 进行递增和递减操作。然而,由于这些操作不是原子的,不同的线程可能同时对 'counter' 进行读取或者写入操作,最终导致不可预测的行为。

使用原子操作解决线程竞争问题

使用 'std::atomic' 来确保线程操作的原子性:

#include <iostream>
#include <vector>
#include <thread>
#include <atomic>

int main(){
    std::atomic<int> counter = 0;
    const auto maxThreads = std::thread::hardware_concurrency();
    std::vector<std::thread> threads;
    threads.reserve(maxThreads);

    for(size_t tCounter = 0; tCounter < maxThreads; ++tCounter){
        threads.push_back(std::thread([&counter]() noexcept {
            for(int i = 0; i < 1000; ++i){
                counter.fetch_add(1);
                counter.fetch_sub(1);
                counter.fetch_add(1);
                counter.fetch_sub(1);
            }
        }));
    }

    for(auto& thread: threads) thread.join();

    std::cout << counter.load() << '\n';
}
C++
  • 解释:上述代码中,使用 'std::atomic' 来管理 'counter' 变量,确保每个操作都是原子性的,以此来避免线程竞争的情况发生。虽然同步操作会使代码运行时更安全,但同时也会带来额外的性能开销。

其他的同步方法

在每个线程中使用局部变量进行计算,然后在线程结束前锁定并更新全局值。需要注意的是,定义变量为 'volatile' 并不能提供正确的同步。

Lambda 表达式 和 std::async

‘std::async' 是在 C++11 中引入的一种高级 API,允许设置和调用计算任务,既可以延迟执行,也可以完全异步执行。使用 'std::async' 可以方便地实现多线程操作,而不需要手动管理线程的创建和同步:

#include <iostream>
#include <future>
#include <vector>
#include <numeric>

int main(){
    const auto printThreadID = [](const char* str){
        std::cout << ": "
        << std::this_thread::get_id() << " thread id\n";
    };

    std::vector<int> numbers(100);
    std::future<void> iotaFuture = std::async(std::launch::async,
        [&numbers. startArg = 10, &printThreadID](){
            std::iota(numbers.begin(), numbers.end(), startArg);
            printThreadID("iota in");
        }
    };

    iotaFuture.get();
    printThreadID("printing numbers in");
    for(const auto& num: numbers) std::cout << num << ", ";
}
C++
  • 解释:上述代码中,'std::async' 返回了一个 'std::future<void>' 对象,这个对象负责同步操作,并保证调用 'get()' 时可以取到异步任务的结果。'get()' 是一个阻塞操作,会一直等到异步任务完。

然而,代码中通过 'future<void>' 来处理任务,且向 Lambda 表达式传递的是引用捕获的 'vector',这存在一定的局限性,可以通过改用 'std::future<std::vector<int>>' 来通过 future 机制传递 'vector':

std::future<vector<int>> iotaFuture = std::async(std::launch::async,
            [startArg = 10](){
            std::vector<int> numbers(100);
            std::iota(numbers.begin(), numbers.end(), startArg);
            std::cout << "calling from: " << std::this_thread::get_id() << " thread id\n";
            return numbers;
    }
};
auto vec = iotaFuture.get();
C++
  • 解释:这种方法直接使用 'std::future<std::vector<int>>' 直接返回 'vector',这样 Lambda 捕获和返回的数据更为直观和安全。

尽管 'std::async' 和 'std::future' 具有简化多线程操作的优点,但在实际应用过程中,对于处理一些更复杂的场景时存在一些局限性:

  • 缺乏对任务的延续性支持(continuation)。
  • 任务合并困难。
  • 缺乏取消或加入(cancellation/joining)功能。
  • 不是一个常规类型(regular type)。

C++17 中的并行算法和 Lambda 表达式

在 C++17 中,线程支持得到了增强,可以通过标准库算法轻松实现并行化,只需要在算法中指定第一个参数:

auto myVec = GenerateVector();
std::sort(std::execution::par, myVec.begin(), myVec.end());
C++

这里的 'std::execution::par' 启用了排序算法的并行执行,此处还有其他可选项:

  • 'std::execution::seq': 表示算法按顺序执行,不并行。
  • 'std::execution::par': 表示算法可以并行执行。
  • 'std::execution::par_unseq': 表示算法可以并行执行并向量化。

C++20 引入了额外的执行策略 'std::execution::unseq' ,用于在单线程上启用向量化执行(执行策略声明和全局对象位于 '<execution>' 头文件中)。

并行算法中的问题

一个存在潜在问题的并行操作:

#include <iostream>
#include <vector>
#include <numeric>
#include <execution>

int main(){
    std::vector<int> vec(1000);
    std::iota(vec.begin(), vec.end(), 0);
    std::vector<int> output;

    std::for_each(std::execution::par, vec.begin(), vec.end(),
        [&output](int& elem){
            if(elem % 2 == 0) output.push_back(elem);
        });

    for(const auto& elem: output) std::cout << elem << ", ";
}
C++

存在的问题;

  • 当传递一个 lambda 给 'std::for_each' 时,执行不再仅限于单个线程。多个线程可能会同时操作 'output' 向量,导致插入元素的的顺序错误,甚至可能因为多个线程同时修改向量而导致崩溃。
  • 可以通过引入互斥锁(mutex)并在每次调用 'push_back' 前锁定它来解决同步的问题。但是,如果筛选条件简单且执行速度快,那么并行操作可能回避顺序版本('seq')更慢,并且并行执行会导致输出向量中的元素顺序无法判断。

Leave a Comment

Share this Doc

第四章 Lambda in C++17

Or copy link

CONTENTS
It's late! Remember to rest.