c++11、14新特性

参考自:c++ primer plus、c++标准库
相关的电子书可以在 https://hk1lib.org/ 上找到。

小功能

std::initializer_list

这是一个模板类,可用于容器构造时的初始化,初始列中的元素必须为同一类型(或者可以转换成同一类型)。

vector<int> vecTest {10, 6, 7};
string str {"abc"};

// 也可以声明该模板类的对象,以便进行函数调用
void print(std::initializer_list<int> initList)
{
	for(auto beg = initList.begin(); beg != initList.end(); ++beg)
	{
		std::cout << *beg << " ";
  }
}
// 然后这样调用
print({10, 20, 30});

不对,initializer_list居然有迭代器?它明明不是一种容器。原来它内部有一个array容器,{10,20,30}这一包东西实则被array管理。

auto

根据等号右边的变量类型自动进行类型推断,比如容器的迭代器类型就太长了(特别是map或者set),或者迭代器本身就很难写对,可以用auto比较省事,但不要滥用,最好只在迭代器类型或者使用lambda的时候才用。

vector<int> vec {10,20,30};
auto it = vec.begin(); // 这里的类型应该是vector::iterator

decltype

将变量的类型声明为表达式指定的类型。

// 想让y的类型与x一致
decltype (x) y;

// 定义模板时用处更大
template<typename T, typename U>
void pro(T t, U u)
{
	decltype(T*U) tu;
}

explicit

禁止含有单参数构造函数的类发生隐式转换

class A
{
public:
    A(int a);
    explicit A(double d);
};

int main()
{
    A a1, a2;
    a1 = 5; // 编译通过,整型5转换成A对象
    a2 = 1.0; // 报错,需要将1.0显示转换才行
}

右值引用

用&&表示右值引用,所谓右值就是只能出现在等号右边的变量,包括常数、字符常量、函数返回值(不能返回引用)等等。右值的特点就是不能对其取地址。

移动语义

主要是为了解决大规模数据量的拷贝问题。比如容器vector和string,它们都占用内存的一块连续的区域,且使用动态内存分配,即如果超出容器容量,容器自动扩充容量。扩充容量必须另外找一块足够大的连续内存,还要把原来的数据全部搬移到新内存中来。如果有成千上万的数据,工作量未免也太大了。
这时我们想到,如果要在电脑中移动文件,其实不需要移动文件所在空间,而可以更改目录项。同样地,移动语义也不需要移动数据,而只需要移交数据的所有权。
有兴趣可以看看《c++ primer plus》第6版第18.2小节,有一个很长的移动语义示例。
从本质上来看,移动语义有点像浅拷贝,但是浅拷贝完了之后会新增一个指针指向原有数据,而使用移动赋值之后,则不会新增一个指针,而会把原来的指针置空,所以移动复制运算符的参数不能是const对象。

默认的成员函数

c++11后,编译器会自动提供构造函数、析构函数、复制构造函数、复制赋值运算符、移动构造函数和移动赋值运算符。
如果自己提供了构造函数、复制构造函数或复制赋值运算符,那么编译器就不会提供默认的移动构造函数和移动赋值运算符。如果提供了移动构造函数或移动赋值运算符,编译器就不会提供默认的复制构造函数和复制赋值运算符。

默认的方法与禁用的方法

前面提到编译器会给类提供一些默认的成员函数,因此,即使我们声明了一个空类,但是在编译器看来,这个类也不为空。
default关键字表明我们希望使用编译器提供的默认版本的函数(仅限上文提到过的那六种)。
delete关键字表明我们希望禁止对象使用该函数,该关键字可用于任意的函数(但其实对那六种函数之外的函数声明delete没什么用处,我是这么认为的)。

class A
{
public:
    A() = default;
    ~A() = default;
    A(const A&) = delete;
}

什么时候我们不应该使用编译器提供给我们的默认版本的函数呢?
当我们的类中有指针对象的时候,我们应该自己提供构造函数、析构函数和复制构造函数等。特别是复制构造函数,由于指针的拷贝分为浅拷贝和深拷贝,而编译器的默认复制构造函数只会进行浅拷贝,我们需要手动进行深拷贝而避免一个对象的析构影响复制体的行为。

