C++11新特性
C++ 11有哪些新特性?
- nullptr替代 NULL
- 引入了 auto 和 decltype 这两个关键字实现了类型推导
- 基于范围的 for 循环for(auto& i : res){}
- 类和结构体的中初始化列表
- Lambda 表达式(匿名函数)
- std::forward_list(单向链表)
- 右值引用和move语义
auto、decltype和decltype(auto)的用法
- C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。auto 让编译器通过初始值来进行类型推演。从而获得定义变量的类型,所以说 auto 定义的变量必须有初始值。
- Decltype: C++11又引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。编译器只分析表达式并得到它的类型,却不计算表达式的值。
- decltype(auto):是C++14新增的类型指示符,可以用来声明变量以及指示函数返回类型。在使用时,会将“=”号右边的表达式替换掉auto,再根据decltype的语法规则来确定类型。举个例子:
int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e
C++中NULL和nullptr区别
- NULL来自C语言,一般由宏定义实现,而 nullptr 则是C++11的新增关键字。
- 在C语言中,NULL被定义为(void*)0,而在C++语言中,NULL则被定义为整数0。
- 在C++中指针必须有明确的类型定义。但是将NULL定义为0带来的另一个问题是无法与整数的0区分。
- nullptr在C++11被引入用于解决这一问题,nullptr可以明确区分整型和指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误。
智能指针的原理、常用的智能指针及实现
智能指针的作用:
- C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。
- 智能指针在C++11版本之后提供,包含在头文件<memory>中,shared_ptr、unique_ptr、weak_ptr。shared_ptr多个指针指向相同的对象。
原理:
- 智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏。
- 动态分配的资源,交给一个类对象去管理,当类对象声明周期结束时,自动调用析构函数释放资源
常用的智能指针:
shared_ptr
实现原理:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向对象时,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。
unique_ptr
实现原理:unique_ptr采用的是独享所有权语义,一个非空的unique_ptr总是拥有它所指向的资源。转移一个unique_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空;所以unique_ptr不支持普通的拷贝和赋值操作,不能用在STL标准容器中;局部变量的返回值除外(因为编译器知道要返回的对象将要被销毁);如果你拷贝一个unique_ptr,那么拷贝结束后,这两个unique_ptr都会指向相同的资源,造成在结束时对同一内存指针多次释放而导致程序崩溃。
weak_ptr
实现原理:weak_ptr弱引用。 引用计数有一个问题就是互相引用形成环,这样两个指针指向的内存都无法释放。需要使用weak_ptr打破环形引用。weak_ptr是一个弱引用,它是为了配合shared_ptr而引入的一种智能指针,它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数。如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有 weak_ptr引用该内存,内存也会被释放。所以weak_ptr不保证它指向的内存一定是有效的,在使用之前使用函数lock()检查weak_ptr是否为空指针。
- auto_ptr
实现原理:
主要是为了解决“有异常抛出时发生内存泄漏”的问题 。因为发生异常而无法正常释放内存。auto_ptr有拷贝语义,拷贝后源对象变得无效,这可能引发很严重的问题;而unique_ptr则无拷贝语义,但提供了移动语义,这样的错误不再可能发生,因为很明显必须使用std::move()进行转移。
auto_ptr不支持拷贝和赋值操作,不能用在STL标准容器中。STL容器中的元素经常要支持拷贝、赋值操作,在这过程中auto_ptr会传递所有权,所以不能在STL中使用。
- 说说你了解的auto_ptr作用
- auto_ptr的出现,主要是为了解决“有异常抛出时发生内存泄漏”的问题;抛出异常,将导致指针p所指向的空间得不到释放而导致内存泄漏;
- auto_ptr构造时取得某个对象的控制权,在析构时释放该对象。我们实际上是创建一个auto_ptr<Type>类型的局部对象,该局部对象析构时,会将自身所拥有的指针空间释放,所以不会有内存泄漏;
- auto_ptr的构造函数是explicit,阻止了一般指针隐式转换为 auto_ptr的构造,所以不能直接将一般类型的指针赋值给auto_ptr类型的对象,必须用auto_ptr的构造函数创建对象;
- 由于auto_ptr对象析构时会删除它所拥有的指针,所以使用时避免多个auto_ptr对象管理同一个指针;
- Auto_ptr内部实现,析构函数中删除对象用的是delete而不是delete[],所以auto_ptr不能管理数组;
- auto_ptr支持所拥有的指针类型之间的隐式类型转换。可以通过*和->运算符对auto_ptr所有用的指针进行提领操作;
- T* get(),获得auto_ptr所拥有的指针;T* release(),释放auto_ptr的所有权,并将所有用的指针返回。
- 智能指针的循环引用
定义:循环引用是指使用多个智能指针share_ptr时,出现了指针之间相互指向,从而形成环的情况,有点类似于死锁的情况,这种情况下,智能指针往往不能正常调用对象的析构函数,从而造成内存泄漏。
- 智能指针出现循环引用怎么解决?
弱指针用于专门解决shared_ptr循环引用的问题,weak_ptr不会修改引用计数,即其存在与否并不影响对象的引用计数器。循环引用就是:两个对象互相使用一个shared_ptr成员变量指向对方。弱引用并不对对象的内存进行管理,在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存。
- 手写实现智能指针类需要实现哪些函数?
一个构造函数、拷贝构造函数、复制构造函数、析构函数、移动函数;
- 智能指针是一个数据类型,一般用模板实现,模拟指针行为的同时还提供自动垃圾回收机制。它会自动记录SmartPointer<T*>对象的引用计数,一旦T类型对象的引用计数为0,就释放该对象。
- 除了指针对象外,我们还需要一个引用计数的指针设定对象的值,并将引用计数计为1,需要一个构造函数。新增对象还需要一个构造函数,析构函数负责引用计数减少和释放内存。
- 通过覆写赋值运算符,才能将一个旧的智能指针赋值给另一个指针,同时旧的引用计数减1,新的引用计数加1
- 关于lambda函数的全部知识
lambda表达式的语法定义如下:
[capture] (parameters) mutable ->return-type {statement};
- 利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象;
- 每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类重载了()运算符)被称为闭包类型(closure type)。在运行时,这个lambda表达式就会返回一个匿名的闭包实例。lambda表达式的结果就是一个个闭包。闭包的一个好处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量
- lambda必须使用尾置返回来指定返回类型,可以忽略参数列表和返回值,但必须永远包含捕获列表和函数体;
C++内存管理
- 类的对象存储空间?
- 非静态成员的数据类型大小之和。
- 编译器加入的额外成员变量(如指向虚函数表的指针)。
- 为了边缘对齐优化加入的padding。
Note:空类(无非静态数据成员)的对象的size为1, 当作为基类时, size为0
- 简要说明C++的内存分区/可以说一下你了解的C++得内存管理吗?
- C++中的内存分区,分别是堆、栈、自由存储区、全局/静态存储区、常量存储区和代码区
栈:在执行函数时,函数内局部变量的存储单元在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是内存容量有限
堆:由 new分配的内存块,内存释放由我们的应用程序去控制,一个new要对应一个 delete。如程序员没有释放,在程序结束后,操作系统会自动回收
自由存储区:那么自由存储区就是C++中通过new和delete动态分配和释放对象的抽象概念。需要注意的是,自由存储区和堆比较像,但不等价。(可不说)
全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在该区定义的变量若没有初始化,则会被自动初始化,例如int型变量自动初始为0
常量存储区:存放的是常量,不允许修改
代码区:存放函数体的二进制代码
- 如何用代码判断大小端存储?
大端存储:字数据的高字节存储在低地址中
小端存储:字数据的低字节存储在低地址中
- 什么是内存池,如何实现
- 直接使用new、malloc 等申请内存的缺点在于:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。
- 内存池是一种内存分配方式:是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块, 若内存块不够再继续申请新的内存。这样做的一个显著优点是尽量避免了内存碎片,使得内存分配效率得到提升。
STL详解见阿秀;
- C++中类的数据成员和成员函数内存分布情况?
- C++类是由结构体发展得来的,所以他们的成员变量(C语言的结构体只有成员变量)的内存分配机制是一样的。
- 一个类对象的地址就是类所包含的这一片内存空间的首地址,这个首地址也就对应具体某一个成员变量的地址。
- 成员函数不占用对象的内存。这是因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。(静态成员函数也存放在代码区)
- 关于this指针你知道什么?全说出来
- this指针是类的指针,指向对象的首地址。
- this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用。
- this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置。
- this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高。
this指针的用处:
一个对象的this指针并不是对象本身的一部分,不会影响 sizeof(对象) 的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行
this指针的使用:
- 在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this;
- 当形参数与成员变量名相同时用于区分;
- This指针的一些问题总结:
#A. this指针是什么时候创建的?
this在成员函数的开始执行前构造,在成员的执行结束后清除。
#B. this指针存放在何处?堆、栈、全局变量,还是其他?
this指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。
#C. this指针是如何传递类中的函数的?绑定?还是在函数参数的首参数就是this指针?那么,this指针又是如何找到“类实例后函数的”?
大多数编译器通过ecx(寄数寄存器)寄存器传递this指针。一般来说,不同编译器都会遵从一致的传参规则,否则不同编译器产生的obj就无法匹配了。
在call之前,编译器会把对应的对象地址放到eax中。this是通过函数参数的首参来传递的。this指针在调用之前生成,至于“类实例后函数”,没有这个说法。类在实例化时,只分配类中的变量空间,并没有为函数分配空间。自从类的函数定义完成后,它就在那儿,不会跑的。
#E.我们只有获得一个对象后,才能通过对象使用this指针。如果我们知道一个对象this指针的位置,可以直接使用吗?
this指针只有在成员函数中才有定义。因此,获得一个对象后,也不能通过对象使用this指针。所以,无法知道一个对象的this指针的位置。在成员函数里,是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。
#F.每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?
普通的类函数都不会创建一个函数表来保存函数指针。只有虚函数才会被放到函数表中。但是,即使是虚函数,如果编译期就能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。正是由于this指针的存在,用来指向不同的对象,从而确保不同对象之间调用相同的函数可以互不干扰。
- 内存泄漏的后果?如何监测?解决方法?
1) 内存泄漏
内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。
2) 后果
泄漏大量内存的程序将会出现性能下降到内存逐渐用完,导致另一个程序失败;
3) 如何排除
使用工具软件BoundsChecker,BoundsChecker是一个运行时错误检测工具,它主要定位程序运行时期发生的各种错误;
调试运行DEBUG版程序,运用以下技术:CRT(C run-time libraries)、运行时函数调用堆栈、内存泄漏时提示的内存分配序号(集成开发环境OUTPUT窗口),综合分析内存泄漏的原因,排除内存泄漏。
4) 解决方法
智能指针。
5) 检查、定位内存泄漏
检查方法:
在main函数最后面一行,加上一句_CrtDumpMemoryLeaks()。调试程序,自然关闭程序让其退出,查看输出:
输出这样的格式{453}normal block at 0x02432CA8,868 bytes long
被{}包围的453就是我们需要的内存泄漏定位值,868 bytes long就是说这个地方有868比特内存没有释放。
定位代码位置:
在main函数第一行加上_CrtSetBreakAlloc(453);意思就是在申请453这块内存的位置中断。然后调试程序,程序中断了,查看调用堆栈。加上头文件#include <crtdbg.h>
- 在成员函数中调用delete this会出现什么问题?对象还可以使用吗?
- 在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。
- 当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。
- 为什么是不可预期的问题?
- delete this之后不是释放了类对象的内存空间了么,那么这段内存应该已经还给系统,不再属于这个进程。照这个逻辑来看,应该发生指针错误,无访问权限之类的令系统崩溃的问题才对啊?这个问题牵涉到操作系统的内存管理策略。
- delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃
- 如果在类的析构函数中调用delete this,会发生什么?
会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。
- 你知道空类的大小是多少吗?
- C++空类的大小不为0,不同编译器设置不一样,vs设置为1;
- C++标准指出,不允许一个对象(当然包括类对象)的大小为0,不同的对象不能具有相同的地址;
- 带有虚函数的C++类大小不为1,因为每一个对象会有一个vptr指向虚函数表,具体大小根据指针大小确定;
- C++中要求对于类的每个实例都必须有独一无二的地址,那么编译器自动为空类分配一个字节大小,这样便保证了每个实例均有独一无二的内存地址。
- this指针调用成员变量时,堆栈会发生什么变化?
- 当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。
- 即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this的。
- 例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。
- 类对象的大小受哪些因素影响?
- 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小;
- 内存对齐另外分配的空间大小,类内的数据也是需要进行内存对齐操作的;
- 虚函数的话,会在类对象插入vptr指针,加上指针大小;
- 当该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在在派生类中的空间中,也会对派生类进行扩展。
C++基础语法
其他语言篇
- C++和Python的区别
包括但不限于:
- Python是一种脚本语言,是解释执行的,而C++是编译语言,是需要编译后在特定平台运行的。python可以很方便的跨平台,但是效率没有C++高。
- Python使用缩进来区分不同的代码块,C++使用花括号来区分
- C++中需要事先定义变量的类型,而Python不需要,Python的基本数据类型只有数字,布尔值,字符串,列表,元组等等
- Python的库函数比C++的多,调用起来很方便
- C++与Java的区别
语言特性:
- Java有更为简洁的语法,完全面向对象,JVM可以安装到任何的操作系统上,可移植性强
- Java语言中没有指针的概念,不同于C++中利用指针实现的“伪数组”,Java引入了真正的数组。有利于防止在C++程序中常见的因为数组操作越界等指针操作而对系统数据进行非法读写带来的不安全问题。
- C++也可以在其他系统运行,但是需要不同的编码(例如对一个数字,在windows下是大端存储,在unix中则为小端存储),Java程序一般都是生成字节码,在JVM里面运行得到结果。
- Java用接口(Interface)技术取代C++程序中的抽象类。接口与抽象类有同样的功能,但是省却了在实现和维护上的复杂性
垃圾回收:
- C++用析构函数回收垃圾,写C和C++程序时一定要注意内存的申请和释放
- Java语言不使用指针,内存的分配和回收都是自动进行的,程序员无须考虑内存碎片的问题
应用场景:
- Java在桌面程序上不如C++实用,C++可以直接编译成exe文件,指针是c++的优势,可以直接对内存的操作,但同时具有危险性。
- Java在Web 应用上具有C++ 无可比拟的优势,具有丰富多样的框架对于底层程序的编程以及控制方面的编程。
- 为什么C++没有垃圾回收机制?这点跟Java不太一样。
- 首先,实现一个垃圾回收器会带来额外的空间和时间开销。你需要开辟一定的空间保存指针的引用计数和对他们进行标记mark。
- 然后需要单独开辟一个线程在空闲的时候进行free操作。
- 垃圾回收会使得C++不适合进行很多底层的操作。
- C++和C语言的区别
- C++中new和delete是对内存分配的运算符,取代了C中的malloc和free。
- 标准C++中的字符串类取代了标准C函数库头文件中的字符数组处理函数
- C++中用来做控制态输入输出的iostream类库替代了标准C中的stdio函数库。
- C++中的try/catch/throw异常处理机制取代了标准C中的setjmp()和longjmp()函数。
- 也就是C++可以重载,C语言不允许。
- C++语言中,允许变量定义语句在程序中的任何地方,只要在是使用它之前就可以;而C语言中,必须要在函数开头部分。而且C++不允许重复定义变量,
- 在C++中,除了值和指针之外,新增了引用。
- C++相对与C增加了一些关键字,如:bool、using、dynamic_cast、namespace等等
- extern"C"的用法
为了能够正确的在C++代码中调用C语言的代码:在程序中加上extern "C"后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++;
使用extern "C"的情况:
- C++代码中调用C语言代码;
- 在C++中的头文件中使用;
- 在多个人协同开发时,可能有人擅长C语言,而有人擅长C++;
举个例子,C++中调用C代码:
#ifndef __MY_HANDLE_H__
#define __MY_HANDLE_H__
extern "C"{
typedef unsigned int result_t;
typedef void* my_handle_t;
my_handle_t create_handle(const char* name);
result_t operate_on_handle(my_handle_t handle);
void close_handle(my_handle_t handle);
}
- 什么是类型安全?
- 类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。
- “类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。
- 类型安全的编程语言与类型安全的程序之间,没有必然联系。
- C++的类型安全
相比于C语言,C++提供了一些新的机制保障类型安全:
- 操作符new返回的指针类型严格与对象匹配,而不是void*
- C中很多以void*为参数的函数可以改写为C++模板函数,而模板是支持类型检查的;
- 引入const关键字代替#define constants,它是有类型、有作用域的,而#define constants只是简单的文本替换
- 一些#define宏可被改写为inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全
- C++提供了dynamic_cast关键字,使得转换过程更加安全,因为dynamic_cast比static_cast涉及更多具体的类型检查。
- 想保证程序的类型安全性,应尽量避免使用空类型指针void*,尽量不对两种类型指针做强制转换。
- C++中标准库是什么?
C++ 标准库可以分为两部分:
标准函数库: 这个库是由通用的、独立的、不属于任何类的函数组成的。函数库继承自 C 语言。
面向对象类库: 这个库是类及其相关函数的集合。
- 输入/输出 I/O、字符串和字符处理、数学、时间、日期和本地化、动态分配、其他、宽字符函数。
- 标准的 C++ I/O 类、String 类、数值类、STL 容器类、STL 算法、STL 函数对象、STL 迭代器、STL 分配器、本地化库、异常处理类、杂项支持库。
- 面向对象的三大特性是什么?
继承:让某种类型对象获得另一个类型对象的属性和方法。它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展
常见的三种继承:
- 实现继承:指使用基类的属性和方法而无需额外编码的能力
- 接口继承:指仅使用属性和方法的名称、但是子类必须提供实现的能力
- 可视继承:指子窗体(类)使用基窗体(类)的外观和实现代码的能力(C++里好像不怎么用)
封装:数据和代码捆绑在一起,避免外界干扰和不确定性访问。封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
多态:同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为(重载实现编译时多态,虚函数实现运行时多态)。
多态性是允许将子类类型的指针赋值给父类类型的指针
实现多态有二种方式:
- 覆盖:是指子类重新定义(重写)父类的虚函数的做法。
- 重载:是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。
- 写C++代码时有一类错误是coredump ,你遇到过吗?
coredump是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做core的文件,这个core文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。
- 你知道Debug和Release的区别是什么吗?
- 调试版本:包含调试信息,所以容量比Release大很多,并且不进行任何优化(优化会使调试复杂化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。Debug模式下生成两个文件,除了.exe或.dll文件外,还有一个.pdb文件,该文件记录了代码中断点等调试信息;
- 发布版本:不对源代码进行调试,编译时对应用程序的速度进行优化,使得程序在代码大小和运行速度上都是最优的。(调试信息可在单独的PDB文件中生成)。Release模式下生成一个文件.exe或.dll文件。
- 实际上,Debug 和 Release 并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照预定的选项行动。事实上,我们甚至可以修改这些选项,从而得到优化过的调试版本或是带跟踪语句的发布版本。
- 为什么模板类一般都是放在一个h文件中
- 模板定义很特殊。由template<…>处理的任何东西都意味着编译器在当时不为它分配存储空间,它一直处于等待状态直到被一个模板实例告知。在编译器和连接器的某一处,有一机制能去掉指定模板的多重定义。所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。
- 在分离式编译的环境下,编译器编译某一个.cpp文件时并不知道另一个.cpp文件的存在,也不会去查找(当遇到未决符号时它会寄希望于连接器)。这种模式在没有模板的情况下运行良好,但遇到模板时就傻眼了,因为模板仅在需要的时候才会实例化出来。所以,当编译器只看到模板的声明时,它不能实例化该模板,只能创建一个具有外部连接的符号并期待连接器能够将符号的地址决议出来。然而当实现该模板的.cpp文件中没有用到模板的实例时,编译器懒得去实例化,所以,整个工程的.obj中就找不到一行模板实例的二进制代码,于是连接器也黔驴技穷了
- 介绍一下几种典型的锁
- 读写锁
多个读者可以同时进行读
写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
- 互斥锁
一次只能一个线程拥有互斥锁,其他线程只有等待
互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。
互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁
- 条件变量
互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。
- 自旋锁
如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。
运行篇
- C++从代码到可执行程序经历了什么?
预编译:主要处理源代码文件中的以“#”开头的预编译指令。
处理规则见下:
- 删除所有的#define,展开所有的宏定义。
- 处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。
- 处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他 文件。
- 删除所有的注释,“//”和“/**/”。
- 保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重 复引用。
- 添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是 能够显示行号。
编译:把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应 的汇编代码文件。
- 词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分 割成一系列的记号。
- 语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的 语法树是一种以表达式为节点的树。
- 语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进 行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定 的语义。
- 优化:源代码级别的一个优化过程。
- 目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言 表示。
- 目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移 来替代乘法运算、删除多余的指令等。
汇编:将汇编代码转变成机器可以执行的指令(机器码文件)。汇编过程有汇编器as完成。经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Linux 下)、xxx.obj(Window下)。
链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。
链接分为静态链接和动态链接:
- 静态链接:函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库 中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个 目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西, 在执行的时候运行速度快。
- 动态链接:动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形 成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分,副 本,而是这多个程序在执行时共享同一份副本;
更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运 行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
- 动态编译与静态编译
- 静态编译:编译器在编译可执行文件时,把需要用到的对应动态链接库中的部分提取出来,连接到可执行文件中去,使可执行文件在运行时不需要依赖于动态链接库;
- 动态编译:的可执行文件需要附带一个动态链接库,在执行时,需要调用其对应动态链接库的命令。
动态优点:
1.是缩小了执行文件本身的体积,
2.是加快了编译速度,节省了系统资源。
动态缺点:
- 很简单的程序,只用到了链接库的一两条命令,也需要附带一个相对庞大的链库;
- 如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行
- 在main执行之前和之后执行的代码可能是什么?
main函数执行之前:主要就是初始化系统相关资源:
- 设置栈指针
- 初始化静态static变量和global全局变量
- 将未初始化部分的全局变量赋初值:数值型short,int,long等为0,bool为FALSE,指针为NULL等
- 全局对象初始化,将main函数的参数argc,argv等传递给main函数,然后才真正运行main函数
main函数执行之后:
全局对象的析构函数会在main函数之后执行;
可以用 atexit 注册一个函数,它会在main 之后执行;
- 程序在执行int main(int argc, char *argv[])时的内存结构,你了解吗?
- 参数的含义是程序在命令行下运行的时候,需要输入argc 个参数,每个参数是以char 类型输入的,依次存在数组里面,数组是 argv[],所有的参数在指针
- char * 指向的内存中,数组的中元素的个数为 argc 个,第一个参数为程序的名称。
- main函数的返回值有什么值得考究之处吗?
- 程序运行过程入口点main函数,main()函数返回值类型必须是int,这样返回值才能传递给程序激活者(如操作系统)表示程序正常退出。
- main(int args, char **argv) 参数的传递。参数的处理,一般会调用getopt()函数处理,但实践中,这仅仅是一部分,不会经常用到的技能点
- 结构体内存对齐问题?
- 结构体内成员按照声明顺序存储,第一个成员地址和整个结构体地址相同。
- 未特殊说明时,按结构体中size最大的成员对齐(若有double成员,按8字节对齐)
- alignof可以计算出类型的对齐方式,alignas可以指定结构体的对齐方式。
- C++中将临时变量作为返回值时的处理过程?
- 临时变量,在函数调用过程中是被压到程序进程的栈中的,当函数退出时,临时变量出栈,即临时变量已经被销毁,临时变量占用的内存空间没有被清空,但是可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了
- 函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中与临时变量的生命周期没有关系。
- 怎么快速定位错误出现的地方?
- 如果是简单的错误,可以直接双击错误列表里的错误项或者生成输出的错误信息中带行号的地方就可以让编辑窗口定位到错误的位置上。
- 对于复杂的模板错误,最好使用生成输出窗口。
多数情况下出发错误的位置是最靠后的引用位置。如果这样确定不了错误,就需要先把自己写的代码里的引用位置找出来,然后逐个分析了。
- 静态类型和动态类型,静态绑定和动态绑定的介绍
- 静态类型:对象在声明时采用的类型,在编译期既已确定;
- 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
- 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
- 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;
非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)
- 静态绑定和动态绑定的区别?
- 静态绑定发生在编译期,动态绑定发生在运行期;
- 对象的动态类型可以更改,但是静态类型无法更改;
- 要想实现动态,必须使用动态绑定;
- 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;
- 引用是否能实现动态绑定,为什么可以实现?
可以。
引用在创建的时候必须初始化,只有在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。
class Base
{
public:
virtual void fun() {cout << "base :: fun()" << endl;}
};
class Son : public Base
{
public:
virtual void fun() {cout << "son :: fun()" << endl;}
void func() {cout << "son :: not virtual function" <<endl;}
};
int main()
{
Son s;
Base& b = s; // 基类类型引用绑定已经存在的Son对象,引用必须初始化
s.fun(); //son::fun()
b.fun(); //son :: fun()
return 0;
}
- 隐式转换,如何消除隐式转换?
- C++的基本类型中并非完全的对立,部分数据类型之间是可以进行隐式转换的。所谓隐式转换,是指不需要用户干预,编译器私下进行的类型转换行为。很多时候用户可能都不知道进行了哪些转换
- C++面向对象的多态特性,就是通过父类的类型实现对子类的封装。通过隐式转换,你可以直接将一个子类的对象使用父类的类型进行返回。在比如,数值和布尔类型的转换,整数和浮点数的转换等。某些方面来说,隐式转换给C++程序开发者带来了不小的便捷。C++是一门强类型语言,类型的检查是非常严格的。
- 基本数据类型 基本数据类型的转换以取值范围的作为转换基础(保证精度不丢失)。隐式转换发生在从小->大的转换中。比如从char转换为int。从int->long。自定义对象 子类对象可以隐式的转换为父类对象。
- C++中提供了explicit关键字,在构造函数声明的时候加上explicit关键字,能够禁止隐式转换。
- 如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制。可以通过将构造函数声明为explicit加以制止隐式类型转换,关键字explicit只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为explicit。
- 为什么不能把所有的函数写成内联函数?
- 内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。
- 如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;
- 每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:
- 函数体内的代码比较长,将导致内存消耗代价
- 函数体内有循环,函数执行时间要比函数调用开销大
变量篇
- 变量声明和定义区别?
- 声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间;
- 定义要在定义的地方为其分配存储空间。
- 相同变量可以在多处声明(外部变量extern),但只能在一处定义
- 怎样判断两个浮点数是否相等?
- 对两个浮点数判断大小和是否相等不能直接用==来判断,会出错!
- 对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!浮点数与0的比较也应该注意。与浮点数的表示方式有关。
- C++中struct和class的区别?
相同点:
两者都拥有成员函数、公有和私有部分,任何可以使用class完成的工作,同样可以使用struct完成
不同点:
- 成员struct默认是公有的,class则默认是私有的
- class默认是private继承, 而struct默认是public继承
基本函数篇
- 形参与实参的区别?
- 形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元。因此,形参只有在函数内部有效。
- 实参可以是常量、变量、表达式、函数等,无论实参是何种类型的,在进行函数调用时,它们都必须具有确定的值,会产生一个临时变量。
- 实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。
- 函数调用中发生的数据传送是单向的。即只能把实参的值传送给形参。
- 当形参和实参不是指针类型时,函数运行时,形参和实参是不同的变量,他们在内存中位于不同的位置,形参将实参的内容复制一份,在该函数运行结束的时候形参被释放,而实参内容不会改变。
- 全局变量和局部变量有什么区别?
生命周期不同:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;
使用方式不同:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。
- C++中新增了string,它与C语言中的 char *有什么区别吗?它是如何实现的?
- string继承自basic_string,其实是对char*进行了封装,封装的string包含了char*数组,容量,长度等等属性。
- string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2*n),然后将原字符串拷贝过去,并加上新增的内容。
- strlen和sizeof区别?
- sizeof是运算符,结果在编译时得到而非运行中获得;strlen是字符处理的库函数
- sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是'\0'的字符串。
- sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小
- 你知道strcpy和memcpy的区别是什么吗?
- 复制的内容不同。strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。
- 复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。
- 用途不同。通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy
- final和override关键字
final:当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。
class Base { virtual void foo(); }; class A : public Base { void foo() final; // foo 被override并且是最后一个override,在其子类中不可以重写 }; class B final : A // 指明B是不可以被继承的 { void foo() override; // Error: 在A中已经被final了 }; class C : B // Error: B is final { }; |
例子如下:
override:当在父类中使用了虚函数时候,你可能需要在某个子类中对这个虚函数进行重写;
class A { virtual void foo(); } class B : public A { void foo(); //OK virtual void foo(); // OK void foo() override; //OK void f11()override;//错误 } |
如果不使用override,当将要重写的函数写错,编译器并不会报错,因为它并不知道你的目的是重写虚函数,而是把它当成了新的函数。override指定了子类的这个虚函数是重写的父类的,如果函数名称写错,编译器是不会编译通过的
- C++中的重载、重写(覆盖)和隐藏的区别
重载(overload):
重载是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否是虚函数无关。
重写(覆盖)(override):
重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数且:与基类的虚函数有相同的参数个数,与基类的虚函数有相同的参数类型,与基类的虚函数有相同的返回值类型。
重载与重写的区别:
- 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
- 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
- 重写关系中,调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体
- 你知道重载运算符吗?
- 引入运算符重载,是为了实现类的多态性;
- 我们只能重载已有的运算符,而无权发明新的运算符;对于一个重载的运算符,其优先级和结合律与内置类型一致才可以;不能改变运算符操作数个数;
- 两种重载方式:成员运算符和非成员运算符,成员运算符比非成员运算符少一个参数;下标运算符、箭头运算符必须是成员运算符;
- 当重载的运算符是成员函数时,this绑定到左侧运算符对象。成员运算符函数的参数数量比运算符对象的数量少一个;至少含有一个类类型的参数;
- 从参数的个数推断到底定义的是哪种运算符,当运算符既是一元运算符又是二元运算符(+,-,*,&);
- 下标运算符必须是成员函数,下标运算符通常以所访问元素的引用作为返回值,同时最好定义下标运算符的常量版本和非常量版本;
- 箭头运算符必须是类的成员,解引用通常也是类的成员;重载的箭头运算符必须返回类的指针;
- 当程序中有函数重载时,函数的匹配原则和顺序是什么?
- 名字查找
- 确定候选函数
- 寻找最佳匹配
- 什么是函数隐藏(hide)
隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包括以下情况:
- 两个函数参数相同,但是基类函数不是虚函数。隐藏和重写的区别在于基类函数是否是虚函数。
- 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。隐藏和重载的区别在于两个函数不在同一个类中。重载是在一个类中,隐藏是基类和派生类之间。
- 说一下你理解的 ifdef endif代表着什么?
条件编译:一般情况下,源程序中所有的行都参加编译。但是有时希望对其中一部分内容只在满足一定条件才进行编译,也就是对一部分内容指定编译的条件,这就是“条件编译”。
条件编译命令最常见的形式为:
#ifdef 标识符
程序段1
#else
程序段2
#endif
作用:
当标识符已经被定义过(一般是用#define命令定义),则对程序段1进行编译,否则编译程序段2。 其中#else部分也可以没有,即:
#ifdef
程序段1
#endif
在一个大的软件工程里面,可能会有多个文件同时包含一个头文件,当这些文件编译链接成一个可执行文件上时,就会出现大量“重定义”错误。在头文件中使用#define、#ifndef、#ifdef、#endif能避免头文件重定义。
- Volatile(易变的)、mutable(可变)和explicit(显式)关键字的用法
volatile:
- volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
- 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。
- volatile定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。多线程中被几个任务共享的变量需要定义为volatile类型。
volatile 指针
- 修饰由指针指向的对象、数据是 const 或 volatile 的:
const char* cpch;
volatile char* vpch;
- 指针自身的值——一个代表地址的整数变量,是 const 或 volatile 的:
char* const pchc;
char* volatile pchv;
多线程下的volatile
- 有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。
- 如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。
- volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
Note:
- 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。
- 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
- C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile向const一样会从类传递到它的成员
volatile用在如下的几个地方:
- 中断服务程序中修改的供其它程序检测的变量需要加volatile;
- 多任务环境下各任务间共享的标志应该加volatile;
- 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;
mutable:
例子: class person { int m_A; mutable int m_B;//特殊变量 在常函数里值也可以被修改 public: void add() const//在函数里不可修改this指针指向的值 常量指针 { m_A = 10;//错误 不可修改值,this已经被修饰为常量指针 m_B = 20;//正确 } }; int main() { const person p = person();//修饰常对象 不可修改类成员的值 p.m_A = 10;//错误,被修饰了指针常量 p.m_B = 200;//正确,特殊变量,修饰了mutable } |
mutable的中文意思是“可变的,易变的”,跟const是反义词。mutable也是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。但是,有些时候,我们需要在const函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable来修饰,并且放在函数后后面关键字位置。
explicit:
explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显式的方式进行类型转换,
Note:
- explicit 关键字只能用于类内部的构造函数声明上
- 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换
- C++函数调用的压栈过程
函数的调用过程:
1)从栈空间分配存储空间
2)从实参的存储空间复制值到形参栈空间
3)进行运算
解释:
- 形参在函数未调用之前都是没有分配存储空间的,在函数调用结束之后,形参弹出栈空间,清除形参空间。
- 数组作为参数的函数调用方式是地址传递,形参和实参都指向相同的内存空间,调用完成后,形参指针被销毁,但是所指向的内存空间依然存在,不能也不会被销毁。
- 当函数有多个返回值的时候,不能用普通的 return 的方式实现,需要通过传回地址的形式进行,即地址/指针传递。
- 函数调用过程栈的变化,返回值和参数变量哪个先入栈?
- 调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;
- 调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
- 在被调函数中,被调函数会先保存调用者函数的栈底地址(push ebp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov ebp,esp);
- 在被调函数中,从ebp的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈
- 类如何实现只能静态分配和只能动态分配
- 只能静态分配是把new、delete运算符重载为private属性。
- 只能动态分配是把构造、析构函数设为protected属性,再用子类来动态创建
建立类的对象有两种方式:
① 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;
② 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;
只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有。
- 友元函数和友元类的基本情况
友元定义:友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,一个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。
1)友元函数
有元函数是定义在类外的普通函数,不属于任何类,可以访问其他类的私有成员。但是需要在类的定义中声明所有可以访问它的友元函数。
一个函数可以是多个类的友元函数,但是每个类中都要声明这个函数。
2)友元类
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。
但是另一个类里面也要相应的进行声明
使用友元类时注意:
- 友元关系不能被继承。
- 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
- 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明
- 成员函数里memset(this,0,sizeof(*this))会发生什么
有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。
下面几种情形是不可以这么使用的:
- 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;
- 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存。
- 你知道回调函数吗?它的作用?
- 当发生某种事件时,系统或其他函数将会自动调用你定义的一段函数;
- 回调函数就相当于一个中断处理函数,由系统在符合你设定的条件时自动调用。为此,你需要做三件事:1,声明;2,定义;3,设置触发条件,就是在你的函数中把你的回调函数名称转化为地址作为一个参数,以便于系统调用;
- 回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数;
- 因为可以把调用者与被调用者分开。调用者不关心谁是被调用者,所有它需知道的,只是存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调用函数。
指针/引用篇
- 一个指针占多少字节?
- 在64位的编译环境下的,指针的占用大小为8字节;
- 在32位环境下,指针占用大小为4字节。
- 一个指针占内存的大小跟编译环境有关,而与机器的位数无关。
- 野指针和悬空指针
都是是指向无效内存区域的指针,访问行为将会导致未定义行为。
野指针:指的是没有被初始化过的指针,所以对于指针初始化时都是赋值为 nullptr,这样在使用时不会产生非法内存访问。
int main(void) {
int* p; // 未初始化
std::cout<< *p << std::endl; // 未初始化就被使用
return 0;
}
悬空指针:指针最初指向的内存已经被释放了的一种指针。若指向的内存已经被释放。继续使用这两个指针,行为不可预料。所以指针释放后要置空。
int main(void) {
int * p = nullptr; //p为空指针
int* p2 = new int; //p2指向一个int无名变量
p = p2;
delete p2; //释放p2之后,p和p2都为悬空指针;
}
解决方法:
野指针:指针变量未及时初始化 => 定义指针变量及时初始化,要么置空。
悬空指针:指针free或delete之后没有及时置空 => 释放操作后立即置空。
总结(避免野指针:指针初始化或置空,针对悬空指针,c++引入了智能指针。)
- 指针加减计算要注意什么?
- 指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址。
- 遇到指针的计算,需要明确的是指针每移动一位,它实际跨越的内存间隔是指针类型的长度。
- 常量指针和指针常量区别?
int const *p或const int *p: const * 可变向
int *const p: * const 不变向
- a和&a有什么区别?(不太明确)
假设数组int a[10]; int (*p)[10] = &a;其中:
- a是数组名,是数组首元素地址,+1表示地址值加上一个int类型的大小,
- &a是数组的指针,其类型为int (*)[10](就是前面提到的数组指针),其加1时,系统会认为是数组首地址加上整个数组的偏移(10个int型变量),值为数组a尾元素后一个元素的地址。
- 若(int *)p ,此时输出 *p时,其值为a[0]的值,因为被转为int *类型,解引用时按照int类型大小来读取。
- 值传递、指针传递、引用传递的区别和效率
- 值传递:有一个形参向函数所属的栈拷贝数据的过程,如果值传递的对象是类对象或是大的结构体对象,将耗费一定的时间和空间。(传值)
- 指针传递:同样有一个形参向函数所属的栈拷贝数据的过程,但拷贝的数据是一个固定为4字节的地址。(传值,传递的是地址值)
- 引用传递:同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。(传地址)
- 指针传递和引用传递比值传递效率高。
- 指针和引用的区别?
- 指针是一个变量,存储的是一个地址,引用是原变量的别名
- 指针可以有多级,引用只有一级
- 指针可以为空(指针声明和初始化可以分开),引用不能为空且在定义时必须初始化
- 指针在初始化后可以改变指向,而引用在初始化之后不可再改变
- sizeof指针得到的是本指针的大小,sizeof引用得到的是引用所指向变量的大小
- 当把指针作为参数进行传递时,是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以。
- 引用本质是一个指针,同样会占4字节内存;指针是具体变量,需要占用存储空间
- 在传递函数参数时,什么时候该使用指针,什么时候该使用引用呢?
- 需要返回函数内局部变量的内存的时候用指针。返回局部变量的引用是没有意义的
- 对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小
- 类对象作为参数传递的时候使用引用
- 将引用作为函数参数有哪些好处?
- 传递引用给函数与传递指针的效果是一样的。引用是实参变量或对象的一个别名
- 使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;而使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;
- 当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。
- 使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在被调函数中同样要给形参分配存储单元,且需要重复使用"*指针变量名"的形式进行运算,这很容易产生错误且程序的阅读性较差;
- 另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。
- 数组名和指针(这里为指向数组首元素的指针)区别?
- 二者均可通过增减偏移量来访问数组中的元素。
- 数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。
- 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了
- 什么是函数指针?
- 函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。
- 一个具体函数的名字,如果后面不跟调用符号(即括号),则该名字就是该函数的指针。
- 函数指针的声明方法
int (*pf)(const int&, const int&);
上面的pf就是一个函数指针,指向所有返回类型为int,并带有两个const int&参数的函数。注意*pf两边的括号是必须的,否则上面的定义就变成了:
int *pf(const int&, const int&); 而这声明了一个函数pf,其返回类型为int *, 带有两个const int&参数。
两种方法赋值:
指针名 = 函数名; 指针名 = &函数名
- 函数指针的作用?
- 函数与数据项相似,函数也有地址。我们希望在同一个函数中通过使用相同的形参在不同的时间使用产生不同的效果。
- 一个函数名就是一个指针,它指向函数的代码。一个函数地址是该函数的进入点,也就是调用函数的地址。函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变元传递给其他函数;
内存管理篇
- 堆和栈的区别
管理方式:
- 堆中资源由程序员控制(容易内存泄漏)
- 栈资源由编译器自动管理,无需手工控制。
内存管理机制:
- 系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序(大多数系统会在这块内存空间首地址记录本次分配的大小,这样delete才能正确释放本内存空间,另外系统会将多余的部分重新放入空闲链表中)
- 只要栈的剩余空间大于所申请空间,系统为程序提供内存,否则报异常提示栈溢出。
空间大小:
- 堆是不连续的内存区域(系统用链表来存储空闲内存地址),堆大小受限于计算机系统中有效的虚拟内存(32bit 系统理论上是4G),所以堆的空间比较灵活,比较大。
对于堆,频繁的new/delete会造成大量碎片,使程序效率降低
- 栈是一块连续的内存区域,大小是操作系统预定好的,对于栈先进后出的栈,进出一一对应,不会产生碎片,不会产生碎片。
生长方向:
堆向上,向高地址方向增长。 栈向下,向低地址方向增长。
分配方式:
- 堆都是动态分配(没有静态分配的堆)
- 栈有静态分配和动态分配,静态分配由编译器完成(如局部变量分配),动态分配由alloca函数分配,栈的动态分配的资源由编译器进行释放,无需程序员实现。
分配效率:
- 堆由C++函数库提供,机制很复杂。所以堆的效率比栈低很多。
- 栈是其系统提供的数据结构,计算机在底层对栈提供支持,分配专门 寄存器存放栈地址,栈操作有专门指令。
int *p[10]:表示指针数组,是一个数组变量大小为10,数组每个元素都是指向int的指针。
int (*p)[10]:表示数组指针,是指针类型,指向的是一个int类型的数组
int *p(int):函数声明,函数名是p,参数是int类型的,返回值是int *类型的。
int (*p)(int):是函数指针,该指针指向的函数具有int类型参数,并且返回值是int类型的。
- 什么是内存泄露,如何检测与避免?
内存泄露:
一般我们常说的内存泄漏是指堆内存的泄漏。堆内存使用完后必须显式释放的内存。应用程序般使用malloc,、realloc、 new等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。
避免内存泄露的几种方式:
- 计数法:使用new或者malloc时,让该数+1,delete或free时,该数-1,程序执行完打印这个计数,如果不为0则表示存在内存泄露
- 一定要将基类的析构函数声明为虚函数
- 对象数组的释放一定要用delete []
- 有new就有delete,有malloc就有free,保证它们一定成对出现
检测工具:
Linux下可以使用Valgrind工具
Windows下可以使用CRT库
- 说一说你理解的内存对齐以及原因
- 分配内存的顺序是按照声明的顺序。
- 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
- 最后整个结构体的大小必须是里面变量类型最大值的整数倍。
添加了#pragma pack(n)后规则就变成了下面这样:
- 偏移量要是n和当前变量大小中较小值的整数倍
- 整体大小要是n和最大变量大小中较小值的整数倍
- n值必须为1,2,4,8…,为其他值时就按照默认的分配规则
- 如何判断结构体变量比较是否相等
- 重载了 “==” 操作符
struct foo {
int a;
int b;
bool operator==(const foo& rhs) *//* *操作运算符重载*
{
return( a == rhs.a) && (b == rhs.b);
}
};
- 元素的话,一个个比;
- 指针直接比较,如果保存的是同一个实例地址,则(p1==p2)为真;
new / delete 与 malloc / free篇
- C++中有几种类型的new
new有三种使用方法:plain new,nothrow new和placement new
plain new:就是我们常用的new。
定义:
void* operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();
plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL
nothrow new:
定义:
void * operator new(std::size_t,const std::nothrow_t&) throw();
void operator delete(void*) throw();
nothrow new在空间分配失败的情况下是不抛出异常,而是返回NULL
placement new:
定义:
void* operator new(size_t,void*);
void operator delete(void*,void*);
- 这种new允许在一块已经分配成功的内存上重新构造对象或对象数组。
- placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。
使用placement new注意事项:
- palcement new的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组
- placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用delete,这是因为placement new构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏或者之后释放内存时出现运行时错误。
例子:
class ADT{
int i,j;
public:
ADT(){i = 10;j = 100;} //构造函数
~ADT(){}
};
int main(){
char *p = new(nothrow) char[sizeof ADT + 1];
if (p == NULL) {cout << "alloc failed" << endl;}
ADT *q = new(p) ADT; //placement new:不必担心失败,只要p所指对象的的空间足够ADT创建即可
//delete q; //错误!不能在此处调用delete q;
q->ADT::~ADT(); //显示调用析构函数
delete[] p;
return 0;
}
- new / delete 与 malloc / free的异同?
相同点:
都可用于内存的动态申请和释放
不同点:
- 前者是C++运算符,后者是C/C++语言标准库函数
- new自动计算要分配的空间大小,malloc需要手工计算
- new是类型安全的,malloc不是。
- Malloc/free需要库文件支持,new/delete不用
- malloc和free是标准库函数,支持覆盖;new和delete是运算符,支持重载。
- malloc仅仅分配内存空间,free仅仅回收空间,不具备调用构造函数和析构函数功能; new和delete除了分配回收功能外,还会调用构造函数和析构函数。
- new是封装了malloc,直接free不会报错,但是这只是释放内存,而不会析构对象
- malloc和free返回的是void*类型指针(必须进行类型转换),new和delete返回的是具体类型指针。
- new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。
- new和delete是如何实现的?
new的实现过程是:
new调用名为operator new的标准库函数分配足够空间并调用相关对象的构造函数;
new[]的实现原理:
- 对于简单类型,new[]计算好大小后调用operator new;
- 对于复杂数据结构,new[]先调用operator new[]分配内存,然后在p的前四个字节写入数组大小n,然后调用n次构造函数,针对复杂类型,new[]会额外存储数组大小;
(delete是如何知道自己释放的内存是多少的)
delete的实现过程:
delete对指针所指对象运行适当的析构函数;然后通过调用名为operator delete的标准库函数释放该对象所用内存。
delete[]的实现原理:
针对简单类型,delete和delete[]等同。
假设指针p指向new[]分配的内存。因为要4字节存储数组大小,实际分配的内存地址为[p-4],系统记录的也是这个地址。delete[]实际释放的就是p-4指向的内存。而delete会直接释放p指向的内存,这个内存根本没有被系统记录,所以会崩溃。
需要在 new [] 一个对象数组时, C++在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。
- 既然有了malloc/free,为什么还需要new/delete呢?直接用malloc/free不好吗?
- malloc/free和new/delete都是用来申请内存和回收内存的。
- 在对非基本数据类型的对象使用的时候,对象创建的时候还需要执行构造函数,销毁的时候要执行析构函数。而malloc/free是库函数,是已经编译的代码,所以不能把构造函数和析构函数的功能强加给malloc/free,所以new/delete是必不可少的。
- 被free回收的内存是立即返还给操作系统吗?
不是的,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。
- delete p、delete [] p、allocator都有什么作用?
动态数组管理:
- new一个数组时,[]中必须是一个整数,但是不一定是常量整数,普通数组必须是一个常量整数;
- new动态数组返回的并不是数组类型,而是一个元素类型的指针;
- delete[]时,数组中的元素按逆序的顺序进行销毁;
- new在内存分配上面有一些局限性,new的机制是将内存分配和对象构造组合在一起,同样的,delete也是将对象析构和内存释放组合在一起的。allocator将这两部分分开进行,allocator申请一部分内存,不进行初始化对象,只有当需要的时候才进行初始化操作。
- malloc申请的存储空间能用delete释放吗?
不能,malloc /free主要为了兼容C,new和delete 完全可以取代malloc /free的。
- malloc /free的操作对象都是必须明确大小的,而且不能用在动态类上。
- new 和delete会自动进行类型检查和大小,malloc/free不能执行构造函数与析构函数,所以动态对象它是不行的。
- malloc与free的实现原理?
详细见阿秀c++基础第56条
- malloc、realloc、calloc的区别
malloc函数:
void* malloc(unsigned int num_size);
int *p = malloc(20*sizeof(int));申请20个int类型的空间;
calloc函数:
void* calloc(size_t n,size_t size);
int *p = calloc(20, sizeof(int));
省去了人为空间计算;malloc申请的空间的值是随机初始化的,calloc申请的空间的值是初始化为0的;
realloc函数
void realloc(void *p, size_t new_size);
给动态分配的空间分配额外的空间,用于扩充容量。
宏定义/typedef/const/static篇
- 宏定义和函数有何区别?
- 宏在预处理阶段完成替换,之后被替换的文本参与编译,相当于直接插入了代码,运行时不存在函数调用,执行起来更快;函数调用在运行时需要跳转到具体调用函数。
- 宏定义没有返回值;函数调用具有返回值。
- 宏定义参数没有类型,不进行类型检查;函数参数具有类型,需要检查类型。
- 宏定义不要在最后加分号。
- 宏定义define和typedef区别?
- 宏主要用于定义常量及书写复杂的内容;typedef主要用于定义类型别名。
- 宏替换发生在编译阶段之前,属于文本插入替换;typedef是编译的一部分。
- 宏不检查类型;typedef会检查数据类型。
- 宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。
注意对指针的操作,typedef char * p_char和#define p_char char *区别巨大。
- 内联函数和宏定义的区别
- 宏只做简单字符串替换(编译前)。而内联函数可以进行参数类型检查(编译时),且具有返回值。
- 内联函数在编译时直接将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率,并且进行参数类型检查,具有返回值,可以实现重载。
- 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
- 内联函数有类型检测、语法判断等功能,而宏没有
内联函数适用场景:
- 使用宏定义的地方都可以使用 inline 函数。
- 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率。
- define宏定义和const的区别
编译阶段:
define是在编译的预处理阶段起作用,而const是在编译、运行的时候起作用
安全性:
define只做替换,不做类型检查和计算,也不求解,容易产生错误,const常量有数据类型,编译器可以对其进行类型安全检查
内存占用:
- define只是将宏名称进行替换,没有分配内存空间。
- const定义的变量只是值不能改变,const在程序运行中只有一份备份,且可以执行常量折叠,能将复杂的的表达式计算出结果放入常量表
- C++中const和static的作用
static:
不考虑类的情况:
- 隐藏:所有不加static的全局变量和函数具有全局可见性,可以在其他文件中使用,加了之后只能在该文件所在的编译模块中使用。
- 默认初始化为0:包括未初始化的全局静态变量与局部静态变量,都存在全局未初始化区,静态变量在函数内定义,始终存在,且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不能使用;
考虑类的情况:
- static成员变量:只与类关联,不与类的对象关联。定义时要分配空间,必须在类外初始化,可以被非static成员函数任意访问。
- static成员函数:因为不具有this指针,无法访问类对象的非static成员变量和非static成员函数;不能被声明为const、虚函数和volatile;可以被非static成员函数任意访问
const:
不考虑类的情况:
- const常量在定义时必须初始化,之后无法更改
- const形参可以接收const和非const类型的实参,例如// i 可以是 int 型或者 const int 型void fun(const int& i){ //...}
- const类型变量可以通过类型转换符const_cast将const类型转换为非const类型;
考虑类的情况:
- const成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其const数据成员的值可以不同,所以不能在类中声明时初始化
- const成员函数:const对象不可以调用非const成员函数;非const对象都可以调用;不可以改变非mutable数据的值;
- const修饰变量是也与static有一样的隐藏作用。只能在该文件中使用,其他文件不可以引用声明使用。 因此在头文件中声明const变量是没问题的,因为即使被多个文件包含,链接性都是内部的,不会出现符号冲突。
Note: 用mutable关键字声明的变量可以在const成员函数中被修改
- static的用法和作用?
作用:
- 隐藏。(static函数,static变量均可)当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。
- 保持变量内容的持久。(static变量中的记忆功能和全局生存期)存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和static变量,只不过和全局变量比起来,static可以控制变量的可见范围,说到底static还是用来隐藏的。
- static变量默认初始化为0。全局变量也具备这一属性,因为全局变量也存储在静态数据区。
C++中的类成员声明static:
- 函数体内static变量的作用范围为该函数体,变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;
- 在模块内的static全局变量可以被模块内所有函数访问,但不能被模块外其它函数访问;
- 在模块内的static函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内;
- 在类中的static成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;
- 在类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量。
- static修饰的变量先于对象存在,static类对象必须要在类外进行初始化,;
- 由于static修饰的类成员属于类,不属于对象,因此static类成员函数是没有this指针的,所以static类成员函数不能访问非static的类成员,只能访问static修饰的类成员;
- static成员函数不能被virtual修饰,static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;
- 静态成员函数没有this指针,虚函数的实现是为每一个对象分配一个vptr指针,而vptr是通过this指针调用的,所以不能为virtual;
- 静态成员与普通成员的区别是什么?
- 生命周期
静态成员变量从类被加载开始到类被卸载,一直存在;普通成员变量只有在类创建对象后才开始存在,对象结束,它的生命期结束;
- 共享方式
静态成员变量是全类共享;普通成员变量是每个对象单独享用的;
- 定义位置
普通成员变量存储在栈或堆中,而静态成员变量存储在静态全局区;
- 初始化位置
普通成员变量在类中初始化;静态成员变量在类外初始化;
- 默认实参
可以使用静态成员变量作为默认实参,
- C++的顶层const和底层const
概念区分
- 顶层const (*const):指的是const修饰的变量本身是一个常量,无法修改,指的是指针,就是 * 号的右边
- 底层const(const*):指的是const修饰的变量所指向的对象是一个常量,指的是所指变量,就是 * 号的左边
举个例子
int a = 10; int* const b1 = &a; //顶层const,b1本身是一个常量
const int* b2 = &a; //底层const,b2本身可变,所指的对象是常量
const int b3 = 20; //顶层const,b3是常量不可变
const int* const b4 = &a; //前一个const为底层,后一个为顶层,b4不可变
const int& b5 = a; //用于声明引用变量,都是底层const
区分作用
- 执行对象拷贝时有限制,常量的底层const不能赋值给非常量的底层const
- 使用命名的强制类型转换函数const_cast时,只能改变运算对象的底层const
类的访问权限篇
- 什么是类的继承?
继承的相关概念:
所谓的继承就是一个类继承了另一个类的属性和方法,这个新的类包含了上一个类的属性和方法,被称为子类或者派生类,被继承的类称为父类或者基类;
继承的特点:
子类拥有父类的所有属性和方法,子类可以拥有父类没有的属性和方法,子类对象可以当做父类对象使用;
继承类型:
public、protected、private;
- public,protected和private访问和继承权限/public/protected/private的区别?
- public的变量和函数在类的内部外部都可以访问。
- protected的变量和函数只能在类的内部和其派生类中访问。
- private修饰的元素只能在类内访问。
三种访问权限:
- public:用该关键字修饰的成员表示公有成员,该成员不仅可以在类内可以被访问,在类外也是可以被访问的,是类对外提供的可访问接口;
- private:用该关键字修饰的成员表示私有成员,该成员仅在类内可以被访问,在类体外是隐藏状态;
- protected:用该关键字修饰的成员表示保护成员,保护成员在类体外同样是隐藏状态,但是对于该类的派生类来说,相当于公有成员,在派生类中可以被访问。
三种继承方式:
- 若继承方式是public,基类成员在派生类中的访问权限保持不变,也就是说,基类中的成员访问权限,在派生类中仍然保持原来的访问权限;
- 若继承方式是private,基类所有成员在派生类中的访问权限都会变为私有(private)权限;
- 若继承方式是protected,基类的共有成员和保护成员在派生类中的访问权限都会变为保护(protected)权限,私有成员在派生类中的访问权限仍然是私有(private)权限。
扩展:
访问权限
派生类可以继承基类中除了构造/析构、赋值运算符重载函数之外的成员,但是这些成员的访问属性根据派生方式的不同是变化的;
派生类对基类成员的访问形式有如下两种:
- 内部访问:由派生类中新增的成员函数对从基类继承来的成员的访问
- 外部访问:在派生类外部,通过派生类的对象对从基类继承来的成员的访问
总结:
public、protected、private 的访问权限范围关系:
public > protected > private
继承权限:
public继承
公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,都保持原有的状态,而基类的私有成员任然是私有的,不能被这个派生类的子类所访问。
protected继承
保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然是私有的。
private继承
私有继承的特点是基类的所有公有成员和保护成员都成为派生类的私有成员,并不被它的派生类的子类所访问,基类的成员只能由自己派生类访问,无法再往下继承。
总结:
- 派生类继承自基类的成员权限有四种状态:public、protected、private、不可见
- 派生类对基类成员的访问权限取决于两点:1)继承方式;2)基类成员在基类中的访问权限
- 如何设计一个计算仅单个子类的对象个数?
- 为类设计一个static静态变量count作为计数器;
- 类定义结束后初始化count;
- 在构造函数中对count进行+1;
- 设计拷贝构造函数,在进行拷贝构造函数中进行count +1,操作;
- 设计赋值构造函数,在进行赋值函数中对count+1操作;
- 在析构函数中对count进行-1;
- 如何阻止一个类被实例化?有哪些方法?
- 将类定义为抽象基类或者将构造函数声明为private;
- 不允许类外部创建类对象,只能在类内部创建对象
- 成员初始化列表会在什么时候用到?它的调用过程是什么?
- 当初始化一个引用成员变量时;
- 初始化一个const成员变量时;
- 当调用一个基类的构造函数,而构造函数拥有一组参数时;
- 当调用一个成员类的构造函数,而他拥有一组参数
调用过程:
编译器会一一操作初始化列表,以适当顺序在构造函数之内安插初始化操作,并且在任何显示用户代码前。list中的项目顺序是由类中的成员声明顺序决定的,不是初始化列表中的排列顺序决定的。
- 如果想将某个类用作基类,为什么该类必须定义而非声明?
派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。所以必须定义而非声明。
- 继承机制中对象之间如何转换?指针和引用之间如何转换?
- 向上类型转换
将派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向上类型转换是安全的。
- 向下类型转换
将基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,因为一个基类对应几个派生类,所以向下类型转换时不知道对应哪个派生类,所以在向下类型转换时必须加动态类型识别技术。RTTI技术,用dynamic_cast进行向下类型转换。
- 知道C++中的组合吗?它与继承相比有什么优缺点吗?
继承的优点:子类可以重写父类的方法来方便地实现对父类的扩展。
继承的缺点:
- 父类的内部细节对子类是可见的。
- ②子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。
③如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。
组合:是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
组合的优点:
①当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。
②当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。
③当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。
组合的缺点:
- 容易产生过多的对象。
- 为了能组合多个对象,必须仔细对接口进行定义。
- 如果有一个空类,它会默认添加哪些函数?
1) Empty(); // 缺省构造函数//
2) Empty( const Empty& ); // 拷贝构造函数//
3) ~Empty(); // 析构函数//
4) Empty& operator=( const Empty& ); // 赋值运算符//
赋值与拷贝篇
- 拷贝初始化和直接初始化
当用于类的对象时,初始化的拷贝形式和直接形式有所不同:
- 直接初始化直接调用与实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数。
-
- string str1("I am a string"); // 直接初始化
- string str2(str1); // 直接初始化,直接调用拷贝构造函数
- string str3 = "I am a string"; // 拷贝初始化,先为字符串”I am a string“创建临时对象,再把临时对象作为参数,使用拷贝构造函数构造str3
- string str4 = str1; //语句4 拷贝初始化,这里相当于隐式调用拷贝构造函数,而不是调用赋值运算符函数
拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。
- 初始化和赋值的区别
- 对于简单类型来说,初始化和赋值没什么区别
- 对于类和复杂数据类型来说,就会有很大的区别
- 浅拷贝和深拷贝的区别
浅拷贝:
浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。
深拷贝:
深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。
Note: 浅拷贝被拷贝的对象析构释放资源之后,拷贝对象析构时会再次释放一个已经释放的资源,深拷贝的结果是两个对象之间没有任何关系,各自成员地址不同。
- 对象复用的了解,零拷贝的了解
对象复用:
对象复用其本质是一种设计模式:Flyweight享元模式。
通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。
零拷贝:
零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。零拷贝技术可以减少数据拷贝和共享总线操作的次数。
零拷贝例子:vector的一个成员函数emplace_back()体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:使用push_back()函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和转移构造,效率更高。
- 如何禁止程序自动生成拷贝构造函数?
- 为了阻止编译器默认生成拷贝构造函数和拷贝赋值函数,我们需要手动去重写这两个函数,某些情况下,为了避免调用拷贝构造函数和拷贝赋值函数,我们需要将他们设置成private,防止被调用。
- 类的成员函数和friend函数还是可以调用private函数,如果这个private函数只声明不定义,则会产生一个连接错误;
- 针对上述两种情况,我们可以定一个基类,在基类中将拷贝构造函数和拷贝赋值函数设置成private,那么派生类中编译器将不会自动生成这两个函数,且由于基类中该函数是私有的,因此,派生类将阻止编译器执行相关的操作
构造/析构篇
- C++有哪几种的构造函数(?)
C++中的构造函数可以分为4类:
- 默认构造函数
- 初始化构造函数(有参数)
- 拷贝构造函数
- 移动构造函数(move和右值引用)/移动拷贝构造
//委托构造函数
- 转换构造函数
Note:
- 默认构造函数和初始化构造函数在定义类的对象,完成对象的初始化工作
- 复制构造函数用于复制本类的对象
- 转换构造函数用于将其他类型的变量,隐式转换为本类对象
- 类成员初始化方式?
- 赋值初始化,通过在函数体内进行赋值初始化;
- 列表初始化,在冒号后使用初始化列表进行初始化。
区别在于:
- 对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的;
- 说初始化这个数据成员此时函数体还未执行。
- 有哪些情况必须用到成员列表初始化?
必须使用成员初始化的四种情况
① 当初始化一个引用成员时;
② 当初始化一个常量成员时;
③ 当调用一个基类的构造函数,而它拥有一组参数时;
④ 当调用一个成员类的构造函数,而它拥有一组参数时;
- 构造函数的执行顺序 ?
一个派生类构造函数的执行顺序如下:
① 虚基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。
② 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。
③ 类的成员对象的构造函数(按照成员对象在类中的定义顺序)
④ 派生类自己的构造函数。
- 为什么用成员初始化列表会快一些?
赋值初始化是在构造函数当中做赋值的操作,而列表初始化是做纯粹的初始化操作。C++的赋值操作是会产生临时对象的。临时对象的出现会降低程序的效率。
- 什么情况下会调用拷贝构造函数
- 用类的一个实例化对象去初始化另一个对象的时候
- 函数的参数是类的对象时(非引用传递)
- 函数的返回值是函数体内局部对象的类的对象时 ,此时虽然发生(Named return Value优化)NRV优化,但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数
Note:
- 即使发生NRV优化的情况下,Linux+ g++的环境是不管是值返回方式还是引用方式返回的方式都不会发生拷贝构造函数。
- Windows + VS2019在值返回的情况下发生拷贝构造函数,引用返回方式则不发生拷贝构造函数。
- 在c++编译器发生NRV优化,如果是引用返回的形式则不会调用拷贝构造函数,如果是值传递的方式依然会发生拷贝构造函数。
(例子详见阿秀语言篇40)
- 说说移动构造函数(?)不明确。
移动构造函数设计的初衷:
用对象a初始化对象b后对象a就不在使用了,但是对象a在析构之前的空间还在,既然拷贝构造函数,是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间?这样就避免了新的空间的分配,大大降低了构造的成本。
- 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。
- 所以只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间;
- 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。
C++异常处理篇
- C++的异常处理的方法
常见的异常有:
- 数组下标越界
- 除法计算时除数为0
- 动态分配空间时空间不足
...
C++中的异常处理机制主要使用try、throw和catch三个关键字,其在程序中的用法如下:
#include <iostream>
using namespace std;
int main()
{
double m = 1, n = 0;
try {
cout << "before dividing." << endl;
if (n == 0)
throw - 1; //抛出int型异常
else if (m == 0)
throw - 1.0; //拋出 double 型异常
else
cout << m / n << endl;
cout << "after dividing." << endl;
}
catch (double d) {
cout << "catch (double)" << d << endl;
}
catch (...) {
cout << "catch (...)" << endl;
}
cout << "finished" << endl;
return 0;
}
//运行结果
//before dividing.
//catch (...)
//finished
- 程序的执行流程是先执行try包裹的语句块,如果执行过程中没有异常发生,则不会进入任何catch包裹的语句块,
- 如果发生异常,则使用throw进行异常抛出,再由catch进行捕获,throw可以抛出各种数据类型的信息,代码中使用的是数字,也可以自定义异常class。
- catch根据throw抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用catch(...)的方式捕获任何异常(不推荐)。
- 如果catch了异常,当前函数如果不进行处理,或者已经处理了想通知上一层的调用者,可以在catch里面再throw异常。
- 函数的异常声明列表
有时候,程序员在定义函数的时候知道函数可能发生的异常,可以在函数声明和定义时,指出所能抛出异常的列表,写法如下:
int fun() throw(int,double,A,B,C){...};
这种写法表名函数可能会抛出int,double型或者A、B、C三种类型的异常,如果throw中为空,表明不会抛出任何异常,如果没有throw则可能抛出任何异常
- C++标准异常类 exception
C++ 标准库中有一些类代表异常,这些类都是从 exception 类派生而来的
- bad_typeid:使用typeid运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常,
- bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常
- bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常
- out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常
- C++如何处理多个异常的?
- C++中的异常情况:
语法错误(编译错误):比如变量未定义、括号不匹配、关键字拼写错误等等编译器在编译时能发现的错误,这类错误可以及时被编译器发现,而且可以及时知道出错的位置及原因,方便改正。
运行时错误:比如数组下标越界、系统内存不足等等。这类错误不易被程序员发现,它能通过编译且能进入运行,但运行时会出错,导致程序崩溃。为了有效处理程序运行时错误,C++中引入异常处理机制来解决此问题。
- C++异常处理机制:
异常处理基本思想:执行一个函数的过程中发现异常,可以不用在本函数内立即进行处理, 而是抛出该异常,让函数的调用者直接或间接处理这个问题。
C++异常处理机制由3个模块组成:try(检查)、throw(抛出)、catch(捕获) 抛出异常的语句格式为:throw 表达式;如果try块中程序段发现了异常则抛出异常。
底层原理篇
- 方法调用的原理(栈,汇编)
机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。而为单个过程分配的那部分栈称为帧栈;帧栈可以认为是程序栈的一段,它有两个端点,一个标识起始地址,一个标识着结束地址,两个指针结束地址指针esp,开始地址指针ebp;
由一系列栈帧构成,这些栈帧对应一个过程,而且每一个栈指针+4的位置存储函数返回地址;每一个栈帧都建立在调用者的下方,当被调用者执行完毕时,这一段栈帧会被释放。由于栈帧是向地址递减的方向延伸,因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。如果将栈指针加上一定的值,也就是向上移动,那么就相当于压缩了栈帧的长度,也就是说内存被释放了。
过程实现
① 备份原来的帧指针,调整当前的栈帧指针到栈指针位置;
② 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存;
③ 使用建立好的栈帧,比如读取和写入,一般使用mov,push以及pop指令等等。
④ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了
⑤ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。
⑥ 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理。
⑦ 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。
⑧ 弹出返回地址,跳出当前过程,继续执行调用者的代码。
过程调用和返回指令:call指令/leave指令/ret指令
- cout和printf有什么区别?
- cout<<不是一个函数,它是类std::ostream的全局对象。cout<<后可以跟不同的类型是因为cout<<已存在针对各种类型数据的重载,所以会自动识别数据的类型。
- 输出过程会首先将输出字符放入缓冲区,然后输出到屏幕。
- cout是有缓冲输出:
cout < < "abc " < <endl; 或cout < < "abc\n "; cout < <flush; 这两个才是一样的.
flush立即强迫缓冲输出。
printf是行缓冲输出,不是无缓冲输出。