C++知识问题总结

8 篇文章 1 订阅

Cpp问题整理-github

C++基础

3.常用排序算法

参考文章>>
在这里插入图片描述

20.稳定排序和非稳定排序有哪些

在一组数据中,两个相同的元素的前后位置在经过排序算法后不改变,该排序算法称为稳定排序算法。
稳定排序:
冒泡排序
归并排序
插入排序
基数排序
非稳定排序:
快速排序:最差情况下位O(N^2),情况发生在 数组已经完全逆序,或者数组内的元素都相同,这种情况下每个基准点下都无法将数组分成长度几乎相同的区。在完全逆序的情况下应该随机选择基点。
选择排序
希尔排序
堆排序:堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。

时间复杂度为nlongn的排序算法:
堆排序:在堆排序中,总共需要进行n-1次堆化操作。 因此,堆排序的总时间复杂度为O(nlogn)。循环 n -1 次,每次都是从根节点往下循环查找,所以每一次时间是 logn,总时间:logn(n-1) = nlogn - logn ;
归并排序
假设一颗二叉树的节点数量为N,那么它的高度就是logN + 1。 在拆分阶段,一共做了logN次(log8 = 3)的操作,该阶段时间复杂度是logN。
在合并阶段,我们要遍历二叉树的每一层节点,每层节点数都是N,需要遍历的层数有logN层(log8 = 3),所以这个阶段的时间复杂度就是NlogN。
两个阶段的时间复杂度相加logN + NlogN,可以得到时间复杂度O(NlogN)
快速排序
快速排序quick_sort,时间复杂度分析。

void quick_sort(int[]arr, int low, inthigh){

 if (low==high) return;

 int i = partition(arr, low, high);

 quick_sort(arr, low, i-1);

 quick_sort(arr, i+1, high);

}

仍用f(n)来表示数据量为n时,算法的计算次数,很容易知道:

当n=1时,quick_sort函数只计算1次
f(1)=1【式子A】

在n很大时:

第一步,先做一次partition;

第二步,左半区递归;

第三步,右半区递归;

即:

f(n)=n+f(n/2)+f(n/2)=n+2*f(n/2)【式子B】

画外音:

  • (1)partition本质是一个for,计算次数是n;

  • (2)二分查找只需要递归一个半区,而快速排序左半区和右半区都要递归,这一点在分治法与减治法一章节已经详细讲述过;

【式子B】不断的展开,

f(n)=n+2*f(n/2)

f(n/2)=n/2+2*f(n/4)

f(n/4)=n/4+2*f(n/8)

f(n/2(m-1))=n/2(m-1)+2f(n/2^m)

上面共m个等式,逐步带入,于是得到:

f(n)=n+2*f(n/2)

=n+2*[n/2+2f(n/4)]=2n+4f(n/4)

=2n+4*[n/4+2*f(n/8)]=3n+8f(n/8)

=…

=m*n+2m*f(n/2m)

再配合【式子A】:

f(1)=1

即,n/2^m=1时, f(n/2^m)=1, 此时m=lg(n), 这一步,这是分析这个算法的关键。

将m=lg(n)带入,得到:

f(n)=lg(n)*n+2^(lg(n))f(1)=nlg(n)+n

故,快速排序的时间复杂度是n*lg(n)快排复杂度分析

21.为什么比较排序算法的时间复杂度最低是nlogn?

22.递归函数注意点

  • 递归函数必须有让递归停下来的限制条件,即终止条件。
  • 随着递归函数的不断运行,必须越来越接近这个限制条件,否则会导致栈溢出。

26.C++程序如何引入C函数

C++和C是两种完全不同的编译链接处理方式。
1.引用头文件前需要加上 extern “C”,如果引用多个,那么就如下所示
extern “C”
{
#include “ s.h”
#include “t.h”
#include “g.h”
#include “j.h”
};
2.C++调用C函数的方法,将用到的函数全部重新声明一遍
extern “C”
{
extern void A_app(int);
extern void B_app(int);
extern void C_app(int);
extern void D_app(int);
}
C++语言支持函数重载,C语言不支持函数重载。函数被C++编译后在库中的名字与C语言的不同。

3.若不确定当前编译环境是C还是C++,可以这样:
#ifdef __cplusplus
extern “C” {
#endif

void fun1(int arg1);
void fun2(int arg1, int arg2);
void fun3(int arg1, int arg2, int arg3);

#ifdef __cplusplus
}
#endif

32.两个数不使用临时变量的情况下,如何把它们进行交换

1.加减法

#include<stdio.h>
int main()
{
	int a=520,b=1314;
 
	printf("after:\n");
	printf("a=%d,b=%d\n",a,b);
	
	a=a+b;
	b=a-b;
	a=a-b;
 
	printf("after:\n");
	printf("a=%d,b=%d\n",a,b);
	
	return 0;
}

2.乘除法

#include<stdio.h>
int main()
{
	int a=520,b=1314;
 
	printf("before:\n");
	printf("a=%d,b=%d\n",a,b);
	
	a=a*b;
	b=a/b;
	a=a/b;
 
	printf("after:\n");
	printf("a=%d,b=%d\n",a,b);
	
	return 0;
}

3.异或法
a^a = 0
b^0 = b

#include<stdio.h>
int main()
{
	int a=520,b=1314;
 
	printf("before:\n");
	printf("a=%d,b=%d\n",a,b);
	
	a=a^b;
	b=a^b; // b = a^b^b = a^0 = a
	a=a^b; //a = a^^b^a = 0^b = b
	printf("after:\n");
	printf("a=%d,b=%d\n",a,b);
	
	return 0;
}

38.友元函数

  • 类中通过使用关键字friend 来修饰友元函数,但该函数并不是类的成员函数,其声明可以放在类的私有部分,也可放在共有部分。友元函数的定义一般在类体外实现,也可以在类内实现,不需要加类限定。
  • 一个类中的成员函数可以是另外一个类的友元函数,而且一个函数可以是多个类友元函数。
  • 友元函数可以访问类中的私有成员和其他数据,但是访问不可直接使用数据成员,需要通过对对象进行引用。
  • 友元函数在调用上同一般函数一样,不必通过对对象进行引用。
  • 一般友元函数声明在类的最前面
  • 友元类,在一个类中声明 为友元类,则该友元类都可以访问他的私有数据。

43 重载和重写

重载指的是在同一个作用域内,两函数的函数名可以相同,但是参数不能完全相同,可以是参数类型不同或者是参数个数不同,至于返回值,不影响重载。
重载的意义>>
1.为这个print函数取不同的名字,如print_int、print_string。这里还只是两个的情况,如果是很多个的话,就需要为实现同一个功能的函数取很多个名字,如加入打印long型、char*、各种类型的数组等等。这样做很不友好!
2.类的构造函数跟类名相同,也就是说:构造函数都同名。如果没有函数重载机制,要想实例化不同的对象,那是相当的麻烦!
3.操作符重载,本质上就是函数重载,它大大丰富了已有操作符的含义,方便使用,如+可用于连接字符串等!

重写也叫覆盖。子类重新定义父类中有相同名称和参数的虚函数(virtual)。

  • 1 被重写的函数不能是static的。必须是virtual的
  • 2 重写函数必须有相同的类型,名称和参数列表
  • 3 重写函数的访问修饰符可以不同。尽管父类的virtual方法是private的,派生类中重写改写为public,protected也是可以的。这是因为被virtual修饰的成员函数,无论他们是private/protect/public的,都会被统一放置到虚函数表中。

重定义(redefining),也叫隐藏。子类重新定义父类有相同名称的非虚函数(参数列表可以不同)。子类若有和父类相同的函数,那么,这个类将会隐藏其父类的方法。

47.C++/Python/Java的区别

  • Python是解释型语言:
    所谓解释程序是高级语言翻译程序的一种,它将源语言(如BASIC)书写的源程序作为输入,由解释器根据输入的数据当场执行而不生成任何的目标程序,解释一句后就提交计算机执行一句。就像外语翻译中的“口译”一样,说一句翻一句,不产生全文的翻译文本。
    解释性语言有一个优势:跨平台比较容易
    缺点:效率低

  • C++需要编译后运行:
    先将源代码编译成目标语言(如:机器语言)之后通过连接程序连接到生成的目标程序,进行执行目标程序。
    C++与Python的几个常见区别:
    小区别1>>
    小区别2>>
    小区别3>>

    Python 提供了被称为「垃圾收集器」的自动内存管理机制,不允许直接进行内存处理操作。但在 C++ 里则没有这样的机制,并且所有内存管理操作都需要自行处理。

C++与Java的区别:

  • Java既可以编译执行也可以解释执行:
    编译执行:将源代码编译成与OS相应的本地可执行代码,
    解释执行:编译成中间代码,然后由解释器一句一句解释执行。所谓的中间代码就是字节码,就是JVM可执行的代码;JVM执行它的过程无非是将字节码翻译成OS可执行的代码。
    Java的可跨平台就统一在字节码上。而C的可移植是统一在源代码上的。当然也可以一次性把字节码全部翻译成本地可执行代码,以后每次执行的时候都执行这个本地可执行代码。
  • Java是纯面向对象的语言,所有代码(包括函数、变量)都必须在类中定义。而C++中还有面向过程的东西,比如是全局变量和全局函数。
  • C++中有指针,Java中没有,但是有引用。
  • C++支持多继承,Java中类都是单继承的。但是继承都有传递性,同时Java中的接口是多继承,类对接口的实现也是多实现。
  • C++中,开发需要自己去管理内存,但是Java中JVM有自己的GC机制,虽然有自己的GC机制,但是也会出现OOM和内存泄漏的问题。C++中有析构函数,Java中Object的finalize方法。
  • C++运算符可以重载,但是Java中不可以。同时C++中支持强制自动转型,Java中不行,会出现ClassCastException(类型不匹配)。

48.为什么要用Java虚拟机–JVM

参考文章>>

49.C/C++如何比较两个浮点数的大小

参考文章>>

50.实现atoi

#include <iostream>
#include <string>
using namespace std;


int my_strlen(const char*s){
    if(s== nullptr){
        cout<<"error"<<endl;
    }
    int i;
    for(i=0;s[i]!='\0';i++);
    return i;
}

//reference JDK1.8
int my_atoi(const char* s) {
    if(s== nullptr){
        throw "输入异常";
    }
    bool negative= false;
    int result = 0;//存放中间变量
    int len= my_strlen(s);
    int limit=-2147483647;
    int i=0;
    if(len>0) {
        if (s[0] < '0') {
            if ('-' == s[0]) {
                negative = true;
                limit=-2147483648;
            } else if ('+' != s[0]) {
                throw "输入异常";
            }

            if(len==1){
                throw "error";
            }
            i++;
        }
        while(i<len){
            int digit=s[i++]-'0';
            if(digit<0||digit>9){
                throw "输入异常";
            }else{
                result*=10;
                if(result-digit<limit){
                    throw "溢出";
                }
                result +=digit;
            }
        }
    }else{
        cout<<"error"<<endl;
        throw -2147483647;
    }
    return negative?-result:result;
}

int main() {
   string abc = "124";
    int tmp = my_atoi(abc.c_str());
    cout<<tmp;
    return 0;
}

51.线程池实现:

头文件:

#ifndef _THREAD_POOL_H_
#define _THREAD_POOL_H_

#include <iostream>
#include <vector>
#include <string>
#include <sstream>
#include <queue>
#include <mutex>  
#include <thread> 
#include <condition_variable>
#include <functional>

class ThreadPool
{
public:
    //线程池任务类型
    typedef std::function<void()> Task;

    ThreadPool(int threadnum = 0);
    ~ThreadPool();

    //启动线程池
    void Start();

    //暂停线程池
    void Stop();

    //添加任务
    void AddTask(Task task);

    //线程池执行的函数
    void ThreadFunc();

    //获取线程数量
    int GetThreadNum()
    { return threadnum_; }

private:
    //运行状态
    bool started_;

    //线程数量
    int threadnum_;

    //线程列表
    std::vector<std::thread*> threadlist_;

    //任务队列
    std::queue<Task> taskqueue_;

    //任务队列互斥量
    std::mutex mutex_;

    //任务队列同步的条件变量
    std::condition_variable condition_;
};

#endif

源文件:

#include "ThreadPool.h"
#include <deque>
#include <unistd.h>

ThreadPool::ThreadPool(int threadnum)
    : started_(false),
    threadnum_(threadnum),
    threadlist_(),
    taskqueue_(),
    mutex_(),
    condition_()
{

}

ThreadPool::~ThreadPool()
{
    std::cout << "Clean up the ThreadPool " << std::endl;
    //停止任务并唤醒所有线程
    Stop();
    for(int i = 0; i < threadnum_; ++i)
    {
        //挥手所有线程
        threadlist_[i]->join();
    }
    for(int i = 0; i < threadnum_; ++i)
    {
        //释放所有线程指针
        delete threadlist_[i];
    }
    //清空线程队列
    threadlist_.clear();
}

void ThreadPool::Start()
{
    if(threadnum_ > 0)
    {
        started_ = true;
        for(int i = 0; i < threadnum_; ++i)
        {
            std::thread *pthread = new std::thread(&ThreadPool::ThreadFunc, this);
            threadlist_.push_back(pthread);
        }
    }
    else
    {
       cout<<"0 threads create" ;
    }
}

void ThreadPool::Stop()
{
    started_ = false;
    //唤醒所有线程
    condition_.notify_all();
}

void ThreadPool::AddTask(Task task)
{    
    {
        std::lock_guard<std::mutex> lock(mutex_);
        taskqueue_.push(task);
    }
    //每添加一个任务就唤醒一个线程
    condition_.notify_one();//依次唤醒等待队列的线程
}

void ThreadPool::ThreadFunc()
{
    std::thread::id tid = std::this_thread::get_id();
    std::stringstream sin;
    sin << tid;    
    std::cout << "worker thread is running :" << tid << std::endl;
    Task task;
    while(started_)
    {
        task = NULL;
        {
            std::unique_lock<std::mutex> lock(mutex_);//unique_lock支持解锁又上锁情况
            while(taskqueue_.empty() && started_)
            {
                condition_.wait(lock);
            }
            if(!started_)
            {
                break;
            }
            //std::cout << "wake up" << tid << std::endl;
            //std::cout << "size :" << taskqueue_.size() << std::endl;

            task = taskqueue_.front();    
            taskqueue_.pop();            
        }
        if(task)        //执行worker线程内的任务
        {
            try
            {
                //std::cout << "ThreadPool::ThreadFunc" << std::endl;
                task();
            }
            catch (std::bad_alloc& ba)
            {
                std::cerr << "bad_alloc caught in ThreadPool::ThreadFunc task: " << ba.what() << '\n';
                while(1);
            }
            //task();//task中的IO过程可以使用协程优化,让出CPU
        }                        
    }
}

52.strcpy和memcpy的区别:

主要有以下3方面的区别。
1、复制的内容不同。strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。
2、复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。
3、用途不同。通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy

53.创建对象的过程

参考文章>>
参考文章>>
1.分配内存空间
A a 与 A a = new A()不同: 对于全局对象,静态对象以及分配在栈区域内的对象,对它们的内存分配是在编译阶段就完成了,而对于分配在堆区域内的对象,它们的分配是在程序运行阶段完成的。

内存空间的分配过程中需要确定分配空间的大小,即类对象的大小,这个问题是编译器根据类数据成员来进行分配。
全局对象和静态对象。编译器会为他们划分一个独立的段(全局段)为他们分配足够的空间,一般不会涉及到内存空间不够的问题分配在栈区域的对象。栈区域的大小由编译器的设置决定,不管具体的设置怎样,总归它是有一个具体的值,所以栈空间是有限的,在栈区域内同时分配超过空间大小的对象会导致栈区域溢出,由于栈区域的分配是在编译阶段完成的,所以在栈区域溢出的时候会抛出编译阶段的异常。分配在堆区域的对象。堆内存空间的分配是在运行是进行的,由于堆空间也是有限的,在栈区域内试图同时分配大量的对象会导致分配失败,通常情况会抛出运行时异常或者返回一个没有意义的值(通常是0)。

2.初始化成员变量与赋值
这一阶段是对象创建过程中最神秘的一个阶段,也是最容易被忽视的一个阶段。要想知道这一阶段具体完成那些任务,关键是要区分两个容易混淆的概念:初始化 (Initialization)和赋值(Assignment)。初始化早于赋值,它是随着对象的诞生一起进行的。而赋值是在对象诞生以后又给予它一个新的值。区分了这两个概念后,我们再转到对对象初始化的分析上。对类对象的初始化,实际上是对类对象内的所有数据成员进行初始化。C++已经为我们提供了对类对象进行初始化的能力,我们可以通过实现构造函数的初始化列表(memberinitialization list)来实现。

  • 初始化列表先于构造函数体内的代码执行;
  • 赋值(Assignment)--------------构造函数函数体内赋值实现;
  • 对象经过初始化以后,我们仍然可以对其进行赋值。我们可以通过构造函数的实现体(即构造函数中由"{}"包裹的部分)来实现。
  • 可以看出,构造函数实现了对象的初始化和赋值两个过程:对象的初始化是通过初始化列表来完成,而对象的赋值则才是通过构造函数,或者更准确的说应该是构造函数的实现体。

3.调用构造方法
在什么情况下必须使用初始化列表来初始化成员变量而不能使用构造函数赋值来实现?
当类的成员变量是const 或者 引用类型时,必须使用初始化列表进行初始化,原因是:

  1. const数据成员只在某个对象生存期内是常量,而对于整个类而言却是可变的。因为类可以创建多个对象,不同的对象其const数据成员的值可以不同。所以不能在类的声明中初始化const数据成员,因为类的对象没被创建时,编译器不知道const数据成员的值是什么。

  2. const数据成员的初始化只能在类的构造函数的初始化列表中进行。要想建立在整个类中都恒定的常量,应该用类中的枚举常量来实现,或者static const。

  3. 引用的指向只能初始化,不能修改其指向,赋值过程是在修改其指向,其实这就是赋值和初始化的本质区别

54.完美转发

参考文章>>
C++中的完美转发(perfect-forwarding)到底是什么?说到底,它其实就是一个类型转换,能够将传递到母函数的参数原封不动(这里的原封不动不仅指值不变,还包括类型信息,限定符之类的)在转发给其他函数。

例子:

    // TEMPLATE FUNCTION forward
template<typename T> 
 T&& forward(typename remove_reference<T>::type& _Arg) 
{   
    // forward an lvalue as either an lvalue or an rvalue
    return (static_cast<T&&>(_Arg));
}

template<typename T> 
T&& forward(typename remove_reference<T>::type&& _Arg) 
{   
    // forward an rvalue as an rvalue
    return (static_cast<T&&>(_Arg));
}

首先,可以看出来,forward函数对于参数是左值和右值都实现了一个版本,上面的接收左值,下面的接收右值参数。remove_reference::type就是将T的引用全部去除,比如T是int&,就变成了int。然后后面加上一个&就是左值引用,加上&&就是右值引用。T&&为综合引用的意思,原值是什么类型,T&&就是什么类型,调用static_cast将_Arg转换成原类型。

55.universal 综合引用

参考文章>>
符合 T &&形式的引用,即为综合引用
如果一个综合引用和一个右值引用参数绑定,那么这个综合引用就会被初始化为右值引用,反之,如果一个综合引用被一个左值初始化,那么这个综合引用就变成为一个左值引用。

47 lambda表达式原理

参考文章>>
lambda表达式有点类似于内联函数,在需要调用的地方展开,其实现原理是当我们编写了一个lambda之后,编译器将该表达式翻译成一个未命名类的未命名对象。该类含有一个重载的函数调用运算符

原理:比如在采用值捕获时,lambda形成的类中含有自己的数据成员,同时创建构造函数,函数参数为捕获的变量值,使用捕获的变量的值来初始化数据成员。然后该类中重载函数调用运算符,参数为lambda表达式的参数,函数体内执行lambda表达式函数体中相同的函数。初始化一个匿名类的匿名对象,通过调用匿名类的匿名对象,实现函数调用。

lambda表达式产生的类不含有默认构造函数、赋值运算符及默认析构函数。因此要想使用这个类必须提供一个实参来初始化匿名对象。

默认情况下,由lambda产生类当中的调用运算符是一个const成员函数,所以值捕获的值不能修改。如果加上mutable相当于去掉const。

代码:

int func()
{
    int a =10;
    int b = 20;
		
    auto addfun = [=] (const int c )-> int {
        return a+c;
    };
    int c = addfun(b);
    cout << c << endl;
    cout << a << endl;
};

