面试速通宝典——3
51. 野指针和内存泄漏是什么?如何避免?
内存泄漏:是指程序中以动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
避免:使用智能指针管理资源,在释放对象数组时使用delete,尽量避免在堆上分配内存。
野指针:指向一个已经删除的对象或未申请访问受限内存区域的指针。
避免:对指针进行初始化。用已合法的可访问内存地址对指针初始化,指针用完释放内存,将指针赋值nullptr
解释:
内存泄露确实是编程中一个常见且严重的问题,特别是在使用如C++这样的手动内存管理的语言中。内存泄漏发生在程序动态分配(通过new或malloc)给某个指针的内存,在使用完毕后未能释放(通过delete或free)。如果未释放的内存也无法被访问,则会无法在程序的其余部分将其释放。添加到此,程序再次请求内存,这会消耗更多的内存资源,最终可能导致资源耗尽甚至系统崩溃。
对于避免内存泄漏,你的建议是准确的:
- 使用智能指针管理资源:智能指针,如unique_ptr, shared_ptr, weak_ptr,被用作对象的守护者,在智能指针的生命周期结束时,它们自动删除关联的原始指针,防止内存泄漏。
- 不要忘记在释放对象数组时使用delete []:delete和delete []不同,后者被设计用来释放动态分配的数组。如果你有一个通过new []动态分配的数组,你必须使用delete []去释放它。
- 尽量避免在堆上分配内存:虽然有时候你必须动态分配内存,在可能的情况下尽量减少使用。如果可以的话,试图使用栈上的对象或集合类型,如std::array或std::vector,这些类型在超出范围时自动清理他们的元素。
野指针是指向一个已经释放内存地址或一个未成功分配的内存地址的指针,这些指针的行为是未定义的,而且它们的使用常常会导致程序崩溃。
在使用指针期间,很重要的一点是尽量减少潜在的出错点和程序的复杂性。避免野指针的一些常见方法包括:
- 对指针进行初始化:不初始化的指针变量会导致一个运行时错误。它会持有一个随机的与机器环境有关的值,这意味着你不能预期它的行为。因此,应始终在声明指针时对其进行初始化。
- 用已合法的可访问内存地址对指针初始化:这确保你的指针在使用之前总是指向一个你可以访问且由你管理的内存地址。
- 指针用完释放内存:当你完成使用一个动态分配的内存块后,应该立刻释放它以防止内存泄漏。然而,重要的一点是,删除内存区域之后不要再次使用原指针。
- 将指针赋值为nullptr:删除一个指针后,立即将其设为nullptr是一个很好的实践。这可以防止你不小心访问之前释放的内存区域。因为在尝试访问null指针时,大多数系统都会立即产生一个错误并终止程序,这快速的失败可以让你立刻发现问题。
52. malloc和new的区别是什么?
- malloc/free是标准库函数 , new/delete是C++运算符。
- malloc分配内存失败返回空 , new失败抛异常
- new/delete会调用构造析构函数,malloc/free不会,所以他们无法满足动态对象的要求
- new返回有类型的指针,malloc返回无类型的指针
- 分配内存的位置:malloc从堆上动态分配内存,new是从自由存储区为对象动态分配内存(取决于operator new的实现,可以为堆还可以是静态存储区)
申请内存的步骤:
- new:调用oeprator new函数,分配一块足够大的、且原始的、未命名的内存空间来存储特定类型的对象。运行相应的构造函数来构造对象,并为其传入初值,返回一个指向该对象的指针。
- delete:先调用对象的析构函数,再调用operator delete函数释放内存空间。
53. 多线程会发生什么问题?线程同步有哪些手段?
会引发资源竞争的问题,频繁上锁会导致程序运行效率低下,甚至会导致发生死锁。
线程同步手段:使用atomic原子变量,使用互斥量也就是上锁,使用条件变量或信号量制约对共享资源的并发访问。
解释:
当多个线程并发访问同一资源时,无论是读取还是写入,如果没有合适的同步手段,都可能引发数据的不一致性和竞态条件。为了解决这个问题,我们通常可以使用以下几种常见的线程同步机制,其中你提到的都包括在内:
- 原子操作:原子操作是以不可分割的方式执行的操作。也就是说,执行原子操作的线程在操作过程中不会被调度器打断。在C++中,我们可以使用
std::atomic
类或者具有原子性的内置操作来实现原子操作。 - 互斥量(Mutex):互斥量是一种同步手段,它可以保证同一时刻只有一个线程能够访问特定的资源。在C++中,我们可以使用
std::mutex
类来创建互斥量。 - 条件变量:条件变量是另一种同步手段,它允许线程在满足特定条件时才能访问特定资源。这可以通过
std::condition_variable
类来实现。 - 信号量:信号量是一种计数的同步手段,它允许一定数量的线程同时访问某个资源。信号量通常被用于保护线程池,连接池等资源,它可以限制同时访问这些资源的最大线程数。
54. 什么是STL?
他是C++标准库的重要组成部分,不仅是一个可复用的组件库
也是一个包含了数据结构与算法的软件架构
,它拥有六大组件分别是:仿函数、算法、迭代器、空间配置器、容器、配接器。
解释:
- 容器(Containers): STL中的容器是一种数据结构,可以存储各种类型的数据。例如,数组,链表,栈,队列,哈希表等。
- 算法(Algorithms): STL包含了许多通用的算法,例如,排序,查找,拷贝,修改等。这些算法被设计成可以在任何容器上工作。
- 迭代器(Iterators): 迭代器提供了一种统一的访问容器元素的方法。你可以把迭代器看作一个指向容器元素的指针。
- 仿函数(Functors): 仿函数(也叫函数对象)是一个行为类似函数的对象。你可以像调用函数一样来调用仿函数。仿函数通常与STL算法结合使用。
- 适配器(Adapters): STL适配器提供了一种方式来修改或扩展容器,算法或迭代器的行为。
- 内存分配器(Allocators): STL内存分配器定义了如何分配和回收STL容器使用的内存。
55. 对比迭代器和指针的区别?
迭代器不是指针,是一个模板类,通过重载了指针的一些操作符模拟了指针的一些功能,迭代器返回的是对象引用而不是对象的值。
指针能够指向函数而迭代器不行,迭代器只能指向容器。
解释:
首先,如你所说,迭代器本质上是一个模板类,它比指针更加复杂。迭代器包含了更多的信息,例如迭代器类别(输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器)等,这些类别对其可用操作有所限制。
其次,迭代器通常返回对象引用,这意味着你可以修改它所指向的值,有些迭代器甚至可以运行在那些不支持原生指针操作的容器上,例如链表(list)和关联容器(如set和map)。
最后,正如你所说,迭代器仅用于指向容器中的元素,它不可以指向函数或其他非容器类型。这是因为迭代器被设计为在容器中遍历元素,而不是像指针那样可以广义地指向内存中的任意地址。
因此,尽管迭代器在表现上与指针类似,但更重要的是,它们为数据的抽象和操作提供了一种一致且安全的接口。
56. 线程有哪些状态 , 线程锁有哪些?
五种状态:创建,就绪,运行,阻塞,死亡
线程锁的种类:互斥锁、条件锁、自旋锁、读写锁、递归锁
解释:
线程的五种状态:
- 创建:线程资源被分配,但还没有开始执行。
- 就绪:线程被调度到了就绪队列,等待系统分配处理器。
- 运行:线程获得CPU,开始执行代码。
- 阻塞:线程在等待某些事件(例如I/O操作或者锁)发生而暂停执行。
- 结束:线程已完成任务并退出。
线程锁,也就是用于同步多线程间对共享资源访问的机制。你列举的线程锁的种类几乎涵盖了所有,这里我详细点一下:
-
互斥锁:用于保护共享资源,一次只允许一个线程访问共享资源。
-
条件锁(条件变量):允许线程在某个条件尚未满足时候进入睡眠,满足条件时候唤醒。
-
自旋锁:如果无法立刻获取锁,线程会一直在一个循环中请求锁,直到获取为止。通常在锁持有时间很短的情况下使用。
-
读写锁:区分读操作和写操作。多个线程可以同时进行读操作,但是写操作会阻塞其他的所有操作(读和写)。
-
递归锁:允许一个线程多次获取同一个锁,减少死锁的可能性。
57. 解释说明一下map和unordered_map
Map内部实现是一个红黑树,内部所有的元素都是有序的,而hashmap则是内部实现了一个哈希表,内部存储元素是无序的。
Map优点:有序性,其次是内部实现的是一个红黑树,使得很多操作都可以在logn的复杂度下可以实现效率较高。
Map缺点:空间占用率高。
unordered_map优点:查找效率高。
unordered_map缺点:哈希表的建立比较费时间。
解释:
map是一种基于红黑树实现的关联数组,它存储的键值对是有序的。红黑树保证了每次插入和删除都是对数时间复杂度,从而使整体性能很好。然而,由于红黑树是一种比较复杂的数据结构,所以map相比于unordered_map来说占用的空间会比较大。
另一方面,unordered_map是基于哈希表的。它的主要优点是查找时间复杂度通常可以达到常数级别,这使得它在处理大量数据时非常高效。然而,哈希表的建立和维护需要花费一定的时间,特别是当发生哈希冲突时,可能需要更复杂的冲突解决策略。此外,由于unordered_map的元素是无序的,所以如果需要对数据进行排序或频繁的范围查询,map可能是更好的选择。
58. vector中的push_back()和emplace_back()的区别、以及使用场景
当使用push_back时会先调用类的有参构造函数创建一个临时变量,再将这个元素拷贝或者移动到容器之中。
而emplace_back()则是直接在容器尾部进行构造,比push_back少一次构造函数的调用。
在大部分场景中emplace_back()可以替换push_back(),但是push_back()会比emplace_back()更加安全,emplace_back()只能用于直接在容器中构造新元素的情况,如果将现有的对象添加到容器中则需要使用push_back()。
解释:
就像解释的那样,push_back()和emplace_back()的主要区别在于它们处理对象构造的方式。push_back()通常在向容器添加对象时创建一个新的对象实例,然后把它复制或移动到容器中。相反,emplace_back()则尝试在容器里直接构造对象,省去了创建临时对象和复制/移动的过程。
因此,当处理那些构造、复制或者移动成本很高的对象时,在性能上,emplace_back()通常比push_back()更有优势。特别是当元素的构造函数接受多个参数的时候,使用emplace_back()可以避免写出繁琐的构造临时对象的代码。
然而,push_back()确实在某些情况下更安全。因为emplace_back()能隐式转换类型,比如如果有一个容器是装int的,如果你用emplace_back()向里面添加一个double类型的数据,编译器会隐式把这个double类型的数据转换为int,可能会造成不可预测的错误。此外,push_back()也更适用于需要添加已存在的对象到容器中的场景。
59. 如何实现线程安全,除了加锁还有没有其他的方式?
除了锁之外还可以使用互斥变量(防止多个线程来同时访问共享资源,从而避免数据竞争问题),原子操作(原子操作是不可分割的,使用原子操作可以确保在多线程环境下操作是安全的),条件变量(协调线程之间的协作,用来在线程之间传递信号,从而控制线程的执行流程)等方式。
60. 用vector扩容,resize和reserve的区别
使用resize改变的是vector的大小(size),可能会添加或删除元素。
使用reserve改变的是vector的容量(capacity),不会改变当前元素的值,仅仅是为了优化内存使用和性能。
解释:
resize()和reserve()都是用于改变vector的大小,但它们的作用方式并不相同。
resize()函数会改变vector的size,也就是它的元素数量。如果新的size比当前大,那么在vector的尾部会添加上默认值或者指定的值。如果新的size小于当前size,那么vector尾部超出的元素会被删除。
相反,reserve()函数并不改变vector的size,而是改变它的容量,即vector在必须分配更大的内存空间以前可容纳的元素数量。当我们用reserve增加vector的容量时,其目的是为了防止在进行连续插入操作时出现多次内存的分配和拷贝。
举例来说,如果你有一个空的vector,然后执行reserve(100),那么这个vector的容量就是100,但它的大小仍然是0,因为实际上并没有任何元素被添加进来。如果之后你再进行100次push_back操作,由于已经预留了足够的空间,所以这些操作都能立即完成,不需要做任何内存分配和元素拷贝。这使得程序的效率得以提高。
61. vector扩容为了避免重复扩容做了哪些机制?
当vector内存不够时本身内存会以1.5或者2倍的增长,以减少扩容次数。
引入了reserve,自定义vector最大容量。
62. C++中空类的大小是多少?
1字节
63. weak_ptr是怎么实现的?
实现依赖于计数器和寄存器实现的,计数器用来记录弱引用的数量,寄存器用来存储shared_ptr
解释:
weak_ptr 的设计是以引用计数器和一个指向共享对象的寄存器为基础的。
在 C++ 中,智能指针(如 shared_ptr 和 unique_ptr)负责自动管理对象的生命周期,以避免手动分配和释放内存。然而,这种机制可能导致循环引用问题,尤其是当有两个或多个对象互相引用时。为了解决这个问题,引入了 weak_ptr。
weak_ptr 是一个智能指针类型,它指向一个由 shared_ptr 所管理的对象,但是对于对象的引用计数不会增加。这意味着,即便所有的 shared_ptr 都已经被销毁,只要还有至少一个 weak_ptr,那么这个对象的内存地址不会被立刻回收,只是无法再被访问。
具体来说,weak_ptr 的实现过程如下:
- 一个 weak_ptr 基于一个 shared_ptr 创建
- weak_ptr 将 shared_ptr 内的弱引用计数加一(weak reference count)
- 当最后一个 shared_ptr 被析构后,对象的数据部分被清理掉,但是控制块(包括弱引用计数和对象自身)仍保留
- 当最后一个 weak_ptr 被析构,或者一个 weak_ptr 试图访问一个已经被 shared_ptr 清理的对象时,weak_ptr 会将弱引用计数减一。如果弱引用计数降为 0,那么对象的控制块也会被清理掉,对象彻底被清理干净
- weak_ptr 不会直接提供对对象的访问权,为了访问对象,必须先将 weak_ptr 转换为 shared_ptr,如果对象已经被清理,转换操作会得到一个空的 shared_ptr。
总的来说,weak_ptr 是一种弱引用,它不会影响对象的生命周期,可以用来解决循环引用的问题。它的实现依赖于引用计数器和一个与 shared_ptr 共享的寄存器。
64. 虚函数的底层原理是什么?
虚函数表和虚指针,详细看第四问。[[#^9383bd|虚函数的实现]]
65. 一个函数f(int a , int b),其中a和b的地址关系是什么?
a和b的地址是相邻的。
解释:
在函数f(int a, int b)中,形式参数a和b在栈中的地址通常是相邻的。然而,它们之间的具体地址关系(也就是说,哪个参数的地址更高或更低)可能取决于操作系统和编译器的具体实现。
在许多主流的编译器和操作系统中,如gcc和Windows,函数的参数在栈上以从右到左的顺序被压入。这应该意味着b的地址高于a的地址。
然而,这是一种实现细节,可能会因不同的操作系统和编译器而变化。除非你正在编写依赖于特定平台特性的低级代码,否则通常不建议编写依赖于此类内存布局细节的代码。
66. 移动构造和拷贝构造的区别是什么?
移动构造函数本质上是基于指针的拷贝,实现对堆区内存所有权的移交,在一些特定场景下,可以减少不必要的拷贝。比如用一个临时对象或者右值对象初始化类实例时,我们可以使用move函数,将一个左值对象转变为右值对象。
而拷贝构造则是将传入对象复制一份然后放进新的内存中。
解释:
- 拷贝构造函数:当我们创建一个新对象时,如果该对象是通过另一个已存在的对象初始化的,那么拷贝构造函数就会被调用。拷贝构造函数会将原对象的所有属性一一复制到新的对象。这种复制是值复制,也就是说新旧两个对象虽然属性值相同,但是它们在内存中的地址是不同的,互不影响。
- 移动构造函数:和拷贝构造函数相比,移动构造函数不是复制原有对象的值,而是“窃取”原有对象的资源(例如内存等),而原对象则会被置入一个安全的、析构的状态。例如,原对象是一个动态数组,则移动构造后,原数组将不再拥有这些动态内存,而新对象则接管了这部分内存的所有权。这样就避免了不必要的复制操作,大大提高了效率。
需要注意的是,只有当源对象是一个右值,即临时对象或者已经被移动过的对象时,编译器才会自动使用移动构造函数。如果源对象是一个左值,那么编译器通常会使用拷贝构造函数,这是为了保护数据的安全性。而我们可以通过 std::move 显式告诉编译器我们要使用移动构造函数转移这个左值对象。
总而言之,拷贝构造和移动构造在语义上是不同的:拷贝构造的语义是复制,而移动构造的语义则是转移。
67. lamda表达式捕获列表,捕获的方式有哪些?如果是引用捕获需要注意什么?
按值捕获和引用捕获,默认的引用捕获可能会导致悬挂引用,引用捕获会导致闭包包含一个局部变量的引用或者形参的引用,如果一个由lamda创建的闭包的生命周期超过了局部变量或者形参的生命期,那么闭包的引用将会空悬。解决方法是对个别参数使用值捕获。
解释:
lambda 表达式的捕获列表主要有两种捕获方式:按值捕获和按引用捕获。我再来为你补充一些关于捕获方式的细节和注意事项:
-
按值捕获:这种方式会将外部变量在创建闭包(即 lambda)时进行拷贝,因此闭包对这些变量的使用实际上是对它们的拷贝进行操作。这样得到的闭包内部的数据是独立的,不会影响外部变量的状态。使用方式是
[=]
。 -
按引用捕获:这种方式会将外部变量以引用的方式在创建闭包时进行捕获,并且闭包对iil这些变量的使用实际上是直接对它们进行操作。这样得到的闭包内部的数据和外部的数据是同一个,互相会进行影响。使用方式是
[&]
。
关于按引用捕获,最需要注意的就是你已经提到的悬挂引用问题。正是由于闭包对变量的引用直接影响了变量本身,如果闭包的使用生命周期超过了其引用的外部变量的生命周期,那么当闭包在后续使用这个变量时,就会出现悬挂引用,引发未定义行为。这种情况常常出现在闭包被作为函数返回值返回,或者被长期存储在某个数据结构中的场合。解决此类问题的最常见方法就是你提到的:仅对需要长期存储的变量使用按值捕获,对生命周期较短或者仅在创建闭包时使用的变量,仍然可以使用按引用捕获。
#include <iostream>
// 在这个函数中,我们创建了一个闭包,也就是一个lambda表达式。
// 这个闭包会捕获变量x,并通过引用进行存储和自增。
// 注意到这个函数返回的是一个函数,即返回的是我们创建的lambda表达式函数对象
std::function<int()> create_counter(int x) { return [&x]() { return ++x; }; } //按值捕获
//std::function<int()> create_counter(int &x) { return [&x]() { return ++x; }; } //按引用捕获int main() {int k = 2;auto counter = create_counter(k); std::cout << counter() << std::endl; // 输出3std::cout << counter() << std::endl; // 输出4 std::cout << counter() << std::endl; // 输出5 cout << k << endl; // 输出2return 0;
}
在这个代码中,create_counter
函数返回了一个捕获了变量x的lambda表达式。该lambda表达式定义了一个计数器,可以自增x并返回结果。
即使create_counter
函数的调用已经完成,闭包依然可以访问并修改x。
这就是闭包的魔力–在函数作用域外部可以持久保存函数内部的状态。在定义闭包的地方和使用闭包的地方,你都可以访问和修改这些捕获的变量。
68. 哈希碰撞的处理方法
- 开放地址法 : 当遇到哈希冲突时,去寻找一个新的空闲的哈希地址。
- 再哈希法 : 同时构造多个哈希函数,等发生哈希冲突时就使用其他哈希函数直到不发生冲突为止,虽然不易发生聚集,但是增加了计算时间。
- 链地址法 : 将所有的哈希地址相同的记录都链接在同一链表中。
- 建立公共溢出区 : 将哈希表分为基本表和溢出表,将发生冲突的都存放在溢出表中。
解释:
- 开放地址法:这种方法具有直观性和简单性。一旦原本的哈希地址被占据,就会去寻找下一个可用的地址。这种方法容易造成“聚集”,即连续的内存空间被连续的元素占据,这会使得搜索时间增加。
- 再哈希法:当哈希冲突发生时,使用另一个哈希函数。这种方法可以减少聚集的发生,但如你所说的,多个哈希函数会带来额外的计算成本。
- 链地址法:冲突的元素会被存在同一链表也是一种常见的处理冲突的方法。每个链表都对应一个哈希地址,这样可以在一定程度上减少查找时间。
- 建立公共溢出区:这种方法分离了主哈希区域和冲突区域,主哈希区域存放未冲突的元素,而冲突的元素存放在另一块专门的区域里。这种方法的优点是可以方便地处理冲突,并且插入和查找的速度相对较快。当然,它的缺点就是需要额外的空间来存储冲突的元素。
69. unordered_map的扩容过程
当unordered_map中的元素数量达到了桶的负载因子(0.75) 时,会重新分配桶的数量(通常会按照原有桶的数量* 2 的方式进行扩容,但是具体的增长策略也可以通过修改容器中的max_load_factor成员变量来进行调整),并将所有的元素重新哈希到新的桶中。
解释:
在C++中,无序映射(unordered_map
)在插入新元素导致容器大小超过负载因子(默认为0.75)乘以桶数时,会进行重新哈希,这个过程也就是我们通常说的扩容。
在重新哈希过程中,容器会选择一个新的更大的散列桶(通常是原来的两倍),然后它会重新计算每个元素的散列值,并将它们放入新的散列桶中。从理论上讲,这将保持数据的均匀分布,减少冲突,并使查找效率保持在常数时间复杂度。
但是,需要注意的是,重新哈希是一个代价较高的操作,因为它通常涉及具有大O(n)复杂度的动态内存分配和元素的复制。因此,在构造unordered_map
时,如果你对将要插入的元素数量有预期,可以提前使用reserve
函数来预留更多的空间,避免在插入过程中触发多次重新哈希,从而提高性能。
这里有一段简单的代码演示了扩容过程:
#include <unordered_map>
#include <iostream>int main() {std::unordered_map<int, int> map;std::cout << "Initial bucket count: " << map.bucket_count() << "\n";for (int i = 0; i < 100; ++i)map[i] = i;std::cout << "Bucket count after insertion: " << map.bucket_count() << "\n";return 0;
}
这段代码首先打印出初始的桶数,然后在映射中插入100个元素,并再一次打印出桶数。在大多数平台上,你会看到桶数在插入元素后有增长,这就是扩容过程。
70. vector如何判断应该扩容?(size和capacity)
由当前容器内元素数量的大小和容器最大大小进行比较,如果二者相等就会进行扩容,一般是1.5倍,部分的2倍。
71. 构造函数是否能声明为虚函数?为什么?什么情况下为错误?
构造函数不能声明为虚函数,虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的vptr指针指向,该指针存在对象的内部空间里,需要调用构造函数完成初始化,如果构造函数为虚函数,那么调用构造函数就需要去寻找vptr,但此时vptr还没有完成初始化,导致无法构造对象。
72. 类中static函数是否能声明为虚函数?
不能。因为类中的static函数是所有类实例化对象所共有的,没有this指针,而虚函数依靠vptr和vtable来处理,vptr是一个指针,在类的构造函数中生成,并且只能通过this指针访问,对于静态成员函数来说,他没有this指针,无法访问vptr,因此static函数无法声明为虚函数。
解释:
首先,我们要明白静态成员函数和普通成员函数的差异。静态成员函数是所有该类实例共享的,他们不操作类的实例变量,且能够在没有类实例的情况下调用。因此,静态成员函数没有this指针。
然而,虚函数被设计为通过对象实例的this指针访问,它用于支持多态,使得我们可以通过基类指针调用派生类的实现。它依赖于vptr(指向虚函数表vtable的指针)。vptr是在类的构造函数中创建的,并且只能通过this指针访问。由于静态成员函数没有this指针,因此它无法访问vptr,所以静态成员函数不能是虚函数。
73. 哪些函数不能被声明为虚函数?
构造函数、内联函数(内联函数有实体,在编译时展开,没有this指针)、静态成员函数、友元函数(C++不支持友元函数继承)、非类成员函数。
解释:
- 构造函数:虚函数依赖于对象,它们通过对象的虚表(vtable)来查找对应的函数实现,而在构造函数中,对象还在初始化过程中,还没有完全构建完成,虚表可能还没有正确设置,因此构造函数不能为虚函数。
- 静态成员函数:静态成员函数与特定的对象实例没有关联,它属于类而不是对象,因此无法使用虚函数的动态绑定特性。
- 内联函数:内联函数在编译的时候就已经被展开,它们没有像虚函数那样的动态绑定过程。
- 友元函数:友元函数不是类的成员,它们只是能够访问类的私有和保护成员,因此不能被声明为虚函数。
- 非类成员函数:既然不是类的成员,自然不能被声明为虚函数。
类和对象的关系:
当我们在编程语言中定义一个类时,我们实际上是在定义一个数据类型的蓝图或模板。类定义了数据类型的属性(即数据成员)以及该数据类型可以执行的操作(即成员函数或方法)。
然而,类本身并没有占用任何内存空间。只有当我们根据类创建对象实例时,系统才会为对象分配内存,我们可以把对象实例视作是类的一个具体实例,它包含了由类定义的所有属性和方法。
简单来说,类就像是一个图纸,而对象实例就像是根据这个图纸制造出来的产品。就比如,如果我们有一个“汽车”类,它定义了汽车的各种属性(如颜色、型号等)和操作(如开车、停车等)。然后,我们可以根据这个“汽车”类创建许多具体的汽车对象,比如一辆红色的BMW,一辆蓝色的奔驰等等,每一辆车就是一个对象实例。
以下是一段代码,定义了一个名为Car的类,并创建了一个该类的对象实例:
class Car(object):def __init__(self, color, brand):self.color = colorself.brand = branddef drive(self):print(f"A {self.color} {self.brand} is driving.")# 创建一个对象实例
my_car = Car("red", "BMW")
my_car.drive()
在这段代码中,我们首先定义了Car类,然后创建了一个Car类的对象实例my_car,然后调用了这个对象的drive()方法,输出“A red BMW is driving.”。
所以,类是对一类对象的抽象定义,而对象实例则是这个定义的具体化。
为什么静态成员函数不能使用虚函数的动态绑定特性?
当我们在类中声明一个函数为静态函数时,这个函数就成为了这个类的一部分,它和类的任何特定的对象实例都没有关系。静态函数是类的一个属性,而不是对象的属性。
静态函数只能直接访问类的静态成员变量或静态成员函数,它不能访问类的非静态成员变量或非静态成员函数,也就是说,它不能使用类的this指针。
虚函数的动态绑定或者叫多态是面向对象编程的一种特性,它允许我们通过基类指针或引用来调用派生类中重写的虚函数。这个特性主要用于实现不同的对象能以自己的方式来响应同一消息。但是,对于静态成员函数来说,由于它不与任何的对象实例相关联,因此无法使用动态绑定。
这就意味着,你不能将静态成员函数声明为虚函数来实现多态性。因为虚函数依赖于具体的对象以实现运行时的多态性,而静态成员函数并没有与任何对象实例绑定,它只能通过类来调用,不能通过对象来调用。
对于静态成员函数来说,它的调用通常都是已经确定的,无法在运行时进行动态的更改。而对于虚函数来说,它的调用直到运行时才能确定具体的调用哪个版本的函数。因此,静态成员函数无法使用虚函数的动态绑定特性。
74. 如何保证类的对象只能被开辟在堆上?(将构造函数声明为私有、单例)
将构造函数设置为私有,这样只有使用new运算符来建立对象,但是我们必须准备一个destory函数来进行内存的释放,然后将析构函数设置为protected,提供一个public的static函数来完成构造,类似于单例模式。
如果在栈上分配呢?则是重载new操作符,使得new操作符的功能为空,这样就使得外层程序无法在堆上分配对象,只可以在栈上分配。
解释:
- 在堆上:
-
将构造函数设为私有:这可以防止在栈上直接创建对象实例。
-
提供一个public的静态函数来完成构造:这个静态方法可以调用私有构造函数在堆上创建对象,并返回对象的指针。
-
将析构函数设置为protected:这一步一方面可以防止用户直接delete对象,另一方面保证派生类的析构函数可以被正确地调用。
-
提供一个public的destructor方法:在这个方法里可以正确删除在堆上创建的对象。
class HeapOnly {
private:HeapOnly() {} // 构造函数私有~HeapOnly() {} // 析构函数保护
public:static HeapOnly* createInstance() { // 公有的创建对象方法return new HeapOnly();}static void destructor(HeapOnly* instance) { // 公有的析构对象方法delete instance;}
};
- 在栈上:
关于只能在栈上创建对象,主要思路是禁止使用new操作符,可以通过以下方式实现:
- 将operator new重载为private:这样就不能在堆上使用new创建对象。
- 将析构函数设为私有:这样不允许在栈以外的地方创建对象,因为只有在栈上创建的对象,才能保证在作用域结束后自动调用析构函数。
- 提供一个public的destructor方法:在这个方法里可以正确删除在栈上创建的对象.
class StackOnly {
private:void * operator new(size_t size) = delete; // 禁止使用newvoid operator delete(void *p) = delete; // 禁止使用delete~StackOnly() {} // 析构函数私有
public:static void destructor(StackOnly* instance) { //公有的析构对象方法instance->~StackOnly();}
};
以上两种方法分别可以限制对象只能在堆上或者栈上创建,但是需要注意,这也带来了很大的不便,使用这些方法的同时也需要在设计上谨慎考虑。
75. 讲讲你理解的虚基类
虚基类是C++中的一种特殊的类,用于解决继承所带来的“菱形继承”问题。如果一个派生类同时从两个基类派生,而这两个基类又共同继承自同一个虚基类,就会形成一个“菱形”继承结构,导致派生类中存在两份共同继承的虚基类的实例,从而引发一系列问题。
为了解决这个问题,我们可以将虚基类作为共同基类,并在派生类中采用虚继承的方式。
虚继承会使得派生类中只存在一份共同继承的虚基类的实例,从而避免了多个实例之间的冲突。
虚基类是可以被实例化的。
解释:
例如,假设我们向Animal
类添加一个新的函数getCounter
,这个函数返回一个counter
实例变量。这个counter
变量在每次Animal
实例调用eat
函数时加一。
class Animal {
private:int counter;public:Animal() : counter(0) {}void eat() { counter++; }int getCounter() { return counter; }
};class Dog : public Animal {
public:void bark() { }
};class Cat : public Animal {
public:void meow() { }
};class Chimera : public Dog, public Cat {
};
现在,当我们创建一个Chimera
实例并调用eat
函数时,我们可能期望getCounter
返回1。但是实际上,因为Chimera
包含两个Animal
实例(一个来自Dog
, 另一个来自Cat
),每个Animal
实例都有自己的counter
实例变量,所以会出现问题。更糟糕的是,当我们调用getCounter
函数时,编译器会因为存在歧义而报错。
如果我们将Animal
定义为虚基类,那么Chimera
实例只会包含一个Animal
实例,于是就不会有这个问题了。当你调用eat
函数时,getCounter
将正常返回1,符合我们的预期。这解释了为什么我们在设计类的继承关系时需要考虑虚基类。
class Animal {
private:int counter;public:Animal() : counter(0) {}void eat() { counter++; }int getCounter() { return counter; }
};class Dog : virtual public Animal {
public:void bark() { }
};class Cat : virtual public Animal {
public:void meow() { }
};class Chimera : public Dog, public Cat {
};
虽然虚基类可以被实例化,但如果一个类被声明为虚类,那么这个类是不能被实例化的。
在C++中,抽象类(有时也被称为虚类)是至少包含一个纯虚函数的类。在C++中,我们可以这样定义一个纯虚函数:virtual void func() = 0;
。定义纯虚函数的目的是为了让派生类必须实现此函数。
一旦类中包含了纯虚函数,这个类就不能被实例化成一个对象。这种类我们通常称为抽象类。因为这个类通常定义了一种应该被其他类继承并实现具体功能的接口,所以它自己并不能创建出具体的对象实例。
class Animal {
public:virtual void eat() = 0; // 这是一个纯虚函数
};
在这段代码中,Animal就被定义为了抽象类(或虚类),我们不能直接实例化Animal类:
Animal animal; // 错误:不能实例化抽象类
我们需要创建一个Animal的子类,并在子类中实现eat函数,才能创建一个对象:
class Dog : public Animal {
public:void eat() override {// 实现eat函数}
};Dog dog; // 正确:可以实例化Dog类
总结来说就是:
-
虚基类(Virtual Base Class):当类被声明为虚基类时,意味着在任何从该类进一步派生的类中,无论这个类在派生类中被继承了多少次,只有一份基类的数据成员会被保留。虚基类的目的主要是解决多继承情况下的菱形继承问题。虚基类本身是可以实例化的,只要它没有包含纯虚函数。
-
抽象类:抽象类是包含至少一个纯虚函数的类。纯虚函数就像是一个占位符,它在基类中并没有具体的实现,但它要求任何一个从基类派生的类必须提供这个函数的实现。因为抽象类包含了不完全定义的函数,所以我们不能创建一个抽象类的实例,只能用抽象类作为基类,通过其派生类创建对象。
“虚基类可以被实例化”这句话指的应该是虚基类本身没有纯虚函数,所以它可以被实例化;“如果一个类被声明为虚类(抽象类),那么这个类是不能被实例化的”,这里的“虚类”应该指的是抽象类,它包含至少一个纯虚函数,因此不能被实例化。希望这能帮助你更好地理解。
76. C++哪些运算符不能被重载?
成员访问操作符、域解析操作符、条件运算符之类的不能被重载。
其中并不推荐对逗号运算符、逻辑或、逻辑与之类的运算符进行重载,容易造成歧义。
解释:
首先,让我们聊聊不能被重载的运算符。在C++中,.
(成员访问运算符)、.*
(通过对象指针访问成员的运算符)、::
(域解析运算符)、 ?:
(条件运算符)、sizeof
(获取数据类型或表达式结果大小的运算符)、typeid
(获取对象数据类型的运算符)以及#
(预处理运算符)是不能被重载的。
至于你提到的逗号运算符(,
)、逻辑与运算符(&&
)以及逻辑或运算符(||
),虽然这些运算符在C++中是可以被重载的,但实际上,我们并不推荐这样做,原因如下:
- 对这些运算符重载可能会对代码的可读性造成破坏,使得代码理解起来更加困难。
- 对这些运算符重载可能会混淆它们原有的逻辑(例如,逻辑与运算符本身意味着“而且”,逻辑或运算符代表“或者”,如果重载后逻辑完全改变,那么这无疑会使得代码阅读者十分困惑)。
- 如果对这些运算符进行重载,而重载的逻辑与原有的逻辑不一致,可能会产生错误的预期结果。
因此,对逗号运算符(,
)、逻辑与运算符(&&
)以及逻辑或运算符(||
)进行重载,需要在确保代码逻辑清晰明确,并且对项目没有负面影响的前提下,并且一定要确保其他的代码阅读者也能很好的理解你的代码。
77. 动态链接和静态链接的区别?动态链接的原理是什么?
区别:他们最大的区别就是在于链接得时机不同,静态链接是在形成可执行程序前,而动态链接得进行则是程序进行时。
静态库:就是将库中的代码包含到自己的程序中,每个程序链接静态库后,都会包含一份独立的代码,当程序运行起来时,所有这些重复的代码都需要占用独立的存储空间,显然很浪费计算机资源。
动态库:不会将代码直接复制到自己程序中,只会留下调用接口,程序运行时再去将动态库加载到内存中,所有程序只会共享这一份动态库,因此动态库也被称为共享库。
动态链接的原理:是把程序按照模块划分成各个相对独立部分,在程序运行时才将他们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。
解释:
静态链接指的是在编译时将所有的模块(包括主程序和所有库)合并在一起,生成一个独立的可执行文件。这个文件可以在没有任何外部依赖的情况下运行。
动态链接则是在程序运行时,才将链接到的库添加到内存中。这样的库被称为动态链接库(在Windows中是.dll文件,在Unix-like系统是.so文件)。
现在,我给一个基本的代码例子来解释。假设有一个简单的main
函数,它使用到了math
库中的sqrt
函数:
#include <math.h>
#include <iostream>int main() {double x = 9.0;double y = sqrt(x);std::cout << "The square root of " << x << " is " << y << std::endl;return 0;
}
对于静态链接,你在编译时可以指定链接器以静态方式链接到math
库(这取决于系统和编译器,很多情况下默认的是动态链接)。在这种情况下,sqrt
函数的代码会被包含在可执行文件中,因此之后即使在没有math
库的环境中也能运行。编译命令可能如下:
g++ -o main main.cpp -static -lm
对于动态链接,编译时只会存储sqrt
函数的引用,而不是函数本身的实现。当程序运行时,操作系统会找到相应的.so
或.dll
文件,并将其加载到内存中。此时如果找不到对应的库文件,则程序无法运行。编译命令可能如下:
g++ -o main main.cpp -lm
总的来说,静态链接产生的程序体积大,但在部署时简单,因为没有额外依赖;而动态链接产生的程序体积小,但运行时需要确保有相应的库文件。
78. C++中怎么编译C语言代码?
使用extern"C"
让C++代码按照C语言的方式去编译。
解释:
在C++中,如果含有C语言编写的文件或函数,我们是需要使用"extern “C”"来告诉编译器按照C语言的规则来编译和链接这些代码。
C++和C语言在函数名的处理上有不同。C++支持函数重载,所以编译器会把函数的参数类型等信息添加到函数名中(这一过程我们通常称之为"name mangling"或者"函数名改编"),以确保每个函数名都是唯一的。而C语言并不支持函数重载,所以它的函数名是不会被改编的。
当我们链接C和C++代码时,就需要确保这些C函数的函数名不要被C++编译器改编,以防止链接错误。"extern “C”"就是用于此目的的,它可以阻止C++编译器对其之后的部分进行函数名改编。
一个常见的使用场景是在C++代码中调用C库,比如:
extern "C" {
#include "my_c_library.h"
}
在这段代码中,"extern “C”"告诉编译器,"my_c_library.h"中所有的代码都不要进行name mangling,即按照C语言规则进行编译和链接。
另一个常见的使用"extern “C”"的场景是C++代码中定义了一个C函数
extern "C" void foo() {// 这个函数会按照C语言规则进行编译和链接
}
在这段代码中,尽管foo()函数是在C++代码中定义的,但是由于"extern “C”"的存在,foo()函数的函数名不会被C++编译器改编。
这就是C++中如何编译C代码,以及"extern “C”"的基本用法。
79. 未初始化的全局变量和初始化的全局变量放在哪里?
初始化的全局变量存放在数据段,数据段数据静态分配。
未初始化的全局变量存放在BSS(Block Started By Symbol)段,属于静态内存分配
解释:
在C和C++中,全局变量在内存中的存储位置取决于它们是否被初始化。具体来说:
- 初始化的全局变量:这些变量会被存放在数据段(Data Segment)。这部分内存用于存储程序的全局变量和静态变量,并在程序启动时分配。数据段中的变量在程序运行期间的位置是固定的。
- 未初始化的全局变量:这些变量会被存放在BSS段。BSS是Unix系统源代码的一部分,指的是在程序执行前被系统初始化为零或空指针的数据段。BSS段中的变量从概念上讲是静态分配内存的,它们在程序运行期间的位置也是固定的。
int g_var1; // 未初始化的全局变量,将被放在BSS段
int g_var2 = 0; // 初始化的全局变量,将被放在数据段
80. 说一下内联函数及其优缺点
内联函数是在编译期将函数体内嵌到程序中,以此来节省函数调用的开销。
优点:是节省了函数调用的开销,让程序运行的更加快速
缺点:是如果函数体过长,频繁地使用内联函数会导致代码编译膨胀问题,不能递归执行
解释:
内联函数虽然能提升程序的效率,但使用时确实需要一些注意。
优点:
- 提高执行效率。无需函数调用过程,省去了参数传递、返回地址计算等操作开销。
- 优化了局部变量的处理。一般函数的局部变量在栈中,需要入栈出栈。内联函数的局部变量直接 集成在原函数栈帧中,无需单独处理。
缺点:
- 内联函数会被编译器展开成了多份,如果函数体过大,使用过多内联函数会导致编译后的代码膨胀,消耗更大的内存资源。
- 若在内联函数中做递归操作,会无法进行展开,从而无法做内联优化。
- 编程复杂性增加。需要程序员明确指定内联,而且内联过程还可能出现函数声明和实现不一 致,导致编译错误。
特别注意,C++中的inline只是建议编译器进行内联,实际内联与否还要看编译器的决策,编译器还会考虑函数体的复杂度和调用频次等条件。如果函数体比较复杂,或者进行了递归调用等操作,编译器可能无视inline指示而不进行内联。对于简单的函数进行内联能够明显优化性能,但对于复杂的、大型的、递归的函数,内联的效果可能并不会很好。
81. C++11中的auto是怎么实现自动识别类型的?模板是怎样实现转化成不同类型的?
auto
仅仅只是一个占位符,在编译期间它会被真正的类型替代,或者说C++中变量必须要有明确的类型的,只是这个类型是由编译器自己推导出来的。
函数模板是个蓝图,她本身并不是函数,是编译器用使用方式具体类型函数的模具,所以模板其实就是将原本应该我们做重复的事交给了编译器。
解释;
首先,我们定义一个函数模板,该模板实现了对任意类型的两个值进行比较的操作:
template <typename T>
bool isEqual(T a, T b) {return a == b;
}
在这段代码中,template <typename T>
这行表明我们正在定义一个模板,“T”则是我们的模板类型参数。
然后,我们在函数中使用这个模板参数T。C++编译器就像一个“代码生成机”,接下来自动会根据所提供的具体类型来生成代码。
比如在主函数中实例化模板:
int main() {int a = 5;int b = 5;bool result = isEqual(a, b); // 使用int类型实例化模板cout << "Result is " << (result?"true":"false") << endl;string str1 = "Hello";string str2 = "Hello";result = isEqual(str1, str2); // 使用string类型实例化模板cout << "Result is " << (result?"true":"false") << endl;return 0;
}
当编译器遇到isEqual(5, 5)这样的实例化请求,它看到你传入了两个int类型的变量,于是它就会生成一个处理int
类型参数的函数。同样地,当它看到isEqual(“Hello”, “Hello”),它就会生成一个处理string
类型参数的函数。
这是函数模板的一个基本示例,通过它,我们可以看到模板的工作方式:它们允许我们定义通用的模板,在实例化时根据提供的类型生成对应的代码。
82. map和set的区别和底层实现是什么?map取值的find,[],at方法的区别(at有越界检查的功能)
都是红黑树,
find查找需要判断返回的结果才知道有没有成功。
[]不管有没有就是0,如果原先不存在该key,则插入,如果存在则覆盖插入。
at方法则会进行越界检查,这会损失性能,如果存在则返回它的值,如果不存在则抛出异常。
解释:
#include <iostream>
#include <map>
#include <string>using namespace std;int main() {map<string, int> myMap; // 创建一个字典myMap["apple"] = 1; // 使用 [] 方法插入一个元素myMap["banana"] = 2;// 使用 Find 方法检查元素是否存在if(myMap.find("apple") != myMap.end()) {cout << "Apple is in the map." << endl;} else {cout << "Apple is not in the map." << endl;}// 使用 [] 方法访问一个存在的元素cout << "The value of apple is: " << myMap["apple"] << endl;// 使用 [] 方法访问一个不存在的元素cout << "The value of cherry is: " << myMap["cherry"] << endl;// 使用 At 方法访问一个存在的元素try {cout << "The value of banana is: " << myMap.at("banana") << endl;} catch (const out_of_range& oor) {cerr << "Out of Range error: " << oor.what() << '\n';}// 使用 At 方法访问一个不存在的元素try {cout << "The value of orange is: " << myMap.at("orange") << endl;} catch (const out_of_range& oor) {cerr << "Out of Range error: " << oor.what() << '\n';}return 0;
}
在这段代码中,当我们使用 operator[]
来访问一个不存在的元素时,我们会看到这个元素将被添加到 map 中,并使用默认值进行初始化。而如果我们使用 at
方法来进行访问,由于元素不存在,将会抛出一个 out_of_range 异常。
83. 详细说一说fcntl的作用
作用:用于控制打开的文件描述符的一些属性和行为
有五个功能:
- 复制一个现有的描述符(cmd = F_DUOFD)
- 获得/设置文件描述符标记(cmd = F_GETFD或F_SETFD)
- 获取/设置文件状态标记(cmd=F——GETFL或F_SETFL)
- 获取设置异步IO所有权(cmd=F_GETOWN或F_SETFL)
- 获取设置记录锁(cmd=F_GETLK或F_SET)
84. C++的面向对象主要体现在哪些方面
体现在C++引入了面向对象的一些特性,例如加入了封装继承多态的特点。(然后介绍一下封装继承多态)
解释:
-
封装 :封装是指将数据(变量)和操作数据的函数包装到一个单元(即类)里。通过封装,可以将具有共同属性和功能的一组操作,从逻辑上划分成一个个模块(类)。例如,你可以创建一个名为“汽车”的类,并设置其变量如品牌、颜色、速度等,和控制这些变量的函数,如驾驶和加速等。 通过设置私有成员私有方法,我们可以隐藏内部信息,保护数据,只能通过公有的接口进行访问。
-
继承 :继承是面向对象编程中从已有类(基类,父类或超类)中派生出新的类(派生类,子类或其子类)。这个新的类拥有基类的所有特性,而且还可以添加新的特性。例如,你可以从“汽车”类派生出一个叫“电动汽车”的类,它继承了“汽车”的所有特性,并添加了新的特性,比如充电。继承增加了代码的复用性。
-
多态 :多态是指基类的指针或引用指向派生类对象,并能调用派生类的重写(覆盖)函数,以实现不同的行为。例如,你可能有一个食物制作方法的类(例如“做饭”),并有一些其他派生类,如“炒饭” “炖饭”或“烧饭”。当你尝试调用它们的“做”方法时,会执行每个类特定的“做”方法。这意味着你可以对相同的函数进行不同的操作,这取决于你正在操作的对象。
通过结合这三个特性,C++ 程序员可以创建具有特定属性和行为的复杂对象。这有助于提高代码的可读性和可维护性,并支持更复杂的编程概念,如抽象和接口。
85. 介绍一下extern C关键字,为什么会有这个关键字
是用来实现在C++代码中用C语言的方式来编写代码,是C++为了兼容C语言所加入的关键字
86. 讲一讲迭代器失效及其解决方法
序列容器迭代器失效:当当前元素的迭代器被删除后,后面所有元素的迭代器都会失效,他们都是一块连续存储的空间,所以当使用erase函数操作时,其后的每一个元素都会向前移动一个位置,此时可以使用erase函数操作可以返回下一个有效的迭代器。
Vector迭代器失效问题总结:
- 当执行了
erase
方法时,指向删除节点的迭代器全部失效,指向删除节点之后的迭代器也全部失效。 - 当进行
push_back
方法时,end操作返回的迭代器肯定失效。 - 当插入一个元素后,capacity返回值与没有插入元素之前相比有改变,则需要重新加载整个容器,此时first和end操作返回的迭代器失效。
- 当插入一个元素后,如果空间未重新分配,指向插入位置之前的元素的迭代器依然有效,但指向插入元素之后的迭代器全部失效。
Deque(双端队列)迭代器失效总结:
- 对于deque,插入到除首尾位置之外的任何位置都会导致迭代器、指针和引用都会失效,如果在首尾位置添加元素,迭代器会失效,但是指针和引用不会失效。
- 如果在首尾之外的任何位置删除元素,那么指向被删除元素外其他元素的迭代器都会失效。
- 如果在其首部和尾部删除元素则只会使被指向元素的迭代器失效。
关联型容器迭代器失效:
删除当前的迭代器,仅仅会使当前的迭代器失效,只要erase时,递增当前迭代器即可。
解释:
序列容器迭代器失效
:当我们执行删除操作(比如使用erase)时,确实会导致当前元素之后所有元素的迭代器失效,因为它们所指向的内存区域已经发生了改变。
下边是一个使用C++ STL中erase方法的例子:
std::vector<int> vec = {10, 20, 30, 40, 50};// 建立一个指向第三个元素的迭代器
auto iter = vec.begin() + 2;// 删除这个元素
iter = vec.erase(iter);// 现在iter指向删除元素之后的元素(如果存在的话)cout << *iter << endl; //输出的是40
在这个例子中,erase删除了vec
中的30,并返回一个新的迭代器,它指向了40。这就是你所说的“erase函数操作可以返回下一个有效的迭代器”的效果。
所以,当你在操作底层实现为连续存储区域的容器(例如vector、deque和string)时,要特别注意迭代器的有效性,尤其在插入、删除操作之后。一般来说,如果你计划在遍历容器的过程中删除元素,那么使用erase,并用返回的迭代器更新你正在使用的迭代器,是一个非常好的实践。
对push_back后end迭代器失效的解释
:- 当我们对vector执行push_back操作时,如果vector的容量(capacity)不足以容纳新元素,vector容器会进行内存的重新分配以确保有足够的空间来存储新添加的元素。这种情况下,所有指向原来存储区域的迭代器就会失效。
- 即使push_back操作没有导致内存重新分配,end()方法返回的迭代器也会失效。因为end()方法返回的迭代器并不指向vector的任一元素,而是指向存储区域“尾元素的下一位置”,这个位置是个标志位,被成为了"尾后迭代器"(past-the-end iterator),自然,当插入新元素后,end()方法返回的迭代器的位置改变,那么在插入新元素前获取的尾后迭代器就失效了。
因此,无论是否发生了内存重新分配,都不能保证push_back后end()方法之前返回的迭代器依然有效。在进行了push_back操作后,最好的做法是重新获取end迭代器。
- 对
插入元素后capacity发生改变,begin和end全部失效
的解释:- 当我们在vector插入一个元素后,如果容量(capacity)不足以容纳新元素,那么vector就需要进行内存的重新分配以确保有足够的空间来存储新添加的元素。在这种情况下,所有指向原来存储区域的迭代器(包括begin和end返回的迭代器)都会失效。这是因为,这些迭代器是依赖于vector内部存储元素的物理存储区域的。当引发重新分配时(capacity发生改变时),元素会被移动到新的内存区域,而原来的迭代器依然指向旧的存储区域,因此它们就会失效。因此,如果你早已获取了begin和end返回的迭代器,然后又进行了可能引发重分配的插入操作(如push_back,insert等),那么应该在插入操作后重新获取begin和end返回的迭代器。
- 对deque迭代器的解释:
- 和 vector 不同,deque 是一个分段连续的容器,也就是说它的内存空间不是一块连续的,而是由多块连续内存组合而成的。这样的设计使得在 deque 的首尾添加或删除元素都有很好的性能。当我们在 deque 的除首尾之外的任何位置进行插入或删除操作时,为了保持元素的连续性,这个操作可能会引发元素的移动,从而使所有迭代器、指针和引用都失效。而如果我们在 deque 的首尾位置进行添加或删除元素,由于其特殊的数据组织方式(分段连续),此时只需要在相应端点增减一段内存,然后更新首尾指针即可,而不必移动已有元素。因此,在这种情况下,除了对应端点的迭代器会失效外,其他迭代器、指针和引用都不会失效。下面是一个简单的代码示例来解释这一点:
#include <iostream>
#include <deque>int main() {std::deque<int> deq = {1, 2, 3, 4, 5};int* ptr = &deq[2]; // 指向元素 3std::cout << "*ptr = " << *ptr << std::endl; // 输出 3deq.push_front(0); // 在首位置添加元素std::cout << "*ptr = " << *ptr << std::endl; // 依然可以输出 3,指针没有失效deq.insert(deq.begin() + 2, -1); // 在除首尾位置插入元素std::cout << "*ptr = " << *ptr << std::endl; // 未定义的行为,因为 ptr 已经失效return 0;
}
上面的代码中,我们首先创建一个 deque deq
,然后获取并打印了一个指向元素 3 的指针 ptr
。接着我们在 deq
的首位置添加了一个新元素 0,然后再次打印 ptr
,发现 ptr
并没有失效。然后我们在 deq
的非首尾位置插入了一个新元素 -1,然后再次打印 ptr
,此时 ptr
已经失效,所以对 ptr
的解引用是未定义的行为。
- 对关联型容器迭代器的解释:对于关联容器(如
set,map,multiset,multimap
等),删除元素时只会使指向该元素的迭代器失效,其他迭代器并不会受到影响。在调用erase()
方法删除元素时,一个可行的做法是使用返回的迭代器更新当前迭代器,这样可以保证当前迭代器始终有效。以下是一个例子:
#include <map>
#include <iostream>int main() {std::map<int, std::string> myMap;myMap[0] = "Zero";myMap[1] = "One";myMap[2] = "Two";for(auto it = myMap.begin(); it != myMap.end(); ) {if(it->first == 1) {it = myMap.erase(it); // 删除键为1的元素,并用返回的迭代器更新it} else {++it;}}// 打印剩余元素for(const auto& kv : myMap) {std::cout << kv.first << " : " << kv.second << '\n';}return 0;
}
在这段代码中,我创建了一个包含三个键值对的 map。然后我迭代了整个 map,并在找到键为1的键值对时删除了它。note:调用 erase
的同时也更新了 it
,因此我们可以保证 it
始终是有效的。最后,我打印了 map 中剩余的键值对,可以看到键为1的键值对已经被成功删除。