C++ 万字总结(下篇)

本文详细介绍了C++中如何设计只能在堆或栈上创建的类,以及不可拷贝和不可继承的类。同时,讨论了单例模式的实现,包括饿汉模式和懒汉模式,并探讨了线程安全问题。此外,还涵盖了C++11的新特性,如列表初始化、decltype、右值引用、移动构造和智能指针的使用。
摘要由CSDN通过智能技术生成

菜狗程序员 C++ 万字总结,从入门到入土(下篇)

特殊类

设计一个类,该类只能在堆上创建对象
class HeapOnly
{
public:
    static HeapOnly* CreateObj()
    {
        return new HeapOnly();
    }
private:
    HeapOnly()
    {}
    // 只声明,不实现,C++98 防拷贝方式
    HeapOnly(const HeapOnly&);
    // C++11 防拷贝写法
    HeapOnly(const HeapOnly& = delete;


};

HeapOnly* p = HeapOnly::CreateObj();
设计一个类,该类只能在栈上创建对象
// 构造函数私有,返回局部对象的拷贝
class StackOnly
{
public:
    static StackOnly CreateObj()
    {
        return StackOnly();
    }
private:
    StackOnly()
    {}
};
// 构造函数公有,禁用 new
class StackOnly
{
public:
    StackOnly()
    {}
private:
    void* operator new(size_t size);
    void operator delete(void* p);
    // C++11
    void* operator new(size_t size) = delete;
    void operator delete(void* p) = delete;
}

上面两种方式有一个缺陷,就是无法禁用在静态区创建对象

设计一个类,该类不能被拷贝
class CopyBan
{
private:
    CopyBan(const CopyBan& c);
    CopyBan& operator=(const CopyBan&);
};
设计一个类,该类不能被继承

子类继承父类,需要先调用父类构造函数,因此只需将父类构造函数的访问权限设定成私有

class NonInherit
{
public:
    static NonInherit GetInstance()
    {
        return NonInherit();
    }
private:
    NonInherit()
    {}
};
class A:public NonInherit
{};
// 然而,上面这种方式还是可以继承,只是无法创建对象
// C++11 增加 final 关键字,使类不能被继承

class NonInherit final
{};

单例模式

单例模式就是保证一个进程中一个类仅有一个实例,并且该类提供一个访问接口,该实例被所有程序模块共享

// 饿汉模式:提供一个静态指向单例成员的成员指针,初始化时(在 main 函数之前) new 一个对象给它
class Singleton
{
public:
    static Singleton* GetInstance()
    {
        return _inst;
    }
private:
    // 禁止手动创建对象
    Singleton()
    {}
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton* _inst;
};
Singleton* Singleton::_inst = new Singleton;

一开始就创建对象,如果在构造函数中有很多配置工作,就会导致程序启动慢,迟迟进不去 main 函数

// 懒汉模式
class Singleton
{
public:
    static Singleton* GetInstance()
    {        
        if (_inst == nullptr)
        {
            _inst = new Singleton;
        }
        return _inst;
    }
private:
    // 禁止手动创建对象
    Singleton()
    {}
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton* _inst;
};
Singleton* Singleton::_inst = nullptr;

懒汉模式下,单例模式存在线程安全问题,需要加锁,在 C++11 中,可以使用 mutex 。

class Singleton
{
public:
    static Singleton* GetInstance()
    {
        _mtx.lock();
        if (_inst == nullptr)
        {
            _inst = new Singleton;
        }
        _mtx.unlock();
        return _inst;
    }
    // 垃圾回收类
    class GC
    {
    public:
        ~GC()
        {
            if (Singleton::_inst)
            {
                delete Singleton::_inst;
            }
        }
    };

