C++基础知识小讲堂(3)——函数对象与Lambda表达式

这几对括号是什么东西


既然天使姐姐在她的blog里面提到了lambda表达式那我就此机会就在这边也水一篇语句不通谁都看不懂的文章讲lambda表达式就好了(((

括号运算符的重载

学过C++基础的人都知道C++中大部分的运算符都是可以重载的,除了常见的双目运算符+-等,括号运算符()在C++中也是可以重载的。这个运算符一般来讲被称作“调用运算符”,让这个类的实例可以像一个普通的函数一样通过函数调用的语法来调用对应的括号运算符函数。不要被这里连续的两组括号搞晕了,第一组括号与operator组成一个整体,标志着这个函数是括号运算符的重载,而后面的一组括号则是这个函数的参数列表。注意这里虽然operator()函数没有使用任何非静态类成员,它也不能是static函数,C++对运算符重载的限制使之如此。

struct Callable
{
    void operator()() const
    {
        std::printf("I'm called!");
    }
};

int main()
{
    const Callable c;
    c(); // I'm called!
}

这种函数对象与普通函数的本质区别就是它们是类的实例,它们可以使用成员变量来存储状态信息,而普通的函数只能通过static变量存储全局状态而无法存储独立的状态信息。

class Counter
{
private:
    int value_ = 0;
public:
    void operator()()
    {
        value_++;
        std::printf("Counting %d\n", value_);
    }
};

int main()
{
    Counter c1, c2;
    c1(); // Counting 1
    c1(); // Counting 2
    c2(); // Counting 1
}

老板,每种括号都给我来一对

万能的cppreference告诉我们C++中lambda表达式的语法应该是这样的:

[ 捕获 ] <模板形参>(可选)(C++20) ( 形参 ) 说明符(可选) 异常说明 属性 -> 返回值类型 制约(可选)(C++20) { 函数体 }

其中有很多部分都是可选的,这样最短的合法lambda表达式是这样的:[] {}。这代表一个lambda表达式,其捕获列表为空函数体也为空。

Lambda表达式本身是一个匿名的函数对象(这解答了夜轮天使blog最后的问题,它不是匿名函数而是匿名类的实例)。他比普通的函数对象有一点好处,那就是其语法简洁,用lambda表达式的话你就不需要手动定义一个类了。回顾前面的Callable例子,用lambda表达式的话代码会变得更简洁。注意这里因为我们无法得知lambda表达式的类型,所以直接用auto让编译器自己推断这个类的类型。

const auto c = [] { std::printf("I'm called!"); };
c(); // I'm called

这从某种程度上来讲是起名废和懒人的福音,因为如果你只需要用一两次这个函数的话,你可以就地定义一个lambda表达式,不用起名字还可以少打一些字,何乐而不为呢?实际上在使用STL的算法库的时候,算法库经常需要调用方提供一些回调函数(比如std::sort就需要用户提供一个比较函数),这种简短的只用一次的函数用lambda表达式就再好不过了。比如下面的例子给一个vector的元素做了降序排序,这里就用了lambda表达式简单便捷地提供了比较函数。这个例子还体现了一个lambda表达式的便捷之处:你不用手动给出返回值类型,编译器会帮你自动推断返回值的类型。

std::vector<int> vec{ 2, 4, 3, 5, 1 };
std::sort(vec.begin(), vec.end(),
    [](const int l, const int r) { return l > r; });
for (const int v : vec)
    std::printf("%d ", v); // 5 4 3 2 1 

事实上,lambda表达式什么的都是语法糖,编译器其实把将你的lambda表达式做了类似下面的变换。注意上面lambda表达式各个括号里的内容跟变换后的代码内的内容的对应关系。

const auto comp = [](const int l, const int r) { return l > r; };
// is transformed to
struct __some_lambda_type
{
    auto operator()(const int l, const int r) const
    {
        return l > r;
    }
};
const __some_lambda_type comp;

那中括号里面的内容对应的又是什么呢?实际上中括号里的内容对应的是这个函数对象类的各个成员变量。比如看下面的例子:

void print_plus_x(const std::vector<int>& vec, const int x)
{
    std::for_each(vec.begin(), vec.end(),
        [v = x](const int i) { std::printf("%d ", i + v); });
}

int main()
{
    const std::vector vec{ 1, 2, 3, 4, 5 };
    print_plus_x(vec, 2); // 3 4 5 6 7
}

std::for_each这个函数需要三个参数,前两个是你要遍历的范围的首尾迭代器,而第三个参数则是你提供的遍历每个元素所需要做的工作,显然这个函数对象的参数只能有一个,这时候如果需要用上x的信息就只能把x的值“捕获”到lambda的函数体内部。上面的print_plus_x函数差不多会被编译器翻译成这样,还是注意lambda的几个括号跟翻译过去的类实现的对应(这一段代码是无法通过编译的,因为类成员定义不可使用auto,并且类成员默认值说明不可使用外层函数的局部变量和参数,但是出于演示原因这里这样写比较清楚):

void print_plus_x(const std::vector<int>& vec, const int x)
{
    struct __some_other_lambda_type
    {
        auto v = x;
        auto operator()(const int i) const
        {
            std::printf("%d ", i + v);
        }
    };
    std::for_each(vec.begin(), vec.end(), __some_other_lambda_type{});
}

这里的v = x可以替换成其他的更复杂的初始化表达式,但是如果像这个例子中一样只是需要把外层变量复制进lambda中的话可以直接写那个外层变量的名字:

[x](const int i) { std::printf("%d ", i + x); }

你也可以用&符号来表示按引用捕获,对应的类成员声明就从auto变成了auto&

[&v = x](const int i) { std::printf("%d ", i + v); }
// is transformed to
struct __whatever_type
{
    auto& v = x;
    auto operator()(const int i) const
    {
        std::printf("%d ", i + v);
    }
};

如果你更懒的话,可以指定一种默认的捕获方式让编译器帮你自动捕获所有需要捕获的外部变量。

[&](const int i) { std::printf("%d ", i + x); } // x is implicitly captured

前面的Counter例子也可以用lambda重写,你可能会写成这样:

[i = 0]
{
    i++; // Oops, cannot modify const lvalue
    std::printf("Counting %d\n", i);
};

但是编译器会报错,说i不是可修改的左值。原因即是operator()默认的const

struct __lambda
{
    auto i = 0;
    auto operator()() const
    {
        i++; // Of course this will fail to compile
        std::printf("Counting %d\n", i);
    }
}

解决这一问题的方法即是在lambda的声明里加上mutable,这个关键字可以把对应lambda的operator()变成非const的。注意,在有mutable修饰符的时候不可省略参数列表的括号。

int main()
{
    auto c = [i = 0]() mutable
    {
        i++;
        std::printf("Counting %d\n", i);
    };
    c(); // Counting 1
    c(); // Counting 2
}

泛型算法和C++20的lambda表达式

C++有一个非常强有力的工具,它可以为不同的类型批量生成功能相似的代码,那就是模板。使用模板我们可以将相似的工作进一步抽象出来,写出更广泛适用的算法。

template <typename T>
T add(const T& lhs, const T& rhs)
{
    return lhs + rhs;
}

int main()
{
    auto x = add(1, 2); // int x = 3
    auto y = add(1.2, 2.4); // double y = 3.6
    auto z = add("Hello"s, "World"s) // std::string z = "HelloWorld"
    // auto w = add(1, 1.2f); Compile error: mismatched type of T (int and float)
}

从C++14开始,lambda表达式也出现了与此对应的构造,即泛型lambda表达式。这种lambda的参数类型是占位符auto,比如下面的例子:

int main()
{
    const auto add = [](const auto& lhs, const auto& rhs)
    {
        return lhs + rhs;
    };    
    auto x = add(1, 2); // int x = 3
    auto y = add(1.2, 2.4); // double y = 3.6
    auto z = add("Hello"s, "World"s) // std::string z = "HelloWorld"
    auto w = add(1, 1.2f); // float w = 2.2f
}

实际上每个auto在编译器生成的类中都对应一个模板类型形参:

const auto add = [](const auto& lhs, const auto& rhs)
{
    return lhs + rhs;
};
// is transformed to
struct __lambda_add
{
    template <typename T1, typename T2>
    auto operator(const T1& lhs, const T2& rhs)
    {
        return lhs + rhs;
    }
};
const __lambda_add add;

这与我们上面给出的那个函数模板还是不太相同,不同点就在w那一行上:那个函数模板不接受两个参数类型不同的情况,而下面的泛型lambda允许这种情况。自从C++20开始,lambda表达式也可以显式地指明模板参数列表了:

int main()
{
    const auto add = 
        []<typename T> (const T& lhs, const T& rhs)
        {
            return lhs + rhs;
        };    
    auto x = add(1, 2); // int x = 3
    auto y = add(1.2, 2.4); // double y = 3.6
    auto z = add("Hello"s, "World"s) // std::string z = "HelloWorld"
    // auto w = add(1, 1.2f); Compile error as expected
}
// The lambda is transformed to
struct __lambda_add
{
    template <typename T>
    auto operator(const T& lhs, const T& rhs)
    {
        return lhs + rhs;
    }
};
const __lambda_add add;

std::function

C++里面的闭包(lambda表达式)的特性比其他很多语言都不同。其他的语言的lambda表达式大多是有类型擦除的:具有相同参数和返回类型的lambda表达式的类型是统一的。而这种设计也给这些语言的lambda表达式带来了一些运行时的额外开销——不同的lambda表达式捕获列表中的变量其占用空间大小不尽相同,而一个特定的类型的大小又是固定的,这样就必须要在堆上面动态地申请空间存储这些捕获信息。

与其对应,C++的每一个lambda表达式声明都会使编译器生成一个不同的类型,这样每一个lambda表达式的捕获列表所占空间大小都是编译时已知的,也就消除了动态分配内存带来的运行时开销。同时,因为编译器拥有这个lambda的全部信息,优化器甚至可以对lambda做进一步的优化(内联等)。

不过lambda的类型不固定也带来了一些问题,如果我们想往一个函数里传递一个lambda作为参数,那么这个参数的类型应该是什么呢?C++为我们提供了一种手段,那就是函数模板。使用函数模板我们可以在编译时生成类型特定的相似代码,也就解决了这个问题。

template <typename Func>
void call_twice(const Func& func)
{
    func();
    func();
}

int main()
{
    // Yeah!Yeah!
    call_twice([] { std::printf("Yeah!"); });
}

C++的模板固然强大,但是模板代码必须出现在头文件中这个限制以及模板代码发生改动时过多代码受其牵连需要重新编译这几点很令人头疼。并且,因为C++的lambda不是类型擦除的,要存储一个std::vector容纳相同函数签名的一系列函数对象也就不直接可行。C++标准库在<functional>头文件中提供了这种类型擦除的函数对象,任何满足可调用、返回值为Res、参数类型为Args...的对象都可以隐式转换到一个统一的类型擦除类std::function<Res(Args...)>,这样就解决了上面提出的问题。不过这也带来了类型擦除的运行时开销,所以实际使用中请斟酌使用情况来选取最佳的实现方法。

void regular_function()
{
    std::printf("erasure!");
}

int main()
{
    std::vector<std::function<void()>> callables;
    callables.emplace_back([] { std::printf("Hello "); });
    struct HandmadeFunctor
    {
        void operator()() const
        {
            std::printf("type ");
        }
    };
    callables.emplace_back(HandmadeFunctor{});
    callables.emplace_back(regular_function);
    for (const auto& callable : callables)
        callable(); // Hello type erasure!
}