C++:基础知识

1、内存四区

在C++程序执行时,内存可以被划分为以下四个区域:

(1)代码区(Code Segment:代码区的声明周期从程序加载到内存开始,一直持续到程序结束。代码区中存储的是程序的机器指令代码,这些指令在程序执行过程中被逐条执行。代码区的内容是只读的,不会被修改。

(2)全局数据区(Global Data):全局数据区是用于存储全局变量和静态变量的区域。全局变量和静态变量在程序运行期间一直存在,它们的内存分配是在程序启动时完成的。全局数据区包括静态存储区和常量存储区。

  • 静态存储区(Static Storage):静态变量和局部静态变量在静态存储区分配内存,它们的生命周期与程序的运行周期相同,即从程序启动到程序结束。
  • 常量存储区(Constant Storage):常量数据(如字符串常量)在常量存储区分配内存,它们的值在程序运行期间保持不变。

(3)栈区(Stack Segment:栈区的声明周期与函数的调用和返回相关。当一个函数被调用时,它的局部变量、函数参数和一些与函数调用相关的上下文信息会被分配到栈区。当函数执行完毕并返回时,栈上的这些数据会被释放。栈区的大小是动态变化的,随着函数的嵌套调用和返回而动态分配和释放。

问题局部变量为什么放到栈里面?

答:局部变量的生命周期相对较短,当调用一个函数时,函数的局部变量被推入栈,当函数返回时,局部变量被弹出栈。

(4)堆区(Heap Segment:堆区的声明周期由程序员手动管理。程序在运行时可以通过动态内存分配函数(如malloc或new)从堆区申请一块内存,并在不需要时手动释放(使用free或delete)。堆区的内存分配和释放不受函数调用的影响,可以在程序的任意阶段进行操作。

2、struct和class的区别

structclass
相同点
  • 两者都拥有成员函数、公有和私有部分
  • 任何可以使用class完成的工作,同样可以使用struct完成
不同点struct默认是public继承class默认是private继承

3、final和override关键字

当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。例子如下:

class Base
{
    virtual void foo();
};
 
class A : public Base
{
    void foo() final; // foo 被override并且是最后一个override,在其子类中不可以重写
};

class B final : A // 指明B是不可以被继承的
{
    void foo() override; // Error: 在A中已经被final了
};
 
class C : B // Error: B is final
{
};

当在父类中使用了虚函数时候,你可能需要在某个子类中对这个虚函数进行重写(override),以下方法都可以:

class A
{
    virtual void foo();
}
class B : public A
{
    void foo(); //OK
    virtual void foo(); // OK
    void foo() override; //OK
}

4、浅拷贝和深拷贝

(1)浅拷贝

        浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。

(2)深拷贝

深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。

示例如下:

#include <iostream>  
#include <string.h>
using namespace std;
 
class Student
{
private:
	int num;
	char *name;
public:
	Student(){
        name = new char(20);
		cout << "Student" << endl;
    };
	~Student(){
        cout << "~Student " << &name << endl;
        delete name;
        name = NULL;
    };
	Student(const Student &s){//拷贝构造函数
        //浅拷贝,当对象的name和传入对象的name指向相同的地址
        name = s.name;
        //深拷贝
        //name = new char(20);
        //memcpy(name, s.name, strlen(s.name));
        cout << "copy Student" << endl;
    };
};
 
int main()
{
	{// 花括号让s1和s2变成局部对象,方便测试
		Student s1;
		Student s2(s1);// 复制对象
	}
	system("pause");
	return 0;
}
//浅拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffed0c3ec0
//~Student 0x7fffed0c3ed0
//*** Error in `/tmp/815453382/a.out': double free or corruption (fasttop): 0x0000000001c82c20 ***

//深拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffebca9fb0
//~Student 0x7fffebca9fc0

5、内联函数和宏定义

内联函数:是一种特殊的函数,编译器会尝试将其内联展开,而不是通过函数调用的方式执行。内联函数通常用于执行简单的操作或者频繁调用的函数,以减少函数调用的开销和提高性能。

  1. 在使用时,宏只做简单字符串替换(编译前)。而内联函数可以进行参数类型检查(编译时),且具有返回值。
  2. 内联函数在编译时直接将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率,并且进行参数类型检查,具有返回值,可以实现重载。
  3. 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义。
  4. 内联函数有类型检测、语法判断等功能,而宏没有。

内联函数适用场景:

  • 使用宏定义的地方都可以使用 inline 函数。
  • 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率。

内联函数的示例:

#include <iostream>

// 内联函数示例
inline int addNumbers(int a, int b) {
    return a + b;
}

int main() {
    int result = addNumbers(5, 3);  // 内联函数调用
    std::cout << "Result: " << result << std::endl;

    return 0;
}

6、 new和delete

new的实现原理

  1. 当使用new运算符分配内存时,编译器会首先计算所需的内存大小,包括对象本身的大小和可能的额外内存(如虚函数表等)。
  2. 编译器会生成相应的代码,调用运行时库中的分配函数(如operator new)来分配所需大小的内存块。
  3. 运行时库会使用底层的内存分配机制(如操作系统提供的malloc()函数)来获取一块连续的内存空间。
  4. 运行时库会将分配到的内存进行适当的对齐,并返回一个指向该内存块的指针。

delete 的实现原理:

  1. 当使用 delete 运算符释放内存时,编译器会生成相应的代码,调用运行时库中的释放函数(如 operator delete)。
  2. 运行时库会接收到要释放的内存指针,并执行必要的清理操作(如调用对象的析构函数)。
  3. 运行时库会使用底层的内存释放机制(如操作系统提供的 free() 函数)来释放内存空间。

7、malloc与free的实现原理

  1. 在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk、mmap、munmap这些系统调用实现的。
  2. brk是将数据段(.data)的最高地址指针_edata往高地址推,mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。
  3. malloc小于128k的内存,使用brk分配内存,将_edata往高地址推;malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配;brk分配的内存需要等到高地址内存释放以后才能释放,而mmap分配的内存可以单独释放。当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。在上一个步骤free的时候,发现最高地址空闲内存超过128K,于是内存紧缩。
  4. malloc是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。

8、类成员初始化方式?构造函数的执行顺序 ?为什么用成员初始化列表会快一些?

初始化方式有两种:

(1)、赋值初始化,通过在函数体内进行赋值初始化;

class MyClass {
private:
    int number;
    std::string name;

public:
    MyClass() {
        number = 0;
        name = "Default";
    }
    
    void printData() {
        std::cout << "Number: " << number << std::endl;
        std::cout << "Name: " << name << std::endl;
    }
};

(2)、列表初始化,在冒号后使用初始化列表进行初始化。

class MyClass {
private:
    int number;
    std::string name;

public:
    MyClass(int num, const std::string& n) : number(num), name(n) {
        // 构造函数体
    }
    
    void printData() {
        std::cout << "Number: " << number << std::endl;
        std::cout << "Name: " << name << std::endl;
    }
};

这两种方式的主要区别在于:

函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。

列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。

一个派生类构造函数的执行顺序如下:

① 虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。

② 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。

③ 类类型的成员对象的构造函数(按照成员对象在类中的定义顺序)

④ 派生类自己的构造函数。

为什么用成员初始化列表会快一些?

        赋值初始化是在构造函数当中做赋值的操作,而列表初始化是做纯粹的初始化操作。我们都知道,C++的赋值操作是会产生临时对象的。临时对象的出现会降低程序的效率。

        通过构造函数初始化列表,编译器可以生成更高效的代码,避免了临时对象的创建和额外的赋值操作。这种直接初始化的方式可以提高代码的性能,并且在某些情况下,还可以优化构造函数的调用。

9、STL

*max_element用法

最大值*max_element,最小值*min_element,求和accumulate。

*min_element 和 *max_element头文件是algorithm,返回值是一个迭代器。

accumulate 头文件是numeric,第三个参数是初始值,返回值是一个数。

#include <algorithm>
#include <iostream>
#include <vector>
#include <numeric> 


int a[] = {1, 2, 3, 4, 5};
vector<int> v({1, 2, 3, 4, 5});

// 普通数组
int minValue = *min_element(a, a + 5); 
int maxValue = *max_element(a, a + 5); int sumValue = accumulate(a, a + 5, 0);
 
// Vector数组  
int minValue2 = *min_element(v.begin(), v.end());
int maxValue2 = *max_element(v.begin(), v.end());
int sumValue2 = accumulate(v.begin(), v.end(), 0);

10、c++类的默认六个成员函数详解

  1. 构造函数
  2. 析构函数
  3. 拷贝构造
  4. 赋值运算符重载
  5. 取地址运算符重载
  6. const修饰的取地址运算符重载
1、构造函数

构造函数就是在创建类对象的时候,由编译器自动调用,为对象进行初始化的一个特殊成员函数。它的名称和类名相同,并且在对象的声明周期内只调用一次

构造函数的特性:

1)函数名与类名相同
2)无返回值
3)对象实例化时编译器自动调用对应的构造函数
4)构造函数可以重载
5)如果用户没有自己定义构造函数,那么编译器会自动生成一个无参的默认构造,若用户定义了,则编译器不再自动生成。
6)无参构造和全缺省的构造统称为“默认构造函数”,并且默认构造函数只能有一个。(无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认成员函数。若无参的和全缺省的同时存在,要构造一个无参对象时会出错)
无参构造和全缺省构造构成函数重载,但是不可以同时存在。