//lambda的实现原理
class Myclass
{
public:
    Myclass( int a ) : m_a(a){};	//该形参对应捕获的变量
//该调用运算符的返回类型、形参和函数体都与lambda一致
    const int operator()(const int c) const
    {
        return m_a + c;
    }
private:
    int m_a;		//该数据对应通过值捕获的变量
};
int func2()
{
    int a =10;
    int b = 20;
    Myclass myclass(a);
    int ans =  myclass(b);
    cout<<ans<<endl;
}
int main()
{
    func2();

48.二维数组按行遍历和按列遍历

数组在内存中是按行储存的,按行遍历时可以由指向数组第一个数的指针一直往下走,就可以遍历完整个数组,而按列遍历则要获得指向每一列的第一行的元素的指针,然后每次将指针指下一行,但是指针的寻址很快,所以不会有明显的区别。
按行遍历时,只需要顺序往下遍历即可,按列遍历时,需要只要每行最后一个元素的地址,然后往下接着遍历。
C语言中
c语言数组的元素在内存中是连续存储的
所以会出现以下情况

int main()
{
	int arr[][3] = { {1,2,3},{4,5,6} };
	printf("%d %d\n", arr[0][2],arr[1][0]);
	printf("%d %d\n", arr[0][3], arr[1][0]);

	return 0;
}

在这里插入图片描述
arr[0][3]即arr[1][0]
因为下标运算符[]的运算方式是对指针进行加减然后进行访问
即:arr[1] 就是 *(arr+1)
arr[0][1] 就是 ((arr+0)+1)

所以arr[0][3],arr[1][0]两种形式算出来的值是一样的,都指向同一块地址(第四个元素)。

C++
二维vector不可以做到C语言那样,这是因为二维vector中的每一个vector都是一个对象,其对象存放在栈上,具体存储的元素存储在了堆上。这些个vector对象并不是连续的。

49.volatile 关键字

volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。

50.条件变量存在的必要性

特别是在生产者消费者模型中,加入使用互斥锁保护队列,则每次线程都要在轮询状态中,加锁,查询任务队列状态,解锁,每个线程都要去占用cpu资源去查询,大大损耗了性能,而这个上锁、检查、释放锁的过程就是非常冗余、消耗资源、效率低下的,而条件变量解决了这个问题。条件变量是一个线程间互相同步与通知的手段,他通过主动唤醒的方式减小了各个线程的开销,取代了简单但是消耗较大的一直被动循环检验与等待。
条件变量:
条件变量的内部实质上是一个等待队列,放置等待(阻塞)的线程,线程在条件变量上等待和通知,互斥锁用来保护等待队列(因为所有的线程都可以放入等待队列,所以等待队列成为了一个共享的资源,需要被上锁保护),因此条件变量通常和互斥锁一起使用。
条件变量允许线程等待特定条件(判断条件一般由用户自己给出)发生,当条件不满足时,线程通常先进入阻塞状态,等待条件发生变化。一旦其他的某个线程改变了条件,就可以唤醒等待队列中的一个或多个阻塞的线程。

51.内联函数和宏的区别

内联函数是代码被插入到调用者代码处的函数。如同 #define 宏,内联函数通过避免被调用的开销来提高执行效率,尤其是它能够通过调用(“过程化集成”)被编译器优化。 宏定义不检查函数参数,返回值什么的,只是展开,相对来说,内联函数会检查参数类型,所以更安全。 内联函数和宏很类似,而区别在于,宏是由预处理器对宏进行替代,而内联函数是通过编译器控制来实现的。而且内联函数是真正的函数,只是在需要用到的时候,内联函数像宏一样的展开,所以取消了函数的参数压栈,减少了调用的开销。

宏是预编译器的输入,然后宏展开之后的结果会送去编译器做语法分析。宏与函数等处于不同的级别,操作不同的实体。宏操作的是 token, 可以进行 token的替换和连接等操作,在语法分析之前起作用。而函数是语言中的概念,会在语法树中创建对应的实体,内联只是函数的一个属性。 对于问题:有了函数要它们何用?答案是:一:函数并不能完全替代宏,有些宏可以在当前作用域生成一些变量,函数做不到。二:内联函数只是函数的一种,内联是给编译器的提示,告诉它最好把这个函数在被调用处展开,省掉一个函数调用的开销(压栈,跳转,返回)

内联函数也有一定的局限性。就是函数中的执行代码不能太多了,如果,内联函数的函数体过大,一般的编译器会放弃内联方式,而采用普通的方式调用函数。这样,内联函数就和普通函数执行效率一样

内联函数必须是和函数体申明在一起,才有效。

9.C++ Virtual关键字的作用

1.虚函数

父类指针指向子类对象时,如果基类中带有virtual关键字的成员函数被子类重写,则调用子类的成员函数;如果子类重写的同名函数在基类中没有virtual关键字,则基类指针调用该函数时,调用了基类中的成员函数。
虚函数的调用取决于指向或者引用的对象的类型,而不是指针或者引用自身的类型。
C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过虚函数,父类的指针调用实际子类的成员函数。
2.纯虚函数

一个虚函数只是提供了一个可被子类型改写的接口。但是,它本身并不能通过虚函数机制被调用。这就是纯虚函数。
virtual print( ) = 0;
函数声明后紧跟赋值为0。
包含一个或多个纯虚函数的类被编译器识别为抽象基类。抽象基类不能被实例化,一般用于继承。抽象基类只能作为子对象出现在后续的派生类中。
继承抽象基类的子类必须实现父类的纯虚函数,否则该子类也不可实例化。
3.虚继承解决菱形继承问题

菱形继承时,如果中间两个类不加virtual进行虚继承,则在建立一个最底层的子类对象时,最顶层的基类会被出初始化两次,并析构两次,当中间两个类中有相同的成员属性时,最底层的子类在成员属性的继承上也会出现二义性,即不知道到底继承哪个属性。中间两个类在继承时添加virtual关键字可解决这个问题,添加虚继承后,实际继承的属性为最顶层的父类的属性,且该属性值唯一。该值的内容为最后一次被赋值后的值的大小。
参考>>

class Base
{
public:
    int a = 10;
    Base()
    {
        cout<<"Base"<<endl;
    }
    ~Base()
    {
        cout<<"~Base"<<endl;
    }
};
class ChildA: virtual public Base
{
public:

    ChildA()
    {
        cout<<"ChildA"<<endl;
        a = 20;
    }
    ~ChildA()
    {
        cout<<"~ChildA"<<endl;
    }
};
class ChildB: virtual public Base
{
public:
    ChildB()
    {
        cout<<"ChildB"<<endl;
        a = 30;
    }
    ~ChildB()
    {
        cout<<"~ChildB"<<endl;
    }

};

class sun:  public ChildA,public ChildB
{
public:
    sun()
    {
        cout<<"sun"<<endl;
    }
    ~sun()
    {
        cout<<"~sun"<<endl;
    }
};


int  main(int argc, char const *argv[])
{
    sun s;  //a的值为最后一次被赋值后的值  最后一个构造为ChildB()
    cout<<s.ChildA::a<<endl;    //30
    cout<<s.ChildB::a<<endl;   //30
    cout<<s.a<<endl;           //30
    //所有a变量的地址都相同 说明都继承于Base
}

4.虚析构与纯虚析构

普通析构是不会调用子类的析构的,所以在使用父类指针指向子类对象时,释放时,可能导致子类释放不干净。
利用虚析构解决 在虚构函数前加virtual,父类指针指向子类对象时,释放时,先调用子类析构,再调用父类析构。
纯虚析构需要类内声明,类外实现
出现纯虚析构函数,该类也是抽象类不可实例化
子类虚构函数必须实现,否则也是抽象类
使用父类指针指向子类对象时,如果父类使用纯虚析构函数,则在释放时,先析构子类,再析构父类。

10.C++ 多态的实现原理

多态:父类的引用或者指针指向子类对象。
原理:对于一个只有成员函数的类 其大小为1 如果给成员函数添加virtual关键字,则大小变为4,这是由于该类内部出现一个指针 vfptr 虚函数表指针,指向虚函数表中储存的指向该类内的成员函数的地址,如果该子类Cat没有声明与父类(Animal)virtual函数相同的同名函数speak,或者声明了与父类中没加virtual的同名函数,构造函数中,子类的vfptr会拥有与父类的虚函数表相同的虚函数表 指向&Animal::speak;有子类继承该父类时,如果使用与父类中virtual声明的同名函数,叫做重写重写必须返回值 参数 类型 顺序都相同 ,子类会将自己虚函数表中的父类函数地址改为自己的函数地址vfptr会指向子类自己的虚函数表中该函数的地址 &Cat::speak()

10.1 private如何实现多态

参考文章>>

当虚函数是private的,虚函数表的机制依然生效,只是对外部来说不能访问这个接口,我们依然可以用访问虚函数表的形式实现多态:可以通过寻找虚函数在虚函数表中的地址,通过函数指针来调用。

 
class Base {
private:
    virtual void f() { 
	cout << "Base::f" << endl; 
    } 
}; 
 
class Derive : public Base{
public:
    virtual void f() {
	cout<< "derive::f"<<endl;
    }
};
 
typedef void(*Fun)(void);
 
int main()
{   
    Derive d;
    d.f();
    Base *b = &d;
    Fun pFun = (Fun)*((int*)*(int*)(b)+0);
    pFun();
    return 0;
}

11.为什么析构函数一般设置为虚函数

如果基类的析构函数不是虚函数,当创建一个基类指针指向一个派生类对象,当释放此基类指针时,子类的析构函数不会被调用。具体参考多态原理中的虚函数表指针虚函数表

class A
{
public:
    A()
    {
        m_num = 1;
        cout<<"A构造"<<endl;
    }
    ~A()
    {
        cout<<"A析构"<<endl;
    }
    int m_num;
};
class B:public A
{
public:
    B()
    {
      cout<<"B构造"<<endl;
    }
    ~B()
    {
       cout<<"B析构"<<endl;
    }
};
int main()
{
    A *b = new B();
    delete  b;
}
G:\CSlrn\LeetCodeHot100\cmake-build-debug\LeetCodeHot100.exe
A构造
B构造
A析构
class A
{
public:
    A()
    {
        m_num = 1;
        cout<<"A构造"<<endl;
    }
    virtual ~A()
    {
        cout<<"A析构"<<endl;
    }
    int m_num;
};
class B:public A
{
public:
    B()
    {
      cout<<"B构造"<<endl;
    }
    ~B()
    {
       cout<<"B析构"<<endl;
    }
};
int main()
{
    A *b = new B();
    delete  b;
}
G:\CSlrn\LeetCodeHot100\cmake-build-debug\LeetCodeHot100.exe
A构造
B构造
B析构
A析构

12.继承的作用以及继承的模式

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展增加功能。这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。

继承的三种模式:
公有继承
保护继承
私有继承

1.对于公有继承:
子类可在类内访问父类的public数据和protected数据,在类外只能访问父类public数据。

在这里插入图片描述
在这里插入图片描述

2.对于保护继承:
子类可在类内访问父类的public数据和protected数据,不可在类外访问任何数据。
在这里插入图片描述
在这里插入图片描述
3.对于私有继承,只可在类内访问父类的public数据和protected数据,不可再类外访问任何数据。
在这里插入图片描述
在这里插入图片描述
私有继承是C++另一种实现has-a(包含)关系的途径。使用私有继承,基类的公有成员和保护成员都将成为派生类的私有成员。这意味着基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成员函数中使用它们

13.继承中的同名属性与同名函数

class A {
public:
    int m_A;
    virtual ~A()
    {

        cout<<"A析构"<<endl;
    }
    A()
    {
        m_A = 10;
    }
    virtual  void print()
    {
        cout<<"A.m_A = "<<m_A<<endl;
    }
    static  void print1()
    {
        cout<<"A Static"<<endl;
    }
    static int m_C;
};
int A:: m_C = 201;

class B: public A
{
public:
    int m_A;
    int m_B;
    B()
    {
        m_A = 100;
        m_B = 20;
    }
    ~B()
    {
        cout<<"B析构"<<endl;
    }
    void print()
    {
        cout<<"B.m_B = "<<m_B<<endl;
    }
    static void print1()
    {
        cout<<"B Static"<<endl;
    }
    static int m_C ;
};
int B::m_C = 50;
int main()
{
    A *a = new B();
    B * b = new B();
    return 0;
}

对于普通成员属性和普通成员函数:

普通成员属性
多态(父类指针指向子类对象):只能访问父类的该成员属性,子类隐藏了自身的成员属性。
不多态:优先子类自身的,通过添加作用域的方式访问父类和子类的成员属性。

普通同名函数:
多态:父类中该函数是否有virtual关键字。
不多态:优先使用子类自身的,通过作用域访问父类。

对于静态成员属性和静态成员函数:

不多态:
相同于普通类类对象,优先访问子类自身的静态成员属性和函数,可通过作用域访问父类的静态属性和静态函数。
多态:
static 不可以修饰virtual,所以同名函数肯定不是虚函数,所以只能访问父类的。
只能访问父类的静态属性。
只能访问父类的静态函数。
static关键字不能和virtual组合使用。

22.构造函数能否是虚函数

不可以,因为虚函数表是存储在对象的内存空间的,如果构造函数是虚函数,则需要通过虚函数表来调用,但是对象在未实例化前,并没有分配给内存空间,这是矛盾的,所以构造函数不能是虚函数。

23.析构函数能否是虚函数

可以,当创建父类指针指向子类对象时或普通子类对象时,如果析构函数是普通函数,则只会调用基类的析构函数,不会调用子类的析构函数,导致内存泄漏。
原因:
在调用普通函数时,在编译期间已经确定了它所要调用的函数(静态绑定),因为p是Person*类型,因此只会调用基类的析构函数。
当把基类的析构函数设置为虚函数后,会先调用基类的析构,然后调用子类的析构
原因:
当我们把基类析构函数定义为虚函数时,在调用析构函数时,会在程序运行期间根据指向的对象类型到它的虚函数表中找到对应的虚函数(动态绑定),此时找到的是派生类的析构函数,因此调用该析构函数;而调用派生类析构函数之后会再调用基类的析构函数,因此不会导致内存泄漏

24.类对象与类指针的区别

1.声明方式
类对象:A a;
类指针 : A *a = new A();
2.定义
类对象:利用类的构造函数(构造函数:对类进行初始化工作)在内存栈中分配一块区域(包括一些成员变量赋值);
类指针:是一个内存地址值,指向内存中存放的类对象(包括一些成员变量赋值;类指针可以指向多个不同的对象,这就是多态)
3.使用
引用成员的方式:
类对象 :操作符 “.”
类指针:操作符“->”
4.释放空间
类对象:由类的析构函数来释放空间
类指针:调用delete或delete[]
5.储存位置
类对象:栈,局部临时变量
类指针:堆或自由存储区
6.多态
类对象:无法实现多态
类指针:可以多态
7.访问方式
类对象:
声明后可直接访问,声明即调用了构造函数,分配了内存,不能多态
类指针:
声明后并没有调用构造函数,只是声明了一个未初始化的类指针,调用new后才会调用构造函数并分配内存,初始化了类指针,间接调用。可以多态。

25.static关键字

参考文章>>>
参考文章>>
1.static关键字的作用总览

  • 修饰全局变量时,表明一个全局变量只对定义在同一文件中的函数可见。
  • 修饰局部变量时,表明该变量的值不会因为函数终止而丢失。
  • 修饰函数时,表明该函数只在同一文件中调用。
  • 修饰类的数据成员,表明该成员对该类所有对象共享。即该实例归是属于该类的。
  • 用static修饰不访问非静态数据成员的类成员函数。即一个静态成员函数只能访问它的参数、类的静态数据成员和全局变量,不能访问类中的非静态成员数据。

2.静态局部变量与局部变量

  • 静态变量在全局区分配内存,所以静态局部变量也在全局区分配内存,而普通局部变量在栈区分配内存。
  • 静态局部变量只会被初始化一次,普通局部变量每次调用都会初始化。
int main()
{
    for (int i = 0; i <10 ; ++i)
    {
        static int a = 0;
        a++;
        cout<<a<<endl;

    }
    return 0;
}
G:\CSlrn\LeetCodeHot100\cmake-build-debug\LeetCodeHot100.exe
1
2
3
4
5
6
7
8
9
10

Process finished with exit code 0

  • 静态局部变量一般在声明处就初始化,如果没有显示初始化则默认初始化为0;普通局部变量不会被默认初始化。
int main()
{
    for (int i = 0; i <10 ; ++i)
    {
        static int a ;
        cout<<"a:"<<a++<<endl;
        int b ;
        cout<<"b:"<<b++<<endl;

    }
    return 0;
}
G:\CSlrn\LeetCodeHot100\cmake-build-debug\LeetCodeHot100.exe
a:0
b:6487920
a:1
b:6487921
a:2
b:6487922
a:3
b:6487923
a:4
b:6487924
a:5
b:6487925
a:6
b:6487926
a:7
b:6487927
a:8
b:6487928
a:9
b:6487929

Process finished with exit code 0

  • 静态局部变量内存分配在全局区,直到整个程序运行结束在释放,但是其作用域只在该局部,函数体外不可使用;局部变量在栈区,函数结束就释放。

3.静态全局变量与普通全局变量

  • 静态全局变量不能被其他文件引用。全局变量可以。
  • 可以在其他文件中定义与本文件中相同名字的变量,不发生冲突。全局变量不可以。

4.静态函数与普通函数

  • 静态函数不能被其他文件因引用
  • 可在其他文件中定义域本文件中相同名字的静态函数。

5.静态成员属性与普通成员属性

  • 静态成员属性属于类,每个对象共享数据。普通成员属性属于类对象。
  • 静态成员属性类内声明,类外定义,这是因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的。
  • static关键字只能出现在类内声明中,不能重复出现在类外定义中,这是由于如果在类外加了static关键字,则会与一般静态变量混淆,其作用域只能在本cpp中使用,不能被其他文件使用。
  • 静态成员属性在在类加载的时候就会分配内存,且只分配一次内存,非静态成员属性只有在创建类的实例的时候才会分配内存,且每次类构造对象时都分配一次内存。
  • 类的静态数据成员是静态存储,它是静态生存周期,必须进行初始化。
    6.静态成员函数与普通成员函数
    参考文章>>
  • 静态成员函数与普通成员函数都存储在代码区,所以即使不同的对象也不存在成员函数的拷贝,为一不同是是否有this指针。参考文章>>
  • 静态成员函数可以直接访问静态成员变量和静态成员函数
  • 普通成员函数可以直接访问静态成员函数与静态成员属性,但静态成员函数不能直接访问非静态成员函数和属性。这是由于:1.静态成员函数只属于类本身,随着类的加载而存在,不属于任何对象,是独立存在的。== 2==.非静态成员当且仅当实例化对象之后才存在,静态成员函数产生在前,非静态成员函数产生在后,故不能直接访问。3.内部访问静态成员用self,而访问非静态成员要用this指针,静态成员函数没有this指针,故不能访问。
  • 静态成员函数可以通过对象的引用来来访问非静态成员属性。
  • 对象调用静态成员函数,可以用".“或者”->",也可以用类名::函数名。
  • 静态成员函数不存在this指针,不能访问非静态成员变量。
    7.静态成员函数不能使用const
    对成员函数中使用关键字const是表明:函数不会修改该函数访问的目标对象的数据成员。只有非静态成员属性是属于对象的,但是静态成员函数根本无法访问非静态数据成员,那么就没必要使用const。
    8.哪种成员属性分别可以在类内初始化
    参考文章>>>
    在这里插入图片描述
    9.静态变量的初始化时机

1.编译时初始化
基本数据类型(POD),且初始化值是常量,那么这个初始化过程是在编译期间完成的。
2.加载时初始化
程序被加载时立即进行的初始化。这个初始化发生在main函数之前。即使程序任何地方都没访问过该变量, 仍然会进行初始化,因此形象地称之为"饿汉式初始化"。

  • 静态变量是一个基本数据类型,但是初始值非常量。
  • 静态变量是一个类对象,这种情况下即使是使用常量初始化,也是加载时初始化,而不是编译时初始化。

3.运行时初始化
这个初始化发生在变量第一次被引用。也就是说,从程序执行模型角度看,程序所在进程空间中,哪个线程先访问了这个变量,就是哪个线程来初始化这个变量。因此,相对于加载初始化来说,这种初始化是把真正的初始化动作推迟到第一次被访问时,因而形象地称为"懒汉式初始化"。

int myfunc()
{     	
    static std::string msg = "hello world !";    //运行时初始化
}

总结
如果是编译时和加载时初始化,是不会存在线程安全这个问题的。因为这两种初始化一定发生在Main函数执行之前,这个时候尚未进入程序运行空间,而这些初始化一定是在单线程环境下操作的。
如果是运行时初始化,因为无法保证访问这个静态变量一定只会从某个特定的线程中被访问,因此会存在"线程安全"的问题。

29.C++一个空的类里边有哪些函数

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

class Empty  
{  
public:  
  
    Empty(); // 缺省构造函数
    
    Empty( const Empty& ); // 拷贝构造函数
    
    ~Empty(); // 析构函数
    
    Empty& operator=( const Empty& ); // 赋值运算符
    
    Empty* operator&(); // 取址运算符
    
    const Empty* operator&() const; // 取址运算符 const
    
};

参考文章1>>
参考文章2>>

40.拷贝构造函数为什么传引用

默认情况下,如果类中没有显示声明构造函数,系统会默认生成一个隐式的构造函数和赋值运算函数。
可以使用delete显示不生成构造函数和赋值运算符

  Person(const Person& p) = delete; 
  Person& operator=(const Person& p) = delete;

或者将其声明在private中。
1.拷贝构造要传引用吗

  • 如果不使用引用,则发生拷贝构造时,如果采用值传递,会调用拷贝构造函数生成一个函数的实参,而调用拷贝构造就需要再进行一次相同的过称,会一直循环下去,直到函数的栈溢出。

2.赋值运算符函数的返回值类型要声明为该类型的引用,并在函数结束前返回实例自身的的引用(*this)吗

  • 只有返回一个引用,才能进行连续赋值。否则,如果函数的返回值是void,则应用改赋值运算符将不能进行连续赋值。假设有3个Person对象:p1、p2、p3,在程序中语句p1=p2=p3将不能通过编译。

3.拷贝构造的三个场景
在这里插入图片描述

41.深拷贝,浅拷贝与内存泄漏

参考文章>>
简单的赋值,对于指针或者动态分配的成员来说,任何一个对象对该值的修改都会影响到另一个对象,这种情况就是浅拷贝。
存在指针或者动态分配的成员,如果使用浅拷贝,会导致原来指针指向的空间没有被释放,该指针又指向了和被赋值指针的同一块内存,造成内存泄漏,在对象析构时,这块内存被析构两次。
深拷贝:释放指针原来指向的内存,开辟新空间并赋值。

深拷贝的使用原则:

  • 含有指针类型的成员或者有动态分配内存的成员都应该提供自定义的拷贝构造函数
  • 在提供拷贝构造函数的同时,还应该考虑实现自定义的赋值运算符函数。如果不自定义深拷贝的赋值运算符,会发生和上面所述相同的事情。

拷贝构造函数要确保一下几点:

对于值类型的成员进行值复制
对于指针和动态分配的空间,在拷贝中应重新分配分配空间
对于基类,要调用基类合适的拷贝方法,完成基类的拷贝

深拷贝与浅拷贝的例子
对象的复制一般通过拷贝构造函数完成的。

  • 当对象中没有动态成员时,普通的拷贝构造函数:使用新成员对老成员进行赋值,即可完成要求。
  • 当对象中有动态成员时,使用普通的拷贝构造函数就是浅拷贝,浅拷贝不会重新分配资源,只会进行简单的赋值,这会使新对象的动态成员与老对象的动态成员的值相同,即它们指向同一块内存。这就是问题所在。新老对象的动态成员指向相同的内存。当两个对象都析构时,相同的内存空间会被析构两次,这是错误的。
    一个简单的浅拷贝的例子
class c_Example
{
private:
    int a;
    char * str;
public:
    c_Example() //默认构造函数
    {
        cout<<"c_Example构造"<<endl;
    }
    c_Example(int b,char* cstr) //自定义构造函数
    {
        a = b;
        str = cstr;
    }
    c_Example(const c_Example &A) //拷贝构造函数  浅拷贝
    {
        //静态对象的浅拷贝 可以
        a=  A.a;

        //动态对象的浅拷贝,错误。
        str = A.str;
    }
    void show()
    {
        cout<<"a = ";
        cout<<a<<endl;
        cout<<"str = "<<str<<endl;
    }
};
int main()
{
    char str[5] = "abcd";

    c_Example cexm (5,str);
    c_Example cexmcopy(cexm);
    return 0;
}

动态对象指向相同的动态空间
在这里插入图片描述
简单的深拷贝的例子

class deep_Example
{
private:
    int a;
    char *str;
public:
    deep_Example(int b,const char *cstr)
    {
        a = b;
        delete str;
        str =(char*)malloc(sizeof(cstr));
        strcpy(str,cstr);
    }
    ~deep_Example()
    {
        if(str != NULL)
             delete [] str;
        cout<<"deep_Example的析构"<<endl;
    }
    //深拷贝,对于动态成员,重新分配内存。
    deep_Example(const deep_Example&A)
    {
        a = A.a;
        delete str;
        str =(char*)malloc(sizeof(A.str));
        if (str !=0)
        {
            strcpy(str,A.str);
        }
    }
    void show()
    {
        cout<<"str = "<<str<<endl;
        cout<<"a = "<<a <<endl;
    }
};
int main()
{
    char str[5] = "abcd";
    deep_Example cexm (5,str);
    deep_Example cexmcopy(cexm);
    return 0;
}

动态对象指向不同的内存空间
在这里插入图片描述

44.具有继承关系的子类和基类的size

参看类内结构的工具:VS交叉编译工具

G:\VS>cd G:\CSlrn\360
G:\CSlrn\360>ls
CMakeLists.txt  cmake-build-debug  main.cpp
G:\CSlrn\360>cl -d1reportSingleClassLayoutSolution main.cpp

1.空类大小:1
2.只有一个虚函数的类:该类大小4 来自于虚指针
3.存在继承关系的类

  • (1)单继承
    子类继承了父类的属性和虚函数指针,只要父类有虚函数,就会存在虚函数指针,子类一定会继承。
class A2{
public:
    int age;
    virtual void info()=0;
    A2(){ printf("A2::A2()\n"); }
    ~A2(){ printf("A2::~A2()\n"); }
};
class ASon: public A2{
public:
    
    ASon(){ printf("ASon::ASon()\n"); }
    ~ASon(){ printf("ASon::~ASon()\n"); }
    void info() override {
        cout << "this is info func !" << endl;
    }
};

在这里插入图片描述

  • (2)多继承
    情况一:两个父类,如果两个父类中都有虚函数,则都会存在虚函数指针,子类都会继承
class A1{
public:
    virtual void info()=0;
    int age;
    A1(){ printf("A1::A1()\n"); }
    ~A1(){ printf("A1::~A1()\n"); }
};

class A2{
public:
    int age;
    virtual void info()=0;
    A2(){ printf("A2::A2()\n"); }
    ~A2(){ printf("A2::~A2()\n"); }
};

class ASon: public A1, public A2{
public:
    ASon(){ printf("ASon::ASon()\n"); }
    ~ASon(){ printf("ASon::~ASon()\n"); }
    void info() override {
        cout << "this is info func !" << endl;
    }
};

在这里插入图片描述
情况二:父类中只有一个有虚函数,另一个没有,则只继承一个虚函数指针

class A1{
public:
    void info(){};
    int age;
    A1(){ printf("A1::A1()\n"); }
    ~A1(){ printf("A1::~A1()\n"); }
};

class A2{
public:
    int age;
    virtual void info()=0;
    A2(){ printf("A2::A2()\n"); }
    ~A2(){ printf("A2::~A2()\n"); }
};

class ASon: public A1, public A2{
public:
    ASon(){ printf("ASon::ASon()\n"); }
    ~ASon(){ printf("ASon::~ASon()\n"); }
    void info() override {
        cout << "this is info func !" << endl;
    }
};

在这里插入图片描述

  • (3)菱形继承:
    情况一:无虚继承
class A
{
public:
    int age;
    void info(){};
};

class A1: public A{
public:
    virtual void info(){};
    A1(){ printf("A1::A1()\n"); }
    ~A1(){ printf("A1::~A1()\n"); }
};

class A2:public A{
public:
    virtual void info()=0;
    A2(){ printf("A2::A2()\n"); }
    ~A2(){ printf("A2::~A2()\n"); }
};

class ASon: public A1, public A2{
public:
    ASon(){ printf("ASon::ASon()\n"); }
    ~ASon(){ printf("ASon::~ASon()\n"); }
    void info() override {
        cout << "this is info func !" << endl;
    }
};

在这里插入图片描述
A1 A2都包继承了父类成员,类内部都包含一个父类成员的拷贝。

class AB
{
public:
   int age;
   virtual void info(){};
};

class A1:  public AB{
public:
    void info(){};
    A1(){ printf("A1::A1()\n"); }
    ~A1(){ printf("A1::~A1()\n"); }
};

class A2: public AB{
public:
    void info(){};
    A2(){ printf("A2::A2()\n"); }
    ~A2(){ printf("A2::~A2()\n"); }
};

class ASon: public A1, public A2{
public:
    ASon(){ printf("ASon::ASon()\n"); }
    ~ASon(){ printf("ASon::~ASon()\n"); }
    void info()  {
        cout << "this is info func !" << endl;
    }
};

在这里插入图片描述

情况二:虚继承
子类中不再存在父类成员的拷贝,而是通过虚指针+偏移量。

class A
{
public:
   int age;
     void info(){};
};

class A1: virtual public A{
public:
    void info(){};
    A1(){ printf("A1::A1()\n"); }
    ~A1(){ printf("A1::~A1()\n"); }
};

class A2:virtual public A{
public:
    void info(){};
    A2(){ printf("A2::A2()\n"); }
    ~A2(){ printf("A2::~A2()\n"); }
};

class ASon: public A1, public A2{
public:
    ASon(){ printf("ASon::ASon()\n"); }
    ~ASon(){ printf("ASon::~ASon()\n"); }
    void info()  {
        cout << "this is info func !" << endl;
    }
};

在这里插入图片描述

虚拟继承中派生类重写了基类的虚函数,并且在构造函数或者析构函数中使用指向基类的指针调用了该函数,编译器会为虚基类添加vtordisp域。
在以下两个条件下,类就会用四个字节保存vtordisp。

  1. 派生类重写了虚基类的虚函数。
  2. 派生类定义了构造函数或者析构函数。
  3. 继承方式为虚继承。
    例如:
class A
{
public:
   int age;
    virtual  void info(){};
};

class A1: virtual public A{
public:
    void info(){};
    A1(){ printf("A1::A1()\n"); }
    ~A1(){ printf("A1::~A1()\n"); }
};

可以看到,在查看类A1的布局时,如果正常分析,A1中应该有一个虚指针和int型变量,以及保存A的一个虚指针,大小应该是12,但实际是16,这就是要因为A1中保存了一个vtordisp。关于vtordisp>>
在这里插入图片描述

45.C++三大特性

1.封装
封装可以隐藏实现细节,使得代码模块化;封装是把过程和数据包围起来,对数据的访问只能通过已定义的界面,隐藏类的属性和实现细节,仅仅对外提供接口。

  • 优点:
    1、良好的封装能够减少耦合。
    2、类内部的结构可以自由修改。
    3、可以对成员进行更精确的控制。
    4、隐藏信息,实现细节。
  • 缺点:
    如果封装太多,影响效率;使用者不能知道代码具体实现。
    封装性实际上是由编译器去识别关键字public、private和protected来实现的,体现在类的成员可以有公有成员(public),私有成员(private),保护成员(protected)。私有成员是在封装体内被隐藏的部分,只有类体内说明的函数(类的成员函数)才可以访问私有成员,而在类体外的函数时不能访问的,公有成员是封装体与外界的一个接口,类体外的函数可以访问公有成员,保护成员是只有该类的成员函数和该类的派生类才可以访问的。

2.继承
继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。

  • 优点:
    1、子类拥有父类非private的属性和方法。
    2、子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
    3、子类可以用自己的方式实现父类的方法(虚函数)。
    4、继承减少了重复的代码,继承是多态的前提。
  • 缺点:
    1、父类变,子类就必须变。
    2、继承破坏了封装,对于父类而言,它的实现细节对与子类来说都是透明的。
    3、继承是一种强耦合关系。
  • 三种继承模式:
    公有继承中父类的公有和保护成员在子类中不变,私有的在子类中不可访问。
    私有继承中父类的公有和保护成员在子类中变为私有,但私有的在子类中不可访问。
    保护继承中父类的公有和保护成员在子类中变为保护,但私有的在子类中不可访问。
    3.多态
    • 概念:多态性是指对不同类的对象发出相同的消息将会有不同的实现,按字面的意思就是多种形态,相同的方法调用,但是有不同的实现方式。多态性可以简单地概括为“一个接口,多种方法”。
    • 分类:对于面向对象而言,多态分为编译时多态运行时多态。其中编译时多态是 静态的,主要是指方法的重载,它是根据参数列表的不同来区分不同的函数,通过编译之后会变成两个不同的函数,在运行时谈不上多态。而运行时多态是动态的,它是通过动态绑定来实现的。
    • 基于继承的多态:子类通重写父类的虚函数,当父类指针指向子类对象时,父类指针可以调用子类对象的方法,也就是我们所说的多态性。
    • 优点:大大提高了代码的可复用性;提高了了代码的可维护性,可扩充性;
    • 缺点:易读性比较不好,调试比较困难; 模板只能定义在头文件中,当工程大了之后,编译时间十分的变态;

46.静态多态和动态多态(多态实现)

静态多态:

1.函数重载
重载首先是函数名相同,重载函数的关键是函数参数列表——也称函数特征标。包括:函数的参数数目和类型以及参数的排列顺序。所以,重载函数与返回值,参数名无关。

  • C++为什么可以重载?C语言为什么不可以?
    这是由于C/C++编译器的函数名修饰规则不同导致的
    C编译器的对函数名的修饰无法表达出函数的参数类型以及参数个数以及参数顺序,这就意味着它不能通过参数个数、类型以及顺序来区分同名函数
    C++编译器可以通过对函数名进行修饰,能够通过返回值类型,参数个数,参数类型及顺序来区分同名函数,这就使它能够对同名但参数不同的函数作出区分,从而正确调用。

2.函数模板
函数模板是通用的函数描述,也就是说,使用泛型来定义函数,其中泛型可用具体的类型(int 、double等)替换。通过将类型作为参数,传递给模板,可使编译器生成该类型的函数。

// 交换两个值,但是不清楚是int 还是 double,如果不使用模板,则要写两份代码
// 使用函数模板,将类型作为参数传递
template<class T>
class Swa(T a,T b)
{
    T temp;
    temp = a;
    a = b;
    b = temp;
};

2.动态多态

  • 1.对象类型:
    静态类型:对象声明时的类型,编译时确定。
    动态类型:目前所指对象的类型,运行时确定。
  • 2.父类指针指向子类对象,调用子类的方法。
    父类中该函数必须为虚函数,派生类一定要重写基类中的虚函数。
  • 3.纯虚函数
    纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”
    包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类中重写父类的虚函数以后,才能实例化出对象,否则也是抽象类。
  • 4.虚函数表
    任何带有虚函数的类,都会维护一张虚函数表,对象的前四个字节就是指向虚表的指针(虚表指针)。,这个虚函数表存放着虚函数的地址。
    子类继承父类,子类中的虚函数表按照父类虚函数,子类虚函数的声明顺序排放,如果子类没有重写父类,那父类虚函数还是父类中的定义及地址。如果子类重写了父类的虚函数,那么虚函数表就用重写的函数替换掉原来父类的虚函数。
    参考文章>>

54.构造函数内放虚函数

class A
{
public:
    A()
    {
        print();
    }
    virtual void  print()
    {
        cout<<"print"<<endl;
    }
};
G:\CSlrn\CVTE\cmake-build-debug\CVTE.exe
print
Process finished with exit code 0

56.普通成员函数与虚函数的调用

  • 1.普通函数的地址在编译期间就可以确定,所以普通成员函数可以在不实例化对象的情况下直接调用。
  • 2.虚函数需要通过虚指针找到虚函数表来确定虚函数的地址,虚指针一般出现在对象地址的前四个字节,如果没有实例化对象就无法确定虚指针,从而也就无法确定虚函数表的地址,所以必须要实例化一个对象才能调用。

普通函数调用

class base{
    int a;
public:
    void fun(){
        printf("base fun\n");
    }
};
int main(){
    base *b=NULL;
    b->fun();
}
G:\CSlrn\360\cmake-build-debug\360.exe
base fun

虚函数调用

class base{
    int a;
public:
    virtual void fun(){
        printf("base fun\n");
    }
};
int main(){
    base *b=NULL;
    b->fun();
}

报错

57.动态绑定和静态绑定

  • 静态绑定(前期绑定)是指在程序运行前就已经知道方法是属于那个类的,在编译的时候就可以连接到类的中,定位到这个方法。
  • 动态绑定(后期绑定)是指在程序运行过程中,根据具体的实例对象才能具体确定是哪个方法。

参考文章>>

静态绑定:静态绑定发生在编译时,将名称绑定到一个固定的函数定义,然后在每次调用该名称时执行该定义。在静态绑定中,编译器使用编译时可用的类型信息。如果代码在继承层次结构中的不同类的对象上运行,则编译器可用的唯一类型信息将是用于访问所有对象的基类指针类型。因此,静态绑定将始终使用基类版本的成员函数。

动态绑定:动态绑定发生在运行时。动态绑定仅在编译器可以确定运行时子类对象所属的确切类时才起作用。编译器然后使用这个运行时类型信息来调用该类中定义的函数版本。为了使动态绑定成为可能,编译器将运行时类型信息存储在具有虚函数的类的每个对象中。动态绑定始终使用对象的实际类中的成员函数版本,而无视用于访问对象的指针的类。
参考文章>>

理解:
首先注意四个概念:对象的静态类型、对象的动态类型、静态绑定、动态绑定;
1、对象的静态类型(static type):就是它在程序中被声明时所采用的类型(或理解为类型指针或引用的字面类型),在编译期确定;

2、对象的动态类型(dynamic type):是指“目前所指对象的类型”(或理解为类型指针或引用的实际类型),在运行期确定;

静态绑定、动态绑定这两个概念一般发生在基类和派生类之间。比如:父类指针指向子类对象,那么父类指针的静态类型就是父类指针,动态类型就是子类指针,动态类型可以在程序执行过程中改变(通常是经由赋值动作)。
3、静态绑定(statically bound):又名前期绑定(eraly binding),绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在 编译期;
4、动态绑定(dynamically bound):又名后期绑定(late binding),绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

  • 比如常见的,virtual函数是动态绑定,non-virtual函数是静态绑定。
  • 以virtual函数为例。当某个virtual函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。需要注意的是:动态绑定只有当我们通过指针或引用调用虚函数时才会发生。(如果通过一个具有普通类型(非指针非引用)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。
  • non-virtual函数是静态绑定:Effective C++中有这么一句话,“绝不重新定义继承而来的non-virtual函数”,因为这样会导致函数的调用类型由对象声明时的静态类型确定,而与对象本身脱离了关系。

参考文章>>

静态绑定:静态绑定是指在程序编译过程中,把函数(方法或者过程)调用与响应调用所需的代码结合的过程

class Shape {
protected:
    int width, height;
public:
    Shape(int a,int b):width(a),height(b){}
    int area()
    {
        cout << "Parent class area :" << endl;
        return 0;
    }
};
//将Rectangle类继承Shape类
class Rectangle : public Shape {
public:
    Rectangle(int a,int b) :Shape(a, b) { }
    int area()
    {
        cout << "Rectangle class area :" <<width*height<< endl;
        return 0;
    }
};

// 程序的主函数
int main()
{
    Shape* shape;//定义shpae类指针
    Rectangle rec(10, 7);//派生类对象
    // 基类指针指向派生类对象(存储矩形的地址)
    shape = &rec;
    // 调用矩形的求面积函数 area
    shape->area();
    return 0;
}

输出:Parent class area :

可以看到调用的却是基类的函数。

在没有加virtual关键字的时候,通过基类指针指向派生类对象时,基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。这是因此在系统编译过程中,== 已经将area()函数和shape类绑定在一起了。==

而动态绑定是在加了virtual关键字以后,派生类中的成员函数在重写的时候会自动生成自己的虚函数表(单独的一个地址),并通过虚指针指向该地址。

即:shape指针->vptr->Rectangle::area()

​通过以上内容,我们可以知道在使用基类指针调用虚函数的时候,它能够根据所指的类对象的不同来正确调用虚函数。而这些能够正常工作,得益于虚指针和虚函数表的引入,使得在程序运行期间能够动态调用函数。

动态绑定有以下三项条件要符合:

  • 使用指针进行调用
  • 指针属于up-cast后的
  • 调用的是虚函数

静态绑定,他们是类对象直接可调用的,而不需要任何查表操作,因此调用的速度也快于虚函数。

58.构造函数/析构函数能不能调用虚函数

1.构造函数调用虚函数,语法不会错误,但不能实现多态:
参考文章>>
2.析构函数调用虚函数也是这样,不会实现多态。

派生类与基类的构造函数初始化时,虚函数表是不一样的,意味着构造函数中调用虚函数是当前类的虚函数,无法多态调用。
即使允许多态调用,如果在基类中调用派生类的虚函数,由于派生类还未开始初始化,如果访问了还未初始化的数据,那就有很大的问题了。
在基类未构造的时候,父类调用虚函数是基于虚函数表,此时基类没有虚函数表,所以调用的肯定是父类的虚函数,所以无法形成多态,即使可以多态,父类可以调用子类的虚函数,如果该虚函数中访问了子类未初始化的数据,则会引发各种不可预料的问题。
参考文章>>

59.构造函数调用成员函数

可以调用,但是如果该成员函数访问了未初始化的数据,则会出现问题。

60.拷贝构造函数可以传指针吗

可以传,但是指针作为参数的构造函数不是拷贝构造函数,而是普通的构造函数
以下代码可以验证,当写入A(A* test)作为拷贝构造函数时,默认的拷贝构造函数没有被覆盖。

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

using namespace std;

class A
{
public:
    A(int a)
    {
        this->a = a;
        cout << "in constructor" << endl;
    }
    A(A* test)
    {
        this->a = test->a;
        cout << "in copy constructor" << endl;
    }
    A(const A& b)
    {
        this->a= b.a;
        cout << "in const copy constructor" << endl;
    }
    void printA()
    {
        cout << this->a << endl;
    }
private:
    int a;
};
int main(void)
{
    A a(2);
    A b(&a);
    b.printA();
    A c =  a;
    return 0;
}

61.虚函数表

陈硕的虚函数表解读

每继承一个类,就有一个虚函数表
子类无覆盖,子类的虚函数跟在第一个父类的最后虚函数后面

参考文章>>
参考文章>>
概述:C++中,一个类存在虚函数,那么编译器就会为这个类生成一个虚函数表,在虚函数表里存放的是这个类所有虚函数的地址。当生成类对象的时候,编译器会自动的将类对象的前四个字节设置为虚表的地址,而这四个字节就可以看作是一个指向虚函数表的指针。虚函数表可以看做一个函数指针数组。

虚函数表是属于类,类的所有对象共享这个类的虚函数表。并且,普通子类对象与基类指针指向的子类对象,使用同一个虚函数表,符合C++的多态要求。

结论

  • 虚函数表属于类,类的所有对象共享这个类的虚函数表。
  • 虚函数表由编译器在编译时生成,保存在.rdata只读数据段。

虚函数表是类对象之间共享的,而非每个对象都保存了一份。
父类:

class Base {
     public:
            virtual void f() { cout << "Base::f" << endl; }
            virtual void g() { cout << "Base::g" << endl; }
            virtual void h() { cout << "Base::h" << endl; }
};

参考文章>>
1.单继承:父类的虚函数始终是Base::f(),Base::g(),Base::h()。

无覆盖则子类父类的虚函数表不同,但指向的虚函数相同;
在这里插入图片描述
&d代表子类,而不能代表单个对象
在这里插入图片描述
有覆盖,则虚函数表不同,覆盖的虚函数的地址不同。
在这里插入图片描述
在这里插入图片描述
2.多继承:父类中都存放着各自的虚函数表 子类中有所有父类的虚函数表
无覆盖:
在这里插入图片描述
在这里插入图片描述
有覆盖:重写f()
在这里插入图片描述
子类重写的虚函数父类中所有的同名虚函数
在这里插入图片描述
参考文章>>

62.虚函数表指针与虚函数表

对于虚函数的调用是通过查虚函数表来进行的,每个虚函数在虚函数表中都存放着自己的一个地址,而如何在虚函数表中进行查找,则是通过虚指针来调用,在内存结构中它一般都会放在类最开始的地方,而对于普通函数则不需要通过查表操作。这张虚函数表是什么时候被创建的呢?它是在编译的时候产生,否则这个类的结构信息中也不会插入虚指针的地址信息。

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
            void func1();
            void func2();
private:
    int m_data1, m_data1;
};

在这里插入图片描述

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
            void func1();
            void func2();
private:
    int m_data1, m_data1;
};

