C++11:函数对象、闭包和Lambda

函数对象

如果一个类重载了函数调用运算符,那么我们就可以像函数调用一样使用这个类。因为类可以同时存储状态,所以与普通函数对比而言,他会给我们带来更大的灵活性。

举一个简单的例子,使用过STL的开发都知道STL中有std::greater这个模板类。

template <class T> struct greater 
{
    
    
  bool operator() (const T& x, const T& y) const {
    
    return x>y;}
  typedef T first_argument_type;
  typedef T second_argument_type;
  typedef bool result_type;
}

std::greater类定义了函数调用运算符,此运算符接收两个入参,并返回一个bool值,此返回值表示第一个入参是否大于第二个入参。

现在greater重载了函数运算符,我们怎么可以调用此运算符呢?一般是声明一个greater 对象,然后像函数调用一样直接调用此对象。

std::greater<int> bigger;
auto result = bigger(10, 2);

函数调用运算符必须是类成员函数,而且函数调用运算符支持重载,也就是说一个类可以定义多个版本函数调用运算符实现,只要相互之间参数数量或类型有所区别即可。

如果一个类定义了函数调用运算符,则称此类为函数对象,C++98标准中也称函数对象为仿函数。

闭包

闭包是在多个语言中都存在的一个名词,C++中闭包的定义可这样描述:一个函数可以获取其他函数内部状态,我们则成此函数为闭包。简单点儿说闭包就是一个有上下文的函数,一个有状态的函数。

按照这个定义,如果我们向函数对象中添加其他成员,从而成为函数调用运算符的上下文,影响函数调用结果,那此函数对象就是一个闭包了。

举个例子,我们可以定义一个打印string实参内容的类,默认情况下此类会打印到cout中,每个string 直接以空格隔开,同时允许类的使用者提供其他可写入的流及其他分隔符。

class LogString
{
    
    
public:
    LogString(std::ostream& os = std::cout, char separator = ' ')
    : m_os(os)
    , m_separator(separator)
    {
    
    }
    
    void operator()(const std::string& log)  const
    {
    
    
        m_os << log << m_separator;
    }
    
private:
    std::ostream& m_os;
    char m_separator = ' ';
};

采用默认打印方式,默认情况下string实参会输出到cout并添加空格分隔符。

LogString logString;
logString("string to cout stream");

当然此函数对象也允许用户调整输出对象和分隔符,例如把输出对象调整到cerr,分隔符可以换成换行。

LogString logString(std::cerr, '\n');
logString("string to cerr stream");

上述举例,通过调整logString对象内部状态,从而影响logString函数对象的调用结果。所以我们可称logString函数对象为闭包函数。

不仅函数对象可实现闭包,C++的function对象也可以实现闭包,可以通过bind把函数和对象绑定,从而实现基于bind/function的闭包实现。function对象也是一个函数对象,两者在C++层面没有区别,都是通过operator()函数调用实现。

Lambda定义

匿名函数Lambda是C++11引入的新特性,Lambda表达式基于数学中的λ演算得名。Lambda函数有输入/出参数,返回值,函数体,唯独没有函数名,所以lambda表达式不能通过类型名来显示声明对象。

Lambda表达式(函数)定义形式:

[captures/expression/rvalue](params) mutable noexcept/throw() -> return type	{
    
    
	body
}
  • [] 方括号表示向编译器声明当前是一个Lambda express,方括号不可被省略。而方括号内部的captures表示lambda内部可使用的外部变量。captures捕获支持按值捕获和按引用捕获。expression和rvalue是C++14支持的广义捕获,expression为表达式捕获,rvalue为右值捕获。而C++11支持的captures捕获又被称为简单捕获,expression和rvalue捕获又被称为初始化捕获。

  • mutable 此关键字可忽略,如果使用则之前的 () 小括号将不能省略(参数个数可以为 0),默认情况下,对于以值传递方式引入的外部变量,不允许在 lambda 表达式内部修改它们的值。而如果想修改它们,就必须使用mutable关键字。

  • noexcept/throw() 此关键字可忽略,如果使用则之前的 () 小括号将不能省略(参数个数可以为 0)。

  • -> return type 指明匿名函数的返回值类型,如果lambda内部只有一个return语句或函数返回void,编译器可以推倒返回值类型,-> return type可忽略。

  • 函数体,和普通函数一样,函数体是函数的重要构成部分,不可忽略。

Lambda是闭包?

cppreference明确说Lambda是一个函数闭包,我认为这是不太准确的。Lambda是否是闭包,可以从是否包含捕获两个角度阐述。

如果Lambda捕获了上下文对象,我们可称Lamdba是一个闭包,Lambda捕获的对象就是此闭包的上下文,所以可在Lambda中使用捕获对象,从而影响Lambda的行为。

如果Lambda没有捕获上下文对象,Lambda就无法使用上下文对象,从而无法影响自己的行为。所以没有捕获上下文的Lambda从严格意义上说就不是一个闭包,而仅仅是一个函数对象而已。

