1、基础篇
1、C++ 中的四种智能指针
为什么要使⽤智能指针:智能指针其作⽤是管理⼀个指针,避免程序员申请的空间在函数结束时忘记释放,造成内存泄漏这种情况的发⽣。使⽤智能指针可以很⼤程度上的避免这个问题,因为智能指针就是⼀个类,当超出了类的作⽤域是,类会⾃动调⽤析构函数,析构函数会⾃动释放资源。所以智能指针的作⽤原理就是在函数结束时⾃动释放内存空间,不需要⼿动释放内存空间。
常⽤接⼝
T* get();
T& operator*();
T* operator->();
T& operator=(const T& val);
T* release();
void reset (T* ptr = nullptr);
T
是模板参数, 也就是传⼊的类型;get()
⽤来获取auto_ptr
封装在内部的指针, 也就是获取原⽣指针;operator()
重载 ,operator->()
重载了->
,operator=()
重载了=
;realease()
将auto_ptr
封装在内部的指针置为nullptr,
但并不会破坏指针所指向的内容, 函数返回的是内部指针置空之前的值;- 直接释放封装的内部指针所指向的内存, 如果指定了
ptr
的值, 则将内部指针初始化为该值 (否则将其设置为nullptr
)
下⾯分别说⼀下哪四种:
1、auto_ptr
(C++98 的⽅案,C11 已抛弃)采⽤所有权模式。
auto_ptr<std::string> p1 (new string ("hello"));
auto_ptr<std::string> p2;
p2 = p1; //auto_ptr 不会报错.
此时不会报错,p2
剥夺了 p1
的所有权,但是当程序运⾏时访问 p1
将会报错。所以 auto_ptr
的缺点是:存在潜在的内存崩溃问题!
2、unique_ptr
(替换auto_ptr
)
unique_ptr
实现独占式拥有或严格拥有概念,保证同⼀时间内只有⼀个智能指针可以指向该对象。它对于避免资源泄露特别有⽤。
采⽤所有权模式,还是上⾯那个例⼦
unique_ptr<string> p3 (new string (auto));//#4
unique_ptr<string> p4;//#5
p4 = p3;//此时会报错
编译器认为 p4=p3
⾮法,避免了 p3
不再指向有效数据的问题。因此,unique_ptr
⽐ auto_ptr
更安全。
3、shared_ptr
(共享型,强引⽤)
shared_ptr
实现共享式拥有概念,多个智能指针可以指向相同对象,该对象和其相关资源会在 "最后⼀个引⽤被销毁" 时候释放。从名字 share 就可以看出了资源可以被多个指针共享,它使⽤计数机制来表明资源被⼏个指针共享。
可以通过成员函数 use_count()
来查看资源的所有者个数,除了可以通过 new
来构造,还可以通过传⼊auto_ptr,unique_ptr,weak_ptr
来构造。当我们调⽤ release()
时,当前指针会释放资源所有权,计数减⼀。当计数等于 0 时,资源会被释放。
4、weak_ptr
(弱引⽤)
weak_ptr
是⼀种不控制对象⽣命周期的智能指针,它指向⼀个 shared_ptr
管理的对象。进⾏该对象的内存管理的是那个强引⽤的 shared_ptr
。
weak_ptr
只是提供了对管理对象的⼀个访问⼿段。weak_ptr
设计的⽬的是为配合shared_ptr
⽽引⼊的⼀种智能指针来协助 shared_ptr
⼯作,它只可以从⼀个 shared_ptr
或另⼀个 weak_ptr
对象构造,,它的构造和析构不会引起引⽤记数的增加或减少。
weak_ptr
是⽤来解决 shared_ptr
相互引⽤时的死锁问题,如果说两个 shared_ptr
相互引⽤,那么这两个指针的引⽤计数永远不可能下降为0,也就是资源永远不会释放。它是对对象的⼀种弱引⽤,不会增加对象的引⽤计数,和 shared_ptr
之间可以相互转化,shared_ptr
可以直接赋值给它,它可以通过调⽤ lock
函数来获得shared_ptr
当两个智能指针都是 shared_ptr
类型的时候,析构时两个资源引⽤计数会减⼀,但是两者引
⽤计数还是为 1,导致跳出函数时资源没有被释放(析构函数没有被调⽤),解决办法:把其中⼀个改为weak_ptr
就可以。
2、C++ 中内存分配情况
- 栈:由编译器管理分配和回收,存放局部变量和函数参数。
- 堆:由程序员管理,需要手动
new malloc delete free
进⾏分配和回收,空间较大,但可能会出现内存泄漏和空闲碎⽚的情况。 - 全局/静态存储区:分为初始化和未初始化两个相邻区域,存储初始化和未初始化的全局变量和静态变量。
- 常量存储区:存储常量,⼀般不允许修改。
- 代码区:存放程序的⼆进制代码。
3、C++ 中的指针参数传递和引用参数
指针参数传递本质上是值传递,它所传递的是⼀个地址值。值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从⽽形成了实参的⼀个副本(替身)。值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进⾏的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。
引⽤参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。
引⽤传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的⼀个局部变量,但是任何对于引⽤参数的处理都会通过⼀个间接寻址的⽅式操作到主调函数中的相关变量。⽽对于指针传递的参数,如果改变被调函数中的指针地址,它将应⽤不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使⽤指向指针的指针或者指针引⽤。
从编译的⻆度来讲,程序在编译时分别将指针和引⽤添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,⽽引⽤在符号表上对应的地址值为引⽤对象的地址值(与实参名字不同,地址相同)。符号表⽣成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),⽽引⽤对象则不能修改。
4、C++ 中 const 和 static 关键字
static
** 作用: 控制变量的存储方式 和 可见性**
- 修饰局部变量:
- 通常局部变量在程序中存放栈区,局部的生命周期在包含语句块执行完时便会结束,使用
static
关键字对局部变量修饰后,变量会存放静态数据区,其生命周期会延续到整个程序结束。 - 但是作用域没有改变,作用域还是限制在其他语句块中。
- 个人理解:
static
修饰局部变量,可认为是 延长变量生命周期的局部变量(不会随着函数结束而是放)
- 通常局部变量在程序中存放栈区,局部的生命周期在包含语句块执行完时便会结束,使用
- 修饰全局变量
static
对全局变量修饰改变作用域范围,由普通全局变量的整个工程可见,变为本文件可见。
- 修饰函数
- 和全局变量一样 ,改变作用域,限制在本文件使用。
- 修饰类
- 对类中某函数修饰,表示该函数属于一个类,而不是此类的任何对象 ;
- 对类中某一个变量修饰 , 便是该变量为所有对象所有,存储空间中只有一个副本,可以通 过 类和对象调用(该变量类内声明,类外初始化)
const
* *作用:限制可读性
- 修饰基础数据类型
const
在基础数据类型前、后结果一样,在使用这些常量的时候不能改变常量的值
- 修饰指针变量 和 引用变量
- 如果
const 在 * 左侧
,则const修饰指针所指向的变量 ,即指针指向常量 - 如果
const 在 * 右侧
,则const修饰指针本身 ,即指针本身为常量
- 如果
- 修饰普通函数
- const修饰函数参数
- 防止传入的参数在函数体内被改变, 但仅对指针、引用有意义。因为如果是按值传递,传给参数的仅仅是实参的副本,即使在函数体内改变了形参,实参也不会得到影响。
const
修饰的函数参数是指针时, 代表 在函数体内不能修改该指针所指的内容,起到保护作用
- const 修饰函数返回值
const
来修饰返回的指针或引用,保护指针指向的内容或引用的内容不被修改
- const修饰函数参数
- const 在类中的用法
- 修饰成员变量
const
成员变量,只在某个对象⽣命周期内是常量,⽽对于整个类⽽⾔是可以改变的。因为类可以创建多个对象,不同的对象其const
数据成员值可以不同。所以不能在类的声明中初始化 const 数据成员,因为类的对象在没有创建时候,编译器不知道const
数据成员的值是什么。const 数据成员的初始化只能在类的构造函数的初始化列表中进⾏。
- 修饰成员函数
- 在成员函数后添加
const
为这个函数为 常函数。常函数 不可修改修改对象的内容; const
关键字和static
关键字对于成员函数来说是不能同时使⽤的,因为static
关键字修饰静态成员函数不含有this
指针,即不能实例化,const
成员函数⼜必须具体到某⼀个函数。- 成员属性声明时加关键字****
mutable
后,在常函数中依然可以修改
- 在成员函数后添加
- 修饰成员变量
- 修饰类对象
const
修饰类对象,定义常量对象:常量对象可以调⽤类中的const
成员函数,但不能调⽤⾮const
成员函数。- (原因:对象调⽤成员函数时,在形参列表的最前⾯加⼀个形参
this
,但这是隐式的。this
指针是默认指向调⽤函数的当前对象的,所以,很⾃然,this
是⼀个常量指针test * const
,因为不可以修改this
指针代表的地址。但当成员函数的参数列表(即⼩括号)后加了const
关键字(void print() const;)
,此成员函数为常量成员函数,此时它的隐式this
形参为const test * const
,即不可以通过 this 指针来改变指向对象的值。 - ⾮常量对象可以调⽤类中的
const
成员函数,也可以调⽤⾮const
成员函数。
5、C 和 C++ 区别 (函数/类/struct/class)
C 和 C++ 区别
C
和C++
在基本语句上没有过⼤的区别。- C++ 有新增的 语法和关键字 ,语法的区别有头⽂件的不同和命名空间的不同, C++ 允许我们⾃⼰定义⾃⼰的空间,C 中不可以。关键字⽅⾯⽐如 C++ 与 C 动态管理内存的⽅式不同, C++ 中在
malloc
和free
的基础上增加了new
和delete
,⽽且 C++ 中在指针的基础上增加了引⽤的概念,关键字例如 C++ 中还增加了auto
,explicit
体现显示 和隐式转换上的概念要求,还有dynamic_cast
增加类型安全⽅⾯的内容。 - 函数⽅⾯ C++ 中有重载和虚函数的概念 : C++ ⽀持函数重载⽽ C 不⽀持,是因为 C++ 函数的名字修饰与 C 不同, C++ 函数名字的修饰会将参数加在后⾯,例如,
int func(int,double)
经过名字修饰之后会变成_func_int_double
, ⽽ C 中则会变成_func
,所以 C++ 中会⽀持不同参数调⽤不同函数 - C++ 还有虚函数概念,⽤以实现多态 。
- 类⽅⾯, C 的 struct 和 C++ 的 类 也有很⼤不同 : C++ 中的 struct 不仅可以有成员变量还可以成员函数,⽽且对于struct 增加了权限访问的概念, struct 的默认成员访问权限和默认继承权限都是 public , C++ 中除了 struct 还有class 表示类, struct 和 class 还有⼀点不同在于 class 的默认成员访问权限和默认继承权限都是 private 。
- C++ 中增加了 模板还重⽤代码,提供了更加强⼤的 STL 标准库。
C和C++中struct的区别
- C的结构体中不允许有函数的存在,C++的结构体允许有内部成员函数,并且允许该函数是虚函数。
- C的结构体内部成员不能加权限,默认是public;而C++ 的结构体内部成员权限和class一样,可以是
private、protected、public
,且 默认是public
。 - C的结构体不可以继承,C++的结构体可以从其他的结构体或者类继承。
- C的结构体不能对数据成员初始化,C++可以。
- C中使用结构体需要加上struct关键字,或者对结构体用typedef取别名后使用;而C++中可以直接使用结构体名来声明对象。
struct和class区别
- struct一般用于描述一个数据的集合;class是对一个对象数据的封装。
- struct默认访问权限是
public
;class默认访问控制权限是private
。 - 在继承关系中,struct默认是公有继承;class默认是私有继承。
- class关键字可以用于定义模板参数;struct不可以。
6、C++ ⾥是怎么定义常量的?常量存放在内存的哪个位置?
- 对于局部常量,存放在栈区;
- 对于全局常量,编译期⼀般不分配内存,放在符号表中以提⾼访问效率;
- 字⾯值常量,⽐如字符串,放在常量区。
7、C++ 中重载和重写,重定义的区别
重载:是指同⼀可访问区内被声明的⼏个具有不同参数列表的同名函数,依赖于C++函数名字的修饰会将参数加在后⾯,可以是参数类型,个数,顺序的不同。根据参数列表决定调⽤哪个函数,重载不关⼼函数的返回类型。
重写:也叫覆盖,主要发生在不同的类且存在继承关系,子类重新定义父类中有相同名称相同参数的虚函数(virtual)
,“重写”基类方法就是修改它的实现或者说在派生类中重新编写。真正意义上实现了多态。
- 被重写的函数不能是static的。必须是virtual的
- 重写函数必须有相同的类型,名称和参数列表
- 重写函数的访问修饰符可以不同。尽管virtual是private的,派生类中重写改写为public,protected也是可以的
重定义:也叫隐藏,子类重新定义父类中有相同名称的非虚函数,参数列表可以相同也可以不同,会隐藏其父类的实现方法。
8、介绍 C++ 所有的构造函数
类的对象被创建时,编译系统为对象分配内存空间,并⾃动调⽤构造函数,由构造函数完成成员的初始化⼯作。
即构造函数的作⽤:初始化对象的数据成员。
- ⽆参数构造函数:即默认构造函数,如果没有明确写出⽆参数构造函数,编译器会⾃动⽣成默认的⽆参数构造函数,函数为空,什么也不做,如果不想使⽤⾃动⽣成的⽆参构造函数,必须要⾃⼰显示写出⼀个⽆参构造函数。
- ⼀般构造函数:也称重载构造函数,⼀般构造函数可以有各种参数形式,⼀个类可以有多个⼀般构造函数,前提是参数的个数或者类型不同,创建对象时根据传⼊参数不同调⽤不同的构造函数。
- 拷⻉构造函数:拷⻉构造函数的函数参数为对象本身的引⽤,⽤于根据⼀个已存在的对象复制出⼀个新的该类的对象,⼀般在函数中会将已存在的对象的数据成员的值⼀⼀复制到新创建的对象中。如果没有显示的写拷⻉构造函数,则系统会默认创建⼀个拷⻉构造函数,但当类中有指针成员时,最好不要使⽤编译器提供的默认的拷⻉构造函数,最好⾃⼰定义并且在函数中执⾏深拷⻉。
- 类型转换构造函数:根据⼀个指定类型的对象创建⼀个本类的对象,也可以算是⼀般构造函数的⼀种,这⾥提出来,是想说有的时候不允许默认转换的话,要记得将其声明为 explict 的,来阻⽌⼀些隐式转换的发⽣。
- 赋值运算符的重载:注意,这个类似拷⻉构造函数,将=右边的本类对象的值复制给=左边的对象,它不属于构造函数,=左右两边的对象必需已经被创建。如果没有显示的写赋值运算符的重载,系统也会⽣成默认的赋值运算符,做⼀些基本的拷⻉⼯作。
9、C++ 的四种强制转换
C++ 的四种强制转换包括:static_cast
, dynamic_cast
, const_cast
, reinterpret_cast
static_cast
:明确指出类型转换,⼀般建议将隐式转换都替换成显示转换,因为没有动态类型检查,上⾏转换(派⽣类->基类)安全,下⾏转换(基类->派⽣类) 不安全,所以主要执⾏⾮多态的转换操作;dynamic_cast
:专⻔⽤于派⽣类之间的转换,type-id
必须是类指针,类引⽤或void*
,对于下⾏转换是安全的,当类型不⼀致时,转换过来的是空指针,⽽static_cast
,当类型不⼀致时,转换过来的事错误意义的指针,可能造成⾮法访问等问题。const_cast
:专⻔⽤于const
属性的转换,去除const
性质,或增加const
性质, 是四个转换符中唯⼀⼀个可以操作常量的转换符。reinterpret_cast
:不到万不得已,不要使⽤这个转换符,⾼危操作。使⽤特点: 从底层对数据进⾏重新解释,依赖具体的平台,可移植性差; 可以将整形转 换为指针,也可以把指针转换为数组;可以在指针和引⽤之间进⾏肆⽆忌惮的转换。
10、指针和引⽤的区别
- 引⽤是给变量起别名,内部实现是指针常量
int* const ref = &a
,其可以简单的理解为本体指针存放的是变量的地址 - 引⽤的本质是指针常量,其指向不可修改,⽽指针可以改变指向
- 引⽤创建的同时必须初始化,指针创建的时候可以不必初始化
- 引⽤不能为空,指针可以为 NULL
- "引⽤变量 ref" 的内存单元保存的是“被引⽤变量 a”的地址。sizeof 引⽤得到代表对象的⼤⼩。⽽ sizeof 指针得到的是指针本身的⼤⼩。
- 引⽤使⽤的时候⽆需解引⽤,指针需要解引⽤
- 作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引⽤的实质是传地址,传递的是变量的地址。
11、野(wild)指针与悬空(dangling)指针有什么区别?如何避免?
- 野指针(
wild pointer
):就是没有被初始化过的指针。⽤ gcc -Wall 编译, 会出现used uninitialized
警告。 - 悬空指针:是指针最初指向的内存已经被释放了的⼀种指针。
⽆论是野指针还是悬空指针,都是指向⽆效内存区域(这⾥的⽆效指的是"不安全不可控")的指针。 访问"不安全可控"(invalid)
的内存区域将导致"Undefined Behavior"
。
如何避免使⽤野指针?
- 在平时的编码中,养成在定义指针后且在使⽤之前完成初始化的习惯或者使⽤智能指针。
12、const 修饰指针如何区分?
下⾯都是合法的声明,但是含义⼤不同:
const int * p1; //指向整形常量 的指针,它指向的值不能修改
int * const p2; //指向整形的常量指针 ,它不能再指向别的变量,但指向(变量)的值可以修改。
const int *const p3; //指向整形常量 的 常量指针 。它既不能再指向别的常量,指向的值也不能修改。
理解这些声明的技巧在于,查看关键字const右边来确定什么被声明为常量 ,如果该关键字的右边是类型,则值是常量;如果关键字的右边是指针变量,则指针本身是常量。
13、函数指针
⾸先是定义:函数指针是指向函数的指针变量。函数指针本身⾸先是⼀个指针变量,该指针变量指向⼀个具体的函数。这正如⽤指针变量可指向整型变量、字符型、数组⼀样,这⾥是指向函数。
在编译时,每⼀个函数都有⼀个⼊⼝地址,该⼊⼝地址就是函数指针所指向的地址。有了指向函数的指针变量后,可⽤该指针变量调⽤函数,就如同⽤指针变量可引⽤其他类型变量⼀样,在这些概念上是⼤体⼀致的。
其次是⽤途:调⽤函数和做函数的参数,⽐如回调函数。
示例:
char * fun(char * p) {…} // 函数fun
char * (*pf)(char * p); // 函数指针pf
pf = fun; // 函数指针pf指向函数fun
pf(p); // 通过函数指针pf调⽤函数fun
14、堆和栈区别
- 分配和管理方式不同:
- 堆是动态分配的,其空间的分配和释放都由程序员控制;
- 栈是由编译器自动管理的,其分配方式有两种:静态分配由编译器完成,比如局部变量的分配;动态分配由alloca()函数进行分配,但是会由编译器释放;
- 产生碎片不同:
- 对堆来说,频繁使用new/delete或者malloc/free会造成内存空间的不连续,产生大量碎片,是程序效率降低;
- 对栈来说,不存在碎片问题,因为栈具有先进后出的特性;
- 生长方向不同:
- 堆是向着内存地址增加的方向增长的,从内存的低地址向高地址方向增长;
- 栈是向着内存地址减小的方向增长的,从内存的高地址向低地址方向增长;
- 申请大小限制不同:
- 栈顶和栈底是预设好的,大小固定;
- 堆是不连续的内存区域,其大小可以灵活调整
15、函数传递参数的⼏种⽅式
- 值传递:形参是实参的拷⻉,函数内部对形参的操作并不会影响到外部的实参。
- 指针传递:也是值传递的⼀种⽅式,形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进⾏操作。
- 引⽤传递:实际上就是把引⽤对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上⾯。
16、new / delete ,malloc / free 区别
new
与delete
直接带具体类型的指针,malloc
和free
返回void
类型的指针。
new
类型是安全的,而malloc
不是。例如int *p = new float[2];
就会报错;而int p = malloc(2sizeof(int))
编译时编译器就无法指出错误来。new
一般分为两步:new
操作和构造。new
操作对应与malloc
,但new
操作可以重载,可以自定义内存分配策略,不做内存分配,甚至分配到非内存设备上,而malloc
不行。new
调用构造函数,malloc
不能;delete
调用析构函数,而free
不能。malloc/free
需要库文件stdlib.h
的支持,new/delete
则不需要!
「注意」:delete
和free
被调用后,内存不会立即回收,指针也不会指向空,delete
或free
仅仅是告诉操作系统,这一块内存被释放了,可以用作其他用途。但是由于没有重新对这块内存进行写操作,所以内存中的变量数值并没有发生变化,出现野指针的情况。因此,释放完内存后,应该讲该指针指向NULL。
17、volatile 和 extern 关键字
volatile 三个特性
- 易变性:在汇编层⾯反映出来,就是两条语句,下⼀条语句不会直接使⽤上⼀条语句对应的
volatile
变量的寄存器内容,⽽是重新从内存中读取。 - 不可优化性:
volatile
告诉编译器,不要对我这个变量进⾏各种激进的优化,甚⾄将变量直接消除,保证程序员写在代码中的指令,⼀定会被执⾏。 - 顺序性:能够保证
volatile
变量之间的顺序性,编译器不会进⾏乱序优化。
extern
- 在 C 语⾔中,修饰符
extern
⽤在变量或者函数的声明前,⽤来说明 “此变量/函数是在别处定义的,要在此处引⽤”。 - 注意
extern
声明的位置对其作⽤域也有关系,如果是在main
函数中进⾏声明的,则只能在main 函数中调⽤,在其它函数中不能调⽤。其实要调⽤其它⽂件中的函数和变量,只需把该
⽂件⽤包含进来即可,为啥要⽤extern
?因为⽤extern
会加速程序的编译过程,这样能节省时间。 - 在 C++ 中
extern
还有另外⼀种作⽤,⽤于指示 C 或者 C++函数的调⽤规范。⽐如在 C++ 中调⽤ C 库函数,就需要在 C++ 程序中⽤extern “C”
声明要引⽤的函数。这是给链接器⽤的,告诉链接器在链接的时候⽤C 函数规范来链接。主要原因是 C++ 和 C 程序编译完成后在⽬标代码中命名规则不同,⽤此来解决名字匹配的问题。
18、define 和 const 区别(编译阶段、安全性、内存占⽤等)
对于 define 来说,宏定义实际上是在预编译阶段进⾏处理,没有类型,也就没有类型检查,仅仅做的是遇到宏定义进⾏字符串的展开,遇到多少次就展开多少次,⽽且这个简单的展开过程中,很容易出现边界效应,达不到预期的效果。因为 define 宏定义仅仅是展开,因此运⾏时系统并不为宏定义分配内存,但是从汇编 的⻆度来讲,define 却以⽴即数的⽅式保留了多份数据的拷⻉。
对于 const 来说,const 是在编译期间进⾏处理的,const 有类型,也有类型检查,程序运⾏时系统会为 const 常量分配内存,⽽且从汇编的⻆度讲,const 常量在出现的地⽅保留的是真正数据的内存地址,只保留了⼀份数据的拷⻉,省去了不必要的内存空间。⽽且,有时编译器不会为普通的 const 常量分配内存,⽽是直接将 const 常量添加到符号表中,省去了读取和写⼊内存的操作,效率更⾼。
19、计算大小
class A{}; sizeof(A) = 1; //空类在实例化时得到⼀个独⼀⽆⼆的地址,所以为 1.
class A{virtual Fun(){} }; sizeof(A) = 4(32bit)/8(64bit) //当 C++ 类中有虚函数的时候,会有⼀个指向虚函数表的指针(vptr)
class A{static int a; }; sizeof(A) = 1;
class A{int a; }; sizeof(A) = 4;
class A{static int a; int b; }; sizeof(A) = 4;
20、⾯向对象的三⼤特性,并举例说明
所谓封装就是把客观事物封装成抽象的类,并且类可以把⾃⼰的数据和⽅法只让信任的类或者对象操作,对不可信的进⾏信息隐藏。⼀个类就是⼀个封装了数据以及操作这些数据的代码的逻辑实体。在⼀个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种⽅式,对象对内部数据提供了不同级别的保护,以防⽌程序中⽆关的部分意外的改变或错误的使⽤了对象的私有部分。
所谓继承是指可以让某个类型的对象获得另⼀个类型的对象的属性的⽅法。它⽀持按级分类的概念。继承是指这样⼀种能⼒:它可以使⽤现有类的所有功能,并在⽆需重新编写原来的类的情况下对这些功能进⾏扩展。通过继承创建的新类称为“⼦类”或者“派⽣类”,被继承的类称为“基类”、“⽗类”或“超类”。继承的过程,就是从⼀般到特殊的过程。要实现继承,可以通过“继承”和“组合”来实现。
继承概念的实现⽅式有两类:
- 实现继承:实现继承是指直接使⽤基类的属性和⽅法⽽⽆需额外编码的能⼒。
- 接⼝继承:接⼝继承是指仅使⽤属性和⽅法的名称、但是⼦类必需提供实现的能⼒。
所谓多态就是向不同的对象发送同⼀个消息,不同对象在接收时会产⽣不同的⾏为(即⽅法)。即⼀个接⼝,可以实现多种⽅法。多态与⾮多态的实质区别就是函数地址是早绑定还是晚绑定的。如果函数的调⽤,在编译器编译期间就可以确定函数的调⽤地址,并产⽣代码,则是静态的,即地址早绑定。⽽如果函数调⽤的地址不能在编译器期间确定,需要在运⾏时才确定,这就属于晚绑定。
21、多态的实现
多态其实⼀般就是指继承加虚函数实现的多态,对于重载来说,实际上基于的原理是,编译器为函数⽣成符号表时的不同规则,重载只是⼀种语⾔特性,与多态⽆关,与⾯向对象也⽆关,但这⼜是 C++中增加的新规则,所以也算属于 C++,所以如果⾮要说重载算是多态的⼀种,那就可以说:多态可以分为静态多态和动态多态。
静态多态其实就是重载,因为静态多态是指在编译时期就决定了调⽤哪个函数,根据参数列表来决定;动态多态是指通过⼦类重写⽗类的虚函数来实现的,因为是在运⾏期间决定调⽤的函数,所以称为动态多态,⼀般情况下我们不区分这两个时所说的多态就是指动态多态。
动态多态的实现与虚函数表,虚函数指针相关。
扩展:⼦类是否要重写⽗类的虚函数?⼦类继承⽗类时, ⽗类的纯虚函数必须重写,否则⼦类也是⼀个虚类不可实例化。 定义纯虚函数是为了实现⼀个接⼝,起到⼀个规范的作⽤,规范继承这个类的程序员必须实现这个函数。
22、虚函数
在类的定义中,以virtual
修饰的函数就是虚函数。它的出现
- 为了便于继承的时候可以直接重写该函数,实现多态;
- 为了实现动态的编译绑定,具体实现什么功能要根据基类指针指向的对象来进行动态绑定。如果有多个子类继承自一个基类,那么我们只要继承这个基类再重写虚函数即可完成多态。
虚函数的实现:在有虚函数的类中,类的最开始部分是一个虚函数表的指针vptr
,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率。(这个开销非常小,几乎可以忽略不计)
23、简述C++虚函数作用及底层实现原理
要点是要答出虚函数表和虚函数表指针的作用
C++中虚函数使用虚函数表和 虚函数表指针实现,虚函数表是一个类的虚函数的地址表,用于索引类本身以及父类的虚函数的地 址,假如子类的虚函数重写了父类的虚函数,则对应在虚函数表中会把对应的虚函数替换为子类的 虚函数的地址;虚函数表指针存在于每个对象中(通常出于效率考虑,会放在对象的开始地址处), 它指向对象所在类的虚函数表的地址;在多继承环境下,会存在多个虚函数表指针,分别指向对应 不同基类的虚函数表。
24、一个对象访问普通成员函数和虚函数哪个更快?
访问普通成员函数更快,因为普通成员函数的地址在编译阶段就已确定,因此在访问时直接调 用对应地址的函数,而虚函数在调用时,需要首先在虚函数表中寻找虚函数所在地址,因此相比普 通成员函数速度要慢一些
25、在什么情况下,析构函数需要是虚函数?
若存在类继承关系并且析构函数中需要析构某些资源时,析构函数需要是虚函数,否则当使用父类指针指向子类对象,在delete时只会调用父类的析构函数,而不能调用子类的析构函数,造成内存泄露等问题
26、内联函数、构造函数、静态成员函数可以是虚函数吗?
都不可以。
- 内联函数需要在编译阶段展开,而虚函数是运行时动态绑定的,编译时无法展开;
- 构造函数在进行调用时还不存在父类和子类的概念,父类只会调用父类的构造函数,子类调用子类 的,因此不存在动态绑定的概念;
- 静态成员函数是以类为单位的函数,与具体对象无关,虚函数是 与对象动态绑定的,因此是两个不冲突的概念;虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数——构造函数了。相对来说,析构函数就可以设定为虚函数。
27、构造函数中可以调用虚函数吗?
可以,但是没有动态绑定的效果,父类构造函数中调用的仍然是父类版本的函数,子类中调用的仍然是子类版本的函数
28、构造函数的执⾏顺序?析构函数的执⾏顺序?
构造函数顺序
- 基类构造函数。如果有多个基类,则构造函数的调⽤顺序是某类在类派⽣表中出现的顺序,⽽不是它们在成员初始化表中的顺序。
- 成员类对象构造函数。如果有多个成员类对象则构造函数的调⽤顺序是对象在类中被声明的顺序,⽽不是它们出现在成员初始化表中的顺序。
- 派⽣类构造函数。
析构函数顺序
- 调⽤派⽣类的析构函数;
- 调⽤成员类对象的析构函数;
- 调⽤基类的析构函数。
29、纯虚函数有什么作用?如何实现?
纯虚函数的出现就是为了让继承可以出现多种情况:
- 有时我们希望派⽣类只继承成员函数的接⼝
- 有时我们⼜希望派⽣类既继承成员函数的接⼝,⼜继承成员函数的实现,⽽且可以在派⽣类中可以重写成员函数以实现多态
- 有的时候我们⼜希望派⽣类在继承成员函数接⼝和实现的情况下,不能重写缺省的实现。
定义纯虚函数是为了实现一个接口,起到规范的作用,想要继承这个类就必须覆盖该函数。
实现方式是在虚函数声明的结尾加上= 0
即可。
30、如何让一个类不能实例化?
将类定义为抽象类(也就是存在纯虚函数)或者将构造函数声明为private
。
31、静态绑定和动态绑定
C++ 在面向对象编程中,有着静态绑定和动态绑定的定义,为了了解这两个概念,首先先简单阐述一些名词:
- 静态类型:对象在声明时使用的类型,在编译期就已经确定
- 动态类型:指针变量或引用变量所指向对象的类型,在运行期才能确定
- 静态绑定:绑定的是静态类型,对象的函数和属性依赖于绑定的静态类型,发生在编译期
- 动态绑定:绑定的是动态类型,对象的函数和属性依赖于绑定的动态类型,发生在运行期
非虚函数一般都是静态绑定,虚函数则是动态绑定。调用一个 虚函数 时,究竟调用哪个函数取决于发出调用的那个对象的 动态类型
区别:
- 静态绑定发生在编译期,动态绑定发生在运行期
- 对象的动态类型可以更改,但是静态类型无法更改
- 要想实现动态,必须使用动态绑定
- 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;
32、深拷⻉和浅拷⻉的区别(举例说明深拷⻉的安全性)
当出现类的等号赋值时,会调⽤拷⻉函数,在未定义显示拷⻉构造函数的情况下, 系统会调⽤默认的拷⻉函数-即浅拷⻉,它能够完成成员的⼀⼀复制。当数据成员中没有指针时,浅拷⻉是可⾏的。但当数据成员中有指针时,如果采⽤简单的浅拷⻉,则两类中的两个指针指向同⼀个地址,当对象快要结束时,会调⽤两次析构函数,⽽导致指野指针的问题。所以,这时必需采⽤深拷⻉。深拷⻉与浅拷⻉之间的区别就在于深拷⻉会在堆内存中另外申请空间来存储数据,从⽽也就解决来野指针的问题。简⽽⾔之,当数据成员中有指针时,必需要⽤深拷⻉更加安全。
浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享一块内存;而深拷贝会创造一个相同的对象,新对象与原对象不共享内存,修改新对象不会影响原对象。
33、什么情况下会调⽤拷⻉构造函数(三种情况)
类的对象需要拷⻉时,拷⻉构造函数将会被调⽤,以下的情况都会调⽤拷⻉构造函数:
- ⼀个对象以值传递的⽅式传⼊函数体,需要拷⻉构造函数创建⼀个临时对象压⼊到栈空间中。
- ⼀个对象以值传递的⽅式从函数返回,需要执⾏拷⻉构造函数创建⼀个临时对象作为返回值。
- ⼀个对象需要通过另外⼀个对象进⾏初始化。
34、为什么拷⻉构造函数必须是引⽤传递,不能是值传递?
为了防⽌递归调⽤。当⼀个对象需要以值⽅式进⾏传递时,编译器会⽣成代码调⽤它的拷⻉构造函数⽣成⼀个副本,如果类 A 的拷⻉构造函数的参数不是引⽤传递,⽽是采⽤值传递,那么就⼜需要为了创建传递给拷⻉构造函数的参数的临时对象,⽽⼜⼀次调⽤类 A 的拷⻉构造函数,这就是⼀个⽆限递归
35、预处理,编译,汇编,链接程序的区别
⼀段⾼级语⾔代码经过四个阶段的处理形成可执⾏的⽬标⼆进制代码。预处理器→编译器→汇编器→链接器:最难理解的是编译与汇编的区别。
这⾥采⽤《深⼊理解计算机系统》的说法。
- 预处理阶段:写好的⾼级语⾔的程序⽂本⽐如 hello.c,预处理器根据 #开头的命令,修改原始的程序,如#include<stdio.h> 将把系统中的头⽂件插⼊到程序⽂本中,通常是以 .i 结尾的⽂件。
- 编译阶段:编译器将 hello.i ⽂件翻译成⽂本⽂件 hello.s ,这个是汇编语⾔程序。⾼级语⾔是源程序。所以注意概念之间的区别。汇编语⾔程序是⼲嘛的?每条语句都以标准的⽂本格式确切描述⼀条低级机器语⾔指令。不同的⾼级语⾔翻译的汇编语⾔相同。
- 汇编阶段:汇编器将 hello.s 翻译成机器语⾔指令。把这些指令打包成可重定位⽬标程序,即.o⽂件。hello.o是⼀个⼆进制⽂件,它的字节码是机器语⾔指令,不再是字符。前⾯两个阶段都还有字符。
- 链接阶段:⽐如 hello 程序调⽤ printf 程序,它是每个 C 编译器都会提供的标准库 C 的函
数。这个函数存在于⼀个名叫 printf.o 的单独编译好的⽬标⽂件中,这个⽂件将以某种⽅式合
并到 hello.o 中。链接器就负责这种合并。得到的是可执⾏⽬标⽂件。
36、动态编译与静态编译
- 静态编译,编译器在编译可执⾏⽂件时,把需要⽤到的对应动态链接库中的部分提取出来,链接到可执⾏⽂件中去,使可执⾏⽂件在运⾏时不需要依赖于动态链接库;
- 动态编译,可执⾏⽂件需要附带⼀个动态链接库,在执⾏时,需要调⽤其对应动态链接库的命令。所以其优点⼀⽅⾯是缩⼩了执⾏⽂件本身的体积,另⼀⽅⾯是加快了编译速度,节省了系统资源。缺点是哪怕是很简单的程序,只⽤到了链接库的⼀两条命令,也需要附带⼀个相对庞⼤的链接库;⼆是如果其他计算机上没有安装对应的运⾏库,则⽤动态编译的可执⾏⽂件就不能运⾏。
37、动态链接和静态链接区别
静态连接库就是把 (.a)
⽂件中⽤到的函数代码直接链接进⽬标程序,程序运⾏的时候不再需要其它的库⽂件;动态链接就是把调⽤的函数所在⽂件模块(.so)
和调⽤函数在⽂件中的位置等信息链接进⽬标程序,程序运⾏的时候再从 (.so)
中寻找相应函数代码,因此需要相应(.so)
⽂件的⽀持。
静态链接库与动态链接库都是共享代码的⽅式,如果采⽤静态链接库,则⽆论你愿不愿意,(.a)
中的指令都全部被直接包含在最终⽣成的可执行⽂件中了。但是若使⽤ (.so)
,该 (.so)
不必被包含在最终 可执行⽂件 ⽂件中,可执行⽂件 ⽂件执⾏时可以“动态”地引⽤和卸载这个与 可执行⽂件 独⽴的 (.so)
⽂件。
静态链接库和动态链接库的另外⼀个区别在于静态链接库中不能再包含其他的动态链接库或者静态库,⽽在动态链接库中还可以再包含其他的动态或静态链接库。
动态库就是在需要调⽤其中的函数时,根据函数映射表找到该函数然后调⼊堆栈执⾏。如果在当前⼯程中有多处对(.so)
⽂件中同⼀个函数的调⽤,那么执⾏时,这个函数只会留下⼀份拷⻉。但如果有多处对 (.so)
⽂件中同⼀个函数的调⽤,那么执⾏时该函数将在当前程序的执⾏空间⾥留下多份拷⻉,⽽且是⼀处调⽤就产⽣⼀份拷⻉。
38、动态联编与静态联编
在 C++ 中,联编是指⼀个计算机程序的不同部分彼此关联的过程。按照联编所进⾏的阶段不同,可以分为静态联编和动态联编;
- 静态联编是指联编⼯作在编译阶段完成的,这种联编过程是在程序运⾏之前完成的,⼜称为早期联编。要实现静态联编,在编译阶段就必须确定程序中的操作调⽤(如函数调⽤)与执⾏该操作代码间的关系,确定这种关系称为束定,在编译时的束定称为静态束定。静态联编对函数的选择是基于指向对象的指针或者引⽤的类型。其优点是效率⾼,但灵活性差。
- 动态联编是指联编在程序运⾏时动态地进⾏,根据当时的情况来确定调⽤哪个同名函数,实际上是在运⾏时虚函数的实现。这种联编⼜称为晚期联编,或动态束定。动态联编对成员函数的选择是基于对象的类型,针对不同的对象类型将做出不同的编译结果。C++中⼀般情况下的联编是静态联编,但是当涉及到多态性和虚函数时应该使⽤动态联编。
- 动态联编的优点是灵活性强,但效率低。动态联编规定,只能通过指向基类的指针或基类对象的引⽤来调⽤虚函数,其格式为:指向基类的指针变量名->虚函数名(实参表)或基类对象的引⽤名.虚函数名(实参表)
实现动态联编三个条件:
- 必须把动态联编的⾏为定义为类的虚函数;
- 类之间应满⾜⼦类型关系,通常表现为⼀个类从另⼀个类公有派⽣⽽来;
- 必须先使⽤基类指针指向⼦类型的对象,然后直接或间接使⽤基类指针调⽤虚函数;
39、字节对齐
struct S1
{
int i;
char j;
int a;
double b;
};
struct S2
{
int i;
char j;
double b;
};
struct S3
{
};
sizeof(S1) == 24;
sizeof(S2) == 16;
sizeof(S2) == 1; // 空类与空结构体为1位
上面的结果,S1
的成员占用字节为4 + 1 + 4 + 8 = 17
,但是结果却是24
。原因就是结构体的内存大小是按照最大占用内存的成员的倍数,即必须得是double b
占用的倍数,所以就是24
。S2
的结果也是一样的道理
2、类和数据抽象
1、什么是类的继承?
类与类之间的关系:
- has-A 包含关系,⽤以描述⼀个类由多个部件类构成,实现 has-A 关系⽤类的成员属性表示,即⼀个类的成员属性是另⼀个已经定义好的类;
- use-A,⼀个类使⽤另⼀个类,通过类之间的成员函数相互联系,定义友元或者通过传递参数的⽅式来实现;
- is-A,继承关系,关系具有传递性;
继承的相关概念:
所谓的继承就是⼀个类继承了另⼀个类的属性和⽅法,这个新的类包含了上⼀个类的属性和⽅法,被称为⼦类或者派⽣类,被继承的类称为⽗类或者基类;
继承的特点:
⼦类拥有⽗类的所有属性和⽅法,⼦类可以拥有⽗类没有的属性和⽅法,⼦类对象可以当做⽗类对象使⽤;
继承中的访问控制:
public、protected、private
2、什么是组合
⼀个类⾥⾯的数据成员是另⼀个类的对象,即内嵌其他类的对象作为⾃⼰的成员;创建组合类的对象:⾸先创建各个内嵌对象,难点在于构造函数的设计。创建对象时既要对基本类型的成员进⾏初始化,⼜要对内嵌对象进⾏初始化。
创建组合类对象,构造函数的执⾏顺序:先调⽤内嵌对象的构造函数,然后按照内嵌对象成员在组合类中的定义顺序,与组合类构造函数的初始化列表顺序⽆关。然后执⾏组合类构造函数的函数体,析构函数调⽤顺序相反。
3、构造函数析构函数可否抛出异常
- 从语法的角度来说,构造函数可以抛出异常,但从逻辑和风险控制的角度来说,尽量不要抛出异常,否则可能导致内存泄漏。C++ 只会析构已经完成的对象,对象只有在其构造函数执⾏完毕才算是完全构造妥当。在构造函数中发⽣异常,控制权转出构造函数之外。因此,在对象 b 的构造函数中发⽣异常,对象b的析构函数不会被调⽤。因此会造成内存泄漏。
- 析构函数不可以抛出异常,如果析构函数抛出异常,则异常点之后的程序,比如释放内存等操作,就不会被执行,从而造成内存泄露的问题;而且当异常发生时,C++通常会调用对象的析构函数来释放资源,如果此时析构函数也抛出异常,即前一个异常未处理又出现了新的异常,从而造成程序崩溃的问题。
4、类如何实现只能静态分配和只能动态分配
首先讲解一下什么是类对象的静态分配和动态分配。
- 类对象的静态分配:例如
A a
; 这个方式是由编译器在编译阶段完成的,主要是通过直接移动栈顶指针,挪出适当的空间,然后在这个 内存空间上调用构造函数形成一个栈对象。也就是说直接调用类的构造函数来生成一个类对象。 - 类对象的动态分配:例如:
A* a = new A()
;编译器对于类对象的内存分配是在运行时动态分配的,使用new
产生的对象会建立在堆区。这个过程分为两步进行:- 执行函数
operator new()
函数,找到合适的内存进行分配。 - 调用构造函数,初始化这片内存空间。
- 执行函数
接下来说说如何只能静态分配和如何只能动态分配。
- 只能静态分配(也就是不能产生堆对象),也就是说不能使用
new
和delete
来产生或者释放空间内存,由于operator new()
和oper delete()
可以重载,将这两部分设置为private
即可。 - 只能动态分配(也就是说不产生栈对象),将类的构造函数和析构函数设为protected属性,这样类对象不能够访问,但是派生类能够访问,能够正常的继承。(将构造函数设置为private,但是你这样也会导致无法动态生成堆对对象。考虑将析构函数设置成private,那么问题来了,在静态分配过程中,析构函数的不能调用会影响构造函数的调用吗?答案是肯定的,这是因为编译器再为其分配栈对象的时候,会先检查类的析构函数的可访问性(当然也包括其他的非静态函数),如果析构函数在类外无法访问,那么拒绝在栈空间为类对象分配内存。)
总结:如果只想静态产生类对象,那么僵new和delete操作符设置为私有的;如果只想动态产生类对象,那么将析构函数设置为私有的(不能继承)或者是保护(可继承)。
5、何时需要成员初始化列表?过程是什么?
- 当初始化⼀个引⽤成员变量时;
- 初始化⼀个 const 成员变量时;
- 当调⽤⼀个基类的构造函数,⽽构造函数拥有⼀组参数时;
- 当调⽤⼀个成员类的构造函数,⽽他拥有⼀组参数;
过程:
编译器会⼀⼀操作初始化列表,以适当顺序在构造函数之内安插初始化操作,并且在任何显示⽤户代码前。list
中的项⽬顺序是由类中的成员声明顺序决定的,不是初始化列表中的排列顺序决定的。
6、哪些函数不能是虚函数
- 构造函数,构造函数初始化对象,派⽣类必须知道基类函数⼲了什么,才能进⾏构造;当有虚函数时,每⼀个类有⼀个虚表,每⼀个对象有⼀个虚表指针,虚表指针在构造函数中初始化;
- 内联函数,内联函数表示在编译阶段进⾏函数体的替换操作,⽽虚函数意味着在运⾏期间进⾏类型确定,所以内联函数不能是虚函数;
- 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
- 友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
- 普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。
3、STL 容器和算法
1、什么是C++ STL?
C++ STL
从广义来讲包括了三类:算法,容器和迭代器。
- 算法包括排序,复制等常用算法,以及不同容器特定的算法。
- 容器就是数据的存放形式,包括序列式容器和关联式容器,序列式容器就是list,vector等,关联式容器就是set,map等。
- 迭代器就是在不暴露容器内部结构的情况下对容器的遍历。
2、什么时候需要用hash_map,什么时候需要用map?
总体来说,hash_map
查找速度会比 map
快,而且查找速度基本和数据数据量大小,属于常数级别;而 map
的查找速度是 log(n)
级别。并不一定常数就比 log(n)
小,hash
还有 hash
函数的耗时,明白了吧,如果你考虑效率,特别是在元素达到一定数量级时,考虑考虑 hash_map
。但若你对内存使用特别严格,希望程序尽可能少消耗内存,那么一定要小心,hash_map
可能会让你陷入尴尬,特别是当你的 hash_map
对象特别多时,你就更无法控制了。而且 hash_map
的构造速度较慢。
现在知道如何选择了吗?权衡三个因素: 查找速度, 数据量, 内存使用 。
3、三种map的对比
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
map |
红黑树 | key 有序 |
key 不可重复 |
key 不可修改 |
O(logn) |
O(logn) |
multimap |
红黑树 | key 有序 |
key 可重复 |
key 不可修改 |
O(logn) |
O(logn) |
unordered_map |
哈希表 | key 无序 |
key 不可重复 |
key 不可修改 |
O(1) |
O(1) |
4、vector
底层原理:
vector底层是一个动态数组,包含三个迭代器,start
和finish
之间是已经被使用的空间范围,end_of_storage
是整块连续空间包括备用空间的尾部。当有新的元素插⼊时,如果⽬前容量够⽤则直接插⼊,如果容量不够,则容量扩充⾄两倍,如果两倍容量不⾜, 就扩张⾄⾜够⼤的容量。扩充的过程并不是直接在原有空间后⾯追加容量,⽽是重新申请⼀块连续空间,将原有的数据拷⻉到新空间中,再释放原有空间,完成⼀次扩充。需要注意的是,每次扩充是重新开辟的空间,所以扩充后,原有的迭代器将会失效。
vector迭代器失效的情况
当插入一个元素到vector
中,由于引起了内存重新分配,所以指向原内存的迭代器全部失效。当删除容器中一个元素后,该迭代器所指向的元素已经被删除,那么也造成迭代器失效。erase
方法会返回下一个有效的迭代器,所以当我们要删除某个元素时,需要it=vec.erase(it);
。
正确释放vector的内存
vec.clear() //:清空内容,但是不释放内存。
vector().swap(vec) //:清空内容,且释放内存,想得到一个全新的vector。
vec.shrink_to_fit() //:请求容器降低其capacity和size匹配。
vec.clear();vec.shrink_to_fit(); //:清空内容,且释放内存。
vector 扩容为什么要以1.5倍或者2倍扩容?
考虑可能产生的堆空间浪费,成倍增长倍数不能太大,使用较为广泛的扩容方式有两种,以2倍的方式扩容,或者以1.5倍的方式扩容。以2倍的方式扩容,导致下一次申请的内存必然大于之前分配内存的总和,导致之前分配的内存不能再被使用,所以最好倍增长因子设置为(1,2)之间
频繁调用 push_back()影响:
向 vector
的尾部添加元素,很有可能引起整个对象 存储空间的重新分配,重新分配更⼤的内存,再将原数据拷⻉到新空间中,再释 放原有内存,这个过程是耗时耗⼒的,频繁对 vector
调⽤ push_back()
会导致性能的下降。
在 C++11 之后, vector
容器中添加了新的⽅法: emplace_back()
,和 push_back()
⼀样的是都是在容器末尾添加⼀个新的元素进去,不同的是 emplace_back()
在效率上相⽐较于 push_back()
有了⼀定的提升。
emplace_back()
函数在原理上⽐ push_back()
有了⼀定的改进,包括在内存优化⽅⾯和运⾏效率⽅⾯。内存优化主要体现在使⽤了就地构造(直接在容器内构造对象,不⽤拷⻉⼀个复制品再使⽤)+强制类型转换的⽅法来实现,在运⾏效率⽅⾯,由于省去了拷⻉构造过程,因此也有⼀定的提升。
5、List
list
与 vector
相⽐,list
的好处就是每次插⼊或删除⼀个元素,就配置或释放⼀个空间,⽽且原有的迭代器也不会失效。STL list
是⼀个双向链表,普通指针已经不能满⾜ list
迭代器的需求, 因为list
的存储空间是不连续的。list
的迭代器必需具备前移和后退功能,所以 list
提供的是
BidirectionalIterator
。list
的数据结构中只要⼀个指向 node
节点的指针就可以了。
6、Deque
deque, vector
是单向开⼝的连续线性空间,deque
则是⼀种双向开⼝的连续线性空间。所谓双向开 ⼝,就是说 deque
⽀持从头尾两端进⾏元素的插⼊和删除操作。相⽐于 vector
的扩充空间的方式,deque
实际上更加贴切的实现了动态空间的概念。deque
没有容量的概念,因为它是动态地以分段连续空间组合⽽成,随时可以增加⼀段新的空间并连接起来。
由于要维护这种整体连续的假象,并提供随机存取的接⼝(即也提供 RandomAccessIterator),避开了“重新配置,复制,释放”的轮回,代价是复杂的迭代器结 构。也就是说除非必要,我们应该尽可能 的使⽤ vector
,⽽不是 deque
。
那么我们回过来具体说 deque
是如何做到维护整体连续的假象的, deque
采⽤⼀块所谓的 map
作为主控,这⾥的 map
实际上就是⼀块大小连续的空间,其中每⼀个元素,我们称之为 节点 node
,都指向了另⼀段连续线性空间称为缓冲区,缓冲区才是 deque
的真正存储空间主体。SGI STL
是允许我们指定 缓冲区的⼤⼩的,默认 0
表示使⽤ 512bytes
缓冲区。当 map
满载 时,我们选⽤ ⼀块更⼤的空间来作为 map
,重新调整配置。deque
另外⼀个关键的就是它的 iterator
的设计,deque
的 iterator
中有四个部分,cur
指向缓冲区现⾏元素,first
指向缓冲区 的头,last
指向缓冲区的尾(有时会包含备⽤空间),node
指向管控中⼼。所以总结来说,deque
的数据结构中包含了,指向第⼀个节点的iterator start
, 和指向最后⼀个节点的 iterator finish
,⼀块连续空间作为主控 map
,也需要记住 map
的⼤⼩,以备判断何时配置更⼤的 map
。
7、stack
是⼀种先进后出的数据结构,只有⼀个出⼝,stack 允许从最顶端新增元素,移除最顶端元素,取得最顶端元素。deque
是双向开⼝的数据结构,所以使⽤ deque 作为底部结构并封闭其头端开⼝,就形成了⼀个 stack。
8、queue
是⼀种先进先出的数据结构,有两个出⼝,允许从最底端加⼊元素,取得最顶端元素,从最底端新增元素,从最顶端移除元素。deque
是双向开⼝的数据结构,若以 deque 为底部结构并封闭其底端的出⼝,和头端的⼊⼝,就形成了⼀个 queue
。(其实 list
也可以实现 deque
)
9、heap
堆并不属于 STL
容器组件,它是个幕后英雄,扮演 priority_queue
的助⼿,priority_queue
允许⽤户以任何次序将任何元素推⼊容器内,但取出时⼀定是从优先权最⾼(数值最⾼)的元素开始取。大根堆(binary max heap
)正具有这样的性质,适合作为 priority_queue
的底层机制。大根堆,是⼀个满⾜每个节点的键值都⼤于或等于其⼦节点键值的⼆叉树(具体实现是⼀个vector
,⼀块连续空间,通过维护某种顺序来实现这个⼆叉树),新加⼊元素时,新加⼊的元素要放在最下⼀层为叶节点,即具体实现是填补在由左⾄右的第⼀个空格(即把新元素插⼊在底层 vector
的 end()
),然后执⾏⼀个所谓上溯的程序:将新节点拿来与 父节点⽐较,如果其键值比父节点⼤,就父子对换位置,如此⼀直上溯,直到不需要对换或直到根节点为⽌。当取出⼀个元素时,最大值在根节点,取走根节点,要割舍最下层最右边的右节点,并将其值重新安插⾄最⼤堆,最末节点放⼊根节点后,进⾏⼀个下溯程序:将空间节点和其较⼤的节点对调,并持续下⽅,直到叶节点为⽌。
10、priority_queue
底层时⼀个 vector
,使⽤ heap
形成的算法,插⼊,获取 heap
中元素的算法,维护这个vector
,以达到允许⽤户以任何次序将任何元素插⼊容器内,但取出时⼀定是从优先权最⾼(数值最高)的元素开始取的⽬的。
slist
:STL list
是⼀个双向链表, slist
是⼀个单向链表。
11、map 和 set 有什么区别,分别⼜是怎么实现的?
map
和 set
都是 C++ 的关联容器,其底层实现都是红⿊树(RB-Tree
)
由于 map
和 set
所开放的各种操作接⼝,RB-tree
也都提供了,所以⼏乎所有的 map
和 set
的操作⾏为,都只是转调 RB-tree
的操作⾏为。
区别在于:
map
中的元素是key-value
(关键字—值)对:关键字起到索引的作⽤,值则表示与索引相关联的数据;Set
与之相对就是关键字的简单集合,set
中每个元素只包含⼀个关键字。set
的迭代器是const
的,不允许修改元素的值;map
允许修改value
,但不允许修改key
。其原因是因为map
和set
是根据关键字排序来保证其有序性的,如果允许修改key
的话,那么⾸先需要删除该键,然后调节平衡,再插⼊修改后的键值,调节平衡,如此⼀来,严重破坏了map
和set
的结构,导致iterator
失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以STL
中将set
的迭代器设置成const
,不允许修改迭代器的值;而map
的迭代器则不允许修改key
值,允许修改value
值。map
⽀持下标操作,set
不⽀持下标操作。map
可以⽤key
做下标,map
的下标运算符[ ] 将关键码作为下标去执行查找,如果关键码不存在,则插⼊⼀个具有该关键码和mapped_type
类型默认值的元素⾄map
中,因此下标运算符[ ]在map
应⽤中需要慎用,const_map
不能⽤,只希望确定某⼀个关键值是否存在⽽不希望插⼊元素时也不应该使⽤,mapped_type
类型没有默认值也不应该使⽤。如果find
能解决需要,尽可能⽤find
。
12、说一说 STL 迭代器删除元素
这个主要考察的是迭代器失效的问题。对于序列容器 vector
,deque
来说,使⽤ erase(itertor)
后,后边的每个元素的迭代器都会失效,但是后边每个元素都会往前移动⼀个位置,但是 erase
会返回下⼀个有效的迭代器;
对于关联容器 map set
来说,使⽤了 erase(iterator)
后,当前元素的迭代器失效,但是其结构是红⿊树,删除当前元素的,不会影响到下⼀个元素的迭代器,所以在调⽤ erase
之前,记录下⼀个元素的迭代器即可。
对于 list
来说,它使⽤了不连续分配的内存,并且它的 erase
方法也会返回下⼀个有效的iterator
,因此上⾯两种正确的⽅法都可以使⽤。
13、reserve和resize的区别?
resize()
:改变当前容器内含有元素的数量(size())
,eg: vector v; v.resize(len);
v
的size
变为len
,如果原来v
的size
⼩于len
,那么容器新增(len-size)
个元素,元素的值为默认为0
.当v.push_back(3);
之后,则是3
是放在了v
的末尾,即下标为len
,此时容器是size
为len+1
;reserve()
:改变当前容器的最⼤容量(capacity)
,它不会⽣成元素,只是确定这个容器允许放⼊多少对象,如果reserve(len)
的值⼤于当前的capacity()
,那么会重新分配⼀块能存len
个对象的空间,然后把之前v.size()
个对象通过copy constructor
复制过来,销毁之前的内存;
14、size和capacity的区别
size
表示当前vector
中有多少个元素(finish – start)
,而capacity
函数则表示它已经分配的内存中可以容纳多少元素(end_of_storage – start)
。
15、说⼀下 STL 中迭代器的作⽤,有指针为何还要迭代器
迭代器
Iterator
(迭代器)模式⼜称 Cursor
(游标)模式,⽤于提供⼀种⽅法顺序访问⼀个聚合对象中各个元素, ⽽⼜不需暴露该对象的内部表示。或者这样说可能更容易理解:Iterator
模式是运⽤于聚合对象的⼀种模式,通过运⽤该模式,使得我们可以在不知道对象内部表示的情况下,按照⼀定顺序(由iterator
提供的⽅法)访问聚合对象中的各个元素。
由于Iterator
模式的以上特性:与聚合对象耦合,在⼀定程度上限制了它的⼴泛运⽤,⼀般仅
⽤于底层聚合⽀持类,如STL
的list、vector、stack
等容器类及ostream_iterator
等扩展iterator
。
迭代器和指针的区别
- 迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的⼀些功能,通过重载了指针
的⼀些操作符,->、、++、--
等。迭代器封装了指针,是⼀个“可遍历STL容器内全部或部分元素”的对象, 本质是封装了原⽣指针,是指针概念的⼀种提升,提供了比指针更⾼级的⾏为,相当于⼀种智能指针,他可以根据不同类型的数据结构来实现不同的++,--
等操作 - 迭代器返回的是对象引⽤⽽不是对象的值,所以cout只能输出迭代器使⽤取值后的值⽽不能
直接输出其⾃身。
迭代器产生原因
Iterator
类的访问⽅式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果。
4、C++11新特性
C++11 的特性主要包括下⾯⼏个⽅⾯:
- 提⾼运⾏效率的语⾔特性:右值引⽤、泛化常量表达式
- 原有语法的使⽤性增强:初始化列表、统⼀的初始化语法、类型推导、范围
for
循环、Lambda
表达式、final
和override
、构造函数委托 - 语⾔能⼒的提升:空指针
nullptr
、default
和delete
、⻓整数、静态assert
- C++ 标准库的更新:智能指针、正则表达式、哈希表等