class B : public A {
public:
    virtual void vfunc1();
            void func2();
private:
    int m_data3;
};

class C : public B {
public:
    virtual void vfunc1();
            void func2();
private:
    int m_data1, m_data4;
};

在这里插入图片描述

对于非虚函数,三个类中虽然都有一个叫 func2 的函数,但他们彼此互不关联,因此都是各自独立的,不存在重载一说,在调用的时候也不需要进行查表的操作,直接调用即可。
由于子类B和子类C都是继承于基类A,因此他们都会存在一个虚指针用于指向虚函数表。注意,假如子类B和子类C中不存在虚函数,那么这时他们将共用基类A的一张虚函数表,在B和C中用虚指针指向该虚函数表即可。但是,上面的代码设计时子类B和子类C中都有一个虚函数 vfunc1,因此他们就需要各自产生一张虚函数表,并用各自的虚指针指向该表。由于子类B和子类C都对 vfunc1 作了重载,因此他们有三种不同的实现方式,函数地址也不尽相同,在使用的时候需要从各自类的虚函数表中去查找对应的 vfunc1 地址。
对于虚函数 vfunc2,两个子类都没有进行重载操作,所以基类A、子类B和子类C将共用一个 vfunc2,该虚函数的地址会分别保存在三个类的虚函数表中,但他们的地址是相同的。
从上图可以发现,在类对象的头部存放着一个虚指针,该虚指针指向了各自类所维护的虚函数表,再通过查找虚函数表中的地址来找到对应的虚函数。
对于类中的数据而言,子类中都会包含父类的信息。如上例中的子类C,它自己拥有一个变量 m_data1,似乎是和基类中的 m_data1 重名了,但其实他们并不存在联系,从存放的位置便可知晓。文章链接

拥有虚函数的类的内存布局

在这里插入图片描述
虚函数原理

62.构造函数里放其他构造函数

参考文章>>
如果在构造函数里调用其他类的构造函数,不会出现问题,但是要注意临时对象的作用区域,离开构造函数就会被析构。

class A1
{
public:
    A1(int _a1 = 1) : a1(_a1) { cout<<"A1 Create"<<endl;}
    ~A1() {}
private:
    int a1;
};
class C
{
public:
    C()
    {
        A1 a;
    }
private:
    int c;
};

如果在构造函数里调用自身的构造函数,则对会构造一个临时对象,而其成员属性是初始化的自身的成员属性,而不是现在这个外层构造函数的。

struct CLS
{
    int m_i;
    CLS( int i ) : m_i(i){}
    CLS()
    {
        CLS(0);
    }
};
int main()
{
    CLS obj;
    cout << obj.m_i << endl;

    system("PAUSE");
    return 0;
}
G:\CSlrn\12-24\cmake-build-debug\12_24.exe
4200539
请按任意键继续. . .

63. 类对象调用到成员函数

每个函数地址都会有一个对应的符号及其地址,保存在符号表中,在汇编阶段,每个目标文件都有一个导出符号表和重定位表,导出符号表中存储本目标文件中所有的符号及其地址的关联关系,重定位表中存储着外部引用的符号,在汇编阶段,编译器不知道某个外部符号的具体地址,所以就在需要该符号的地方将其地址填0,等待重定位。
连接器在链接阶段,需要扫描所目标文件中的导出符号表,找到重定位表中的符号的具体地址,将修改目标文件中的机器指令,填入该符号的正确地址。
链接过程

64. 实现一个只能在堆或栈上实例化对象

栈上的对象,可以自动析构,所以将其析构函数私有化即可让其只能在堆上实例化,但是要提供一个destory函数来释放自身资源。
堆上的对象,需要new操作符来实例化,所以私有化new操作符即可让其只能在栈上初始化。

65.delete this指针会发生什么

会释放自身资源,即析构自身。

66.在已分配的内存上构造对象

placement new

  char mem[sizeof(Person)]; // 或者 auto mem = malloc(sizeof(Person));

  auto p = new(mem) Person();

  assert((void*)p == (void*)mem); // 两个指针指向同一块内存

67.析构函数出错

析构函数从语法上是可以抛出异常的,但是这样做很危险,请尽量不要这要做。原因在《More Effective C++》中提到两个:

  • (1)如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
  • (2)通常异常发生时,c++的异常处理机制在异常的传播过程中会进行栈展开(stack-unwinding),因发生异常而逐步退出复合语句和函数定义的过程,被称为栈展开。在栈展开的过程中就会调用已经在栈构造好的对象的析构函数来释放资源,此时若其他析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃。
    所以,析构函数出错也不能抛出异常,要禁止析构函数中异常的传播,将析构函数定义为noexcept。这将保证要么正常结束析构,要么终止程序。

方案:

  • 如果程序遭遇一个“于析构期间发生的错误”后无法继续执行,“强制结束程序”是个合理选项,毕竟它可以阻止异常从析构函数传播出去导致不明确行为。
  • 一般而言,使用try{} catch(…){}将异常吞掉是个坏主意,因为面对动作失败选择无所作为,然而有时候吞下异常比“草率结束程序”或“不明确行为带来的风险”好。能够这么做的一个前提就是程序必须能够继续可靠的执行。
  • 将析构函数中可能会抛出异常的操作移到析构函数外面执行,这个异常必须来自析构函数以外的某个函数。因为析构函数吐出异常就是危险,总会带来“过早结束程序”或“发生不明确行为”的风险。

68.多重继承

一、多重继承定义
在现实生活中,一些新事物往往会拥有两个或者两个以上事物的属性,为了解决这个问题,C++引入了多重继承的概念,C++允许为一个派生类指定多个基类,这样的继承结构被称做多重继承。(派生类有两个或两个以上的直接基类)
当一个派生类要使用多重继承的时候,必须在派生类名和冒号之后列出所有基类的类名,并用逗好分隔。

   class Derived : public Base1, public Base2, {}

二 多重继承的二义性

class BC0
{
public:
    int K;
};
class BC1 : public BC0
{
public:
    int x;
};
class BC2 : public BC0
{
public:
    int x;
};
class  DC : public BC1, public BC2{
};

在这里插入图片描述
两个问题
在类DC中有4个int类型的成员。

问题1:类DC的对象中存在多个同名成员 x, 应如何使用?
问题2:类DC的对象中,存在两份来自类BC0的成员K,如何区分?

int main( )
{
    DC d;
    d.x = 1;       // error C2385: 对"x"的访问不明确
         //可能是"x"(位于基"BC1"中),也可能是"x"(位于基"BC2"中)
    d.BC1::x = 2;   // OK,from BC1
    d.BC2::x = 3;   // OK,from BC2
    d.K = 4;           // error C2385: 对"K"的访问不明确
    d.BC1::K = 5;  // OK,from BC1
    d.BC2::K = 6;  // OK,from BC2
    return 0;
}

解决方案:
在BC1类和BC2类继承BC0时,其前面加上virtual关键字就可以实现虚拟继承,使用虚拟继承后,当系统碰到多重继承的时候就会先自动加一个BC0的拷贝,当再次请求一个BC0的拷贝时就会被忽略,以保证继承类成员函数的唯一性。

class BC0
{
public:
    int K;
};
class BC1 : virtual public BC0
{
public:
    int x;
};
class BC2 : virtual public BC0
{
public:
    int x;
};
class DC : public BC1, public BC2
{
};
void main( )
{
    DC d;       //虚继承使得BC0仅被DC间接继承一份
    d.K = 13;    // OK
}

三、多重继承下的构造函数
单继承派生类构造时,首先构造基类,其次是派生类的数据成员的初始化(顺序和派生类数据成员的声明顺序相同),最后执行派生类的构造函数体。
多继承派生类构造时,首先构造虚基类,多个虚基类按照他们被继承的顺序依次构造,其次构造一般基类,多个一般基类按照被继承的顺序构造,然后初始化派生类的数据成员,初始化顺序和派生类数据成员的声明顺序相同,最后执行派生类的构造函数体。

#include<iostream>
using namespace std;
class B1
{
public:
    B1(int i)
    {
        cout<<"B1"<<endl;
    }
};
class B2
{
public:
    B2(int i)
    {
        cout<<"B2"<<endl;
    }
};
class B3
{
public:
    B3(int i)
    {
        cout<<"B3"<<endl;
    }
};
class B4
{
public:
    B4(int i)
    {
        cout<<"B4"<<endl;
    }
};
class D : public B2, virtual B1, public B4, virtual B3
{
public:
    D(int n): B1(n), B2(n), B3(n), B4(n) {}
};
int main( )
{
    D d(1);
    return 0;
}

执行结果:

B1
B3
B2
B4

由上面的例子可知,虚基类构造函数的调用顺序:
在同一层次中,先调用虚基类的构造函数,接下来依次是非虚基类的构造函数,然后是派生类的构造函数。
若同一层次中包含多个虚基类,这些虚基类的构造函数按对它们声明的先后次序调用;非虚基类也是按照它们的声明先后次序调用。
多重继承

69. 菱形继承

概念:两个派生类继承同一个基类,又有某个类同时继承着这两个派生类
在这里插入图片描述
这种继承带来的问题主要有两方面:

  • 羊和驼都继承了动物的类成员,当羊驼想要使用时,会产生二义性
  • 羊驼实际继承了两份来自动物的数据,但实际只需要一份
    想要解决有两个思路,一是给羊驼的每一份数据加上作用域,但本质上羊驼还是继承了两份数据。二是通过虚继承的方式,使羊驼仅继承一份数据。