override和final

这两个标识符用来管理虚方法(并非关键字,因此可以声明一个变量名名为override或final)。
override是避免子类错误地声明了从父类继承的虚函数而产生的隐藏行为。

class A
{
public:
    virtual void doSth(float val);
};

class a : public A
{
public:
    virtual void doSth(int val);
};
// 此时子类a会隐藏doSth带参数float的基类版本
// 也就是说下列语句可能通过编译但是调用的不是版本并不是从父类继承而来的
a obja;
obja.doSth(1);

在成员函数的参数列表后面加上override,就可以向编译器表明我是想要在子类重写该虚函数而不是隐藏,否则就会报错。
final则是禁止派生类覆盖虚函数。

noexcept

在函数的参数列表后加关键字noexcept就向编译器明确表示,该函数不会抛出异常。异常即调用函数时产生的内存分配错误、运行期错误、逻辑错误等等,异常对象不会随着函数结束而被销毁,而是会一直向函数调用方传播,如果异常对象一直没有得到处理,程序就会terminate。
c++11后,类的构造函数、移动构造函数、复制构造函数都是默认不抛异常的。
可以用noexcept(func)来测试func是否会抛出异常。

lambda

即匿名函数(没有函数名而使用[]代替),与函数指针、函数符(重载了()运算符的结构体或类)一起统称为函数对象。可以用于跟STL的算法进行“互动”。

int a = 1;
int b = 2;
auto lamb1 = [](int val) { 
    val++;
    cout << val <<endl; 
};
// 在[]内可以指定捕获变量的变量名或者捕获形式
// [a]将采用值传递的形式传递a到lambda内部
// 而[&a]将采用引用传递传递到lambda内部
// [=]表示对所有变量都采用值传递,[&]表示对所有变量都采用引用传递

该实例的lamb1的类型是这样的 std::function,也就是把lamb1的返回值和参数列表抽取出来,可以想象,如果lambda的参数列表太长,这个类型就很难写,这时候auto的好处就体现出来了。(事实证明,懒惰也是人类技术发展的一大重要因素)

智能指针

防止内存泄漏的神器。主要管理堆上分配的内存,将普通的指针封装为一个栈对象,栈对象的生存期由系统管理,生命期结束自动调用析构函数释放申请的内存,因此不必害怕内存泄漏。这就是RAII机制。
智能指针都包含在c++的memory头文件中。

std::unique_ptr

对持有的堆内存宣誓所有权,不允许任何其他对象再指向该内存。也就是该类的拷贝构造函数和复制运算符都标记为delete。
初始化方法有以下几种:

// 方式1
std::unique_ptr<int> prt1(new int(1));
// 方式2
std::unique_ptr<int> ptr2;
ptr.reset(new int(1));
// 方式3
std::unique_ptr<int> ptr3 = std::make_unique<int>(1);

这里最好使用第三种方法,原因可看《Effective Modern C++》第三版第十七条款。
简单来说是这样的,new是一个关键字,它会调用operator::new,并做以下两件事:
1、调用底层的malloc获取一块堆内存
2、在这块内存上调用构造函数
试想一下,如果构造函数发生异常(谁知道呢?),那么new必然返回失败,而且这块堆内存可没人给它收尸,也就造成了内存泄漏。

unique_ptr实现了移动构造函数(右值语义,类似于浅复制,但是不会创建新的指针),因此函数返回一个unique_ptr的局部对象完全没有问题。也可以用std::move来移交堆内存的所有权。move也不能乱用,只有实现了移动构造函数或移动赋值运算符的类才可以。
unique_ptr还可以持有一组堆对象:

std::unique_ptr<int []> ptr1(new int[10]);

std::unique_ptr<int []> ptr(std::make_unique<int []>(10));

常用函数有:
1、void reset(T* p)
释放当前持有的指针管理权并获得p指针的管理权(抛家弃子+接盘)。p默认为nullptr。

