c++语言常见问题
目录
c++内存分区模型
程序运行前:
代码区:存放函数体的二进制代码,由操作系统进行管理,特点(共享,可读)
全局区:存放全局变量和静态变量以及常量,该区域的数据在程序结束后由操作系统释放
程序运行后:
栈区:由编译器自动分配释放,存放函数的参数值,局部变量等
堆区:由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收
const和static
- static修饰局部变量
通常局部变量在程序中存放栈区,局部的生命周期在包含语句块执行完时便会结束,使用 static 关键字对局部变量修饰后,变量会存放静态数据区,其生命周期会延续到整个程序结束。
但是作用域没有改变 - static修饰全局变量
static 对全局变量修饰改变作用域范围,由普通全局变量的整个工程可见,变为本文件可见。 - const关键字
变量加上const关键字,成为了常量,常量的值不能被更改
注意:静态变量和常量都属于全局区,但是我们要尽量使用静态变量,因为静态变量修饰了作用域范围为当前文件,避免使用全局变量使耦合性变高
静态局部变量的构造和析构
而对于局部静态变量,程序首次执行到局部静态变量的定义处时才发出构造,析构相反,这样就存在一些问题:
- 一方面是因为程序的实际执行路径有多个决定因素(例如基于消息驱动模型的程序和多线程程序),有时是不可预知的;
- 另一方面是因为局部静态变量分布在程序代码各处,彼此直接没有明显的关联,很容易让开发者忽略它们之间的这种关系(这是最坑的地方)。
菱形继承
产生原因是c++的多继承,在实际开发中应该尽量避免多继承
是什么:c++多继承导致的问题,两个派生类继承同一个基类,又有某个类同时继承者两个派生类,产生数据的二义性
虚继承:使用虚继承,在间接继承共同基类时只保留一份基类成员。具体说引入虚基表,如果通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到了A。
继承的上移下移左移和右移
- 上移:上移操作是指将子类中的成员(属性或方法)移动到父类中。这样做的好处是可以减少代码重复,提高代码的复用性。例如,如果多个子类中有相同的方法实现,可以将这个方法上移到父类中。
- 下移:下移操作是指将父类中的成员移动到子类中。这通常发生在父类中的某些成员只对特定的子类有用时。
- 左移:指的是将成员从一个子类移动到另一个子类,或者从父类移动到子类。这实际上是上移和下移的组合。
虚函数
在实现c++多态时会用到虚函数。虚函数使用的目的是通过父类访问子类定义的函数。所谓虚函数就是在父类定义一个未实现的函数名,相当于函数的入口。
当父类指针指向子类对象的时候,父类指针可以调用父类的方法,也可以调用子类的方法,具体调用什么就是看是否是虚函数,是否在虚函数表中。 如果父类的方法在子类中被重写,那么调用的是子类方法,如果没有被重写,子类继承父类方法。
虚函数表
每个包含了虚函数的类都包含一个虚表。当我们用父类的指针来操作一个子类的时候,这张虚函数表就像一张地图一样指明了实际所应该调用的函数。
虚函数表(vtable)的表项在编译期已经确定,也就是一组常量函数指针。跟代码一样,在程序编译好的时候就保存在可执行文件里面。
虚函数表是针对类的,一个类的所有对象的虚函数表都一样。
虚函数表指针
虚表指针的存储的位置与对象存储的位置相同,可能在栈、也可能在堆或数据段等。
虚函数调用关系
总体来说虚函数的调用关系是:this指针->vptr->vtable ->virtual虚函数
在有虚函数的类实例中,this指针调用vptr指针,vptr找到vtable(虚函数列表),通过虚函数列表找到需要调用的虚函数的地址
编译器如何处理虚函数表
对于派生类来说,编译器简历虚表的过程有三步:
- 拷贝基类的虚函数表,如果是多继承,就拷贝每个基类的虚函数表
- 查看派生类中是否有重写基类的虚函数,如果有,就替换成已经重写后的虚函数地址
- 查看派生类中是否有新添加的虚函数,如果有,就加入到自身的虚函数表中
动态绑定
为什么调用虚函数的时候是动态绑定?区别于普通函数调用的静态绑定,函数的调用在编译阶段就可以确定下来了。而虚函数的调用需要在运行中查虚函数表确定。
纯虚函数
纯虚函数是指在基类中声明但没有实现的虚函数。定义纯虚函数是为了实现一个接口,起到一个规范的作用,当类中有了纯虚函数,这个类也称为抽象类。抽象类特点:无法实例化对象,子类必须重写抽象类中的纯虚函数,否则也属于抽象类。
virtual void Examp() = 0;//纯虚函数
构造函数可以是虚函数吗
不能。因为虚函数的调用是需要通过“虚函数表”来进行的,而虚函数表也需要在对象实例化之后才能够进行调用。在构造对象的过程中,还没有为“虚函数表”分配内存。所以,这个调用也是违背先实例化后调用的准则。
还有什么函数不能是虚函数
-
友元函数:不是虚函数,因为友元函数不是类成员,只有类成员才能是虚函数。 友元函数不是类的成员函数,但是可以访问类的私有成员和保护成员。
友元函数可以声明在类的内部或外部,但是即使声明在类的内部,也不是类的成员函数。 -
静态成员函数:不能是虚,因为静态成员函数虽然是类的成员对象,但是没有this指针
虚析构
父类的析构函数必须设置成虚析构
当父类指针指向子类对象时 ,如果父类的析构函数不声明成虚析构函数,当删除父类指针时,只调动父类的析构函数,而不调动子类的析构函数,造成内存泄漏。
当父类的析构函数声明成虚析构函数的时候,父类的指针指向子类时,删除父类的指针,先调动子类的析构函数,再调动父类的析构函数。
c++中this指针
this
指针是一个隐含于每一个非静态成员函数中的特殊指针。它指向调用该非静态成员函数的那个对象。- 当对一个对象调用成员函数时,编译程序先将对象的地址赋给
this
指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用this
指针。 this
并不是一个常规变量,而是个右值,所以不能取得this
的地址(不能&this
)。
C++程序编译过程
- 编译预处理:将存储在不同文件中的程序模块集成为一个完整的源程序代码,并将宏展开为原始语句加入到头文件中。
- 编译:将源码.cpp文件翻译成.s汇编代码
- 汇编:将汇编代码翻译成机器指令.o文件
- 链接:将引用的其他文件,包括外部库函数文件关联起来,生成可执行文件
c++静态链接和动态链接
库是什么
- 库是写好的现有的,成熟的,可以复用的代码。
- 现实中每个程序都要依赖很多基础的底层库
静态链接(静态库)
在程序的链接阶段,将上一阶段生成的test.o文件与库函数合并生成可执行文件。
这样做好处就是以后的代码和库函数无关了,可以随便移植。
坏处是
①静态链接的文件体积太大。
②如果库函数更新的话需要重新链接。
③在内存中会存在多分拷贝,因为每个程序都会存在一份库函数
动态链接(动态库)
动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可,增量更新。
动态编译和静态编译
静态编译,编译器在编译可执行文件时,把需要用到的对应链接库中的部分提出来,连接到可执行文件中,使可执行文件在运行时不需要依赖于动态链接库。
动态编译,可执行文件需要附带一个动态链接库,在执行的时候,需要调用其对应动态链接库的命令。
动态联编和静态联编
如果要实现静态联编,就必须在编译阶段(运行前)确定程序中的操作调用与执行该操作代码的关系。
动态联编指的是程序运行的时候动态的进行。实际上是虚函数的实现过程,又叫做晚期联编。动态联编对成员函数的选择是基于对象类型的,针对不同对象类型做出不同编译结果。
c++一般情况下的联编是静态联编,当涉及到虚函数的时候就会使用动态联编。
左值和右值
左值:左值是用户创建的,通过作用域规则可知其生存期的,就是左值(包括函数返回的局部变量的引用以及const对象)。
左值存储在内存中、有明确存储地址(可寻址)的数据,关联了名称的内存位置,允许程序的其他部分来访问它的值。
左值包括:变量名、函数名以及数据成员名等
右值:右值的创建和销毁由编译器幕后控制,程序员只能确保在本行代码有效的,就是右值(包括立即数);
右值包括:各种表达式
深拷贝与浅拷贝
当数据成员中有指针时,必须要用深拷贝。
如果没有自定义拷贝构造函数,会调用默认拷贝构造函数(默认浅拷贝),这样就会调用两次析构函数。第一次析构函数delete了内存,第二次的就指针悬挂了。
野指针和指针悬挂
野指针(wild pointer)指的是未经初始化的指针,因为这个指针由于没有初始化,可能会指向任何内存空间,完全随机的,有两种情况:
- 指向的地址是系统使用的内存,用户程序不能使用,如果用户程序使用则会报错
- 指向的不是系统的内存,不报错。但是如果这个指针指向了你之前程序使用过的内存,则你在个指针赋值,就会修改之前的内存上的数据,也会出问题。
悬挂指针(dangling pointer)它曾经指向一个内存地址,但该内存地址已经被释放或重新分配给其他用途,而指针变量本身没有被更新或设置为NULL
RALL
RAII是c++中的一个惯用法,即“Resource Acquisition Is Initialization”,翻译为“资源获取就初始化”。在构造函数中申请分配资源,在析构函数中释放资源。因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。RAII的核心思想是将资源或者状态与对象的生命周期绑定,通过C++的语言机制,实现资源和状态的安全管理,智能指针是RAII最好的例子。
智能指针
智能指针本质上是一个对象,里面封装了普通指针,就是RALL技术
智能指针就可以方便我们控制指针对象的生命周期。在智能指针中,一个对象什么情况下被析构或被删除,是由指针本身决定的,并不需要用户进行手动管理
unique_ptr独享指针
unique_ptr没有复制构造函数,不支持普通的拷贝和赋值操作
unique最常见的使用场景,就是替代原始指针,为动态申请的资源提供异常安全保证。
只要unique_ptr创建成功,unique_ptr对应的析构函数都能保证被调用,从而保证申请的动态资源能被释放掉。
shared_ptr共享指针
当对象的所有权需要共享(share)时,share_ptr可以进行赋值拷贝
每一个shared_ptr的拷贝都指向相同的内存
每使用他一次,内存的引用计数加1,每析构一次,内部的引用计数减1,减为0时,删除所指向的堆内存。
weak_ptr
weak_ptr
比较特殊,它主要是为了配合shared_ptr
而存在的。就像它的名字一样,它本身是一个弱指针,因为它本身是不能直接调用原生指针的方法的。如果想要使用原生指针的方法,需要将其先转换为一个shared_ptr
。那weak_ptr
存在的意义到底是什么呢?
weak指针的出现是为了解决shared指针循环引用造成的内存泄漏的问题。由于shared_ptr
是通过引用计数来管理原生指针的,那么最大的问题就是循环引用(因为它们都在互相等待对方先释放,所以造成内存泄漏。),这样必然会导致内存泄露(无法删除)。而weak_ptr
不会增加引用计数,因此将循环引用的一方修改为弱引用,可以避免内存泄露。
vector扩容原理
当空间不够装下数据(vec.push_back(val))时,会自动申请另一片更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间。
当释放或者删除(vec.clear())里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。所有的内存空间是在vector析构时候才能被系统回收。
如果想手动缩小空间,可以用swap.
迭代器与指针的区别
迭代器实际上是对“遍历容器”这一操作进行了封装。迭代器不是指针,是类模板。重载了指针的一些操作符
STL容器是线程安全的吗
STL容器不是线程安全的
对于vector,即使写方(生产者)是单线程写入,但是并发读的时候,由于潜在的内存重新申请和对象复制问题,会导致读方(消费者)的迭代器失效
解决方法:
1.线程加锁
2.固定vector的大小,避免动态扩容,v.resize(1000);
c++头文件重复包含和多重定义问题
在编写头文件时,始终应该使用 #ifndef, #define, #endif 指令来防止多重包含。
- #ifndef MY_HEADER_FILE_H 检查 MY_HEADER_FILE_H 是否被定义。
- 如果没有定义,则执行以下代码,并定义 MY_HEADER_FILE_H。
- #endif // MY_HEADER_FILE_H 标记条件编译块的结束。
c++中结构体和类的区别
- 默认访问权限不同 : 结构体中的成员变量和成员函数默认为公有(public),可以在任意地方被访问。类中的成员变量和成员函数默认为私有(private),只能在类的内部被访问。
- 类型限定: 结构体中的成员变量可以是任意类型,包括内置类型和用户自定义类型。类中的成员变量可以是内置类型、用户自定义类型或者是指向其他对象的指针。
- 类可以实现多态性,而结构体不能。
STL容器的底层结构
- vector数组
底层是数组,支持随机访问,插入和删除在数组尾部的时间复杂度为O(1),但在数组中间插入或删除则需要移动大量元素,时间复杂度为O(n)。 - deque队列
底层是数组,插入和删除在两端的时间复杂度为O(1),在中间则为O(n)。 - list
底层使用双向链表,支持在头部和尾部快速插入和删除,但随机访问效率较低,时间复杂度为O(n)。 - set 和 map
底层通常使用红黑树(Red-Black Tree)实现,提供了高效的查找、插入和删除操作,时间复杂度为O(log n)。 - unordered_set 和 unordered_map
底层使用哈希表(Hash Table),支持常数时间的查找、插入和删除(理想情况下),但在哈希冲突较多时,性能会下降。