7)编译器生成无参默认构造,什么也没有实现,有什么用?
解:不仅是编译器生成的默认构造,还有自己写的无参默认构造,构造后打印其成员变量的值都为乱码。但是,因为C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语法已经定义好的类型:如int/char…,自定义类型就是我们使用class/struct/union自己定义的类型,**编译器会自动调用自定义类型成员的默认构造函数。**而不对内置类型做任何处理。

2、析构函数

析构函数是一个对象在销毁时会自动调用析构函数,完成对象销毁前,类的一些资源清理工作。(不是完成对象的销毁,局部对象销毁工作是由编译器完成的)

析构函数的特性:

1)析构函数,名是在类名前加上字符~。
2)无参数,无返回值
3)一个类只有一个析构函数,如果用户自己没有定义,则系统会自动生成一个。
4)对象生命周期结束时,C++编译系统会自动调用析构函数。

5)编译器自动生成的析构函数,会对自定义类型成员调用它的析构函数

3、拷贝构造函数 

只有单个形参,并且该形参是对本类类型对象的引用(一般用const修饰),在用已存在类类型对象创建新对象时,编译器会自动调用。

拷贝构造函数的特性:

1)拷贝构造只是构造函数的一个重载
2)拷贝构造的参数只有一个,且必须引用传参,使用传值方式会引发无穷递归调用。