2、T* release()
释放管理的指针所有权并返回之,并不会销毁其管理的指针对象。(主动放手让它追求幸福)

3、T* get()
返回管理的指针,但不会释放所有权,因此不能再用它来构造新的智能指针。(只能看不能碰)

4、void swap(unique_ptr& p)
与另一unique_ptr互换管理对象的所有权

std::shared_ptr

unique_ptr无疑是自私的,它不允许管理的对象再被其他指针引用。shared_ptr是无私的,允许多个shared_ptr共享一个对象,每多一个shared_ptr指向该对象,引用计数就会加1,当引用计数为0时,自动释放该对象占用的堆内存。
多个线程增加和减少对象的引用计数是安全的,但多个线程同时操作对象却不一定安全。
常用函数:
1、shared_ptr make_shared()
应优先采用这种方式来进行shared_ptr的初始化。

2、int use_count()
返回管理对象的引用计数。

3、void reset()和void reset(T* p)
reset()放弃对象的管理权,因此对象的引用计数会减1。reset(T* p)放弃管理原有对象管理权后还会接管指针p。

4、bool unique()
返回当前shared_ptr是否不与其他智能指针共享对象的管理权。

std::enable_shared_from_this

如有需要把类的this指针包装成一个shared_ptr并返回给外部使用,就可以继承该模板。

class A : public std::enable_shared_from_this<A>
{
public:
    A();
    ~A();
    
    std::shared_ptr<A> getSelf()
    {
        return shared_from_this();
    }
};

int main()
{
    std::shared_ptr<A> sp1(new A());
    std::shared_ptr<A> sp2 = sp1->getSelf();
    // 此时sp1与sp2指向同一个A类对象
    return 0;
}

注意事项
1、不应该共享栈对象的this指针
智能指针设计的初衷就是为了管理堆对象,因为堆对象不会自动释放占用的内存,而栈对象的内存有系统自动分配和释放。

2、避免enable_shared_from_this的循环引用
也就是避免编写出类似下列代码:

class A : public std::enable_shared_from_this<A>
{
public:
    A();
    ~A();
    
    void func()
    {
        self = shred_from_this();
    }
private:
	std::shared_ptr<A> self;
};

这里类里面包含了一个self指针,而func又会把类的this指针共享给self。那么如果调用了func函数后,A对象占用的内存实际上并不会被释放。(想一想,销毁A对象必须先销毁self,但是销毁self又得销毁this指针,销毁this指针必须销毁A对象,这就陷入了死循环,这就是典型的shared_ptr之间的互相引用,必然造成内存泄漏。)

std::weak_ptr

比shared_ptr弱一些的管理者,我们可以把shared_ptr看成是胖狱警,对象就是犯人,胖狱警持有手铐,每一名胖狱警管理犯人的方式就是给他铐上手铐(管他犯人身上有几幅手铐,拷上就完事了),而weak_ptr是瘦狱警,没有手铐管控力度自然就小了。
weak_ptr是为了协助shared_ptr工作的,可以从shared_ptr或weak_ptr构造,不能直接操作对象(没有重载->和.两种操作符),也不增加对象的引用计数。可以用来解决shared_ptr相互引用的死锁问题。
常用函数:
1、bool expired()
查看管理的对象是否已经被销毁了

2、lock()
把weak_ptr提升为shared_ptr(瘦狱警拿到了手铐,地位瞬间提升)

3、swap(weak_ptr& wp)

4、reset()

5、int use_count()
返回当前weak_ptr管理对象的引用计数

自定义智能指针持有对象的释放资源函数

智能指针管理的对象析构时只有调用delete或者delete[],但如果对象还持有文件描述符呢?文件描述符对外只是一个int类型,只回收这一块的内存没有任何用处。

// 我们可以这样定义资源释放函数
auto deleteFunctor = [](int* fdptr)
{
    close(*fdptr);
    delete fd;
}

std::unique_ptr<int, void (*) (int* fdp)> up(fdptr, deleteFunctor);