#include<iostream>
using namespace std;
class Animal //动物类
{
public:
    int m_Age;
};
class Sheep :public Animal{}; //羊类
class Tuo :public Animal{}; //驼类
class SheepTuo :public Sheep, public Tuo{}; //羊驼类
void test()
{
    SheepTuo st;
    st.Sheep::m_Age = 18;
    st.Tuo::m_Age = 28;
 
    cout << "st.Sheep::m_Age = " << st.Sheep::m_Age << endl;
    cout << "st.Tuo::m_Age = " << st.Tuo::m_Age << endl;
}
int main()
{
    test();
    system("pause");
}

在这里插入图片描述
可以看到羊驼实际上存在两份数据,为了更直观的看到羊驼类的对象模型,可以借助VS自带的命令提示工具,到cpp文件存放目录后执行cl /d1 reportSingleClassLayoutSheepTuo test.cpp,其中test.cpp就是文件名,执行结果如下:
在这里插入图片描述
很明显羊驼从羊和驼两个父类中各自继承了一份m_Age,通过限定作用域的方式无法彻底解决这个问题,这个时候就要使用虚继承.

虚继承与虚基类
具体实现为在羊类和驼类的继承前加上virtual关键词,Animal类称为虚基类
代码如下:

#include<iostream>
using namespace std;
class Animal //虚基类
{
public:
    int m_Age;
};
class Sheep :virtual public Animal{}; //虚继承
class Tuo :virtual public Animal{}; //虚继承
class SheepTuo :public Sheep, public Tuo{};
void test()
{
    SheepTuo st;
    st.Sheep::m_Age = 18;
    st.Tuo::m_Age = 28;
 
    cout << "st.Sheep::m_Age = " << st.Sheep::m_Age << endl;
    cout << "st.Tuo::m_Age = " << st.Tuo::m_Age << endl;
 
    cout << "m_Age=" << st.m_Age << endl;
}
int main()
{
    test();
    system("pause");
}

可以看到此时可直接用st.m_Age访问类成员,说明此时羊驼类中的m_Age只有一份
再次借助VS命令提示工具查看对象模型,运行结果如下

在这里插入图片描述
可以看出羊类和驼类中的数据只是一个虚基类指针,并未继承具体的数据,这个虚基类指针指向各自的虚基类表,而虚基类表中存在一个偏移量,通过这个偏移量再加上首地址可以找到基类中的数据,所以实际上羊驼只继承了一份数据(也就是基类中的那份)。

内存

18.哈希冲突的解决方法

1.开放定地址法:

为产生冲突的地址按照某种规律探测下个没有存值的空间
线性探测再散列
在产生冲突的地址的基础上,递增地址,找到下一个没存值的地址。
二次探测再散列
在产生冲突的地址上,按照-1 +1 -4 +4 -9 +9 -16 +16的规律左右探测未存值的地址空间。
伪随机探测再散列
在产生冲突的地址上,增加一个通过随机函数生成的数,直至不发生哈希冲突。
2.再哈希法

对产生冲突的key值使用另一个哈希函数计算,直至不发生哈希冲突。

3.链地址法(java hashmap)

对产生哈希冲突的hash值,使用链表进行连接在数组冲的同一位置。

4.建立一个公共溢出区域

建立一个公共溢出区域,把冲突的都放在另一个地方,不放在表里面。

19.哈希表长度如何确定

设计一个哈希表的关键有三个:怎么控制哈希表的长度,怎么设计哈希函数,怎么处理哈希冲突

哈希表的长度一般是定长的,在存储数据之前我们应该知道我们存储的数据规模是多大,应该尽可能地避免频繁地让哈希表扩容。但是如果设计的太大,那么就会浪费空间,因为我们跟不用不到那么大的空间来存储我们当前的数据规模;如果设计的太小,那么就会很容易发生哈希冲突,体现不出哈希表的效率。所以,我们设计的哈希表的大小,必须要做到尽可能地减小哈希冲突,并且也要尽可能地不浪费空间,选择合适的哈希表的大小是提升哈希表性能的关键。

当我们选择哈希函数的时候,经常会选择除留余数法,即用存储数据的key值除以哈希表的总长度,得到的余数就是它的哈希值。常识告诉我们,当一个数除以一个素数的时候,会产生最分散的余数。由于我们通常使用表的大小对哈希函数的结果进行模运算,如果表的大小是一个素数,那么这样我们就会尽可能地产生分散的哈希值

另外,哈希表中还有一个概念就是表的装填因子(负载因子),它的值一般被定义为:

装填因子 a = 总键值对数(下标占用数) /  哈希表总长度

至于为什么要设计这样一个概念,我们可以像,如果一个哈希表中的数据装的越多,是不是越容易发生哈希冲突。如果当哈希表中满到只剩下一个下标可以插入的时候,这个时候我们还要往这个哈希表中插入数据,于是我们可能会达到一个O(n)级别的插入效率,我们甚至要遍历整个哈希表才可能找到那个能存储的位置。

通常,我们关注的是使哈希表平均查找长度最小,把平均查找长度保证在O(1)级别。装填因子a的取值越小,产生冲突的机会就越小,但是也不能取太小,这样我们会造成较大的空间浪费。即如果我们a取0.1,而我们哈希表的长度为100,那我们只装了10个键值对就存不下了,就要对哈希表进行扩容,而剩下90个键值对空间其实是浪费了的。通常,只要a取的合适(一般取0.7-0.8之间),哈希表的平均查找长度就会是常数也就是O(1)级别的。

当然,根据数据量的不同,会有不同的哈希表的大小。当数据量小的时候,最好就是能够实现哈希表扩容的机制,即达到了哈希表当前长度的装填因子,我们就需要扩大哈希表大小,一般都是乘2。

下面,对上面这些观点进行一个总结,来设计一个效率尽可能高的哈希表大小

1.确保哈希表长度是一个素数,这样会产生最分散的余数,尽可能减少哈希冲突
2.设计好哈希表装填因子,一般控制在0.7-0.8
3.确认我们的数据规模,如果确认了数据规模,可以将数据规模除以装填因子,根据这个结果来寻找一个可行的哈希表大小
4.当数据规模可能会动态变化,不确定的时候,这个时候我们也需要能够根据数据规模的变化来动态给我们的哈希表扩容,所以一开始需要自己确定一个哈希表的大小作为基数,然后在此基础上达到装填因子规模时对哈希表进行扩容。

22.C++ new与malloc的区别

1.申请的内存所在的位置

new从自由存储区上为对象动态申请内存,自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请该内存即为自由存储区。
malloc函数从堆上分配内存,堆是操作系统中的术语,是操作系统维护的一块特殊内存,用于程序的内存动态分配,malloc从堆上分配内存,使用free释放已分配的内存。
new是否能够在堆上动态分配内存(等价于自由存储区能否是堆),取决于new的实现,这要看new在哪里为对象分配内存。

2.返回类型的安全性

new分配内存成功时,返回对象类型的指针,类型与对象严格匹配,无须进行类型转换,所以new 是符合类型安全性的操作符
malloc内存分配成功则是返回void*,需要通过强制类型转换将void*指针转换成我们需要的类型。

3.内存分配失败时的返回值

new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。


int *a  = (int *)malloc ( sizeof (int ));

分配失败会返回NULL


int * a = new int();

分配失败会抛出异常

4.是否需要指定内存大小

new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。
malloc则需要显式的指出所需内存的尺寸。

B b = new B();
B b = (B)malloc(sizeof(B));

5.是否调用构造/析构函数

new分配对象时的三个步骤
1.调用new分配一块足够大的,原始的,未命名的内存空间以存储特定类型的对象。
2.编译器运行构造函数以构造对象,并为其传入初值。
3.对象构造完成后,返回一个指向该对象的指针。
delete1.释放对象内存时经历的两个步骤
调用对象的析构函数。
编译器调用delete 或delet[]释放内存空间
总之来说,new/delete会调用对象的构造函数/析构函数以完成对象的构造/析构。而malloc则不会。

6.对数组的处理

可以初始化

7.new与malloc是否可以相互调用

operator new /operator delete的实现可以基于malloc,而malloc的实现不可以去调用new。

8.是否可以被重载

我们知道我们有足够的自由去重载operator new /operator delete ,以决定我们的new与delete如何为对象分配内存,如何回收对象。
而malloc/free并不允许重载。

9.能否直观的重新分配内存

- 使用malloc分配的内存后,如果在使用过程中发现内存不足,可以使用realloc函数进行内存重新分配实现内存的扩充。
realloc先判断当前的指针所指内存是否有足够的连续空间,如果有,原地扩大可分配的内存地址,并且返回原来的地址指针;如果空间不够,先按照新指定的大小分配空间,将原有数据从头到尾拷贝到新分配的内存区域,而后释放原来的内存区域。
- new没有这样直观的配套设施来扩充内存。

10.客户处理内存分配不足

operator new抛出异常以反映一个未获得满足的需求,
客户能够指定处理函数或重新制定分配器。
对于malloc,客户并不能够去编程决定内存不足以分配时要干什么事,只能看着malloc返回NULL。
总结表
在这里插入图片描述
问题:new申请的内存是否可以用free来释放?
参考文章>>
答:

  • 对于内置数据类型或者是没有自定义析构函数的类,可以理解为由于没有构造和析构的过称,没有构造函数,也就不会开辟新的空间,所以new出来的对象直接用free释放也是可以的,但会引起混淆,非常不提倡。

  • 对于其他类型,由于new会调用构造函数,会在构造函数内开辟空间,所以必须调用析构函数释放空间,但是free不会调用析构函数,所以会导致开辟的空间无法释放,造成内存泄漏,所以new和free不能搭配使用。

  • 对于new[]那就更不能用free释放了。
    问题:为什么new delete/和new[] delete[]要成对使用

  • 在非内置数据类型和带有自定义构造的数据类型中,如果用new[]创建对象,返回的指针的前四个字节包含创建对象的数量。对于delete,delete会直接释放指针所指向的内存,会认为给到的值就是需要析构的地址。delete[]会将给定值的前四个字节拿出,这四个字节会储存对象的数量,delete[]会调用free相应的次数来析构对象成员,同时调用对象的析构函数相应的次数。这就要求new/delete与new[] /delete[]要配合使用。
    所以如果new完用delete[]释放,则delete[]会读取它的前四个字节,而使用new操作符前四个字节并不是记录数量的,所以delete[]会非法访问。如果new[]的使用delete,delete会把指针的前四个字节作为次数,指针指向的地址被左移四个字节,程序报错。
    color

22-1 为什么有了malloc还要new

1、malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符,它们都可用于申请动态内存和释放内存。
2、对于非内部数据类型的对象而言,比如C++类,光用maloc/free无法满足动态对象的要求。C++类对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。由于内部数据类型的“对象”没有构造与析构的过程,对它们而言malloc/free和new/delete是等价的。
3、 既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,结果不会导致程序出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

23.malloc的实现细节

参考文章>>

24.类的内存分布

参考文章>>

25. new的机制

详解new机制
一个程序申请内存的流程在这里插入图片描述
一个C++应用程序在堆中分配内存的过程一共有三种方式:

  • 应用程序—>C++标准库—>new、delete相关—>malloc和free—>HeapAlloc、VirtualAlloc(操作系统底层API)
  • 应用程序—>new、delete相关—>malloc和free—>HeapAlloc、VirtualAlloc(操作系统底层API)
  • 应用程序—>malloc和free—>HeapAlloc、VirtualAlloc(操作系统底层API)
  • 应用程序—>HeapAlloc、VirtualAlloc(操作系统底层API)
    可以看出:
  • 操作系统的API是我们开发应用程序绕不过去的,但是直接调用操作 系统API也不是我们大多数情况需要的,因为我们更多的时候需要写出多平台的代码,而不是针对某一个操作系统来写
  • 尽管我们调用new和delete,但是其本质还是调用的C语言库中使用的malloc和free两个函数(这是我们一会儿要着重关注和研究的)

new和 delete出现的意义之一:我们 定义的局部/全局对象,都有调构造函数和析构函数。然而对于 动态开辟的 对象, 也希望能处理初始化和清理的问题,然而C语言中的 malloc和 free不会做这件事,于是有了 new和 delete。
new和 delete出现的意义之二:对于面向过程的语言,处理错误的方式是返回值/错误码;面向对象的语言,处理错误的方式一般是抛异常,C++也要求抛异常。 new动态申请空间失败了并不会像函数一样返回空,而是要求抛异常。

实际上,operator new和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间。
在这里插入图片描述
总结 ——

内置类型
如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new []和delete[]申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。

自定义类型

:black_heart: new的原理
调用operator new函数申请对象空间
调用构造函数,完成对象的构造

:black_heart: delete的原理
调用析构函数,完成对象中资源的清理工作
调用operator delete函数释放对象的空间

:black_heart: new 类型[]的原理

  • 调用operator new[]函数,实际在operator new[]中调用operator new函数完成N个对象空间的申请,下面是new2.cpp中源码
  • 调用N次构造函数
void *__CRTDECL operator new(size_t) /*_THROW1(std::bad_alloc)*/;

void * operator new[]( size_t cb )
{
    void *res = operator new(cb);

    RTCCALLBACK(_RTC_Allocate_hook, (res, cb, 0));

    return res;
}

:black_heart: delete[] 的原理

  • 调用N次析构函数,完成N个对象中资源的清理
  • 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放N个对象空间

26.高效分配和回收内存

实现一个简易的内存池


template <typename  T>
class AdvancedMemoryPool {
public:
    AdvancedMemoryPool(int poolSize):poolSize(poolSize) {
        expandPool();
    }
    ~AdvancedMemoryPool() {
        std::lock_guard lock(mutex_);
        for (auto& chunk : pool_) {
            delete reinterpret_cast<T*>(chunk);
        }
    }
    template <typename ...Args>
    T* alloc(Args&& ...args) {
        std::lock_guard lock(mutex_);
        if (freeChunks_.empty()) {
            expandPool();
        }
        T* ptr = reinterpret_cast<T*>(freeChunks_.front());
        freeChunks_.pop_front();
        return new(ptr) T(std::forward<Args>(args)...);
    }
    void dealloc(T* ptr) {
        assert(ptr != nullptr);
        ptr->~T();
        std::lock_guard lock(mutex_);
        freeChunks_.push_back(ptr);
    }
    size_t getFreeChunksCount() const {
        std::lock_guard lock(mutex_);
        return freeChunks_.size();
    }
    size_t getUsedChunksCount() const {
        return poolSize - getFreeChunksCount();
    }
    private:
    void expandPool() {
        char* newBlock = malloc(sizeof(T) * poolSize);
        for (size_t i = 0; i < poolSize; ++i) {
            freeChunks_.push_back(reinterpret_cast<T*>((char*)newBlock + i*sizeof(T)));
        }
        pool_.push_back(newBlock);
    }
    mutable std::mutex mutex_;
    std::list<void*> freeChunks_;
    std::list<void*> pool_;
    int poolSize;
};

class ComplexObject {
public:
    ComplexObject(int a){
        m_num =a ;
        cout << "construct: a="<<a<< endl;
    }
    ~ComplexObject(){
        cout << "destruct" << endl;
    }
//    void* operator new(size_t size);
//    void operator delete(void* p);
private:
    int m_num{0};
};

AdvancedMemoryPool<ComplexObject> mp(10);


//void* ComplexObject::operator new(size_t size) {
//    return mp.alloc();
//}
//
//void ComplexObject::operator delete(void* p) {
//    mp.dealloc(reinterpret_cast<ComplexObject*>(p));
//}

int main() {
    AdvancedMemoryPool<ComplexObject> pool(10);
    ComplexObject* obj1 = pool.alloc<int>(3);
    ComplexObject* obj2 = pool.alloc<int>(1);
    std::cout << "Free chunks: " << pool.getFreeChunksCount() << std::endl;
    std::cout << "Used chunks: " << pool.getUsedChunksCount() << std::endl;
    pool.dealloc(obj1);
    pool.dealloc(obj2);
    std::cout << "Free chunks after deallocation: " << pool.getFreeChunksCount() << std::endl;
    std::cout << "Used chunks after deallocation: " << pool.getUsedChunksCount() << std::endl;        return 0;   
}

30.内存泄露和内存溢出的区别

内存溢出 out of memory:是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。或者你要求分配的内存超出了系统能给你的,系统不能满足需求,于是产生溢出。
内存泄露 memory leak:是指程序在申请内存后,无法释放已申请的内存空间,导致该内存无法被再次分配,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

31 内存泄漏的检测方式

参考文章>>
1.确保动态申请的内存都得到了释放:
2.使用智能指针:
3.使用内存泄漏检测库函数:
在visual studio下,调试模式

#include<crtdbg.h>
#ifdef _DEBUG
#define new  new(_NORMAL_BLOCK, __FILE__, __LINE__)
#endif
int main()
{
	_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);  //第13行
	int* p = new int[10];
	return 0;
}
Detected memory leaks!
Dumping objects ->
g:\c++code\dbg\dbg\dbg.cpp(13) : {103} normal block at 0x01517C30, 40 bytes long.
 Data: <                > CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD 
Object dump complete.

或者_CrtDumpMemoryLeaks函数。

4.内存泄漏检测工具:
参考文章>>

5.Linux系统下内存泄漏的检测方法(valgrind)
  编译:g++ -g -o test test.cpp
  使用:valgrind --tool=memcheck ./test

可以检测如下问题:

使用未初始化的内存(全局/静态变量初始化为0,局部变量/动态申请初始化为随机值);
内存读写越界;
内存覆盖(strcpy/strcat/memcpy);
动态内存管理(申请释放方式不同,忘记释放等);
内存泄露(动态内存用完后没有释放,又无法被其他程序使用)。
java是否会存在内存泄漏>>

39.什么是自由储存区

在C++中,malloc函数和free函数可以在堆上动态申请和释放内存,new/delete可以在自由存储区上申请内存,堆是操作系统的术语,是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当运行程序调用malloc()时就会从中分配,调用free()归还内存。将new/delete申请的内存称为自由储存区,是因为new/delete是可以重载的,大部分C++编译器默认使用堆来实现自由存储,并且new/delete默认使用malloc和free来完成,所以说new分配的对象,既可以说在堆上,也可以说在自由存储区。
参考文章>>

40.栈溢出的危害

1.通常产生栈溢出之后程序运行结果是无法预计的,最有可能就是崩溃退出。
2.到了黑客手上,结果就是被人控制电脑。

41.内存碎片

参考文章>>
内存碎片通常分为内部碎片和外部碎片

  1. 内部碎片是由于采用固定大小的内存分区,当一个进程不能完全使用分给它的固定内存区域时就产生了内部碎片,通常内部碎片难以完全避免;
  2. 外部碎片是由于某些未分配的连续内存区域太小,以至于不能满足任意进程的内存分配请求,从而不能被进程利用的内存区域。
    解决方式:现在普遍采用的段页式内存分配方式就是将进程的内存区域分为不同的段,然后将每一段由多个固定大小的页组成。通过页表机制,使段内的页可以不必连续处于同一内存区域,从而减少了外部碎片,然而同一页内仍然可能存在少量的内部碎片,只是一页的内存空间本就较小,从而使可能存在的内部碎片也较少。

指针

42.内存泄露排查

内存泄露排查

如何判断系统是否出现了内存泄漏:

出现内存泄漏问题,一种是泄漏严重,内存耗尽,此时的现象一定是服务器上的某些服务异常中断,开启打印后会看到类似于“内存申请失败”之类的log;
另一种情况可能是泄漏的速度较慢,这是需要定期的去检查一下系统上的内存情况,如果服务占用的内存超过了程序实际所需的内存大小,或者虚拟内存的占用一直在不断增长,则可以判定出现了内存泄漏。

2. 如何解决内存泄漏的问题:
2.1 内存池:
内存池解决不了已经发生泄漏的线上系统的问题,主要做的是一种预防以及辅助定位的工作。

可以根据系统所使用到的内存的情况,将内存根据分配的大小等进行分类,并在malloc后对池子中的内存附加上一些专有的标记信息,在日志中记录malloc的位置。

这样,当出现内存泄漏时,就可以根据出现泄漏的内存的类型去走查对应部分的代码。

使用内存池时需要注意的是:

一定要使用多进程的方式,每个进程对应一个独立的内存池。

2.2 mtrace工具:
2.2.1 mtrace的使用简介:
mtrace是一个用于定位内存泄漏的工具,可以实现看到具体是哪一行代码malloc申请的内存没有被释放。

mtrace工具的主要思路是在我们的调用内存分配和释放的函数中装载“钩子(hook)”函数,通过“钩子”函数打印的日志来帮助我们分析对内存的使用是否存在问题。

对该工具的使用包括两部分内容:

一是要修改源码,装载hook函数(通过mtrace、muntrace);
另一个是通过运行修改后的程序,生成特殊的log文件,然后利用mtrace工具分析日志,判断是否存在内存泄漏以及定位可能发生内存泄漏的代码位置。
PS:“mtrace”这个名字既是指 mtrace() 这个函数,也是指用于分析log日志的工具。

mtrace() 函数原型:

#include <mcheck.h>

void mtrace(void);			//装载钩子
void muntrace(void);		//卸载钩子

函数的具体介绍可以参看 man 3 mtrace。

其中,mtrace()用于开启内存分配跟踪,muntrace()用于取消内存分配跟踪。

具体的做法是 mtrace() 函数中会为那些和动态内存分配有关的函数安装“钩子(hook)”(例如malloc()/realloc()/memalign()/free()),这些钩子函数会为我们记录所有有关内存分配和释放的跟踪信息,而 muntrace() 则会卸载相应的钩子函数。

基于这些钩子函数的调试跟踪信息,就可以分许是否存在内存泄漏问题了。

2.2.2 mtrace的使用举例:

#include <stdlib.h>
#include <stdio.h>
#include <mcheck.h>

int main(int argc, char **argv)
{
	mtrace();

	int *p = (int*)malloc(16);

	free(p);

	p = (int*)malloc(32);		//出问题的行,malloc申请内存后没有释放

	muntrace();

	return 0;
}

运行运行
如果需要使用mtrace,则编译时需要加 -g 编译选项:

gcc -g test_memleak.c -o test_memleak

生成日志并分析定位问题:

首先设置生成日志文件的路径,具体的方法是通过定义并导出一个环境变量 MALLOC_TRACE :

export MALLOC_TRACE=./test.log

然后就可以直接运行可执行文件了:

./test_memleak

随后查看生成的日志文件:

$ cat test.log
= Start
@ ./test_leak:[0x400624] + 0x852450 0x10
@ ./test_leak:[0x400634] - 0x852450
@ ./test_leak:[0x40063e] + 0x852470 0x20
= End

日志中的三行分别对应源码中的三次 malloc、free、malloc 操作。

其中,每行的第一个数字对应源码的代码地址,+ 或 - 符号表示这一样是分配内存 或是 释放内存,第三个数字表示的是malloc()函数分配的内存的首地址。

使用 addr2line 工具可以反推出源文件的行数:

$ addr2line -f -e test_leak 0x400624
main
/home/u/samples/test_memleak.c:9

这样的方法适合代码量不大的小程序,对于大型程序,系统提供了一个叫做 mtrace 的命令行工具,可以帮助我们完成对日志的分析:\


$ mtrace ./test_leak $MALLOC_TRACE

Memory not freed:
-----------------
           Address     Size     Caller
0x0000000000852470     0x20  at /home/u/samples/test_memleak.c:13

2.3 valgrind
Valgrind是一套Linux下的仿真调试工具的集合。Valgrind由内核以及基于内核的其他调试工具组成。内核类似于一个框架(framework),它模拟了一个CPU环境,并提供服务给其他工具;而其他工具类似于插件(plug-in),利用内核提供的服务完成各种特定的内存调试任务。

Valgrind不仅可以用于定位程序中内存泄漏的问题,包括内存访问越界、重复释放等。

16.指针和地址区别

地址
地址是内存中每个字节的编号。
例如一块4GB的内存,其字节数和为4294967296 。

4G  = 4 * 1024 * 1024 * 1024
    = 4294967296 Byte

从0开始。其字节范围为0-4294967295,
由于2进制太长,一般用16进制表示
其范围表示为:

0x00000000 <-> 0xFFFFFFFF

指针
指针是储存地址的变量。
联系
地址可以保存在指针中,指针仅保存地址。
区别:
地址是字节编号;指针是一种变量。
在指针中,明确* &的作用:
例如:

int a = 5;
int* p = &a;

上面的代码中,
*表示指针定义标记,表示定义的是一个指针变量。
&表示取地址运算符
上面代码的含义为:取到a的地址并储存在指针变量p中。

而在下面这段代码中,*表示 取目标符,是从地址指向的内存块中拿到内容。
这段代码的含义为:取到地址变量p中地址指向的内存块的内容a,将其赋值为10。

*p = 10;

补充:指针与引用的区别

(1)指针:指针是一个存储地址的变量,指向内存的一个存储单元;而引用跟原来的变量是同一个东西,只不过是原变量的一个别名。

(2)引用不可以为空,当被创建的时候,必须初始化,而指针可以是空值,可以在任何时候被初始化

(3)可以有const指针,但是没有const引用;
因为引用的指向本来就是不可变的,无需加const声明。
(4)指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a是不合法的)

