面经——C++基础知识

目录

1. 基本语言

1.1 static关键字

1.1.1 全局静态变量

在全局变量前加上关键字static全局变量就定义成一个全局静态变量.

内存中的位置:全局数据区,在整个程序运行期间一直存在。

初始化:未经初始化的全局静态变量会被自动初始化为0(自动对象的值是任意的,除非他被显式初始化);

作用域:全局静态变量在声明他的文件之外是不可见的,准确地说是从定义之处开始,到文件结尾

1.1.2 局部静态变量

在局部变量之前加上关键字static局部变量就成为一个局部静态变量

内存中的位置:静态存储区

初始化:未经初始化的局部静态变量会被自动初始化为0(自动对象的值是任意的,除非他被显式初始化);

作用域:作用域仍为局部作用域,当定义它的函数或者语句块结束的时候,作用域结束。但是当局部静态变量离开作用域后,并没有销毁,而是仍然驻留在内存当中,只不过我们不能再对它进行访问,直到该函数再次被调用,并且值不变

1.1.3 静态函数

在函数返回类型前加static,函数就定义为静态函数。函数的定义和声明在默认情况下都是extern的,但静态函数只是在声明他的文件当中可见,不能被其他文件所用。

函数的实现使用static修饰,那么这个函数只可在本cpp内使用,不会同其他cpp中的同名函数引起冲突;

warning:

  • 不要在头文件中声明static的全局函数
  • 不要在cpp内声明非static的全局函数,如果你要在多个cpp中复用该函数,就把它的声明提到头文件里去,否则cpp内部声明需加上static修饰;

特点:静态函数与普通函数不同,它只能在声明它的文件当中可见,不能被其他文件可用。静态函数只能调用静态变量为什么

1.1.4 类的静态成员

对于非静态数据成员,每个类对象都有自己的拷贝。而静态数据成员被当做是类的成员,无论这个类被定义了多少个,静态数据成员都只有一份拷贝,为该类型的所有对象所共享(包括其派生类)。所以,静态数据成员的值对每个对象都是一样的,它的值可以更新。

因为静态数据成员在全局数据区分配内存,属于本类的所有对象共享,所以它不属于特定的类对象,在没有产生类对象前就可以使用
静态数据成员不是由构造函数初始化的。
注意:一般来说,静态成员变量在类的内部声明,声明时直接通过static关键字修饰,但是不能在类的内部初始化静态成员,必须在类的外部定义和初始化每个静态成员,一个静态数据成员只能被定义一次。
静态成员变量不占用类的大小,而是在类外(全局数据区)单独分配空间

#include <stdio.h>

class Test
{
private:
    static int c;
};

int Test::c = 0; //在类外初始化类静态成员的方法

1.1.5 类的静态函数

与普通的成员函数相比,静态成员函数由于不是与任何的对象相联系,因此它不具有this指针。从这个意义上来说,它无法访问属于类对象的非静态数据成员,也无法访问非静态成员函数,只能调静态成员变量和静态成员函数。

静态成员函数和静态数据成员一样,它们都属于类的静态成员,它们都不是对象成员。因此,对静态成员的引用不需要用对象名。

和其他成员函数一样,既可以在类的内部也可以在类的外部定义静态成员函数。(注意:当在类的外部定义静态成员时,不能重复static关键字,该关键字只能出现在类内部的声明语句中)。

在静态成员函数的实现中不能直接引用类中说明的非静态成员,可以引用类中说明的静态成员(这点非常重要)。如果静态成员函数中要引用非静态成员时,可通过对象来引用。从中可看出,调用静态成员函数使用如下格式:<类名>::<静态成员函数名>(<参数表>);
类的静态成员变量和静态成员函数

练习

using namespace std;
class CRectangle {
private:
	int w, h;

public:
	static int totalArea;
	static int totalNumber;
	CRectangle(int w_, int h_);
	~CRectangle();
	static void PrintTotal();
};
CRectangle::CRectangle(int w_, int h_)
{
	w = w_; h = h_;
	totalNumber++;  //有对象生成则增加总数
	totalArea += w * h;  //有对象生成则增加总面积
}
CRectangle::~CRectangle()
{
	totalNumber--;  //有对象消亡则减少总数
	totalArea -= w * h;  //有对象消亡则减少总而积
}
void CRectangle::PrintTotal()
{
	cout << totalNumber << "," << totalArea << endl;
}
int CRectangle::totalNumber = 0;
int CRectangle::totalArea = 0;
// 必须在定义类的文件中对静态成员变量进行一次声明或初始化,否则编译能通过,链接不能通过

int main() {
	CRectangle::PrintTotal();
	CRectangle r1(3, 3), r2(2, 2);
	CRectangle::PrintTotal();
	r1.PrintTotal();
	return 0;
}

上述例子来源于此,总结如下:

  1. 静态成员函数与静态成员变量可以理解为,把一些与类紧密相关的全局变量和全局函数写到类里。虽然可以用类外的全局变量去记录,但是封装进类中会更好地理解和维护。上述例子中用静态变量记录了总面积和总个数,用静态函数打印出他们。
  2. 因此可以看出为什么静态函数只能访问静态变量,因为静态函数会不知道该调用哪一个对象的成员变量,所以只能调用共有的静态成员变量。换另一句话可以说成,因为静态函数没有this指针,所以只能调用静态成员
  3. 必须在定义类的文件中对静态成员变量进行一次声明或初始化,否则编译能通过,链接不能通过
  4. 静态成员函数和静态成员变量放在public和private下好像都行(?)

1.2 static的作用

c/c++共有

  1. 修饰全局变量时,表明一个全局变量只对定义在同一文件中的函数可见。

  2. 修饰局部变量时,表明该变量的值不会因为函数终止而丢失。

  3. 修饰函数时,表明该函数只在同一文件中调用。

c++独有:

  1. 修饰类的数据成员,表明对该类所有对象这个数据成员都只有一个实例。即该实例归所有对象共有。

  2. 用static修饰不访问非静态数据成员的类成员函数
    这意味着一个静态成员函数只能访问它的参数、类的静态数据成员和全局变量

  3. 加了static关键字的全局变量只能在本文件中使用。例如在a.c中定义了static int a=10;那么在b.c中用extern int a是拿不到a的值得,a的作用域只在a.c中。 2.static定义的静态局部变量分配在数据段上,普通的局部变量分配在栈上,会因为函数栈帧的释放而被释放掉。

  4. 去除this指针。对一个类中成员变量和成员函数来说,加了static关键字,则此变量/函数就没有了this指针了,必须通过类名才能访问。

其他的补充内容

1.3 struct与class的区别,以及struct的内存分配

在C语言中,可以使用结构体(Struct)来存放一组不同类型的数据。结构体的定义形式为:

struct stu{
    char *name;  //姓名
    int num;  //学号
    int age;  //年龄
    char group;  //所在学习小组
    float score;  //成绩
};

既然结构体是一种数据类型,那么就可以用它来定义变量。例如:

struct stu stu1, stu2;

1.3.1 struct与class的区别

在这里插入图片描述
更加具体可以看这个

1.3.2 内存对齐原则

理论上讲结构体的各个成员在内存中是连续存储的,和数组非常类似,例如上面的结构体变量 stu1、stu2 的内存分布如下图所示,共占用 4+4+4+1+4 = 17 个字节。
在这里插入图片描述
但是在编译器的具体实现中,各个成员之间可能会存在缝隙,对于 stu1、stu2,成员变量 group 和 score 之间就存在 3 个字节的空白填充(见下图)。这样算来,stu1、stu2 其实占用了 17 + 3 = 20 个字节。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1.3.3 union

union的使用方法
总结:

  1. 定义方式:
union Token{
   char cval;
   int ival;
   double dval;
};
  1. union的赋值为互斥赋值。在任意时刻,联合中只能有一个数据成员可以有值。当给联合中某个成员赋值之后,该联合中的其它成员就变成未定义状态了。
  2. 相比与struct的内存分配方式,union由于使用了覆盖技术,内存大小取最大的成员内存大小,因此一个token的长度为8个字节(double的长度最大)

1.3 类的大小以及空类的大小

1.31 空类的大小

. 缺省构造函数。
. 缺省拷贝构造函数。
. 缺省析构函数。
. 缺省赋值运算符。
. 缺省取址运算符。
. 缺省取址运算符 const
只有当实际使用这些函数的时候,编译器才会去定义它们

在C++中空类会占一个字节,这是为了让对象的实例能够相互区别。具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。如果没有这一个字节的占位,那么空类就无所谓实例化了,因为实例化的过程就是在内存中分配一块地址。
注意:当该空白类作为基类时,该类的大小就优化为0了,这就是所谓的空白基类最优化。
注意:空白基类最优化无法被施加于多重继承上只适合单一继承。

空白基类的优化

class Empty {};
struct D : public Empty { int a;};

此处sizeof(D)=4,即只包括int a的大小,空类此时为0。

当一个类包含一个空类对象数据成员。

class Empty {};
class HoldsAnInt {
    int x;
    Empty e;
};

sizeof(HoldsAnInt)=8。因为在这种情况下,空类的1字节是会被计算进去的。而又由于字节对齐的原则,所以结果为4+4=8。

1.3.2 类的大小

  • 首先,类大小的计算遵循结构体的对齐原则
  • 类的大小与普通数据成员有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响
  • 虚函数对类的大小有影响,是因为虚函数表指针带来的影响
  • 虚继承对类的大小有影响,是因为虚基表指针带来的影响
  • 空类的大小是一个特殊情况,空类的大小为1

一、普通情况

#include<iostream>
using namespace std;
class base
{
    public:
    base()=default;
    ~base()=default;
    private:
    static int a;
    int b;
    char c;

};

int main()
{
    base obj;
    cout<<sizeof(obj)<<endl;
}

计算结果:8
静态变量a不计算在对象的大小内,由于字节对齐,结果为4+4=8
二、空类的大小:见上

三、含有虚函数成员

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

sizeof(Base) = 16。vptr指针的大小为8,又因为对象中还包含一个int变量,字节对齐得8+8=16。
类型汇总:看这个就行了

#include<iostream>
using namespace std;


class A     
{     
};    

class B     
{  
    char ch;     
    virtual void func0()  {  }   
};   

class C    
{  
    char ch1;  
    char ch2;  
    virtual void func()  {  }    
    virtual void func1()  {  }   
};  

class D: public A, public C  
{     
    int d;     
    virtual void func()  {  }   
    virtual void func1()  {  }  
};     
class E: public B, public C  
{     
    int e;     
    virtual void func0()  {  }   
    virtual void func1()  {  }  
};  

int main(void)  
{  
    cout<<"A="<<sizeof(A)<<endl;    //result=1  
    cout<<"B="<<sizeof(B)<<endl;    //result=16      
    cout<<"C="<<sizeof(C)<<endl;    //result=16  
    cout<<"D="<<sizeof(D)<<endl;    //result=16  
    cout<<"E="<<sizeof(E)<<endl;    //result=32  
    return 0;  
}  

结果分析:

  1. A为空类,所以大小为1
  2. B的大小为char数据成员大小+vptr指针大小。由于字节对齐,大小为8+8=16
  3. C的大小为两个char数据成员大小+vptr指针大小。由于字节对齐,大小为8+8=16
  4. D为多继承派生类,由于D有数据成员,所以继承空类A时,空类A的大小1字节并没有计入当中,D继承C,此情况D只需要一个vptr指针,所以大小为数据成员加一个指针大小。由于字节对齐,大小为8+8=16
  5. E为多继承派生类,此情况为我们上面所讲的多重继承,含虚函数覆盖的情况。此时大小计算为数据成员的大小+2个基类虚函数表指针大小。考虑字节对齐,结果为8+8+2*8=32

四、虚继承情况
具体看这个

1.3 C++和C的区别

设计思想上:
C++是面向对象的语言,而C是面向过程的结构化编程语言

面向过程:按这种模型编写的程序以一系列的线性步骤(代码) 为特征,可被理解为作用于数据的代码。
比如要解决一个问题,通过线性的设计一系列函数,经过层层的调用来解决这个问题,可以理解为处理数据的代码

语法上:

C++具有封装继承多态三种特性

  • 封装:
    1. 封装是一种把代码和代码所操作的数据捆绑在一起,使这两者不受外界干扰和误用的机制。
    2. 封装可被理解为一种用做保护的包装器,以防止代码和数据被包装器外部所定义的其他代码任意访问。对包装器内部代码与数据的访问通过一个明确定义的接口来控制。封装代码的好处是每个人都知道怎样访问代码,进而无需考虑实现细节就能直接使用它,同时不用担心不可预料的副作用。类就是一个很典型的封装的单元
    3. C++相比C,增加多许多类型安全的功能,比如强制类型转换。(为什么)
    4. C++支持范式编程,比如模板类、函数模板等。

  • 继承就是新类从已有类那里得到已有的特性。 类的派生指的是从已有类产生新类的过程。原有的类成为基类或父类,产生的新类称为派生类或子类,有三种继承方式:public,pretected,private

  • 多态:可以简单概括为 “一个接口,多种方法”,即用的是同一个接口,但是效果各不相同,多态有两种形式的多态,一种是静态多态,一种是动态多态

1.4 c++中四种cast转换

更加详细的解释
C++中四种类型转换是:static_cast, dynamic_cast, const_cast, reinterpret_cast

  • reinterpret_cast:可以用于任意类型的指针之间的转换,对转换的结果不做任何保证
  • dynamic_cast:这种其实也是不被推荐使用的,更多使用static_cast,dynamic本身只能用于存在虚函数的父子关系的强制类型转换,对于指针,转换失败则返回nullptr,对于引用,转换失败会抛出异常。
  • const_cast:对于未定义const版本的成员函数,我们通常需要使用const_cast来去除const引用对象的const,完成函数调用。另外一种使用方式,结合static_cast,可以在非const版本的成员函数内添加const,调用完const版本的成员函数后,再使用const_cast去除const限定。
  • static_cast:完成基础数据类型;同一个继承体系中类型的转换;任意类型与空指针类型void* 之间的转换。

1、const_cast

const_cast,用于修改类型的constvolatile属性。