3)若用户没有定义拷贝构造,编译器会自动生成默认拷贝构造,但是只是按对象字节序进行拷贝,是浅拷贝

备注:(浅拷贝在拷贝指针的时候没有重新开辟一块空间将指针所指内存单元的内容拷贝到新空间里,再让拷贝的指针指向它。所以浅拷贝只做了表面功夫,**拷贝的指针仍然和之前的指针指向同一片空间,**所以一旦任何一个指针销毁,释放空间,那么另一个的指针也会出错。)

4、赋值运算符重载
运算符的重载

运算符重载是具有特殊函数名的函数也具有其返回值类型函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名为:关键字operator后面接需要重载的符号
函数原型:返回值类型 operator操作符(参数列表)

赋值运算符的重载

a)一个类如果没有显式定义赋值运算符的重载,那么编译器会自动生成一个,完成对象按字节序的值拷贝(浅拷贝)
b)如果类里面有指针,若同样赋值运算符利用浅拷贝进行,那么程序会崩溃。

5、const成员

1、const 修饰的类成员函数

const修饰的类成员函数称为const成员函数,实际修饰的是该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。

2、const修饰的变量

1)const修饰常量,表示其值无法修改
const int i=6; int const i=6;
2)const指向常量的指针
a)
int a=1; int b=2; const int *p1=&a; p1=&b;//可以修改指针的指向 //*p1=3不可以通过指针修改指针指向内存单元的值
指针p1不可修改它指向的内存单元的值,但是p1可修改其指向的内存单元。
b)
int a=1; int *const p1=&a;//初始化后不能指向其他内存单元,但是可以通过指针改变其指向内存单元的值
左定值,右定向
(*号在const的左边,则指针所指的内存单元的值不能通过指针改变,*号在const的右边,表示指针所指向的内存单元不可以改变。)
3)指向常量的指针常量
int a=1; int const * const p1 =&a; //指针指向的值和指向的内存单元都不可以改变