Lambda编译器实现

借助gcc提供的编译指令,我们可查看gcc的编译中间文件,从而窥探gcc编译器针对Lambda的实现。由于Lambda在是否捕获上下文上存在差异。这部分我们会从是否捕获两个角度介绍,通过gcc的-fdump-tree-all选项输出中间代码并分析其原理。

无捕获Lambda

为了说明匿名Lambda函数生成的数据结构就是一个函数对象,分别采用无捕获的Lambda和函数对象两种方式实现二元加法。对比查看两者差异。



int main()
{
    
    
    class Add
	{
    
    
	public:
		int operator()(int a, int b) const
		{
    
    
			return a + b;
		}
	};
	auto add1 = [](int a, int b) {
    
    
		return a + b;
	};

	auto sum1 = add1(0, 2);

	Add add2;
	auto sum2 = add2(2, 3);

	return 0;
}

使用-fdump-tree-all编译选项生成中间gimple代码。

main ()
{
    
    
  int D.40661;

  {
    
    
    typedef struct Add Add;
    struct Add add2;
    struct __lambda0 add1;
    typedef struct __lambda0 __lambda0;
    int sum1;
    int sum2;

    try
      {
    
    
        sum1 = main()::<lambda(int, int)>::operator() (&add1, 0, 2);
        sum2 = main()::Add::operator() (&add2, 2, 3);
        D.40661 = 0;
        return D.40661;
      }
    finally
      {
    
    
        add2 = {
    
    CLOBBER};
        add1 = {
    
    CLOBBER};
      }
  }
  D.40661 = 0;
  return D.40661;
}


main()::<lambda(int, int)>::operator() (const struct __lambda0 * const __closure, int a, int b)
{
    
    
  int D.40664;

  D.40664 = a + b;
  return D.40664;
}


main()::Add::operator() (const struct Add * const this, int a, int b)
{
    
    
  int D.40666;

  D.40666 = a + b;
  return D.40666;
}

对比分析gimple中间代码,我们可以得到如下结论:

  • Lambda函数,对应数据类型为struct __lambda0,编译器首先定义一个struct __lambda0类型的add1变量,然后调用main()::<lambda(int, int)>::operator() 函数完成两个整数加法,此函数就是struct __lambda0的()调用运算符实现。
  • Add函数对象,对应数据类型为struct Add,编译器定义一个struct Add类型的变量add2,然后调用main()::Add::operator() 函数完成两个整数加法,main()::Add::operator() 就是struct Add的()调用运算符实现。
  • Add函数对象和匿名函数生成的数据结构完全一致,所以我们说lambda匿名函数就是一个函数对象。
  • 无论Lambda函数还是Add函数对象,都没有存在上下文动态变化并最终影响operator() 调用行为,因此无捕获的Lambda函数就是一个简单的函数对象论断是完全正确的。

有捕获Lambda

为了说明有捕获的Lambda函数生成的数据结构就是一个闭包,分别采用有捕获的Lambda和函数对象两种方式实现二元运算。对比查看两者差异。

int main()
{
    
    
	class Operator
	{
    
    
	public:
		Operator(bool add)
		: m_add(add)
		{
    
    

		}

		int operator()(int a, int b) const
		{
    
    
			if (m_add)
			{
    
    
				return a + b;
			}
			return a - b;
		}

	private:
		bool m_add;
	};

	Operator operator2(true);

	bool add = true;
	auto operator1 = [add](int a, int b) mutable{
    
    
		if (add)
		{
    
    
			return a + b;
		}

		return a - b;
	};

	auto sum1 = operator1(0, 2);
	auto sum2 = operator2(2, 3);

	return 0;
}

使用-fdump-tree-all编译选项生成中间gimple代码。

main ()
{
    
    
  int D.40663;

  {
    
    
    typedef struct Operator Operator;
    struct Operator operator2;
    bool add;
    struct __lambda0 operator1;
    typedef struct __lambda0 __lambda0;
    int sum1;
    int sum2;

    try
      {
    
    
        main()::Operator::Operator (&operator2, 1);
        add = 1;
        operator1.__add = add;
        sum1 = main()::<lambda(int, int)>::operator() (&operator1, 0, 2);
        sum2 = main()::Operator::operator() (&operator2, 2, 3);
        D.40663 = 0;
        return D.40663;
      }
    finally
      {
    
    
        operator2 = {
    
    CLOBBER};
        operator1 = {
    
    CLOBBER};
      }
  }
  D.40663 = 0;
  return D.40663;
}


main()::Operator::Operator (struct Operator * const this, bool add)
{
    
    
  MEM[(struct  &)this] = {
    
    CLOBBER};
  {
    
    
    this->m_add = add;
  }
}