(5)指针的值可以为空,但是引用的值不能为NULL,并且引用在定义的时候必须初始化;

(6)指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。

(7)”sizeof引用”得到的是所指向的变量(对象)的大小,而”sizeof指针”得到的是指针本身的大小;

char a = '1' ;

const char&  b = a;
cout<<sizeof(b)<<endl; // 1  原变量类型的大小

char*p = &a;
cout<<sizeof(p)<<endl;   //32位 4   64位 8
return 0;

(8)指针和引用的自增(++)运算意义不一样;
引用自增,改变原值
指针自增,地址递增
(9)如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄漏;
(10)访问实体方式不同,指针需要显式解引用,引用编译器自己处理。
(11)引用比指针使用起来简洁、安全。
参考文章>>
参考文章>>

17.数组与指针的联系与区别

数组是存储元素的集合
指针是储存地址的变量
联系:
1.指针被初始化为数组名时 
当一个指针变量被初始化成数组名时,就说该指针变量指向了数组,对数组的操作都可以由指针来实现。此时数组与指针是融为一体的。

char str[20], *ptr;
ptr=str;

2.指针指向数组元素时

 int a[10], *p;
 p=a;

对数组的操作也可以由指针来完成
数组元素的地址是不能变化的,是指针常量;但是指针变量是可以变化的。
3.指针与一维数组
任何能由下标完成的操作,都可以用指针来实现,一个不带下标的数组名就是一个指向该数组的指针。C语言对数组的处理,实际上是转换成指针地址的运算。
指向由j个元素组成的一位数组: 
当指针变量p不是指向整型变量,而是指向一个包含j个元素的一维数组。如果p=a[0],则p++不是指向a[0][1],而是指向a[1]。这时p的增值以一维数组的长度为单位。

4.普通变量、指针、数组三者对于编译器的区别。
普通变量存取数据只需要取地址一次即可完成。
指针存取数据需要取地址两次来完成:第一次取到储存这个指针变量的地址,第二次取到地址中的内容指向的地址中的内容。
具体到数组,它即具有普通变量的直接性,即不用取两次地址里的内容而是取一次,同时又具有和指针相同的偏移量引用方式,即下标的实现实际是由指针加偏移量实现的。。

区别:
1.把数组作为参数传递的时候,会退化为指针。
在作为函数形参时,失去其数组内涵的同时,它还失去了其常量特性,可以作自增、自减等操作,可以被修改,变成了一个普通的指针。
2、数组名是指针常量
虽然数组名可以转换为指向其指代实体的指针,但是它只能被看作一个指针常量,不能被修改
3.字符数组与字符串常量
字符数组的元素是可以被修改的,储存在可写数据块。
字符串常量储存在常量区,只读,如果要可写,需要为其分配非常量的内存空间。

char a[3] = "abc";
strcpy(a,"end");//it's ok
 
char *a = "abc";
strcpy(a,"end"); //it's wrong

4.数组和指针的分配

  • 数组是开辟一块连续的内存空间,数组本身的标示符代表整个数组,可以用sizeof取得真实的大小
  • 指针则是只分配一个指针大小的内存,并可把它的值指向某个有效的内存空间。
int a[10];
int *p = a;
cout<<sizeof(a);  //40
cout<<sizeof(p);  //4

存储位置:
数组:
全局数组和所有静态数组存储在全局区/静态区。
类中的数组和函数内普通局部数组位于栈上。
动态分配的数组位于堆上。

指针:
动态分配的指针位于堆上
静态分配的指针位于栈上

7.C++中引用和指针的区别

1.指针和引用的定义和性质区别:

(1)指针:指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元;而引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。如:

int a=1;int *p=&a;

int a=1;int &b=a;

(2)引用不可以为空,当被创建的时候,必须初始化,而指针可以是空值,可以在任何时候被初始化。

(3)可以有const指针,但是没有const引用;

(4)指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a是不合法的)

(5)指针的值可以为空,但是引用的值不能为NULL,并且引用在定义的时候必须初始化;

(6)指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。

(7)”sizeof引用”得到的是所指向的变量(对象)的大小,而”sizeof指针”得到的是指针本身的大小;

(8)指针和引用的自增(++)运算意义不一样;

(9)如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄漏;

2.指针和引用作为函数参数进行传递时的区别。

(1)引用作为参数进行传递
传递的是实参的别名,对实参的改动实际就是对原值的改动。
2)指针作为函数的参数进行传递
传递的是地址的副本,通过地址修改数据,也会对原址造成修改。

51.函数指针、指针函数、回调函数

参考文章>>
1.指针函数

顾名思义,它的本质是一个函数,不过它的返回值是一个指针。

int*  func(int n )
{
    static int ret = n+1;
    int *p = &ret;
    return p;
}
int main()
{
//    string  s= "yicfihpfbz";
    int * ans = func(5);
    cout<<*ans<<endl;
}

如果要从函数内返回一个局部变量的指针,当函数执行完后,该指针指向的地址被回收,出现意想不到的值或错误,所以要将返回的指针指向的变量声明为静态变量,其存储在全局区,不会因为程序结束而被回收。

2.函数指针

优点:修改下指针指向的函数入口地址,调用函数指针的地方所使用的函数全部被修改,可以批量修改。
函数的定义是存在于代码段,因此,每个函数在代码段中,也有着自己的入口地址,函数名中就存储着函数入口地址,函数指针就是指向代码段中函数入口地址的指针。

int prit(int a,int b)
{
    cout<<"a = "<<a<<endl;
    cout<<"b = "<<b<<endl;
}
int main()
{
//    string  s= "yicfihpfbz";
//p是参数为(int,int),返回值为int的类型的函数指针,对于C++ 给定函数名,函数参数个数 类型 顺序即可分辨不同的函数。
    int (*p)(int ,int );
    p = prit;
    int res = p(3,4);
}

3.回调函数

回调函数是函数指针的应用,将函数指针作为callback函数的参数,可以通过callback函数的函数指针参数来调用对应的函数。便于后续优化,修改函数指针参数即可调用不同的函数。

void prit(int a,int b)
{
    cout<<"a = "<<a<<endl;
    cout<<"b = "<<b<<endl;
}
void callback(int a,int b,void (*p)(int ,int))
{
    p(a,b);
}
int main()
{
//    string  s= "yicfihpfbz";
    callback(2,3,prit);
}

新特性

4.C++智能指针 /Shared_ptr

参考文章>>
智能指针的作用
C++程序设计中使用堆内存是非常频繁的操作,堆内存的申请和释放都由程序员自己管理。程序员自己管理堆内存可以提高了程序的效率,但是整体来说堆内存的管理是麻烦的,C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。

理解智能指针需要从下面三个层次

  1. 从较浅的层面看,智能指针是利用了一种叫做**RAII(资源获取即初始化)**的技术对普通的指针进行封装,这使得智能指针实质是一个对象,行为表现的却像一个指针。
  2. 智能指针的作用是防止忘记调用delete释放内存和程序异常的进入catch块忘记释放内存。另外指针的释放时机也是非常有考究的,多次释放同一个指针会造成程序崩溃,这些都可以通过智能指针来解决。
  3. 智能指针还有一个作用是把值语义转换成引用语义。C++和Java有一处最大的区别在于语义不同,在Java里面下列代码:

Animal a = new Animal();
  Animal b = a;
你当然知道,这里其实只生成了一个对象,a和b仅仅是把持对象的引用而已。但在C++中不是这样,
Animal a;
Animal b = a;

这里却是就是生成了两个对象。

智能指针在C++11版本之后提供,包含在头文件中,shared_ptr、unique_ptr、weak_ptr

1.shared_ptr:

shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。

  • 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如std::shared_ptr p4 = new int(1);的写法是错误的
  • 拷贝和赋值。拷贝使得对象的引用计数增加1,赋值使得原对象引用计数减1,当计数为0时,自动释放内存。后来指向的对象引用计数加1,指向后来的对象。 对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数
  • get函数获取原始指针
  • 注意不要用一个原始指针初始化多个shared_ptr,否则会造成二次释放同一内存
  • 注意避免循环引用,shared_ptr的一个最大的陷阱是循环引用,循环,循环引用会导致堆内存无法正确释放,导致内存泄漏。
    关于循环引用>>

两个shared_ptr相互引用,实际引用数量为1,但计数器计数为2,导致内存无法释放。
使用weak_ptr,它的构造和析构不增加或减少引用计数。

void Share_Ptr_Test()
{
    int a = 10;
    shared_ptr<int>ptra = make_shared<int>(a);
    shared_ptr<int>ptra2(ptra);
    shared_ptr<int>ptra3(ptra);
    shared_ptr<int>ptra4(ptra);
    cout<<ptra.use_count()<<endl;   // 4
    cout<<ptra2.use_count()<<endl;  // 4
    int b = 20;
    int* pb = &b;

    cout<<pb<<endl;

    shared_ptr<int>ptrb = make_shared<int>(b);
    ptra2 = ptrb;         //ptrb发生一次引用,计数器+1,ptra2发生一次赋值,ptra的计数器-1
    pb = ptrb.get();
    
    cout<<ptra.use_count()<<endl;   // 3
    cout<<ptra2.use_count()<<endl;  // 2
    cout<<ptrb.use_count()<<endl;   //2
    cout<<pb<<endl;
}

2.unique_ptr

“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。
相比与原始指针unique_ptr用于其RAII的特性,使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。
unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权。

void  Unique_Ptr_Test()
{
    unique_ptr<int>uptr(new int (10));
    //unique_ptr<int>uptr2 = uptr; //错误 不可赋值
    //unique_ptr<int>uptr2(uptr);//错误  不可拷贝

    unique_ptr<int>uptr2 = move(uptr);//转换所有权
//    cout<<*uptr<<endl; //错误 指针已经为空

    cout<<*uptr2<<endl;         //10
    uptr2.release();            //释放所有权
//    cout<<*uptr2<<endl;  //错误 指针已经为空
}

3.weak_ptr

1.是为了配合shared_ptr而引入的一种智能指针,因为它不具有普通指针的行为,没有重载operator*和->,它的最大作用在于协助shared_ptr工作,像旁观者那样观测资源的使用情况。
2.weak_ptr可以从一个shared_ptr或者另一个weak_ptr对象构造,获得资源的观测权。但weak_ptr没有共享资源,它的构造不会引起指针引用计数的增加。
3.使用weak_ptr的成员函数use_count()可以观测资源的引用计数,另一个成员函数expired()的功能等价于use_count()==0,但更快,表示被观测的资源(也就是shared_ptr的管理的资源)已经不复存在。
4.weak_ptr可以使用一个非常重要的成员函数lock()从被观测的shared_ptr获得一个可用的shared_ptr对象, 从而操作资源。但当expired()==true的时候,lock()函数将返回一个存储空指针的shared_ptr。
两个使用场景:1.解决shared_ptr的循环引用问题 。2. 探测share_ptr指向的内存空间是否有效。
虽然通过弱引用指针可以有效的解除循环引用,但这种方式必须在程序员能预见会出现循环引用的情况下才能使用,也可以是说这个仅仅是一种编译期的解决方案,如果程序在运行过程中出现了循环引用,还是会造成内存泄漏的。因此,不要认为只要使用了智能指针便能杜绝内存泄漏。毕竟,对于C++来说,由于没有垃圾回收机制,内存泄漏对每一个程序员来说都是一个非常头痛的问题。

void Weak_Ptr_Test()
{
 shared_ptr<int>sh_ptr = make_shared<int>(10);
 cout<<sh_ptr.use_count()<<endl;
 weak_ptr<int>wp(sh_ptr);

 cout<<wp.use_count()<<endl;

 if(!wp.expired())
 {
     shared_ptr<int>sh_ptr2 = wp.lock();
     cout<<sh_ptr.use_count()<<endl;

     *sh_ptr2 = 100;

     cout<<*sh_ptr2<<endl;
     cout<<*sh_ptr<<endl;

     cout<<wp.use_count()<<endl;
 }
}

4. auto_ptr

实际是一个对象,但通过重载运算符表现的像个指针
1.auto_ptr不支持拷贝和赋值操作,不能用在STL标准容器中。
2.STL容器中的元素经常要支持拷贝、赋值操作,在这过程中auto_ptr会传递所有权,auto_ptr采用的是独享所有权语义,一个非空的auto_ptr总是拥有它所指向的资源。
3.转移一个auto_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空。

很大缺陷:我们看到当通过复构造函数,通过操作符=赋值后,原来的那个智能指针对象就失效了.只有新的智能指针对象可以有效使用了.用个专业点的说法叫所有权的转移.被包装的指针指向的内存块就像是一份独享占用的财产,当通过复制构造,通过=赋值传给别的智能指针对象时,原有的对象就失去了对那块内存区域的拥有权.也就是说任何时候只能有一个智能指针对象指向那块内存区域,不能有两个对象同时指向那块内存区域.

这样一来auto_ptr不能做为STL中容器的元素,为啥呢? 因为容器中经常存在值拷贝的情况嘛,把一个容器对象直接赋值给另一对象.完了之后两个容器对象可得都能用啊.而如果是auto_ptr的话显然赋值后只能一个有用,另一个完全报废了.另外比如你有个变量auto_ptr ap( new int(44) ); 然后ap被放进一个容器后,ap就报废不能用了.
auto_ptr实现>>
参考文章>>
5.实现

shared_ptr

#include <iostream>
 #include <memory>
 
 template<typename T>
 class SmartPointer {
 private:
     T* _ptr;
     size_t* _count;
 public:
     SmartPointer(T* ptr = nullptr) :
             _ptr(ptr) {
         if (_ptr) {
             _count = new size_t(1);
         } else {
             _count = new size_t(0);
         }
     }
 
     SmartPointer(const SmartPointer& ptr) {
         if (this != &ptr) {
             this->_ptr = ptr._ptr;
             this->_count = ptr._count;
             (*this->_count)++;
         }
     }
 
     SmartPointer& operator=(const SmartPointer& ptr) {
         if (this->_ptr == ptr._ptr) {
             return *this;
         }
 
         if (this->_ptr) {
             (*this->_count)--;
             if (this->_count == 0) {
                 delete this->_ptr;
                 delete this->_count;
             }
         }
 
         this->_ptr = ptr._ptr;
         this->_count = ptr._count;
         (*this->_count)++;
         return *this;
     }
 
     T& operator*() {
         assert(this->_ptr == nullptr);
         return *(this->_ptr);
 
     }
 
     T* operator->() {
         assert(this->_ptr == nullptr);
         return this->_ptr;
     }
 
     ~SmartPointer() {
         (*this->_count)--;
         if (*this->_count == 0) {
             delete this->_ptr;
             delete this->_count;
         }
     }
 
     size_t use_count(){
         return *this->_count;
     }
 };
 
 int main() {
     {
         SmartPointer<int> sp(new int(10));
         SmartPointer<int> sp2(sp);
         SmartPointer<int> sp3(new int(20));
         sp2 = sp3;
         std::cout << sp.use_count() << std::endl;
         std::cout << sp3.use_count() << std::endl;
     }
     //delete operator
 }

unique_ptr
unique_ptr“独占”所指对象。我们知道指针或引用在离开作用域时是不会进行析构的,但是类在离开作用域时会自动执行析构函数,所以我们可以用一个类来实现指针指针(unique_ptr本质上是一个类,只是可以像一个指针一样使用)。因此我们可以通过析构函数调用delete去释放资源。那么如何实现“独占”呢?,我们可以在类中把拷贝构造函数和拷贝赋值声明为private,这样就不可以对指针指向进行拷贝了,也就不能产生指向同一个对象的指针。
实现 >>

cpp文件

#include <utility>
#include<iostream>


/****
 * 智能指针unique_ptr的简单实现
 * 
 * 特点:独享它指向的对象。也就是说,同时只有一个unique_ptr指向同一个对象,当这个unique_ptr被销毁时,指向的对象也随即被销毁
 * 
 * 典型用途:
 * 1. 在一个函数定义一个A* ptr = new A(), 结束还需要用delete,而用unique_ptr,就不需要自己调用delete
 * 2. 作为一个类的变量,这个变量只在本类使用,不会被其他类调用,也不会作为参数传递给某个函数
 * */
template<typename T>
class unique_ptr
{
private:
	T * ptr_resource = nullptr;

public:
    //explicit构造函数是用来防止隐式转换, 即不允许写成unique_ptr<T> tempPtr = T;
    //std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝所以可以提高利用效率,改善性能.
    //move之后,raw_resource内部的资源将不能再被raw_resource使用
	explicit unique_ptr(T* raw_resource) noexcept : ptr_resource(std::move(raw_resource)) {}
	unique_ptr(std::nullptr_t) : ptr_resource(nullptr) {}

	unique_ptr() noexcept : ptr_resource(nullptr) {}

	//析构时, 释放托管的对象资源
	~unique_ptr() noexcept
	{
		delete ptr_resource;
	}
	// Disables the copy/ctor and copy assignment operator. We cannot have two copies exist or it'll bypass the RAII concept.
    //重要,禁止两种拷贝的赋值方式
    //使用"=delete"修饰,表示函数被定义为deleted,也就意味着这个成员函数不能再被调用,否则就会出错。
	unique_ptr(const unique_ptr<T>&) noexcept = delete;
	unique_ptr& operator = (const unique_ptr&) noexcept = delete;
	
public:
    //&& 是右值引用,见https://zhuanlan.zhihu.com/p/107445960
	// 允许移动语义。虽然无法复制unique_ptr,但可以安全地移动。
    //例子:unique_ptr<Test> tPtr3(std::move(tPtr1));
	unique_ptr(unique_ptr&& move) noexcept
	{
        std::cout << "construct for unique_ptr&&" << std::endl;
		move.swap(*this);
	}
	// ptr = std::move(resource)
	unique_ptr& operator=(unique_ptr&& move) noexcept
	{
        std::cout << "operator= for unique_ptr&&" << std::endl;

		move.swap(*this);
		return *this;
	}

	explicit operator bool() const noexcept
	{
		return this->ptr_resource;
	}
	// releases the ownership of the resource. The user is now responsible for memory clean-up.
	T* release() noexcept
	{
		return std::exchange(ptr_resource, nullptr);
	}
	// returns a pointer to the resource
	T* get() const noexcept
	{
		return ptr_resource;
	}
	// swaps the resources
	void swap(unique_ptr<T>& resource_ptr) noexcept
	{
		std::swap(ptr_resource, resource_ptr.ptr_resource);
	}
	// reset就删除老的,指向新的
	void reset(T* resource_ptr) noexcept(false)
	{
		// ensure a invalid resource is not passed or program will be terminated
		if (resource_ptr == nullptr)
			throw std::invalid_argument("An invalid pointer was passed, resources will not be swapped");

		delete ptr_resource;

		ptr_resource = nullptr;

		std::swap(ptr_resource, resource_ptr);
	}
public:
	// overloaded operators
	T * operator->() const noexcept
	{
		return this->ptr_resource;
	}
	T& operator*() const noexcept
	{
		return *this->ptr_resource;
	}
	// 额外说明noexcept
    //noexcept C++11关键字, 告诉编译器,函数中不会发生异常,有利于编译器对程序做更多的优化
    //C++中的异常处理是在运行时而不是编译时检测的。为了实现运行时检测,编译器创建额外的代码,然而这会妨碍程序优化
};

main文件

#include "UniquePtr.h"
/**
 * 简单的类,将被智能指针使用
 * */
class Test {
public:
	Test() {
		std::cout << "Test class construct" << std::endl;
	}
	~Test() {
		std::cout << "Test class destruct" << std::endl;
	}

	void printSomething() {
		std::cout << "Test printSomething " << std::endl;
	}

    void printResource() {
		std::cout << "Test printResource " << a << std::endl;
	}

    int getResource() {
        return a;
    }

private:
    int a = 10;
};

/**
 * 使用unique_ptr的类
 * */
class PUser {
public:
	PUser() {
        //初始化pTest
        pTest.reset(new Test());
		std::cout << "PUser construct " << std::endl;
	}
	~PUser() {
		std::cout << "PUser destruct" << std::endl;
	}

    //可以在类的各种函数,使用pTest,
	void userTest() {
		std::cout << "userTest " << pTest->getResource() << std::endl;
	}

private:
    //典型用法,在一个类中,作为一个类成员变量
    unique_ptr<Test> pTest;
};



/**
 * 主程序入口
 * */
int main(int argc, char* argv[]) {
    unique_ptr<Test> tPtr1(new Test());
    //以下这两句话,//编译就不通过,因为已经定义, unique_ptr& operator = (const unique_ptr&) noexcept = delete;
    //unique_ptr<Test> tPtr2 = tPtr1;
    //unique_ptr<Test> tPtr3(tPtr1);
    
    //以下两句话就允许,因为pPtr1做了控制权转移
    unique_ptr<Test> tPtr3(std::move(tPtr1));
    unique_ptr<Test> tPtr4 = std::move(tPtr3);

    //tPtr1->printResource();//这一句就崩溃,因为tPtr1非空,只不过资源完全不能用了
    tPtr1->printSomething();//这一句不崩溃,tPtr1虽然资源不能用,但是代码段可以调用,只要代码段没有使用到资源


    PUser* pUser = new PUser();
    pUser->userTest();

    return 0;
}

53.shared_ptr的线程安全

参考文章>>
参考文章>>
1.计数器count本身是原子操作,是线程安全的
2.类内ptr指针的赋值与count的赋值是分开操作,是线程不安全的。
例如:初始化两个shared_ptr对象A和B,B指向一个对象C,执行语句A=B,A和B内的ptr指针完成拷贝,但count计数器却没有赋值完成时,此时如果在另一个线程发生B的赋值操作,则B内的count计数器-1并等于0,使B内的对象ptr发升析构,则A对象的ptr指针将指向一个野指针/悬空指针。

所以shared_ptr是线程不安全的,多线程中需要加锁保证安全。

new一个指针,传给shared_ptr,有什么问题

1. 内存泄漏风险
当您这样做时,可能会发生内存泄漏的情况。例如:

int* ptr = new int(10);
shared_ptr p1(ptr);
shared_ptr p2(ptr);

// 如果在这之后发生异常,ptr就不会被释放
如果在创建p1和p2之后发生异常,ptr就不会被释放,导致内存泄漏。

2. 可能出现野指针
如果在创建shared_ptr后,原始指针被删除,而shared_ptr仍然持有该指针,可能会导致野指针:

int* ptr = new int(10);
shared_ptr p(ptr);

delete ptr; // 这里会导致问题,因为p仍然持有该指针
这会导致程序行为不确定。

3. 引用计数不正确
当多个shared_ptr管理同一个原始指针时,每个shared_ptr都会维护自己的引用计数。这样可能会导致意外的行为:

int* ptr = new int(10);
shared_ptr p1(ptr);
shared_ptr p2(ptr);

// 现在p1和p2都独立地管理ptr
如果p1被销毁,但p2仍然存在,ptr可能会被删除两次。

最佳实践
为了避免这些问题,最好使用以下方法:

使用make_shared来创建shared_ptr:
shared_ptr p1 = std::make_shared(10);
shared_ptr p2 = std::make_shared(20);
避免直接传递裸指针给shared_ptr:
// 不要这样做
shared_ptr p1(new int(10));
shared_ptr p2(p1.get());

// 而是这样
shared_ptr p1 = std::make_shared(10);
shared_ptr p2 = p1;
如果必须使用原始指针,确保所有共享它的shared_ptr都是通过相同的源(如同一个shared_ptr)得到的。

5.C++的类型转换 /static_cast/dynamic_cast

1.static_cast
(1)用于类层次结构中基类(父类)和派生类(子类)之间指针或引用的转换。
进行上行转换(把派生类的指针或引用转换成基类表示)是安全的;进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的。
(2)用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。
(3)把空指针转换成目标类型的空指针。
(4)把任何类型的表达式转换成void类型

class ANIMAL
{
public:
    ANIMAL():_type("ANIMAL"){};
    virtual void OutPutname(){cout<<"ANIMAL";};
private:
    string _type ;
};
class DOG:public ANIMAL
{
public:
    DOG():_name("大黄"),_type("DOG"){};
    void OutPutname(){cout<<_name;};
    void OutPuttype(){cout<<_type;};
private:
    string _name ;
    string _type ;
};

int main()
{
    //基类指针转为派生类指针,且该基类指针指向基类对象。
    ANIMAL * ani1 = new ANIMAL ;
    DOG * dog1 = static_cast<DOG*>(ani1);
    //dog1->OutPuttype();//错误,在ANIMAL类型指针不能调用方法OutPutType();在运行时出现错误。

    //基类指针转为派生类指针,且该基类指针指向派生类对象
    ANIMAL * ani3 = new DOG;
    DOG* dog3 = static_cast<DOG*>(ani3);
    dog3->OutPutname(); //正确 大黄

    //子类指针转为派生类指针
    DOG *dog2= new DOG;
    ANIMAL *ani2 = static_cast<DOG*>(dog2);
    ani2->OutPutname(); //正确,结果输出为大黄

    //
    system("pause");

}