备注:

  1. const对象可以调用非const成员函数吗?
    解:不可以
  2. 非const对象可以调用const成员函数吗?
    解:可以
  3. const成员函数内可以调用其它的非const成员函数吗?
    解:不可以
  4. 非const成员函数内可以调用其它的const成员函数吗?
    解:可以
6、取地址运算符重载及const取地址运算符重载

一般情况下不用重新定义,编译器会默认生成。只有特殊情况下(想让别人获取到指定内容时)会重新定义。

11、单例模式

        在一个项目中,全局范围内,某个类的实例有且仅有一个,通过这个唯一实例向其他模块提供数据的全局访问,这种模式就叫单例模式。单例模式的典型应用就是任务队列。

防护措施

        如果使用单例模式,首先要保证这个类的实例有且仅有一个,也就是说这个对象是独生子女,如果我们实施计划生育只生一个孩子,不需要也不能给再他增加兄弟姐妹。因此,就必须采取一系列的防护措施。对于类来说以上描述同样适用。涉及一个类多对象操作的函数有以下几个:

构造函数:创建一个新的对象
拷贝构造函数:根据已有对象拷贝出一个新的对象
拷贝赋值操作符重载函数:两个对象之间的赋值

为了把一个类可以实例化多个对象的路堵死,可以做如下处理:

(1)、构造函数私有化,在类内部只调用一次,这个是可控的。

  • 由于使用者在类外部不能使用构造函数,所以在类内部创建的这个唯一的对象必须是静态的,这样就可以通过类名来访问了,为了不破坏类的封装,我们都会把这个静态对象的访问权限设置为私有的。
  • 在类中只有它的静态成员函数才能访问其静态成员变量,所以可以给这个单例类提供一个静态函数用于得到这个静态的单例对象。

(2)、拷贝构造函数私有化或者禁用(使用 = delete)

(3)、拷贝赋值操作符重载函数私有化或者禁用(从单例的语义上讲这个函数已经毫无意义,所以在类中不再提供这样一个函数,故将它也一并处理一下。)

由于单例模式就是给类创建一个唯一的实例对象,所以它的 UML 类图是很简单的:

因此,定义一个单例模式的类的示例代码如下: 

// 定义一个单例模式的类
class Singleton
{
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    Singleton(const Singleton& obj) = delete;
    Singleton& operator=(const Singleton& obj) = delete;
    static Singleton* getInstance();
private:
    Singleton() = default;
    static Singleton* m_obj;
};

在实现一个单例模式的类的时候,有两种处理模式:

  • 饿汉模式
  • 懒汉模式
饿汉模式

        饿汉模式就是在类加载的时候立刻进行实例化,这样就得到了一个唯一的可用对象。关于这个饿汉模式的类的定义如下:

// 饿汉模式
class TaskQueue
{
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    TaskQueue(const TaskQueue& obj) = delete;
    TaskQueue& operator=(const TaskQueue& obj) = delete;
    static TaskQueue* getInstance()
    {
        return m_taskQ;
    }
private:
    TaskQueue() = default;
    static TaskQueue* m_taskQ;
};
// 静态成员初始化放到类外部处理
TaskQueue* TaskQueue::m_taskQ = new TaskQueue;

int main()
{
    TaskQueue* obj = TaskQueue::getInstance();
}

定义这个单例类的时候,就把这个静态的单例对象创建出来了。当使用者通过 getInstance() 获取这个单例对象的时候,它已经被准备好了。
注意事项:类的静态成员变量在使用之前必须在类的外部进行初始化才能使用。

懒汉模式

懒汉模式是在类加载的时候不去创建这个唯一的实例,而是在需要使用的时候再进行实例化。

