C++面经

  • 编译、链接与库
    • 编译
      • 单文件编译
      • 多文件编译
    • 动态链接与静态链接
      • 静态链接
      • 动态链接
  • 面向对象
    • c++⾯向对象 三大特性
      • 封装
      • 继承
      • 多态
        • 静态多态(模板或重载)
        • 动态多态(面向对象、继承、多态、虚函数)
    • 面向对象和面向过程语言的区别
      • 面向过程
      • 面向对象
    • c++虚函数
      • 前瞻
      • 虚函数工作原理
        • 动态绑定
      • 继承情况下的虚函数表
      • 虚函数的性能分析
      • 虚函数的一些问题
    • 抽象基类-纯虚函数
    • C++所有构造函数
    • 成员初始化列表
    • 什么情况下会调用拷贝构造函数?
    • 为什么拷贝构造函数必须是引用?
    • 构造函数析构函数是否能抛出异常
    • 创建派生类对象,构造函数的执行顺序是什么?析构函数的执行顺序?
    • C++成员变量的初始化顺序问题
    • c++的public、private、protected
    • 继承
    • 什么不能被继承?
    • C++中this指针相关问题
      • This指针的来源
      • this指针是什么时候创建的?
      • this指针存放在何处?堆、栈、全局变量,还是其他?
      • 如果我们知道一个对象this指针的位置,可以直接使用吗?
      • 在成员函数中调用delete this会出现什么问题?
      • 如果在类的析构函数中调用delete this,会发生什么?
  • 语言特性和数据类型
    • 结构体(struct)和共同体(union)的区别
      • 结构体struct
      • 共同体union
    • struct的内存对齐规则
    • std::initializer_list
    • lambda表达式
      • 捕获列表 [capture]
    • static 和const分别怎么用,类里面static和const可以同时修饰成员函数吗
      • static
        • static对于变量
        • static对于函数
        • static对于类
      • const
    • static初始化时机和线程安全问题
  • 内存与指针
    • 指针和引用的区别
    • 指向常量的指针和常量指针
    • 空指针、悬空指针、野指针、void指针?
    • arr和&arr[0]和&arr的不同
    • char a,char a[],char *a,char *[],char * *a 之间的区别
    • 一维数组名和二维数组名的区别
    • 数组指针和指针数组
    • C++内存布局/程序分段
    • 栈帧
    • 谈一谈new/delete和malloc/free的区别和联系(c++管理内存的方式)
    • 有了malloc/free为什么还要new/delete?
    • new delete,new[] delete[]一定要配对使用吗?为什么?
    • 函数传递参数的⼏种⽅式
    • 左值和右值
      • 左值
      • 右值
    • 左值引用和右值引用
      • **左值引用**
      • **右值引用**
        • 延长生命周期
        • 移动构造函数
          • 普通的左值是否也能借助移动语义来优化性能呢
        • 右值引用类型的变量可能是左值也可能是右值
        • 完美转发
    • 深拷贝和浅拷贝
    • 内存泄漏?出现内存泄漏如何调试?
    • RAII基本理解与使用
    • c++智能指针
      • 为什么要用智能指针?
      • unique_ptr
      • shared_ptr
      • weak_ptr
  • 模板元编程与泛型
    • 模板底层怎么实现?
    • 函数模板
    • 类模板
    • 模板和实现可不可以不写在一个文件里面?为什么?
    • 模板类和模板函数的区别是什么?
    • 默认模板参数
    • 可变参数模板
      • 对参数进行解包
        • 递归
        • 初始化列表展开
    • 模板和继承,这个区别是什么?
    • ------
    • C++中局部静态变量的问题
      • **首先说一下局部静态变量的理解**
      • **静态局部变量的构造和析构**
      • 函数局部静态变量的返回
    • 用const和#define定义常量哪个更好?
    • #define宏常量和const常量的区别
    • typedef与#define的区别
  • define为一宏定义语句,本质就是文本替换
    • #define<>和#define“ ”的区别
    • O0、O1、O2、O3优化
    • c++中四种变量存储类型总结
    • inline 内联函数与宏定义
    • struct 和 typedef struct
    • explicit 关键字
    • friend友元类和友元函数
    • C语言怎么实现多态
    • 如何定义一个只能在堆上(栈上)生成对象的类?
    • C++如何阻止类被实例化?
      • 如何阻止?
    • 怎么在栈上栈上分配内存?
    • 带返回值的函数如果不return会怎么样?
    • c++ 四种强制类型转换
      • 向上类型转换和向下类型转换
      • 四种强制类型转换
      • 为什么要引入四种强制类型转换
      • 为什么说不要使用 dynamic_cast
    • C++11的enum class 、enum struct 和 enum
    • :watermelon:C++11新特性
      • auto和decltype
      • nullptr和null的区别
      • 成员列表初始化
      • 右值引用
      • for循环
      • C++11中的原子操作(atomic operation)
      • C++11的std::function和std::bind
      • c++11关键字
        • noexcept
        • override
        • final关键字
        • =default
        • =delete
        • using
    • 实现一个引用计数功能?c++中共享指针是怎样计数的?
    • 哈希表时间复杂度为什么是O(1)?
    • sizeof相关面试题
    • Volatile的作用?是否具有原子性?对编译器有什么影响?
    • extern 关键字
    • auto类型推断的原理
    • C++程序优化的方法
    • void*(泛型指针)
    • C++11 RVO/NRVO机制
    • C++从源代码到可执行程序的流程是怎样的
    • C++的静态链接和动态链接讲一下
      • 静态链接
      • 动态链接
    • 动态编译和静态编译
    • 动态联编和静态联编
    • 指针没有初始化会怎么样
    • 野指针和指针悬挂
    • 基础变量没有初始化怎么样
    • c/c++参数入栈顺序和参数计算顺序
    • mutable关键字
    • c++RITT机制
    • 虚继承
    • c++出现的内存问题
    • C++编译期多态与运行期多态
    • C++ 中的指针参数传递和引用参数传递
    • 简单说一下函数指针
    • 如何让函数在 main 函数执行前执行?
    • i++和++i的区别
    • 变量声明和定义区别?
    • C++异常处理的方法
    • C++函数调用的压栈过程
    • codedump
    • 怎样判断两个浮点数是否相等?
    • C++为什么提供move函数?
  • STL
    • STL介绍
    • **各个容器特点总结**
    • vector
      • vector描述
      • vector的底层原理
      • vector内存增长机制
      • vector中的reserve和resize的区别
      • vector中size()和capacity()的区别
      • vector的元素类型可以是引用吗?
      • vector迭代器失效的情况
      • vector在栈还是堆,能开10万个元素的vector吗,怎么扩容的,怎么开辟内存
      • vector作为函数返回值用法
      • emplace_back和push_back
      • 一个vetor内存很大但实际我只用了很小一部分怎么解决
      • vector元素是指针类型
      • 频繁调用push_back的影响
      • Vector如何释放空间?
      • vector的插入复杂度
      • vector为什么是成倍增长而不是固定大小的一个容量呢?
      • **为什么选择以1.5倍或者2倍方式进行扩容?而不是3倍4倍扩容?**
    • deque
      • deque描述
      • deque容器的底层存储机制
      • deque的iterator
      • deque和vector的区别
    • list
      • list描述
      • list和vector的区别
      • 怎么找某vector或者list的倒数第二个元素
    • stack
    • queue
    • vector, list, map等容器使用场合是什么?
    • 关联式容器(map,multimap,set,multiset)
      • 为何关联容器的插入删除效率一般比用其他序列容器高?
      • set multiset 重复数据是怎么实现的?
      • 为何每次insert之后,以前保存的iterator不会失效?
      • 为何map和set不能像vector一样有个reserve函数来预分配数据?
      • 当数据元素增多时(10000和20000个比较),map和set的插入和搜索速度变化如何?
      • map用[]越界会发生什么?
      • map和set为什么用红黑树
      • map中[]与find的区别?
    • 容器内部删除一个元素
    • allocator的使用
    • 迭代器的底层机制和失效的问题
    • 迭代器和指针的区别
    • 迭代器的种类
    • 仿函数
    • 算法
      • stable_sort
    • STL容器是线程安全的吗?
    • c++STl中vector、list和map插入1000万个元素,消耗对比
  • c++工具类
    • double buffer代替锁(无锁化)
    • 内存池
      • 内存池概述
      • 连接数据库的步骤

https://www.nowcoder.com/discuss/395705006039597056?sourceSSR=search

编译、链接与库

编译分为四个过程:预编译、编译、汇编、链接

预编译:处理以#开头的指令;
编译:将源码.cpp翻译成汇编代码;
汇编:将汇编代码翻译成机器指令;
链接:一个源文件中函数可能引用了其他源文件的变量或函数,或者引用了库函数,链接的目的就是将这些文件链接成一个整体,从而生成一个可执行的.exe文件。

编译

单文件编译

在命令行中用g++编译出可执行文件:

g++ main.cpp -o test

等价于以下操作:
C++面经_第1张图片

(1)预处理(Preprocessing)。由预处理器cpp完成,将.cpp源文件预处理为.i文件。把#include头文件、宏定义这些东西做一下预处理展开或替换,比如把宏定义的量替到函数里等等

g++  -E  test.cpp  -o  test.i    //生成预处理后的.i文件

(2)编译(Compilation)。由编译器cc1plus完成,将.i文件编译为.s的汇编文件。使用-S选项,只进行编译而不进行汇编,生成汇编代码。

g++ -S test.i -o test.s			//生成汇编.s文件

(3)汇编(Assembly)。由汇编器as完成,将.s文件汇编成.o的二进制目标文件

g++  -c  test.s  -o  test.o    //生成二进制.o文件

(4)链接(Linking)。由链接器ld,将.o文件连接生成可执行程序。

g++ test.o  -o  test	  //生成二进制可执行文件

多文件编译

用例结构

$ tree
.
├── include
│   └── Math.h
├── main.cpp
└── src
    └── Math.cpp

直接编译会报错:

$ g++ main.cpp src/Math.cpp -o test
main.cpp:2:10: fatal error: Math.h: 没有那个文件或目录
    2 | #include "Math.h"
      |          ^~~~~~~~
compilation terminated.
src/Math.cpp:1:10: fatal error: Math.h: 没有那个文件或目录
    1 | #include "Math.h"
      |          ^~~~~~~~
compilation terminated.

这是因为我们自己写的头文件不在编译器默认的头文件搜索路径里,这个时候需要用-I参数告诉编译器,找头文件的时候也去搜索一下我们自己加的目录, -I参数后面紧跟想自定义的搜索路径即可

g++ main.cpp src/Math.cpp -Iinclude -o test

动态链接与静态链接

Linux系统里静态库是.a后缀结尾,动态库是.so后缀结尾。二者最主要的区别是打进可执行文件里的时机不一样

静态链接

静态库是在编译链接的时候,需要哪个静态库,就把这个静态库直接编进最后的可执行文件里,相当于可执行文件里有一份完整的静态库的代码拷贝;

动态链接

把调用的函数所在的文件模块和调用函数在文件中的位置等信息链接进目标程序,程序在运行时再从文件模块中寻找相应的代码;
动态库是在编译的时候,先告诉编译器用了哪个库,库在哪,然后在可执行文件真正运行的时候,再把库加载进来。

1.可执行文件大小不一样,占用磁盘大小不一样
3.扩展性与兼容性不一样:如果静态库中某个函数的实现变了,那么可执行文件必须重新编译,而对于动态链接生成的可执行文件,只需要更新动态库本身即可,不需要重新编译可执行文件。正因如此,使用动态库的程序方便升级和部署
4.依赖不一样:静态链接的可执行文件不需要依赖其他的内容即可运行,而动态链接的可执行文件必须依赖动态库的存在。即便如此,系统中一般存在一些大量公用的库,所以使用动态库并不会有什么问题。
5.复杂性不一样:相对来讲,动态库的处理要比静态库要复杂,例如,如何在运行时确定地址?多个进程如何共享一个动态库?当然,作为调用者我们不需要关注。另外动态库版本的管理也是一项技术活。
6.加载速度不一样:由于静态库在链接时就和可执行文件在一块了,而动态库在加载或者运行时才链接,因此,对于同样的程序,静态链接的要比动态链接加载更快。所以选择静态库还是动态库是空间和时间的考量。但是通常来说,牺牲这点性能来换取程序在空间上的节省和部署的灵活性时值得的。再加上局部性原理

面向对象

c++⾯向对象 三大特性

封装

最开始接触代码是C语言,那么开始写一些逻辑代码的时候会很麻烦,因为你要在函数中定义变量,然后按顺序写对应的逻辑,接着可以将逻辑封装成函数。当时会感觉很麻烦,因为很散装,知道后面学了struct结构体,把对应逻辑需要的数据可以放到一个结构体里面,这样就会比较好看。接着出现的问题就是数据封装到了一起,但是处理数据对应的逻辑即函数却还是在外面。因此就有了将数据和对应逻辑进行封装的类的出现。