多线程

从c++11开始,c++提供了线程支持库,这意味着,我们可以跨平台实现c++多线程编程。
这里只是介绍少部分内容,如果想要详细了解多线程编程,可以看《c++并发编程实战》,这本书介绍了很多线程库的内容,如何保证线程安全、细粒度锁、无锁编程等等。

join和detach

对应着子线程的两种状态。
调用join函数后,主线程必须等待子线程结束才能结束,而调用detach后,主线程无需等待子线程结束,而把子线程交由系统管理。
子线程调用了detach之后就不能再调用join了。(属于是脱缰的兰博基尼,百公里加速基本在3.5秒以内,拉都拉不回来)

创建线程

可以用各种函数对象进行线程初始化

class A
{
public:
    void operator() () {
        cout << "created by way1" << endl;
    }
};

auto threadFunc = [] {
    cout << "created by way2" << endl;
};

void threadFunc2() {
    cout << "created by way3" << endl;
}
// 方式1
A a;
std::thread t1(a);

// 方式2
std::thread t2(threadFunc);

// 方式3
std::thread t3(threadFunc2);

// 如果需要传递参数进去,只需要在函数对象名称后面加上传入参数即可。
std::thread t(func, arg1, arg2...);

线程传递参数注意事项

传入参数时实则是值传递,因此传递参数时对象会发生多次拷贝。
使用VC2019编译以下程序:

#include
#include 
#include 
using namespace std;

class Tool
{
public:
    Tool() : a(0)
    {};

    Tool(const Tool &other) {
        this->a = other.a;
        cout << "copy constructor" << "  " <<this_thread::get_id() << endl;
    }

    int a;
};

class A
{
public:
    void operator() (Tool t) {
        cout << t.a << endl;
    }
};

int main() {
    A a;
    Tool tt;
    cout << this_thread::get_id() << endl;
    thread t1(a, tt);
    t1.join();

    return 0;
}

得到的结果是:
c++11、14新特性_第1张图片
也就是说类对象tt传递到子线程再到真正被调用的过程中,总共发生了三次复制?前两次复制都发生在主线程。
这里我只能理解为,编译器先将tt拷贝到了一块特定的区域,然后创建子线程的时候再把这块内存拷贝过去,因此调用了两次拷贝构造函数。
如果不希望进行这两次复制,可以在初始化线程的时候这样写:

thread t1(a, ref(tt));

这样运行结果就变成:
在这里插入图片描述

互斥量

除了mutex这种常见互斥量之外,还存在recursive_mutex,即递归互斥量,允许同一个线程对同一个互斥量上锁多次。还有timed_mutex,是带计时器的互斥量,也就是锁住一段时间然后释放。

lock_guard

一个具有RAII特性的类模板,构造时自动调用上锁,析构时自动解锁。

std::lock_guard<std::mutex> lockguard(my_mutex);
// 为了避免反复对同一个互斥量上锁,可以多加一个参数
std::lock_guard<std::mutex> lockguard(my_mutex, std::adopt_lock);
// adopt_lock是一个结构体对象,用于标记该互斥量是否已经上锁,如果已经上锁则不会再次加锁

lock函数模板

可以同时对多个互斥量上锁,不会引发死锁问题,当其中一个互斥量没能成功上锁,就会释放所有互斥量并再次尝试同时上锁(有活锁风险)。

std::lock(mutex1, mutex2 ...);

unique_lock

lock_guard模板虽然很好,但是灵活度不高,如果临界区区域很小,还要限制lock_guard的作用域,否则就会拉低程序运行效率。(我觉得还是c++标准制定组觉得程序里面那么会花括号不优雅啊,事实证明,审美也是人类技术进步的一大重要因素)

// 初始化
// 第二参数同样是避免反复加锁
unique_lock<mutex> lk(mylock, adopt_lock);

有以下四个成员函数:
1、lock:加锁
2、unlock:解锁
3、try_lock:尝试加锁,拿不到锁就会立即返回false
4、release:返回持有的互斥量指针,并释放释放其所有权,注意在此过程中,已经上锁的互斥量不会解锁