main()::<lambda(int, int)>::operator() (struct __lambda0 * const __closure, int a, int b)
{
    
    
  int D.40668;
  bool add [value-expr: __closure->__add];

  _1 = __closure->__add;
  if (_1 != 0) goto <D.40666>; else goto <D.40667>;
  <D.40666>:
  D.40668 = a + b;
  // predicted unlikely by early return (on trees) predictor.
  return D.40668;
  <D.40667>:
  D.40668 = a - b;
  return D.40668;
}


main()::Operator::operator() (const struct Operator * const this, int a, int b)
{
    
    
  int D.40672;

  _1 = this->m_add;
  if (_1 != 0) goto <D.40670>; else goto <D.40671>;
  <D.40670>:
  D.40672 = a + b;
  // predicted unlikely by early return (on trees) predictor.
  return D.40672;
  <D.40671>:
  D.40672 = a - b;
  return D.40672;
}

对比分析gimple中间代码,我们可以得到如下结论:

  • Lambda函数,对应数据类型为struct __lambda0,编译器首先定义一个struct __lambda0类型的operator1变量,然后将add赋值给内部成员变量__add, operator1.__add = add,最后调用main()::<lambda(int, int)>::operator() 函数完成两个整数二元运算,此函数就是struct __lambda0的()调用运算符实现。匿名函数通过调用operator()前面的内部成员__add赋值决定当前二元运算是加法运算还是减法运算。

  • Operator函数对象,对应数据类型为struct Operator,编译器定义一个struct Operator类型的变量operator2,然后调用定位构造函数main()::Operator::Operator (struct Operator * const this, bool add) 完成对象初始化, 最后调用main()::Operator::operator() 函数完成两个整数二元运算,main()::Operator::operator() 就是struct Operator的()调用运算符实现。函数对象在调用定位构造时会传入add变量,operator2函数对象通过传入的add变量决定当前二元运算符是加法运算还是减法运算。

  • Operator函数对象和匿名函数生成的数据结构完全一致,所以我们说lambda匿名函数就是一个函数对象。

  • 无论Lambda函数还是Operator函数对象,都存在一个上下文变量,并最终决定operator() 调用行为,因此有捕获的Lambda函数就是一个严格意义上的闭包。

为何无法赋值捕获对象?

在Lambda编程中,我们经常会碰Lambda函数尝试修改捕获对象,最终导致编译失败问题,需要添加mutable修饰才可修改捕获对象,那这底层是什么原理呢?

要回答或解决这个问题,我们还是需要从gimple中间代码入手,仔细阅读Lambda函数对象中间代码,你应该会发现Lambda函数调用重载的声明是这样的:
main()::<lambda(int, int)>::operator() (const struct __lambda0 * const __closure, int a, int b)
而捕获对象__closure的声明为
const struct __lambda0 * const __closure
这个声明限制__closure指针变量和__closure指针所指向的对象都不允许被赋值或修改。这就是普通Lambda函数无法修改捕获对象的原因。

中间代码gimple,从变量声明定义角度阐述了普通Lambda函数为何不能修改捕获对象,这毕竟太底层,没人会关注Lambda匿名函数是如何编译运行的。我们可以从C++语法的角度重新阐述这个论断,operator()是函数对象的函数调用操作符重载函数,也就是函数调用重载函数不允许修改函数对象,那就是说这个operator()函数是一个const常量成员函数,Lambda匿名函数是一个const常量函数。

无mutable修饰的Lambda函数是一个const常量函数,无法对捕获对象重新赋值。那么添加mutable后,Lambda函数实现会变成什么样呢?我们为二元操作符匿名函数添加mutable修饰,并研究其变化。

auto operator1 = [add](int a, int b) mutable {
    
    
	if (add)
	{
    
    
		return a + b;
	}

	return a - b;
};

Lambda匿名函数生成的函数调用操作符重载函数的gimple中间代码。

main()::<lambda(int, int)>::operator() (struct __lambda0 * const __closure, int a, int b)
{
    
    
  int D.40668;
  bool add [value-expr: __closure->__add];

  _1 = __closure->__add;
  if (_1 != 0) goto <D.40666>; else goto <D.40667>;
  <D.40666>:
  D.40668 = a + b;
  // predicted unlikely by early return (on trees) predictor.
  return D.40668;
  <D.40667>:
  D.40668 = a - b;
  return D.40668;
}

可以看到,Lambda函数调用重载的声明现在变成:
main()::<lambda(int, int)>::operator() (struct __lambda0 * const __closure, int a, int b)
而捕获对象__closure的声明变成
struct __lambda0 * const __closure, int a, int b
这个声明表示指针所指向的对象可以修改和赋值,从C++语法意义上可以说,添加mutable修饰后,operator()函数由const成员函数变成了非const成员函数,Lambda匿名函数添加mutable后变成一个非const常量函数。

总结

本文从函数对象入手,循序渐进的介绍了函数对象,闭包和Lambda函数等概念。并通过分析gcc产生的gimple代码,从两个角度介绍了Lambda函数的实现原理和mutable的工作原理。希望对你有所帮助。

猜你喜欢

转载自blog.csdn.net/liuguang841118/article/details/126925310