(1)懒汉模式类的定义
// 懒汉模式
class TaskQueue
{
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    TaskQueue(const TaskQueue& obj) = delete;
    TaskQueue& operator=(const TaskQueue& obj) = delete;
    static TaskQueue* getInstance()
    {
        if(m_taskQ == nullptr)
        {
            m_taskQ = new TaskQueue;
        }
        return m_taskQ;
    }
private:
    TaskQueue() = default;
    static TaskQueue* m_taskQ;
};
TaskQueue* TaskQueue::m_taskQ = nullptr;

        在调用 getInstance() 函数获取单例对象的时候,如果在单线程情况下是没有什么问题的,如果是多个线程,调用这个函数去访问单例对象就有问题了。假设有三个线程同时执行了getInstance() 函数,在这个函数内部每个线程都会 new 出一个实例对象。此时,这个任务队列类的实例对象不是一个而是 3 个,很显然这与单例模式的定义是相悖的。

(2) 线程安全问题
双重检查锁定

对于饿汉模式是没有线程安全问题的,在这种模式下访问单例对象的时候,这个对象已经被创建出来了。要解决懒汉模式的线程安全问题,最常用的解决方案就是使用互斥锁。可以将创建单例对象的代码使用互斥锁锁住,处理代码如下:

class TaskQueue
{
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    TaskQueue(const TaskQueue& obj) = delete;
    TaskQueue& operator=(const TaskQueue& obj) = delete;
    static TaskQueue* getInstance()
    {
        m_mutex.lock();
10      if (m_taskQ == nullptr)
11      {
12            m_taskQ = new TaskQueue;
13      }
        m_mutex.unlock();
        return m_taskQ;
    }
private:
    TaskQueue() = default;
    static TaskQueue* m_taskQ;
    static mutex m_mutex;
};
TaskQueue* TaskQueue::m_taskQ = nullptr;
mutex TaskQueue::m_mutex;

在上面代码的 10~13 行这个代码块被互斥锁锁住了,也就意味着不论有多少个线程,同时执行这个代码块的线程只能是一个(相当于是严重限行了,在重负载情况下,可能导致响应缓慢)。我们可以将代码再优化一下:

class TaskQueue
{
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    TaskQueue(const TaskQueue& obj) = delete;
    TaskQueue& operator=(const TaskQueue& obj) = delete;
    static TaskQueue* getInstance()
    {
9       if (m_taskQ == nullptr)
        {
            m_mutex.lock();
            if (m_taskQ == nullptr)
            {
                m_taskQ = new TaskQueue;
            }
            m_mutex.unlock();
        }
        return m_taskQ;
    }
private:
    TaskQueue() = default;
    static TaskQueue* m_taskQ;
    static mutex m_mutex;
};
TaskQueue* TaskQueue::m_taskQ = nullptr;
mutex TaskQueue::m_mutex;

        改进的思路就是在加锁、解锁的代码块外层有添加了一个 if判断(第 9 行),这样当任务队列的实例被创建出来之后,访问这个对象的线程就不会再执行加锁和解锁操作了(只要有了单例类的实例对象,限行就解除了),对于第一次创建单例对象的时候线程之间还是具有竞争关系,被互斥锁阻塞。上面这种通过两个嵌套的 if 来判断单例对象是否为空的操作就叫做双重检查锁定

双重检查锁定的问题

假设有两个线程 A、B,当线程 A 执行到第 8 行时在线程 A 中 TaskQueue 实例对象 被创建,并赋值给 m_taskQ。

static TaskQueue* getInstance()
{
    if (m_taskQ == nullptr)
    {
        m_mutex.lock();
        if (m_taskQ == nullptr)
        {
            m_taskQ = new TaskQueue;
        }
        m_mutex.unlock();
    }
    return m_taskQ;
}

但是实际上 m_taskQ = new TaskQueue; 在执行过程中对应的机器指令可能会被重新排序。正常过程如下:

  • 第一步:分配内存用于保存 TaskQueue 对象。
  • 第二步:在分配的内存中构造一个 TaskQueue 对象(初始化内存)。
  • 第三步:使用 m_taskQ 指针指向分配的内存。

但是被重新排序以后执行顺序可能会变成这样:

  • 第一步:分配内存用于保存 TaskQueue 对象。
  • 第二步:使用 m_taskQ 指针指向分配的内存。
  • 第三步:在分配的内存中构造一个 TaskQueue 对象(初始化内存)。

