C++ lambda 的那些坑

Posted on Sat 11 May 2024 in Journal

Abstract C++ lambda 的那些坑
Authors Walter Fan
 Category    learning note  
Status v1.0
Updated 2024-05-11
License CC-BY-NC-ND 4.0

在 C++ 中,Lambda 表达式提供了一种简洁、灵活的方式来定义匿名函数对象(也被称为闭包或 lambda 表达式)。然而,像任何其他编程语言特性一样,Lambda 在使用时也存在一些需要注意的“坑”。下面我将列举一些在使用 C++ Lambda 时可能会遇到的问题和陷阱。

  1. 捕获列表的陷阱 Lambda 表达式的捕获列表用于捕获 Lambda 外部的变量以供在 Lambda 体内使用。捕获方式有值捕获(通过等号 =)和引用捕获(通过 &)。

  2. 值捕获的陷阱:如果捕获的变量在 Lambda 表达式被调用之前被修改,Lambda 体内将使用捕获时的值,而不是最新的值。

  3. 引用捕获的陷阱:如果捕获的变量在 Lambda 表达式生命周期结束后被销毁,但 Lambda 仍然被保存并稍后调用,这将导致悬垂引用(dangling reference)问题。
  4. 默认捕获的陷阱:使用默认捕获([=] 或 [&])时要特别小心,因为它会捕获所有外部可见的变量。这可能导致意外的捕获和不必要的性能开销。

  5. Lambda 的返回值类型 Lambda 表达式的返回类型是由其返回语句自动推断的。如果 Lambda 体内有多个返回语句且它们的类型不同,这将导致编译错误。此外,如果 Lambda 没有返回语句,它的返回类型将被推断为 void。

  6. Lambda 的生命周期 Lambda 表达式的生命周期与其创建时的上下文紧密相关。如果 Lambda 是在一个函数内部创建的,并且该函数返回了该 Lambda,那么必须确保 Lambda 的所有捕获的变量在 Lambda 的生命周期内都是有效的。

  7. Lambda 与 std::function 的交互 std::function 是一个通用的、类型安全的函数包装器,它可以存储、复制和调用任何可调用的目标(如函数、Lambda 表达式、函数对象等)。然而,将 Lambda 存储在 std::function 中可能会引入额外的开销,因为 std::function 是一个动态类型的容器,它在运行时需要进行类型擦除和动态调度。

  8. Lambda 的性能考虑 虽然 Lambda 表达式在语法上非常简洁和灵活,但它们可能会引入一些性能开销。这包括捕获变量的拷贝开销(对于值捕获)和引用捕获可能导致的悬垂引用问题。此外,如果 Lambda 被频繁地创建和销毁(例如在循环中),这可能会导致额外的内存分配和释放开销。

  9. Lambda 的可读性和可维护性 虽然 Lambda 表达式可以提高代码的简洁性和可读性,但它们也可能使代码更难以理解和维护。特别是在复杂的代码中,嵌套过多的 Lambda 表达式可能会使代码结构变得混乱和难以跟踪。

  10. Lambda 与模板的交互 当 Lambda 与模板一起使用时,可能会遇到一些复杂的问题。例如,Lambda 的类型在编译时是未知的(它是一个唯一的匿名类型),这可能会导致模板类型推断失败或产生意外的结果。此外,Lambda 的捕获列表和返回类型也可能与模板参数产生交互,导致复杂的类型匹配问题。

错误用法示例

例1: 悬挂引用

#include <iostream>
#include <functional>

std::function<void()> createLambda() {
    int x = 10;
    return [&]() {
        std::cout << x << std::endl;  // x is a dangling reference
    };
}

int main() {
    auto lambda = createLambda();
    lambda();  // This will crash because x no longer exists
    return 0;
}

正确的写法应该是

std::function<void()> createLambda() {
    int x = 10;
    return [x]() {
        std::cout << x << std::endl;  // x is captured by value
    };
}

例 2: 捕捉不当的 this 指针

要确保 lambda 的寿命不会比 this 指针指向对象还长,或者按值捕获必要的数据。

#include <iostream>
#include <functional>

class MyClass {
public:
    MyClass(int value) : value(value) {}

    std::function<void()> createLambda() {
        return [this]() {
            std::cout << value << std::endl;  // this might be a dangling pointer
        };
    }

private:
    int value;
};

int main() {
    std::function<void()> lambda;
    {
        MyClass obj(42);
        lambda = obj.createLambda();
    }
    // obj is destroyed here
    lambda();  // This will crash because `this` is a dangling pointer
    return 0;
}

正确的写法是

int main() {
    {
        MyClass obj(42);
        auto lambda = obj.createLambda();
        lambda();  // Safe to call here
    }
    // obj is destroyed here, but lambda has already been called
    return 0;
}