封装就是将抽象得到的数据和行为相结合,形成一个有机的整体,也就是将数据与操作数据的源代码进行有机的结合,形成类,其中数据和函数都是类的成员,目的在于将对象的使用者和设计者分开,可以隐藏实现细节包括包含私有成员,使得代码模块增加安全指数,同时提高软件的可维护性和可修改性。

所以总结来说封装这个特性包含两三特点:

  1. 结合性,即是将属性和方法结合
  2. 信息隐蔽性,利用接口机制隐蔽内部实现细节,只留下接口给外界调用
  3. 实现代码重用

继承

类的派生指的是从已有类产生新类的过程。原有的类成为基类或父类,产生的新类称为派生类或子类,子类继承基类后,可以创建子类对象来调用基类的函数,变量等。

一般来说有如下三种继承方式:

  1. 单一继承:继承一个父类,这种继承称为单一继承,这也是我们用的做多的继承方式。
  2. 多重继承:一个派生类继承多个基类,类与类之间要用逗号隔开,类名之前要有继承权限,假使两个或两个基类都有某变量或函数,在子类中调用时需要加类名限定符如obj.classA::i = 1;
  3. 菱形继承:多重继承掺杂隔代继承1-n-1模式,此时需要用到虚继承,例如 B,C虚拟继承于A,D再多重继承B,C,否则会出错。后面有将具体虚继承怎么做。
    此外还有继承权限的问题,

多态

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

  • 静态多态:依赖参数的类型来选择调用特定版本的函数实现,在编译时可以确定,实现方式为模板或重载
  • 动态多态:即面向对象的多态,在运行时确定,实现方法为继承+虚函数

静态多态(模板或重载)

直接实现各自对象类型的定义,不需要共有基类,甚至可以没有任何关系。只需要各个具体类的实现中要求相同的接口声明,静态多态本质上就是模板的具现化。

	class Line
    {
    public:
        void Draw()const{    std::cout << "Line Draw()\n";    }
    };
    class Circle
    {
    public:
        void Draw(const char* name=NULL)const{    std::cout << "Circle Draw()\n";    }
    };
	template<typename Geometry>
    void DrawGeometry(const Geometry& geo)
    {
        geo.Draw();
    }
    Line line;
    DrawGeometry(line);
    Circle circle;
    DrawGeometry(circle);

//Line Draw()
//Circle Draw()

重载也可以成为特设多态(ad-hoc 多态)

class printData
{
   public:
      void print(int i) {
        cout << "整数为: " << i << endl;
      }
      void print(double  f) {
        cout << "浮点数为: " << f << endl;
      }
      void print(char c[]) {
        cout << "字符串为: " << c << endl;
      }
};

动态多态(面向对象、继承、多态、虚函数)

对于相关的对象类型,确定它们之间的一个共同功能集,然后在基类中,把这些共同的功能声明为多个公共的虚函数接口。各个子类重写这些虚函数,以完成具体的功能。具体实现就是c++的虚函数。多态是以封装和继承为基础实现的性质,一个形态的多种表现方式。一个接口,多个功能,在用父类指针调用函数时,实际调用的是指针指向的实际类型(子类)的成员函数。

class Geometry
{
public:
    virtual void Draw()const = 0;
};
class Line : public Geometry
{
public:
    virtual void Draw()const{    std::cout << "Line Draw()\n";    }
};
class Circle : public Geometry
{
public:
    virtual void Draw()const{    std::cout << "Circle Draw()\n";    }
};

void DrawGeometry(const Geometry *geo)
{
    geo->Draw();
};

Line line;
Circle circle;
DrawGeometry(&line);
DrawGeometry(&circle);

面向对象和面向过程语言的区别

首要要知道这两个都是一种编程思想

面向过程

就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了。

  • 性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式开发、 Linux/Unix等一般采用面向过程开发,性能是最重要的因素。
  • 没有面向对象易维护、易复用、易扩展

面向对象

是把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个事物在整个解决问题的步骤中的行为。

核心在于封装和归一化:封装明确标识出允许外部使用的所有成员函数和数据项,提供接口;归一化可以不加区分的处理所有接口兼容的对象集合,无需理会对象类型,即利用继承和多态实现。

  • 易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统 更加灵活、更加易于维护

  • 性能比面向过程低

c++虚函数

前瞻

虚函数是实现多态的一个技术之一。在c++中,动态联编通过指针和引用来实现。但是,想一想,正常来说我们不允许程序将一种类型的指针指向另外一种类型的地址。但是在类中却可以这样做,派生类的指针指向基类对象的地址(派生类对象的地址赋给基类指针),而且不用进行显式转换。

派生类的指针指向基类对象的地址(派生类对象的地址赋给基类指针)称为向上转型,c++允许隐式向上转型。将子类指向父类,向下转换则必须强制类型转换。

然后我们就可以用父类的指针指向其子类的对象,然后通过父类的指针调用实际子类的成员函数。如果子类重写了该成员函数就会调用子类的成员函数,没有声明重写就调用基类的成员函数。这种技术可以让父类的指针有“多种形态”。

虚函数工作原理

每个包含了虚函数的类都包含一个虚表。

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

C++面经_第2张图片

  • 虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。
  • 虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。
  • 普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。
  • 在编译器的编译阶段即可构造出虚表

C++面经_第3张图片

  • 每个对象内部包含一个虚指针,来指向自己所使用的虚表。由编译器在类中添加,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。
  • 一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。

动态绑定

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};
class B : public A {
public:
    virtual void vfunc1();
    void func1();
private:
    int m_data3;
};
class C: public B {
public:
    virtual void vfunc2();
    void func2();
private:
    int m_data1, m_data4;
};

C++面经_第4张图片

  • 这三个类都有虚函数,故编译器为每个类都创建了一个虚表,即类A的虚表(A vtbl),类B的虚表(B vtbl),类C的虚表(C vtbl)。
  • 类A,类B,类C的对象都拥有一个虚表指针,*__vptr,用来指向自己所属类的虚表。
B bObject;
A *p = & bObject;
  1. 定义一个类B的对象bObject。由于bObject是类B的一个对象,故bObject包含一个虚表指针,指向类B的虚表。
  2. 声明一个类A的指针p来指向对象bObject。虽然p是基类的指针只能指向基类的部分,但是虚表指针亦属于基类部分,所以p可以访问到对象bObject的虚表指针
  3. 而对象bObject的虚表指针指向类B的虚表,所以p->vfunc1();实质会调用B::vfunc1()函数。

我们把经过虚表调用虚函数的过程称为动态绑定,其表现出来的现象称为运行时多态。动态绑定区别于传统的函数调用,传统的函数调用我们称之为静态绑定,即函数的调用在编译阶段就可以确定下来了。

那么,什么时候会执行函数的动态绑定?这需要符合以下三个条件。

  • 通过指针来调用函数
  • 指针upcast向上转型(继承类向基类的转换称为upcast,关于什么是upcast,可以参考本文的参考资料)
  • 调用的是虚函数

如果一个函数调用符合以上三个条件,编译器就会把该函数调用编译成动态绑定,其函数的调用过程走的是上述通过虚表的机制。

继承情况下的虚函数表

单继承如上图所示

多继承如下:
C++面经_第5张图片

  • 子类有多少个基类就有多少个虚函数表指针,前提是基类要有虚函数才算上这个基类。
  • 子类虚函数会覆盖每一个父类的每一个同名虚函数。
  • 父类中没有的虚函数而子类有,填入第一个虚函数表中,且用父类指针是不能调用。
  • 父类中有的虚函数而子类没有,则不覆盖。仅子类和该父类指针能调用。

这样就会有内存和执行速度方面的成本:

  1. 每个对象都会增大,因为在对象最前面的位置加入了指针
  2. 对于每个类,编译器都会创建虚函数地址表
  3. 对于每个函数调用,都要查找表中地址

虚函数的性能分析

间接访问并不是虚函数速度慢的主要原因,真正原因是编译器在编译时通常并不知道它将要调用哪个函数,所以它不能被内联优化和其它很多优化,因此就会增加很多无意义的指令(准备寄存器、调用函数、保存状态等),而且如果虚函数有很多实现方法,那分支预测的成功率也会降低很多,分支预测错误也会导致程序性
能下降。

  • 第一步是通过对象的vptr找到该类的vtbl,因为虚函数表指针是编译器加上去的,通过vptr找到vtbl就是指针的寻址而已。

  • 第二步就是找到对应vtbl中虚函数的指针,因为vtbl大部分是指针数组的形式实现的

单继承的情况下调用虚函数所需的代价基本上和非虚函数效率一样,在大多数计算机上它多执行了很少的一些指令

多继承的情况由于会根据多个父类生成多个vptr,在对象里为寻找 vptr 而进行的偏移量计算会变得复杂一些

  1. 空间层面为了实现运行时多态机制,编译器会给每一个包含虚函数或继承了虚函数的类自动建立一个虚函数表,所以虚函数的一个代价就是会增加类的体积。在虚函数接口较少的类中这个代价并不明显,虚函数表vtbl的体积相当于几个函数指针的体积,如果你有大量的类或者在每个类中有大量的虚函数,你会发现 vtbl 会占用大量的地址空间
  2. 但这并不是最主要的代价,主要的代价是发生在类的继承过程中,在上面的分析中,可以看到,当子类继承父类的虚函数时,子类会有自己的vtbl,如果子类只覆盖父类的一两个虚函数接口,子类vtbl的其余部分内容会与父类重复。如果存在大量的子类继承,且重写父类的虚函数接口只占总数的一小部分的情况下,会造成大量地址空间浪费
  3. 同时由于虚函数指针vptr的存在,虚函数也会增加该类的每个对象的体积。在单继承或没有继承的情况下,类的每个对象只会多一个vptr指针的体积,也就是4个字节;在多继承的情况下,类的每个对象会多N个(N=包含虚函数的父类个数)vptr的体积,也就是4N个字节。当一个类的对象体积较大时,这个代价不是很明显,但当一个类的对象很轻量的时候,如成员变量只有4个字节,那么再加上4(或4N)个字节的vptr,对象的体积相当于翻了1(或N)倍,这个代价是非常大的。

虚函数的一些问题

  • 构造函数可以设置为虚的吗?
    答:不能。因为虚函数的调用是需要通过“虚函数表”来进行的,而虚函数表也需要在对象实例化之后才能够进行调用。在构造对象的过程中,还没有为“虚函数表”分配内存。所以,这个调用也是违背先实例化后调用的准则。

    子类的默认构造函数总要执行的操作:执行基类的代码后调用父类的构造函数。

  • c++虚析构函数
    如果类是父类,则必须声明为虚析构函数。基类声明一个虚析构函数,为了确保释放派生对象时,按照正确的顺序调用析构函数。

    如果析构函数不是虚的,那么编译器只会调用对应指针类型的虚构函数。切记,是指针类型的,不是指针指向类型的!而其他类的析构函数就不会被调用。例如如下代码:

  Employee* pe = new Singer;
  delete pe;

只会调用Employee的析构函数而不会调用Singer类的析构函数。如果这个类不是父类也可以定义虚析构函数,只是效率方面问题罢了

  • 那些函数不能是虚函数?
    除了上面说的构造和析构函数往外。

    友元函数不是虚函数,因为友元函数不是类成员,只有类成员才能使虚函数。

    静态成员函数不能是虚。在C++中,静态成员函数不能被声明为virtual函数。首先会编译失败,也就是不能同过编译。

原因如下:

  1. static成员不属于任何类对象或类实例,所以即使给此函数加上virtual也是没有任何意义的。
  2. 静态与非静态成员函数之间有一个主要的区别。那就是静态成员函数没有隐藏的this指针。对于虚函数,它的调用恰恰需要this指针。在有虚函数的类实例中,this指针调用vptr指针,vptr找到vtable(虚函数列表),通过虚函数列表找到需要调用的虚函数的地址。总体来说虚函数的调用关系是:this指针->vptr->vtable ->virtual虚函数。所以说,static静态函数没有this指针,也就无法找到虚函数了

内联函数也不能是虚的,因为要在编译的时候展开,而虚函数要求动态绑定。另外就是虚函数的类对象必须包含vptr,但是内联函数是没有地址的,编译的时候直接展开了所以不行。

构造函数也不行。