该运算符用来修改类型的const(唯一有此能力的C+±style转型操作符)或volatile属性。除了const 或volatile修饰之外, new_type和expression的类型是一样的。

  1. 常量指针被转化成非常量的指针,并且仍然指向原来的对象;
  2. 常量引用被转换成非常量的引用,并且仍然指向原来的对象;
  3. const_cast一般用于修改底指针(底层指针:代表指针所指的对象是一个常量)。如const char *p形式。(对应的顶层指针为char const * p
const int g = 20;
int *h = const_cast<int*>(&g);//去掉const常量const属性

const int g = 20;
int &h = const_cast<int &>(g);//去掉const引用const属性

 const char *g = "hello";
char *h = const_cast<char *>(g);//去掉const指针const属性

2、static_cast

static_cast相当于传统的C语言里的强制转换,该运算符把expression转换为new_type类型,用来强迫隐式转换,例如non-const对象转为const对象,编译时检查,用于非多态的转换,可以转换指针及其他,但没有运行时类型检查来保证转换的安全性。它主要有如下几种用法:

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

注意:static_cast不能转换掉expression的const、volatile、或者__unaligned属性。

class Base{ 
	//.....
};
class Derived : public Base{
	//.....
};
 
void main()
{
	//基本类型转换 float -> int
	int i;
	float f = 166.7f;
	i = static_cast<int>(f);
	
	//子类 -> 父类
	Derived d;
	Base b = static_cast<Base>(d);
	
	//父类 -> 子类
	Base bb ;
    //Derived* dd = static_cast<Derived>(bb);  //compile error
	Base* pB = new Base;
	Derived* pD = static_cast<Derived*>(pB); //编译通过,但是是不安全的(例如访问子类成员)
}

3、dynamic_cast

用于动态类型转换。只能用于含有虚函数的类,用于类层次间的向上和向下转化。只能转指针引用。向下转化时,如果是非法的对于指针返回NULL,对于引用抛异常。要深入了解内部转换的原理。

它通过判断在执行到该语句的时候,变量的运行时类型和要转换的类型是否相同来判断是否能够进行向下转换。

dynamic_cast转换操作符在执行类型转换时首先将检查能否成功转换,如果能成功转换则转换之,如果转换失败,如果是指针则返回一个0值,如果是转换的是引用,则抛出一个bad_cast异常,所以在使用dynamic_cast转换之间最好使用if语句对其转换成功与否进行测试

在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。dynamic_cast是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。

class Base
{
public:
	virtual void foo(){};
};
 
class Derived : public Base
{
 
};
 
void main()
{
	//基类 -> 子类
	Base *pb1 = new Base;
	Derived *pd1 = dynamic_cast<Derived *>(pb1); //失败,pd1 = NULL
 
	//子类 -> 子类
	Base *pb2 = new Derived;
	Derived *pd2 = dynamic_cast<Derived *>(pb2); //成功
 
	//子类 -> 基类
	// 步骤:先声明一个子类指针指向子类对象,
	// 之后强转,将该子类指针的类型转换为父类指针
	// 但是该父类指针依旧指向子类对象
	Base *pB = new Derived;
	Base *pD = dynamic_cast<Base *>(pB);		 //成功
}
#include <iostream>  
#include <algorithm>
#include <vector> 
#include <typeinfo>
using namespace std;
class A
{
public:
	A() {
		m_value = 0;
	}
	A(int a) {
		m_value = a;
	}
	void p() {
		cout << m_value << endl;
	}
	virtual void foo()
	{
		cout << "A::foo() is called" << endl;
	}
	virtual void foo1()
	{
		cout << "A::foo1() is called" << endl;
	}
	int m_value;
};
class B :public A
{
public:
	B(int a) {
		m_value = a;
	}

	void foo()
	{
		cout << "B::foo() is called" << endl;
	}
	void foo1()
	{
		cout << "B::foo1() is called" << endl;
	}
};


int main(){
	B* aa = new B(1);
	aa->foo1();
	cout << typeid(aa).name() << endl;

	A* bb = dynamic_cast<B*>(aa);
	bb->foo1();
	cout << typeid(bb).name() << endl;

	return 0;
}

输出

B::foo1() is called
class B *

B::foo1() is called
class A *

4、reinterpret_cast
reinterpret_cast<T>(expression);
几乎什么都可以转,比如将int转指针,可能会出问题,尽量少用;
T必须是一个指针、引用、算术类型、函数指针或者成员指针

该操作符用于将一种类型转换为另一种不同的类型,比如可以把一个整型转换为一个指针,或把一个指针转换为一个整型,因此使用该操作符的危险性较高,一般不应使用该操作符。
举例:

int i;
char *p = "This is a example.";
i = reinterpret_cast<int>(p);   //将指针p的值(即地址)转为int型 【如 0x00b4cd10 -> 11848976】

5、为什么不使用C的强制转换?

C的强制转换表面上看起来功能强大什么都能转,但是转化不够明确,不能进行错误检查,容易出错。

1.5 给定三角形ABC和一点P(x,y,z),判断点P是否在ABC内,给出思路并手写代码

#include <iostream>
#include <math.h>
using namespace std;
#define ABS_FLOAT_0 0.0001
struct point_float{
    float x;
    float y;
};
float GetTriangleSquar(const point_float pt0, const point_float pt1, const point_float pt2){
    point_float AB, BC;
    AB.x = pt1.x - pt0.x;
    AB.y = pt1.y - pt0.y;
    BC.x = pt2.x - pt1.x;
    BC.y = pt2.y - pt1.y;
    return fabs((AB.x * BC.y - AB.y * BC.x)) / 2.0f;
}
bool IsInTriangle(const point_float A, const point_float B, const point_float C, const point_float D)
{
    float SABC, SADB, SBDC, SADC;
    SABC = GetTriangleSquar(A, B, C);
    SADB = GetTriangleSquar(A, D, B);
    SBDC = GetTriangleSquar(B, D, C);
    SADC = GetTriangleSquar(A, D, C);
    float SumSuqar = SADB + SBDC + SADC;
    if ((-ABS_FLOAT_0 < (SABC - SumSuqar)) && ((SABC - SumSuqar) < ABS_FLOAT_0)){
        return true;
    }
    else{
        return false;
    }
}

1.6 c++中的smart pointer四个智能指针:shared_ptr,unique_ptr,weak_ptr,auto_ptr

C++里面的四个智能指针: auto_ptr, shared_ptr, weak_ptr, unique_ptr

其中后三个是c++11支持,并且第一个已经被11弃用。
简单的介绍一下:

1.智能指针主要用于管理在堆上分配的内存,它将普通的指针封装为一个栈对象
2.当栈对象的生存周期结束后,会在析构函数中释放掉申请的内存,从而防止内存泄漏。C++ 11中最常用的智能指针类型为shared_ptr,它采用引用计数的方法,记录当前内存资源被多少个智能指针引用。该引用计数的内存在堆上分配。当新增一个时引用计数加1,当过期时引用计数减一。只有引用计数为0时,智能指针才会自动释放引用的内存资源。
3. 对shared_ptr进行初始化时不能将一个普通指针直接赋值给智能指针,因为一个是指针,一个是类可以通过make_shared 函数或者通过构造函数传入普通指针。并可以通过get函数获得普通指针。

为什么要使用智能指针:
智能指针的作用是管理一个指针,因为存在以下这种情况:申请的空间在函数结束时忘记释放,造成内存泄漏。(在函数内申请的指针在堆上,并不会因为跳出了函数体就自动地释放,需要手动释放)
使用智能指针可以很大程度上的避免这个问题,因为智能指针就是一个类,当超出了类的作用域是,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要手动释放内存空间。

请你回答一下智能指针有没有内存泄露的情况
两个对象相互使用一个shared_ptr成员变量指向对方,会造成循环引用,使得他们的引用计数都是1. 使引用计数失效,从而导致内存泄漏。更加详细的解释

请你来说一下智能指针的内存泄漏如何解决
为了解决循环引用导致的内存泄漏,引入了weak_ptr弱指针,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但不指向引用计数的共享内存,但是其可以检测到所管理的对象是否已经被释放,从而避免非法访问。

1.6.1 auto_ptr(c++98的方案,cpp11已经抛弃)

采用所有权模式。

auto_ptr< string> p1 (new string ("I reigned lonely as a cloud.));
auto_ptr<string> p2;
p2 = p1; //auto_ptr不会报错.

此时不会报错,p2剥夺了p1的所有权,但是当程序运行时访问p1将会报错。所以auto_ptr的缺点是:存在潜在的内存崩溃问题!

1.6.2 unique_ptr(替换auto_ptr)

unique_ptr实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露(例如“以new创建对象后因为发生异常而忘记调用delete”)特别有用。
不能通过赋值的方法来初始化uniqu_ptr

unique_pre p(new string("test"));
unique_pre p = make_unique<string>("test");

采用所有权模式,还是上面那个例子

unique_ptr<string> p3 (new string ("auto"));   //#4
unique_ptr<string> p4;                       //#5
p4 = p3;//此时会报错!!

编译器认为p4=p3;非法,避免了p3不再指向有效数据的问题。因此,unique_ptrauto_ptr更安全。

另外unique_ptr还有更聪明的地方:当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁止这么做,比如:

unique_ptr<string> pu1(new string ("hello world"));
unique_ptr<string> pu2;
pu2 = pu1;                                      // #1 not allowed
unique_ptr<string> pu3;
pu3 = unique_ptr<string>(new string ("You"));   // #2 allowed

其中#1留下悬挂的unique_ptr(pu1),这可能导致危害。而#2不会留下悬挂的unique_ptr,因为它调用 unique_ptr 的构造函数,该构造函数创建的临时对象在其所有权让给 pu3 后就会被销毁。这种随情况而已的行为表明,unique_ptr 优于允许两种赋值的auto_ptr 。

指针指向非法的内存地址,那么这个指针就是悬挂指针,也叫野指针。意为无法正常使用的指针。

注:如果确实想执行类似与#1的操作,要安全的重用这种指针,可给它赋新值。C++有一个标准库函数std::move(),让你能够将一个unique_ptr赋给另一个。例如:

unique_ptr<string> ps1, ps2;
ps1 = demo("hello");
ps2 = move(ps1);
ps1 = demo("alexia");
cout << *ps2 << *ps1 << endl;

1.6.3 shared_ptr

shared_ptr实现共享式拥有概念。多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字share就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。可以通过成员函数use_count()来查看资源的所有者个数。除了可以通过new来构造,还可以通过传入auto_ptr, unique_ptr,weak_ptr来构造。当我们 调用release() 时,当前指针会释放资源所有权,计数减一。当计数等于0时,资源会被释放

shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性(auto_ptr 是独占的), 在使用引用计数的机制上提供了可以共享所有权的智能指针。

成员函数:

  • use_count 返回引用计数的个数
  • unique 返回是否是独占所有权( use_count 为 1)
  • swap 交换两个 shared_ptr 对象(即交换所拥有的对象)
  • reset 放弃内部对象的所有权或拥有对象的变更, 会引起原有对象的引用计数的减少
  • get 返回内部对象(指针), 由于已经重载了()方法, 因此和直接使用对象是一样的.如 shared_ptr sp(new int(1)); sp 与 sp.get()是等价的

1.6.4 weak_ptr

  1. weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象。进行该对象的内存管理的是那个强引用的 shared_ptr。weak_ptr只是提供了对管理对象的一个访问手段。
  2. weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少
  3. weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。
  4. 它是对对象的一种弱引用,不会增加对象的引用计数
  5. 和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr。
  6. weak_ptr不能直接初始化,要依赖于shared_ptr
    int main(){
    	shared_ptr<int> p(new int(5));
    	weak_ptr<int> p2 = p;
    }
    
class B;
class A{
public:
	weak_ptr<B> pb_;
	~A(){
	cout<<"A delete\n";
	}
};
class B{
	public:
	shared_ptr<A> pa_;
	~B(){
	cout<<"B delete\n";
	}
};
void fun(){
	shared_ptr<B> pb(new B());
	shared_ptr<A> pa(new A());
	//相互引用
	pb->pa_ = pa;
	pa->pb_ = pb;
	cout<<pb.use_count()<<endl;
	cout<<pa.use_count()<<endl;
}
int main(){
	fun();
	return 0;
}

可以看到fun函数中pa ,pb之间互相引用,两个资源的引用计数为2,当要跳出函数时,智能指针pa,pb析构时两个资源引用计数会减一,但是两者引用计数还是为1,导致跳出函数时资源没有被释放(A B的析构函数没有被调用)。

如果把其中一个改为weak_ptr就可以了,我们把类A里面的shared_ptr<B> pb_; 改为weak_ptr<B> pb_; 运行结果如下,这样的话,weak_ptr<B> pb_开始就只有1,而且引用的过程中次数不会加一,当pb_析构时,pb_的计数变为0,pb_得到释放,pb_释放的同时也会使A的计数减一,同时shared_ptr<A> pa_析构时使pa_的计数减一,那么pa_的计数为0,A得到释放。
总而言之,pb和pa初始化时计数都为1,但是pa因为引用了,次数变2,但是pb不变,当pb正常析构后,pa的次数减为1,此时可以正常的析构

注意的是我们不能通过weak_ptr直接访问对象的方法 ,比如B对象中有一个方法print(),我们不能这样访问,pa->pb_->print(); 因为pb_是一个weak_ptr应该先把它转化为shared_ptr,如:shared_ptr p = pa->pb_.lock(); p->print();

————————————————以下为书本内容—————————————————
我们知道除了静态内存和栈内存外,每个程序还有一个内存池,这部分内存被称为自由空间或者堆。程序用堆来存储动态分配的对象即那些在程序运行时分配的对象,当动态对象不再使用时,我们的代码必须显式的销毁它们。

在C++中,动态内存的管理是用一对运算符完成的:new和delete,new:在动态内存中为对象分配一块空间并返回一个指向该对象的指针,delete:指向一个动态独享的指针,销毁对象,并释放与之关联的内存。

动态内存管理经常会出现两种问题:一种是忘记释放内存,会造成内存泄漏;一种是尚有指针引用内存的情况下就释放了它,就会产生引用非法内存的指针。

为了更加容易(更加安全)的使用动态内存,引入了智能指针的概念。智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。标准库提供的两种智能指针的区别在于管理底层指针的方法不同,

  • shared_ptr允许多个指针指向同一个对象
  • unique_ptr则“独占”所指向的对象。
  • 标准库还定义了一种名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象,
  • 这三种智能指针都定义在memory头文件中。

1.6.1 shared_ptr类
创建智能指针时必须提供额外的信息,指针可以指向的类型:

shared_ptr<string> p1;
shared_ptr<list<int>> p2;

默认初始化的智能指针中保存着一个空指针。
智能指针的使用方式和普通指针类似,解引用一个智能指针返回它指向的对象,在一个条件判断中使用智能指针就是检测它是不是空。

if(p1  && p1->empty())
	*p1 = "hi";

如下表所示是shared_ptr和unique_ptr都支持的操作:
在这里插入图片描述
如下表所示是shared_ptr特有的操作:
在这里插入图片描述
1.6.1.1 make_shared函数:

最安全的分配和使用动态内存的方法就是调用一个名为make_shared的标准库函数,此函数在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr。头文件和share_ptr相同,在memory中
必须指定想要创建对象的类型,定义格式见下面例子:

shared_ptr<int> p3 = make_shared<int>(42);
shared_ptr<string> p4 = make_shared<string>(10,'9');
shared_ptr<int> p5 = make_shared<int>();

make_shared用其参数来构造给定类型的对象,如果我们不传递任何参数,对象就会进行值初始化。

1.6.1.2 shared_ptr的拷贝和赋值
当进行拷贝和赋值时,每个shared_ptr都会记录有多少个其他shared_ptr指向相同的对象。

auto p = make_shared<int>(42);
auto q(p);

我们可以认为每个shared_ptr都有一个关联的计数器,通常称其为引用计数,无论何时我们拷贝一个shared_ptr,计数器都会递增。当我们给shared_ptr赋予一个新值或是shared_ptr被销毁(例如一个局部的shared_ptr离开其作用域)时,计数器就会递减,一旦一个shared_ptr的计数器变为0,它就会自动释放自己所管理的对象

auto r = make_shared<int>(42);//r指向的int只有一个引用者
r=q;//给r赋值,令它指向另一个地址
	//递增q指向的对象的引用计数
	//递减r原来指向的对象的引用计数
	//r原来指向的对象已没有引用者,会自动释放

shared_ptr自动销毁所管理的对象
当指向一个对象的最后一个shared_ptr被销毁时,shared_ptr类会自动销毁此对象,它是通过另一个特殊的成员函数-析构函数完成销毁工作的,类似于构造函数,每个类都有一个析构函数。析构函数控制对象销毁时做什么操作。析构函数一般用来释放对象所分配的资源。shared_ptr的析构函数会递减它所指向的对象的引用计数。如果引用计数变为0,shared_ptr的析构函数就会销毁对象,并释放它所占用的内存。

shared_ptr还会自动释放相关联的内存
当动态对象不再被使用时,shared_ptr类还会自动释放动态对象,这一特性使得动态内存的使用变得非常容易。如果你将shared_ptr存放于一个容器中,而后不再需要全部元素,而只使用其中一部分,要记得用erase删除不再需要的那些元素。

程序使用动态内存的原因:
(1)程序不知道自己需要使用多少对象
(2)程序不知道所需对象的准确类型
(3)程序需要在多个对象间共享数据

1.6.2 直接管理内存
C++定义了两个运算符来分配和释放动态内存,new和delete,使用这两个运算符非常容易出错。

使用new动态分配和初始化对象
在自由空间分配的内存是无名的,因此new无法为其分配的对象命名,而是返回一个指向该对象的指针。

int *pi = new int;//pi指向一个动态分配的、未初始化的无名对象

此new表达式在自由空间构造一个int型对象,并返回指向该对象的指针

默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象的值将是未定义的,而类类型对象将用默认构造函数进行初始化。

。。。剩下的内容

1.7 指针和引用

  1. 指针有自己的一块空间,而引用只是一个别名
  2. 使用sizeof看一个指针的大小是4,而引用则是被引用对象的大小
    注意:在32位的计算机中,指针占4个字节。同理,在64位的计算机中,指针占8个字节。
  3. 指针可以被初始化为NULL,而引用的初始值必须是一个对象,并且引用必须被初始化
  4. 作为参数传递时,指针需要被解引用才可以对对象进行操作,而直接对引用的修改都会改变引用所指向的对象
  5. 指针在使用中可以指向其它对象,但是引用只能是一个对象的引用,不能被改变;
  6. 指针可以有多级指针(**p),而引用只能一级,不能定义引用的引用
  7. 指针和引用使用++运算符的意义不一样;
  8. 如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄露。

其他

  • 引用实际是通过指针实现的。
  • 引用是一个常量指针
  • 在对引用定义时,需要对这个常量指针初始化

1.8 数组和指针的区别

指针数组
保存数据的地址保存数据
间接访问数据。首先获得指针的内容,然后将其作为地址,从该地址中提取数据直接访问数据
通常用于动态的数据结构通常用于固定数目且数据类型相同的元素
通过Malloc分配内存,free释放内存隐式的分配和删除
通常指向匿名数据,操作匿名函数自身即为数据名

1.8.1 引用占用额外的空间吗

引用本质上是一个指针常量,在内存中为引用开辟了一个指针型的内存单元

struct Test
{
int a[5];
int &b;
}

int main()
{
using std::cout;
cout<<"结构体占用的内存空间字节大小为:"<<sizeof(Test);
}

运行结果为:24
因此引用同指针一样,占用一个4字节的内存空间(32位)

1.9 野指针

野指针就是指向一个已删除的对象或者未申请访问受限内存区域的指针。
指针指向了一块随机的空间,不受程序控制。

1.9.1 野指针与垂悬指针的区别

野指针:访问一个已销毁或者访问受限的内存区域的指针,野指针不能判断是否为NULL来避免
垂悬指针:指针正常初始化,曾指向一个对象,该对象被销毁了,但是指针未制空,那么就成了悬空指针。

1.9.2 野指针产生的原因

1.指针定义时未被初始化:指针在被定义的时候,如果程序不对其进行初始化的话,它会随机指向一个区域,因为任意指针变量(出了static修饰的指针)它的默认值都是随机的

  # include <stdio.h>
  int main(void)
  {
    int i = 3;

    int *j; 
    *j = i; 
    //指针j还未初始化,即指针j指向0XCCCCCCCC内存空间,该地址应用程序无权访问。
    //使用 *j试图往这个内存空间中写数据时,程序运行后会报错。
    return 0;
  }  

2.指针被释放时没有置空:我们在用malloc()开辟空间的时候,要检查返回值是否为空,如果为空,则开辟失败;如果不为空,则指针指向的是开辟的内存空间的首地址。指针指向的内存空间在用free()和delete释放后,如果程序员没有对其进行置空或者其他赋值操作的话,就会成为一个野指针
3.指针操作超越变量作用域:不要返回指向栈内存的指针或者引用,因为栈内存在函数结束的时候会被释放。实质上也是指针所指内容被释放时没有置空。

1.9.3 野指针的危害

问题:指针指向的内容已经无效了,而指针没有被置空,解引用一个非空的无效指针是一个未被定义的行为,也就是说不一定导致错误,野指针被定位到是哪里出现问题,在哪里指针就失效了,不好查找错误的原因。

1.9.4 规避方法(初始化和释放时)

  1. 初始化指针的时候将其置为nullptr,之后对其操作。
  2. 释放指针的时候将其置为nullptr。

1.10 为什么析构函数必须是虚函数?为什么C++默认的析构函数不是虚函数

可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间(因为使用父类指针指向子类时,只能使用父类的函数,而不能使用子类的函数,这样的话,该指针就不能访问子类的析构函数),防止内存泄漏。

C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。

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

class A {
public:
	A() {};
	~A() {
		cout << "调用A的析构" << endl;
	};
	void dosomething() {
		cout << "In A!" << endl;
	}
};

class B :public A {
public:
	B() {};
	~B() {
		cout << "调用B的析构" << endl;
	};
	void dosomething() {
		cout << "In B!" << endl;
	}
};

int main() {
	A *p = new B();
	p->dosomething();
	delete p;
	return 0;
}

输出

In B!
调用B的析构
调用A的析构

1.11 C++ 私有构造函数的作用

主要用于单例模式
总结:

  1. 构造函数私有化的类的设计保证了其他类不能从这个类派生或者创建类的实例。还有这样的用途:例如,实现这样一个class:它在内存中至多存在一个,或者指定数量个的对象(可以在class的私有域中添加一个static类型的计数器,它的初值置为0,然后在GetInstance()中作些限制:每次调用它时先检查计数器的值是否已经达到对象个数的上限值,如果是则产生错误,否则才new出新的对象,同时将计数器的值增1。最后,为了避免值复制时产生新的对象副本,除了将构造函数置为私有外,复制构造函数也要特别声明并置为私有。
  2. 构造函数私有化的类的设计可以保证只能用new命令在堆中来生成对象,只能动态的去创建对象,这样可以自由的控制对象的生命周期。但是,这样的类需要提供创建和撤销的公共接口。
  3. 为什么要用静态函数声明类在堆上创建的函数:因为静态函数可以类的实例化之前使用。
    在堆上创建类,而构造函数私有化如下:
class OnlyHeapClass
{
public:
   static OnlyHeapClass* GetInstance()
       {
              // 创建一个OnlyHeapClass对象并返回其指针
              return (new OnlyHeapClass);
       }
   void Destroy();
private:
       OnlyHeapClass() { }
       ~OnlyHeapClass() {}
};
int main()
{
       OnlyHeapClass *p = OnlyHeapClass::GetInstance();
       ... // 使用*p
       delete p;
       return 0;
}
 
  1. 更多内容可以看这个

1.11 析构函数的作用

析构函数与构造函数对应,当对象结束其生命周期,如对象所在的函数已调用完毕时,系统会自动执行析构函数。
析构函数名也应与类名相同,只是在函数名前面加一个位取反符,例如stud( ),以区别于构造函数。它不能带任何参数,也没有返回值(包括void类型)。只能有一个析构函数,不能重载。

如果用户没有编写析构函数,编译系统会自动生成一个缺省的析构函数(即使自定义了析构函数,编译器也总是会为我们合成一个析构函数,并且如果自定义了析构函数,编译器在执行时会先调用自定义的析构函数再调用合成的析构函数),它也不进行任何操作。所以许多简单的类中没有用显式的析构函数。

如果一个类中有指针,且在使用的过程中动态的申请了内存,那么最好显示构造析构函数在销毁类之前,释放掉申请的内存空间,避免内存泄漏。

类析构顺序:1)派生类本身的析构函数;2)对象成员析构函数;3)基类析构函数。

1.12 静态函数和虚函数的区别

  • 静态函数在编译的时候就已经确定运行时机,虚函数在运行的时候动态绑定。
  • 虚函数因为用了虚函数表机制,调用的时候会增加一次内存开销

1.13 虚函数和多态

多态的实现主要分为静态多态和动态多态,静态多态主要是重载,在编译的时候就已经确定;动态多态是用虚函数机制实现的,在运行期间动态绑定。
举个例子:一个父类类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数,在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数。
虚函数的实现:在有虚函数的类中,类的最开始部分是一个虚函数表的指针,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段(.text)中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率。

1.14 函数指针

1、定义

  • 函数指针是指向函数的指针变量。
  • 函数指针本身首先是一个指针变量该指针变量指向一个具体的函数。这正如用指针变量可指向整型变量、字符型、数组一样,这里是指向函数。
  • C在编译时,每一个函数都有一个入口地址函数指针指向该函数的入口地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样,在这些概念上是大体一致的。

2、用途:
调用函数和做函数的参数,比如回调函数

3、示例:

char* fun(char * p)  {}     // 函数fun
char* (*pf)(char* p);       // 声明函数指针pf
pf = fun;                    // 函数指针pf指向函数fun
pf(p);                       // 通过函数指针pf调用函数fun

1.15 静态函数和虚函数的区别

静态函数在编译的时候就已经确定运行时机,虚函数在运行的时候动态绑定。虚函数因为用了虚函数表机制,调用的时候会增加一次内存开销

1.15.1 虚函数是如何实现动态绑定的

面向对象程序设计基于三个基本概念:数据抽象继承动态绑定
此处的动态绑定是指:我们使用基类的引用或指针去调用虚函数,将会发生动态绑定即如果基类指针或引用指向的对象是基类对象,则调用基类的该函数,如果基类的指针或引用指向的是派生类对象则调用的是派生类中的该函数。

1.16 重载和重写

重载:在同一作用域中,两个函数名相同,但是参数列表不同(个数,类型),返回值类型没有要求,
重写:子类继承了父类,父类中的函数是虚函数,在子类中重新定义了这个虚函数,这种情况是重写(也可以成为覆盖)

1.18 strcpy和strlen

strcpy 是字符串拷贝函数,原型:

char *strcpy(char* dest, const char *src);

注意:新版本的C++中,strcpy并不安全,改用strcpy_s

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

int main()
{
	char src[40];
	char dest[100];

	//memset(dest, '\0', sizeof(dest));
	strcpy_s(src, "This is runoob.com");
	strcpy_s(dest, src);

	cout << "最终的目标字符串:" << dest << endl;

	return 0;
}

C 库函数 size_t strlen(const char *str) 计算字符串 str 的长度,直到空结束字符,但不包括空结束字符。

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

int main()
{
	char src[40];
	char dest[100];

	//memset(dest, '\0', sizeof(dest));
	strcpy_s(src, "This is runoob.com");
	strcpy_s(dest, src);

	cout << "最终的目标字符串:" << dest << endl;
	cout << "dest的长度为" << strlen(dest) << endl;

	return 0;
}

总结:

  1. src逐字节拷贝到dest(从右边到左边),直到遇到’\0’结束,因为没有指定长度,可能会导致拷贝越界,造成缓冲区溢出漏洞,安全版本是strncpy函数。strcpy是把第二个参数中的内容复制一份到新的内存中。=的话相当于直接改变指针的指向
  2. strlen函数是计算字符串长度的函数,返回从开始到’\0’之间的字符个数。与size()的区别在于,strlen为函数,而size()为运算符,运算符和函数的区别可以看这个。

1.20 虚函数和多态

  1. 多态的实现主要分为静态多态动态多态。静态多态主要是重载,在编译的时候就已经确定;动态多态是用虚函数机制实现的,在运行期间动态绑定。举个例子:一个父类类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数,在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数。
  2. 虚函数的实现:在有虚函数的类中,类的最开始部分是一个虚函数表的指针(4 个字节),这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段(.text)中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率。

1.21 ++i和i++

++i先自增1,再返回,i++先返回i,再自增1。

  1. ++i 实现:
int&  int::operator++()
{
*this +=1return *this}
  1. i++ 实现:
const int  int::operatorint{
int oldValue = *this++*this);
return oldValue;
}