为减少出错机率, 还是写得笨点好

std::function<void()> createLambda() {
    int localValue = value;  // Capture the necessary data by value
    return [localValue]() {
        std::cout << localValue << std::endl;
    };
}

例 3: 捕获方式的错误选择

在某些情况下,捕获方式选择错误会导致意想不到的行为或编译错误。特别是当捕获的对象是一个指针或复杂对象时。

示例:捕获指针后指针所指对象被销毁

#include <iostream>
#include <vector>
#include <algorithm>

void demo() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    int* ptr = &data[0];

    auto lambda = [ptr]() {
        std::cout << *ptr << std::endl;  // ptr 所指对象可能已被销毁
    };

    data.clear();  // data 被清空,ptr 成为悬挂指针

    lambda();  // 可能导致未定义行为或崩溃
}

int main() {
    demo();
    return 0;
}

解决方法:

在捕获指针时,确保其生命周期不会在 lambda 使用前结束。或者避免捕获指针,改用更安全的捕获方式。

例 4. 隐式捕获导致的未定义行为

使用隐式捕获(如 [=] 或 [&])可能会捕获到一些你并不希望捕获的变量,导致意外行为。

示例:

#include <iostream>

void demo() {
    int x = 10;
    auto lambda = [=]() {
        std::cout << x << std::endl;  // 隐式捕获 x,按值捕获
    };

    x = 20;
    lambda();  // 输出 10,而不是 20
}

int main() {
    demo();
    return 0;
}

解决方法:

尽量避免使用隐式捕获,明确列出需要捕获的变量,确保代码的可读性和可维护性。

auto lambda = [x]() {
    std::cout << x << std::endl;
};

例 5. 可变性 (mutable) 的误用

默认情况下,lambda 捕获的变量是常量(const)。如果需要修改捕获的变量,必须使用 mutable 关键字。如果误用了 mutable,可能导致意外的行为或难以调试的错误。

示例:

#include <iostream>

void demo() {
    int x = 10;
    auto lambda = [x]() mutable {
        x = 20;
        std::cout << x << std::endl;  // 输出 20
    };

    lambda();
    std::cout << x << std::endl;  // 输出 10,x 并未被修改
}

int main() {
    demo();
    return 0;
}
  • mutable 使得 lambda 内部可以修改捕获的变量副本。
  • 外部的 x 并未被修改,这可能与预期不符。

解决方法:

理解 mutable 的用法及其影响,确保修改捕获变量副本是预期行为。如果需要修改外部变量,应捕获引用。

auto lambda = [&x]() {
    x = 20;
    std::cout << x << std::endl;
};

例 6. 捕获未初始化的变量

捕获未初始化的变量会导致未定义行为。这种错误有时不易察觉,特别是在复杂的代码中。

示例:

#include <iostream>

void demo() {
    int x;
    auto lambda = [&x]() {
        std::cout << x << std::endl;  // 未初始化的 x
    };

    lambda();  // 未定义行为
}

int main() {
    demo();
    return 0;
}

解决方法:

在捕获变量之前,确保其已正确初始化。

int x = 0;  // 确保 x 已初始化
auto lambda = [&x]() {
    std::cout << x << std::endl;
};

例 7. 捕获对象后修改其状态

如果捕获的是复杂对象(如类对象),在 lambda 内修改其状态可能导致不可预见的错误或状态不一致。

示例:

#include <iostream>
#include <vector>

void demo() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    auto lambda = [data]() mutable {
        data.push_back(6);  // 修改捕获的副本,外部不可见
        std::cout << data.size() << std::endl;  // 输出 6
    };

    lambda();
    std::cout << data.size() << std::endl;  // 输出 5,外部未改变
}

int main() {
    demo();
    return 0;
}

解决方法:

根据需求选择合适的捕获方式,确保 lambda 内外状态一致。

auto lambda = [&data]() {
    data.push_back(6);  // 修改原始对象
    std::cout << data.size() << std::endl;
};

总结

虽然 C++ 中的 Lambda 表达式是一个非常强大和有用的特性,但在使用时也需要谨慎处理上述提到的陷阱和问题。通过仔细考虑 Lambda 的捕获方式、返回类型、生命周期、性能开销以及与其他特性的交互方式,可以编写出更加健壮、高效和可维护的代码。

1) 避免使用默认的捕获模式 按引用的默认捕获模式可能导致空悬引用, 按值的默认捕获模式会好些, 但也不会完全避免空悬引用(例如指针的复制), 我们最好显式的列出 lambda 所依赖的局部变量或者形参

2) 使用初始化捕获将对象移入闭包


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。