成员函数模板不能是虚函数。因为c++ 编译器在解析一个类的时候就要确定虚函数表的大小,如果允许一个虚函数是模板函数,那么compiler就需要在parse这个类之前扫描所有的代码,找出这个模板成员函数的调用(实例化),然后才能确定vtable的大小,而显然这是不可行的,除非改变当前compiler的工作机制。因为类模板中的成员函数在调用的时候才会创建

  • 虚函数表是共享还是独有的?
    答:虚函数表是针对类的,一个类的所有对象的虚函数表都一样。在gcc编译器的实现中虚函数表vtable存放在可执行文件的只读数据段.rodata中。是编译器在编译器为我们处理好的。

  • 虚函数表和虚函数指针的位置?
    答:既不在堆上,也不在栈上。虚函数表(vtable)的表项在编译期已经确定,也就是一组常量函数指针。跟代码一样,在程序编译好的时候就保存在可执行文件里面。程序运行前直接加载到内存中即可。而堆和栈都是在运行时分配的。而跟虚函数表对应的,是虚函数表指针(vptr),作为对象的一个(隐藏的)成员,总是跟对象的其他成员一起。如果对象分配在堆上,vptr也跟着在堆上;如果对象分配在栈上,vptr也在栈上……

  • 编译器如何处理虚函数表
    对于派生类来说,编译器简历虚表的过程有三步:

  1. 拷贝基类的虚函数表,如果是多继承,就拷贝每个基类的虚函数表
  2. 查看派生类中是否有重写基类的虚函数,如果有,就替换成已经重写后的虚函数地址
  3. 查看派生类中是否有新添加的虚函数,如果有,就加入到自身的虚函数表中
  • 构造函数或析构函数中调用虚函数会怎样?
    首先不应该在构造函数和析构函数中调用虚函数。

    在构造函数中调用虚函数。假如有一个动物基类,这个基类定义了一个虚函数来表示动物的行为,叫做action。我们在基类的构造函数中调用这个虚函数。然后有一个派生类重写了该虚函数。当我们创建一个派生类对象的时候,首先会执行基类部分,因此执行基类的构造函数,然后才会执行子类的构造函数。编译器在执行基类构造函数中的虚函数时,会认为这是一个基类的对象,因为派生类还并没有构造出来。因此达不到动态绑定的效果,父类构造函数中调用的仍然是父类版本的函数,子类中调用的仍然是子类版本的函数

    在析构函数中调用虚函数。析构函数也是一样,派生类先进行析构,如果有父类的析构函数中有virtual函数的话,派生类的内容已经被析构了,C++会视其基类,执行基类的virtual函数。

  • 如何获取 虚表地址和虚函数地址?

  //该类如下:
  class Base {
  public:
      virtual void f() { cout << "Base::f" << endl; }
      virtual void g() { cout << "Base::g" << endl; }
      void h() { cout << "Base::h" << endl; }
  };
  
  Base b;
  //  1.&b代表对象b的起始地址
  //  2.(int *)&b 强转成int *类型,为了后面取b对象的前4个字节,前四个字节是虚表指针
  //  3.*(int *)&b 取前四个字节,即vptr虚表地址
  printf("虚表地址:%p\n", *(int *)&b);
  //  根据上面的解析我们知道*(int *)&b是vptr,即虚表指针.并且虚表是存放虚函数指针的
  //  所以虚表中每个元素(虚函数指针)在32位编译器下是4个字节,因此(int *)*(int *)&b
  //  这样强转后为了后面的取四个字节.所以*(int *)*(int *)&b就是虚表的第一个元素.
  printf("第一个虚函数地址:%p\n", *(int *)*(int *)&b);
  printf("第二个虚函数地址:%p\n", *((int *)*(int *)(&b) + 1));
  //始终记着vptr指向的是一块内存,
  //  这块内存存放着虚函数地址,这块内存就是我们所说的虚表.
  //64位下把int换成longlong就好了

抽象基类-纯虚函数

定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。

有纯虚函数的类叫做抽象基类,对于抽象类来说,C++是不允许它去实例化对象的。也就是说,抽象类无法实例化对象

virtual void func() = 0;

“=0”这个操作在虚函数中有2层意思,即告诉编译器:

  1. 有的朋友误解这是返回值为0的意思,但是它并不是,它仅表示的是这个是个纯虚函数,是个抽象函数,没有实现
  2. 这个类的继承类里面必须要实现这个函数。

C++所有构造函数

类对象被创建时,编译器为对象分配内存空间,并自动调用构造函数,由构造函数完成成员的初始化工作。

因此构造函数的是初始化对象的成员函数

默认构造函数: 如果没有人为构造函数,则编译器会自动默认生成一个无参构造函数。

一般构造函数: 包含各种参数,一个类可以有多个一般构造函数,前提是参数的个数和类型和传入参数的顺序都不相同,根据传入参数调用对应的构造函数。

拷贝构造函数: 拷⻉构造函数的函数参数为对象本身的引用,用于根据⼀个已存在的对象复制出⼀个新的该类的对象,⼀般在函数中会将已存在的对象的数据成员的值⼀⼀复制到新创建的对象中。如果没有显示的写拷⻉构造函数,则系统会默认创建⼀个拷⻉构造函数,但当类中有指针成员时,最好不要使⽤编译器提供的默认的拷⻉构造函 数,最好⾃⼰定义并且在函数中执⾏深拷⻉

移动构造函数: 有时候我们会遇到这样一种情况,我们用对象a初始化对象b后对象a我们就不在使用了,但是对象a的空间还在呀(在析构之前)所以可以直接使用a的空间,这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷。函数参数为对象的右值引用&& ,拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制

#include 
using namespace std;
class demo{
public:
    demo():num(new int(6)){
        cout<<"construct!"<<endl;
    }
    demo(const demo &d):num(new int(*d.num)){
        cout<<"copy construct!"<<endl;
    }
    demo& operator=(const demo &d){
    	*(this->num) = *d.num;
    	cout<<"copy ="<<endl;
    	return *this;
    }
    //添加移动构造函数
    demo(demo &&d):num(d.num){
        d.num = NULL;
        cout<<"move construct!"<<endl;
    }
    ~demo(){
    	delete num;
        cout<<"class destruct!"<<endl;
    }
    int *num;
};
demo get_demo(){
    return demo();
}
int main(){
    demo a = get_demo();
    cout << *a.num <<"\n";
    demo b;
    b = a;
    cout << *a.num <<"\n";
    return 0;
}

//g++ p.cpp -o p -fno-elide-constructors
//./p
//construct!
//move construct!
//class destruct!
//move construct!
//class destruct!
//6
//construct!
//copy =
//6
//class destruct!
//class destruct!

浅层复制时,两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了(pointer dangling)。所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间(同时也是b->value指向的空间)

赋值构造函数:=运算符的重载,类似拷贝构造函数,将=右边的类对象赋值给类对象左边的对象,不属于构造函数,=两边的对象必须都要被创建

类型转换构造函数: 有时候不想要隐式转换,用explict关键字修饰。一般来说带一个参数的构造函数,或者其他参数是默认的构造函数 http://c.biancheng.net/view/2339.html

成员初始化列表

  • 当使用构造函数初始化的时候,首先调用一次构造函数生成对象mA,然后执行赋值构造函数。
  • 使用成员列表初始化的时候,直接调用的是拷贝构造函数完成。
  • 可以看到成员列表初始化的情况下少一次构造函数的调用,效率上肯定会提高。
#include 
using namespace std;
class demo{
public:
    demo():num(new int(6)){
        cout<<"construct!"<<endl;
    }
    demo(const demo &d):num(new int(*d.num)){
        cout<<"copy construct!"<<endl;
    }
    demo& operator=(const demo &d){
    	*(this->num) = *d.num;
    	cout<<"copy ="<<endl;
    	return *this;
    }
    //添加移动构造函数
    demo(demo &&d):num(d.num){
        d.num = NULL;
        cout<<"move construct!"<<endl;
    }
    ~demo(){
    	delete num;
        cout<<"class destruct!"<<endl;
    }
    int *num;
};

class classB
{
public:
    //构造函数初始化
    //classB(demo &a) {mA = a;}
    //成员列表初始化
    classB(demo &a): mA(a) {}
private:
    demo mA;
};

int main(){
    demo a;
    cout << *a.num <<"\n";
    classB b(a);
    return 0;
}

// 成员列表初始化
construct!
6
copy construct!
class destruct!
class destruct!
// 构造函数初始化
construct!
6
construct!
copy =
class destruct!
class destruct!

什么情况下会调用拷贝构造函数?

  1. 对象以值传递的方式进入函数体
  2. 对象以值传递的方式从函数返回
  3. 一个对象需要另外一个对象初始化

为什么拷贝构造函数必须是引用?

为了防止递归调用。

如果不用引用,就会是值传递的方式,但是值传递会调用拷贝构造函数生成临时对象,从而又调用一次拷贝构造函数。就这样无穷的递归下去。

如果是指针类型

就变成了一个带参数的构造函数了。。。

比如A(A* test)

构造函数析构函数是否能抛出异常

构造函数可以抛出异常

对象只有在构造函数执行完成之后才算构造妥当,c++只会析构已经完成的对象。因此如果构造函数中发生异常,控制权就需要转移出构造函数,执行异常处理函数。在这个过程中系统会认为对象没有构造成功,导致不会调用析构函数。在构造函数中抛出异常会导致当前函数执行流程终止,在构造函数流程前构造的成员对象会被释放,但是如果在构造函数中申请了内存操作,则会造成内存泄漏。另外,如果有继承关系,派生类中的构造函数抛出异常,那么基类的构造函数和析构函数可以照常执行的。

解决办法:用智能指针来管理内存就可以

C++标准指明析构函数不能、也不应该抛出异常

C++异常处理模型最大的特点和优势就是对C++中的面向对象提供了最强大的无缝支持。那么如果对象在运行期间出现了异常,C++异常处理模型有责任清除那些由于出现异常所导致的已经失效了的对象(也即对象超出了它原来的作用域),并释放对象原来所分配的资源, 这就是调用这些对象的析构函数来完成释放资源的任务,所以从这个意义上说,析构函数已经变成了异常处理的一部分。

析构函数不能抛出异常原因有两个:

  1. 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
  2. 异常发生时,c++的异常处理机制在异常的传播过程中会进行栈展开(stack-unwinding)。在栈展开的过程中就会调用已经在栈构造好的对象的析构函数来释放资源,此时若其他析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃。
    解决办法:把异常完全封装在析构函数内部,决不让异常抛出函数之外,代码如下:
DBConn::~DBconn()
{
    try
    {
	    db.close(); 
    }
    catch(...)
    {
        abort();
    }
}
//如果close抛出异常就结束程序,通常调用abort完成:

创建派生类对象,构造函数的执行顺序是什么?析构函数的执行顺序?

首先要知道类的构造函数不能被继承,构造函数不能被继承是有道理的,因为即使继承了,它的名字和派生类的名字也不一样(构造函数名字和类名一样),不能成为派生类的构造函数,当然更不能成为普通的成员函数。在设计派生类时,对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是大部分基类都有private属性的成员变量,它们在派生类中无法访问,更不能使用派生类的构造函数来初始化。这种矛盾在C++继承中是普遍存在的,解决这个问题的思路是:在派生类的构造函数中调用基类的构造函数

对象创建时候执行顺序是:**静态代码 --> 非静态代码 --> 构造函数。**静态代码包括(静态方法,静态变量,静态代码块等),非静态代码即(成员方法,成员变量,成员代码块等)。代码块中或者成员变量中如果有类对象的话,肯定要先将类对象创建出来。所以说先执行代码块再执行构造函数,而且如果代码块中有多个成员变量,则按照成员变量的声明顺序进行构造。初始化列表的顺序, 不影响成员变量构造顺序

因为静态变量等这些在内存中属于全局变量,所以要先创建。然后类会查看成员函数的相关信息,为该类的对象中的每个成员变量分配相应的存储空间,然后再执行构造函数创建对象,如果构造函数有初始化,则将值放到准备好的内存空间中。

构造函数执行顺序

  1. 基类构造函数。如果有多个基类,则构造函数的调用顺序是该基类在派生类中出现的顺序,而不是他们在成员初始化列表中的顺序。

  2. 成员类对象构造函数。如果有多个成员类构造函数调用顺序是对象在类中被声明的顺序,而不是他们在成员初始化列表中的顺序

  3. 派生类构造函数
    析构函数顺序

  4. 派生类析构函数

  5. 成员类对象的析构函数

  6. 调用基类的析构函数

C++成员变量的初始化顺序问题