1. 23 设计函数在main函数执行前先运行

__attribute((constructor))void before()
{
    printf("before main\n");
}

GNU C(是一种C函数库) 的一大特色就是__attribute__ 机制。
attribute 可以设置函数属性(Function Attribute)、变量属性(Variable Attribute)和类型属性(Type Attribute)。

其位置约束为: 放于声明的尾部“;” 之前
attribute 书写特征为: attribute 前后都有两个下划线,并切后面会紧跟一对原括弧,括弧里面是相应的__attribute__ 参数。
attribute 语法格式为: attribute ((attribute-list))

attribute constructor/destructor 若函数被设定为constructor属性,则该函数会在 main()函数执行之前被自动的执行。类似的,若函数被设定为destructor属性,则该函数会在main()函数执行之后或者exit()被调用后被自动的执行。拥有此类属性的函数经常隐式的用在程序的初始化数据方面。

构造函数(constructors)析构函数(destructors)
程序员应当使用类似下面的方式来指定这些属性:
static void start(void) attribute ((constructor));
static void stop(void) attribute ((destructor));
带有"构造函数"属性的函数将在main()函数之前被执行,
而声明为"析构函数"属性的函数则将在main()退出时执行。

或者使用全局类

#include <iostream>

using namespace std;
class test {
public:
	test() {
		cout << "before main" << endl;
	}
	~test() {
		cout << "after main" << endl;
	}
};
test t;
int main() {
	cout << "hello, world" << endl;
	return 0;
}

1.24 以下四行代码的区别是什么? const char * arr = “123”; char * brr = “123”; const char crr[] = “123”; char drr[] = “123”;

字符串123保存在常量区,const本来是修饰arr指向的值不能通过arr去修改,但是字符串“123”在常量区,本来就不能改变,所以加不加const效果都一样

const char * arr = "123"; // 常量区,存放常量字符串,不可以修改

字符串123保存在常量区,这个arr指针指向的是同一个位置,同样不能通过brr去修改"123"的值

char * brr = "123"; 
// 常量区,存放着常量字符串,不可以修改,其实加不加const效果都一样

这里123本来是在上的,但是编译器可能会做某些优化,将其放到常量区

const char crr[] = "123"; // 放在栈上

字符串123保存在区,可以通过drr去修改

char drr[] = "123"; // 放在栈上的

1.25 C++里是怎么定义常量的?常量存放在内存的哪个位置?

常量在C++里的定义就是一个top-level const加上对象类型,常量定义必须初始化。

  • 对于局部对象,常量存放在区,
  • 对于全局对象,常量存放在全局/静态存储区
  • 对于字面值常量,常量存放在常量存储区

1.26 内存分配、堆栈

一、一个C/C++编译的程序占用内存分为以下几个部分:
静态区域

  • 全局区(静态区static):存放全局变量静态数据常量。程序结束后由系统释放。全局区分为已初始化全局区(data)和未初始化全局区(bss),C++ 里面没有这个区分。
  • 常量区(文字常量区):存放常量字符串,程序结束后由系统释放。
  • 代码区:包括只读存储区和文本区,其中只读存储区存储字符串常量,文本区存储程序的机器代码。

动态区域

  • 栈区(stack):由编译器自动分配与释放,存放为运行时函数分配的局部变量、函数参数、返回数据、返回地址等。其操作类似于数据结构中的栈。
    使用栈空间存储函数的返回地址、参数、局部变量、返回值,从高地址向低地址增长。在创建进程时会有一个最大栈大小,Linux可以通过ulimit命令指定。
    一个例子:
char *p3 = "123456"; //123456在常量区,p3在栈上
  • 堆区(heap):一般由程序员自动分配,如果程序员没有释放,程序结束时可能有OS回收。其分配类似于链表。
    当进程未调用malloc时是没有堆段的,只有调用malloc时采用分配一个堆,并且在程序运行过程中可以动态增加堆大小(移动break指针),从低地址向高地址增长。分配小内存时使用该区域。 堆的起始地址由mm_struct 结构体中的start_brk标识,结束地址由brk标识。
  • 映射区: 存储动态链接库以及调用mmap函数进行的文件映射 (?)

全局区与常量区的区别

二、三种内存分配方式
在这里插入图片描述
32bitCPU可寻址4G线性空间,每个进程都有各自独立的4G逻辑地址,其中0-3G是用户态空间,3-4G是内核空间,不同进程相同的逻辑地址会映射到不同的物理地址中。

1. 从静态存储区分配
内存在程序编译的时候已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。

2. 在栈上创建

由编译器自动分配和释放 。在执行函数时,函数内局部变量的存储单元可以在栈上创建,函数执行结束时,这些内存单元会自动被释放。
栈内存分配运算内置于处理器的指令集,效率高,但是分配的内存容量有限。

3. 从堆上分配

亦称为动态内存分配。 一般由程序员手动申请以及释放
程序在运行的时候使用malloc或者new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。
动态内存的生命周期有程序员决定,使用非常灵活,但如果在堆上分配了空间,既有责任回收它,否则运行的程序会出现内存泄漏,频繁的分配和释放不同大小的堆空间将会产生内存碎片。

一个例子

int a = 0; 全局初始化区
char *p1; 全局未初始化区
main()
{
	int b; //栈中
	char s[] = "abc"; //栈中
	char *p2; //栈中
	char *p3 = "123456"; //123456/0在常量区,p3在栈上
	static int c =0//全局(静态)初始化区
	//以下分配得到的10和20字节的区域就在堆区
	p1 = (char *)malloc(10);
	p2 = new char[20];//(char *)malloc(20);
	strcpy(p1, "123456"); //123456/0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。
}

三、栈(stack)和堆(heap)具体的区别
1. 在申请方式上

  • 栈(stack): 现在很多人都称之为堆栈,这个时候实际上还是指的栈。它由编译器自动管理,无需我们手工控制。 例如,声明函数中的一个局部变量 int b 系统自动在栈中为b开辟空间;在调用一个函数时,系统自动的给函数的形参变量在栈中开辟空间。
  • 堆(heap): 申请和释放由程序员控制,并指明大小。容易产生memory leak。
  • 在C中使用malloc函数。如:p1 = (char *)malloc(10);在C++中用new运算符。如:p2 = new char[20];//(char *)malloc(10);但是注意p1本身在全局区,而p2本身是在栈中的,只是它们 指向的空间是在堆中。(为什么p2在栈中?)

2. 申请后系统的响应上

  • 栈(stack):只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出
  • 堆(heap): 首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序
    另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete或 free语句才能正确的释放本内存空间。
    另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

3. 申请大小的限制

  • 栈(stack):在Windows下,栈是从高地址向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小。例如,在VC6下面,默认的栈空间大小是1M(好像是,记不清楚了)。当然,我们可以修改:打开工程,依次操作菜单如下:Project->Setting->Link,在Category 中选中Output,然后在Reserve中设定堆栈的最大值和commit。
    注意:reserve最小值为4Byte;commit是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。
  • 堆(heap): 堆是从低地址向高地址扩展的数据结构,是不连续的内存区域(空闲部分用链表串联起来)。正是由于系统是用链表来存储空闲内存,自然是不连续的,而链表的遍历方向是由低地址向高地址。一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。由此可见,堆获得的空间比较灵活,也比较大。

4. 分配空间的效率上

  • 栈(stack):栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。但程序员无法对其进行控制。
  • 堆(heap):是C/C++函数库提供的,newmalloc分配的内存,一般速度比较慢,而且容易产生内存碎片。它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。这样 可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。显然,堆的效率比栈要低得多。

5、堆和栈中的存储内容

  • 栈(stack):在函数调用时,第一个进栈的是主函数中子函数调用后的下一条指令(子函数调用语句的下一条可执行语句)的地址,然后是子函数的各个形参。在大多数的C编译器中,参数是由右往左入栈的,然后是子函数中的局部变量。注意:静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中子函数调用完成的下一条指令,程序由该点继续运行。
  • 堆(heap):一般是在堆的头部用一个字节存放堆的大小,堆中的具体内容有程序员安排。

6. 存取效率的比较
栈的效率比堆高很多。

  • 栈是机器系统提供的数据结构,计算机在底层提供栈的支持,分配专门的寄存器来存放栈的地址,压栈出栈都有相应的指令,因此比较快。
  • 堆是由库函数提供的,机制很复杂,库函数会按照一定的算法进行搜索内存,因此比较慢。

拿栈上的数组arr和堆上的数组arr1来说:

void main()
{
	int arr[5]={1,2,3,4,5};
	int *arr1;
	arr1=new int[5];
	for (int j=0;j<=4;j++){
		arr1[j]=j+6;
	}
	int a=arr[1];
	int b=arr1[1];
}

上面代码中,arr1(局部变量)是在栈中,但是指向的空间确在堆上,两者的存取效率,当然是arr高。因为arr[1]可以直接访问,但是访问arr1[1],首先要访问数组的起始地址arr1,然后才能访问到arr1[1]

总而言之:
1)申请方式:
栈由系统自动分配和管理,堆由程序员手动分配和管理。
2)效率:
栈由系统分配,速度快,不会有内存碎片。
堆由程序员分配,速度较慢,可能由于操作不当产生内存碎片。
3)扩展方向
栈从高地址向低地址进行扩展,堆由低地址向高地址进行扩展。
4)程序局部变量是使用的栈空间,new/malloc动态申请的内存是堆空间,函数调用时会进行形参和返回值的压栈出栈,也是用的栈空间。

1.27 deletedelete []区别

delete只会调用一次析构函数,而delete[]会调用每一个成员的析构函数。在More Effective C++中有更为详细的解释:“当delete操作符用于数组时,它为每个数组元素调用析构函数,然后调用operator delete来释放内存。”deleteNew配套,delete []new []配套

MemTest* mTest1=new MemTest[10];
MemTest* mTest2=new MemTest;
int*pInt1=new int[10];
int*pInt2=new int; 
delete[]pInt1;  //-1-
delete[]pInt2;  //-2-
delete[]mTest1; //-3-
delete[]mTest2; //-4-

这就说明:

  • 对于内建简单数据类型(例子中为int型变量),deletedelete[]功能是相同的。对于自定义的复杂数据类型(例子中为MemTest类),deletedelete[]不能互用。
  • delete[]删除一个数组,delete删除一个指针。简单来说,用new分配的内存用delete删除,用new[]分配的内存用delete[]删除。delete[]会调用数组元素的析构函数内部数据类型没有析构函数,所以问题不大。如果你在用delete时没用括号,delete就会认为指向的是单个对象,否则,它就会认为指向的是一个数组。

1.28 malloc/free、new/delete的异同

一、相同
两者都可以用来动态申请内存释放内存

二、不同

  1. malloc/free是C/C++标准库的函数(库函数)new/delete是C++操作符

  2. 申请内存位置不同
    new操作符是从自由存储区上为对象动态分配内存空间的,malloc函数是从上动态分配内存。
    自由存储区是C++基于new操作符的一个抽象概念, 凡是通过new操作符进行内存申请的, 该内存称为自由存储区。 而自由存储区的位置取决于operator new的实现细节。自由存储区不仅可以是堆, 也可以是静态存储区, 取决operator new在哪里为对象分配内存。

  3. 返回值类型不同
    new操作符内存分配成功时, 返回的是对象类型的指针,而malloc返回的是void*指针, 需要通过强转才能转成我们所需要的类型。

  4. 定制内存大小不同
    malloc/free需要手动计算类型大小,而new/delete编译器可以自己计算类型大小。

  5. 内存分配失败时的返回值
    new内存分配失败时会直接抛bac_alloc异常, 它不会返NULLmalloc分配内存失败时返回NULL

  6. malloc/free只是动态分配内存空间/释放空间。而new/delete除了分配空间还会调用构造函数和析构函数进行初始化与清理(清理成员)。重要!!!

  7. 申请数组时: new[]一次分配所有内存,多次调用构造函数,搭配使用delete[]delete[]多次调用析构函数,销毁数组中的每个对象。而malloc则只能sizeof(int) * n

总结如下图
在这里插入图片描述
三、两点重要说明
(1)对于用户自定义的对象而言,用malloc/free无法满足动态管理对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free因此C++需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete

具体调用是,new分配内存时,先调用malloc后调用构造函数,释放空间时,先调用析构函数,后调用free

(2)既然new/delete的功能完全覆盖了malloc/free,为什么C++还保留malloc/free呢?

因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,理论上讲程序不会出错,但是该程序的可读性很差。所以new/delete、malloc/free必须配对使用。
在这里插入图片描述

四、C++的其他内存管理接口

void * operator new (size_t size);
void operator delete (size_t size);
void * operator new[ ](size_ t size);
void operator delete[ ] (size_t size);

说明:

  1. operator newoperator deleteoperator new[]/operator delete[]malloc/free用法一样是标准库函数,没有重载newdelete,只是名字看起来比较奇怪。
  2. 他们只负责分配空间/释放空间,不会调用对象构造函数/析构函数来初始化/清理;
  3. 实际operator new和operator delete只是malloc和free的一层封装。

1.因此new的作用就是:
调用operator new分配空间。
调用构造函数初始化对象。

2.delete作用
调用析构函数清理对象;
调用operator delete释放空间。

3.new[ ]的作用
调用operator new分配空间。
调用N次构造函数分别初始化每个对象。

4.delete[ ]的作用
调用N次析构函数清理对象。
调用operator delete释放空间。

1.29 C++里是怎么定义常量的?常量存放在内存的哪个位置?

常量在C++里的定义就是一个顶层 const加上对象类型,常量定义必须初始化。对于局部对象,常量存放在栈区,对于全局对象,常量存放在全局/静态存储区。对于字面值常量,常量存放在常量存储区。(常量存储区内的数据不能修改)

1.30 c++ 顶层(top-level)const 和底层(low-level)const

  • 顶层const 指的是指针本身是一个常量变量或者引用自身不能被改变
  • 底层const指的是指针所指的对象是一个常量
int i=0int* const p1=&i;  //不能改变p1的值,这是一个顶层const
const int ci=42;     //不能改变ci的值,变量ci不能被改变,这是一个顶层const
const int *p2=&ci;  //不能改变p2的值,这是一个底层const
const int* const p3=p2;//靠右的const是顶层const,靠左的const是一个底层const
const int &r=ci;//用于声明引用的const,都是底层const(左值引用??)

1.31 const的用法

