[C++系列] 89. C++11新特性简单介绍

1. C++11简介

在 2003 年 C++ 标准委员会曾经提交了一份技术勘误表(简称 TC1),使得 C++03 这个名字已经取代了 C++98 称为C++11 之前的最新 C++ 标准名称。不过由于 TC1 主要是对 C++98 标准中的漏洞进行修复,语言的核心部分则没有改动,因此人们习惯性的把两个标准合并称为 C++98/03 标准。从 C++0xC++11C++ 标准 10 年磨一剑,第二个真正意义上的标准珊珊来迟。

相比于 C++98/03C++11 则带来了数量可观的变化,其中包含了约 140个新特性,以及对 C++03 标准中约 600 个缺陷的修正,这使得 C++11 更像是从 C++98/03 中孕育出的一种新语言。相比较而言, C++11 能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更强大,而且能提升程序员的开发效率。

2. 列表初始化

2.1 C++98中{}的初始化问题

参见代码如下:

int array1[] = {1,2,3,4,5};
int array2[5] = {0};

对于一些自定义的类型,却无法使用这样的初始化。比如:

参见代码如下:

vector<int> v{1,2,3,4,5};

就无法通过编译,导致每次定义 vector 时,都需要先把 vector 定义出来,然后使用循环对其赋初始值,非常不方便。

C++11 扩大了用大括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用初始化列表时,可添加等号(=),也可不添加。

2.2 内置类型的列表初始化

参见代码如下:

 x int main()
 {    
     // 内置类型变量
     int x1 = {10};
     int x2{10};
     int x3 = 1+2;
     int x4 = {1+2};
     int x5{1+2};
     // 数组
     int arr1[5] {1,2,3,4,5};
     int arr2[]{1,2,3,4,5};
     
     // 动态数组,在C++98中不支持
     int* arr3 = new int[5]{1,2,3,4,5};
     
     // 标准容器
     // map就变得相当方便了
     vector<int> v{1,2,3,4,5};
     map<int, int> m{{1,1}, {2,2,},{3,3},{4,4}};  
     return 0;
 }

注意:列表初始化可以在{}之前使用等号,其效果与不使用=没有什么区别。

2.3 自定义类型的列表初始化

  1. 标准库支持单个对象的列表初始化

参见代码如下:

class Point
{
public:
    Point(int x = 0, int y = 0)
        : _x(x)
        , _y(y)
    {}
    
private:
    int _x;
    int _y;
};
 
int main()
{
    Pointer p{ 1, 2 };
    return 0;
}
  1. 多个对象的列表初始化

多个对象想要支持列表初始化,需给该类(模板类)添加一个带有 initializer_list 类型参数的构造函数即可。注意:initializer_list 是系统自定义的类模板,该类模板中主要有三个方法: begin()、end() 迭代器以及获取区间中元素个数的方法 size()

参见代码如下:

//多个对象初始化
class Test
{
	int m_data[100];
	int m_size;
public:
	Test(initializer_list<int> l)
	{
		int * tmp = m_data;
		for (auto & e : l)
		{
			*tmp = e;
			tmp++;
		}

		m_size = l.size();
	}

	void show()
	{
		for (int i = 0; i < m_size; i++)
		{
			cout << m_data[i] << ' ';
		}
		cout << endl;
	}
};

int main()
{
    // 当时自己所模拟实现的vector是不支持该语法的
    // 得自己添加initializer_list<int>的构造函数
	Test t{ 1, 2, 3, 4, 5 };
	t.show();
	return 0;
}

vector(initializer_list<T> l)
{
    reserve(l.size());

    for (auto & e : l)
    {
        *m_finish = e;
        m_finish++;
    }
}

3. 变量类型推导

3.1 为什么需要类型推导

在定义变量时,必须先给出变量的实际类型,编译器才允许定义,但有些情况下可能不知道需要实际类型怎么给,或者类型写起来特别复杂,比如:

参见代码如下:

#include <map>
#include <string>
int main()
{
    short a = 32670;
    short b = 32670;
    
    // c如果给成short,会造成数据丢失,如果能够让编译器根据a+b的结果推导c的实际类型,
    // 就不会存在问题
    short c = a + b;
    std::map<std::string, std::string> m{{"apple", "苹果"}, {"banana","香蕉"}};
    
    // 使用迭代器遍历容器, 迭代器类型太繁琐
    std::map<std::string, std::string>::iterator it = m.begin();
    while(it != m.end())
    {
        cout<<it->first<<" "<<it->second<<endl;
        ++it;
    }
    return 0;
}

C++11 中,可以使用 auto 来根据变量初始化表达式类型推导变量的实际类型,可以给程序的书写提供许多方便。将程序中 cit 的类型换成 auto,程序可以通过编译,而且更加简洁。关于 auto 的详细介绍可以参考
C++ 初阶课件。