class A
{
private:
    int n1;
    int n2;
public:
    A():n2(0),n1(n2+2){}
    void Print(){
        cout << "n1:" << n1 << ", n2: " << n2 <
  1. 成员变量在使用初始化列表初始化时,只与定义成员变量的顺序有关,与构造函数中初始化成员列表的顺序无关。因为成员变量的初始化次序是根据变量在内存中次序有关,而内存中的排列顺序早在编译期就根据变量的定义次序决定了。

  2. 如果不使用初始化列表初始化,在构造函数内初始化时,此时与成员变量在构造函数中的位置有关。

  3. 类成员在定义时,是不能初始化的

  4. 类中const成员常量必须在构造函数初始化列表中初始化。

  5. 类中static成员变量,必须在类外初始化。
    变量的初始化顺序:

  6. 初始化基类中的静态成员变量和静态代码块,按照在程序中出现的顺序初始化;

  7. 初始化派生类中的静态成员变量和静态代码块,按照在程序中出现的顺序初始化;

  8. 初始化基类的普通成员变量和代码块,再执行父类的构造方法;

  9. 初始化派生的普通成员变量和代码块,在执行子类的构造方法;

c++的public、private、protected

public:公有类权限,可以被任何类或者函数访问。
protected:受保护类权限,可以被本类或子类访问。
private:私有类权限,只能被本类访问

c++类中默认是私有继承
c++结构体中默认是public继承。

继承

  1. 公有继承(public)

公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,它们都保持原有的状态,而基类的私有成员仍然是私有的,不能被这个派生类的子类所访问。

  1. 私有继承(private)

私有继承的特点是基类的公有成员和保护成员都作为派生类的私有成员,并且不能被这个派生类的子类所访问。

  1. 保护继承(protected)

保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元访问,基类的私有成员仍然是私有的。

#include
using namespace std;
//
class A       //父类
{
private:
    int privatedateA;
protected:
    int protecteddateA;
public:
    int publicdateA;
};
//
class B :public A      //基类A的派生类B(共有继承)
{
public:
    void funct()
    {
        int b;
        b=privatedateA;   //error:基类中私有成员在派生类中是不可见的
        b=protecteddateA; //ok:基类的保护成员在派生类中为保护成员
        b=publicdateA;    //ok:基类的公共成员在派生类中为公共成员
    }
};
//
class C :private A  //基类A的派生类C(私有继承)
{
public:
    void funct()
    {
        int c;
        c=privatedateA;    //error:基类中私有成员在派生类中是不可见的
        c=protecteddateA;  //ok:基类的保护成员在派生类中为私有成员
        c=publicdateA;     //ok:基类的公共成员在派生类中为私有成员
    }
};
//
class D :protected A   //基类A的派生类D(保护继承)
{
public:
    void funct()
    {
        int d;
        d=privatedateA;   //error:基类中私有成员在派生类中是不可见的
        d=protecteddateA; //ok:基类的保护成员在派生类中为保护成员
        d=publicdateA;    //ok:基类的公共成员在派生类中为保护成员
    }
};
//
int main()
{
    int a;
 
    B objB;
    a=objB.privatedateA;   //error:基类中私有成员在派生类中是不可见的,对对象不可见
    a=objB.protecteddateA; //error:基类的保护成员在派生类中为保护成员,对对象不可见
    a=objB.publicdateA;    //ok:基类的公共成员在派生类中为公共成员,对对象可见
 
    C objC;
    a=objC.privatedateA;   //error:基类中私有成员在派生类中是不可见的,对对象不可见
    a=objC.protecteddateA; //error:基类的保护成员在派生类中为私有成员,对对象不可见
    a=objC.publicdateA;    //error:基类的公共成员在派生类中为私有成员,对对象不可见
 
    D objD;
    a=objD.privatedateA;   //error:基类中私有成员在派生类中是不可见的,对对象不可见
    a=objD.protecteddateA; //error:基类的保护成员在派生类中为保护成员,对对象不可见
    a=objD.publicdateA;    //error:基类的公共成员在派生类中为保护成员,对对象不可见
 
    return 0;
}

什么不能被继承?

1、构造函数

构造函数不能被继承第一个原因:在创建派生类对象时必须调用派生类的构造函数。派生类构造函数通常使用成员列表初始化来调用基类构造函数以创建派生类中的基类部分。 如果派生类没有使用成员列表初始化语法,则将使用默认的基类构造函数,如果基类没有默认的构造函数就会报错。第二个原因:因为即使继承了,它的名字和派生类的名字也不一样(构造函数名字和类名一样),不能成为派生类的构造函数,当然更不能成为普通的成员函数。在设计派生类时,对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是大部分基类都有private属性的成员变量,它们在派生类中无法访问,更不能使用派生类的构造函数来初始化。这种矛盾在C++继承中是普遍存在的,解决这个问题的思路是:在派生类的构造函数中调用基类的构造函数

2、析构函数

析构函数不能被继承,释放对象的时候需要先调用派生类析构函数然后调用基类析构函数。

3、赋值运算符=

这里面说一下为什么赋值运算符不能被继承,其实不应该说不能被继承,而是被覆盖了。第一点是在c++的编译器中如果没有定义赋值运算符,会自动加上一个默认的。所以如果赋值运算符可以被继承,则会导致派生类也会有这个赋值运算符。第二就是在如果派生类的成员和基类的成员有相同的声明,那么派生类会覆盖基类的成员,哪怕参数和数据类型都不相同,也会被覆盖。显然,B1中的赋值运算符函数名operator =和基类A1中的operator =同名,所以,A1中的赋值运算符函数int perator=(int a);被B1中的隐含的赋值运算符函数B1& perator =(const B1& robj);所覆盖。所以赋值运算符会被覆盖,不能被继承,那么派生类就不能使用基类中的赋值运算符做一些事情,就像图上说的特征标随类而异。举个例子就是基类A中赋值运算符参数是int,但是派生类赋值运算符参数是类类型,导致编译出错。

class A1
{
public:
    int operator=(int a)
    {
        return 8;
    }
};



class B1 : public A1
{
public:
     B1& operator =(const B1& robj); // 注意这一行是编译器添加的,派生类和基类参数都不一样,肯定会报错。
};
B1 b;
int a= 8
b=a//报错。

除了如上面的三个函数不能被继承外还有一些,如:

c++11的final关键字

将自身的构造函数与析构函数放在private作用域内

友元+虚继承

解释一下这个,首先要明白什么是友元和虚继承。

友元:友元的本质就是想在类外不通过成员函数访问类的私有成员,分为友元函数和友元类。友元函数内部可以直接访问私有成员。一个类A可以将另一个类B声明为自己的友元,类B的所有成员函数就都可以访问类 A 对象的私有成员。

虚继承:虚拟继承是多重继承中特有的概念。虚拟基类是为解决多重继承而出现的。如:类D继承自类B1、B2,而类B1、B2都继承自类A,因此在类D中两次出现类A中的变量和函数。为了节省内存空间,可以将B1、B2对A的继承定义为虚拟继承,而A就成了虚拟基类。虚拟继承在一般的应用中很少用到,所以也往往被忽视,这也主要是因为在C++中,多重继承是不推荐的,也并不常用,而一旦离开了多重继承,虚拟继承就完全失去了存在的必要因为这样只会降低效率和占用更多的空间。

class CFinalClassMixin {//从这个类中继承的类都不能再被继承
  friend class Cparent;
private:
  CFinalClassMixin() {}
  ~CFinalClassMixin(){}
};
class Cparent: virtual public CFinalClassMixin, public CXXX {
public:
  Cparent() {}
  ~Cparent(){}
};
class CChild : public Cparent {
public:
  CChild() {};//编译错误
  ~CChild() {};//编译错误
};

如果不是虚继承,那么CChild直接调用Cparent的构造函数,这是成立的,而且CChild是不需要调用CFinalClassMixin的构造函数。若把它声明为虚继承(派生类对象一定会调用基类的构造函数),那么CChild就必须负责CFinalClassMixin构造函数的调用,这样又因为不能继承friend类,所以不能调用,造成了错误。

C++中this指针相关问题

This指针的来源

通过转化成c语言好理解一点。早期还没有针对特定c++的编译器,因此编译c++的时候都先翻译成c语言,再进行编译。对于class结构来说,c语言中与之对应的就是结构体。

类中的成员变量可以翻译成结构体域中的变量,但是结构体中没有成员函数这个概念,因此成员函数就翻译成为全局函数。那么如果函数内部想使用成员数据,可以使用该对象指针的方式,指向成员变量。

其作用就是指向非静态成员函数所作用的对象。 在类对象的内存空间中,只有数据成员和虚函数表指针,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。

  1. this 指针是一个隐含于每一个非静态成员函数中的特殊指针。它指向调用该非静态成员函数的那个对象。
  2. 当对一个对象调用成员函数时,编译程序先将对象的地址赋给 this 指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用 this 指针。
  3. this 并不是一个常规变量,而是个右值,所以不能取得 this 的地址(不能 &this)。

this指针是什么时候创建的?

this在成员函数的开始执行前构造,在成员的执行结束后清除。

但是如果class里面没有方法的话,它们是没有构造函数的,只能当做C的struct使用。采用TYPE xx的方式定义的话,在栈里分配内存,这时候this指针的值就是这块内存的地址。采用new的方式创建对象的话,在堆里分配内存,new操作符通过eax(累加寄存器)返回分配的地址,然后设置给指针变量。之后去调用构造函数(如果有构造函数的话),这时将这个内存块的地址传给ecx

this指针存放在何处?堆、栈、全局变量,还是其他?

this指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。在汇编级别里面,一个值只会以3种形式出现:立即数、寄存器值和内存变量值。不是存放在寄存器就是存放在内存中,它们并不是和高级语言变量对应的。

如果我们知道一个对象this指针的位置,可以直接使用吗?

this指针只有在成员函数中才有定义。

因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。

在成员函数中调用delete this会出现什么问题?

在类对象的内存空间中,只有数据成员和虚函数表指针,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。

当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。

如果在类的析构函数中调用delete this,会发生什么?

会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。

语言特性和数据类型

结构体(struct)和共同体(union)的区别

结构体struct

把不同类型的数据组合成一个整体。struct里每个成员都有自己独立的地址。sizeof(struct)是内存对齐后所有成员长度的加和。(引申出内存对齐的问题)

共同体union

各成员共享一段内存空间, 一个union变量的长度等于各成员中最长的长度,以达到节省空间的目的。所谓的共享不是指把多个成员同时装入一个union变量内, 而是指该union变量可被赋予任一成员值,但每次只能赋一种值, 赋入新值则覆盖旧值。 sizeof(union)是最长的数据成员的长度。

struct的内存对齐规则

为什么要字节对齐?

需要字节对齐的根本原因在于CPU访问数据的效率问题。假如没有字节对齐,那么一个double类型的变量会存储在4-11上(正常是0-7)这样计算机取这个数据的会会取两次,降低效率。而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误。

对齐规则

由于在x86下,GCC默认按4字节对齐,但是可以使用__attribute__选项改变对齐规则, vs studio上用#pragma pack (n)方式改变

struct node1  
{
    char c1; // 1 byte
    char c2;// 1 byte
    int a;// 4 byte
};
// 1 + 1 + (2) + 4,补了两字节

int main(){
	node1 str1;
	std::cout<

std::initializer_list

普通数组、POD (plain old data,没有构造、析构和虚函数的类或结构体)类型都可以使用 {} 进行初始化,也就是我们所说的初始化列表。而对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 () 进行。这些不同方法都针对各自对象,不能通用。所以引入initializer_list

#include 
class Magic {
public:
Magic(std::initializer_list<int> list);
};
Magic magic = {1,2,3,4,5};
std::vector<int> v = {1, 2, 3, 4};

lambda表达式

我觉得lambda表达式的出现很简洁,也使得代码变得没那么膨胀吧

lambda 表达式定义了一个匿名函数,并且可以捕获一定范围内的变量。lambda 表达式的语法形式简单归纳如下:

[capture](params) opt -> ret {body;};

[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
// 函数体
}

捕获列表 [capture]

捕获一定范围内的变量,有以下几种方式:

  1. 值捕获,被捕获的变量在 lambda 表达式被创建时拷贝,而非调用时才拷贝
void learn_lambda_func_1() {
	int value_1 = 1;
	auto copy_value_1 = [value_1] {
	return value_1;
};
value_1 = 100;
auto stored_value_1 = copy_value_1();
// 这时, stored_value_1 == 1, 而 value_1 == 100.
// 因为 copy_value_1 在创建时就保存了一份 value_1 的拷贝
}
  1. 引用捕获,与引用传参类似,引用捕获保存的是引用,值会发生变化。
void learn_lambda_func_2() {
int value_2 = 1;
auto copy_value_2 = [&value_2] {
return value_2;
};
value_2 = 100;
auto stored_value_2 = copy_value_2();
// 这时, stored_value_2 == 100, value_1 == 100.
// 因为 copy_value_2 保存的是引用
}
  1. 隐式捕获,手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处
    理,这时候可以在捕获列表中写一个 & 或 = 向编译器声明采用 引用捕获或者值捕获.
    [] - 表示不捕捉任何变量
    [&] - 捕获外部作用域中所有变量,并作为引用在函数体内使用 (按引用捕获)
    [=] - 捕获外部作用域中所有变量,并作为副本在函数体内使用 (按值捕获)
    [=, &foo] - 按值捕获外部作用域中所有变量,并按照引用捕获外部变量 foo
    [this] - 捕获当前类中的 this 指针,让 lambda 表达式拥有和当前类成员函数同样的访问权限

参数列表 (params): 和普通函数的参数列表一样,如果没有参数参数列表可以省略不写
opt 选项:不需要可以省略,一般有两个
1. mutable: 可以修改按值传递进来的拷贝(注意是能修改拷贝,而不是值本身)
2. exception: 指定函数抛出的异常,如抛出整数类型的异常,可以使用 throw ();

返回值类型:很多时候,lambda 表达式的返回值是非常明显的,因此在 C++11 中允许省略 lambda 表达式的返回值。

函数体:函数的实现,这部分不能省略,但函数体可以为空。

static 和const分别怎么用,类里面static和const可以同时修饰成员函数吗

static

static对于变量

  1. 局部变量
    在局部变量之前加上关键字static,局部变量就被定义成为一个局部静态变量。

    内存中的位置:data段
    
    初始化:局部的静态变量只能被初始化一次
    
    作用域:作用域仍为局部作用域,当定义它的函数或者语句块结束的时候,作用域随之结束。
    

当static用来修饰局部变量的时候,它就改变了局部变量的存储位置(从原来的栈中存放改为静态存储区)及其生命周期(局部静态变量在离开作用域之后,并没有被销毁,而是仍然驻留在内存当中,直到程序结束,只不过我们不能再对他进行访问),但未改变其作用域。

  1. 全局变量
    在全局变量之前加上关键字static,全局变量就被定义成为一个全局静态变量。

    内存中的位置:静态存储区(静态存储区在整个程序运行期间都存在)
    
    初始化:未经初始化的全局静态变量会被程序自动初始化为0
    
    作用域:全局静态变量在声明他的文件之外是不可见的。准确地讲从定义之处开始到文件结尾。(只能在本文件中存在和使用)
    

全局变量本身就是静态存储方式, 静态全局变量当然也是静态存储方式。两者的区别在于非静态全局变量的作用域是整个源程序, 当一个源程序由多个源文件组成时,非静态的全局变量在各个源文件中都是有效的(在其他源文件中使用时加上extern关键字重新声明即可)。 而静态全局变量则限制了其作用域, 即只在定义该变量的源文件内有效, 在同一源程序的其它源文件中不能使用它

static对于函数

   修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命名空间里的函数重名,可以将函数定位为 static。(和全局变量一样限制了作用域而已)

static对于类

  1. 成员变量
    用static修饰类的数据成员实际使其成为类的全局变量,会被类的所有对象共享,包括派生类的对象。

    因此,**static成员必须在类外进行初始化,而不能在构造函数内进行初始化。不过也可以用const修饰static数据成员在类内初始化 。**
    
  2. 成员函数
    用static修饰成员函数,使这个类只存在这一份函数,所有对象共享该函数,不含this指针。

    静态成员是可以独立访问的,也就是说,无须创建任何对象实例就可以访问。
    
    **不可以同时用const和static修饰成员函数。**
    

const

  1. const修饰变量:限定变量为不可修改。
  2. const修饰指针:指针常量和指向常量的指针
  3. const和函数:有以下几种形式
     const int& fun(int& a); //修饰返回值
     int& fun(const int& a); //修饰形参
     int& fun(int& a) const{} //const成员函数
  1. const和类:①const修饰成员变量,在某个对象的声明周期内是常量,但是对于整个类而言是可以改变的。因为类可以创建多个对象,不同的对象其const成员变量的值是不同的。切记,不能在类内初始化const成员变量,因为类的对象没创建前,编译器并不知道const成员变量是什么,因此const数据成员只能在初始化列表中初始化。②const修饰成员函数,主要目的是防止成员函数修改成员变量的值,即该成员函数并不能修改成员变量。③const对象,常对象,常对象只能调用常函数。
  2. 限定成员函数不可以修改任何数据成员
  • static和const可以同时修饰成员函数吗?
    答:不可以。C++编译器在实现const的成员函数的时候为了确保该函数不能修改类的实例的状态,会在函数中添加一个隐式的参数const this*。但当一个成员为static的时候,该函数是没有this指针的。也就是说此时const的用法和static是冲突的。两者的语意是矛盾的。static的作用是表示该函数只作用在类型的静态变量上,与类的实例没有关系;而const的作用是确保函数不能修改类的实例的状态**,与类型的静态变量没有关系。因此不能同时用它们。

static初始化时机和线程安全问题

先说在C语言中:

静态局部变量和全局变量一样,数据都存放在全局区域,所以在主程序之前,编译器已经为其分配好了内存,在编译阶段分配好了内存之后就进行初始化,在程序运行结束时变量所处的全局内存会被回收。所以在c语言中无法使用变量对静态局部变量进行初始化。

再说C++和C语言的区别:

c++主要引入了类这种东西,要进行初始化必须考虑到相应的构造函数和析构函数,而且很多时候构造或者析构函数中会指定我们定义的操作,并非简单的分配内存。因此为了造成不必要的影响(一些我不需要的东西被提前构造出来)所以c++规定全局或者静态对象在首次用到的时候才会初始化。

所以c++整了两种初始化的情况,我理解就是编译初始化和运行初始化。

编译初始化也叫静态初始化。对全局变量和const类型的初始化主要是,叫做zero initialization 和 const initialization,静态初始化在程序加载的过程中完成。从具体实现上看,zero initialization 的变量会被保存在 bss 段,const initialization 的变量则放在 data 段内,程序加载即可完成初始化,这和 c 语言里的全局变量静态变量初始化基本是一致的。其次全局类对象也是在编译器初始化。

动态初始化也叫运行时初始化。主要是指需要经过函数调用才能完成的初始化或者是类的初始化,一般来说是局部静态类对象的初始化和局部静态变量的初始化。

C++面经_第6张图片

下面是我自己的实验的一段代码:

#include
static int a;
int main()
{
	int b = 5;
	{	
        //g++编译报错
	 	static int c = b;
        //gcc编译不报错
        static int c = b;	
	}
	return 0;
}

如果在c语言中有局部静态变量赋值操作的话会报错:undefined reference to ' __cxa_guard_acquire'和 '__cxa_guard_release'。而这两个API接口恰恰是c++中保证局部静态变量运行时初始化的关键,具体参考c++局部静态变量和线程安全具体实现参考

线程安全问题:

C语言中非局部静态变量一般在main执行之前的静态初始化过程中分配内存并初始化,可以认为是线程安全的;C++11标准针规定了局部静态变量初始化是线程安全的。这里的线程安全指的是:一个线程在初始化 m 的时候,其他线程执行到 m 的初始化这一行的时候,就会挂起而不是跳过。

具体实现如下:局部静态变量在编译时,编译器的实现是和全局变量类似的,均存储在bss段中。然后编译器会生成一个guard_for_bar 用来保证线程安全和一次性初始化的整型变量,是编译器生成的,存储在 bss 段。它的最低的一个字节被用作相应静态变量是否已被初始化的标志, 若为 0 表示还未被初始化,否则表示已被初始化(if ((guard_for_bar & 0xff) == 0)判断)。 __cxa_guard_acquire 实际上是一个加锁的过程, 相应的 __cxa_guard_abort__cxa_guard_release 释放锁。

void foo() {
    static Bar bar;
}

//gcc 4.8.3 编译器生成的汇编代码
void foo() {
    if ((guard_for_bar & 0xff) == 0) {
        if (__cxa_guard_acquire(&guard_for_bar)) {
            try {
                Bar::Bar(&bar);
            } catch (...) {
                __cxa_guard_abort(&guard_for_bar);
                throw;
            }
            __cxa_guard_release(&guard_for_bar);
            __cxa_atexit(Bar::~Bar, &bar, &__dso_handle);
        }
    }
    // ...
}

内存与指针

指针和引用的区别

关于引用的本质可以看这个

指针和引⽤都是⼀种内存地址的概念,区别呢,指针是⼀个实体,引⽤只是⼀个别名,因此:

  • sizeof 指针得到的是指针本身的⼤⼩,sizeof 引⽤得到代表对象的⼤⼩
  • 指针和引用的自增运算结果不同,指针是指向下一个地址,引用是引用的变量值加1;
  • 引用在定义时必须初始化,而指针可以不用初始化;
  • 指针常量本身允许寻址,即&p返回指针常量本身的地址,*p表示被指向的对象;引用变量本身不允许寻址,&r返回的是被引用对象的地址,而不是变量r的地址(r的地址由编译器掌握,程序员无法直接对它进行存取)
  • 在参数传递中,指针需要被解引⽤后才可以对对象进⾏操作,⽽直接对引⽤进⾏的修改会直接作⽤到引⽤对象上。
  • 组元素允许是指针但不允许是引用,主要是为了避免二义性。假如定义一个“引用的数组”,那么array[0]=8;这条语句该如何理解?是将数组元素array[0]本身的值变成8呢,还是将array[0]所引用的对象的值变成8呢?
  • 作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引⽤的实质是传地址,传递的是变量的地址。
  • 在C++中,指针和引用经常用于函数的参数传递,然而,指针传递参数和引用传递参数是有本质上的不同的:指针传递参数本质上是值传递的方式,它所传递的是一个地址值。值传递的特点是被调函数对形式参数的任何操作都是作为局部变量进行,不会影响主调函数的实参变量的值。而在引用传递过程中, 被调函数的形参虽然也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址(指针放的是实参变量地址的副本)。

指针它指向⼀块内存,指针的内容是所指向的内存的地址,在编译的时候,则是将“指针变量名-指针变量的地址”添加到符号表中,所以说,指针包含的内容是可以改变的,允许拷⻉和赋值,有 const 和⾮ const 区别,甚⾄可以为空,sizeof 指针得到的是指针类型的⼤⼩。

⽽对于引⽤来说,它只是⼀块内存的别名,在添加到符号表的时候,是将"引⽤变量名-引⽤对象的地址"添加到符号表中,符号表⼀经完成不能改变,所以引⽤必须⽽且只能在定义时被绑定到⼀块内存上,后续不能更改,也不能为空,也没有 const 和⾮ const 区别。

指向常量的指针和常量指针

指向常量的指针表明不能通过解除引用运算符去改变其值,指向的变量是常量

常量指针表明初始化后的指针指向的地址是不能改变的,但这块地址上的存储的值可以改变,地址跟随一生。所以p2= &a是错误的,而*p2 = a 是正确的。,所以引用和常量指针类似

* 前修饰指针指向的内容,* 后修饰指针本身

const int p     //p为常量,初始化后不能更改

// 
const int *p	//*p为常量,不能通过*p改变其内容
int const *p	//同上
int *const p	//常量指针

空指针、悬空指针、野指针、void指针?

空指针: 用NULL或nullptr初始化,不指向任何对象;
悬空指针: 指针所指的内存被释放,而指针没有置空;
野指针: 就是没有被初始化过的指针;
void指针: 一种特殊类型的指针,可以存放任意对象的地址,任何类型的指针可以直接赋值给void指针,反之不行,void指针必须进行强制类型转换才能赋值给其他指针。

arr和&arr[0]和&arr的不同

首先如果打印的话,三个打印的完全一样,都是数组首元素的地址。

首先&arr应该是整个元素的地址,但是打印出来却是首元素的地址,不同之处在于对地址做加法运算后有不同,如下:

int arr[10]={0};
printf("%p\n",arr);//首元素的地址
printf("%p\n",arr+1);

printf("%p\n",&arr[0]);//首元素的地址
printf("%p\n",&arr[0]+1);

printf("%p\n",&arr);//整个数组元素的地址
printf("%p\n",&arr+1); 

输出首元素地址都是相同的,arr+1和&arr[0]+1都是只移动了4个字节,但是&arr+1移动了40个字节

结论:&arr代表的是整个数组的地址,虽然它具体表现为首个元素的地址,但是在对其进行操作时,是以整个数组为单位的。

arr 本身是左值(但不可仅凭此表达式修改),指代数组对象。不过 arr 会在大多数场合隐式转换成右值表达式 &(arr[0]) ,为指针类型,指向 arr[0] 。&arr 是右值表达式,为指针类型,指向 arr 本身。简单来说就是 arr 本身不是地址而是指代整个数组,只不过会隐式转成指针罢了。

char a,char a[],char *a,char *[],char * *a 之间的区别

  1. char a
    定义了一个存储空间,存储的是char类型的变量

  2. char a[]
    是一个字符数组,数组中的每一个元素是一个char类型的数据

  3. char *a
    字符串的本质(在计算机眼中)是其第一个字符的地址,c和c++中操作字符串是通过内存中其存储的首地址来完成的

    对于char a[]来说a代表的是数组的首地址,那么对char *a来说a代表的也是字符串的首地址

    因此char a[]和char *a可以放到一块看,这两个没有本质区别。

    但是要注意对于char s[]和char* a我们可以有a=s,但不能有s=a,因为创建数组的时候s的地址不为空已经确定,但是a是一个空指针,不能将非空的地址指向空指针

  4. char *a[]
    *的优先级是低于[]的,因此要先看a[]再看 *

    因此这是一个char数组,数组中的每一个元素都是指针,这些指针指向char类型

    char *a[ ] = {"China","French","America","German"}

  5. char **a
    两个**代表相同的优先级,因此从右往左看,即char*(*a)

    char *a不就是一个字符串数组,a代表首地址。那么char* (*a)就是和char*a[]一样的数据结构

一维数组名和二维数组名的区别

不管是一维还是多维数组,都是内存中一块线性连续空间,因此在内存级别上,其实都只是一维。

所以一维数组名是指向该数组的指针,二维数组名也是指向该数组的指针,但是+1之后,跳过的是一行。

问:二维数组名为什么不能直接赋值给二级指针?

答:一句话来说就是二维数组名是行指针,也就是指向数组的指针。而二级指针是指向指针的指针,它们不是同一类型。

定义一维数组 int a[i] 和二维数组int bi,a相当于int (*),而b相当于int (*)[j]。想要获得 a[i] 中第 x 个元素,可以直接使用 *(a+x)。而想要获得 b[i][j] 中第 x 行第 y 个元素,则需用 *(*(b+x)+y),因为 b 相当于数组指针,(b+x) 则是指向第 x 个数组的指针,注意,是指向数组,而不是数组元素!所以 *(b+x) 获得的是第 x 个数组的数组名,即该数组的首元素地址,这时再结合偏移量 y 就可以取得该元素。

数组指针和指针数组

其实就是数组的指针和指针的数组

数组的指针:指向一个数组的指针就是数组指针

指针的数组:一个数组的每一个元素都是指针

C++内存布局/程序分段

C++的内存分为:栈区、堆区、全局区/静态存储区、常量区、代码区

栈区:存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放;
堆区:动态申请的内存空间,由程序员分配和释放,若程序结束后还没有释放,操作系统会自动回收;
全局区/静态存储区:存放全局变量和静态变量,程序运行结束后系统自动回收;
常量区:存放常量,不允许修改,系统自动回收;
代码区:不允许修改,编译后的二进制文件存放在这里。

编译器进⾏管理,在需要时由编译器⾃动分配空间,在不需要时候⾃动回收空间,⼀般保存的是局部变量和函数参数等。 连续的内存空间,在函数调⽤的时候,⾸先⼊栈的主函数的下⼀条可执⾏指令的地址,然后是函数的各个参数。

⼤多数编译器中,参数是从右向左⼊栈(原因在于采⽤这种顺序,是为了让程序员在使⽤C/C++的“函数参数⻓度可变”这个特性时更⽅便。如果是从左向右压栈,第⼀个参数(即描述可变参数表各变量类型的那个参数)将被放在栈底,由于可变参的函数第⼀步就需要解析可变参数表的各参数类型,即第⼀步就需要得到上述参数,因此,将它放在栈底是很不⽅便的。)本次函数调⽤结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的地址,程序由该点继续运⾏,不会产⽣碎⽚。 栈是⾼地址向低地址扩展,栈低⾼地址,空间较⼩。

由程序员管理,需要⼿动 new malloc delete free 进⾏分配和回收,如果不进⾏回收的话,会造成内存泄漏的问题。 不连续的空间,实际上系统中有⼀个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第⼀个⼤于等于申请 ⼤⼩的空间分配给程序,⼀般在分配程序的时候,也会空间头部写⼊内存⼤⼩,⽅便 delete 回收空间⼤⼩。当然如果有剩余的,也会将剩余的插⼊到空闲链表中,这也是产⽣内存碎⽚的原因。 堆是低地址向⾼地址扩展,空间交⼤,较为灵活。

栈帧

函数栈帧的创建与销毁的整个过程就是我们定义、调用一个函数,并对其传参,最终得到返回值以及对函数空间的释放的整个过程。
C++面经_第7张图片
调用函数时:

  1. 形参入栈
  2. 保护当前ebp,将ebp地址入栈,ebp指向esp
  3. 开辟空间,esp下移
  4. 运算后将结果写入寄存器
  5. 销毁空间,esp指回ebp,
  6. 取出原ebp地址,ebp返回该地址
  7. 销毁形参,esp上移

https://blog.csdn.net/Zero__two_/article/details/120781099?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522167661754516800184181437%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=167661754516800184181437&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2alltop_positive~default-1-120781099-null-null.142v73insert_down2,201v4add_ask,239v2insert_chatgpt&utm_term=%E6%A0%88%E5%B8%A7&spm=1018.2226.3001.4187

谈一谈new/delete和malloc/free的区别和联系(c++管理内存的方式)

都是用来申请动态内存和释放动态内存的

  • 概念上的区别
    malloc/free是C++/C语言的标准库函数,而new/delete是C++的运算符。new调用operator new()申请内存(底层一般用malloc实现),然后调用构造函数,初始化成员变量,最后返回类型指针;而malloc则只分配内存,不会进行初始化类成员的工作。

  • 返回类型的安全性
    new操作符内存分配成功时候,返回的是对象类型的指针,不需要进行类型转换,从这个角度来说比较安全

    malloc内存分配成功则返回void*类型(泛型指针),必须通过强制类型转换将void*转换成需要的类型。

  • 分配失败后返回值也不同

    malloc失败,会返回空指针。

    new失败,默认是抛出异常,要捕获异常bad_alloc

  • 是否需要指定内存大小

    new操作符申请内存的时候不需要指定内存块的大小,编译器会自动根据类型信息来计算

    malloc需要显示的指出内存的大小

  • 后续内存的分配
    当malloc分配内存后,发现后续内存分配不足的时候,可以使用realloc函数进行内存重载实现内存的扩充。realloc会先判断当前指针所指向的内存是否有足够的连续空间,如果有则可以原地扩大内存地址,并返回原来地址空间指针。如果空间不够,就从新分配一个空间,将原来的数据拷贝到新分配的内存区域,释放原来的内存区域。

    new没有这样的配套设施

被free回收的内存是立即返还给操作系统吗?

这个需要看源码。

被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片

有了malloc/free为什么还要new/delete?

对于一些非内部数据类型(eg:类对象)来说,光用mal|oc/free无法满足要求。对象在创建的同时要自动执行构造函数,对象在消亡的时候要自动执行析构函数,而由于malloc/free是库函数而不是运算符,不在编译器的控制权限内(库函数是编译好的代码由链接器链接到为我们的代码中),也就不能自动执行构造函数和析构函数。所以,在c++中需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理和释放内存工作的运算符delete。

既然new/delete 的功能完全覆盖了malloc/free,为什么C++不把malloc/free 淘汰出局呢?这是因为C++程序经常要调用C 函数,而C 程序只能用malloc/free 管理动态内存。

new delete,new[] delete[]一定要配对使用吗?为什么?

首先可以明确一点:配套使用是肯定没有错的。

  • new和delete
    new和delete主要是为了为那些自定义类型的对象开辟空间,因为这些对象在创建的时候要自动执行构造函数,消亡的时候要执行析构函数,对于自定义类型对象如果不配对使用的话,可能会出现没有析构干净的情况

  • new[]和delete[]
    我们再用new[]创建数组的时候,比如一个对象大小为N,则K个数组需要K*N个空间来构造。那当delete的时候如何知道这个数组空间的大小呢?我们会在new出来的这个空间的头部申请一个int类型的字节,4字节用来存储数组的长度,这样调用delete[]的时候就知道数组的大小,才会调用K次析构函数,释放K * N + 4字节大小的内存。

new了长度为n的数组,元素类型是自定义的类,如果使用delete,就仅仅析构了首元素的,后面的没析构

函数传递参数的⼏种⽅式

值传递:形参是实参的拷⻉,函数内部对形参的操作并不会影响到外部的实参。

指针传递:也是值传递的⼀种⽅式,形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进⾏操作。

引⽤传递:实际上就是把引⽤对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上⾯。

左值和右值

需要关注左值,纯右值和将亡值这三种即可。

从本质上理解,右值的创建和销毁由编译器幕后控制,程序员只能确保在本行代码有效的,就是右值(包括立即数);而用户创建的,通过作用域规则可知其生存期的,就是左值(包括函数返回的局部变量的引用以及const对象)。

左值

存储在内存中、有明确存储地址(可寻址)的数据,可以取地址、位于赋值符号左边的值,就记住,左值是表达式结束(不一定是赋值表达式)后依然存在的对象。左值也是一个关联了名称的内存位置,允许程序的其他部分来访问它的值。

有以下特征:

  1. 可通过取地址运算符获取其地址

  2. 可修改的左值可用来赋值

  3. 可以用来初始化左值引用
    那些是左值?

  4. 变量名、函数名以及数据成员名

  5. 返回左值引用的函数调用

  6. 由赋值运算符或复合赋值运算符连接的表达式,如(a=b, a-=b等)

  7. 解引用表达式*ptr

  8. 前置自增和自减表达式(++a, ++b)

  9. 成员访问(点)运算符的结果

  10. 由指针访问成员( -> )运算符的结果

  11. 下标运算符的结果([])

  12. 字符串字面值(“abc”),这个比较特殊

右值

指那些可以提供数据值的表达式(不一定可以寻址,例如存储于寄存器中的数据)。右值有可能在内存中也有可能在寄存器中。一般来说就是活不过一行就会消失的值

  1. 字面值(字符串字面值除外),例如1,‘a’, true等
  2. 返回值为非引用的函数调用或操作符重载,例如:str.substr(1, 2), str1 + str2, or it++
  3. 后置自增和自减表达式(a++, a–)
  4. 算术表达式(x + y;)
  5. 逻辑表达式
  6. 比较表达式
  7. 取地址表达式
  8. lambda表达式auto f = []{return 5;};

右值又有纯右值和将亡值的说法:

  1. 纯右值:非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等。

  2. 将亡值:是与右值引用相关的表达式,比如,将要被移动的对象、T&&函数返回值、std::move返回值和转换为T&&的类型的转换函数的返回值等。即将被销毁、却能够被移动的值

左值引用和右值引用

三、右值引用与左值引用的区别

(1)左值引用绑定到有确定存储空间以及变量名的对象上,表达式结束后对象依然存在;右值引用绑定到要求转换的表达式、字面常量、返回右值的表达式等临时对象上,赋值表达式结束后就对象就会被销毁。
(2)左值引用后可以利用别名修改左值对象;右值引用绑定的值不能修改。
四、引入右值引用的原因

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

左值引用

左值引用分为:左值引用和常量左值引用(不希望被修改)

非常量左值引用只能绑定到非常量左值上
常量左值引用可以绑定到非常量左值、常量左值、非常量右值、常量右值等所有的值类型。

int a=10;              //非常量左值(有确定存储地址,也有变量名)
const int a1=10;       //常量左值(有确定存储地址,也有变量名)
const int a2=20;       //常量左值(有确定存储地址,也有变量名)
 
//非常量左值引用
int &b1=a;            //正确,a是一个非常量左值,可以被非常量左值引用绑定
int &b2=a1;           //错误,a1是一个常量左值,不可以被非常量左值引用绑定
int &b3=10;           //错误,10是一个非常量右值,不可以被非常量左值引用绑定
int &b4=a1+a2;        //错误,(a1+a2)是一个常量右值,不可以被非常量左值引用绑定

//常量左值引用
const int &c1=a;      //正确,a是一个非常量左值,可以被非常量右值引用绑定
const int &c2=a1;     //正确,a1是一个常量左值,可以被非常量右值引用绑定
const int &c3=a+a1;   //正确,(a+a1)是一个非常量右值,可以被常量右值引用绑定
const int &c4=a1+a2;  //正确,(a1+a2)是一个常量右值,可以被非常量右值引用绑定

右值引用

贴一个超级详细的连接
右值引用,就是绑定到右值的引用,通过&&来获得右值引用。

右值引用是C++11中新增加的一个很重要的特性,他主是要用来解决C++98/03中遇到的两个问题,
第一个问题就是临时对象非必要的昂贵的拷贝操作
第二个问题是在模板函数中如何按照参数的实际类型进行转发

特点:

延长生命周期

A GetA()
{
    return A();
}
A a = GetA();
A&& a = GetA();

通过右值引用,比之前少了一次拷贝构造和一次析构,原因在于右值引用绑定了右值,让临时右值的生命周期延长了。我们可以利用这个特点做一些性能优化,即避免临时对象的拷贝构造和析构。

const A& a = GetA();也可以实现同样的效果,但是A& a = GetA();会报错,因为非常量左值引用只能接受非常量左值,而且我们也得不到临时变量的地址,没法绑定。

移动构造函数

右值引用的一个重要作用是用来支持移动语义的

class A
{
public:
    A() :m_ptr(new int(0)){std::cout << "construct\n";}
    A(const A& a):m_ptr(new int(*a.m_ptr)) //深拷贝的拷贝构造函数
    {
        cout << "copy construct" << endl;
    }
    A(A&& a) :m_ptr(a.m_ptr)  //浅拷贝的移动构造函数
    {
        a.m_ptr = nullptr;
        cout << "move construct" << endl;
    }
    ~A(){ delete m_ptr; std::cout << "disconstruct\n";}
private:
    int* m_ptr;
};

A GetA()
{
    return A();
}

int main(){
    A a = GetA(); 
} 
编译时-fno-elide-constructors
输出:
construct
move construct
move construct

这个构造函数并没有做深拷贝,仅仅是将指针的所有者转移到了另外一个对象,同时,将参数对象a的指针置为空,这里仅仅是做了浅拷贝,因此,这个构造函数避免了临时变量的深拷贝问题(指针被释放两次)。

为什么会匹配到这个构造函数?因为这个构造函数只能接受右值参数,而函数返回值是右值,所以就会匹配到这个构造函数。这里的A&&可以看作是临时值的标识,对于临时值我们仅仅需要做浅拷贝即可,无需再做深拷贝,从而解决了前面提到的临时变量拷贝构造产生的性能损失的问题

需要注意的一个细节是,我们提供移动构造函数的同时也会提供一个拷贝构造函数,以防止移动不成功的时候还能拷贝构造,使我们的代码更安全。

普通的左值是否也能借助移动语义来优化性能呢

移动语义是通过右值引用来匹配临时值的,那么,普通的左值是否也能借助移动语义来优化性能呢,那该怎么做呢?事实上C++11为了解决这个问题,提供了std::move方法来将左值转换为右值,从而方便应用移动语义。move是将对象资源的所有权从一个对象转移到另一个对象,只是转移,没有内存的拷贝,这就是所谓的move语义。

如果是一些基本类型比如int和char[10]定长数组等类型,使用move的话仍然会发生拷贝(因为没有对应的移动构造函数)。所以,move对于含资源(堆内存或句柄)的对象来说更有意义。

右值引用类型的变量可能是左值也可能是右值

int&& var1 = 1; var1类型为右值引用,但var1本身是左值,因为具名变量都是左值。

template<typename T>
void f(T&& t){}

f(10); //t是右值

int x = 10;
f(x); //t是左值

T&& t在发生自动类型推断的时候,它是未定的引用类型(universal references),如果被一个左值初始化,它就是一个左值;如果它被一个右值初始化,它就是一个右值,它是左值还是右值取决于它的初始化。

但是Test&& rhs(Test是一个确定的类)并没有发生类型推导,因为Test&&是确定的类型了。所以rhs是Test&&右值引用。

完美转发

void reference(int& v) {
std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
std::cout << "右值引用" << std::endl;
}

template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(v);
std::cout << "std::move 传参:";
reference(std::move(v));
std::cout << "std::forward 传参:";
reference(std::forward<T>(v));
}

int main() {
std::cout << "传递右值:" << std::endl;
pass(1);
std::cout << "传递左值:" << std::endl;
int v = 1;
pass(v); // pass(T&& v)是未定的引用类型,它是左值还是右值取决于它的初始化
return 0;
}
传递右值:
普通传参:左值引用
std::move 传参:右值引用
std::forward 传参:右值引用
传递左值:
普通传参:左值引用
std::move 传参:右值引用
std::forward 传参:左值引用

直接使用reference(v)时,无论传递左右值,都调用reference(int&) ,对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值;对于 pass(v)就是左值。

C++11引入的std::forward完美转发解决这个问题:在函数模板中,完全依照模板的参数的类型(即保持参数的左值、右值特征),将参数传递给函数模板中调用的另外一个函数。即按照参数的实际类型进行转发。

右值引用T&&是一个universal references,可以接受左值或者右值,正是这个特性让他适合作为一个参数的路由,然后再通过std::forward按照参数的实际类型去匹配对应的重载函数,最终实现完美转发。

我们可以结合完美转发和移动语义来实现一个泛型的工厂函数,这个工厂函数可以创建所有类型的对象。具体实现如下,这个工厂函数的参数是右值引用类型,内部使用std::forward按照参数的实际类型进行转发,如果参数的实际类型是右值,那么创建的时候会自动匹配移动构造,如果是左值则会匹配拷贝构造。

template<typename…  Args>
T* Instance(Args&&… args)
{
    return new T(std::forward<Args >(args));
}

深拷贝和浅拷贝

在未定义显示拷贝构造函数的情况下,系统会调用默认的拷贝函数——即浅拷贝,它能够完成成员的一一复制。

当数据成员中没有指针时,浅拷贝是可行的;

**但当数据成员中有指针时,会出问题。如果没有自定义拷贝构造函数,会调用默认拷贝构造函数,这样就会调用两次析构函数。**第一次析构函数delete了内存,第二次的就指针悬挂了。所以,此时,必须采用深拷贝。

深拷贝与浅拷贝的区别就在于深拷贝会在堆内存中另外申请空间来储存数据,从而也就解决了指针悬挂的问题。简而言之,当数据成员中有指针时,必须要用深拷贝。

内存泄漏?出现内存泄漏如何调试?

内存泄露一般指的是堆内存的泄露,即用户自己开辟的内存空间。应用程序使用malloc、realloc、new等函数从堆中分配到一块内存后,必须调用free或delete进行回收,否则这块内存不能继续被使用。内存泄漏会因为减少可用内存的数量从而降低计算机的性能。最终,在最糟糕的情况下,过多的可用内存被分配掉导致全部或部分设备停止正常工作,或者应用程序崩溃。内存泄漏可能不严重,甚至能够被常规的手段检测出来。在现代操作系统中,一个应用程序使用的常规内存在程序终止时被释放。这表示一个短暂运行的应用程序中的内存泄漏不会导致严重后果。在C++中出现内存泄露的主要原因就是程序猿在申请了内存后(malloc(), new),没有及时释放没用的内存空间,甚至消灭了指针导致该区域内存空间根本无法释放。

内存泄漏的原因

  1. malloc/new和delete/free没有匹配
  2. new[] 和 delete[]也没有匹配
  3. 没有将父类的析构函数定义为虚函数,当父类的指针指向子类对象时,delete该对象不会调用子类的析构函数

检测手段

  1. 良好的程序设计能力,把new和delete全部都封装到构造函数和析构函数中,保证任何资源的释放都在析构函数中进行
  2. 智能指针(万一被问道,这也是一个问题)
  3. valgrind ,这个可以打印出发生内存泄露的部分代码
  4. linux使用swap命令观察还有多少可以用的交换空间,两分钟内执行三四次,肉眼看看交换区是不是变小了
  5. 使用/usr/bin/stat工具如netstat、vmstat等。如果发现波段有内存被分配且没有释放,有可能进程出现了内存泄漏。

RAII基本理解与使用

将资源或者状态与对象的生命周期绑定,通过C++的语言机制,实现资源和状态的安全管理

基本概念: RAII是c++中的一个惯用法,即“Resource Acquisition Is Initialization”,翻译为“资源获取就初始化”。是一种资源管理技术。c++之父说这种技术是依赖于类中构造函数和析构函数的性质以及与异常处理的交互性质来管理资源。

提出原因: 首先要明确在计算机系统中,资源的数量是有限的,一定要合理管理和使用有限的资源。比如内存,文件,套接字等等吧。因此在使用资源的时候要遵循三个步骤:

  1. 获取资源
  2. 使用资源
  3. 释放资源
    正常情况下没什么问题,大家都非常规规矩矩的这样做。但是我们都知道程序员很懒,一般不喜欢干重复性很高的工作,因此有两种情况下会出现一些问题。第一种比如说对文件操作,考虑一种极端情况,各种if判断后都要释放资源,也就是说释放资源的语句要写很多次,这样是非常麻烦的。第二种情况就是在使用资源的过程中程序会抛出异常,我们必须用catch来捕获所有异常然后关闭文件,当控制流程特别复杂的时候就很烦,代码很臃肿。而且有时候释放资源的语句就不会被执行。那么有些资源就是放不掉,因此Bjarne Stroustrup就想到不管啥情况都能释放资源的就是析构函数,因为stack unwinding(栈展开)导致析构函数能被执行。因此将资源的初始化和释放都放到一个类中就能解决上面遇到的问题。

stack winding:When program run, each function(data, registers, program counter, etc) is mapped onto the stack as it is called. Because the function calls other functions, they too are mapped onto the stack. This is stack winding.

stack unwinding:Unwinding is the removal of the functions from the stack in the reverse order.(展开是以相反的顺序从堆栈中删除函数)。在c++中,系统必须确保调用所有创建起来的局部对象的析构函数。

总结: 在构造函数中申请分配资源,在析构函数中释放资源。因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。RAII的核心思想是将资源或者状态与对象的生命周期绑定,通过C++的语言机制,实现资源和状态的安全管理,智能指针是RAII最好的例子

RALL是c++区别于其他所有编程语言的重要特性。最初学习c++时的教条是new和delete一定要配套使用,但是使用RALL思想后,改成每一个资源配置动作都应该在单一语句执行,获得资源后立刻交给对象去管理,一般不要出现delete,用智能指针。

c++智能指针

https://aijishu.com/a/1060000000286819

智能指针是一个RAIIResource Acquisition is initialization)类模型,用来动态的分配内存。
把指针用类封装然后实例化成对象,在对象过期的时候,让析构函数删除指向的内存
它提供所有普通指针提供的接口,却很少发生异常。在构造中,它分配内存,当离开作用域时,它会自动释放已分配的内存。这样的话,程序员就从手动管理动态内存的繁杂任务中解放出来了。

指针类别 支持 备注
unique_ptr C++ 11 拥有独有对象所有权语义的智能指针
shared_ptr C++ 11 拥有共享对象所有权语义的智能指针
weak_ptr C++ 11 到 std::shared_ptr 所管理对象的弱引用
auto_ptr C++ 17中移除 拥有严格对象所有权语义的智能指针

为什么要用智能指针?

原因1:内存泄露,即new和delete不匹配

原因2:多线程下对象析构问题,造成这个问题本质的原因是类对象自己销毁(析构)的时候无法对自己加锁,所以要独立出来,采用这个中间层(shared_ptr).

unique_ptr

unique_ptr的核心特点就如它的名字一样,它拥有对持有对象的唯一所有权。即两个unique_ptr不能同时指向同一个对象。

那具体这个唯一所有权如何体现呢?