一、const修饰基本数据类型

  1. const修饰一般常量及数组
const int a=10;               
const int arr[3]={1,2,3};                        
等价的书写方式:     
int const a=10;
int const arr[3]={1,2,3};

对于类似这些基本数据类型,修饰符const可以用在类型说明符前,也可以用在类型说明符后,其结果是一样的。在使用这些常量的时候,只要不改变这些常量的值便好

  1. const修饰指针*
const int * p = &i;         // 指针指向的数据为const,为底层const
int const * p = &i;         // 同上
int * const p = &i;         // 指针本身为const,为顶层const
const int * const p = &i    // 指针本身和指向的内容均为const
  1. const修饰指针&
int const &a = x;
const int &a = x;
// 两种写法都行,表示引用a为const,不能更改
a++; //此时会出错!

二、const应用到函数中

  1. 作为参数的const修饰符
    不能对形参内容进行改编

  2. 作为函数返回值的const修饰符
    使得函数调用表达式不能作为左值。见下面代码中的a.get()=1;


#include <iostream>
#include <string>

using namespace std;
class test{
public:
    int i = 0;
    test(){};
    ~test(){};
    int & foo(){
        return i;
    }
};
int main(){
    test t;
    // 因为要t.foo()要成为左值,因此foo的返回类型为引用
    t.foo() = 2; 
    //如果此时不改为 int & foo() const{},则会成功运行!
    //所以为了安全起见最好在返回值加上const,使得函数调用表达式不能作为左值  
    cout << t.foo() << endl; 
    return 0;
}


三、const在类中的用法
注意是类内的成员函数
const修饰的成员函数表明函数调用不会对对象做出任何更改,事实上,如果确认不会对对象做更改,就应该为函数加上const限定,这样无论const对象还是普通对象都可以调用该函数

知识补充:
C++ const成员变量和成员函数(常成员函数)

  • 函数开头的 const 用来修饰函数的返回值,表示返回值是 const 类型,也就是不能被修改,例如const char * getname()。
  • 函数头部的结尾加上 const 表示常成员函数,这种函数只能读取成员变量的值,而不能修改成员变量的值,例如char * getname() const。使得函数调用表达式不能作为左值

1.32 如果同时定义了两个函数,一个带const,一个不带,会有问题吗?

不会,这相当于函数的重载。

1.33 隐式类型转换

  • 首先,对于内置类型,低精度的变量给高精度变量赋值会发生隐式类型转换
  • 其次,对于只存在单个参数的构造函数的对象构造来说,函数调用可以直接使用该参数传入,编译器会自动调用其构造函数生成临时对象。

隐式转化的危害和explicit

1.34 extern“C”

extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。 加上extern "C"后,会指示编译器这部分代码按C语言的进行编译,而不是C++的。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般之包括函数名。
这个功能十分有用处,因为在C++出现以前,很多代码都是C语言写的,而且很底层的库也是C语言写的,为了更好的支持原来的C代码和已经写好的C语言库,需要在C++中尽可能的支持C,而extern "C"就是其中的一个策略。

例如函数void fun(int, int)

  • 在C++中,编译后的可能是(不同编译器结果不同)_fun_int_int
  • C语言没有类似的重载机制,一般是利用函数名来指明编译后的函数名的,对应上面的函数可能会是_fun这样的名字。
    如果在C++中想要调用在C语言下定义的_fun会失败,因为会编译成_fun_int_int,这时,extern "C"的作用就体现出来了。

1.35 RTTI

运行时类型识别(Run-time type identification , RTTI),是指在只有一个指向基类的指针或引用时,确定所指对象的准确类型的操作。其常被说成是C++的四大扩展之一(其他三个为异常、模板和名字空间)

使用RTTI的两种方法:

  1. typeid()
      第一种就像sizeof(),它看上像一个函数,但实际上它是由编译器实现的。typeid()带有一个参数,它可以是一个对象引用或指针,返回全局typeinfo类的常量对象的一个引用。可以用运算符“= =”和“!=”来互相比较这些对象,也可以用name()来获得类型的名称。如果想知道一个指针所指对象的精确类型,我们必须逆向引用这个指针。

  2. dynamic_cast <type-id> (expression)
      该运算符把expression转换成type-id类型的对象。Type-id 必须是类的指针、类的引用或者void*,不可是对象;如果 type-id 是类指针类型,那么expression也必须是一个指针,如果 type-id 是一个引用,那么 expression 也必须是一个引用。
      dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换。在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的;在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。

1.36 动态多态和静态多态、虚函数表

子类若重写父类虚函数,虚函数表中,该函数的地址会被替换,对于存在虚函数的类的对象,在VS中,对象的对象模型的头部存放指向虚函数表的指针,通过该机制实现多态。

1.36.1 虚函数表

虚函数表的补充
虚函数表的继承

1.36.2 运行时多态(动态多态)

补充
实现方式:虚函数
是指在程序运行时才能确定函数和实现的链接,此时才能确定调用哪个函数,父类指针或者引用能够指向子类对象,调用子类的函数,所以在编译时是无法确定调用哪个函数。
更好的理解:

Person p = new Man();   
p.toString();

Java支持运行时多态,意为p.toString()实际执行p所引用实例的toString(),究竟执行Person类(父类)还是Man类(子类)的方法,运行时再确定。如果Man类声明了toString()方法,则执行之;否则执行Person类的toString()方法。
程序运行时,Java从实例所属的类(new 类)开始寻找匹配的方法执行,如果当前类中没有匹配的方法,则沿着继承关系逐层向上,依次在父类或各祖先类中寻找匹配方法,直到Object类。寻找p.toString()匹配执行方法的过程如下图所示。
在这里插入图片描述

运行期多态的设计思想要归结到类继承体系的设计上去。对于有相关功能的对象集合,我们总希望能够抽象出它们共有的功能集合,在基类中将这些功能声明为虚接口(虚函数),然后由子类继承基类去重写这些虚接口,以实现子类特有的具体功能。典型地我们会举下面这个例子:
在这里插入图片描述

class Animal
{
public :
    virtual void shout() = 0;
};
class Dog :public Animal
{
public:
    virtual void shout(){ cout << "汪汪!"<<endl; }
};
class Cat :public Animal
{
public:
    virtual void shout(){ cout << "喵喵~"<<endl; }
};
class Bird : public Animal
{
public:
    virtual void shout(){ cout << "叽喳!"<<endl; }
};

int main()
{
    Animal * anim1 = new Dog;
    Animal * anim2 = new Cat;
    Animal * anim3 = new Bird;
     
   //藉由指针(或引用)调用的接口,在运行期确定指针(或引用)所指对象的真正类型,调用该类型对应的接口
    anim1->shout();
    anim2->shout();
    anim3->shout();
 
    //delete 对象
    ...
   return 0;
}

运行期多态的实现依赖于虚函数机制。当某个类声明了虚函数时,编译器将为该类对象安插一个虚函数表指针,并为该类设置一张唯一的虚函数表,虚函数表中存放的是该类虚函数地址。运行期间通过虚函数表指针与虚函数表去确定该类虚函数的真正实现。

虚函数表的补充
虚函数表的继承

运行期多态的优势还在于它使处理异质对象集合称为可能:

//我们有个动物园,里面有一堆动物
int main()
{
    vector<Animal*>anims;
 
    Animal * anim1 = new Dog;
    Animal * anim2 = new Cat;
    Animal * anim3 = new Bird;
    Animal * anim4 = new Dog;
    Animal * anim5 = new Cat;
    Animal * anim6 = new Bird;
 
    //处理异质类集合
    anims.push_back(anim1);
    anims.push_back(anim2);
    anims.push_back(anim3);
    anims.push_back(anim4);
    anims.push_back(anim5);
    anims.push_back(anim6);
 
    for (auto & i : anims)
    {
        i->shout();
    }
    //delete对象
    //...
    return 0;
}

总结运行期多态通过虚函数发生于运行期
缺点:运行期间进行虚函数绑定,提高了程序运行开销;庞大的类继承层次,对接口的修改易影响类继承层次;由于虚函数在运行期才绑定,所以编译器无法对虚函数进行优化

1.36.3 编译期多态(静态多态)

实现方式:函数重载和模板
是在编译期就把函数链接起来,此时即可确定调用哪个函数或模板,静态多态是由模板和重载实现的,在宏多态中,是通过定义变量,编译时直接把变量替换,实现宏多态
对模板参数而言,多态是通过模板具现化和函数重载解析实现的。以不同的模板参数具现化导致调用不同的函数,这就是所谓的编译期多态。
相比较于运行期多态,实现编译期多态的类之间并不需要成为一个继承体系,它们之间可以没有什么关系,但约束是它们都有相同的隐式接口。我们将上面的例子改写为:

class Animal
{
public :
    void shout() { cout << "发出动物的叫声" << endl; };
};
class Dog
{
public:
     void shout(){ cout << "汪汪!"<<endl; }
};
class Cat
{
public:
     void shout(){ cout << "喵喵~"<<endl; }
};
class Bird
{
public:
     void shout(){ cout << "叽喳!"<<endl; }
};
template <typename T>
void  animalShout(T & t)
{
    t.shout();
}
int main()
{
    Animal anim;
    Dog dog;
    Cat cat;
    Bird bird;
 
    animalShout(anim);
    animalShout(dog);
    animalShout(cat);
    animalShout(bird);
 
    getchar();
}

在编译之前,函数模板中t.shout()调用的是哪个接口并不确定。在编译期间,编译器推断出模板参数,因此确定调用的shout是哪个具体类型的接口。不同的推断结果调用不同的函数,这就是编译器多态。这类似于重载函数在编译器进行推导,以确定哪一个函数被调用。

1.37 C++中拷贝赋值函数的形参能否进行值传递?

1.37.1 拷贝构造和赋值构造

#include <iostream>
using namespace std;

class Distance {
private:
	int feet;
	int inches;

public:
	Distance() {
		feet = 0;
		inches = 0;
	}
	Distance(int x, int y) {
		feet = x;
		inches = y;
	}
	// 拷贝构造
	Distance(const Distance& obj) {
		this->feet = obj.feet;
		this->inches = obj.inches;
	}

	// 赋值构造(重载=运算符)
	void operator=(const Distance& D) {
		this->feet = D.feet;
		this->inches = D.inches;		
	}
	void displayDistance() {
		cout << this->feet << " " << this->inches << endl;
	}


};
int main() {
	Distance D1(11, 10), D2(5, 11);
	cout << "First Distance : ";
	D1.displayDistance();
	cout << "Second Distance :";
	D2.displayDistance();

	// 使用赋值构造
	D1 = D2;
	cout << "First Distance :";
	D1.displayDistance();

	// 使用拷贝构造
	Distance D3(5, 20);
	Distance D4(D3);
	cout << "4 Distance :";
	D4.displayDistance();

	return 0;
}

string类的拷贝构造与赋值构造

技术总结:

  1. my_string内要内置私有成员:char* m_data,用来指向所要表示的字符串。
  2. 构造函数
    • 如果为空指针,则只用开辟一片区域,只放'\0'
    • 不然的话利用strcpy拷贝,存入数据
  3. 拷贝构造函数
    • 开辟一片新的内存区域
    • 利用strcpy拷贝
  4. 赋值构造函数
    • 先检查是否赋值自身,如果是自身,就返回*this
    • 不是的话,删掉原来内存区域,开辟一片新的
    • 利用strcpy拷贝
#include <iostream>
#include <vector>
#include <string>
#include <sstream>
#include <typeinfo>
#include "io_ues.h"
#include <cstring>


using namespace std;
class my_string{
public:
	// 构造函数
	my_string(const char* str = nullptr){
		if(str != nullptr){
			m_data = new char[strlen(str)+1];
			strcpy(m_data,str);
		}else{
			m_data = new char[1];
			*m_data = '\0';
		}
	};

	// 拷贝构造函数
	my_string(const my_string & others){
		m_data = new char[strlen(others.m_data)+1];
		strcpy(m_data,others.m_data);
	};

	// 赋值构造函数
	my_string & operator = (const my_string &others){
		if(this->m_data == others.m_data) return  *this;
		else{
			this->m_data = new char[strlen(others.m_data)+1];
			delete[] m_data;
			m_data = new char[strlen(others.m_data)+1];
			strcpy(m_data,others.m_data);
			return *this;
		}
	};

	void display(const my_string &obj){
		cout << m_data << endl;
	};
	~my_string(){
		delete[] m_data;
	};
private:
	char* m_data;	
};
int main(){
	my_string s1("hello");
	my_string s2(s1);//拷贝构造
	my_string s3;
	s3 = s1; //fu
	s1.display(s1);
	s1.display(s2);
	s1.display(s3);
}

1.37.2 回答问题

如果构造函数为

	Distance(const Distance obj) {
		this->feet = obj.feet;
		this->inches = obj.inches;
	}

不能。如果是这种情况下,调用拷贝构造函数的时候,首先要将实参传递给形参,这个传递的时候又要调用拷贝构造函数。如此循环,无法完成拷贝,栈也会满。
此时,obj是形参,句子Distance D4(D3)里的D3是实参,在调用拷贝构造函数时,首先要将D3传递给obj,这是需要又需要调用拷贝构造函数,这样会造成死循环。

补充知识
实参和形参

  1. 形参(parameter):
    全称为"形式参数" 由于它不是实际存在变量,所以又称虚拟变量。是在定义函数名和函数体的时候使用的参数,目的是用来接收调用该函数时传入的参数.在调用函数时,实参将赋值给形参。因而,必须注意实参的个数,类型应与形参一一对应,并且实参必须要有确定的值。

  2. 实参(argument):
    全称为"实际参数"是在调用时传递给函数的参数. 实参可以是常量、变量、表达式、函数等, 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。 因此应预先用赋值,输入等办法使实参获得确定值

当形参是引用类型时,对应的实参被引用传递,引用形参是对应的实参的别名。
实参的值被拷贝给形参时,形参和实参是两个相互独立的对象,对应的实参被值传递。

1.38 select(?)

select在使用前,先将需要监控的描述符对应的bit位置1,然后将其传给select,当有任何一个事件发生时,select将会返回所有的描述符,需要在应用程序自己遍历去检查哪个描述符上有事件发生,效率很低,并且其不断在内核态和用户态进行描述符的拷贝,开销很大

1.39 移动构造函数

右值引用,移动语义和完美转发
回顾一下如何用c++实现一个字符串类MyStringMyString内部管理一个C语言的char *数组,这个时候一般都需要实现拷贝构造函数拷贝赋值函数,因为默认的拷贝是浅拷贝,而指针这种资源不能共享,不然一个析构了,另一个也就完蛋了。
具体代码如下:

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

class MyString
{
public:
    static size_t CCtor; //统计调用拷贝构造函数的次数
    static size_t MCtor; //统计调用移动构造函数的次数
    static size_t CAsgn; //统计调用拷贝赋值函数的次数
    static size_t MAsgn; //统计调用移动赋值函数的次数

public:
    // 构造函数
   MyString(const char* cstr=0){
       if (cstr) {
          m_data = new char[strlen(cstr)+1];
          strcpy(m_data, cstr);
       }
       else {
          m_data = new char[1];
          *m_data = '\0';
       }
   }

   // 拷贝构造函数
   MyString(const MyString& str) {
       CCtor ++;
       m_data = new char[ strlen(str.m_data) + 1 ];
       strcpy(m_data, str.m_data);
   }
   // 移动构造函数
   MyString(MyString&& str) noexcept
       :m_data(str.m_data) {
       MCtor ++;
       str.m_data = nullptr; //不再指向之前的资源了
   }

   // 拷贝赋值函数 =号重载
   MyString& operator=(const MyString& str){
       CAsgn ++;
       if (this == &str) // 避免自我赋值!!
          return *this;

       delete[] m_data;
       m_data = new char[ strlen(str.m_data) + 1 ];
       strcpy(m_data, str.m_data);
       return *this;
   }

   // 移动赋值函数 =号重载
   MyString& operator=(MyString&& str) noexcept{
       MAsgn ++;
       if (this == &str) // 避免自我赋值!!
          return *this;

       delete[] m_data;
       m_data = str.m_data;
       str.m_data = nullptr; //不再指向之前的资源了
       return *this;
   }

   ~MyString() {
       delete[] m_data;
   }

   char* get_c_str() const { return m_data; }
private:
   char* m_data;
};
size_t MyString::CCtor = 0;
size_t MyString::MCtor = 0;
size_t MyString::CAsgn = 0;
size_t MyString::MAsgn = 0;
int main()
{
    vector<MyString> vecStr;
    vecStr.reserve(1000); //先分配好1000个空间
    for(int i=0;i<1000;i++){
        vecStr.push_back(MyString("hello"));
    }
    cout << "CCtor = " << MyString::CCtor << endl;
    cout << "MCtor = " << MyString::MCtor << endl;
    cout << "CAsgn = " << MyString::CAsgn << endl;
    cout << "MAsgn = " << MyString::MAsgn << endl;
}

/* 结果
CCtor = 0
MCtor = 1000
CAsgn = 0
MAsgn = 0
*/

可以看到,移动构造函数与拷贝构造函数的区别是,

  • 拷贝构造的参数是const MyString& str,是常量左值引用
  • 移动构造的参数是MyString&& str,是右值引用,而MyString(“hello”)是个临时对象,是个右值,优先进入移动构造函数而不是拷贝构造函数。移动构造函数与拷贝构造不同,它并不是重新分配一块新的空间,将要拷贝的对象复制过来,而是"偷"了过来,将自己的指针指向别人的资源,然后将别人的指针修改为nullptr,这一步很重要,如果不将别人的指针修改为空,那么临时对象析构的时候就会释放掉这个资源,"偷"也白偷了。下面这张图可以解释copy和move的区别。

在这里插入图片描述
不用奇怪为什么可以抢别人的资源,临时对象的资源不好好利用也是浪费,因为生命周期本来就是很短,在你执行完这个表达式之后,它就毁灭了,充分利用资源,才能很高效。

我的理解:

  • 拷贝构造函数:实参到形参需要调用一次拷贝构造函数,临时变量到return需要调用一次拷贝构造函数,总共需要两次
  • 移动拷贝构造:实参到形参需要调用一次拷贝构造函数,此时构造出一个临时变量,此时直接return这个临时变量(指针的转化),就不用再构造了

1.39.1 移动构造函数何时会被调用,何时不会被调用

在这里插入图片描述

#pragma once
#include<iostream>
#include<string>
#include<vector>
using namespace std;
class Test
{
public:
	Test(const string& s = "hello world") :str(new string(s)) { cout << "默认构造函数" << endl; };
	Test(const Test& t);
	Test& operator=(const Test& t);
	Test(Test&& t)noexcept;
	Test& operator=(Test&& t)noexcept;
	~Test();
public:
	string* str;
};
Test::Test(const Test& t)
{
	str = new string(*(t.str));
	cout << "拷贝构造函数" << endl;
}
Test& Test::operator=(const Test& t)
{
	cout << "拷贝赋值运算符" << endl;
	return *this;
}
Test::Test(Test&& t)noexcept
{
	str = t.str;
	t.str = nullptr;
	cout << "移动构造函数" << endl;
}
Test& Test::operator=(Test&& t)noexcept
{
	cout << "移动赋值运算符" << endl;
	return *this;
}
Test::~Test()
{
	cout << "析构函数" << endl;
}

