【面试】 CVTE 视源股份 C++ 软件开发 一面

微信搜索“编程笔记本”,获取更多信息
------------- codingbook2020 -------------

面经面经面经!今天分享的是 CVTE 视源股份C++ 软件开发一面面试题,CVTE 的面试题是真的多!面对疾风吧!!!

面试题

1.1 C++ 的三大特性是什么?什么是多态?

C++ 的三大特性是封装性继承性多态性

  • 封装性
    C++语言中支持数据封装,是支持数据封装的工具,对象是数据封装的实现。所谓封装具有两方面的含义:一是将有关的数据和操作代码封装在一个对象中,各个对象相互独立,互不干扰。二是将对象中某些数据与操作代码对外隐蔽,即隐蔽其内部细节,只留下少量接口,以便与外界联系,接收外界消息。
  • 继承性
    一个类(基类)可以根据需要生成它的派生类,派生类还可以再生成派生类。继承机制允许派生类继承基类的数据和操作,也就是说允许派生类使用基类的数据和操作,同时派生类还可以增加新的数据和操作。
  • 多态性
    面向对象中的多态性指的是不同的对象收到相同的消息时产生多种不同的行为。实现多态的方法主要两种,一种是编译时多态,主要由重载模板来实现;一种是运行时多态,主要由虚函数(包括继承)来实现。

1.2 虚函数的工作原理?

C++ 中的虚函数的作用主要是实现了多态的机制。基类定义虚函数,子类可以重写该函数。
当子类重新定义了父类的虚函数后,当父类的指针指向子类对象的地址时,父类指针根据赋给它的不同子类指针,动态地调用子类的该函数,而不是父类的函数,且这样的函数调用发生在运行阶段,而不是发生在编译阶段,称为动态联编

更通俗地说就是:如果使用了 virtual 关键字,程序将根据引用或指针指向的对象类型来选择方法,否则使用引用类型或指针类型来选择方法。

请看一个例子:

#include <iostream>

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 <iostream>

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++ 工程师:为什么析构函数必须是虚函数?为什么默认的析构函数不是虚函数?

1.3 如果是多重继承只有一个虚表指针吗

在多继承情况下,有多少个含有虚函数的基类就有多少个虚函数表和虚表指针

1.4 使用过类模板吗?函数模板和类模板的区别是什么?

类模板

所谓类模板,实际上是建立一个通用类,其数据成员、成员函数的返回值类型和形参类型不具体指定,用一个虚拟的类型来代表。使用类模板定义对象时,系统会根据实参的类型来取代类模板中虚拟类型从而实现了不同类的功能。

其框架如下:

template<class T, class V>
class Class_Name {
public:
    T func_name(T a, V b) {
        //
    }

private:
    T val;
    V *p;
}

或者

template<typename T, typename V>
class Class_Name {
public:
    T func_name(T a, V b) {
        //
    }

private:
    T val;
    V *p;
}

其中,template 或 template 是声明类模板的类型,此时 T 是一个虚拟类型,根据不同的实参映射成不同的具体类型。后面跟着的常规的类的定义,只是可以使用 T 作为一种类型去定义成员函数或成员变量。

函数模板

函数模板是一种特殊的函数,可以使用不同的类型进行调用,对于功能相同的函数,不需要重复编写代码,并且函数模板与普通函数看起来很相似,区别就是类型可以被参数化。

例如:

template<class T>
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);

类模板和函数模板的区别

函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定。即函数模板允许隐式调用和显式调用,而类模板只能显示调用

1.5 用过哪些 STL 特性?

STL 共有六大组件:容器算法迭代器仿函数适配器

容器

容器是一种容纳特定类型对象的集合,STL 的容器分为顺序容器关联容器。顺序容器
中的元素是按位置存储的,关联容器中的元素是按值存储的。
常用的顺序容器有:可变数组 vector<T>固定数组 array<T, n>字符串 string单链表 list<T>双向链表 slist<T>队列 queue<T>栈 stack<T>双端队列 deque<T>优先级队列 priority_queue<T>堆 heap<T>
常见的关联容器有:集合 set可重复集合 multiset字典 map<T, V>可重复字典 multimap<T, V>无序集合 unordered_set无序可重复集合 unordered_multiset无序字典 unordered_map<T, V>无序可重复字典 unordered_multimap<T, V>

算法

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<int>::iterator iter;

// 反向迭代器
// 容器类名::reverse_iterator 迭代器变量名
vector<int>::reverse_iterator iter;

// 常量正向迭代器
// 容器类名::const_iterator 迭代器变量名
vector<int>::const_iterator iter;

// 常量反向迭代器
// 容器类名::const_reverse_iterator 迭代器变量名
vector<int>::const_reverse_iterator iter;


/* 一个示例 */

#include <iostream>
#include <vector>
#include <iterator>

using namespace std;