  1. unique_ptr不能被复制到另外一个unique_ptr
  2. unique_ptr所持有的对象只能通过转移语义将所有权转移到另外一个unique_ptr
std::unique_ptr a1(new A());
std::unique_ptr a2 = a1;//编译报错,不允许复制
std::unique_ptr a3 = std::move(a1);//可以转移所有权,所有权转义后a1不再拥有任何指针

unique_ptr 本身拥有的方法主要包括:

  1. get() 获取其保存的原生指针,尽量不要使用
  2. bool() 判断是否拥有指针
  3. release() 释放所管理指针的所有权,返回原生指针。但并不销毁原生指针。
  4. reset() 释放并销毁原生指针。如果参数为一个新指针,将管理这个新指针
std::unique_ptr a1(new A());
A *origin_a = a1.get();//尽量不要暴露原生指针
std::unique_ptr a2(a1.release());//常见用法,转义拥有权
a2.reset(new A());//释放并销毁原有对象,持有一个新对象
a2.reset();//释放并销毁原有对象,等同于下面的写法
a2 = nullptr;//释放并销毁原有对象
#include 
#include 

using namespace std;
class demo{
public:
    demo():num(new int(6)){
        cout<<"construct!"<<endl;
    }
    demo(const demo &d):num(new int(*d.num)){
        cout<<"copy construct!"<<endl;
    }
    demo& operator=(const demo &d){
    	*(this->num) = *d.num;
    	cout<<"copy ="<<endl;
    	return *this;
    }
    //添加移动构造函数
    demo(demo &&d):num(d.num){
        d.num = NULL;
        cout<<"move construct!"<<endl;
    }
    ~demo(){
    	delete num;
        cout<<"class destruct!"<<endl;
    }
    int *num;
};

int main(){
    //demo* a = new demo(); //沒有delete,內存洩漏
    //cout << *(a->num) <<"\n";
    
    //demo* a = new demo();
    //cout << *(a->num) <<"\n";
    //delete a;
    
    unique_ptr<demo> a(new demo()); //自動delete
    cout << *(a->num) <<"\n";
    
    return 0;
}
//g++ -g p.cpp -o p -fno-elide-constructors
///valgrind --tool=memcheck --leak-check=full ./p
total heap usage: 4 allocs, 4 frees (二者相等,說明沒洩漏)

shared_ptr

概述

共享所有权,也就是说多个指针可以指向一个相同的对象,当最后一个shared_ptr离开作用域的时候才会释放掉内存。

实现原理:在shared_ptr内部有一个共享引用计数器来自动管理,计数器实际上就是指向该资源指针的个数,每当复制一个 shared_ptr,引用计数会 + 1。当一个 shared_ptr 离开作用域时,引用计数会 - 1,当引用计数为 0 的时候,则delete 内存。这样相比auto来说就好很多,当计数器为0的时候指针才会彻底释放掉这个资源。

线程安全问题?

参考,有时间总结一下

Boost 文档对于 shared_ptr 的线程安全有一段专门的记述,内容如下:

shared_ptr objects offer the same level of thread safety as built-in types.
A shared_ptr instance can be “read” (accessed using only const operations) simultaneously by multiple threads. 一个 shared_ptr 实例可以同时被多个线程“读”(仅使用不变操作进行访问)
Different shared_ptr instances can be “written to” (accessed using mutable operations such as operator= or reset) simultaneosly by multiple threads (even when these instances are copies, and share the same reference count underneath.)Any other simultaneous accesses result in undefined behavior.不同的 shared_ptr 实例可以同时被多个线程“写入”(使用类似 operator= 或 reset 这样的可变操作进行访问)(即使这些实例是拷贝,而且共享下层的引用计数)。 任何其它的同时访问的结果会导致未定义行为。”

总结:
1、同一个shared_ptr被多个线程“读”是安全的。
2、同一个shared_ptr被多个线程“写”是不安全的。
3、共享引用计数的不同的shared_ptr被多个线程”写“ 是安全的。

所以说我们可以借助shared_ptr实现线程安全的对象释放,但是shared_ptr本身不是100%线程安全的,不考虑其管理对象的安全级别

shared_ptr 可能的线程安全隐患大概有如下几种,一是引用计数的加减操作是否线程安全,二是shared_ptr修改指向时,是否线程安全。