using namespace std;
int main()
{
	vector<Test> vec(1);
	Test t("what");
	vec.push_back(std::move(t));
	return 0;
}

将移动构造函数与移动赋值构造都声明为noexpect,就可以优先使用移动构造函数,因为此时告诉编译器,noexpect定义的移动构造函数是安全的。

1.40 noexcept

从C++11开始,我们能看到很多代码当中都有关键字noexcept。比如下面就是std::initializer_list的默认构造函数,其中使用了noexcept

      constexpr initializer_list() noexcept
      : _M_array(0), _M_len(0) { }

该关键字告诉编译器,函数中不会发生异常,这有利于编译器对程序做更多的优化
如果在运行时,noexecpt函数向外抛出了异常(如果函数内部捕捉了异常并完成处理,这种情况不算抛出异常),程序会直接终止,调用std::terminate()函数,该函数内部会调用std::abort()终止程序。

使用noexcept表明函数或操作不会发生异常,会给编译器更大的优化空间。然而,并不是加上noexcept就能提高效率,步子迈大了也容易扯着蛋。
以下情形鼓励使用noexcept

  • 移动构造函数(move constructor)
  • 移动分配函数(move assignment)
  • 析构函数(destructor)。这里提一句,在新版本的编译器中,析构函数是默认加上关键字noexcept的
  • 叶子函数(Leaf Function)。叶子函数是指在函数内部不分配栈空间,也不调用其它函数,也不存储非易失性寄存器,也不处理异常。

1.41 浅拷贝和深拷贝

具体看这个就行,也是用自定义string类举了个例子

1.42 volatile关键字

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
声明时语法:int volatile vInt;,当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。

#include <stdio.h>

void main() {
    int i = 10;
    int a = i;

    printf("i = %d", a);

    // 下面汇编语句的作用就是改变内存中 i 的值
    // 但是又不让编译器知道
    __asm {
	        mov dword ptr [ebp-4], 20h
    }

    int b = i;
    printf("i = %d", b);
}

然后,在 Debug 版本模式运行程序,输出结果如下:

i = 10
i = 32

然后,在 Release 版本模式运行程序,输出结果如下:

i = 10
i = 10

输出的结果明显表明,Release 模式下,编译器对代码进行了优化,第二次没有输出正确的 i 值。

volatile int i = 10;可以避免以上情况。这说明这个 volatile 关键字发挥了它的作用。其实不只是“内嵌汇编操纵栈”这种方式属于编译无法识别的变量改变,另外更多的可能是多线程并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程 visible。一般说来,volatile用在如下的几个地方:

  • 中断服务程序中修改的供其它程序检测的变量需要加volatile;
  • 多任务环境下各任务间共享的标志应该加volatile;
  • 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;

1.43 extern

extern是一个关键字,它告诉编译器存在着一个变量或者一个函数,如果在当前编译语句的前面中没有找到相应的变量或者函数,也会在当前文件的后面或者其它文件中定义。

extern 与头文件(*.h)的区别和联系
对变量而言,如果你想在本源文件(例如文件名A)中使用另一个源文件(例如文件名B)的变量,方法有2种:

  1. 在A文件中必须用extern声明在B文件中定义的变量(当然是全局变量);
  2. 在A文件中添加B文件对应的头文件,当然这个头文件包含B文件中的变量声明,也即在这个头文件中必须用extern声明该变量,否则,该变量又被定义一次。

对函数而言,如果你想在本源文件(例如文件名A)中使用另一个源文件(例如文件名B)的函数,方法有2种:

  1. 在A文件中用extern声明在B文件中定义的函数(其实,也可省略extern,只需在A文件中出现B文件定义函数原型即可);
  2. 在A文件中添加B文件对应的头文件,当然这个头文件包含B文件中的函数原型,在头文件中函数可以不用加extern。

1.45 源码、反码和补码的区别和意义