unique_ptr也可以调用move来获取另一个unique_ptr所管理的互斥量所有权。

call_once模板

保证在多个线程之间,某个函数只会被调用一次(创建一个单例类的时候就很友好)。

std::once_flag flag;
std::call_once(flag, func);

once_flag是一个结构体,可以标记一个函数是否处于已调用状态,若已被调用过则不会再次被调用。

条件变量

需要搭配unique_lock使用。

std::mutex lock;
std::unique_lock<mutex> lk(lock);
std::condition_variable mycond;
mycond.wait(lk, [] () 
    { return a == 1 ? true : false}; );

如果第二参数的lambda返回false,条件变量就会一直解锁互斥量,并阻塞直到其他线程调用notify_one函数。被唤醒后,条件变量重新尝试获取互斥量然后程序继续执行。
除此之外还有notify_all,可以唤醒所有阻塞的线程。

async与future

async是函数模板,用来启动异步任务。
所谓异步,也就是整件事的处理流程都不需要本线程参与,本线程只想获取结果。异步IO就是这样,如果read的时候采用异步IO,那么把硬件设备的数据拷贝到内存,再从内存拷贝到用户态缓冲区这两件事都不需要等待。(就好比我买了一个硬盘,硬盘直接送到家门口)

int func() {
    return 1;
}

// 新开一个子线程启动任务
std::future<int> result = std::async(func);
// 获取结果
// get是会阻塞的,要等待子线程结束
// get只能调用一次,除非是shared_future
// 因为get采用移动语句,调用之后原来的指针就置空了
int res = result.get();

如果加上第二参数,即async(func, std::launch::deferred),那么线程入口函数会延迟到future调用wait或者get才执行。并且,或许是处于减少线程创建开销的考虑,线程入口函数直接在主线程执行。

future_status

int func() {
	dosth...
	return 1;
}
std::future<int> result = std::async(func);
std::future_status status = result.wait_for(一段时间);

future_status是一个枚举类型,可以返回future当前状态,timeout(等待超时,即在等待时间内子线程没有执行完任务)、ready(子线程执行完毕)、deferred(延迟执行)。

packaged_task

一个类模板,看名字就知道,这大概是一个wrapper,用来把各种可调用对象包装起来作为线程入口函数。

int func(int val) {
	return ++val;
}

std::packadge_task<int(int)> package1(func);
std::thread t1(std::ref(packadge1), 2);
std::future<int> res = package1.get_future();

也可以包装lambda等等。

原子操作

一个线程执行原子操作时,不会被其他线程打断。针对的是一个单一变量而不是一段临界区,粒度更细,且效率比互斥量高。

// 对于原子整型(只是我的习惯叫法而已,不是术语)
// ++、--、+=、&=、|=等都是支持的
std::atomic<int> ato_int = 0;

需要注意的是,ato_int = 3这样的写操作不是原子操作。应该用load方法读原子类型,store方法写原子类型,这两种方法都是原子操作。

无锁编程

也就是乐观锁,每次修改数据时都觉得不会有其他线程进入临界区,所以每次修改数据都不会上锁,等修改完了之后再检查临界区内是否真的只有一个线程。
无锁编程需要用的compare and swap的思想,比如我往一个队列中放入元素,该队列有链表构成,保存了头和尾节点,从队列中取走元素就是把原来的头节点取出,把新的头节点置为旧的头节点的下一个节点。那么函数实现可以是这样的:

Node* pop() {
	 // 先保存原来的队列头
    Node* const old_head = head.load();
    // 如果当前的队列头与先前保存的队列头一致,证明没有其他线程调用了pop因此队列没有修改
    // 那我们就可以把新的队列头更新为head->next
    while(!head.compare_and_swap(old_head, head->next)); 
    return old_head;
}

本人才疏学浅,如有纰漏还望指教。

c++11、14新特性_第2张图片

你可能感兴趣的:(c++,开发语言)