3.2 decltype类型推导

  1. 为什么需要 decltype

auto 使用的前提是:必须要对 auto 声明的类型进行初始化,否则编译器无法推导出 auto 的实际类型。 但有时候可能需要根据表达式运行完成之后结果的类型进行推导,因为编译期间,代码不会运行,此时 auto 也就无能为力。

参见代码如下:

template<class T1, class T2>
T1 Add(const T1& left, const T2& right)
{
    return left + right;
}

如果能用加完之后结果的实际类型作为函数的返回值类型就不会出错,但这需要程序运行完才能知道结果的实际类型,即 RTTI(Run-Time Type Identification 运行时类型识别)。

C++98 中确实已经支持 RTTI

  • typeid 只能查看类型不能用其结果类定义类型
  • dynamic_cast 只能应用于含有虚函数的继承体系中

运行时类型识别的缺陷是降低程序运行的效率。

  1. decltype

decltype 是根据表达式的实际类型推演出定义变量时所用的类型,比如:

  1. 推演表达式类型作为变量的定义类型

参见代码如下:

 int main()
 {
 	int a = 4;
 	double b = 3.14;
 	char c = '7';
 
     // 用decltype推演a+b+c的实际类型,作为定义d的类,为double类型
     // 不支持decltype(int + double)  d,不能使用类型
 	decltype(a + b + c) d;
 
 	cout << typeid(d).name() << endl;
 
    // 能直接获取类型名称,这就是与auto的区别
 	cout << typeid(decltype(getmemory)).name() << endl;
 	cout << typeid(decltype(getmemory(0))).name() << endl;
 	return 0;
 }
  1. 推演函数返回值的类型

参见代码如下:

void* GetMemory(size_t size)
{
    return malloc(size);
}
 
int main()
{
    // 如果没有带参数,推导函数的类型
    cout << typeid(decltype(GetMemory)).name() << endl;
    
    // 如果带参数列表,推导的是函数返回值的类型,注意:此处只是推演,不会执行函数
    cout << typeid(decltype(GetMemory(0))).name() <<endl;
    
    return 0;
}

4. 基于范围的for循环