  1. shared_ptr 的引用计数是原子操作的,所以引用计数的加减是线程安全的
  2. shared_ptr修改指针指向的时候会不安全。 同一个shared_ptr被多个线程“读”是安全的。同一个shared_ptr被多个线程“写”是不安全的(多个线程操作同一个shared_ptr对象)。如下面的代码:
   void fn(shared_ptr& sp) {
       ...
       if (..) {
           sp = other_sp;
       } else if (...) {
           sp = other_sp2;
       }
   }

当你在多线程回调中修改shared_ptr指向的时候。shared_ptr内数据指针要修改指向,sp原先指向的引用计数的值要减去1,other_sp指向的引用计数值要加1。然而这几步操作加起来并不是一个原子操作,如果多少线程都在修改sp的指向的时候,那么有可能会出问题。比如在导致计数在操作减一的时候,其内部的指向,已经被其他线程修改过了。引用计数的异常会导致某个管理的对象被提前析构,后续在使用到该数据的时候触发core dump。当然如果你没有修改指向的时候,是没有问题的。

测试:在多个线程中同时对一个shared_ptr循环执行两遍swap。

shared_ptr的swap函数的作用就是和另外一个shared_ptr交换引用对象和引用计数,是写操作。执行两遍swap之后, shared_ptr引用的对象的值应该不变。