    static GC _gc;

private:
    // 在类中定义静态锁,保证每个线程来的时候访问的是同一把锁
    static std::mutex _mtx;
    static Singleton* _inst;

};
std::mutex Singleton::_mtx;
Singleton* Singleton::_inst = nullptr;
// 在 main 函数结束后,会自动调用 GC 的析构函数,释放对象
Singleton::GC _gc;

频繁加锁和解锁给系统带来了负担,并且对于单例模式而言,只需要在第一次判断对象是否为 nullptr 时,加锁保护,因此可以使用双检查锁。

static Singleton* GetInstance()
{
    // 后续线程进入第一个判断直接退出
    if (_inst == nullptr)
    {
        // 第一个线程进来,加锁并创建对象
        _mtx.lock();
        if (_inst == nullptr)
        {
            _inst = new Singleton;
        }
        _mtx.unlock();
        return _inst;
    }
}

饿汉模式与懒汉模式对比:

  1. 饿汉模式简单;但是在饿汉模式中,由于要用静态成员去初始化单一对象,当有两个单例类时,并且对这两个类所创建的对象有顺序要求时,无法保证哪个对象先创建出来。
  2. 懒汉模式则可以控制对象的创建顺序。缺点是:相对饿汉模式,相对复杂。

其它懒汉模式版本:

class Singleton
{
public:
    static Singleton* GetInstance()
    {
        // 局部静态成员在第一次进入函数初始化,之后不再初始化
        // 如果单例对象太大,静态区占用太多,不合适,并且无法控制对象释放
        static Singleton inst;
        return &inst;
    }
};

C++ 11

{}列表初始化
vector<int> v{1,2,3};
int* arr = new int[5]{1,2,3,4,5};

decltype

根据表达式结果推演出类型

// 用 decltype 推演出 x*y 的实际类型,并用此类型定义 a 的类型
decltype(x*y) a;
右值引用

之前的引用 T& 和右值引用 T&& 都是给对象取别名。左值可以是一个变量名或是解引用的指针。

对于左值,我们可以使用获取它的地址,并且可以对它赋值。左值可以出现在赋值号左边,但不能出现在赋值号右边。

有个例外,const 修饰的左值,不能给它赋值,但是可以取地址。

右值也是一个数据的表达式,比如:字面量常量、表达式返回值、传值返回函数的返回值。

右值出现在赋值符号的右边,不能出现在赋值符号左边(不能被修改),并且右值不能取地址。

左值引用不能引用右值,但是 const 左值引用可以引用右值。

const int& ra = 10 + 20;

因此,const 引用既可以引用左值也可以引用右值。

右值引用不能引用右值,右值引用可以引用 move 以后的左值。

int a = 10;
int&& r = std::move(a);

右值引用的使用场景:

右值引用是为了补齐左值引用的短板。

左值引用可以用来作函数形参,大大减少了内存开销;左值引用也可以用作函数返回值,
但是并不是所有返回值都能用左值引用,比如局部变量,如果局部变量过大,就会对系统内存空间造成负担,因此这就是左值引用的短板。

// to_string 返回值不能是引用类型,因为 str 出了作用域就不存在了,因此只能传值返回
// return str 会调用拷贝构造函数将 str 拷贝给一个临时对象,临时对象创建好,局部对象 str 就销毁了
// 然后再调用一次拷贝构造,将临时对象拷贝给 ret ,ret 创建好,临时对象销毁,
// 总共发生两次拷贝构造,编译器会优化成一次拷贝构造
string to_string(int val)
{
    string str;

    // do something

    return str;
}
string ret = to_string(1);

移动构造

针对函数传值返回的缺点,C++11 使用移动构造核移动赋值解决这个问题。

移动构造就是参数类型是右值引用的构造函数。

class string
{
    // string 的移动构造函数
    string(string&& s)
    {

    }
};

string to_string(int val)
{
    string str;

    // do something

    // str 是一个即将销毁的对象,会被认为是右值,
    // 因此 return 时会调用 string 的移动构造函数,创建一个临时对象
    // 临时对象会再调用移动构造,创建 ret 对象
    // 编译器也会进行优化,两次移动构造变成一次移动构造
    return str;
}
string ret = to_string(1);

移动构造就是将一个对象中的资源全部移动到另一个对象中。通过将右值资源窃取过来,就不用做深拷贝了,比如:将函数采用传值返回,如果有移动构造,就不会调用拷贝构造函数,而是调用移动构造函数,省去了对象的深拷贝。

同理,移动赋值将临时对象的资源完全赋值给一个对象。

class string
{
    string& operator=(string&& s)
    {

    }
};
// 下面代码不会优化,会分别调用一次移动移动拷贝构造核移动赋值
string ret;
ret = to_string(1);
万能引用

通过模板来实现左值用左值引用,右值用右值引用

void Func(int &&x)
{}
void Func(const int &&x)
{}
void Func(const int &x)
{}
template<class T>
// 这里的引用叫做万能引用,但是 t 在后续调用的函数中(非当前函数)都退化成左值
// 因为右值引用再被引用后,就可以取到地址,也就是说,此时 t 的属性退化,t 会被识别成左值。因此在 Func 函数中仅传递 t,t 就会被识别成左值
void PerfectForward(T&& t)
{
    // 通过完美转发可以保持 t 的属性,t 再作为后续调用的函数参数时,依然需要使用完美转发,否则 t 的属性仍然会退化
    Func(std::forward<T>(t));
}

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

所谓完美,