这样重排序并不影响单线程的执行结果,但是在多线程中就会出问题。如果线程 A 按照第二种顺序执行机器指令,执行完前两步之后失去 CPU 时间片被挂起了,此时线程 B 在第 3 行处进行指针判断的时候 m_taskQ 指针是不为空的,但这个指针指向的内存却没有被初始化,最后线程 B 使用了一个没有被初始化的队列对象就出问题了(出现这种情况是概率问题,需要反复的大量测试问题才可能会出现)。

在 C++11 中引入了原子变量 atomic,通过原子变量可以实现一种更安全的懒汉模式的单例,代码如下:

class TaskQueue
{
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    TaskQueue(const TaskQueue& obj) = delete;
    TaskQueue& operator=(const TaskQueue& obj) = delete;
    static TaskQueue* getInstance()
    {
        TaskQueue* queue = m_taskQ.load();  
        if (queue == nullptr)
        {
            // m_mutex.lock();  // 加锁: 方式1
            lock_guard<mutex> locker(m_mutex);  // 加锁: 方式2
            queue = m_taskQ.load();
            if (queue == nullptr)
            {
                queue = new TaskQueue;
                m_taskQ.store(queue);
            }
            // m_mutex.unlock();
        }
        return queue;
    }

    void print()
    {
        cout << "hello, world!!!" << endl;
    }
private:
    TaskQueue() = default;
    static atomic<TaskQueue*> m_taskQ;
    static mutex m_mutex;
};
atomic<TaskQueue*> TaskQueue::m_taskQ;
mutex TaskQueue::m_mutex;

int main()
{
    TaskQueue* queue = TaskQueue::getInstance();
    queue->print();
    return 0;
}

上面代码中使用原子变量 atomic 的 store() 方法来存储单例对象,使用 load() 方法来加载单例对象。在原子变量中这两个函数在处理指令的时候默认的原子顺序是 memory_order_seq_cst(顺序原子操作 - sequentially consistent),使用顺序约束原子操作库,整个函数执行都将保证顺序执行,并且不会出现数据竞态(data races),不足之处就是使用这种方法实现的懒汉模式的单例执行效率更低一些。

静态局部对象

在实现懒汉模式的单例的时候,相较于双重检查锁定模式有一种更简单的实现方法并且不会出现线程安全问题,那就是使用静态局部局部对象,对应的代码实现如下:

class TaskQueue
{
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    TaskQueue(const TaskQueue& obj) = delete;
    TaskQueue& operator=(const TaskQueue& obj) = delete;
    static TaskQueue* getInstance()
    {
9        static TaskQueue taskQ;
10       return &taskQ;
    }
    void print()
    {
        cout << "hello, world!!!" << endl;
    }

private:
    TaskQueue() = default;
};

int main()
{
    TaskQueue* queue = TaskQueue::getInstance();
    queue->print();
    return 0;
}

在程序的第 9、10 行定义了一个静态局部队列对象,并且将这个对象作为了唯一的单例实例。使用这种方式之所以是线程安全的,是因为在 C++11 标准中有如下规定,并且这个操作是在编译时由编译器保证的:

如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。

最后总结一下懒汉模式和饿汉模式的区别:

懒汉模式的缺点是在创建实例对象的时候有安全问题,但这样可以减少内存的浪费(如果用不到就不去申请内存了)。饿汉模式则相反,在我们不需要这个实例对象的时候,它已经被创建出来,占用了一块内存。对于现在的计算机而言,内存容量都是足够大的,这个缺陷可以被无视。

写一个任务队列 

首要任务就是设计一个单例模式的任务队列,那么就需要赋予这个类一些属性和方法:

属性:

  • 存储任务的容器,这个容器可以选择使用 STL中的队列(queue)
  • 互斥锁,多线程访问的时候用于保护任务队列中的数据

方法:主要是对任务队列中的任务进行操作

  • 任务队列中任务是否为空
  • 往任务队列中添加一个任务
  • 从任务队列中取出一个任务
  • 从任务队列中删除一个任务

根据分析,就可以把这个饿汉模式的任务队列的单例类定义出来了:

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