2.dynamic_cast
(1)其他三种都是编译时完成的,dynamic_cast 是运行时处理的,运行时要进行类型检查。
(2)不能用于内置的基本数据类型的强制转换
(3)dynamic_cast 要求 <> 内所描述的目标类型必须为指针或引用。dynamic_cast 转换如果成功的话返回的是指向类的指针或引用,转换失败的话则会返回 nullptr
(4)在类的转换时,在类层次间进行上行转换(子类指针指向父类指针)时,dynamic_cast 和 static_cast 的效果是一样的。在进行下行转换(父类指针转化为子类指针)时,dynamic_cast 具有类型检查的功能,比 static_cast 更安全。
(5)使用 dynamic_cast 进行转换的,基类中一定要有虚函数,否则编译不通过(类中存在虚函数,就说明它有想要让基类指针或引用指向派生类对象的情况,此时转换才有意义)。这是由于运行时类型检查需要运行时类型信息,而这个信息存储在类的虚函数表中。

例子:
dynamic_cast 用于在类的继承层次之间进行类型转换,它既允许向上转型(Upcasting),也允许向下转型(Downcasting)。向上转型是无条件的,不会进行任何检测,所以都能成功;向下转型的前提必须是安全的,要借助 RTTI 进行检测,所有只有一部分能成功。

dynamic_cast 与 static_cast 是相对的,dynamic_cast 是“动态转换”的意思,static_cast 是“静态转换”的意思。dynamic_cast 会在程序运行期间借助 RTTI 进行类型转换,这就要求基类必须包含虚函数;static_cast 在编译期间完成类型转换,能够更加及时地发现错误。

向上转换:不进行安全检查

#include <iostream>
#include <iomanip>
using namespace std;
class Base{
public:
    Base(int a = 0): m_a(a){ }
    int get_a() const{ return m_a; }
    virtual void func() const { }
protected:
    int m_a;
};

class Derived: public Base{
public:
    Derived(int a = 0, int b = 0): Base(a), m_b(b){ }
    int get_b() const { return m_b; }
private:
    int m_b;
};

int main(){
    //情况①
    Derived *pd1 = new Derived(35, 78);
    Base *pb1 = dynamic_cast<Derived*>(pd1);
    cout<<"pd1 = "<<pd1<<", pb1 = "<<pb1<<endl; //输出地址相同
    cout<<pb1->get_a()<<endl; //35
    pb1->func(); //正常调用
    //情况②
    int n = 100;
    Derived *pd2 = reinterpret_cast<Derived*>(&n);
    Base *pb2 = dynamic_cast<Base*>(pd2);
    cout<<"pd2 = "<<pd2<<", pb2 = "<<pb2<<endl; //相同的地址
    cout<<pb2->get_a()<<endl;  //输出一个垃圾值
    pb2->func();  //内存错误
    return 0;
}
对于情况1,可以看到pd1与pb1的地址相同,且pb1可以正常调用Base类的方法
对于情况②
pd 2指向的是整型变量 n,并没有指向一个 Derived 类的对象,在使用 dynamic_cast 进行类型转换时也没有检查这一点(因为向上转型始终是安全的,所以 dynamic_cast 不会进行任何运行期间的检查)

而是将 pd 的值直接赋给了 pb(这里并不需要调整偏移量),最终导致 pb 也指向了 n。因为 pb 指向的不是一个对象,所以get_a()得不到 m_a 的值(实际上得到的是一个垃圾值),pb2->func()也得不到 func() 函数的正确地址。

向下转换
向下转型是有风险的,dynamic_cast 会借助 RTTI 信息进行检测,确定安全的才能转换成功,否则就转换失败

#include <iostream>
using namespace std;
class A{
public:
   virtual void func() const { cout<<"Class A"<<endl; }
private:
   int m_a;
};
class B: public A{
public:
   virtual void func() const { cout<<"Class B"<<endl; }
private:
   int m_b;
};
class C: public B{
public:
   virtual void func() const { cout<<"Class C"<<endl; }
private:
   int m_c;
};
class D: public C{
public:
   virtual void func() const { cout<<"Class D"<<endl; }
private:
   int m_d;
};
int main(){
   A *pa = new A();
   B *pb;
   C *pc;
  
   //情况①
   pb = dynamic_cast<B*>(pa);  //向下转型失败
   if(pb == NULL){
       cout<<"Downcasting failed: A* to B*"<<endl;
   }else{
       cout<<"Downcasting successfully: A* to B*"<<endl;
       pb -> func();
   }
   pc = dynamic_cast<C*>(pa);  //向下转型失败
   if(pc == NULL){
       cout<<"Downcasting failed: A* to C*"<<endl;
   }else{
       cout<<"Downcasting successfully: A* to C*"<<endl;
       pc -> func();
   }
  
   cout<<"-------------------------"<<endl;
  
   //情况②
   pa = new D();  //向上转型都是允许的
   pb = dynamic_cast<B*>(pa);  //向下转型成功
   if(pb == NULL){
       cout<<"Downcasting failed: A* to B*"<<endl;
   }else{
       cout<<"Downcasting successfully: A* to B*"<<endl;
       pb -> func();
   }
   pc = dynamic_cast<C*>(pa);  //向下转型成功
   if(pc == NULL){
       cout<<"Downcasting failed: A* to C*"<<endl;
   }else{
       cout<<"Downcasting successfully: A* to C*"<<endl;
       pc -> func();
   }
  
   return 0;
}

在这里插入图片描述
可以看到,前两次转换失败,但是后两次转换成功

这段代码中类的继承顺序为:A --> B --> C --> D。pa 是A类型的指针,当 pa 指向 A 类型的对象时,向下转型失败,pa 不能转换为B或C类型。当 pa 指向 D 类型的对象时,向下转型成功,pa 可以转换为B或C*类型。同样都是向下转型,为什么 pa 指向的对象不同,转换的结果就大相径庭呢?
是否能够转换成功,需要看指针指向的对象的,对象的继承链上如果有层级更高的类,则可以转换成功,否则转换失败。

因为每个类都会在内存中保存一份类型信息,编译器会将存在继承关系的类的类型信息使用指针“连接”起来,从而形成一个继承链(Inheritance Chain),也就是如下图所示的样子:

在这里插入图片描述
当使用 dynamic_cast 对指针进行类型转换时,程序会先找到该指针指向的对象,再根据对象找到当前类(指针指向的对象所属的类)的类型信息,并从此节点开始沿着继承链向上遍历,如果找到了要转化的目标类型,那么说明这种转换是安全的,就能够转换成功,如果没有找到要转换的目标类型,那么说明这种转换存在较大的风险,就不能转换。

所以在第二种方式中,pa实际上是指向的D,于是程序顺着D开始向上找,找到了B和C,于是认定是安全的,所以转换成功

总起来说,dynamic_cast 会在程序运行过程中遍历继承链,如果途中遇到了要转换的目标类型,那么就能够转换成功,如果直到继承链的顶点(最顶层的基类)还没有遇到要转换的目标类型,那么就转换失败。对于同一个指针(例如 pa),它指向的对象不同,会导致遍历继承链的起点不一样,途中能够匹配到的类型也不一样,所以相同的类型转换产生了不同的结果。

3.const_cast:
const_cast用于将常量指针转换为普通指针,const_cast<>里边的内容必须是引用或者指针,就连把 int 转成 int 都不行。
4.reinterpret_cast
 1、改变指针或引用的类型
 2、将指针或引用转换为一个足够长度的整形
 3、将整型转换为指针或引用类型。

关于reinterpret_cast转换和内存布局的关系:

class A {
public:
  void MethodA() {
    std::cout << __func__ << " a_: " << a_ << std::endl;
  }
private:
  int a_ = 3;
};
class B {
public:
  virtual void MethodB() {
    std::cout << __func__ << " b_: " << b_ << std::endl;
  }
protected:
  int b_ = 1;
};
class C {
public:
  virtual void MethodC() {
    std::cout << __func__ << " c_: " << c_ << std::endl;
  }
protected:
  int c_ = 2;
};
class D {
public:
  void MethodD() {
    std::cout << __func__ << " d_: " << d_ << std::endl;
  }
protected:
  int d_ = 4;
};
int main() {
  {
    B* p = new B;
    C* pc = reinterpret_cast<C*>(p);
    pc->MethodC(); //输出B的方法 B的方法,值为1,因为 B C有相同的内存布局,但存储的具体地址值不一样,调用MethodC时,找到的是MethodB虚方法,值也是B的值。
    A* pa = reinterpret_cast<A*>(p);
    pa->MethodA(); //输出的是A的方法,因为A不是虚类,内存布局与B不同,调用MethodA是通过符号调用的,但是A里面的a_值是在B的内存布局上找的,由于B内存中有虚函数指针的存在,以A的内存布局套到B上,在A中存a_的地方,在B中是不确定存的什么值。
  }
//  {
//    A* p = new A;
//    B* pb = reinterpret_cast<B*>(p);
//    pb->MethodB(); //由于B是虚函数,所以调method的时候要通过虚函数指针,但是在A的内存布局中,应该存虚指针的位置存的其他值,不一定是函数地址,所以是ub的。
//  }
    
    {
      A* p = new A;
      D* pd = reinterpret_cast<D*>(p);
      pd->MethodD(); //AD内存布局相同,通过符号名调函数调的是D的函数,但是在d_的内存位置,存的是a_的值,所以打印了a_的值。
    }
    return 0;
}


6.C++11新特性

1.nullptr

在某种意义上来说,传统 C++ 会把 NULL、0 视为同一种东西,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 ((void*)0),有些则会直接将其定义为 0。

nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较,当需要使用 NULL 时候,养成直接使用 nullptr的习惯。

2. 类型推导

C++11 引入了 auto 和 decltype 这两个关键字实现了类型推导,让编译器来操心变量的类型。

  • auto 不能用于函数传参
  • auto 还不能用于推导数组类型

decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。它的用法和 sizeof 很相似:decltype(表达式)

decltype功能
decltype并不会实际计算表达式的值,编译器分析表达式并得到它的类型。
- decltype + 变量 var
当使用decltype(var)的形式时,decltype会直接返回变量的类型(包括顶层const和引用)。
- decltype + 表达式 expr
当使用decltype(expr)的形式时,decltype会返回表达式结果对应的类型。
- decltype + 函数名 func_name
当使用decltype(func_name)的形式时,decltype会返回对应的函数类型,不会自动转换成相应的函数指针。

编译器分析表达式并得到它的类型,却不实际计算表达式的值.

C++11 引入了一个叫做拖尾返回类型(trailing return type),利用 auto 关键字将返回类型后置:

template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y) {
    return x+y;
}

3. 区间迭代

C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句。

// & 启用了引用
for(auto &i : arr) {    
    std::cout << i << std::endl;
}

4. 初始化列表

C++11 提供了统一的语法来初始化任意的对象,例如:

struct A {
    int a;
    float b;
};
struct B {

    B(int _a, float _b): a(_a), b(_b) {}

private:
    int a;
    float b;
};

A a {1, 1.1};    // 统一的初始化语法
B b {2, 2.2};

C++11 还把初始化列表的概念绑定到了类型上,并将其称之为 std::initializer_list,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和 POD 的初始化方法提供了统一的桥梁,例如:

#include <initializer_list>

class Magic {
public:
    Magic(std::initializer_list<int> list) {}
};

Magic magic = {1,2,3,4,5};
std::vector<int> v = {1, 2, 3, 4};

5. 模板增强

外部模板

传统 C++ 中,模板只有在使用时才会被编译器实例化。只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板实例化。

C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使得能够显式的告诉编译器何时进行模板的实例化:

template class std::vector<bool>;            // 强行实例化
extern template class std::vector<double>;  // 不在该编译文件中实例化模板

尖括号 “>”

在传统 C++ 的编译器中,>>一律被当做右移运算符来进行处理。但实际上我们很容易就写出了嵌套模板的代码:

std::vector<std::vector<int>> wow;

这在传统C++编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译。

类型别名模板

在传统 C++中,typedef 可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型。

C++11 使用 using 引入了下面这种形式的写法,并且同时支持对传统 typedef 相同的功效:

template <typename T>
using NewType = SuckType<int, T, 1>;    // 合法

默认模板参数

我们可能定义了一个加法函数:

template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y) {
    return x+y
}

但在使用时发现,要使用 add,就必须每次都指定其模板参数的类型。
在 C++11 中提供了一种便利,可以指定模板的默认参数:

template<typename T = int, typename U = int>
auto add(T x, U y) -> decltype(x+y) {
    return x+y;
}

6.继承构造

在继承体系中,如果派生类想要使用基类的构造函数,需要在构造函数中显式声明。

struct A
{
  A(int i) {}
  A(double d,int i){}
  A(float f,int i,const char* c){}
  //...等等系列的构造函数版本
};
struct B:A
{
  B(int i):A(i){}
  B(double d,int i):A(d,i){}
  B(folat f,int i,const char* c):A(f,i,e){}
  //......等等好多个和基类构造函数对应的构造函数
};

C++11的继承构造:

struct A
{
  A(int i) {}
  A(double d,int i){}
  A(float f,int i,const char* c){}
  //...等等系列的构造函数版本
};
struct B:A
{
  using A::A;
  //关于基类各构造函数的继承一句话搞定
  //......
};

7. Lambda 表达式

Lambda 表达式,实际上就是提供了一个类似匿名函数的特性,而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。

Lambda 表达式的基本语法如下:

[ caputrue ] ( params ) opt -> ret { body; };
1) capture是捕获列表;
2) params是参数表;(选填)
3) opt是函数选项;可以填mutable,exception,attribute(选填)
mutable说明lambda表达式体内的代码可以修改被捕获的变量,并且可以访问被捕获的对象的non-const方法。
exception说明lambda表达式是否抛出异常以及何种异常。
attribute用来声明属性。
4) ret是返回值类型(拖尾返回类型)。(选填)
5) body是函数体。

捕获列表:lambda表达式的捕获列表精细控制了lambda表达式能够访问的外部变量,以及如何访问这些变量。

  1. []不捕获任何变量。
  2. [&]捕获外部作用域中所有变量,并作为引用在函数体中使用(按引用捕获)。
  3. [=]捕获外部作用域中所有变量,并作为副本在函数体中使用(按值捕获)。注意值捕获的前提是变量可以拷贝,且被捕获的变量在
int a = 0;
    auto func1 = [=]{return a;};

    a +=1;
    cout<<func1()<<endl;

    int b = 0;
    auto  func2 = [&b]{return b;};
    b += 1;
    cout<<func2()<<endl;
    return 0;
  1. [=,&foo]按值捕获外部作用域中所有变量,并按引用捕获foo变量。
  2. [bar]按值捕获bar变量,同时不捕获其他变量。
  3. [this]捕获当前类中的this指针,让lambda表达式拥有和当前类成员函数同样的访问权限。如果已经使用了&或者=,就默认添加此选项。捕获this的目的是可以在lamda中使用当前类的成员函数和成员变量。
class A
{
 public:
     int i_ = 0;

     void func(int x,int y){
         auto x1 = [] { return i_; };                   //error,没有捕获外部变量
         auto x2 = [=] { return i_ + x + y; };          //OK
         auto x3 = [&] { return i_ + x + y; };        //OK
         auto x4 = [this] { return i_; };               //OK
         auto x5 = [this] { return i_ + x + y; };       //error,没有捕获x,y
         auto x6 = [this, x, y] { return i_ + x + y; };     //OK
         auto x7 = [this] { return i_++; };             //OK
};

int a=0 , b=1;
auto f1 = [] { return a; };                         //error,没有捕获外部变量    
auto f2 = [&] { return a++ };                      //OK
auto f3 = [=] { return a; };                        //OK
auto f4 = [=] {return a++; };                       //error,a是以复制方式捕获的,无法修改
auto f5 = [a] { return a+b; };                      //error,没有捕获变量b
auto f6 = [a, &b] { return a + (b++); };                //OK
auto f7 = [=, &b] { return a + (b++); };                //OK

注意f4,虽然按值捕获的变量值均复制一份存储在lambda表达式变量中,修改他们也并不会真正影响到外部,但我们却仍然无法修改它们。如果希望去修改按值捕获的外部变量,需要显示指明lambda表达式为mutable。被mutable修饰的lambda表达式就算没有参数也要写明参数列表。

原因:lambda表达式可以说是就地定义仿函数闭包的“语法糖”。它的捕获列表捕获住的任何外部变量,最终会变为闭包类型的成员变量。按照C++标准,lambda表达式的operator()默认是const的,一个const成员函数是无法修改成员变量的值的。而mutable的作用,就在于取消operator()的const。

int a = 0;
auto f1 = [=] { return a++; };                //error
auto f2 = [=] () mutable { return a++; };       //OK

lambda表达式是不能被赋值的:

auto a = [] { cout << "A" << endl; };
auto b = [] { cout << "B" << endl; };

a = b;   // 非法,lambda无法赋值
auto c = a;   // 合法,生成一个副本

最常用的是在STL算法中,比如你要统计一个数组中满足特定条件的元素数量,通过lambda表达式给出条件,传递给count_if函数:

int value = 3;
vector<int> v {1, 3, 5, 2, 6, 10};
int count = std::count_if(v.beigin(), v.end(), [value](int x) { return x > value; });

再比如你想生成斐波那契数列,然后保存在数组中,此时你可以使用generate函数,并辅助lambda表达式:

vector<int> v(10);
int a = 0;
int b = 1;
std::generate(v.begin(), v.end(), [&a, &b] { int value = b; b = b + a; a = value; return value; });
// 此时v {1, 1, 2, 3, 5, 8, 13, 21, 34, 55}

当需要遍历容器并对每个元素进行操作时:

std::vector<int> v = { 1, 2, 3, 4, 5, 6 };
int even_count = 0;
for_each(v.begin(), v.end(), [&even_count](int val){
    if(!(val & 1)){
        ++ even_count;
    }
});
std::cout << "The number of even is " << even_count << std::endl;

8. 新增容器

std::array 保存在栈内存中,相比堆内存中的 std::vector,我们能够灵活的访问这里面的元素,从而获得更高的性能。

std::array 会在编译时创建一个固定大小的数组,std::array 不能够被隐式的转换成指针,使用 std::array只需指定其类型和大小即可:

std::array<int, 4> arr= {1,2,3,4};

int len = 4;
std::array<int, len> arr = {1,2,3,4}; // 非法, 数组大小参数必须是常量表达式

当我们开始用上了 std::array 时,难免会遇到要将其兼容 C 风格的接口,这里有三种做法:

void foo(int *p, int len) {
    return;
}

std::array<int 4> arr = {1,2,3,4};

// C 风格接口传参
// foo(arr, arr.size());           // 非法, 无法隐式转换
foo(&arr[0], arr.size());
foo(arr.data(), arr.size());

// 使用 `std::sort`
std::sort(arr.begin(), arr.end());

std::forward_list

std::forward_list 是一个列表容器,使用方法和 std::list 基本类似。
和 std::list 的双向链表的实现不同,std::forward_list 使用单向链表进行实现,提供了 O(1) 复杂度的元素插入,不支持快速随机访问(这也是链表的特点),也是标准库容器中唯一一个不提供 size() 方法的容器。当不需要双向迭代时,具有比 std::list 更高的空间利用率。
无序容器
C++11 引入了两组无序容器:
std::unordered_map/std::unordered_multimap 和 std::unordered_set/std::unordered_multiset。

  unordered_set<int>unset;
    unset.insert(5);
    unset.insert(4);
    unset.insert(1);
    unset.insert(2);
    unset.insert(5);
    unset.insert(4);
    for(auto item:unset)
    {
        cout<<item<<endl;
    }
    cout<<"unmset"<<endl;
    unordered_multiset<int>unmset;
    unmset.insert(5);
    unmset.insert(4);
    unmset.insert(1);
    unmset.insert(2);
    unmset.insert(5);
    unmset.insert(4);
    for(auto item:unmset)
    {
        cout<<item<<endl;
    }

unordered_set:内部不出现重复值,相比于set,不同点是不排序

unordered_multiset:内部可出现重复值,并且重复值放在一块,但不排序。

无序容器中的元素是不进行排序的,内部通过 Hash 表实现,插入和搜索元素的平均复杂度为 O(constant)。

元组 std::tuple

元组的使用有三个核心的函数:

std::make_tuple: 构造元组
std::get: 获得元组某个位置的值
std::tie: 元组拆包

合并两个元组,可以通过 std::tuple_cat 来实现。

auto new_tuple = std::tuple_cat(get_student(1), std::move(t));

9. 正则表达式

正则表达式描述了一种字符串匹配的模式。一般使用正则表达式主要是实现下面三个需求:

  1. 检查一个串是否包含某种形式的子串;
  2. 将匹配的子串替换;
  3. 从某个串中取出符合条件的子串。

C++11 提供的正则表达式库操作 std::string 对象,对模式 std::regex (本质是 std::basic_regex)进行初始化,通过 std::regex_match 进行匹配,从而产生 std::smatch (本质是 std::match_results 对象)。

我们通过一个简单的例子来简单介绍这个库的使用。考虑下面的正则表达式:

[a-z]+.txt: 在这个正则表达式中, [a-z] 表示匹配一个小写字母, + 可以使前面的表达式匹配多次,因此 [a-z]+ 能够匹配一个及以上小写字母组成的字符串。在正则表达式中一个 . 表示匹配任意字符,而 . 转义后则表示匹配字符 . ,最后的 txt 表示严格匹配 txt 这三个字母。因此这个正则表达式的所要匹配的内容就是文件名为纯小写字母的文本文件。
std::regex_match 用于匹配字符串和正则表达式,有很多不同的重载形式。最简单的一个形式就是传入std::string 以及一个 std::regex 进行匹配,当匹配成功时,会返回 true,否则返回 false。例如:

#include <iostream>
#include <string>
#include <regex>

int main() {
    std::string fnames[] = {"foo.txt", "bar.txt", "test", "a0.txt", "AAA.txt"};
    // 在 C++ 中 `\` 会被作为字符串内的转义符,为使 `\.` 作为正则表达式传递进去生效,需要对 `\` 进行二次转义,从而有 `\\.`
    std::regex txt_regex("[a-z]+\\.txt");
    for (const auto &fname: fnames)
        std::cout << fname << ": " << std::regex_match(fname, txt_regex) << std::endl;
}

另一种常用的形式就是依次传入 std::string/std::smatch/std::regex 三个参数,其中 std::smatch 的本质其实是 std::match_results,在标准库中, std::smatch 被定义为了 std::match_results,也就是一个子串迭代器类型的 match_results。使用 std::smatch 可以方便的对匹配的结果进行获取,例如:

std::regex base_regex("([a-z]+)\\.txt");
std::smatch base_match;
for(const auto &fname: fnames) {
    if (std::regex_match(fname, base_match, base_regex)) {
        // sub_match 的第一个元素匹配整个字符串
        // sub_match 的第二个元素匹配了第一个括号表达式
        if (base_match.size() == 2) {
            std::string base = base_match[1].str();
            std::cout << "sub-match[0]: " << base_match[0].str() << std::endl;
            std::cout << fname << " sub-match[1]: " << base << std::endl;
        }
    }
}

以上两个代码段的输出结果为:

以上两个代码段的输出结果为:

foo.txt: 1
bar.txt: 1
test: 0
a0.txt: 0
AAA.txt: 0
sub-match[0]: foo.txt
foo.txt sub-match[1]: foo
sub-match[0]: bar.txt
bar.txt sub-match[1]: bar

10.语言级线程支持

std::thread
std::mutex/std::unique_lock
std::future/std::packaged_task
std::condition_variable

代码编译需要使用 -pthread 选项

27.struct和class的区别

在C++中struct得到了很大的扩充,扩充的内容即为原来C语言不支持的。

  • 1.struct可以包括成员函数

  • 2.struct可以实现继承

  • 3.struct可以实现多态
    区别:
    1.默认的继承访问权。
    class默认的是private,strcut默认的是public。到底默认是public继承还是private继承,取决于子类而不是基类。意思是,struct可以继承class,同样class也可以继承struct,那么默认的继承访问权限是看子类到底是用的struct还是class。如下:

struct A
{
    int a;//默认访问权限为public
};

struct B: A   //默认公有继承
{
    int b;   //默认访问权限为public
};

class C: A    //私有继承
{
    int c;//默认访问权限为private
};
int main()
{
//    Solution238Test();

    B b;
    cout<<b.a<<endl;
    cout<<b.b<<endl;
    C c;
    cout<<c.c<<endl;  //错误  访问权限为private
    cout<<c.a<<endl;  //错误  默认私有继承,类内可访问,类外不可访问

    return 0;
}

2.默认访问权限。
struct作为数据结构的实现体,它默认的数据访问控制是public的,而class作为对象的实现体,它默认的成员变量访问控制是private的。

3.“class”这个关键字还用于定义模板参数,就像“typename”。但关键字“struct”不用于定义模板参数。

4.class和struct在使用大括号{ }上的区别
1.)class和struct如果定义了构造函数的话,都不能用大括号进行初始化
  2.)如果没有定义构造函数,struct可以用大括号初始化。
  3.)如果没有定义构造函数,且所有成员变量全是public的话,class可以用大括号初始化。

28.C++的struct和union的区别