  1. 就是在参数传递过程(转发)中,不产生额外的开销;
  2. 函数模板向其它函数传递自身形参时,如果相应实参是左值,就应该被转发为左值;如果相应实参是右值,他就应该被转发为右值。
新的类功能

新增默认成员函数:

  1. 移动构造函数
  2. 移动赋值运算符重载

当一个类中没有自己实现析构函数、拷贝构造、赋值运算符重载和移动构造函数时,编译器会生成一个默认的移动构造函数。

默认的移动构造函数对内置类型成员值拷贝;对自定义类型,如果自定义类型实现了移动移动构造,就调用移动构造,没有实现就调用拷贝构造。

移动赋值同理。

如果自己提供了移动构造和移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。

强制生成默认成员函数:

// 强制生成默认拷贝构造函数
Person(const Person& p) = default;

可变参数模板
// Args 是一个模板参数包,包中可以包含任意个模板参数
template<class T, class ...Args>
void ShowList(T value, Args ...args)
{
    cout << value << endl;
    ShowList(arg...);
}
// 当参数包无参数时,调用下面函数,结束递归
void ShowList()
{
    
}

参数包中的参数可以用列表初始化得到

// 但是参数类型必须都是 int
int arr[]  = {...args};
lambda 表达式

lambda 表达式就是一个匿名函数。

auto add = [](int x, int y)->int{return x + y;};
cout << add(1, 2) << endl;

[] 为捕获列表,可以捕获局部变量,从而在 lambda 表达式中使用。

当没有参数时,可以省略参数列表,因为返回值类型可以推演,所以返回值也可以省略。

底层编译器实际上是把 lambda 表达式转换成一个函数对象(仿函数)。

函数对象就是定义一个类,并且重载 operator(),使得类可以像使用函数一样去使用。

emplace_back
std::list<std::pair<int, string>> l;
// 先调用一次构造函数,再调用一次拷贝构造(深拷贝)
std::pair<int, string> kv(20, "sort");
l.emplace_back(kv);
// 先调用一次构造函数(创建匿名对象),再调用移动构造
l.emplace_back(std::pair<int, string>(20, "sort"));
// 直接调用一次构造函数
l.emplace_back(10, "sort");

emplace_back 直接传构造对象参数包,可以减少拷贝的次数,提高效率。

线程库

线程库包括了线程对象及其接口,用来控制线程和获取线程状态。

通过给线程对象提供一个线程函数,线程对象可以用来关联一个线程,线程函数可以是:函数指针、函数对象(仿函数)和 lambda 表达式。

void f1(int a)
{}
// 函数指针形式
thread t1(f1, 1);
// 函数对象形式
class TF
{
public:
    void operator()()
    {

    }
};
TF tf;
thread t2(tf);
// lambda 表达式
thread t3([]{});

当多个线程对共享数据进行修改时,会带来线程安全问题。

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

int main()
{
    int n;
    cin >> n;
    int N = 1000000;
    int x = 0;
    mutex mtx;
    vector<thread> vs;
    vs.resize(n);
    for (auto& td : vs)
    {
        td = thread([&mtx, &N, &x]
        {
            for (int i = 0; i < N; i++)
            {
                mtx.lock();
                cout << this_thread::get_id() << ":" << x << endl;
                ++x;
                mtx.unlock();
            }
        }
        );
    }

    for (auto& td : vs)
        td.join();


    return 0;
}

两个线程想要交替打印 1000 内的奇偶数,上面的代码是有问题的。一是无法保证哪个线程先运行,二是两个线程启动后,无法保证一个线程解锁后,另一个线程就能得到锁。

int main()
{
	int n = 100;
	mutex mtx;
	condition_variable cv;
	bool flag = true;

	// 偶数
	thread t2([&](){
		int j = 2;
		for (; j < n;)
		{
			unique_lock<mutex> lock(mtx);
			cv.wait(lock, [&flag]()->bool{return !flag; });  // false
			cout << j << endl;
			j += 2;
			flag = true;
			cv.notify_one();
		}
	});

	// 奇数
	thread t1([&](){
		int i = 1;
		for (; i < n;)
		{
			unique_lock<mutex> lock(mtx);
			cv.wait(lock, [&flag]()->bool{return flag; }); // true
			cout << i << endl;
			i += 2;
			flag = false;
			cv.notify_one();
		}
	});


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

	return 0;
}

上面代码引入 unique_lock,在创建时传入一个互斥锁 mutex,实现对其封装,禁止赋值和拷贝构造,以独占所有权的方式管理 mutex 对象,在调用 unique_lock 构造函数时上锁,调用析构函数时解锁,可以避免死锁问题。

上面代码有一个全局变量 flag,初始值 true;

首先无论是哪个线程先抢到锁,都会进行判断 flag,当 flag 为 true 时,线程 t1 会继续执行,当 flag 为 false 时,线程 t2 继续执行,否则阻塞并解锁,保证一个线程阻塞,另一个线程必执行;

当其中一个线程执行完,会将 flag 取反,并唤醒另一个阻塞线程,保证同一个线程不会被反复执行,确保两个线程交替执行。

智能指针

当 malloc 和 free 之间发生异常,会导致内存泄漏,这种问题叫做异常安全问题。

智能指针就是利用对象生命周期来控制程序资源,在对象构造时获取资源,在对象析构时释放资源;并且能够像指针一样使用(实现 operator* 和 operator-> 的重载)。

C++98 中提供了 auto_ptr 智能指针,除了在调用构造函数时指向资源,析构函数释放指向的资源外,在拷贝构造和赋值运算符重载时,会简单粗暴地将原始管理资源的指针赋值给新的指针,并将原始指针赋值为 NULL。

这会导致原始指针访问资源时发生问题。

C++11 提供了 unique_ptr,与 auto_ptr 不同的是,unique_ptr 禁用了拷贝构造和赋值运算符重载。

C++11 又提供了支持拷贝的 share_ptr,它是通过引用计数的方式来实现多个 share_ptr 对象之间的资源共享。

share_ptr 内部维护这一个引用计数,在调用析构函数时,引用计数减一,并且判断引用计数,引用计数不为 0,不能释放资源,反之可以。

引用计数的操作不是原子性的,因此存在线程安全问题,需要加锁;同时多个线程去访问堆上的资源也是不安全的。

同时 share_ptr 还存在循环引用问题(二者资源的释放都需要对方先析构,导致二者都没法析构)。可以通过 weak_ptr 解决,weak_ptr 类型的指针不会导致引用计数的增加。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Schuyler Hu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值