回答:源码就是二进制,反码通常是为了解决加法的运算问题,把减法变成了加法。而补码是为了解决如果只是用反码进行加法运算,可能会出现的正负零的问题。
一、源码
假设机器能处理的位数为8。最高位存放符号(0为正,1为负)
1 [源码](0000 0001
-1 [源码](1000 0001
-5 [源码](1000 0101

二、反码
正数的反码和源码相同,负数的反码为,符号位不变,其余的都0变1,1变0
1 [反码](0000 0001
-1 [反码](1111 1110
-5 [反码](1111 1010

三、补码
正数的补码和源码、反码相同,负数的补码为反码+1
1 [补码](0000 0001
-1 [补码](1111 1111
-5 [补码](1111 1011

四、意义
(00000001)(1)原 + (10000001)(-1)原 = (10000010)(-2)原。显然不正确。

反码 :解决负数加法运算问题,将减法运算转换为加法运算,从而简化运算规则;

(00000001) (1)反+ (11111110)反(-1) = (11111111)(-0)反。有点小问题。
(00000001)(1) 反+ (11111101)(-2)反 = (11111110)(-1)反。正确
补码 :解决负数加法运算正负零问题,弥补了反码的不足。
总之,反码与补码都是为了解决负数运算问题,跟正数没关系,因此,不管是正整数还是正小数,原码,反码,补码都全部相同。

1.46 C++中NULL和nullptr的区别

C中NULL是void*空指针,可以隐式转换成相应的类型。但是NULL在C++中被宏定义为了0,因为C++不支持void*的隐式转换,因此定义了nullptr关键字,这个关键字可以转换成相应类型的空指针。

1.47 存储容量(空间)换算公式

1 KB = 2^10(1024) B
1 MB = 2^10(1024) KB
1 GB = 2^10(1024) MB
1 TB = 2^10(1024) GB
1 PB = 2^10(1024) TB
1 EB = 2^10(1024) PB
1 ZB = 2^10(1024) EB
1 YB = 2^10(1024) ZB

1.48 move的底层是实现

std::move的功能是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);

  • C++ 标准库使用比如vector::push_back等这类函数时,会对参数的对象进行复制,连数据也会复制.这就会造成对象内存的额外创建,本来原意是想把参数push_back进去就行了,通过std::move,可以避免不必要的拷贝操作。
  • std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝所以可以提高利用效率,改善性能.。转移后,原对象不能继续使用。
  • 对指针类型的标准库对象并不需要这么做。

1.49 内联和宏定义在使用上的区别

内联函数与宏的区别:

  • 内联函数与普通函数相比,可以加快程序的运行速度,因为不需要中断调用 ,在编译的时候内联函数可以直接镶嵌到目标代码中。
  • 而宏只是简单的字符替换
  • 内联函数要做参数类型检查,这是内联函数跟宏相比的优势。

inline一般只用于如下情况:

  1. 一个函数不断被重复调用
  2. 函数只有简单的几行 ,且不包含for、while、switch等语句

1.50 函数模板与类模板

C++ 常见的模板类
迭代器
顺序容器:vector,deque,list,queue(适配器模板类),priority_queue,stack
联合容器:set,multimap,unordered_map,

#include<iostream>
#include<string>
#include<vector>
using namespace std;
template <class T1, class T2>
class test {
public:
	T1 t1;
	T2 t2;
	test(T1 x,T2 y) :t1(x), t2(y) {};
	void display() {
		cout << t1 << ' ' << t2 << endl;
	}
};

template <typename T>
void foo(T t) {
	cout << t << endl;
}

int main()
{
	test<int,string> t(1 ,"sfd");
	t.display();
	foo<int>(1);
	return 0;
}

1.51 lambda表达式

C++11的一大亮点就是引入了Lambda表达式。利用Lambda表达式,可以方便的定义和创建匿名函数。对于C++这门语言来说来说,“Lambda表达式”或“匿名函数”这些概念听起来好像很深奥,但很多高级语言在很早以前就已经提供了Lambda表达式的功能,如C#,Python等。今天,我们就来简单介绍一下C++中Lambda表达式的简单使用。
lambda详解
附上两个例子:
一、

#include<iostream>
#include<string>
#include<vector>
#include<algorithm>

using namespace std;

int main()
{
	vector<vector<int>> num = { {1,3},{0,2},{1,4},{1,1},{5,8},{0,3},{2,6} };
	sort(num.begin(), num.end(), [](vector<int>& a, vector<int>& b) -> bool {
		if (a[0] != b[0]) return a[0] > b[0];
		else return a[1] < b[1];
		});
	for (auto p : num) {
		for (auto q : p) {
			cout << q << ' ';
		}
		cout << endl;
	}
	return 0;
}

二、

int main()
{
    int a = 123;
    auto f = [a] { cout << a << endl; }; 
    a = 321;
    f(); // 输出:123
}

1.52 可变参数列表

#include <iostream>
#include <cstdarg>
using namespace std;
int sum(int count, ...);
int main()
{
	cout << sum(5, 4, 6, 8, 10, 12) << endl;
	cout << sum(8, 22, 33, 44, 55, 66, 77, 88, 99) << endl;
	return 0;
}

int sum(int count, ...)
{
	if (count <= 0)
	{
		return 0;
	}

	//为了实现可变参数列表,首先需要声明一个va_list类型的指针
	//va_list类型是在cstdarg头文件里面定义的,该指针用来依次
	//指向各个参数

	//va_start是一个宏,用来初始化arg_ptr,使其指向列表的第一个
	//参数,这个宏的第二个参数是sum函数参数列表省略号前得固定参    
	//数的名称,用来确定第一个参数的位置    
	va_list arg_ptr;
	va_start(arg_ptr, count);

	int CountSum = 0;

	//va_arg是一个宏,返回arg_ptr指向的
	//参数位置,并使arg_ptr递增来指向下
	//一个参数值

	//va_arg宏的第二个参数是需要统计的第
	//一个参数的类型,如果类型不正确,
	//程序也可能会执行,但得到的是无用的
	//数据,arg_ptr将被错误地递增
	for (int i = 0; i < count; ++i)
	{
		CountSum += va_arg(arg_ptr, int);
	}
	//将va_list类型的指针复位成空值
	//就是清空可变参数列表
	va_end(arg_ptr);

	return CountSum;
}

2 容器和算法

容器名称底层实现
map红黑树
unordered_map哈希表
multimap红黑树
set红黑树
vector一段连续内存
list双向链表(或者双向循环链表)
stack适配器(deque)
queue适配器(deque)
deque一段一段等长的连续空间构成,用vector记录首地址

2.1 map和set

mapset都是C++的关联容器,其底层实现都是红黑树(RB-Tree)。由于 map 和set所开放的各种操作接口,RB-tree 也都提供了,所以几乎所有的 map 和set的操作行为,都只是转调 RB-tree 的操作行为。
mapset区别在于:

(1)map中的元素是key-value(关键字—值) 对:关键字起到索引的作用,值则表示与索引相关联的数据;set与之相对就是关键字的简单集合,set中每个元素只包含一个关键字

(2)set的迭代器是const的,不允许修改元素的值map允许修改value,但不允许修改key。其原因是因为 mapset是根据关键字排序来保证其有序性的,如果允许修改key的话,那么首先需要删除该键,然后调节平衡,再插入修改后的键值,调节平衡,如此一来,严重破坏了mapset的结构,导致iterator失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const,不允许修改迭代器的值;而map的迭代器则不允许修改key值,允许修改value值。
(不能修改,但是可以删除?)

(3)map支持下标操作set不支持下标操作。map可以用key做下标,map的下标运算符[]将关键码作为下标去执行查找,如果关键码不存在,则插入一个具有该关键码和mapped_type类型默认值的元素至map中,因此下标运算符[]在map应用中需要慎用,const_map不能用,只希望确定某一个关键值是否存在而不希望插入元素时也不应该使用,mapped_type类型没有默认值也不应该使用。如果find能解决需要,尽可能用find。

map的下标操作
例如:map<int,int> mymap;
mymap[num];对于这个下标操作,

  • 如果存在num关键字,则返回对应的值;
  • 如果不存在num关键字,则创建一个键值对,键为num,值为值类型的默认初始化值

注意:

  • 由于下标运算符可能插入一个新元素,我们只能对constmap 使用下标操作符
  • 如果只希望访问map的元素,不希望因为访问的关键字不存在而创建一个新的元素,可以使用mymap.at(key),如果不存在关键字key会抛出异常,如果存在key则访问其对应的值。

2.2 STL的组成

stl有容器空间配置器适配器迭代器仿函数以及算法这6个组件
它们六者关系大概如下:容器通过空间配置器取得数据存储空间,算法通过迭代器获取容器内容,仿函数可以协助算法完成不同的策略变化,配接器可以修饰或套界仿函数。

  1. 容器(Containers):各种数据结构,如Vector,List,Deque,Set,Map,用来存放数据,STL容器是一种Class Template,就体积而言,这一部分很像冰山载海面的比率。
  2. 算法(Algorithms):各种常用算法如Sort,Search,Copy,Erase,从实现的角度来看,STL算法是一种Function Templates(函数模版)。
  3. 迭代器(Iterators):扮演容器与算法之间的胶合剂,是所谓的“泛型指针”,共有五种类型,以及其它衍生变化,从实现的角度来看,迭代器是一种将:Operators*,Operator->,Operator++,Operator--等相关操作予以重载的Class Template(类模板)。所有STL容器都附带有自己专属的迭代器——是的,只有容器设计者才知道如何遍历自己的元素,原生指针(Native pointer)也是一种迭代器。
  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。

STL的分配器用于封装STL容器在内存管理上的底层细节。在C++中,其内存配置和释放如下:

  • new运算分两个阶段:(1)调用::operator new配置内存;(2)调用对象构造函数构造对象内容

  • delete运算分两个阶段:(1)调用对象析构函数;(2)调用::operator delete释放内存

为了精密分工,STL allocator将两个阶段操作区分开来:

  • 内存配置由alloc::allocate()负责,内存释放由alloc::deallocate()负责;
  • 对象构造由::construct()负责,对象析构由::destroy()负责。

同时为了提升内存管理的效率,减少申请小内存造成的内存碎片问题,SGI STL采用了两级配置器,当分配的空间大小超过128B时,会使用第一级空间配置器;当分配的空间大小小于128B时,将使用第二级空间配置器。

  • 第一级空间配置器直接使用malloc()realloc()free()函数进行内存空间的分配和释放,
  • 第二级空间配置器采用了 内存池技术,通过空闲链表来管理内存。

2.3 STL迭代器删除元素

这个主要考察的是迭代器失效的问题。
总结

迭代器失效分三种情况考虑,也是分三种数据结构考虑,分别为数组型链表型树型数据结构

数组型数据结构(vector,queue…):该数据结构的元素是分配在连续的内存中,inserterase操作,都会使得删除点和插入点之后的元素挪位置,所以,插入点和删除掉之后的迭代器全部失效,也就是说insert(*iter)(或erase(*iter)),然后在iter++,是没有意义的。解决方法:erase(*iter)的返回值是下一个有效迭代器的值。 iter = cont.erase(iter);

链表型数据结构(list):对于list型的数据结构,使用了不连续分配的内存,删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器.解决办法两种,erase(*iter)会返回下一个有效迭代器的值,或者erase(iter++).

树形数据结构(map): 使用红黑树来存储数据,插入不会使得任何迭代器失效;删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器。erase迭代器只是被删元素的迭代器失效,但是返回值为void,所以要采用erase(iter++)的方式删除迭代器。

注意:经过erase(iter)之后的迭代器完全失效,该迭代器iter不能参与任何运算,包括iter++,*iter

  1. 对于序列容器vector,deque来说,使用erase(itertor)后,后边的每个元素的迭代器都会失效,但是后边每个元素都会往前移动一个位置,但是erase会返回下一个有效的迭代器;(这个往前)
// 删除大于3的元素
int main() {
    vector<int> ans = {1, 2, 3, 4, 5, 6};
    vector<int>::iterator it;
    for (it = ans.begin(); it != ans.end();) {
        if (*it > 3)
            it = ans.erase(it);
        else
            it++;
    }
    vectordisplay(ans);

    return 0;
}
  1. 对于关联容器map set来说,使用了erase(iterator)后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素的,不会影响到下一个元素的迭代器,所以在调用erase之前,记录下一个元素的迭代器即可

STL的map表里有一个erase方法用来从一个map中删除掉指令的节点
eg1:

map<string,string> mapTest;
typedef map<string,string>::iterator ITER;

ITER iter=mapTest.find(key);
mapTest.erase(iter);

像上面这样只是删除单个节点,map的形为不会出现任务问题,
但是当在一个循环里用的时候,往往会被误用,那是因为使用者没有正确理解iterator的概念.
像下面这样的一个例子就是错误的写法,

eg2:

for(ITER iter=mapTest.begin();iter!=mapTest.end();++iter)
{
cout<<iter->first<<":"<<iter->second<<endl;
mapTest.erase(iter);
}

这是一种错误的写法,会导致程序行为不可知.究其原因是map 是关联容器,对于关联容器来说,如果某一个元素已经被删除,那么其对应的迭代器就失效了,不应该再被使用;否则会导致程序无定义的行为。
可以用以下方法解决这问题:
正确的写法
(1).使用删除之前的迭代器定位下一个元素。STL建议的使用方式

for(ITER iter=mapTest.begin();iter!=mapTest.end();)
{
cout<<iter->first<<":"<<iter->second<<endl;
mapTest.erase(iter++);
}

(2).erase() 成员函数返回下一个元素的迭代器
erase()的返回值是下一个结点

for(ITER iter=mapTest.begin();iter!=mapTest.end();)
{
cout<<iter->first<<":"<<iter->second<<endl;
iter=mapTest.erase(iter);
}

3.对于list来说,它使用了不连续分配的内存,并且它的erase方法也会返回下一个有效的iterator,因此上面两种正确的方法都可以使用。

#include <iostream>
#include <list>
#include <map>
#include <unordered_map>
#include <vector>

using namespace std;
int main() {
	list<int> test = { 1, 2, 3, 4 };
	list<int>::iterator it;
	for (it = test.begin(); it != test.end();) {
		if (*it > 2) it = test.erase(it);
		else it++;
	}
	for (it = test.begin(); it != test.end(); it++) {
		cout << *it << endl;
	}
}

或者写成

#include <iostream>
#include <list>
#include <map>
#include <unordered_map>
#include <vector>

using namespace std;
int main() {
	list<int> test = { 1, 2, 3, 4 };
	list<int>::iterator it;
	for (it = test.begin(); it != test.end();) {
		if (*it > 2) test.erase(it++);
		else it++;
	}
	for (it = test.begin(); it != test.end(); it++) {
		cout << *it << endl;
	}
}

2.4 STL中MAP数据存放形式

红黑树。unordered map底层结构是哈希表
set,用的是红黑树;hash_set,底层用得是hash table

2.5 STL的基本组成

STL主要由:以下六部分组成:
容器,迭代器,仿函数,算法,分配器,配接器
他们之间的关系:分配器给容器分配存储空间,算法通过迭代器获取容器中的内容,仿函数可以协助算法完成各种操作,配接器用来套接适配仿函数

2.6 map和unordered_map

  • 需要引入的头文件不同
    map: #include < map >
    unordered_map: #include < unordered_map >
  • 内部实现机理不同

map: map内部实现了一个红黑树(红黑树是非严格平衡二叉搜索树,而AVL是严格平衡二叉搜索树),红黑树具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素。因此,对于map进行的查找删除添加等一系列的操作都相当于是对红黑树进行的操作,复杂度为O(logn)。map中的元素是按照二叉搜索树(又名二叉查找树、二叉排序树,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值)存储的,使用中序遍历可将键值按照从小到大遍历出来。

unordered_map:unordered_map内部实现了一个哈希表(也叫散列表,通过把关键码值映射到Hash表中一个位置来访问记录,查找的时间复杂度可达到O(1),其在海量数据处理中有着广泛应用)。因此,其元素的排列顺序是无序的。

  • 优缺点以及适用处

map

  1. 优点:
    • 有序性,这是map结构最大的优点,其元素的有序性在很多应用中都会简化很多的操作
    • 红黑树,内部实现一个红黑书使得map的很多操作在lgn的时间复杂度下就可以实现,因此效率非常的高
  2. 缺点: 空间占用率高,因为map内部实现了红黑树,虽然提高了运行效率,但是因为每一个节点都需要额外保存父节点、孩子节点和红/黑性质,使得每一个节点都占用大量的空间
  3. 适用处:对于那些有顺序要求的问题,用map会更高效一些

unordered_map

  1. 优点: 因为内部实现了哈希表,因此其查找速度非常的快
  2. 缺点: 哈希表的建立比较耗费时间
  3. 适用处:对于查找问题,unordered_map会更加高效一些,因此遇到查找问题,常会考虑一下用unordered_map

总结:

  • 内存占有率的问题就转化成红黑树 VS hash表 , 还是unorder_map占用的内存要高。
  • 但是unordered_map执行效率要比map高很多
  • 对于unordered_map或unordered_set容器,其遍历顺序与创建该容器时输入的顺序不一定相同,因为遍历是按照哈希表从前往后依次遍历的

2.7 map与multimap

map和unordered_map的差别和使用
1、Map
map 的所有元素都是 pair,同时拥有实值(value)和键值(key)。pair 的第一元素被视为键值,第二元素被视为实值。所有元素都会根据元素的键值自动被排序。不允许键值重复
底层实现:红黑树
适用场景:有序键值对不重复映射
用法和unordered_map类似
堆也有pair的排序功能,但是堆是一个完全二叉树,复杂度为O(logn)

2、Multimap
多重映射。multimap 的所有元素都是 pair,同时拥有实值(value)和键值(key)。pair 的第一元素被视为键值,第二元素被视为实值。所有元素都会根据元素的键值自动被排序。允许键值重复。
底层实现:红黑树
适用场景:有序键值对可重复映射

#include <iostream>  
#include <unordered_map>  
#include <map>
#include <string>  
using namespace std;  
int main()  
{  

    multimap<int, string> myMap={
    { 1, "test1" },
    { 2, "test2" },
    { 3, "test3" },
    { 4, "test4" },
    { 5, "test5" },
    { 6, "test6" },
    { 2, "test2_" }};// test_是为了测试mulitmap的重复元素属性
    auto iter = myMap.begin();
    while (iter!= myMap.end())
    {  
        cout << iter->first << "," << iter->second << endl;  
        ++iter;  
    }  

    return 0;  
}  
1,test1
2,test2
2,test2_ //如果使用的是map,则不会出现这一行
3,test3
4,test4
5,test5
6,test6

map 与 multimap是存储key-value(键-值 对)类型的容器。
不同之处在于:map只允许key与 value一一对应;multimap一个key可对应多个value

在这里插入图片描述

2.8 vector和list(链表)

1、概念

1)Vector
连续存储的容器,动态数组,在堆上分配空间
底层所采用的数据结构非常简单,就只是一段连续的线性内存空间
通过分析 vector 容器的源代码不难发现,它就是使用 3 个迭代器(可以理解成指针)来表示的:

//_Alloc 表示内存分配器,此参数几乎不需要我们关心
template <class _Ty, class _Alloc = allocator<_Ty>>
class vector{
    ...
protected:
    pointer _Myfirst;
    pointer _Mylast;
    pointer _Myend;
};

其中,

  • _Myfirst 指向的是 vector 容器对象的起始字节位置;
  • _Mylast 指向当前最后一个元素的末尾字节;
  • _myend 指向整个 vector 容器所占用内存空间的末尾字节。

下图演示了以上这 3 个迭代器分别指向的位置。
xiatu
如图 1所示,通过这 3 个迭代器,就可以表示出一个已容纳 2 个元素,容量为 5 的 vector 容器。
对于空的 vector 容器,由于没有任何元素的空间分配,因此 _Myfirst、_Mylast 和 _Myend 均为 null。
通过灵活运用这 3 个迭代器,vector 容器可以轻松的实现诸如首尾标识、大小、容器、空容器判断等几乎所有的功能,比如:

template <class _Ty, class _Alloc = allocator<_Ty>>
class vector{
public:
    iterator begin() {return _Myfirst;}
    iterator end() {return _Mylast;}
    size_type size() const {return size_type(end() - begin());}
    size_type capacity() const {return size_type(_Myend - begin());}
    bool empty() const {return begin() == end();}
    reference operator[] (size_type n) {return *(begin() + n);}
    reference front() { return *begin();}
    reference back() {return *(end()-1);}
    ...
};

两倍容量增长:
vector 增加(插入)新元素时,如果未超过当时的容量,则还有剩余空间,那么直接添加到最后(插入指定位置),然后调整迭代器。
如果没有剩余空间了,

  1. 则会重新配置原有元素个数的两倍空间,
  2. 然后将原空间元素通过复制的方式初始化新空间,
  3. 再向新空间增加元素,最后析构并释放原空间之前的迭代器会失效。

性能:
在这里插入图片描述

  • 访问:O(1)
  • 插入:在最后插入(空间够):很快
  • 在最后插入(空间不够):需要内存申请和释放,以及对之前数据进行拷贝。
  • 在中间插入(空间够):内存拷贝
  • 在中间插入(空间不够):需要内存申请和释放,以及对之前数据进行拷贝。
  • 删除:在最后删除:很快
  • 在中间删除:内存拷贝
  • 适用场景:经常随机访问,且不经常对非尾节点进行插入删除。

2)List
动态链表,在上分配空间,每插入一个元数都会分配空间,每删除一个元素都会释放空间。
底层:双向链表(SGI STL中使用双向循环列表

性能:
在这里插入图片描述

访问:随机访问性能很差,只能快速访问头尾节点。
插入:很快,一般是常数开销
删除:很快,一般是常数开销
适用场景:经常插入删除大量数据

2、区别

1)vector底层实现是数组;list是双向链表
2)vector支持随机访问,list不支持。
3)vector是顺序内存,list不是。
4)vector在中间节点进行插入删除会导致内存拷贝,list不会。
5)vector一次性分配好内存,不够时才进行2倍扩容;list每次插入新节点都会进行内存申请
6)vector随机访问性能好,插入删除性能差;list随机访问性能差,插入删除性能好

3、应用

vector拥有一段连续的内存空间,因此支持随机访问,如果需要高效的随即访问,而不在乎插入和删除的效率,使用vector。
list拥有一段不连续的内存空间,如果需要高效的插入和删除,而不关心随机访问,则应使用list。

2.9 STL中迭代器的作用,有指针为何还要迭代器

1、迭代器
Iterator(迭代器)模式又称Cursor(游标)模式,用于提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示。或者这样说可能更容易理解:Iterator模式是运用于聚合对象的一种模式,通过运用该模式,使得我们可以在不知道对象内部表示的情况下,按照一定顺序(由iterator提供的方法)访问聚合对象中的各个元素

由于Iterator模式的以上特性:与聚合对象耦合,在一定程度上限制了它的广泛运用,一般仅用于底层聚合支持类,如STL的list、vector、stack等容器类及ostream_iterator等扩展iterator

聚合在信息科学中是指对有关的数据进行内容挑选、分析、归类,最后分析得到人们想要的结果,主要是指任何能够从数组产生标量值的数据转换过程 。近年来随着大数据的发展,聚合技术已经广泛地应用于文本分析,信息安全,网络传输等领域。

2、迭代器和指针的区别

迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的一些功能,通过重载了指针的一些操作符->*++--等。迭代器封装了指针,是一个“可遍历STL( Standard Template Library)容器内全部或部分元素”的对象, 本质是封装了原生指针,是指针概念的一种提升(lift),提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。

迭代器返回的是对象引用而不是对象的值,所以cout只能输出迭代器使用*取值后的值而不能直接输出其自身。

3、迭代器产生原因

Iterator类的访问方式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果。

2.12 STL里resize和reserve的区别

resize():改变当前容器内含有元素的数量(size()),eg:

vector<int>v;
v.resize(len);

v的size变为len,如果原来v的size小于len,那么容器新增(len-size)个元素,元素的值为默认为0。当v.push_back(3);之后,则是3是放在了v的末尾,即下标为len,此时容器是size为len+1;
reserve():改变当前容器的最大容量(capacity),它不会生成元素,只是确定这个容器允许放入多少对象,如果reserve(len)的值大于当前的capacity()那么会重新分配一块能存len个对象的空间,然后把之前v.size()个对象通过copy construtor复制过来,销毁之前的内存;如果v.reserve(n)函数的n的大小比vector原来的容量小,容量没有变化。size也没有变。

1.capacity:该值在容器初始化时赋值,指的是容器能够容纳的最大的元素的个数。还不能通过下标等访问,因为此时容器中还没有创建任何对象

2.size:指的是此时容器中实际的元素个数。可以通过下标访问0-(size-1)范围内的对象。

就比如说一个瓶子的容量是600ml,意思是它最多可以装600ml,而不是说现在瓶子里就有600ml,这里的600ml就相当于capacity;而说现在瓶子的size为300ml,就是说它真的装有300ml。

3.区别:
那么就可以分清楚resize和reserve的区别了:

  • reserve是设置了capacity的值,比如reserve(20),表示该容器最大容量为20,但此时容器内还没有任何对象,也不能通过下标访问。resize既分配了空间,也创建了对象,可以通过下标访问。
  • reserve只修改capacity大小,不修改size大小,resize既修改capacity大小,也修改size大小。
  • reserve是容器预留空间,但并不真正创建元素对象,在创建对象之前,不能引用容器内的元素,因此当加入新的元素时,需要用push_back()/insert()函数。resize是改变容器的大小,并且创建对象,因此,调用这个函数之后,就可以引用容器内的对象了,因此当加入新的元素时,用operator[]操作符,或者用迭代器来引用元素对象。
  • 再者,两个函数的形式是有区别的,reserve函数之后一个参数,即需要预留的容器的空间;resize函数可以有两个参数,第一个参数是容器新的大小,第二个参数是要加入容器中的新元素,如果这个参数被省略,那么就调用元素对象的默认构造函数。
    resize的源码
 void resize(size_type new_size)
   { 
   		resize(new_size, T());
   }
  void resize(size_type new_size, const T& x)
   {
    	if (new_size < size()) 
      		erase(begin() + new_size, end()); // erase区间范围以外的数据,确保区间以外的数据无效
   		else
      		insert(end(), new_size - size(), x); // 填补区间范围内空缺的数据,确保区间内的数据有效
   }

举个例子

#include<iostream>
#include<vector>
using namespace std;
int main()
{
    vector<int> a;
    cout<<"initial capacity:"<<a.capacity()<<endl;
    cout<<"initial size:"<<a.size()<<endl;
    // 0 0

    /*resize改变capacity和size*/
    a.resize(20);
    cout<<"resize capacity:"<<a.capacity()<<endl;
    cout<<"resize size:"<<a.size()<<endl;
    // 20 20


    vector<int> b;
     /*reserve改变capacity,不改变resize*/
    b.reserve(100);
    cout<<"reserve capacity:"<<b.capacity()<<endl;
    cout<<"reserve size:"<<b.size()<<endl;
    // 100 0
return 0;
}
#include <iostream>
#include <vector>
using namespace std;
int main() {
    vector<int> a;
    a.reserve(100);
    a.resize(50);
    cout<<a.size()<<"  "<<a.capacity()<<endl;
        //50  100
        
    a.resize(150);
    cout<<a.size()<<"  "<<a.capacity()<<endl;
        //150  200 (200 = 100 + (150 - 50))
        
    a.reserve(50);
    // 如果v.reserve(n)函数的n的大小比vector原来的容量小。容量没有变化。size也没有变:
    cout<<a.size()<<"  "<<a.capacity()<<endl;
        //150  200
        
    a.resize(50);
    cout<<a.size()<<"  "<<a.capacity()<<endl;
        //50  200    
}

2.13 哈希冲突

哈希冲突的产生原因
对应不同的关键字可能获得相同的hash地址,即 key1≠key2,但是f(key1)=f(key2)f为哈希函数。这种现象就是冲突,而且这种冲突只能尽可能的减少,不能完全避免。因为哈希函数是从关键字集合和地址集合的映像,通常关键字集合比较大,而地址集合的元素仅为哈希表中的地址值。

解决哈希冲突的四种方法

  • 开放定址法: 当发生地址冲突时,按照某种方法继续探测哈希表中的其他存储单元,直到找到空位置为止。
  • 再哈希法:当发生哈希冲突时使用另一个哈希函数计算地址值,直到冲突不再发生。这种方法不易产生聚集,但是增加计算时间,同时需要准备许多哈希函数。
  • 链地址法:将所有哈希值相同的Key通过链表存储。key按顺序插入到链表中
  • 建立公共溢出区:采用一个溢出表存储产生冲突的关键字。如果公共溢出区还产生冲突,再采用处理冲突方法处理。

2.14 hash表如何rehash,以及怎么处理其中保存的资源

C++的hash表中有一个负载因子loadFactor表示Hsah表中元素的填满的程度当loadFactor<=1时,hash表查找的期望复杂度为O(1)。因此,每次往hash表中添加元素时,我们必须保证是在loadFactor <1的情况下,才能够添加。
因此,当Hash表中loadFactor==1时,Hash就需要进行rehash。rehash过程中,会模仿C++的vector扩容方式,Hash表中每次发现loadFactor ==1时,就开辟一个原来桶数组的两倍空间,称为新桶数组,然后把原来的桶数组中元素全部重新哈希到新的桶数组中。

2.15 各种排序算法及时间复杂度

在这里插入图片描述

  • 插入排序:对于一个带排序数组来说,其初始有序数组元素个数为1,然后从第二个元素,插入到有序数组中。对于每一次插入操作,从后往前遍历当前有序数组,如果当前元素大于要插入的元素,则后移一位;如果当前元素小于或等于要插入的元素,则将要插入的元素插入到当前元素的下一位中。
void insetsort(vector<int> nums) {
	int len = nums.size();
	int pre, cur;
	for (int i = 1; i < len; i++) {
		pre = i - 1;
		cur = nums[i];
		while (pre >= 0 && nums[pre] > cur) {
			nums[pre + 1] = nums[pre];
			pre--;
		}
		nums[pre + 1] = cur;
	}
	display(nums);
}
  • 希尔排序:先将整个待排序记录分割成若干子序列,然后分别进行直接插入排序,待整个序列中的记录基本有序时,在对全体记录进行一次直接插入排序。其子序列的构成不是简单的逐段分割,而是将每隔某个增量的记录组成一个子序列。希尔排序时间复杂度与增量序列的选取有关,其最后一个值必须为1.
void shellsort(vector<int> nums) {
	int len = nums.size();
	for (int gap = floor(len / 2); gap > 0; gap = floor(gap / 2)) {
		for (int i = 0; i < gap; i++) {
			for (int j = i + gap; j < len; j += gap) {
				int pre = j - gap;
				int cur = nums[j];
				while (pre >= 0 && nums[pre] > cur) {
					nums[pre + gap] = nums[pre];
					pre -= gap;
				}
				nums[pre + gap] = cur;
			}
		}
	}
	display(nums);
}
  • 归并排序:该算法采用分治法;对于包含m个元素的待排序序列,将其看成m个长度为1的子序列。然后两两合归并,得到n/2个长度为2或者1的有序子序列;然后再两两归并,直到得到1个长度为m的有序序列。
void merge(vector<int>& nums, int left, int mid, int right) {
	vector<int> temp(right-left+1,0);
	int i = left, j = mid + 1,k=0;

	while (i <= mid && j <= right) {
		if (nums[i] <= nums[j]) temp[k++] = nums[i++];
		else temp[k++] = nums[j++];
	}
	while (i <= mid) temp[k++] = nums[i++];
	while (j <= right) temp[k++] = nums[j++];

	for (int i = left,k=0; i <= right; i++,k++) {
		nums[i] = temp[k];
	}
}
void mergesort(vector<int>& nums,int left,int right) {
	if(left >= right) return;
	int mid = left + (right - left) / 2;
	mergesort(nums, left, mid);
	mergesort(nums, mid + 1, right);
	merge(nums, left, mid, right);
}
  • 冒泡排序:对于包含n个元素的带排序数组,重复遍历数组,首先比较第一个和第二个元素,若为逆序,则交换元素位置;然后比较第二个和第三个元素,重复上述过程。每次遍历会把当前前n-i个元素中的最大的元素移到n-i位置。遍历n次,完成排序。
void bubblesort(vector<int> nums) {
	int len = nums.size();
	for (int i = 0; i < len-1; i++) {
		for (int j = 0; j < len-1; j++) {
			if (nums[j + 1] < nums[j]) swap(nums[j + 1], nums[j]);
		}
	}
	display(nums);
}
  • 快速排序:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
  • 选择排序:每次循环,选择当前无序数组中最小的那个元素,然后将其与无序数组的第一个元素交换位置,从而使有序数组元素加1,无序数组元素减1.初始时无序数组为空。
void selectsort(vector<int> nums) {
	int len = nums.size();
	int min, temp;
	for (int i = 0; i < len; i++) {
		min = i;
		for (int j = i + 1; j < len; j++) {
			if (nums[j] < nums[min]) min = j;
		}
		swap(nums[i], nums[min]);
	}
	display(nums);
}

表现最稳定的排序算法之一,因为无论什么数据进去都是O(n2) 的时间复杂度,所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。

  • 堆排序:堆排序是一种选择排序,利用堆这种数据结构来完成选择。其算法思想是将带排序数据构造一个最大堆(升序)/最小堆(降序),然后将堆顶元素与待排序数组的最后一个元素交换位置,此时末尾元素就是最大/最小的值。然后将剩余n-1个元素重新构造成最大堆/最小堆。

  • 桶排序

void bucketsort(vector<int>& nums) {
	int minValue = INT_MAX;
	int maxValue = INT_MIN;
	for (auto num : nums) {
		minValue = min(minValue, num);
		maxValue = max(maxValue, num);
	}

	int bucketSize = 10;
	int bucketCount = floor( (maxValue - minValue) / bucketSize) + 1;
	vector<vector<int>> buckets(bucketCount,vector<int>());

	// 将元素按照映射关系放入桶内
	for (int i = 0; i < nums.size(); i++) {
		int index = floor((nums[i] - minValue) / bucketSize);
		buckets[index].push_back(nums[i]);
	}

	// 对于每一个桶进行插入排序
	for (int i = 0; i < buckets.size(); i++) {
		if (buckets[i].size()) {
			for (int j = 1; j < buckets[i].size(); j++) {
				int pre = j - 1;
				int cur = buckets[i][j];
				while (pre >= 0 && buckets[i][pre] > cur) {
					buckets[i][pre + 1] = buckets[i][pre];
					pre--;
				}
				buckets[i][pre + 1] = cur;
			}
		}
	}

	// 如果桶不为空,输出桶内的所有元素
	for (int i = 0; i < bucketCount; i++) {
		if (buckets[i].size()) {
			for (int j = 0; j < buckets[i].size(); j++) {
				cout << buckets[i][j]<<' ';
			}
		}
	}
}

稳定的排序方法
基数排序、冒泡排序、直接插入排序、折半插入排序、归并排序

2.15 快排算法;以及什么是稳定性排序,快排是稳定性的吗;快排算法最差情况推导公式

1、快排算法
根据哨兵元素,用两个指针指向待排序数组的首尾,首指针从前往后移动找到比哨兵元素大的,尾指针从后往前移动找到比哨兵元素小的,交换两个元素,直到两个指针相遇,这是一趟排序,经常这趟排序后,比哨兵元素大的在右边,小的在左边。经过多趟排序后,整个数组有序。

稳定性:不稳定

平均时间复杂度:O(nlogn)

2、稳定排序

假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

快排算法是不稳定的排序算法。例如:

待排序数组:int a[] ={1, 2, 2, 3, 4, 5, 6};

若选择a[2](即数组中的第二个2)为枢轴,而把大于等于比较子的数均放置在大数数组中,则a[1](即数组中的第一个2)会到pivot的右边, 那么数组中的两个2非原序。

若选择a[1]为比较子,而把小于等于比较子的数均放置在小数数组中,则数组中的两个2顺序也非原序。

3、快排最差情况推倒

在快速排序的早期版本中呢,最左面或者是最右面的那个元素被选为枢轴,那最坏的情况就会在下面的情况下发生啦:

  • 数组已经是正序排过序的。 (每次最右边的那个元素被选为枢轴)
  • 数组已经是倒序排过序的。 (每次最左边的那个元素被选为枢轴)
  • 所有的元素都相同(1、2的特殊情况)

因为这些案例在用例中十分常见,所以这个问题可以通过要么选择一个随机的枢轴,或者选择一个分区中间的下标作为枢轴,或者(特别是对于相比更长的分区)选择分区的第一个、中间、最后一个元素的中值作为枢轴。有了这些修改,那快排的最差的情况就不那么容易出现了,但是如果输入的数组最大(或者最小元素)被选为枢轴,那最坏的情况就又来了。

快速排序,在最坏情况退化为冒泡排序 ,需要比较O(n2)次(n(n - 1)/2次)。

2.16 红黑树和AVL树的定义,特点,以及二者区别

参考回答:

  • 平衡二叉树的左右子树都是平衡二叉树。指的是以所有节点为根结点,它的左右子树高度差的绝对值不超过1。而红黑树是一种二叉查找树,节点非黑即红,它是一种弱平衡的红黑树。而红黑树是一种二叉查找树,节点非黑即红,它是一种弱平衡的红黑树。
  • 他们都在进行插入和删除操作时通过一些特定的操作来保持二叉树的平衡,平衡二叉树追求的是绝对的平衡,而红黑树为局部的平衡。查找和插入都为logn
  • 红黑在进行查找,删除和插入时,最多只需要三次旋转就能重新达到平衡,并且这些操作的时间复杂度都为logn,但是二叉平衡树在删除时,需要从删除结点开始往上检查所有的平衡因子,因此需要2logn

一、平衡二叉树(AVL树):
平衡二叉树又称为AVL树,是一种特殊的二叉排序树。其左右子树都是平衡二叉树,且左右子树高度之差的绝对值不超过1。一句话表述为:以树中所有结点为根的树的左右子树高度之差的绝对值不超过1。将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF,那么平衡二叉树上的所有结点的平衡因子只可能是-1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。

二、红黑树:
红黑树是一种二叉查找树,但在每个节点增加一个存储位表示节点的颜色,可以是红或黑(非红即黑)。通过对任何一条从根到叶子的路径上各个节点着色的方式的限制,红黑树确保没有一条路径会比其它路径长出两倍,因此,红黑树是一种弱平衡二叉树,相对于要求严格的AVL树来说,它的旋转次数少,所以对于搜索,插入,删除操作较多的情况下,通常使用红黑树。

性质:

  1. 每个节点非红即黑
  2. 根节点是黑的;
  3. 每个叶节点(叶节点即树尾端NULL指针或NULL节点)都是黑的;
  4. 如果一个节点是红色的,则它的子节点必须是黑色的。
  5. 对于任意节点而言,其到叶子点树NULL指针的每条路径都包含相同数目的黑节点;

三、区别
红黑树和AVL树类似,都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。红黑树和AVL树的区别在于它使用颜色来标识结点的高度,它所追求的是局部平衡而不是AVL树中的非常严格的平衡。

  1. 红黑树放弃了追求完全平衡,追求大致平衡,在与平衡二叉树的时间复杂度相差不大的情况下,保证每次插入最多只需要三次旋转就能达到平衡,实现起来也更为简单。红黑树牺牲了严格的高度平衡的优越条件为代价红黑树能够以O(log2 n)的时间复杂度进行搜索、插入、删除操作。
  2. 平衡二叉树追求绝对平衡,条件比较苛刻,实现起来比较麻烦,每次插入新节点之后需要旋转的次数不能预知。

当然,还有一些更好的,但实现起来更复杂的数据结构能够做到一步旋转之内达到平衡,但红黑树能够给我们一个比较“便宜”的解决方案。
红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高.

2.17 哈夫曼编码(?)

哈夫曼编码是哈夫曼树的一种应用,广泛用于数据文件压缩。哈夫曼编码算法用字符在文件中出现的频率来建立使用0,1表示个字符的最优表示方式,其具体算法如下:

  1. 哈夫曼算法以自底向上的方式构造表示最优前缀码的二叉树T。
  2. 算法以|C|个叶结点开始,执行|C|-1次的“合并”运算后产生最终所要求的树T。
  3. 假设编码字符集中每一字符c的频率是f©。以f为键值的优先队列Q用在贪心选择时有效地确定算法当前要合并的2棵具有最小频率的树。一旦2棵具有最小频率的树合并后,产生一棵新的树,其频率为合并的2棵树的频率之和,并将新树插入优先队列Q。经过n-1次的合并后,优先队列中只剩下一棵树,即所要求的树T。

2.18 map底层为什么用红黑树实现(?)

1、红黑树:
红黑树是一种二叉查找树,但在每个节点增加一个存储位表示节点的颜色,可以是红或黑(非红即黑)。通过对任何一条从根到叶子的路径上各个节点着色的方式的限制,红黑树确保没有一条路径会比其它路径长出两倍,因此,红黑树是一种弱平衡二叉树,相对于要求严格的AVL树来说,它的旋转次数少,所以对于搜索,插入,删除操作较多的情况下,通常使用红黑树。

性质:

  1. 每个节点非红即黑

  2. 根节点是黑的;

  3. 每个叶节点(叶节点即树尾端NULL指针或NULL节点)都是黑的;

  4. 如果一个节点是红色的,则它的子节点必须是黑色的。

  5. 对于任意节点而言,其到叶子点树NULL指针的每条路径都包含相同数目的黑节点;

2、平衡二叉树(AVL树):

红黑树是在AVL树的基础上提出来的。

平衡二叉树又称为AVL树,是一种特殊的二叉排序树。其左右子树都是平衡二叉树,且左右子树高度之差的绝对值不超过1。

AVL树中所有结点为根的树的左右子树高度之差的绝对值不超过1。

将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF,那么平衡二叉树上的所有结点的平衡因子只可能是-1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。

3、红黑树较AVL树的优点:

AVL 树是高度平衡的,频繁的插入和删除,会引起频繁的rebalance,导致效率下降;红黑树不是高度平衡的,算是一种折中,插入最多两次旋转,删除最多三次旋转。

所以红黑树在查找,插入删除的性能都是O(logn),且性能稳定,所以STL里面很多结构包括map底层实现都是使用的红黑树。

2.19 Top(K)问题

  1. 直接全部排序只适用于内存够的情况
    当数据量较小的情况下,内存中可以容纳所有数据。则最简单也是最容易想到的方法是将数据全部排序,然后取排序后的数据中的前K个。
    这种方法对数据量比较敏感,当数据量较大的情况下,内存不能完全容纳全部数据,这种方法便不适应了。即使内存能够满足要求,该方法将全部数据都排序了,而题目只要求找出top K个数据,所以该方法并不十分高效,不建议使用。

  2. 快速排序的变形只使用于内存够的情况
    这是一个基于快速排序的变形,因为第一种方法中说到将所有元素都排序并不十分高效,只需要找出前K个最大的就行。
    这种方法类似于快速排序,首先选择一个划分元,将比这个划分元大的元素放到它的前面,比划分元小的元素放到它的后面,此时完成了一趟排序。如果此时这个划分元的序号index刚好等于K,那么这个划分元以及它左边的数,刚好就是前K个最大的元素;如果index > K,那么前K大的数据在index的左边,那么就继续递归的从index-1个数中进行一趟排序;如果index < K,那么再从划分元的右边继续进行排序,直到找到序号index刚好等于K为止。再将前K个数进行排序后,返回Top K个元素。这种方法就避免了对除了Top K个元素以外的数据进行排序所带来的不必要的开销。

  3. 最小堆法
    这是一种局部淘汰法。先读取前K个数,建立一个最小堆。然后将剩余的所有数字依次与最小堆的堆顶进行比较,如果小于或等于堆顶数据,则继续比较下一个;否则,删除堆顶元素,并将新数据插入堆中,重新调整最小堆。当遍历完全部数据后,最小堆中的数据即为最大的K个数。

  4. 分治法
    将全部数据分成N份,前提是每份的数据都可以读到内存中进行处理,找到每份数据中最大的K个数。此时剩下NK个数据,如果内存不能容纳NK个数据,则再继续分治处理,分成M份,找出每份数据中最大的K个数,如果M*K个数仍然不能读到内存中,则继续分治处理。直到剩余的数可以读入内存中,那么可以对这些数使用快速排序的变形或者归并排序进行处理。

  5. Hash法
    如果这些数据中有很多重复的数据,可以先通过hash法,把重复的数去掉。这样如果重复率很高的话,会减少很大的内存用量,从而缩小运算空间。处理后的数据如果能够读入内存,则可以直接排序;否则可以使用分治法或者最小堆法来处理数据。

2.20 stack overflow,并举个简单例子导致栈溢出

栈溢出概念:
栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致栈中与其相邻的变量的值被改变。

栈溢出的原因:

  1. 局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。局部变量是存储在栈中的,因此这个很好理解。解决这类问题的办法有两个,一是增大栈空间,二是改用动态分配,使用堆(heap)而不是栈(stack)。
  2. 递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。
  3. 指针或数组越界。这种情况最常见,例如进行字符串拷贝,或处理用户输入等等。

栈溢出例子:

#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[]) {
	char buf[256];
	strcpy(buf,argv[1]);
	printf("Input:%s\n",buf);
	return 0;
}