int main()
{
    vector<int> ivec;

    // 初始化
    for (int i = 0; i < 10; ++i) {
        ivec.push_back(i);
    }

    // 输出
    for (auto elem : ivec) {
        cout << elem << "\t";
    }

    cout << endl;

    // 利用迭代器修改容器元素
    for (vector<int>::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<T>());       // 最大堆
make_heap(beg, end, greater<T>());    // 最小堆

cout << greater<int>()(6, 4);         // true
cout << less<int>()(6, 4);            // fasle

其中 less<T>()greater<T>()就是仿函数,比较功能分别为小于大于

适配器

C++ 中的适配器有三种:容器适配器迭代器适配器函数适配器
容器适配器:因为这些容器都是基于其他标准容器实现的,所以叫做容器的适配器,具体的有 stack、queue、priority_queue 。默认的情况下,stack 和 queue 基于 deque 而实现的,priority_queue 是在 vector 上实现的,可以根据第二个实参指定容器的类型。
迭代器适配器:包括三种 reverse(逆向)适配器、insert (安插型)迭代器、stream (串流)适配器。
函数适配器:用于扩展一元和二元函数对象,如仿函数对像等等,用于容器与算法之间的操作时使用。

1.6 vector 的扩容原理?

vector 通过一个连续的数组存放元素。在新增数据的时候,如果集合已满,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,在插入新增的元素。

有几点需要注意:

  • 对 vector 的任何操作,一旦引起空间重新配置(扩容),指向原 vector 的所有迭代器就都失效了
  • vector 默认的容量为 0 ,程序员也可以在定义 vector 时指定容量
  • vector 在扩容通常将容量翻倍

1.7 如果让你实现 vector 动态删除后容量缩小你会如何设计?

设置一个“占用率”阈值 c ,当删除元素后 size : capacity小于 c 时,分配一块较小的内存,将原来的数据复制过来,释放之前的内存。

问题的关键是 c 值如何选择。我认为 c=0.5 是不合理的,原因是:当容器的容量用了一半时,我们需要缩容至一半容量,此时若再插入一个元素,我们需要扩容至两倍。缩容和扩容意味着内存的开辟和释放以及大量元素的复制操作,并且伴随着迭代的失效等,这无疑会带来性能的损失。

一个可行的方案是,选择 c = 1/3 。若容器的 size 小于 capacity 的 1/3 时,我们进行缩容操作,将容量缩小至原容量的一半(仍大于 size)。

1.8 了解过类型萃取吗?

类型萃取:使用模板技术来萃取类型(包含自定义类型和内置类型)的某些特性,用以判断该类型是否含有某些特性,从而在泛型算法中来对该类型进行特殊的处理用来提高效率或者其他用途。

通俗点来说,就是编译器根据传递过来的实参类型,来决定使用什么样的方法去完成既定操作,达到提高效率的作用。

举个例子:
周末我的朋友们来我家约我去动物园,如果只来了两个小伙伴,那我就开上我的轿车带他们去;如果来了六个小伙伴,那我就会开上我爸的商务车。(_)

关于类型萃取更加深入的介绍我们后期再制作。

1.9 了解过 C++11 新特性吗?

1. nullptr
nullptr 的出现是为了替代 NULL 。nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较,这与 NULL 直接宏定义成 0 不同。

2. 类型推导

  • auto i = 5;此时 i 的类型为 int
  • decltype(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<int> list) {}
};

A a = {1, 2, 3};

除此之外,还有 Lambda 表达式正则表达式元组语言级线程支持右值引用和 move 语义等很多新特性。

1.10 C++ 有哪些创建线程的方式?

#include <iostream>
#include <thread>

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.
*/

1.11 了解过智能指针吗?智能指针怎么解决内存泄漏?weak_ptr 怎么解决循环引用的问题?

参见往期笔记:使用 STL 智能指针就一定不会出现内存泄漏了吗?

1.12 TCP 滑动窗口的原理?

参见往期笔记:【面经】360 – 软件开发 面试题 3。

1.13 TCP 和 UDP 的区别?

参见往期笔记:TCP/IP 协议栈。

1.14 为什么 TCP 连接不是两次握手?为什么要四次挥手而不是三次?

为什么 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 协议栈。

1.15 怎么解决 TCP 粘包问题?

概念

TCP 是一个基于字节流的传输服务,"流"意味着 TCP 所传输的数据是没有边界的。TCP 粘包是指发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。出现粘包现象的原因是多方面的,它既可能由发送方造成,也可能由接收方造成。

原因

所谓粘包问题,主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。

  • 发送方引起的粘包是由 TCP 协议本身造成的,TCP 为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。若连续几次发送的数据都很少,通常 TCP 会根据优化算法把这些数据合成一包后一次发送出去,这样接收方就收到了粘包数据。
  • 接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据。

解决方案

针对粘包产生的原因,有相应地以下几个解决方案:

  • TCP 提供了强制数据立即传送的操作指令 push ,TCP 软件收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满。
  • 在开头标记一个数据流长度信息,并固定该报文长度(自定义协议)。在客户端接收数据时先接收报文长度数据,判断客户端发送数据流长度,并只接收该长度字节数据,以此实现实现拆包。
  • 使用定长包

1.16 怎么判断一个链表是否有环,写出代码?

使用快慢指针法可以判断单链表是否有环。快指针一次走两步,慢指针一次走一步,若两指针相遇则有环,若快指针走到表尾时仍然未和慢指针相遇,则无环。

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;
}

点击下方图片关注我,或微信搜索**“编程笔记本”**,获取更多信息。
在这里插入图片描述

发布了19 篇原创文章 · 获赞 0 · 访问量 218
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览