微信搜索“编程笔记本”,获取更多信息
------------- codingbook2020 -------------
面经面经面经!今天分享的是 CVTE 视源股份的 C++ 软件开发的一面面试题,CVTE 的面试题是真的多!面对疾风吧!!!
C++ 的三大特性是封装性、继承性、多态性。
C++ 中的虚函数的作用主要是实现了多态的机制。基类定义虚函数,子类可以重写该函数。
当子类重新定义了父类的虚函数后,当父类的指针指向子类对象的地址时,父类指针根据赋给它的不同子类指针,动态地调用子类的该函数,而不是父类的函数,且这样的函数调用发生在运行阶段,而不是发生在编译阶段,称为动态联编。
更通俗地说就是:如果使用了 virtual 关键字,程序将根据引用或指针指向的对象类型来选择方法,否则使用引用类型或指针类型来选择方法。
请看一个例子:
#include
using namespace std;
class Father {
public:
virtual void show() {
cout << "This is class Father" << endl;
}
};
class Son : public Father {
public:
void show() {
cout << "This is class Son" << endl;
}
};
int main()
{
Father *f = new Son;
f->show();
return 0;
}
/*
编译运行:
jincheng@jincheng-PC:~/Desktop$ g++ -o test test.cpp
jincheng@jincheng-PC:~/Desktop$ ./test
This is class Son
*/
可以看到,父类指针根据赋给其的具体子类指针,调用子类的方法,而非父类的方法。若父类中的该方法未声明为 virtual ,则父类指针只会调用父类方法。请看下面的例子:
#include
using namespace std;
class Father {
public:
// virtual void show() {
void show() {
cout << "This is class Father" << endl;
}
};
class Son : public Father {
public:
void show() {
cout << "This is class Son" << endl;
}
};
int main()
{
Father *f = new Son;
f->show();
return 0;
}
/*
编译运行:
jincheng@jincheng-PC:~/Desktop$ g++ -o test test.cpp
jincheng@jincheng-PC:~/Desktop$ ./test
This is class Father
*/
介绍完虚函数的功能后,我们再来看一下虚函数是如何工作的。
编译器处理虚函数的方法是:为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向数组的指针,数组的元素是函数地址,这种数组成为虚函数表,这样的指针称为虚表指针。即,每个类使用一个虚函数表,每个类对象使用一个虚表指针。
举个例子:基类对象包含一个虚表指针,指向基类中所有虚函数的地址表。派生类对象也将包含一个虚表指针,指向派生类虚函数表。看下面两种情况:
通过上面的分析我们知道了虚函数的引入是为了实现运行时多态,也了解到虚函数的调用比普通函数的开销要大(查表)。
有关虚函数在析构函数中的使用,参见往期笔记:面试 C++ 工程师:为什么析构函数必须是虚函数?为什么默认的析构函数不是虚函数?
在多继承情况下,有多少个含有虚函数的基类就有多少个虚函数表和虚表指针。
类模板
所谓类模板,实际上是建立一个通用类,其数据成员、成员函数的返回值类型和形参类型不具体指定,用一个虚拟的类型来代表。使用类模板定义对象时,系统会根据实参的类型来取代类模板中虚拟类型从而实现了不同类的功能。
其框架如下:
template
class Class_Name {
public:
T func_name(T a, V b) {
//
}
private:
T val;
V *p;
}
或者
template
class Class_Name {
public:
T func_name(T a, V b) {
//
}
private:
T val;
V *p;
}
其中,template 或 template 是声明类模板的类型,此时 T 是一个虚拟类型,根据不同的实参映射成不同的具体类型。后面跟着的常规的类的定义,只是可以使用 T 作为一种类型去定义成员函数或成员变量。
函数模板
函数模板是一种特殊的函数,可以使用不同的类型进行调用,对于功能相同的函数,不需要重复编写代码,并且函数模板与普通函数看起来很相似,区别就是类型可以被参数化。
例如:
template
void Swap(T &a, T &b)
{
T tmp = a;
a = b;
b = tmp;
}
// 下面的调用均可
int ia = 1, ib = 2;
double da = 1.0, db = 2.0;
Swap(ia, ib);
Swap(da, db);
类模板和函数模板的区别
函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定。即函数模板允许隐式调用和显式调用,而类模板只能显示调用。
STL 共有六大组件:容器、算法、迭代器、仿函数、适配器。
容器
容器是一种容纳特定类型对象的集合,STL 的容器分为顺序容器和关联容器。顺序容器
中的元素是按位置存储的,关联容器中的元素是按值存储的。
常用的顺序容器有:可变数组 vector
常见的关联容器有:集合 set、可重复集合 multiset、字典 map
算法
STL 提供了超过近百个算法模板,这里我们将常用的几个算法列举如下:
/* 查找算法 */
// 在迭代区间 [beg, end) 内查找值为 val 的元素。成功则返回对应的迭代器,失败则返回 end
find(beg, end, val);
// 在迭代区间 [beg, end) 内统计值为 val 的元素的个数
count(beg, end, val);
/* 可变序列算法 */
// 将迭代区间 [beg, end) 内元素复制到以 beg2 开始的区间
copy(beg, end, beg2);
/* 排序算法 */
// 将迭代区间 [beg, end) 内元素按字典次序排序
sort(beg, end);
// 将迭代区间 [beg, end) 内元素反转
reverse(beg, end);
/* 关系算法 */
// 返回迭代区间 [beg, end) 内最大元素的迭代器
max_element(beg, end);
// 返回迭代区间 [beg, end) 内最小in元素的迭代器
min_element(beg, end);
/* 堆算法 */
// 以迭代区间 [beg, end) 内元素建立堆
make_heap(beg, end, less<T>()); // 最大堆
make_heap(beg, end, greater<T>()); // 最小堆
// 将堆顶元素置于容器末尾,可被调用者取走
pop_heap(beg, end);
// 向容器尾添加一个元素,并调整堆序
push_heap(beg, end);
// 进行堆排序
sort_beap(beg, end, cmp);
迭代器
要访问容器中的元素,需要通过迭代器 (iterator)进行。迭代器是一个变量,相当于容器和操纵容器的算法之间的中介。迭代器可以指向容器中的某个元素,通过迭代器就可以读写它指向的元素。从这一点上看,迭代器和指针类似。
主要有以下几种迭代器:
// 正向迭代器
// 容器类名::iterator 迭代器变量名
vector::iterator iter;
// 反向迭代器
// 容器类名::reverse_iterator 迭代器变量名
vector::reverse_iterator iter;
// 常量正向迭代器
// 容器类名::const_iterator 迭代器变量名
vector::const_iterator iter;
// 常量反向迭代器
// 容器类名::const_reverse_iterator 迭代器变量名
vector::const_reverse_iterator iter;
/* 一个示例 */
#include
#include
#include
using namespace std;
int main()
{
vector ivec;
// 初始化
for (int i = 0; i < 10; ++i) {
ivec.push_back(i);
}
// 输出
for (auto elem : ivec) {
cout << elem << "\t";
}
cout << endl;
// 利用迭代器修改容器元素
for (vector::iterator it = ivec.begin(); it != ivec.end(); ++it) {
*it += 1;
}
// 输出
for (auto elem : ivec) {
cout << elem << "\t";
}
cout << endl;
return 0;
}
/*
运行结果:
jincheng@jincheng-PC:~/Desktop$ g++ -o test test.cpp
jincheng@jincheng-PC:~/Desktop$ ./test
0 1 2 3 4 5 6 7 8 9
1 2 3 4 5 6 7 8 9 10
*/
仿函数
仿函数本质就是类重载了一个 operator() ,创建一个行为类似函数的对象。仿函数的主要功能是为了搭配 STL 算法使用,单独使用仿函数的情况比较少。
仿函数最常用的功能就是作为排序算法的排序准则,正如上面提及的:
make_heap(beg, end, less()); // 最大堆
make_heap(beg, end, greater()); // 最小堆
cout << greater()(6, 4); // true
cout << less()(6, 4); // fasle
其中 less
和 greater
就是仿函数,比较功能分别为小于和大于。
适配器
C++ 中的适配器有三种:容器适配器、迭代器适配器、函数适配器。
容器适配器:因为这些容器都是基于其他标准容器实现的,所以叫做容器的适配器,具体的有 stack、queue、priority_queue 。默认的情况下,stack 和 queue 基于 deque 而实现的,priority_queue 是在 vector 上实现的,可以根据第二个实参指定容器的类型。
迭代器适配器:包括三种 reverse(逆向)适配器、insert (安插型)迭代器、stream (串流)适配器。
函数适配器:用于扩展一元和二元函数对象,如仿函数对像等等,用于容器与算法之间的操作时使用。
vector 通过一个连续的数组存放元素。在新增数据的时候,如果集合已满,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,在插入新增的元素。
有几点需要注意:
设置一个“占用率”阈值 c ,当删除元素后 size : capacity
小于 c 时,分配一块较小的内存,将原来的数据复制过来,释放之前的内存。
问题的关键是 c 值如何选择。我认为 c=0.5 是不合理的,原因是:当容器的容量用了一半时,我们需要缩容至一半容量,此时若再插入一个元素,我们需要扩容至两倍。缩容和扩容意味着内存的开辟和释放以及大量元素的复制操作,并且伴随着迭代的失效等,这无疑会带来性能的损失。
一个可行的方案是,选择 c = 1/3 。若容器的 size 小于 capacity 的 1/3 时,我们进行缩容操作,将容量缩小至原容量的一半(仍大于 size)。
类型萃取:使用模板技术来萃取类型(包含自定义类型和内置类型)的某些特性,用以判断该类型是否含有某些特性,从而在泛型算法中来对该类型进行特殊的处理用来提高效率或者其他用途。
通俗点来说,就是编译器根据传递过来的实参类型,来决定使用什么样的方法去完成既定操作,达到提高效率的作用。
举个例子:
周末我的朋友们来我家约我去动物园,如果只来了两个小伙伴,那我就开上我的轿车带他们去;如果来了六个小伙伴,那我就会开上我爸的商务车。(_)
关于类型萃取更加深入的介绍我们后期再制作。
1. nullptr
nullptr 的出现是为了替代 NULL 。nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较,这与 NULL 直接宏定义成 0 不同。
2. 类型推导
auto i = 5;
此时 i 的类型为 intdecltype(1.0+2.0) d;
此时 d 的类型是 double 。decltype 的出现是为了弥补 auto 只能对变量进行类型推导的缺陷,decltype 可以对表达式进行类型推导3. 范围 for
范围 for 使得循环更加便捷,常用的是对容器进行遍历:for (auto elem : ivec) {}
4. 初始化列表
在类中使用初始化列表:
struct A {
public:
A(int _a, float _b): a(_a), b(_b) {} // 初始化列表
private:
int a;
float b;
};
A a{1, 1.1};
新标准还将初始化列表绑定到了类型上:std::initializer_list
。使用如下:
class A {
public:
A(std::initializer_list list) {}
};
A a = {1, 2, 3};
除此之外,还有 Lambda 表达式、正则表达式、元组、语言级线程支持、右值引用和 move 语义等很多新特性。
#include
#include
using namespace std;
/* 普通方式 */
void thread_proc() {
cout << "This is 1st thread." << endl;
}
void test1() {
thread t(thread_proc);
t.join();
}
/* Lambda 表达式 */
void test2() {
thread t([] () {
cout << "This is 2nd thread." << endl;
});
t.join();
}
/* 仿函数 */
struct functor {
void operator() (int a) {
cout << "This is "<< a << "th thread." << endl;
}
};
void test3() {
thread t(functor(), 3);
t.join();
}
/* 绑定对象 */
class A {
public:
A(int a) {
m_a = a;
}
void m_func() {
cout << "This is "<< m_a << "th thread." << endl;
}
private:
int m_a;
};
void test4() {
A a(4);
thread t(bind(&A::m_func, &a));
t.join();
}
/* 成员方法 */
void test5() {
A a(5);
thread t(&A::m_func, &a);
t.join();
}
int main() {
test1();
test2();
test3();
test4();
test5();
return 0;
}
/*
运行结果:
jincheng@jincheng-PC:~/Desktop$ g++ -lpthread -o test test.cpp
jincheng@jincheng-PC:~/Desktop$ ./test
This is 1st thread.
This is 2nd thread.
This is 3th thread.
This is 4th thread.
This is 5th thread.
*/
参见往期笔记:使用 STL 智能指针就一定不会出现内存泄漏了吗?
参见往期笔记:【面经】360 – 软件开发 面试题 3。
参见往期笔记:TCP/IP 协议栈。
为什么 TCP 连接不是两次握手?
为了实现可靠数据传输,TCP 协议的通信双方,都必须维护一个序列号,以标识发送出去的数据包中,哪些是已经被对方收到的。三次握手的过程即是通信双方相互告知序列号起始值,并确认对方已经收到了序列号起始值的必经步骤。如果只是两次握手,至多只有连接发起方的起始序列号能被确认,另一方选择的序列号则得不到确认。
为什么要四次挥手而不是三次?
为了更直白地回答这个问题,引用 CallMeJiaGu 博客中的一段内容:
先看第一种情况:
① client->server :server 我要断开连接了。
② server->client :好的,那你断开连接吧。
再看另一种情况:
① client->server :server 我要断开连接了。
② server->client :等会,我还有一些响应的信息还没发给你。
…过了一会儿…
③ client->server :你还没发好吗?我要断开连接了。
④ server->client :再等会,我还有一些内容还没发好。
…过了一会儿…
⑤ client->server :你好慢啊,我真的要断开连接了。
⑥ server->client :你好烦啊,发好了我会跟你说的。
六次会话都还没断开连接,这次 client 就学乖了,不要一直找 server,它好了会给消息的。
第三种情况:
① client->server :server 我要断开连接了。
② server->client :等会,我还有一些响应的信息还没发给你。
…过了一会儿…
③ server->client :响应信息发好了,我要断开连接了。
虽然 server 信息发好了,但是要考虑到 client 还在接收信息,于是 server 就在等 client 的确认信息。
④ client->server :接收好了,断开连接了。
综上所述,要想将数据完整地收发并可靠地断开连接,至少需要四次挥手。
关于三次握手和四次挥手的详细内容,参见往期笔记:TCP/IP 协议栈。
概念
TCP 是一个基于字节流的传输服务,"流"意味着 TCP 所传输的数据是没有边界的。TCP 粘包是指发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。出现粘包现象的原因是多方面的,它既可能由发送方造成,也可能由接收方造成。
原因
所谓粘包问题,主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。
解决方案
针对粘包产生的原因,有相应地以下几个解决方案:
使用快慢指针法可以判断单链表是否有环。快指针一次走两步,慢指针一次走一步,若两指针相遇则有环,若快指针走到表尾时仍然未和慢指针相遇,则无环。
bool hasLoop(TreeNode *pHead)
{
if (pHead == nullptr) {
return false;
}
bool has_loop = false;
TreeNode *pFast = pHead;
TreeNode *pSlow = pHead;
while (pFast->m_pNext != nullptr) {
pFast = pFast->m_pNext->m_pNext;
pSlow = pSlow->m_pNext;
if (pFast == pSlow) {
has_loop = true;
break;
}
}
return has_loop;
}