2.21 递归的优缺点

优点:

  1. 简洁
2. 在树的前序,中序,后序遍历算法中,递归的实现明显要比循环简单得多。

缺点:

  1. 递归由于是函数调用自身,而函数调用是有时间和空间的消耗的:每一次函数调用,都需要在内存栈中分配空间以保存参数、返回地址以及临时变量,而往栈中压入数据和弹出数据都需要时间。->效率

  2. 递归中很多计算都是重复的,由于其本质是把一个问题分解成两个或者多个小问题,多个小问题存在相互重叠的部分,则存在重复计算,如fibonacci斐波那契数列的递归实现。->效率

  3. 调用栈可能会溢出,其实每一次函数调用会在内存栈中分配空间,而每个进程的栈的容量是有限的,当调用的层次太多时,就会超出栈的容量,从而导致栈溢出。->性能

2.22 push_back和emplace_back的区别

// emplace_back()
_Alloc_traits::construct(this->_M_impl, this->_M_impl._M_finish,
                                 std::forward<_Args>(__args)...); 
// push_back()
_Alloc_traits::construct(this->_M_impl, this->_M_impl._M_finish,
                                 __x);                           

对于 std::forward() 函数而言,本质上是一个类型转换函数,它的声明函数如下所示:

/**
 *  以下程序来自STL源码 bits/move.h
 *  @brief  Forward an lvalue.
 *  @return The parameter cast to the specified type.
 *
 *  This function is used to implement "perfect forwarding".
 */
template<typename _Tp>
constexpr _Tp &&forward(typename std::remove_reference<_Tp>::type &__t) noexcept {
    return static_cast<_Tp &&>(__t);
}

在强制类型转换中,将参数 __t传递给对应类 _Tp 的构造函数 ,然后调用了该类的构造函数从而完成对象创建过程。

总结:
因此,在 emplace_back() 函数中,是支持直接将构造函数所需的参数传递过去,然后构建一个新的对象出来,然后填充到容器尾部的,但前提是要有对应的构造函数。

通常使用push_back()向容器中加入一个右值元素(临时对象)时,首先会调用构造函数构造这个临时对象,然后需要调用拷贝构造函数将这个临时对象放入容器中。原来的临时变量释放。这样造成的问题就是临时变量申请资源的浪费。
引入了右值引用,转移构造函数后,push_back()右值时就会调用构造函数和转移构造函数,如果可以在插入的时候直接构造,就只需要构造一次即可。这就是c++11 新加的emplace_back。

2.23 deque的底层实现

  • 和 vector 容器采用连续的线性空间不同,deque 容器存储数据的空间是由一段一段等长的连续空间构成,各段空间之间并不一定是连续的,可以位于在内存的不同区域
  • 为了管理这些连续空间,deque 容器用数组(数组名假设为 map)存储着各个连续空间的首地址。也就是说,map 数组中存储的都是指针,指向那些真正用来存储数据的各个连续空间
    在这里插入图片描述
  • 通过建立 map 数组,deque 容器申请的这些分段的连续空间就能实现“整体连续”的效果。换句话说,当 deque 容器需要在头部或尾部增加存储空间时,它会申请一段新的连续空间,同时在 map 数组的开头或结尾添加指向该空间的指针,由此该空间就串接到了 deque 容器的头部或尾部。
  • 如果 map 数组满了,再申请一块更大的连续空间供 map 数组使用,将原有数据(很多指针)拷贝到新的 map 数组中,然后释放旧的空间。

deque 容器除了维护先前讲过的 map 数组,还需要维护startfinish 这 2 个 deque 迭代器

  • start 迭代器记录着 map 数组中首个连续空间的信息,
  • finish 迭代器记录着 map 数组中最后一个连续空间的信息。
  • 另外需要注意的是,和普通 deque 迭代器不同,start 迭代器中的 cur 指针指向的是连续空间中首个元素 ;而 finish 迭代器中的 cur 指针指向的是连续空间最后一个元素的下一个位置
  • 在这里插入图片描述

3 类和数据抽象

3.1 C++中类成员的访问权限

看这个 链接

  • 公有成员在程序中类的外部是可访问的。可以不使用任何成员函数来设置和获取公有变量的值
  • 私有成员变量或函数在类的外部是不可访问的,甚至是不可查看的。只有友元函数可以访问私有成员。
  • protected(受保护)成员变量或函数与私有成员十分相似,但有一点不同,protected(受保护)成员在派生类(即子类)中是可访问的。

默认情况下,类的所有成员都是私有的。
知识补充:友元函数

  • 类的友元函数是定义在类外部,但有权访问类的所有私有(private)成员和保护(protected)成员。尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数
  • 友元可以是一个函数,该函数被称为友元函数;友元也可以是一个类,该类被称为友元类,在这种情况下,整个类及其所有成员都是友元。
  • 如果要声明函数为一个类的友元,需要在类定义中该函数原型前使用关键字 friend
  • 更多内容见此

C++通过 publicprotectedprivate 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员

继承中的特点
有public, protected, private三种继承方式,它们相应地改变了基类成员的访问属性。

  1. public 继承:基类 public 成员,protected 成员,private 成员的访问属性在派生类中分别变成:public, protected, private

  2. protected 继承:基类 public 成员,protected 成员,private 成员的访问属性在派生类中分别变成:protected, protected, private

  3. private 继承:基类 public 成员,protected 成员,private 成员的访问属性在派生类中分别变成:private, private, private

但无论哪种继承方式,上面两点都没有改变:

  1. private 成员只能被本类成员(类内)友元访问,不能被派生类访问
  2. protected 成员可以被派生类访问。

3.2 C++中struct和class的区别

在C++中,可以用struct和class定义类,都可以继承。区别在于:

  • struct默认继承权限默认访问权限public
  • class默认继承权限默认访问权限private
    另外,class还可以定义模板类形参,比如template <class T, int i>。
    总结:
    在这里插入图片描述

更加详细请看

3.3 C++类内可以定义引用数据成员吗?

可以,必须通过成员函数初始化列表初始化。

#include <iostream>
using std::cout;
using std::endl;
class A{
public:
	int &a;
    A(int &b):a(b){}//初始化列表先于计算之前
    
};
int main()
{
    int b = 10;
    A a(b);
    cout << a.a <<endl;
    return 0;
}

4 面向对象与泛型编程

4.1 右值引用,跟左值又有什么区别?(?)

右值引用是C++11中引入的新特性 , 它实现了转移语义和精确传递。它的主要目的有两个方面:

  1. 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。
  2. 能够更简洁明确地定义泛型函数。

左值和右值的概念:

  • 左值:能对表达式取地址、或具名对象/变量。一般指表达式结束后依然存在的持久对象。例如:a,b等变量名
  • 右值:不能对表达式取地址,或匿名对象。一般指表达式结束就不再存在的临时对象。例如:5,10等数值;

另一个利用英文来区别:

  • 左值:lvalue = loactor value的缩写,可意为存储在内存中、有明确存储地址(可寻址)的数据;
  • 右值:rvalue = read value的缩写,指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。

右值引用和左值引用的区别:

  1. 左值可以寻址,而右值不可以。
  2. 左值可以被赋值,右值不可以被赋值,可以用来给左值赋值。
  3. 左值可变,右值不可变(仅对基础类型适用,用户自定义类型右值引用可以通过成员函数改变)。

常见的左值引用:

int num = 10;
int &b = num; //正确
int &c = 10; //错误

注意,虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,**常量左值引用既可以操

作左值,也可以操作右值**,例如:
int num = 10;
const int &b = num;
const int &c = 10;

常见的右值引用

int num = 10;
//int && a = num;  //右值引用不能初始化为左值
int && a = 10;

和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:

int && a = 10;
a = 100;
cout << a << endl;

C++11右值引用(一看即懂)
[c++11]我理解的右值引用、移动语义和完美转发
具体应用
如何评价 C++11 的右值引用(Rvalue reference)特性?