   #include 
   #include 
   #include 
   
   using std::tr1::shared_ptr;
   
   shared_ptr gp(new int(2000));
   
   //多线程操作不同的shared_ptr对象,安全
   //该函数拷贝了一个p1,用p1进行操作
   shared_ptr  CostaSwapSharedPtr1(shared_ptr & p)
   {
       shared_ptr p1(p);
       shared_ptr p2(new int(1000));
       p1.swap(p2);
       p2.swap(p1);
       return p1;
   }
   
   //多线程操作指向同一个shared_ptr对象,不安全
   //直接对全局变量gp进行操作
   shared_ptr  CostaSwapSharedPtr2(shared_ptr & p)
   {
       shared_ptr p2(new int(1000));
       p.swap(p2);
       p2.swap(p);
       return p;
   }
   
   //线程执行函数
   void* thread_start(void * arg)
   {
       int i =0;
       for(;i<100000;i++)
       {
           shared_ptr p= CostaSwapSharedPtr2(gp);
           if(*p!=2000)
           {
               printf("Thread error. *gp=%d \n", *gp);
               break;
           }
       }
       printf("Thread quit \n");
       return 0;
   }
   
   int main()
   {
       pthread_t thread;
       int thread_num = 10, i=0;
       pthread_t* threads = new pthread_t[thread_num];
       for(;i

解决方案之一就是加锁。所以甭管安全不安全,加锁就完事儿了。

所管理数据的线程安全性

我们上面说的是针对shared_ptr本身的线程安全问题。但是用shared_ptr管理对象的线程安全问题又是另一会儿事。

如果shared_ptr管理的数据是STL容器,那么多线程如果存在同时修改的情况,是极有可能触发core dump的。比如多个线程中对同一个vector进行push_back,或者对同一个map进行了insert。甚至是对STL容器中并发的做clear操作,都有可能出发core dump,当然这里的线程不安全性,其实是其所指向数据的类型的线程不安全导致的,并非是shared_ptr本身的线程安全性导致的。尽管如此,由于shared_ptr使用上的特殊性,所以我们有时也要将其纳入到shared_ptr相关的线程安全问题的讨论范围内。

拥有的一些方法

  1. reset方法
    reset() 释放并销毁原生指针。如果参数为一个新指针,将管理这个新指针

    ”当智能指针调用了reset函数的时候,就不会再指向这个对象了,所以如果还有其它智能指针指向这个对象,那么其他的智能指针的引用计数会减1

    cppreference参考

  2. make_shared方法
    返回一个指定类型的 std::shared_ptr,和shared_ptr的构造函数一样都是用来初始化一个智能指针对象的。但是效率上有所不同:

make_shared执行一次堆分配,而shared_ptr构造函数执行两次
读过源码的应该都知道,stared_ptr里面维护了两个部分,或者叫两个控制块:

  • 引用计数相关控制块,添加删除等等

  • 被管理的对象,原生指针
    如果使用new即自身构造函数来分配内存的话,就会对于上面两部分执行heap-allocation,即两次堆分配,如下图:

    但是如果使用make_shared的话,只用执行一次heap_allocation,如下图:

    其次使用make_shared还是异常安全的

    在c++17之后就不是问题了,因为函数的求职顺序发生了变化,函数的每个参数都需要在计算其他参数之前完全执行

    比如下面代码:

    //潜在的资源泄露 
   processWidget(std::shared_ptr(new Widget),computePriority());

在运行期,函数的参数必须在函数被调用前被估值,所以在调用processWidget时,下面的事情肯定发生在processWidget能开始执行之前:

1、表达式new Widget必须被估值即一个Widget必须被创建在堆上。2、std::shared_ptr(负责管理由new创建的指针)的构造函数必须被执行。

3、computePriority必须跑完。

编译器不需要必须产生这样顺序的代码。 new Widget 必须在std::shared_ptr的构造函数被调用前执行,因为new的结构被用为构造函数的参数,但是computePriority可能在这两个调用前(后,或很奇怪地,中间)被执行。也就是,编译器可能产生出这样顺序的代码:

   执行“new Widget”。
   执行computePriority。
   执行std::shared_ptr的构造函数。

如果computePriority产生了一个异常,则在第一步动态分配的Widget就会泄露了,因为它永远不会被存放到在第三步才开始管理它的std::shared_ptr中。

使用std::make_shared可以避免这样的问题。

  1. swap方法
    swap 交换两个 shared_ptr 对象(即交换所拥有的对象)

  2. shared_from_this
    我们往往会需要在类内部使用自身的 shared_ptr,如下代码:

   class Widget
   {
   public:
       void do_something(A& a)
       {
           a.widget = 该对象的 shared_ptr;
       }
   }

上述代码是说我们将当前对象的sp交由对象a管理,那就意味着当前对象的生命周期的结束不能早于对象 a。因为对象 a 在析构之前还是有可能会使用到 a.widget。如果我们直接 a.widget = this;那肯定不行, 因为这样并没有增加当前 shared_ptr 的引用计数。shared_ptr 还是有可能早于对象 a 释放。如果我们使用 a.widget = std::make_shared(this);,肯定也不行,因为这个新创建的 shared_ptr,跟当前对象的 shared_ptr 毫无关系。当前对象的 shared_ptr 生命周期结束后,依然会释放掉当前内存,那么之后 a.widget 依然是不合法的。对于这种,需要在对象内部获取该对象自身的 shared_ptr, 那么该类必须继承 std::enable_shared_from_this

总结: 智能指针的优势在于一旦某个对象不再被引用,系统会立刻回收内存,通常发生在关键任务完成后的清理时期。同时,内存中所有的对象都是有用的,绝对没有垃圾占内存的现象出现。

weak_ptr

weak_ptr 比较特殊,它主要是为了配合shared_ptr而存在的。就像它的名字一样,它本身是一个弱指针,因为它本身是不能直接调用原生指针的方法的。如果想要使用原生指针的方法,需要将其先转换为一个shared_ptr。那weak_ptr存在的意义到底是什么呢?

weak指针的出现是为了解决shared指针循环引用造成的内存泄漏的问题。由于shared_ptr是通过引用计数来管理原生指针的,那么最大的问题就是循环引用(比如 a 对象持有 b 对象,b 对象持有 a 对象),这样必然会导致内存泄露(无法删除)。而weak_ptr不会增加引用计数,因此将循环引用的一方修改为弱引用,可以避免内存泄露。

如下述代码:

struct A{
    shared_ptr b;
};
struct B{
    shared_ptr a;
};
shared_ptr pa = make_shared();
shared_ptr pb = make_shared();
pa->b = pb;
pb->a = pa;

pa 和 pb 存在着循环引用,根据 shared_ptr 引用计数的原理,pa 和 pb 都无法被正常的释放,因为我们需要对方先释放。对于这种情况, 我们可以使weak_ptr:

struct A{
    shared_ptr b;
};
struct B{
    weak_ptr a;
};
shared_ptr pa = make_shared();
shared_ptr pb = make_shared();
pa->b = pb;
pb->a = pa;

weak_ptr 不会增加引用计数,因此可以打破 shared_ptr 的循环引用。

当创建一个shared指针对象时候,该指针所指向的资源数为1,当用shared对象指针创建一个weak对象时候,资源计数器没有变化。weak_ptr的构造和析构并不会改变引用计数的大小,同时由于weak_ptr没有重载运算符*,->,因此他不操作资源,只是观测

方法

  1. expired() 判断所指向的原生指针是否被释放,如果被释放了返回 true,否则返回 false
  2. use_count() 返回原生指针的引用计数
  3. lock() 返回 shared_ptr,如果原生指针没有被释放,则返回一个非空的 shared_ptr,否则返回一个空的 shared_ptr
  4. reset() 将本身置空

有以下问题:

  1. 要是用weak指针对象如何判断该指针指向的对象是否销毁?
    答:weak_ptr类中有一个成员函数lock(),这个函数可以返回一个指向共享对象的shared_ptr,如果weak指针所指向的资源不存在,那么lock函数返回一个空shared指针,通过这个可以判断

  2. weak_ptr类中没有重载operator *和operator->,因此不能使用weak_ptr类对象直接访问指针所指向的资源,因此如果想要访问weak_ptr指向的资源的时候,必须首先使用lock成员函数获取到该weak_ptr所指向资源的shared_ptr的对象,然后再去访问。这样做也为了避免我们在写程序时,忘记考虑weak_ptr所指向的资源被释放的情况。

弱指针的使用有两个:第一当 parent 类持有 child 的 shared_ptr, child 持有指向 parent 的 weak_ptr。第二是定义对象时,用强智能指针shared_ptr,在其它地方引用对象时,使用弱智能指针weak_ptr。

模板元编程与泛型

元编程侧重点在于「用代码生成代码」,泛型编程侧重点在于「减小代码对特定数据类型的依赖」。这两件事 C++ 的模板都可以干。

https://zhuanlan.zhihu.com/p/101898043

泛型编程是一种编程风格。提高了程序的复用性,提高效率。用一种广泛的表达去取代具体数据类型,这在c++中就叫做模板编程。

c++模板主要分成两大类:函数模板和类模板。

模板的格式是template