class TaskQueue
{
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    TaskQueue(const TaskQueue& obj) = delete;
    TaskQueue& operator=(const TaskQueue& obj) = delete;
    static TaskQueue* getInstance()
    {
        return &m_obj;
    }
    // 任务队列是否为空
    bool isEmpty()
    {
        lock_guard<mutex> locker(m_mutex);
        bool flag = m_taskQ.empty();
        return flag;
    }
    // 添加任务
    void addTask(int data)
    {
        lock_guard<mutex> locker(m_mutex);
        m_taskQ.push(data);
    }
    // 取出一个任务
    int takeTask()
    {
        lock_guard<mutex> locker(m_mutex);
        if (!m_taskQ.empty())
        {
            return m_taskQ.front();
        }
        return -1;
    }
    // 删除一个任务
    bool popTask()
    {
        lock_guard<mutex> locker(m_mutex);
        if (!m_taskQ.empty())
        {
            m_taskQ.pop();
            return true;
        }
        return false;
    }
private:
    TaskQueue() = default;
    static TaskQueue m_obj;
    queue<int> m_taskQ;
    mutex m_mutex;
};
TaskQueue TaskQueue::m_obj;

int main()
{
    thread t1([]() {
        TaskQueue* taskQ = TaskQueue::getInstance();
        for (int i = 0; i < 100; ++i)
        {
            taskQ->addTask(i + 100);
            cout << "+++push task: " << i + 100 << ", threadID: " 
                << this_thread::get_id() << endl;
            this_thread::sleep_for(chrono::milliseconds(500));
        }
    });
    thread t2([]() {
        TaskQueue* taskQ = TaskQueue::getInstance();
        this_thread::sleep_for(chrono::milliseconds(100));
        while (!taskQ->isEmpty())
        {
            int data = taskQ->takeTask();
            cout << "---take task: " << data << ", threadID: " 
                << this_thread::get_id() << endl;
            taskQ->popTask();
            this_thread::sleep_for(chrono::seconds(1));
        }
    });
    t1.join();
    t2.join();
}

在上面的程序中有以下几点需要说明一下:

正常情况下,任务队列中的任务应该是一个函数指针(这个指针指向的函数中有需要执行的任务动作),此处进行了简化,用一个整形数代替了任务队列中的任务。

任务队列中的互斥锁保护的是单例对象的中的数据也就是任务队列中的数据,上面所说的线程安全指的是在创建单例对象的时候要保证这个对象只被创建一次,和此处完全是两码事儿,需要区别看待。

12、公有成员,私有成员,保护成员

成员权限
  • 公有成员:public表明该数据成员、成员函数是对所有用户开放的,所有用户都可以直接进行调用。
  • 私有成员:private为类的内部实现细节,只能被本类的成员函数访问,或者是友元访问。
  • 保护成员:protected对于子女(继承)、朋友(友元)来说,就是public的,可以自由使用,没有任何限制,而对于其他的外部class,protected就变成private。
继承
  • 公有继承:继承自父类的成员保持不变。
  • 私有继承:继承自父类的成员全部变为私有成员。
  • 保护继承:继承自父类的公有成员变为保护成员,其余不变。

13、友元函数和友元类

        对象中的 protected 成员和private 成员不允许被非成员函数直接访问,这称为类的封装性。为了外部访问类内的私有和保护数据,我们可以定义友元。这么做事为了实现数据共享,提高执行效率,同时,又保有类的一定的封装性和数据隐藏性。

友元可以是一个函数,该函数称为友元函数。友元也可以是一个类,该类被称为友元类。

友元函数

类的友元函数是指在类定义的一个普通函数,不是类的成员函数,但是它可以自由地访问类中的私有数据成员。它访问对象中的成员必须通过对象名。

友元类

若一个类为另一个类的友元,则此类的所有成员都能访问对方类的私有成员。

也就是,我认你做朋友了,那么我的东西你随便看随便用,哪怕是私有的东西。认你做朋友的方式,就是在我底下,把你声明成是我的朋友。举个例子如下:

在 A 底下将 B 声明为了友元类,那么 B 中实例化了一个 A 类型的 a,a 中的私有变量就可以被访问了。

主要注意的几点:友元关系不可以被继承;友元关系是单向的;友元关系不能传递。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值