区别:
1.存储多个元素时,struct会为每个成员分配空间,Union每个成员共用一个存储空间,只能存储最后一个成员的信息。
2.Union只存放被选中的成员,struct每个成员都存在。
3.对Union的不同成员赋值,会对其他成员重写,原来成员的值就不存在了;对struct成员赋值是独立的,互不影响。
参考文章>>
结论:复合数据类型,如union,struct,class的对齐方式为成员中对齐方式最大的成员的对齐方式。

29.const关键字的作用

参考文章>>
1.const修饰变量

  • 变量的值不能变,必须初始化。

2.const修饰指针

  • 默认修饰const左侧的符号,如果没有则修饰右侧
    例如:
int b= 5;
int *const a = &b                  表示指针是常量,但是指向的值可以改变。
int const * c = &b;                                   表示指向的值为常量,但指针不是常量。

3.const修饰形参

  • 表名他是一个输入参数,在函数内部不可以改变其值。

4.const修饰类成员函数

  • 该函数不能改变对象的成员变量
  • 不能调用非const成员函数,因为任何非const成员函数会有修改成员变量的企图
  • 即const类对象只能调用const成员函数
  • const关键字不能与static关键字同时使用,因为static关键字修饰静态成员函数,静态成员函数不含有this指针,即不能实例化,const成员函数必须具体到某一实例。

5.const修饰函数返回值类型

  • 使其返回值不得为左值
    也是用const来修饰返回的指针或引用,保护指针指向的内容或引用的内容不被修改,也常用于运算符重载。归根究底就是使得函数调用表达式不能作为左值。
int a = 0;
int& Ret()
{
    return  a;
}
int  main(int argc, char const *argv[])
{
    Ret() = 6;
    cout<<a<<endl; 
}

6.const修饰成员变量

  • 表示成员变量不能被修改,同时只能在初始化列表中赋值

7.const修饰类对象

  • 对象的任何成员都不能修改
  • 只能调用const成员函数

ASK:类中的所有函数都可以声明为const函数吗。哪些函数不能?

(1)构造函数不能:const修饰函数表示该函数的返回值是const类型的,该返回值只能赋值给同类型的const变量。 const是可以修饰类的成员函数,但是该函数不能修改数据成员。构造函数也属于类的成员函数,但是构造函数是要修改类的成员变量,所以类的构造函数不能申明成const类型的。
(2)静态成员函数不行:static静态成员是属于类的,而不属于某个具体的对象,所有的对象共用static成员。this指针是某个具体对象的地址,因此static成员函数没有this指针。而函数中的const其实就是用来修饰this指针的,意味this指向的内容不可变,所以const不能用来修饰static成员函数
(3)对于那些需要修改数据成员的函数,不能将其定义为const函数。

const与define的区别:

参考文章>>
(1) 编译器处理方式不同

  • define宏是在预处理阶段展开。
  • const常量是编译、运行阶段使用。

(2) 类型和安全检查不同

  • define宏没有类型,不做任何类型检查,仅仅是展开。
  • const常量有具体的类型,在编译阶段会执行类型检查。

(3) 存储方式不同

  • define宏仅仅是展开,有多少地方使用,就展开多少次,宏常量在内存中有若干个备份,占用代码段空间,不会分配内存。
  • const常量会在内存中分配(可以是堆中也可以是栈中)。

(4)const 可以节省空间,避免不必要的内存分配
例如:

#define PI 3.14159           //常量宏 
const doulbe Pi=3.14159;    //此时并未将Pi放入ROM中 ...... 
double i=Pi;                //此时为Pi分配内存,以后不再分配! 
double I=PI;                //编译期间进行宏替换,分配内存 
double j=Pi;                //没有内存分配 
double J=PI;                //再进行宏替换,又一次分配内存! 

const定义常量从汇编的角度来看,只是给出了对应的内存地址,而不是像#define一样给出的是立即数,所以,const定义的常量在程序运行过程中只有一份拷贝(因为是全局的只读变量,存在静态区),而 #define定义的常量在内存中有若干个拷贝。
(4)是否可调试
const常量是可以进行调试的,define不能进行调试,因为在预编译阶段就已经替换掉了。
(5)是否可以再定义
const不能重定义,#define可以通过#undef取消符号的定义,然后重新定义。
(6)条件编译
#define 可以用来做条件编译,const不能。
(7)类成员
const修饰类成员变量,表示该变量不可修改。
define不能定义类成员变量。
(8)define可以定义表达式
参考文章>>

30.初始化列表

1.必须使用初始化列表的三个情况

  • 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
  • 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
  • 没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化。
    2.初始化列表的优点
  • 可以不调用默认构造参数
    3.成员变量的初始化顺序
  • 成员是按照他们在类中出现的顺序进行初始化的,而不是按照他们在初始化列表出现的顺序初始化的,看代码。
    参考文章>>

42 左值引用和右值引用

参考文章>>
声明引用的时候必须初始化,且一旦绑定,不可把引用绑定到其他对象;即引用必须初始化,不能对引用重定义;对引用的一切操作,就相当于对原对象的操作。
1.左值

可以取到地址的值,必须在内存中有实体。

2.右值

临时变量或不能取地址的值。例如常量值,函数返回值,lambda表达式等,不能出现在=左侧。
但也有例外情况,例如 :const修饰的常量不能放在等号左边,但可以取地址,所以此常量即不是左值。

3.左值引用

引用的本质还是靠指针实现的,左值引用相当于给变量起别名,右值引用也是。
左值引用的特点:
1.左值引用只能绑定到左值上,不能绑定到右值上

int & i = 10;

2.const左值引用可以绑定到右值上

const int & i = 10;

3.能将右值引用赋值给左值引用
该左值引用绑定到右值引用指向的对象

int&& iii = 10;
int& ii = iii;      //ii等于10,对ii的改变同样会作用到iii

4.右值引用

右值引用是将引用绑定到一个将亡的值上。
1.右值引用可以绑定右值

int&& r1=10;
	r1=100;

2.右值引用不能绑定左值

	int a=10;
	int&& r2=a; //编译失败:右值引用不能引用左值

3.右值引用本身就是左值

int &&rr1 = 42; //正确,42是右值
int &&rr2 = rr1; //错误,rr1是左值!

5.move()

std::move把你传进来的参数所有的引用都去掉,然后在加上&&,也就是变成右值引用。使用move函数,也就是对move中的参数说,我想直接用你的值,不想再把你拷贝一份,所以把你直接变成右值。

参考文章>>
将一个左值强制转化为右值引用,通过右值引用使用该值,实现移动语义。可以减少一次拷贝,直接使用右值进行构造对象。

	int a = 10;
	int&& ra = move(a);
	return 0;

1.传入参数使用move语义
使用move语义,可以将左值v的资源直接转移到形参,避免由实参到形参所需要的一次拷贝。

vector<int> v = { /*...*/ };
vector<int> w = twice_vector( std::move(v) );

2.返回值使用move语义
将返回值数组的数据直接转移给返回值对象,避免发生复制操作。

vector<int>twice_vector(vector<int>a)
{
  for (auto& e : a)
    e *= 2;
  return std::move(a); //直接将实参数组的数据资源转移给左值,避免临时对象无意义的复制
}

6.移动语义

移动语义概念:将一个对象中资源移动到另外一个对象的方式。可以有效的缓解内存的压力。提高程序的运行效率,因为不需要开辟空间和释放空间。

移动拷贝构造函数:
调用者直接从引用对象中偷取数据,减少一次数据的拷贝,导致原引用对象被清空,实际是一个资源的完全转移。类似于剪切。
相比于普通的拷贝构造函数,移动拷贝构造函数不会再将动态类型的数据复制一份,而是直接用原对象的数据,窃取。

//移动构造函数
//剪切走other的资源。
Holder(Holder&& other)     // 右值引用为函数形参
{
  m_data = other.m_data;   // (1)
  m_size = other.m_size;
  other.m_data = nullptr;  // (2)
  other.m_size = 0;
}
//移动赋值运算符
Holder& operator=(Holder&& other)    // 右值引用为函数形参  
{  
  if (this == &other) return *this;
  delete[] m_data;         // (1)
  m_data = other.m_data;   // (2)
  m_size = other.m_size;
  other.m_data = nullptr;  // (3)
  other.m_size = 0;
  return *this;
}

在下面的拷贝构造中,h1的资源已经全部被转移到h2,没有发生任何拷贝。

int main()
{
  Holder h1(1000);           // h1是一个左值
  Holder h2(std::move(h1));  // 调用移动构造函数
  //Holder h3(std::move(h1)); // 此时h1已无数据,被转移到了h2  (1)
}

右值引用作用总结
(1)替代需要销毁对象的拷贝,提高效率
某些情况下,需要拷贝一个对象然后将其销毁,如:临时类对象的拷贝就要先将旧内存的资源拷贝到新内存,然后释放旧内存,引入右值引用后,就可以让新对象直接使用旧内存并且销毁原对象,这样就减少了一次拷贝,从而提高了运行效率,减少了内存和运算资源的使用;即新对象直接使用右值,例如移动拷贝构造函数,函数返回值,函数参数等,传右值会省去很多拷贝
(2)移动含有不能共享资源的类对象
像IO、unique_ptr这样的类包含不能被共享的资源(如:IO缓冲、指针),因此,这些类对象不能拷贝但可以移动。这种情况,需要先调用std::move将左值强制转换为右值,再进行右值引用。

移动语义文章>>
移动构造函数的注意点:
参数(右值)的符号必须是右值引用符号,即“&&”。
参数(右值)不可以是常量,因为我们需要修改右值。
参数(右值)的资源链接和标记必须修改,否则,右值的析构函数就会释放资源,转移到新对象的资源也就无效了。

43.deltype关键字

参考文章>>

44.shared_ptr直接类构造和用make_shared构造的区别

参考文章>>

struct A;
std::shared_ptr<A> p1 = std::make_shared<A>();
std::shared_ptr<A> p2(new A);

上面两者有什么区别呢? 区别是:std::shared_ptr构造函数会执行两次内存申请,而std::make_shared则执行一次。

std::shared_ptr在实现的时候使用的refcount技术,因此内部会有一个计数器(控制块,用来管理数据)和一个指针,指向数据。因此在执行std::shared_ptr p2(new A)的时候,首先会申请数据的内存,然后申请内控制块,因此是两次内存申请,而std::make_shared()则是只执行一次内存申请,将数据和控制块的申请放到一起。
带来的问题:
因为C++允许参数在计算的时候打乱顺序,使用直接类构造时,因此一个可能的顺序如下:
new A()
std::shared_ptr
此时假设第2步出现异常,则在第一步申请的内存将没处释放了,上面产生内存泄露的本质是当申请数据指针后,没有马上传给std::shared_ptr。

45.模板:全特化与偏特化

模板为什么要特化,因为编译器认为,对于特定的类型,如果你能对某一功能更好的实现,那么就该优先使用更好的。
1:类模板
既可以全特化,也可以偏特化
2.函数模板:
可以全特化,但是不能偏特化,原因:

46.内联函数

被声明为内联函数的函数,编译器会将代码拷贝到函数被调用的地方。对于一些较小的函数,如果频繁调用,可以将其设计为内联函数,省去了执行函数地址转移等操作,占用系统资源更少,执行效率更高。如果调用内联函数的地方太多,就会造成代码膨胀,因为编译器会把每个调用内联函数的位置都拷贝一份函数实现嵌入其中,重复的嵌入。

类似于拷贝一个对象的地址或者拷贝该对象本身。

以下情况不宜使用内联
(1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
(2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。

47.move函数

在C++11及之后的标准中,引入了“移动语义”以及相关的std::move函数。移动语义允许资源(如动态分配的内存、文件句柄等)从一个对象转移到另一个对象,而不是复制。这样做的主要目的是优化性能和资源管理。

std::move并不是真的“移动”对象,它只是将其转换为右值引用(rvalue reference)。右值引用允许一个对象的资源被“偷走”,而不需要复制。这在处理临时对象或明确表示对象不再需要使用时特别有用。
1.转移所有权

#include <vector>
#include <iostream>

std::vector<int> createLargeVector() 
{
    std::vector<int> v(1000000, 42); // Large vector
    return v; // Return by value
}

int main() 
{
    std::vector<int> vec = std::move(createLargeVector()); // Move vector instead of copying
    std::cout << "Vector size: " << vec.size() << std::endl;
    return 0;
}

BG
右值引用
右值引用(rvalue reference)就是必须绑定到右值的引用,主要包括无名对象、表达式、字面量。通过 && 来获得右值的引用。
简单理解,右值引用就是临时对象的引用,但临时对象并不一定是右值,而是要立刻销毁的临时对象才是右值对象。比如函数内定义的有名对象是临时对象,并不是立刻销毁。

右值引用特性

  • 1)右值引用只能绑定到一个将要销毁的对象。可以自由地将一个右值引用的资源“移动”到另一个对象上;
  • 2)类似于左值引用,右值引用也是一个对象的别名;

右值引用和左值引用的区别
左值引用(lvalue reference)是我们熟悉的常规引用,为了区分右值引用而提出。特点是不能将左值引用绑定到1)要求转换的表达式;2)字面常量;3)返回右值的表达式;

int a = 2;
int &i = a * 2; // 错误:临时计算结果a * 2 是右值,不能绑定到左值引用
const int& ii = a * 2; // 正确:可以将一个const引用绑定到一个右值上
int &&r = a * 2; // 正确:std::move将左值a转换成了右值,能绑定到右值引用

int &i1 = 42; // 错误:42是字面常量,不能绑定到左值引用
int &&r1 = 42; // 正确:42是字面常量,能绑定到右值引用

int &i2 = std::move(a); // 错误:std::move将左值a转换成了右值,不能绑定到左值引用
int &&r2 = std::move(a); // 正确:std::move将左值a转换成了右值,能绑定到右值引

注意:可以将一个const引用(不论const &,还是const &&)绑定到一个右值上

左值和右值最明显的区别是:左值有持久的状态,不会立即销毁;右值要么是字面常量,要么是表达式求值过程中创建的临时对象。

变量是左值
变量是左值,不能将一个右值引用直接绑定到一个变量上,即使变量是右值引用类型。

int a = 42;
int &&rr1 = 42; // 正确:字面常量是右值
int &&rr2 = a;  // 错误:变量a是左值
int &&rr3 = rr1; // 错误:右值引用rr1是左值

std::move函数

不能将一个右值引用绑定到一个左值上,但可以通过调用std::move函数,将左值转换为对应的右值引用类型。

int &&rr1 = 42;
int &&rr4 = rr1; // 错误:不能将右值引用绑定到另一个右值引用
int &&rr5 = std::move(rr1); // OK

调用move意味着承诺:除对rr1赋值或销毁它之外,不能再使用它。

移动构造函数和移动赋值运算符

移动构造函数(又称move constructor)和移动赋值运算符(又称move assignment运算符),类似于copy函数(copy构造函数,copy assignment运算符),不过前2个函数是从给定对象“窃取”资源,而非拷贝资源。

除了完成资源移动,move constructor还必须确保移动后源对象处于这样的状态:销毁源对象是无害的。
一旦资源移动完成后,资源不再属于源对象而是属于新创建的对象,源对象必须不再指向被移动的资源。

例,为StrVec类定义move constructor,实现从一个StrVec到另一个StrVec的元素move而非copy:

class StrVec
{
public:
	StrVec(const StrVec &s); // copy constructor
	StrVec(StrVec &&s) noexcept; // move constructor
	...
private:
	string *elements;
	string *first_free;
	string *cap;
};

StrVec::StrVec(StrVec &&s) noexcept // move操作不应抛出任何异常
// 成员初始化器接管s中的资源
	: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
	// 令s进入这一的状态 -- 对齐运行析构函数是安全的
	s.elements = s.first_free = s.cap = nullptr;
}

move构造函数中,新创建对象成员初始化器接管了源对象中的资源,并将源对象指向资源的指针都置空,就完成了资源的移动操作。源对象析构时,资源并不会被释放,因此新对象使用资源是安全的。
noexcept 表明该函数不抛出任何异常。

移动操作、标准库容器和异常
因为移动操作“窃取”资源,通常不分配任何资源。因此移动操作通常不会抛出异常。既然如此,为什么需要指明noexcept呢?
这是因为,除非编译器知道我们的move构造函数不会抛出异常,否则会认为移动我们的类对象可能抛出异常,并且为了处理这种可能而做一些额外工作。因此,如果确认不会抛出异常,就用noexcept显式指出。

TIPS
不抛出异常的move构造函数和move assignment运算符必须标记为noexcept。

移动操作通常不抛出异常,但不代表不能抛出异常,而且标准库容器能对异常发生时自身的行为提供保障。比如,vector保证,调入push_back发生异常(如内存不够),vector自身不会改变。

为了避免这种潜在问题,除非vector知道元素类型的move构造函数不会抛出异常,否则,在重新分配内存的过程中,必须用copy构造函数而非move构造函数。
如果希望在vector重新分配内存这类情况下,对我们自定义类型的对象进行move而非copy,就必须显式告诉标准库我们的移动构造函数可以安全使用。

简而言之:move构造函数如果可能抛出异常,就使用copy构造函数构造对象。如果move构造函数不抛出异常,就用noexcept显式声明。

移动赋值运算符(move assignment)
move assignment执行与析构函数和move构造函数相同的工作。如果我们的move assignment运算符不抛出任何异常,就应该标记为noexcept。
定义move assignment三步:

  • 释放当前对象已有资源;
  • 接管源对象的资源;
  • 置源对象为可析构状态;
class StrVec
{
public:
	...
	StrVec& operator=(StrVec &&rhs) noexcept; // move assignment
	...
private:
	string *elements;
	string *first_free;
	string *cap;
};

StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{
	if (this != &rhs) {// 避免自移动,因为move返回结果可能是对象自身
		// 释放this对象已有元素, 相当于调用this->~StrVec
		free(); 
		// 从rhs接管资源
		elements = rhs.elements;
		first_free = rhs.first_free;
		cap = rhs.cap;

		// 将rhs置于可析构状态
		elements = first_free = cap = nullptr;
	}
	return *this;
}

48.完美转发

STL

1.介绍常用STL容器

顺序性容器

1.vector:
特点:

  • vector是一种动态数组,在内存中具有连续的存储空间,支持快速随机访问
  • 由于具有连续的存储空间,所以在插入和删除操作方面,效率比较慢。
  • 在push_back的过程中,若发现分配的内存空间不足,则重新分配一段连续的内存空间,其大小是现在连续空间的2倍,再将原先空间中的元素复制到新的空间中,性能消耗比较大。
    实现原理
  • 为降低二次分配时的成本,vector实际配置的大小可能比用户需求的要更大,即容量。
  • vector采用的数据结构是线性的连续空间。
  • vector在增加元素时,如果超过自身的最大容量,vector将自身的容量扩充为原来的两倍:1.重新申请两倍于原来大小的空间.2.将现在的元素移动到新的空间。3.释放旧空间。
  • vector空间一旦被重新配置,原来的迭代器都失效了,因为vector的实际地址已经改变了。
    效率
    插入删除都是O1.
    在这里插入图片描述
头部插入删除:O(N)
尾部插入删除:O(1)
中间插入删除:O(N)
查找:O(N)

适用场景

优点:支持随机储存,查询效率高
缺点:在头部和中间插入删除元素效率低,需要移动内存
适用场景:适用于元素结构简单,变化小,并且频繁随机访问的场景

2.deque 双端队列:
参考文章>>
是一种优化了的、对序列两端元素进行添加和删除操作的基本序列容器。它允许较为快速地随机访问,但它不像vector 把所有的对象保存在一块连续的内存块,而是采用多个连续的存储块,并且在一个映射结构中保存对这些块及其顺序的跟踪。向deque 两端添加或删除元素的开销很小。它不需要重新分配空间,所以向末端增加元素比vector 更有效。

特点:

  • deque和vector类似,支持快速随机访问。

  • 二者最大的区别在于,vector只能在末端插入数据,而deque支持双端插入数据。

  • deque没有所谓的容量概念,deque的内存空间分布是小片的连续,小片间用链表相连,实际上内部有一个map(不是stl的map)的指针。deque空间的重新分配要比vector快,重新分配空间后,原有的元素是不需要拷贝的。随时可以增加一段新的空间并链接起来。

  • 1)按照页或者块来分配存储器,每页包含固定数目的元素

  • 2)支持随机储存

  • 3)头尾删除插入元素效率高
    原理:

  • deque维护一个段连续的空间称为map,map由node即节点组成,map中的每个node对应着一段连续的内存空间,这段内存空间是真实存储元素的地方,每个node会记录这段连续空间的头部地址,尾部地址以及当前元素所在地址。

  • deque还会维护一个start指针和finish指针,分别指向第一缓冲区的第一个元素以及最后一个缓冲区的最后一个元素。

  • 当第一缓冲区放满或者最后一个缓冲区放满时,deque会再寻找一个连续的空间并在map中添加相应的node。

  • deque是分段连续空间。维持”整体连续“假象的任务,落在迭代器的operator++和operator—两个运算子上。deque的迭代器首先必须能指出分段连续空间在哪里,其次它必须能够判断自己是否处于其所在缓冲区的边缘,如果是,一旦前进或后退时就必须跳跃至下一个或上一个缓冲区。
    在这里插入图片描述

  • 由于分段数组的大小是固定的,并且他们的首地址被连续存放在索引数组中,因此可以对其进行随机访问,但是效率比vector低很多

  • 向两端加入新元素时,如果这一端的分段数组未满,则可以直接加入,如果这一端的分段数组已满,只需要创建新的分段数组,并把该分段数组的地址加入到索引数组中即可,这样就不需要对已有的元素进行移动,因此在双端队列的两端加入新的元素都具有较高的效率

  • 当双端队列删除首尾元素时,也不需要移动,所以效率也比较高

  • 双端队列中间插入元素时,需要将插入点到某一端之间的所有元素向容器的这一端移动,因此向中间插入元素的效率较低,而且往往插入的位置越靠近中间,效率越低,删除队列中元素时,情况也类似

效率
在这里插入图片描述

头部尾部插入删除:O(1)
中间插入删除:O(N)
查找:O(N)

适用场景
优点:头部尾部插入删除快
缺点:不适合中间插入删除操作,占用内存多
适用场景:适用于既要频繁随机储存,又要关心两端数据的插入与删除操作,并且不需要频繁对中间元素进行插入删除的场景

3.list:
参考文章>>
元素存放在堆中,每个元素都是放在一块内存中,他的内存空间可以是不连续的,通过指针来进行数据的访问,这个特点使得它的随机存取变得非常没有效率,因此它没有提供[]操作符的重载。但是由于链表的特点,它可以很有效率的支持任意地方的删除和插入操作。

特点:

  • 1.list是可以在常数范围内在任意位置进行插入和删除的序列式容器,并且该容器可以前后双向迭代。
  • 2.list的头部、中间插入不需要挪动数据,效率较高,均为O(1)。
  • 3.list插入数据是新增节点,不需要扩容。因此节省了空间。
  • 4.底层节点动态开辟,小节点容易造成内存碎片,空间利用率低,缓存利用率低。
  • 5.与其他序列式容器相比,list和forward_list最大的缺陷是不支持直接任意位置的随机访问,比如:要访问list的第6个元素,必须从已知的位置(比如头部或者尾部)迭代到该位置,在这段位置上迭代需要线性的时间开销;list还需要一些额外的空间,以保存每个节点的相关联信息(对于存储类型较小元素的大list来说这可能是一个重要的因素)
  • 6.没有空间预留习惯,所以每分配一个元素都会从内存中分配,每删除一个元素都会释放它占用的内存;
  • 7.在哪里添加删除元素性能都很高,不需要移动内存,当然也不需要对每个元素都进行构造与析构了,所以常用来做随机插入和删除操作容器;
  • 8.访问开始和最后两个元素最快,其他元素的访问时间都是O(n);
  • 9.如果经常进行添加和删除操作并且不经常随机访问的话,使用list。

原理:

  • list的底层是双向链表结构,双向链表中每个元素存储在互不相关的独立节点中,在节点中通过指针指向其前一个元素和后一个元素。
  • list是一个双向链表,因此它的内存空间是可以不连续的,通过指针来进行数据的访问,这使list的随机存储变得非常低效,因此list没有提供[]操作符的重载。但list可以很好地支持任意地方的插入和删除,只需移动相应的指针即可。
    效率
    在这里插入图片描述

关联容器

1.map:
map是一种关联容器,该容器用唯一的关键字来映射相应的值,即具有key-value功能。map内部自建一棵红黑树(一种自平衡二叉树),这棵树具有数据自动排序的功能,所以在map内部所有的数据都是有序的,以二叉树的形式进行组织。
特点:

  • 查询效率O(log n)
  • 1)元素为键值对形式,键和值可以是任意类型
  • 2)因为key有序,所以可以通过二分对key进行快速查找
  • 3)增加和删除结点对迭代器的影响很小,除了当前结点是迭代器指向的结点
  • 4)对于迭代器来说,可以修改值,但是不能修改key

实现原理:
标准的STLmap以RB-tree为底层机制,几乎所有的map行为,都只是调用了RB-tree的操作行为而已。RB-tree的节点是一个pair,pair的第一个元素视为键值,第二个元素视为实值。
RB-tree:不仅是一个二叉搜索树,而且满足以下规则:

1.每个节点不是红色就是黑色。
2.根节点为黑色。
3.如果节点为红色,其子节点必须为黑色。
4.所有叶节点都为黑色。
5.红黑树从根节点到任意一个叶节点的路径上黑色节点的数目相同。

新增节点必须为红,新增节点之父必须为黑,新节点根据二叉搜索树的规则插入后不符合上述条件,就必须调整颜色并旋转树形。

适用场景
优点:适用平衡二叉树实现,便于元素查找,而且可以把值映射到另外一个值,可以创建字典
缺点:每次插入都需要调整红黑树,对效率存在一定的影响
适用场景:适用于需要储存一个字典,并要求方便的根据key找value的场景

2.unordered_map:
底层实现:hashtable + bucket
hashtable原理:
使用一个下标范围比较大的数组来存储元素。可以设计一个函数(哈希函数,也叫做散列函数),使得每个元素的关键字都与一个函数值(即数组下标,hash值)相对应,于是用这个数组单元来存储这个元素;即将key经哈希函数运算后,对应到这个数组的某个索引上,在这个索引的位置存储value。
哈希冲突:不同的key经过哈希函数运算后,得到了一个相同的索引,即存储数组的这个存储单元发生存储二义性或者多义性。
桶:如果发生了哈希冲突,则将元素按顺序链在数组中这个存储单元中。
由于 unordered_map 内部采用 hashtable 的数据结构存储,所以,每个特定的 key 会通过一些特定的哈希运算(哈希函数)映射到一个特定的位置,我们知道,hashtable 是可能存在冲突的,在同一个位置的元素会按顺序链在后面。所以把这个位置称为一个 bucket 是十分形象的,每个哈希桶中可能没有元素,也可能有多个元素。
效率
插入删除复杂度 O1, 如果退化成一个链表,其复杂度位O(n)

6.set:
set也是一种关联性容器,它同map一样,底层使用红黑树实现,插入删除操作时仅仅移动指针即可,不涉及内存的移动和拷贝,所以效率比较高。set中的元素都是唯一的,而且默认情况下会对元素进行升序排列。所以在set中,不能直接改变元素值,因为那样会打乱原本正确的顺序,要改变元素值必须先删除旧元素,再插入新元素。不提供直接存取元素的任何操作函数,只能通过迭代器进行间接存取。
原理:
特点
1)元素有序
2)无重复元素
3)插入删除操作的效率比序列容器高,因为对于关联容器来说,不需要做内存的拷贝和内存的移动
效率
增删改查近似:O(log N)

适用场景
优点:使用平衡二叉树实现,便于元素查找,而且保持了元素的唯一性,支持自动排序
缺点:每次插入元素,都需要调整红黑树,效率有一定的影响
适用场景:适用与经常查找一个元素是否在某集群中并且不要排序的场景

7.multiset:
其特性和用法与set完全相同,唯一的差别在于它允许键值重复,因为它的插入操作采用的是底层机制RB-tree的insert_equal(),而非insert_unique()
效率
增删改查近似:O(log N)

容器适配器

8.queue:

queue是一个队列,实现先进先出功能,queue不是标准的STL容器,却以标准的STL容器为基础。queue是在deque的基础上封装的。之所以选择deque而不选择vector是因为deque在删除元素的时候释放空间,同时在重新申请空间的时候无需拷贝所有元素。

9.stack:
stack是实现先进后出的功能,和queue一样,也是内部封装了deque.
特点:

  • 1.没有迭代器

2.STL分为哪几部分

1.容器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-boEWhIGj-1627459498229)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20210706154509159.png)]

img

2.迭代器

迭代器是类似指针的实体,用于访问容器中的各个元素。

迭代器从一个元素顺序移动到另一个元素。此过程称为迭代容器。

迭代器主要包含两个函数:

begin():成员函数begin()返回向量的第一个元素的迭代器。

end():成员函数end()返回一个迭代器到容器的最后一个元素。

3.算法

算法是在各种容器中用于处理其内容的函数。

img

4.函数对象

函数对象是一个包含在类中的函数,因此它看起来像一个对象。函数对象通过使用面向对象的特征(例如泛型编程)来扩展常规函数的特征。因此,我们可以说函数对象是一个智能指针,它比普通函数有许多优点。

2.STL六大组件

STL提供六大组件,彼此可以组合套用:
1.容器( containers): 各种数据结构,如vector, list, deque, set, map,用来存放数据, STL 容器是一种 class template。就体积而言,这一部份很像冰山在海面下的比率。
2.算法( algorithms): 各种常用算法如sort, search, copy, erase等, STL 算法是一种 function template。
3.迭代器( iterators): 扮演容器与算法之间的胶着剂,是所谓的「泛型指标」。共有五种类型,以及其它衍生变化。迭代器是一种将operator*, operator->, operator++, operator–等指针相关操作予以多元化的 class template。所有STL容器都附带有自己专属的迭代器,只有容器设计者才知道如何巡访自己的元素。原生指针( nativepointer)也是一种迭代器。
4.仿函数( functors): 行为类似函数,可做为算法的某种策略( policy)。仿函数是一种重载了 operator()的 class 或class template,一般函数指针可视为狭义的仿函数。
5.适配器( adapters): 一种用来修饰容器( containers)或仿函式( functors)或迭代器( iterators)接口的东西。例如 STL 提供的 queue 和stack,虽然看似容器,其实只能算是一种容器适配器,因为它们的底部完全借重 deque,所有动作都由底层的 deque供应。改变functor接口者,称为function adapter,改变container接口者,称为container adapter,改变iterator界面者,称为iterator adapter。
6.配置器( allocators): 负责空间配置与管理,配置器是一个实现了动态空间配置、空间管理、空间释放的 class template。

3.vector的扩容原理

参考文章>>
当容量不够用时,vector会进行二倍扩容,使用for循环拷贝。
memcpy特点:memcpy是内存的二进制格式拷贝,将一段内存空间中内容原封不动的拷贝到另外一段内存空间中,是一种浅拷贝。适用于POD数据类型。
POD数据:字面意思,普通的,旧的数据类型。通俗的讲,一个类或结构体通过二进制拷贝后还能保持其数据不变,那么它就是一个POD类型。

  • 对于内置类型,比如int类型,使用for循环拷贝和memcpy没有区别。
  • 对于自定义类,类内部有管理资源,如果使用memcpy进行浅拷贝,则会发生内存泄漏或者同一块地址会发生多次析构,导致异常。

所以vector内部不是采用的memcpy拷贝,如果使用memcpy,一些自定义类就无法拷贝。

参考文章>>

52.STL迭代器失效的几种情况

参考文章>>
参考文章>>
1.vector迭代器失效

1.插入新元素引起扩容
导致迭代器全部失效。
2.插入一个元素但没导致扩容
插入元素之前的迭代器有效,插入元素位置后的迭代器全部失效,end()返回的迭代器失效。
3.删除一个元素
删除的元素的迭代器及其后边的迭代器全部失效。

vector<int> vec;
    for (int i = 0; i < 5; i++)
    {
        vec.push_back(i);
    }
    vector<int>::iterator it;
    cout << sizeof(it) << endl;
    for (it = vec.begin(); it != vec.end(); )
    {
        if (*it==3)
            vec.erase(it);//更新迭代器it
    }
    for (it = vec.begin(); it != vec.end(); it++)
        cout << *it << " ";
    cout << endl;

解决方案:erase会返回下一个迭代器的位置,所以当符合删除条件时直接更新迭代器即可,不符合条件时迭代器自增。

 vector<int> vec;
    for (int i = 0; i < 5; i++)
    {
        vec.push_back(i);
    }
    vector<int>::iterator it;
    cout << sizeof(it) << endl;
    for (it = vec.begin(); it != vec.end(); )
    {
        if (*it==3)
        {
            it = vec.erase(it);//更新迭代器it
        }
        else
        {
            it++;
        }
    }
    for (it = vec.begin(); it != vec.end(); it++)
        cout << *it << " ";
    cout << endl;

2.list迭代器失效

1.list的插入操作不会引起迭代器失效

	int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
    list<int> l(arr, arr + sizeof(arr) / sizeof(arr[0]));
	auto  it = l.begin();
    while (it != l.end())
    {
        // erase()函数执行后,it所指向的节点已被删除,因此it无效,在下一次使用it时,必须先给其赋值
        if (*it ==4)
        {
           l.insert(++it,5);
        }
            ++it;
    }
    it = l.begin();
    while (it != l.end())
    {
        cout<<*it<<endl;
        it ++;
    }

2.删除操作会导致指向被删除节点的迭代器失效

void TestListIterator()
{
        int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
        list<int> l(arr, arr + sizeof(arr) / sizeof(arr[0]));

        auto it = l.begin();
        while (it != l.end())
        {
            // erase()函数执行后,it所指向的节点已被删除,因此it无效,在下一次使用it时,必须先给其赋值
             if (*it ==5)
             {
                 l.erase(it);
             }
            ++it;                   //发生删除操作时  迭代器失效
        }
           it = l.begin();
        while (it != l.end())
        {
            cout<<*it<<endl;
            it ++;
        }
}

解决:更新迭代器

void TestListIterator()
{
        int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
        list<int> l(arr, arr + sizeof(arr) / sizeof(arr[0]));

        auto it = l.begin();
        while (it != l.end())
        {
            // erase()函数执行后,it所指向的节点已被删除,因此it无效,在下一次使用it时,必须先给其赋值
            if (*it ==5)
            {
               it =  l.erase(it);
            }
            else
            {
                ++it;
            }
        }
        it = l.begin();
        while (it != l.end())
        {
            cout<<*it<<endl;
            it ++;
        }
}

3.deque迭代器失效

1.对于deque,插入到除首尾位置之外的任何位置都会导致迭代器、指针和引用都会失效,但是如果在首尾位置添加元素,迭代器会失效,但是指针和引用不会失效
2.如果在首尾之外的任何位置删除元素,那么指向被删除元素外其他元素的迭代器全部失效
3.在其首部或尾部删除元素则只会使指向被删除元素的迭代器失效。

4.关联式容器迭代器失效

map unoredered_map set unoredered_set multiset

对于关联容器(如map, set,multimap,multiset),删除当前的iterator,仅仅会使当前的iterator失效,只要在erase时,递增当前iterator即可。这是因为map之类的容器,使用了红黑树来实现,插入、删除一个结点不会对其他结点造成影响。erase迭代器只是被删元素的迭代器失效,但是返回值为void,所以要采用erase(iter++)的方式删除迭代器。

map是关联容器,以红黑树或者平衡二叉树组织数据,虽然删除了一个元素,整棵树也会调整,以符合红黑树或者二叉树的规范,但是单个节点在内存中的地址没有变化,变化的是各节点之间的指向关系。

首先来看一下map迭代器失效的一个例子:

void mapTest()
{
    map<int, int>m;
    for (int i = 0; i < 10; i++)
    {
        m.insert(make_pair(i, i + 1));
    }
    map<int, int>::iterator it;
    for (it = m.begin(); it != m.end();it ++)
    {
        if ((it->first)>5)
            m.erase(it);
    }
     it = m.begin();
    while (it!=m.end())
    {
        cout<<(*it).first<<endl;
        it ++;
    }
}

解决方案:更新迭代器
erase返回被删除元素的下一个元素的迭代器,此时不需要自增操作。

void mapTest()
{
    map<int, int>m;
    for (int i = 0; i < 10; i++)
    {
        m.insert(make_pair(i, i + 1));
    }
    map<int, int>::iterator it;
    for (it = m.begin(); it != m.end();)
    {
        if ((it->first)>5)
            it= m.erase(it);
        else
        {
            it ++;
        }
    }
     it = m.begin();
    while (it!=m.end())
    {
        cout<<(*it).first<<endl;
        it ++;
    }
}

53.map存放结构体排序

map直接把类作为value会报错,需要在类内重载"<"
参考文章>>

54.push_back和emplace_back

参考文章>>
参考文章>>
基本的区别:对于自定义类型的数据,push_back一个对象时,会调用构造函数创建一个临时对象,然后再调用移动构造函数存入容器内,总计一次构造和一次拷贝构造,释放空间时,也会调用两次析构函数。对于emplace_back,传入右值,可以通过完美转发实现原地构造,只需要调用一次构造函数,释放时只需要调用一次析构函数。
stl 中的emplace_back 中涉及的知识(完美转发, 移动语义):

class A{
public:

    A(int size) {
        cout<<"constructor"<<endl;
        this->size = size;
        if(size)data = new int[size];
        for (int i = 0; i < size; ++i)data[i] = i;
    }

    A(const A& o) {
        cout<<"copy constructor"<<endl;
        this->size=o.size;
        data = new int[size];
        memcpy(data,o.data,size*sizeof(int));
    }

    A(A &&o) {

        cout<<"move constructor"<<endl;
        data=o.data;
        this->size=o.size;
        o.data=nullptr;
        o.size=0;
    }
    ~A(){delete []data;}
private:

    int *data = nullptr;
    int size = 0;
};
int main()
{
    vector<A> vec;
    vec.emplace_back(10);
}
G:\CSlrn\Mutex\cmake-build-debug\Mutex.exe
constructor
Process finished with exit code 0

emplace_back传入右值,可以通过完美转发将右值直接传递给自定义类型的构造函数。

55 string

1.string a = "aaaaaa"占用的内存大小

string占32个字节,不论赋值与否。不同库中占用大小可能不同,也有4字节、12、28、32字节的。

无论你的string里放多长的字符串,它的sizeof()都是固定的,字符串所占的空间是从堆中动态分配的,与sizeof()无关。我们所用到的 string 类型一般都会是这样实现:

复制代码

class
{
      char *_Ptr;    //指向字符串的指针
      int _Len;      //字符串的长度
      ........
};

数据结构

19.红黑树与各种树

参考文章>>
参考文章>>
1.满二叉树

一棵二叉树的结点要么是叶子结点,要么它有两个子结点(如果一个二叉树的层数为K,且结点总数是(2^k) -1,则它就是满二叉树)

在这里插入图片描述
2.完全二叉树

若设二叉树的深度为k,除第 k 层外,其它各层 (1~k-1) 的结点数都达到最大个数,第k 层所有的结点都连续集中在最左边,这就是完全二叉树。
在这里插入图片描述
3.二叉搜索树

若左子树不空,则左子树上所有节点的值均小于它的根节点的值;
若右子树不空,则右子树上所有节点的值均大于它的根节点的值;
左、右子树也分别为二叉排序树。
在这里插入图片描述
查找时间复杂度
二叉排序树查找在在最坏的情况下,需要的查找时间取决于树的深度:

  • 1.当二叉搜索树接近满二叉树时,其深度为log2n,因此其最坏情况下的查找时间也为log2n,与二分查找数量级相同。
  • 2.当二叉树如下图所示形成单枝树时,其深度为n,最坏情况下查找时间为O(n),与顺序查找属于同一数量级。在这里插入图片描述
    为了保证二叉排序树查找有较高的查找速度,希望该二叉树接近于满二叉树,或者二叉树的每一个节点的左、右子树深度尽量相等,由此出现了平衡二叉树

优点:在二叉搜索树接近满二叉树的时候,查询效率为logN,插入删除操作简单,不需要修改树的结构
缺点:当先后插入有序值时,退化成链表,查询效率O(N)。

4.平衡二叉树

参考文章>>
它或者是一颗空树,或其任一节点的左子树和右子树的深度之差(平衡因子)的绝对值不超过1,且它的左子树和右子树都是一颗平衡二叉树。平衡二叉树必须是二叉搜索树
平衡因子: 右子树高度 - 左子树高度。
完全平衡二叉树结合完全二叉树和平衡二叉树。
在这里插入图片描述
查找时间复杂度
与树的深度相同,为log2n
插入复杂度:每次插入后,新节点一定是叶子节点,所以AVL树在执行每个插入操作时最多需要1次旋转,其时间复杂度在O(logN)左右。
删除复杂度:AVL树在执行删除时代价稍大,删除时先查找O(logN),再遍历从删除节点开始到根节点所有节点的平衡因子,每一次删除操作最多需要O(logN)次旋转,因此执行每个删除操作的时间复杂度需要O(2logN)。==》

优点:查询效率最好最坏都是logN,插入效率logN。
缺点:需要维持严格的平衡条件,删除时需要先查找logN,删除后遍历从删除节点到根节点的每个节点的平衡因子,最多进行logN次旋转,删除效率2logN。

AVL的左旋和右旋
要构造一棵平衡二叉树,Georgii M. Adelson-Velskii 和 Evgenii M. Landis 提出了一种动态保持二叉平衡树的方法,其基本思想是:在构造二叉排序树的时候,每当插入一个节点时,先检查是否因插入节点而破坏了树的平衡性,如果是,则找出其中最小不平衡子树,在保持排序树的前提下,调整最小不平衡子树中各节点之间的连接关系,以达到新的平衡,所以这样的平衡二叉树简称AVL树。其中最小平衡子树是指:离插入节点最近,且平衡因子绝对值大于1的节点作为根节点的子树。

调整最小不平衡子树一般有四种情况
1.单向右旋(LL型): 插入位置为左子树的左子树,以左子树为轴心,进行单次向右旋转。
2.单向左旋(RR型): 插入位置为右子树的右子树,右子树为轴心,进行单次向左旋转。
3.双向旋转先左后右(LR型):插入位置为左子树的右子树,要进行两次旋转,先向左旋转,再向右旋转。
4.双向旋转先右后左(RL型):插入位置为右子树的左子树,进行两次调整,先右旋转再左旋转;处理情况与LR类似。

:平衡因子与类型有很大的关系,需要以离插入节点最近且平衡因子绝对值>1的节点作为根节点的子树进行判定是哪种类型。

5.红黑树

二叉平衡树的严格平衡策略以牺牲建立查找结构(插入,删除操作)的代价,换来了稳定的O(logN) 的查找时间复杂度。红黑树是一种折中策略,即不牺牲太大的建立查找结构的代价,也能保证稳定高效的查找效率

红黑树是一种自平衡的二叉搜索树,红黑树的每个节点上都有存储表示节点的颜色,可以是红色或者黑色。参考文章>>
红黑树的特性

  • 1.每个节点是红色或者黑色。
  • 2.根节点是黑色。
  • 3.每个叶子节点是黑色,叶子节点为空节点(NULL/NIL),是黑色的。(这里将空结点作为一个特殊的节点对待,设定他们必须是黑色的。)。
  • 4.如果一个节点是红色,则它的子节点必须是黑色,即不可能有相邻的红色节点
  • 5.从一个节点该节点的子孙节点的所有路径上包含相同数目的黑节点。

总结为:

  • 根节点是黑色的;
  • 每个叶子节点(NIL节点)都是黑色的;
  • 如果一个节点是红色的,则其两个子节点都是黑色的;
  • 从任意节点到其每个叶子节点的路径上包含相同数量的黑色节点。

与AVL的区别
AVL树是严格的平衡二叉树,平衡条件必须满足,所有节点的左右子树高度差不超过1。
红黑树是一种弱平衡二叉树,保证从任一结点出发到子孙节点的路径上包含的黑色节点数目相同,这使得从任意结点出发到其子节点,确保没有一条路径会比其它路径长出两倍。
在这里插入图片描述
查找时间复杂度:红黑树虽然不像AVL一样是严格平衡的,但平衡性能还是要比BST要好。其查找代价基本维持在O(logN)左右。
插入复杂度:RBT插入结点时,需要旋转操作和变色操作。但由于只需要保证RBT基本平衡就可以了。因此插入结点最多只需要2次旋转,这一点和AVL的插入操作一样。虽然变色操作需要O(logN),但是变色操作十分简单,代价很小。
删除复杂度:RBT的删除操作代价要比AVL要好的多,删除一个结点最多只需要3次旋转操作。
优点:查找,插入,删除效率都比较高。
缺点:节点存储的信息比较多,消耗空间比较大。
红黑树的基本操作
左旋和右旋:参考文章>>
1.左旋和右旋
目的:在添加和删除节点后,保持红黑树的特性。
2. 添加和删除
新增节点必须为红色。

红黑树相较于AVL树的优势:

  • 插入和删除操作的效率红黑树相较于AVL树在插入和删除操作上更高效。AVL树在插入或删除节点后,可能需要进行多次旋转操作来恢复平衡,这可能导致更多的节点移动。而红黑树通过颜色调整和旋转操作来维护平衡,旋转操作的次数相对较少,因此在插入和删除操作时,红黑树的效率更高。
  • 空间开销更小红黑树相较于AVL树在空间开销上更优。AVL树需要维护每个节点的平衡因子,这需要额外的空间开销。而红黑树只需要一个位来存储节点的颜色属性(红色或黑色),因此相对于AVL树,红黑树需要更少的额外空间。
  • 查询操作的效率红黑树和AVL树在查询操作上具有相似的效率。由于红黑树和AVL树都是二叉搜索树,它们具有相同的查找复杂度,即O(log n)。因此,在查询操作方面,红黑树并没有明显的优势。

总结为
因为AVL树比红黑树更加平衡,但AVL树在插入和删除的时候也会存在大量的旋转操作。所以当你的应用涉及到频繁的插入和删除操作,切记放弃AVL树,选择性能更好的红黑树。

红黑树不追求”完全平衡”,即不像AVL那样要求节点的 |balFact| <= 1,它只要求部分达到平衡,但是提出了为节点增加颜色,红黑是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决,而AVL是严格平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。

就插入节点导致树失衡的情况,AVL和RB-Tree都是非常多两次树旋转来实现复衡rebalance,旋转的量级是O(1)
删除节点导致失衡,AVL需要维护从被删除节点到根节点root这条路径上所有节点的平衡,旋转的量级为O(logN),而RB-Tree非常多只需要旋转3次实现复衡,只需O(1),所以说RB-Tree删除节点的rebalance的效率更高,开销更小!

红黑树如何保证自平衡

红黑树能自平衡,它靠的是什么?三种操作:左旋、右旋和变色。

左旋:以某个结点作为支点(旋转结点:P),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点(P)的右子结点,左子结点保持不变。如图3。
右旋:以某个结点作为支点(旋转结点:P),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。如图4。
变色:结点的颜色由红变黑或由黑变红。

红黑树自平衡

20.二叉树的应用

1.C++STL的map set都是用红黑树实现的。
2.Java的TreeSet和TreeMap使用红黑树实现的。
3.IO复用中,epoll存储已连接的通信信息使用红黑树。
4.B数和B+树在文件系统、数据库索引用的比较多。
5.平衡二叉树能实现快速查找。
6.Linux底层的CFS进程调度算法中,vruntime利用红黑树来进行存储 >>>

21.多叉树实现

===>

#include <iostream>
#include <vector>
using namespace std;
#ifndef DBM_MTREE_H
#define DBM_MTREE_H
typedef int T;
typedef struct MNode {
    T element;
    vector<MNode*> children;
    MNode *Parent;
} MNode;

class MTree {
private:
    MNode *root;
public:
    void init(MNode *root);
    void putChild(MNode* node,MNode* parent);
    void putChildren(vector<MNode*> nodes, MNode *parent);
    void tranversal(MNode *root);
    void tranversal();
    int getMaxDepth(MNode *root,vector<MNode*> nodes);
};

void MTree::init(MNode *root) { this->root = root; }

void MTree::putChild(MNode *node, MNode *parent) {
    parent->children.push_back(node);
    node->Parent = parent;
}

void MTree::putChildren(vector<MNode *> nodes, MNode *parent) {
    for (int i = 0; i < nodes.size(); ++i) {
        putChild(nodes[i], parent);
    }
}

void MTree::tranversal() {
    this->tranversal(this->root);
}

void MTree::tranversal(MNode *root) {
    vector<MNode *> nodes = root->children;
    for (int i = 0; i < nodes.size(); ++i) {
        if (nodes[i]->children.size() > 0)
            tranversal(nodes[i]);
        else
            cout << nodes[i]->element << ",";
    }
    cout << root->element << ",";
}

int MTree::getMaxDepth(MNode *root,vector<MNode*> nodes) {
    auto iResult = 0;

    return iResult;
}

22.二叉树的遍历

二叉树遍历

中序遍历为什么是左中右?

55.栈的应用

1.符号匹配
2.进制转换
3.二叉树迭代遍历

56.布隆过滤器

===>
布隆过滤器算法主要思想就是利用 n 个哈希函数进行 hash 过后,得到不同的哈希值,根据 hash 映射到数组(这个数组的长度可能会很长很长)的不同的索引位置上,然后将相应的索引位上的值设置为1。

判断该元素是否出现在集合中,就是利用k个不同的哈希函数计算哈希值,看哈希值对应相应索引位置上面的值是否是1,如果有1个不是1,说明该元素不存在在集合中。

但是也有可能判断元素在集合中,但是元素不在,这个元素所有索引位置上面的1都是别的元素设置的,这就导致一定的误判几率(这就是为什么上面是活可能在一个集合中的根本原因,因为会存在一定的 hash 冲突)。

注意:误判率越低,相应的性能就会越低。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Michael.Scofield

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

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

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

打赏作者

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

抵扣说明:

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

余额充值