参考博主博文即可:[C++系列] 8. C++基于范围的for循环(C++11

5. final与override

参考博主博文即可:[C++系列] 68. 多态基础及虚函数、抽象类详解

6. 智能指针

参考博主博文即可:[C++系列] 72. 智能指针

7. 新增加容器

静态数组 array、forward_list 以及 unordered 系列。

参考博主博文即可:[C++ 系列] 80. unordered系列关联式容器[C++ 系列] 88. STL进阶及简单总结

8. 委派构造函数

8.1 构造函数冗余造成重复

委派构造函数也是 C++11 中对 C++ 的构造函数的一项改进,其目的也是为了减少程序员书写构造函数的时间。通过委派其他构造函数,多构造函数的类编写更加容易。

参见代码如下:

class Info {
public:
    Info()
        : _type(0)
        , _name('a') {
        InitRSet();
    }
     Info(int type)
        : _type(type)
        , _name('a'){
        InitRSet();
    }
    
    Info(char a)
        : _type(0)
        , _name(a){
        InitRSet();
    }
    
private:
    void InitRSet() {//初始化其他变量} 
private:
    int _type;
    char _name;
    //...
};

上述构造函数除了初始化列表不同之外,其他部分都是类似的,代码重复。

初始化列表可以通过:类内部成员初始化进行优化,但是构造函数体的重复在 C++98 中无法解决。能否将:构造函数体中重复的代码提出来,作为一个基础版本,在其他构造函数中调用呢?

8.2 委派构造函数

所谓委派构造函数:就是指委派函数将构造的任务委派给目标构造函数来完成的一种类构造的方式。

就是在一个构造函数里调用自己的另一个构造函数。

参见代码如下:

class Info{
public: 
    // 目标构造函数
    Info()
        : _type(0)
        , _a('a') {
        InitRSet();
    }
    
    // 委派构造函数
    Info(int type)
        : Info() {
        _type = type;
    }
    
    // 委派构造函数
    Info(char a)
        : Info() {
        _a = a;
    }
    

private:
	void InitRSet(){
        //初始化其他变量
    } 
private:
    int _type =  0;
    char _a = 'a';
    //...
};

在初始化列表中调用”基准版本”的构造函数称为委派构造函数,而被调用的”基准版本”则称为目标构造函数。

注意:构造函数不能同时”委派”和使用初始化列表。

9. 默认函数控制

C++ 中对于空类编译器会生成一些默认的成员函数,比如:构造函数、拷贝构造函数、运算符重载、析构函数和 &const& 的重载、移动构造、移动拷贝构造等函数。如果在类中显式定义了,编译器将不会重新生成默认版本。 有时候这样的规则可能被忘记,最常见的是声明了带参数的构造函数,必要时则需要定义不带参数的版本以实例化无参的对象。而且有时编译器会生成,有时又不生成,容易造成混乱,于是 C++11 让程序员可以控制是否需要编译器生成。

9.1 显示缺省函数

C++11 中,可以在默认函数定义或者声明时加上 =default,从而显式的指示编译器生成该函数的默认版本,用 =default 修饰的函数称为显式缺省函数。

参见代码如下:

class A
{
public:
    A(int a)
        : _a(a)
    {}
 
    // 显式缺省构造函数,由编译器生成
    A() = default;
    
    // 在类中声明,在类外定义时让编译器生成默认赋值运算符重载
    A& operator=(const A& a);
private:
    int _a;
};
 
A& A::operator=(const A& a) = default;
 
int main()
{
    A a1(10);
    A a2;
    a2 = a1;
    return 0;
}

9.2 删除默认函数

如果能想要限制某些默认函数的生成,在 C++98 中,是该函数设置成 private,并且不给定义,这样只要其他人想要调用就会报错。在 C++11 中更简单,只需在该函数声明加上 =delete 即可,该语法指示编译器不生成对应函数的默认版本,称 =delete 修饰的函数为删除函数。

参见代码如下:

class A
{
public:
    A(int a)
        : _a(a)
    {}
    
    // 禁止编译器生成默认的拷贝构造函数以及赋值运算符重载
    A(const A&) = delete;
    A& operator(const A&) = delete;
    
private:
    int _a;
};
 
int main()
{
    A a1(10);
    
    // 编译失败,因为该类没有拷贝构造函数
    //A a2(a1);
    
    // 编译失败,因为该类没有赋值运算符重载
    A a3(20);
    a3 = a2;
    return 0;
}

注意:避免删除函数和 explicit 一起使用。关于该知识点可参考网上其他博主的讲解:C++笔记(1)explicit构造函数C++中explicit关键字有什么用?C++中的默认函数与default和delete用法。都解释的比较清楚,值得去学习研读。

10. 右值引用

10.1 移动语义

如果一个类中涉及到资源管理,用户必须显式提供拷贝构造、赋值运算符重载以及析构函数,否则编译器将会自动生成一个默认的,如果遇到拷贝对象或者对象之间相互赋值,就会出错,比如:

参见代码如下:

// 一个简单的string类
class String
{
public:
    String(char* str = "")
    {
        if (nullptr == str)
            str = "";
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
        }
    
    String(const String& s)
        : _str(new char[strlen(s._str) + 1])
    {
            strcpy(_str, s._str);
    }
    
    String& operator=(const String& s)
    {
        if (this != &s)
        {
            char* pTemp = new char[strlen(s._str) +1];
            strcpy(pTemp, s._str);
            delete[] _str;
            _str = pTemp;
        }
        
        return *this;
    }
    
    ~String()
    {
        if (_str)
            delete[] _str;
    }
    
private:
    char* _str;
};

假设现在有一个函数,返回值为一个 String 类型的对象:

参见代码如下:

String GetString(char* pStr)
{
	// 第一次构造
    String strTemp(pStr);
    // 作返回值会被放到新空间重新构造一次
    // 第二次调用拷贝构造
    return strTemp;
}
 
int main()
{
    String s1("hello");
    // 返回值优化仅调用1次构造函数
    // 第三次调用拷贝构造
    String s2(GetString("world"));	
    return 0;
}

在这里插入图片描述
上述代码看起来没有什么问题,但是有一个不太尽人意的地方:GetString 函数返回的临时对象,将 s2 拷贝构造成功之后,立马被销毁了(临时对象的空间被释放),再没有其他作用;而 s2 在拷贝构造时,又需要分配空间,一个刚释放一个又申请,有点多此一举。

那能否将 GetString 返回的临时对象的空间直接交给 s2 呢?这样 s2 也不需要重新开辟空间了,代码的效率会明显提高。
在这里插入图片描述
将一个对象中资源移动到另一个对象中的方式,称之为移动语义。C++11 中如果需要实现移动语义,必须使用右值引用。

参见代码如下:

String(String&& s)
    : _str(s._str)
{
    s._str = nullptr;    
}   

10.2 C++11中的右值

右值引用,顾名思义就是对右值的引用。C++11 中,右值由两个概念组成:纯右值和将亡值。

  • 纯右值
    纯右值是 C++98 中右值的概念,用于识别临时变量和一些不跟对象关联的值。比如:常量、一些运算表达式 (1+3)
  • 将亡值
    声明周期将要结束的对象。 比如:在值返回时的临时对象。

在这里有个简单明确的定义:

  • 右值:所有不能取地址的值被称为右值
  • 左值:只要能取地址,就是左值

10.3 右值引用

右值引用书写格式:类型&& 引用变量名字 = 实体;

右值引用最长常见的一个使用地方就是:与移动语义结合,减少无必要资源的开辟来提高代码的运行效率。

参见代码如下:

String&& GetString(char* pStr)
{
    String strTemp(pStr);
    return strTemp;
}
 
int main()
{
    String s1("hello");
    String s2(GetString("world"));
    return 0;
}

右值引用另一个比较常见的地方是:给一个匿名对象取别名,延长匿名对象的声明周期。

参见代码如下:

String GetString(char* pStr)
{
    return String(pStr);
}
 
int main()
{
    String&& s = GetString("hello");
    return 0;
}

注意:

  1. 与引用一样,右值引用在定义时必须初始化。
  2. 通常情况下,右值引用不能引用左值。
  3. 右值引用做返回值的时候,注意点跟引用和指针相同
  4. 局部变量的引用或者指针已经被释放,不能直接返回

参见代码如下:

int main()
{
    int a = 10;
    //int&& ra;      // 编译失败,没有进行初始化
    //int&& ra = a;  // 编译失败,a是一个左值
    
    // ra是匿名常量10的别名
    const int&& ra = 10;
    return 0;
}

10.4 std::move()

C++11 中,std::move() 函数位于 头文件中,这个函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,通过右值引用使用该值,实现移动语义。 注意:被转化的左值,其声明周期并没有随着左右值的转化而改变,即 std::move 转化的左值变量 lvalue 不会被销毁。

同样 static_cast<T &&> 也能完成该操作,move() 的底层实现就是 static_cast<T && >

参见代码如下:

// 移动构造函数
class String
{
    //....
    // s为将亡值,将资源进行托管
    // 即将深拷贝编程了浅拷贝操作,将大大提升效率
    // 在C++11中,移动构造函数也会默认提供,是第七个默认提供的函数
    // 默认提供的移动构造函数实现方式跟之前的拷贝构造一致,只能提供浅拷贝
    // 而不做其它操作,所以如果需要移动构造函数,最好手写
    String(String&& s)
        : _str(s._str)
    {
    	// 防止再次释放
        s._str = nullptr;    
    }
    
    // ....
};
 
int main()
{
    String s1("hello world");
    String s2(move(s1));
    String s3(s2);
    return 0;
}

以上代码中,s2 的构造方式与 s3 的构造方式相同吗? 上述代码有什么问题吗?

注意:上述代码是 move() 误用的一个非常典型的例子,move 更多的是用在声明周期即将结束的对象上。

下面来看一个移动构造函数的简单应用:

参见代码如下:

class String
{
	char * m_str;
public:
	static int s_m_Ccount;
	static int s_m_Mcount;

	String(char * str = "")
	{
		if (nullptr == str)
		{
			str = "";
		}
		m_str = new char[strlen(str) + 1];
		strcpy(m_str, str);
	}

	String(const String & s) :
		m_str(new char[strlen(s.m_str) + 1])
	{
		s_m_Ccount++;
		strcpy(m_str, s.m_str);
	}

	// C++11中,移动构造函数也会默认提供,是第七个默认提供的函数。
	// 默认提供的移动构造函数实现方式跟之前的拷贝构造一致,
	// 只能提供浅拷贝,不做其他操作,所以如果需要移动构造函数,最好手写。
	// 移动构造函数,直接拿到指针的值,进行浅拷贝
	String(String && s) :
		m_str(s.m_str) 	{
		s_m_Mcount++;
		s.m_str = nullptr;
	}

	String & operator = (const String & s)
	{
		if (this != &s)
		{
			char * ptmp = new char[strlen(s.m_str) + 1];
			strcpy(ptmp, s.m_str);
			delete []m_str;
			m_str = ptmp;
		}

		return *this;
	}

	~String()
	{
		if (m_str)
		{
			delete []m_str; 
		}
	}

	void show()
	{
		cout << m_str << endl;;
	}
};

int String::s_m_Ccount = 0;
int String::s_m_Mcount = 0;

String getString(char * str)
{
	return String(str);
}

int main()
{
	vector<String> vs;
	vs.reserve(1000);

	for (int i = 0; i < 1000; i++)
	{
		// 若在此使用拷贝构造函数则会产生深拷贝
		// 这是没有必要的
		// 采用移动构造函数,会媲美于写时拷贝,但仍效率方面不足写时拷贝
		vs.push_back(String("caixukun sing jump basketball jinitaimei"));
	}
	// 不调用拷贝构造 输出 0
	cout << String::s_m_Ccount << endl;
	
	// 调用移动构造 输出 1000,且速度大大提升
	cout << String::s_m_Mcount << endl;
	return 0;
}

参见代码如下:

class Person
{
public:
    Person(char* name, char* sex, int age)
        : _name(name)
        , _sex(sex)
        , _age(age)
    {}
    Person(const Person& p)
        : _name(p._name)
        , _sex(p._sex)
        , _age(p._age)
    {}
    
#if 0
    // 移动构造1
    // 调用拷贝构造函数 
    Person(Person&& p)
    	// 调用深拷贝,strcpy进行深拷贝
    	// 可能还会引起空间配置器进行空间配置等一系列不良情况
        : _name(p._name)
        , _sex(p._sex)
        , _age(p._age)
    {}
    
#else
    // 移动构造2
    // 调用移动构造函数
    Person(Person&& p)
    	// 浅拷贝
        : _name(move(p._name))
        // 浅拷贝
        , _sex(move(p._sex))
        // 无所谓深浅,int而已
        , _age(p._age)
    {}
    
#endif
    
private:
    String _name;
    String _sex;
    int _age;
};
 
Person GetTempPerson()
{
	// 生成临时对象并返回
    Person p("prety", "male", 18);
    return p;
}
 
int main()
{
    Person p(GetTempPerson());
    return 0;
}

上述代码中的条件编译打开和不打开有什么区别?

注意:为了保证移动语义的传递,程序员在编写移动构造函数时,最好使用 std::move 转移拥有资源的 成员为右值

10.5 移动语义中的一些问题

注意:

  1. 如果将移动构造函数声明为常右值引用或者返回右值的函数声明为常量,都会导致移动语义无法实现。

参见代码如下:

String(const String&&);
const Person GetTempPerson();
  1. C++11 中,无参构造函数/拷贝构造函数/移动构造函数实际上有 3 个版本

参见代码如下:

Object()
Object(const T&)
// 第七个默认构造函数,功能很强大
Object(T &&)
  1. C++11 中默认成员函数
    默认情况下,编译器会为程序员隐式生成一个(如果没有用到则不会生成)移动构造函数。 如果程序员声明了自定义的构造函数、移动构造、拷贝构造函数、赋值运算符重载、移动赋值、析构函数,编译器都不会再为程序员生成默认版本。编译器生成的默认移动构造函数实际和默认的拷贝构造函数类似,都是按照位拷贝(即浅拷贝)来进行的。 因此,在类中涉及到资源管理时,程序员最好自己定义移动构造函数。其他类有无移动构造都无关紧要。

注意:

  • C++11 中,拷贝构造/移动构造/赋值/移动赋值函数必须同时提供,或者同时不提供,程序才能保证类同时具有拷贝和移动语义。

10.6 完美转发

完美转发是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。

参见代码如下:

void process(int & i)
{
	cout << "process(int &):" << i << endl;
}

void process(int && i)
{
	cout << "process(int &&):" << i << endl;
}

void mforward(int && i)
{
	cout << "mforward(int &&):" << i << endl;
	
	// 在此i有了名字了,将不做为右值,而调用void process(int & i)函数
	// process((i));
	
	process(forward<int &&>(i));
}

void mforward(int & i)
{
	cout << "mforward(int &):" << i << endl;
	
	// 在此forward<int>(i)会将i处理为右值,仍实现不了完美转发
	// 将调用void process(int && i)
    // process(forward<int>(i));

	process(forward<int &>(i));
}

//通用引用
template <typename T>
void myforward(T && t)
// 如果传入的是int &&,那么T就直接代表int,
// 如果传入的是int &,那么T代表int &
// 引用折叠:右值引用+右值引用=右值引用 
//          左值引用+右值引用=左值引用
//int && && -> int &&
//int && & / int & && -> int &
{
	cout << "myforward:" << t << endl;
	// 利用通用引用实现完美转发
	process(forward<T>(t));
	// forward底层实现就是static_cast<T &&>加上引用折叠实现的
	process(static_cast<T &&>(t));
}

int main()
{
	int a = 6;
	process(a);
	process(2);
	process(move(a));

	mforward(a);
	mforward(2);
	mforward(move(a));
	
	myforward(a);
	myforward(2);
	myforward(move(a));
	return 0;
}

至此,可以使用多个函数重载的形式实现完美转发,但是比较啰嗦。所有我们一般使用通用引用配合引用折叠来简单的实现完美转发。

10.7 简单总结

1、什么是右值?
纯右值、将亡值

※可以取地址的统统是左值,否则是右值。

2、move forward
move是将一个值强制转换成右值
forward是将一个值转换为左值/右值

※右值引用可以延长一个临时对象的生命周期
※右值引用做返回值参考引用和指针,不能返回临时变量的右值引用。

3、移动构造函数

允许使用右值进行构造
※右值构造所用到的对象往往都是临时对象,所以可以直接将其资源转移,以节省时间,所以不要将左值通过move转成右值后去构造,否则该左值的资源将被转移,无法再使用。


4、类默认函数
移动构造函数和移动赋值,如果用到了但是没有实现,系统会给一个默认的函数。但是这个函数只会实现浅拷贝,会带来很多问题,所以如果有使用的需求,一定要自己实现一个。

5、通用引用:
用一个模板来实现:
template <typename T>
void func(T&& t)
{
	
}
如果传入的是int &&,那么T就直接代表int,如果传入的是int &,那么T代表int &

6、引用折叠:
右值引用+右值引用=右值引用 左值引用+右值引用=左值引用
int && && -> int &&
int && & / int & && -> int &

11. lambada表达式

11.1 C++98中的一个例子

C++98 中,如果想要对一个数据集合中的元素进行排序,可以使用 std::sort 方法。

参见代码如下:

#include <algorithm>
#include <functional>
 
int main()
{
    int array[] = {4,1,8,5,3,7,0,9,2,6};
    
    // 默认按照小于比较,排出来结果是升序
    std::sort(array, array+sizeof(array)/sizeof(array[0]));
    
    // 如果需要降序,需要改变元素的比较规则
    std::sort(array, array + sizeof(array) / sizeof(array[0]), greater<int>());
    return 0;
}

如果待排序元素为自定义类型,需要用户定义排序时的比较规则:

参见代码如下:

struct Goods
{
    string _name;
    double _price;
};
 
struct Compare
{
    bool operator()(const Goods& gl, const Goods& gr)
    {
        return gl._price <= gr._price;
    }
};

int main()
{
    Goods gds[] = { { "苹果", 2.1 }, { "香蕉", 3 }, { "橙子", 2.2 }, {"菠萝", 1.5} };
    sort(gds, gds+sizeof(gds) / sizeof(gds[0]), Compare());
    return 0;
}

随着 C++ 语法的发展,人们开始觉得上面的写法太复杂了,每次为了实现一个 algorithm 算法, 都要重新去写一个类,如果每次比较的逻辑不一样,还要去实现多个类,特别是相同类的命名,这些都给编程者带来了极大的不便。因此,在 C++11 语法中出现了 Lambda 表达式。

11.2 lambda表达式

参见代码如下:

int main()
{
    Goods gds[] = { { "苹果", 2.1 }, { "香蕉", 3 }, { "橙子", 2.2 }, {"菠萝", 1.5} };
    sort(gds, gds + sizeof(gds) / sizeof(gds[0]), [](const Goods& l, const Goods& r)
                                                  {
                                                       return l._price < r._price;
                                                  });
    return 0;
}

上述代码就是使用 C++11 中的 lambda 表达式来解决,可以看出 lambda 表达式实际是一个匿名函数。

11.3 lambda表达式语法

lambda 表达式书写格式:[capture-list] (parameters) mutable -> return-type { statement }

  1. lambda 表达式各部分说明
  • [capture-list] : 捕捉列表,该列表总是出现在 lambda 函数的开始位置,编译器根据 [ ] 来判断接下来的代码是否为 lambda 函数,捕捉列表能够捕捉上下文中所有的变量及对象,甚至是引用供 lambda 函数使用。 不需要传入,直接当做全局变量使用即可。
  • (parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同 ( ) 一起省略
  • mutable:默认情况下,lambda 函数总是一个 const 函数,mutable 可以取消其常量性。使用该修饰符时,参数列表不可省略(即使参数为空)。
  • ->return-type:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
  • {statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。

注意:lambda 函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为空。因此 C++11 中最简单的 lambda 函数为:[ ]{ }; 该 lambda 函数不能做任何事情。

参见代码如下:

int main()
{
	// 最简单的lambda表达式, 该lambda表达式没有任何意义
	[]{};
	// 省略参数列表和返回值类型,返回值类型由编译器推导为int
	int a = 3, b = 4;
	[a]{return a + 3; };
	// 省略了返回值类型,无返回值类型
	// g不是捕捉到的,而是因为g是全局变量,所以可以直接使用。
	auto fun1 = [&](int c){g = 8; b = a + c; };
	fun1(10);
	cout << a << " " << b << endl;
	// 各部分都很完善的lambda函数
	// =捕获副作用域所有变量,其中b是由引用方式捕获可以被更改
	// 在此a不能被更改,若想修改需要加上mutable关键字,即
	// auto fun2 = [=, &b](int c)mutable->...
	auto fun2 = [=, &b](int c)->int{return b += a + c; };
	//auto fun2 = [&, a](int c)->int{return b += a + c; };
	// 前后捕获不可重复,引用、变量均不可重复
	//auto fun2 = [&, &a](int c)->int{return b += a + c; };
	//auto fun2 = [=, a](int c)->int{return b += a + c; };
	cout << fun2(10) << endl;
	// 复制捕捉x
	int x = 10;
	auto add_x = [x](int a) mutable { x *= 2; return a + x; };
	cout << add_x(10) << endl;

	cout << g;
	return 0;
}

通过上述例子可以看出,lambda 表达式实际上可以理解为无名函数,该函数无法直接调用,如果想要直接调用,可借助 auto 将其赋值给一个变量。

  1. 捕获列表说明
    捕捉列表描述了上下文中那些数据可以被 lambda 使用,以及使用的方式传值还是传引用。
  • [var]:表示值传递方式捕捉变量 var
  • [=]:表示值传递方式捕获所有父作用域中的变量(包括 this[&var]:表示引用传递捕捉变量 var
  • [&]:表示引用传递捕捉所有父作用域中的变量(包括 this)
  • [this]:表示值传递方式捕捉当前的 this 指针

注意:
a. 父作用域指包含lambda函数的语句块

b. 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。 比如:[=, &a, &b]:以引用传递的方式捕捉变量 ab,值传递方式捕捉其他所有变量 [&,a, this]:值传递方式捕捉变量 athis,引用方式捕捉其他变量

c. 捕捉列表不允许变量重复传递,否则就会导致编译错误。 比如:[=, a]= 已经以值传递方式捕捉了所有变量,捕捉 a 重复

d. 在块作用域以外的 lambda 函数捕捉列表必须为空。

e. 在块作用域中的 lambda 函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都 会导致编译报错。

f. lambda 表达式之间不能相互赋值,即使看起来类型相同

参见代码如下:

void (*PF)();
int main()
{
	auto f1 = []{cout << "hello world" << endl; };
	auto f2 = []{cout << "hello world" << endl; };
	// 此处先不解释原因,等lambda表达式底层实现原理看完后,大家就清楚了
	//f1 = f2; // 编译失败--->提示找不到operator=()
	// 允许使用一个lambda表达式拷贝构造一个新的副本
	auto f3(f2);
	f3();
	// 可以将lambda表达式赋值给相同类型的函数指针

	PF = f1;
	PF();

	cout << typeid(f1).name() << endl;
	cout << typeid(f2).name() << endl;
	cout << typeid(f3).name() << endl;
	cout << typeid(PF).name() << endl;
	return 0;
}

11.4 函数对象与lambda表达式

函数对象,又称为仿函数,即可以想函数一样使用的对象,就是在类中重载了 operator() 运算符的类对象。

class Rate
{
public:
    Rate(double rate)
        : _rate(rate)
    {}
 
    double operator()(double money, int year)
    {
        return money * _rate * year;
    }
 
private:
    double _rate;
};
 
int main()
{
    // 函数对象
    double rate = 0.49;
    Rate r1(rate);
    r1(10000, 2);
 
    // 仿函数
    auto r2 = [=](double monty, int year)->double{return monty*rate*year; };
    r2(10000, 2);
    return 0;
}

从使用方式上来看,函数对象与 lambda 表达式完全一样。

函数对象将 rate 作为其成员变量,在定义对象时给出初始值即可,lambda 表达式通过捕获列表可以直接将该变量捕获到。

在这里插入图片描述

实际在底层编译器对于 lambda 表达式的处理方式,完全就是按照函数对象的方式处理的,即:如果定义了一个 lambda 表达式,编译器会自动生成一个类,在该类中重载了 operator() 所有上面的 f1 = f2 自然是编译无法通过的,这根本就是两个类。利用拷贝构造函数进行构造,编译器认为两者相同所以能够进行赋值传入。

11.5 简单总结

※lambda表达式底层的实现是通过仿函数实现的。
※lambda表达式可以使用全局变量,但是不能捕捉全局变量。(注意)
※lambda表达式可以赋值给函数指针变量。

12. 线程库

12.1 thread类的简单介绍

C++11 中最重要的特性就是对线程进行支持了,使得 C++ 在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含 < thread > 头文件,该头文件声明了 std::thread 线程类。C++11 中线程类

参见代码如下:

#include <iostream>
#include <thread>

using namespace std;

void func(int i, int j)
{
	cout << "I am thread." << i << ' ' << j << endl;
}

int main()
{
	thread t1(func, 2, 5);
	thread t2(func, 3, 8);

	t1.join();
	t2.join();

	cout << "I am main thread." << endl;
	return 0;
}

12.2 线程的启动

C++ 线程库通过构造一个线程对象来启动一个线程,该线程对象中就包含了线程运行时的上下文环境,比如:线程函数、线程栈、线程起始状态等以及线程ID等,所有操作全部封装在一起,最后在底层统一传递给 _beginthreadex() 创建线程函数来实现 (注意:_beginthreadexwindows 中创建线程的底层 c 函数)。std::thread() 创建一个新的线程可以接受任意的可调用对象类型(带参数或者不带参数),包括 lambda 表达式(带变量捕获或者不带),函数,函数对象,以及函数指针。

参见代码如下:

// 使用lambda表达式作为线程函数创建线程
int main()
{
    int n1 = 500;
    int n2 = 600;
    thread t([&](int addNum){
                            n1 += addNum;
                            n2 += addNum;
                            }, 500);
    
    t.join();
    std::cout << n1 << ' ' << n2 << std::endl;
    return 0;
}

12.3 线程的结束

启动了一个线程后,当这个线程结束的时候,如何去回收线程所使用的资源呢?thread 库给我们两种选择:

  • 加入式:join()
    join()会主动地等待线程的终止。在调用进程中 join()当新的线程终止时,join() 会清理相关的资源,然后返回,调用线程再继续向下执行。由于 join() 清理了线程的相关资源,thread 对象与已销毁的线程就没有关系了,因此一个线程的对象每次你只能使用一次 join(),当你调用的 join() 之后 joinable() 就将返回 false 了。

参见代码入下:

#include <iostream>
#include <thread>

using namespace std;


void foo()
{
    this_thread::sleep_for(std::chrono::seconds(1));
}
 
int main()
{
    thread t(foo);
    cout << "before join, joinable=" << t.joinable() << std::endl;
    
    t.join();
    cout << "after join, joinable=" << t.joinable()<< endl;
    return 0;
}
  • 分离式:detach()
    detach:会从调用线程中分理出新的线程,之后不能再与新线程交互。就像是你和你女朋友分手,那之后你们就不会再有联系(交互)了,而她的之后消费的各种资源也就不需要你去埋单了(清理资源)。此时调用 joinable() 必然是返回 false分离的线程会在后台运行,其所有权和控制权将会交给 c++ 运行库。同时,C++ 运行库保证,当线程退出时,其相关资源的能够正确的回收。

注意:

  • 必须在 thread 对象销毁之前做出选择,这是因为线程可能在你加入或分离线程之前,就已经结束了,之后如果再去分离它,线程可能会在thread对象销毁之后继续运行下去。

12.4 原子性操作库(atomic)

多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。比如:

参见代码如下:

#include <iostream>
using namespace std;
#include <thread>
 
unsigned long sum = 0L;
 
void fun(size_t num)
{
    for (size_t i = 0; i < num; ++i)
        sum++;
}
 
int main()
{
    cout << "Before joining,sum = " << sum << std::endl;
 
    thread t1(fun, 10000000);
    thread t2(fun, 10000000);
    t1.join();
    t2.join();
 
    cout << "After joining,sum = " << sum << std::endl;
    return 0;
}

C++98 中传统的解决方式:可以对共享修改的数据可以加锁保护。

参见代码如下:

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

std::mutex m;
unsigned long sum = 0L;
 
void fun(size_t num)
{
    for (size_t i = 0; i < num; ++i)
    {
        m.lock();
        sum++;
        m.unlock();
    }
}
 
int main()
{
    cout << "Before joining,sum = " << sum << std::endl;
 
    thread t1(fun, 10000000);
    thread t2(fun, 10000000);
    t1.join();
    t2.join();
 
    cout << "After joining,sum = " << sum << std::endl;
    return 0;
}

虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对 sum++ 时,其他线程就会被阻塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。因此 C++11 中引入了原子操作。
在这里插入图片描述
注意:需要使用以上原子操作变量时,必须添加头文件

参见代码如下:

#include <iostream>
#include <thread>
#include <atomic>
 
using namespace std;

atomic_long sum{ 0 };
 
void fun(size_t num)
{
    for (size_t i = 0; i < num; ++i)
        sum ++;   // 原子操作
}
 
int main()
{
    cout << "Before joining, sum = " << sum << std::endl;
 
    thread t1(fun, 1000000);
    thread t2(fun, 1000000);
    t1.join();
    t2.join();
    
    cout << "After joining, sum = " << sum << std::endl;
    return 0;
}

一般进程线程的处理都在 Linux 下操作了,C++11 的封装确实有很多便利,但是也是确实的不熟悉。

13. 简单总结

C++11 的大刀阔斧的改革又重新激活了这老语言的生命力,也不难看出 C++ 这孤傲的性格,不愿意封装大量的库供使用,无形中增加学习成本。希望能深入学习吧。

发布了391 篇原创文章 · 获赞 329 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/yl_puyu/article/details/104971328