5 编译底层

5.1 C++源文件从文本到可执行文件经历的过程?

对于C++源文件,从文本到可执行文件一般需要四个过程:

  • 预处理阶段:对源代码文件中文件包含关系(头文件)预编译语句(宏定义) 进行分析和替换,生成 预编译文件。
  • 编译阶段:将经过预处理后的预编译文件转换成特定汇编代码生成 汇编文件
  • 汇编阶段:将编译阶段生成的汇编文件转化成机器码,生成 可重定位目标文件
  • 链接阶段:将多个目标文件及所需要的库连接成最终的 可执行目标文件。链接是将各种代码和数据部分收集起来并合并为一个单一文件的过程,该文件最后被加载到存储器中并运行。

5.2 动态链接与静态链接

静态链接是在形成可执行程序前,而动态链接的进行则是在程序执行时
在这里插入图片描述
一、静态库
(.a .lib)

静态链接库在程序编译时被连接到目标代码中参与编译;链接时将库完整地拷贝至可执行文件中,被多次使用就有多份冗余拷贝;生成可执行程序之后,静态链接库不需要(因已将函数拷贝到可执行文件中)。

静态库特点:
1.静态库对函数库的链接是放在编译时期完成的
2.程序在运行时与函数库再无瓜葛,移植方便。
3.浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件
4.如果静态库进行更新则应用该库的所有程序都需要重新编译(全量更新)。

二、动态库
(.so .dll)
程序运行时由系统动态加载动态库到内存,供程序调用,系统只加载一次,多个程序共用,节省内存

动态库特点:
1.动态库把对一些库函数的链接载入推迟到程序运行时期。
2.可以实现进程之间的资源共享。(因此动态库也称为共享库)
3.将一些程序升级变得简单。
4.甚至可以真正做到链接载入完全由程序员在程序代码中控制(显示调用)

三、比较

  1. 时期:
    静态库在编译时连接,在链接时拷贝
    动态库在运行时连接
  2. 资源
    静态库在每次使用时将全部连接进可执行程序 ,浪费资源。
    动态库在使用时访问动态库中函数,节省资源。
  3. 更新升级
    静态库更新,则每个使用该静态库的程序都需要更新,不易于更新升级
    动态库仅更新自身,易于更新升级
  4. 包含其他库
    静态链接库不能再包含其他动态链接库
    动态链接库可以包含其他动态链接库

5.2 include头文件的顺序以及双引号” ”和尖括号<>的区别?

Include头文件的顺序:对于include的头文件来说,如果在文件a.h中声明一个在文件b.h中定义的变量,而不引用b.h。那么要在a.c文件中引用b.h文件,并且要先引用b.h,后引用a.h,否则汇报变量类型未声明错误。

双引号和尖括号的区别: 编译器预处理阶段查找头文件的路径不一样。

对于使用双引号" "包含的头文件,查找头文件路径的顺序为:

  1. 当前头文件目录
  2. 编译器设置的头文件路径(编译器可使用-I显式指定搜索路径)
  3. 系统变量CPLUS_INCLUDE_PATH/C_INCLUDE_PATH指定的头文件路径

对于使用尖括号< >包含的头文件,查找头文件的路径顺序为:

  1. 编译器设置的头文件路径(编译器可使用-I显式指定搜索路径)
  2. 系统变量CPLUS_INCLUDE_PATH/C_INCLUDE_PATH指定的头文件路径

5.3 malloc的原理,另外brk系统调用和mmap系统调用的作用分别是什么?

Malloc函数用于动态分配内存。malloc 函数的实质是它有一个将可用的内存块连接为一个长长的列表的所谓空闲链表。

为了减少内存碎片和系统调用的开销,malloc其采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位。
malloc() 的整体思想是先向操作系统申请一块大小适当的内存,然后自己管理,这就是内存池(Memory Pool)。内存池的研究重点不是向操作系统申请内存,而是对已申请到的内存的管理
malloc和内存池的更多细节

当用户申请内存时,直接从堆区分配一块合适的空闲块。

  • Malloc采用隐式链表结构将堆区分成连续的、大小不一的块,包含已分配块和未分配块;

  • 同时malloc采用显示链表结构来管理所有的空闲块,即使用一个双向链表将空闲块连接起来,每一个空闲块记录了一个连续的、未分配的地址。

  • 当进行内存分配时,Malloc会通过隐式链表遍历所有的空闲块,选择满足要求的块进行分配;

  • 当进行内存合并时,malloc采用边界标记法,根据每个块的前后块是否已经分配来决定是否进行块合并。

Malloc在申请内存时,一般会通过 brk 或者 mmap 系统调用进行申请。

  • 当申请内存小于128K时,会使用系统函数brk堆区中分配;brk是将数据段(.data)的最高地址指针_edata往高地址推;
  • 而当申请内存大于128K时,会使用系统函数mmap映射区分配。mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存

这两种方式分配的都是虚拟内存没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。

在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk,mmap,munmap这些系统调用实现的。

brk,mmap的具体原理

5.4 请你来回答一下什么是内存泄漏(memory leak)

内存泄漏(memory leak)是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏通常是由于调用了malloc/new等内存申请的操作,但是缺少了对应的free/delete。更加细致的分类如下

内存泄漏的分类:

  1. 堆内存泄漏 (Heap leak)。堆内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存,再是完成后必须通过调用对应的 free或者delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生Heap Leak.

  2. 系统资源泄露(Resource Leak)。主要指程序使用系统分配的资源比如 Bitmap,handle ,SOCKET没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。

  3. 没有将基类的析构函数定义为虚函数。 当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露。

为了判断内存是否泄露,我们一方面可以使用linux环境下的内存泄漏检查工具Valgrind(读音:瓦尔格林)和mtrace
另一方面我们在写代码时可以添加内存申请和释放的统计功能,统计当前申请和释放的内存是否一致,以此来判断内存是否泄露。

5.5 什么时候会发生段错误

段错误是计算机软件运行过程中可能出现的一种特殊错误情况。当程序试图访问不允许访问的内存位置,或试图以不允许的方式访问内存位置(例如尝试写入只读位置,或覆盖部分操作系统)时会发生段错误。 分段是操作系统内存管理和保护的一种方法。在大多数情况下,它已经被分页所取代,但是分段的许多术语仍然被使用,“分段错误”就是一个例子。尽管分页被用作主内存管理策略,但有些操作系统在某些逻辑级别上仍然有分段。在类Unix操作系统上,访问无效内存的进程接收SIGSEGV信号。在Microsoft Windows上,访问无效内存的进程会收到状态“访问冲突”异常。

段错误通常发生在访问非法内存地址的时候,具体来说分为以下几种情况:

  • 使用野指针(野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的))
  • 试图修改字符串常量的内容

更多具体的情况

5.6 共享内存相关API(Application Programming Interface,应用程序接口)

共享内存是System V版本的最后一个进程间通信方式共享内存,顾名思义就是允许两个不相关的进程访问同一个逻辑内存,共享内存是两个正在运行的进程之间共享和传递数据的一种非常有效的方式。 不同进程之间共享的内存通常为同一段物理内存。进程可以将同一段物理内存连接到他们自己的地址空间中,所有的进程都可以访问共享内存中的地址。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。

特别提醒:共享内存并未提供同步机制,也就是说,在第一个进程结束对共享内存的写操作之前,并无自动机制可以阻止第二个进程开始对它进行读取,所以我们通常需要用其他的机制来同步对共享内存的访问,例如信号量

5.6.1 共享内存的通信原理

原理:A的PCB-A虚拟地址-A页表-共享物理内存-B页表-B虚拟地址-B的PCB
Linux中,每个进程都有属于自己的进程控制块(PCB)地址空间(Addr Space),并且都有一个与之对应的页表负责将进程的虚拟地址与物理地址进行映射,通过内存管理单元(MMU)进行管理。两个不同的虚拟地址通过页表映射到物理空间的同一区域,它们所指向的这块区域即共享内存

共享内存的通信原理示意图:
在这里插入图片描述
对于上图我的理解是:当两个进程通过页表将虚拟地址映射到物理地址时,在物理地址中有一块共同的内存区,即共享内存,这块内存可以被两个进程同时看到。这样当一个进程进行写操作,另一个进程读操作就可以实现进程间通信。但是,我们要确保一个进程在写的时候不能被读,因此我们使用信号量来实现同步与互斥。

对于一个共享内存,实现采用的是引用计数的原理,当进程脱离共享存储区后,计数器减一,挂架成功时,计数器加一,只有当计数器变为零时,才能被删除。当进程终止时,它所附加的共享存储区都会自动脱离。

5.6.2 共享内存的接口函数以及指令

1.shmget ( )
创建共享内存

int shmget(key_t key, size_t size, int shmflg);

[参数key]:由ftok生成的key标识,标识系统的唯一IPC资源。

[参数size]:需要申请共享内存的大小。在操作系统中,申请内存的最小单位为页,一页是4k字节,为了避免内存碎片,我们一般申请的内存大小为页的整数倍。

[参数shmflg]:如果要创建新的共享内存,需要使用IPC_CREAT,IPC_EXCL,如果是已经存在的,可以使用IPC_CREAT或直接传0。

[返回值]:成功时返回一个新建或已经存在的的共享内存标识符,取决于shmflg的参数。失败返回-1并设置错误码。

2.shmat ( ):挂接共享内存
连接共享内存到当前进程的地址空间shmat

void *shmat(int shmid, const void *shmaddr, int shmflg);

[参数shmid]:共享存储段的标识符。

[参数*shmaddr]:shmaddr = 0,则存储段连接到由内核选择的第一个可以地址上(推荐使用)。

[参数shmflg]:若指定了SHM_RDONLY位,则以只读方式连接此段,否则以读写方式连接此段。

[返回值]:成功返回共享存储段的指针(虚拟地址),并且内核将使其与该共享存储段相关的shmid_ds结构中的shm_nattch计数器加1(类似于引用计数);出错返回-1。

3.shmdt ( )
去关联共享内存

当一个进程不需要共享内存的时候,就需要去关联。该函数并不删除所指定的共享内存区,而是将之前用shmat函数连接好的共享内存区脱离目前的进程。

int shmdt(const void *shmaddr);

[参数*shmaddr]:连接以后返回的地址。

[返回值]:成功返回0,并将shmid_ds结构体中的 shm_nattch计数器减1;出错返回-1。

4.shmctl ( )
销毁共享内存

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

[参数shmid]:共享存储段标识符。

[参数cmd]:指定的执行操作,设置为IPC_RMID时表示可以删除共享内存。

[参数*buf]:设置为NULL即可。

[返回值]:成功返回0,失败返回-1。

更加详细的内容

5.7 内存池、C++ STL 的内存优化

C++中的内存优化采用两级的空间配置器机制来解决。
第一级配置器:
第一级配置器是以malloc(),remalloc(),free()等C函数执行实际的内存配置,释放,重新配置等操作。一级空间配置器分配的是 大于128字节 的空间。如果分配失败,调用句柄释放一部分内存。如果还是失败,调用一个指定的函数。
第二级配置器(内存池):
如果要分配的区块 小于128字节 ,为了降低额外负担,则以内存池管理方式
,内存池又称为次层配置器(sub-allocation):每次配置一大块内存,并维护对应的16个空闲链表(free-list),下次若有相同大小的内存需求,直接从free-list中取,若有小额区块被释放,则由配置器回收到free-list中。

内存池采用了16个空闲链表,这里的16个空闲链表分别管理大小为8,16,24,32,……,120,128的数据块。这里的空闲链表的结点设计为一个联合体(union,因为不用额外的指针),既可以用来表示下一个空闲的数据块(存在于空闲链表中)的地址,也可以用来表示已经被用户使用的数据块(不存在与空闲链表中)的地址。

在STL的第二级配置器中多了一些机制,避免太多小区快造成的内存碎片,小区块造成的不仅仅是内存碎片,同时还有一些额外的负担。区块越小,额外的负担所占的比重越大。
在这里插入图片描述
当用户申请的空间小于128字节,首先将字节数扩大到8的倍数,然后在自由链表中查找对应大小的子链表,如果在自由链表中查找不到或者块数不够,则向内存池进行请,一般一次申请20块,如果不够分配20块,则分配最多的块数给自由链表,并且每次更新申请的块数。如果一块都无法提供,则把剩余的内存挂到自由链表,然后向申请空间,如果申请失败,则看看自由链表还有没有可用的块,如果也没有,最后调用一级空间配置器。
举个例子(首先要明白有三个东西,自由链表,自由链表下的子链表,内存池):

  • 首先,用户申请内存小于128个字节,进入二级配置器程序,
  • 假如第一次用户申请内存为32字节,程序直接调用S_chunk_alloc,直接申请40个大小的32字节空间,1个交给客户,另外19个交给空闲链表,剩下的20个交给内存池。
  • 接下来,第二次申请空间,假设这次用户需要64字节的空间,首先,程序检查64字节的空闲链表节点是否有空闲空间,如果有,直接分配,如果没有,就去内存池,然后检查内存池的大小能分配多少个自己大小的节点,内存池的内存完全满足,则拿出1个给用户,剩下的19个给空闲链表,如果不够,尽可能分配内存池的最大个数给用户和空闲链表。如果一个都分配不出,首先把内存池中的剩下没用的小内存分给相应适合的空闲链表,然后继续调用S_chunk_alloc申请内存,内存申请不足,去检索空闲链表有没有尚未使用的大的内存块,如果有,就拿出来给给内存池,递归调用S_chunk_alloc,如果空闲链表也没有了,则交给一级适配器(因为其有对内存不足处理的方法)。内存够,则拿到申请的内存补充内存池。重复上面的操作,将一个给客户,其他的19个交给空闲链表,剩下的给内存池。

二级配置器实现了对小内存的高效管理,使内部碎片出现的概率大大降低,个人见解,二级适配器差不多已经达到了对小内存接近完美的处理。

相关函数说明

1、空间配置函数allocate
首先先要检查申请空间的大小,如果大于128字节就调用第一级配置器,小于128字节就检查对应的空闲链表,如果该空闲链表中有可用数据块,则直接拿来用(拿取空闲链表中的第一个可用数据块,然后把该空闲链表的地址设置为该数据块指向的下一个地址),如果没有可用数据块,则调用refill重新填充空间。

2、空间释放函数deallocate
首先先要检查释放数据块的大小,如果大于128字节就调用第一级配置器,小于128字节则根据数据块的大小来判断回收后的空间会被插入到哪个空闲链表。

3、重新填充空闲链表refill
在用allocate配置空间时,如果空闲链表中没有可用数据块,就会调用refill来重新填充空间,新的空间取自内存池。缺省取20个数据块,如果内存池空间不足,那么能取多少个节点就取多少个。
从内存池取空间给空闲链表用是chunk_alloc的工作,首先根据end_free-start_free来判断内存池中的剩余空间是否足以调出nobjs个大小为size的数据块出去,如果内存连一个数据块的空间都无法供应,需要用malloc取堆中申请内存。
假如山穷水尽,整个系统的堆空间都不够用了,malloc失败,那么chunk_alloc会从空闲链表中找是否有大的数据块,然后将该数据块的空间分给内存池(这个数据块会从链表中去除)。

总结:
(1)使用allocate 向内存池请求size大小的内存空间,如果需要请求的内存大于128bytes,直接使用malloc

(2)如果需要的内存小于128bytes, allocate 根据size找到最适合的自由链表。

     a.如果链表不为空,返回第一个node,链表头改为第二个node。

     b.如果链表为空,使用blockA1loc请求分配node。

             b1.如果内存池中有大于一个node的空间,分配尽可能多的node(但是最多20个),
             	 将一个node返回,其他的node添加到链表中。

             b2.如果内存池只有一个node的空间,直接返回给用户。

             b3. 若果如果连一个node都没有,再次向操作系统请求分配内存。

                  ①分配成功,再次进行b过程。

                  ②分配失败,循环各个自由链表,寻找空间。

                         I.找到空间,再次进行过程b。

                         II. 找不到空间,抛出异常。

(3) 用户调用deallocate释放内存空间,如果要求释放的内存空间大于128字节, 直接调用free。小于128字节按照其大小找到合适的自由链表,并将其插入。

5.7.1 二级配置器内存回收

当用户从二级空间配置器中申请的内存被释放时,二级空间配置器将回收的内存插入到对应的 free_list 中。其流程如下:
在这里插入图片描述

5.8 LRU实现机制

在这里插入图片描述

原题链接

class MyListNode
    {
        public:
        MyListNode* Pre;
        MyListNode* Next;
        int Key;
        int val;
        MyListNode():Pre(nullptr),Next(nullptr),val(0){}
        MyListNode(int Val,int key):Pre(nullptr),Next(nullptr),val(Val),Key(key){}
        MyListNode(int Val,MyListNode* pre,MyListNode* next):val(Val),Pre(pre),Next(next){}
    };

class LRUCache {


private:
    MyListNode* head;
    MyListNode* Tail;
    int Capacity;
    int size;
    unordered_map<int,MyListNode*> Map;
public:
    LRUCache(int capacity):Capacity(capacity),size(0)
    {
        head=new MyListNode();
        Tail=new MyListNode();
        head->Next=Tail;
        Tail->Pre=head;
    }
    
    int get(int key) 
    {
        if(Map.count(key))
        {
        MyListNode* node=Map[key];
        RemoveToHead(node);
        return node->val;
        }
        else{return -1;}


    }
    
    void put(int key, int value) 
    {
        if(!Map.count(key))
        {
            if(size>=Capacity)
        {
            DeleteNode(Tail->Pre);
            size--;
        }
        MyListNode* node=new MyListNode(value,key);
        Map[key]=node;
        AddNodeToHead(node);
        size++;
        }
        else
        {
            MyListNode* node=Map[key];
            node->val=value;
            RemoveToHead(node);
        }

    }
    void  AddNodeToHead(MyListNode *node)
    {
        node->Next=head->Next;
        head->Next->Pre=node;
        node->Pre=head;
        head->Next=node;
    }
    void RemoveToHead(MyListNode* node)
    {
        node->Pre->Next=node->Next;
        node->Next->Pre=node->Pre;
        AddNodeToHead(node);

    }
    void DeleteNode(MyListNode* node)
    {
        node->Pre->Next=node->Next;
        node->Next->Pre=node->Pre;
        Map.erase(node->Key);
        delete node;

    }

};

cpu如何执行程序

看这个

6 C++ 11新特性

C++11 最常用的新特性如下:
auto关键字:编译器可以根据初始值自动推导出类型。但是不能用于函数传参以及数组类型的推导

nullptr关键字:nullptr是一种特殊类型的字面值,它可以被转换成任意其它的指针类型;而NULL一般被宏定义为0,在遇到重载时可能会出现问题。

智能指针:C++11新增了std::shared_ptr、std::weak_ptr等类型的智能指针,用于解决内存管理的问题。

初始化列表:使用初始化列表来对类进行初始化

右值引用:基于右值引用可以实现移动语义和完美转发,消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率

atomic:原子操作用于多线程资源互斥操作

新增STL容器array以及tuple

6.1 可变参数模板

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值