【游戏客户端面试题干货】-- 2021年度最新游戏客户端面试干货( C++篇 )

【游戏客户端面试题干货】-- 2021年度最新游戏客户端面试干货( C++篇 )

 

  大家好,我是Lampard~~

  经过春招一番艰苦奋战之后,我终于是进入了心仪的公司。

  今天给大家分享一下我在之前精心准备的一套面试知识。

 

  今天和大家分享的是C++的面试题

  本篇博客是结合博主自身遇到的问题以及csdn中近3年的10篇高赞【c++面试题博客】综合写出,有一些我感兴趣的知识点会单独写一篇博客详细分析,大家可以通过链接跳转阅读,本人亲测80%的c++相关题目都是围绕着我总结出来的知识点提出的 。最后祝大家疯狂收割offer,通过自己的努力摆脱生活的苟且,走向诗和远方~

 

一. 面向对象特性:多态,继承,封装

1. 什么是面向对象(OOP)?面向对象的意义?

Object Oriented Programming, 面向对象是一种对现实世界理解和抽象的方法、通过将需求要素转化为对象进行问题处理的一种思想。其核心思想是数据抽象、继承和动态绑定(多态)。
面向对象的意义在于:将日常生活中习惯的思维方式引入程序设计中;将需求中的概念直观的映射到解决方案中;以模块为中心构建可复用的软件系统;提高软件产品的可维护性和可扩展性。

2. 解释下封装、继承和多态?

我们用经典三段论:what,why,how来解决这个问题

(1) 封装

what :封装是实现面向对象程序设计的第一步,封装就是将数据或函数等集合在一个个的黑箱中。隐藏对象的属性和实现的细节,仅对外公开接口。

why:封装的意义有两个,对外在于简化编程使用者不需要了解具体的实现细节,只需要知道如何调用,从而做到模块之间高内聚(把逻辑都封装起来),低耦合(模块之间不需要相互依赖,程序员不需要了解每个模块的实现过程)对内在于保护代码(数据),避免止代码(数据)被我们无意中破坏

how:基本要求是把所有的成员变量(对象的属性)私有化。仅对外提供实现功能的接口。

(2) 继承

what 当两个类具有大量相同的特征(在代码中表现为属性大多想同)时,为了避免代码的重复编写造成冗余,以及方便对共同特性进行修改,从而实现的一种机制。目的让代码变得可重用和易拓展

why:继承的意义也有两个,可重用:可以重复使用代码,降低冗余,易拓展:方便对共同特性(基类)进行修改

how:  子类继承父类所有,只是访问受约束(public,protected,private)。

(3) 多态

what:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果,这就是多态性。在代码方面就是对接口的复用,一个接口多种实现。

why:  程序的可扩展性及可维护性增强。C++的虚函数是很好的例子,虚函数允许子类重新定义成员函数。这样子容易根据不同的子类对象产生不同的函数特性。

how:  C++中,实现多态具体体现在运行和编译两个方面:在程序运行时的多态性通过抽象类和虚函数来体现(运行才知道谁重写);在程序编译时多态性体现在函数和运算符的重载上(在编译的时候就知道实现多态的内容);

 

二. 重载(overload)

上文提及到,重载是c++在编译时展现多态性的一个操作。那么究竟什么是重载呢?

重载:是指允许存在多个同名函数,而这些函数的参数不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。目的是简化编程的工作以及提高代码的可读性。

 举个例子:

那么c++是如何识别这些同名的函数的呢?其实在编译的时候,c++就睡把这个函数定义为类似_addNum_int_int 和 _addNum_int_int_int的结构,编译器会根据参数的数量和类型找到需要执行的函数

重载overload和重写override(或者说覆盖)的区别:

提到重载,就会有一个老生常谈的问题,重载和重写的区别是什么呢?

(1)重载:重载翻译自overload,是指同一可访问区内被声明的几个具有不同参数列表(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。
(2)重写:重写翻译自override,是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致,只有函数体不同。

 

三. 结构,联合,枚举

结构struct:

结构其实没什么好说的,我们早在学C的时候就已经接触过,定义一个结构体的关键字是struct。所以咱们还是直接举例子吧:

我们可以直接向node1一样声明一个结构体对象,也可以像node2那样声明一个结构体指针。它们的取成员的方式一个是使用.另一个是使用->

联合union:

联合的定义和声明方式和结构体没差,只是关键字改为union。那么它和结构体的区别在于,联合只能对其那么多个成员属性的其中一个赋值,而结构体很明显是每一个成员都可以赋值的。举一个不恰当的例子,你有多个异性好友,但是只能选择其中一个作为女朋友,把你的真爱交给她。但是如果你不信邪,我要把真爱交给两个人怎么办,那么很遗憾,之前的女朋友就会变成前女友不会再理你了。

union girls {
	string xiaohua;    // 小花
	string xiaocui;    // 小翠
	string xiaoting;   // 小婷
	girls() {};
	~girls() {};
};

int main() {
	girls girlFriend;
	girlFriend.xiaohua = "love";
	cout << girlFriend.xiaohua << endl;    // love
	girlFriend.xiaocui = "love";
	cout << girlFriend.xiaohua << endl;    // 乱码
	cout << girlFriend.xiaocui << endl;    // love
}

在lua和c进行交互时的数据就是使用联合来进行存储的

对lua和c/c++交互感兴趣的同学可以看一下我的这一篇博客:【lua_stack】

枚举enum:

枚举是一个有趣的类型,它能够帮助我们对一些变量进行限制并且使其变得可读。声明枚举的关键字时enum。

举个例子:

我们可以看到,无论是在定义还是在赋值的时候,我们都没有加"",说明它们并不是一个字符串,当我们把它输出来的时候,其实枚举里面的数据是从0开始记录的数字。

 

四. 类

c++和c的最大区别就是,c++编程思想是面向对象而c的编程思想是面向过程。而面向对象这个对象,在代码中就是封装起来的一个个类了。

class和struct的区别:

上文提到了结构体,其实结构体和类没太大不同,都会有构造函数,析构函数,成员变量。它们的区别是结构体中的变量默认作用域是public的,而类中的变量默认作用域是private。

new辆小车车:

虽然在现实中我们唯唯诺诺,但是在编程世界还不是想new什么就new什么嘛。这不直接来辆小车。

这样子我们就在类中定义了一个含有名字和汽油量gas的小车,然后还有一个run函数。每次运行减少一升油。但是我们这种写法,直接在类中定义函数确实可以,但是如果方法,变量比较多岂不是要写很长很长,这样不好读。所以我们可以换另外一种写法,在类中只定义函数头,然后在下文中,再用类解析符::声明这个函数。

再往下我们还可以把声明和定义写在.h函数和.cpp函数中。

// Car.h
#include <iostream>
#include <string>
using namespace std;

class Car {
public:
	string name;
	int gas = 100;

	void run();
	void addGas(int num);
};

// Car.cpp
#include "Car.h"

void Car::run() {
	gas--;
}

void Car::addGas(int num) {
	gas = gas + num;
}

// test.cpp
#include <iostream>
#include <string>
#include "Car.h"
using namespace std;

int main() {
	Car* car = new Car();
	cout << car->gas << endl;
	car->run();
	cout << car->gas << endl;
	car->run();
	cout << car->gas << endl;

	car->addGas(100);
	cout << car->gas << endl;
}

测试结果是一样的:

 

五.构造器和析构器

构造器和析构器大家都不陌生。C++支持我们写出一个创建或者销毁一个对象时所调用的方法。我们在new创建一个类的时候,就会调用对象的构造器,在delete的时候就会调用对象的析构器,清理内存。如果我们没有显示的定义一个构造器函数的话,那么就会调用默认构造函数。在C++中构造器没有返回值,名字和类相同。析构器同样没有返回值,名字和~+类的名字。

我们给Car设置一个构造函数和析构函数:

Car::Car() {
	cout << "我构造啦!!!" << endl;
	gas = 100;
	name = "BBA";
}

Car::~Car() {
	cout << "我析构啦!!!" << endl;
}

然而我们打印日志会发现:

怎么好像没有调用到析构函数 ? 不是的,是因为要等main函数返回了,销毁main函数的栈桢时才会调用析构函数的(可能会成为考点噢)。

构造函数可以有参数,利用函数重载的特性初始化属性值:

Car::Car(string carName, int gasNum) {
	cout << "我初始化啦!!!" << endl;
	gas = gasNum;
	name = carName;
}

 

六.继承

what 当两个类具有大量相同的特征(在代码中表现为属性大多想同)时,为了避免代码的重复编写造成冗余,以及方便对共同特性进行修改,从而实现的一种机制。目的让代码变得可重用和易拓展

why:继承的意义也有两个,可重用:可以重复使用代码,降低冗余,易拓展:方便对共同特性(基类)进行修改

how:  子类继承父类所有,只是访问受约束(public,protected,private),限制如下。

public,protected,private

公有继承(public) :公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,它们都保持原有的状态(public的话大家都可以访问,protected就需要子类和友元类才能够访问),而基类的私有成员仍然是私有的,不能被这个派生类的子类所访问。
私有继承(private) :私有继承的特点是基类的公有成员和保护成员都作为派生类的私有成员,并且不能被这个派生类的子类所访问。
保护继承(protected) :保护继承的特点是基类的
所有公有成员和保护成员都成为派生类的保护成员(非子类或者友元函数就别想访问了),并且只能被它的派生类成员函数或友元访问,基类的私有成员仍然是私有的。

什么是友元关系呢?我们可以在一个类中任意一个地方是声明friend class XXX,这样子类中的所有属性都可以被该类访问。可以说友元关系比起子类更亲切,连private都能访问,而子类最多访问到protected

 

 C++的继承使用:(冒号)实现,举个例子,我们写一个动物继承:

class Animal {
public:
	void eat();
};

void Animal::eat() {
	cout << "我正在吃东西" << endl;
}

class Pig : public Animal {
public:
	void climb();
};

void Pig::climb() {
	cout << "猪正在爬树" << endl;
}

class Fish : public Animal {
public:
	void swim();
};

void Fish::swim() {
	cout << "鱼正在游泳" << endl;
}

int main() {
	Animal* animal = new Animal;
	Pig* pig = new Pig;
	Fish* fish = new Fish;
	animal->eat();
	pig->eat();
	fish->eat();
	pig->climb();
	fish->swim();
}

测试结果:

如果我们在:后面跟的是private,那么就像上文所说的,子类也访问不到基类的任何成员了。

多继承:

c++支持多继承的功能,但是项目中一般不会使用...。它的实现很简单,只需要在继承的前提下如果有多个父类,那么我们只需要在各个父类用逗号隔开即可

 

七.继承关系中的构造函数和析构函数

首先明确一点是,编译器会遵循"先有老爸再有儿子"的原则,先调用父类的构造函数,然后再调用子类的构造函数

构造器传参:子类的构造函数 :基类(参数)

析构器传参:

逗你玩的哈哈哈哈哈,析构器不需要传参,编译器会在程序结束的时候自动调用。但是析构的顺序和构造函数不一样,是先调用子类的析构然后再调用父类的析构。其实我感觉这个很好理解,就像搭积木,我们肯定要先建地基然后再建造高楼。那么拆的时候呢,肯定也是从上往下拆才合理。

 

八.重写(覆盖)

上文提及重载和重写的区别,那么现在我们就实现一下重写。比如说Animal类中有方法eat(),它的子类豬想重写这个方法,改变函数体内的内容

记住,不要对积类继承下来的方法进行重载,这样子的话父类的函数同样会被覆盖掉。比如说我想重载父类的eat方法,通过参数类型不同的方式。但是结果是,新定义的子类的方法能够调用,而父类的方法已经被覆盖

void Pig::eat(string a) {
	cout << "猪正在吃"+a << endl;
}

...

int main() {
	Pig* pig = new Pig("222");
	pig->eat("香蕉");
	pig->eat();    // 报错
}

 

九. 虚函数

我们刚才实例化对象的时候,示例一直猪是这样写的:

Pig* pig = new Pig;

但因为其是继承Animal动物类的,所以说我们其实也可以这样写:

Animal* pig = new Pig;

这种写法一般情况下没有毛病,但是如果子类重写了的方法时,就会出现问题:调用的还是基类的方法

为了避免这种情况,我们可以在积累的方法定义时,加上一个virtual的关键字,这样的话,若子类没有重写这个方法,那么就会调用父类的方法,若子类重写了该方法,那么就会调用子类的方法。

这就是虚函数,能够让程序的行为符合你预期的实现,同样是c++多态性的一部分。

纯虚函数:

实现纯虚函数(抽象方法)很简单,只要在虚函数的基础上=0就可以了。它的目的是让子类才实现这个方法,而基类就先不定义这个函数体。

而它有以下两个限制:1.纯虚函数子类必须重写,2.含抽象方法的类就是抽象类,而抽象类则不可以实例化。

 

十. 虚表

那么是怎么实现虚函数这个机制的呢?

这个技术的核心是虚函数表,类的对象内部会有指向类内部的虚表地址的指针(__vptr字段记录)。通过这个指针调用虚函数。虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。

单继承

这种情况下,派生类中仅有一个虚函数表。这个虚函数表和基类的虚函数表不是一个表(无论派生类有没有重写基类的虚函数),但是如果派生类没有重写基类的虚函数的话,基类和派生类的虚函数表指向的函数地址都是相同的。

class A1
{
public:
    A1(int _a1 = 1) : a1(_a1) { }
    virtual void f() { cout << "A1::f" << endl; }
    virtual void g() { cout << "A1::g" << endl; }
    virtual void h() { cout << "A1::h" << endl; }
    ~A1() {}
private:
    int a1;
};
class C : public A1
{
public:
    C(int _a1 = 1, int _c = 4) :A1(_a1), c(_c) { }
private:
    int c;
};

类C没有重写A的虚函数,所以虚函数表内部的情况如下: 

_vptr指向的地址(虚表的地址)不一样,但是里面的虚函数地址还是指向同一个。

那么如果子类重写了积累的虚函数会怎么样呢?

class C : public A1
{
public:
    C(int _a1 = 1, int _c = 4) :A1(_a1), c(_c) { }
    virtual void f() { cout << "C::f" << endl; }
    virtual void g() { cout << "C::g" << endl; }
    virtual void h() { cout << "C::h" << endl; }
private:
    int c;
};

那么子类虚表中所记录的虚函数地址就会发生变化。已经和基类不是指向同一个函数了。

如果C中写了一些别的虚函数,那么这些虚函数将排在父类的后面。

多继承

多继承情况下,派生类中有多个虚函数表,虚函数的排列方式和继承的顺序一致。派生类重写函数将会覆盖所有虚函数表的同名内容,派生类自定义新的虚函数将会在第一个类的虚函数表的后面进行扩充。

 

十一. new&delete和malloc&free的区别

共同之处:。

这是一个老生常谈的问题,它们会被摆在一起比较,源于它们都可用于申请动态内存和释放内存。但是malloc是单纯的申请一块内存,而new则是带有面向对象的特性得到的指针是直接带类型信息的。因此一开始学JAVA的同学怎么看malloc怎么不智能,而一开始学C的同学则怎么看new怎么不顺眼。

不同之处:我们可以从以下四个点来进行区分,同时也方便记忆

(1) 函数类型不同

 malloc&free是C++的标准库函数,new&delete是C++的运算符​​。​​​​​由于new&delete是C++的运算符,所以说我们可以重载这个函数,也能够实现在new函数中加入我们生成对象时需要执行的构造函数,在回收delete时执行对象的析构函数。而由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。

(2) 返回类型不同

new返回的是指定指针类型,malloc返回的是void*。上文提及,new出来的指针是带有面向对象的特性,是直接带类型信息的,所以我们可以这样子来进行赋值.

#include <cstdlib>
class Test
{
    ...
}

Test* pn = new Test;

而如果是通过malloc申请的动态内存,则是void*类型,需要我们强制转化为想要的指针类型。

#include <cstdlib>
class Test
{
    ...
}

Test* pm = (Test*)malloc(sizeof(Test));

测试结果:

(3) 请求方式不同

从上面的例子可以看出,new出来的指针不需要显式的说明申请内存块的大小,而malloc是需要显式的声明内存块空间大小的。

(4) 释放方式不同

因为请求方式不一样,所以释放方式也有所不同。malloc我们直接申请一块内存,那么我们就直接free释放掉整块内存就可以了。但是new出来的是一个个对象类型的指针,所以为了能够生成多个对象的指针,就有了new[] 的出现,new[]需要和delete[]搭配使用,delete[]会调用每一个成员的析构函数。

(5) 请求失败结果不同

若因为内存紧张,在堆中申请不到内存,则new会抛出异常,而malloc会返回null值

简单来说,可以把new&delete当作是可以调用构造函数和析构函数的malloc&free,但实际上它们会有着:函数类型,返回类型,请求方式,释放方式以及请求失败结果的不一样。

 

十二. c++中指针和引用

因为博主我大学第一门课是学c,这也导致了我产生一个很严重的误区,指针就是引用。

还记得当时的代码是这样的:

void add1(int num) {
    num = num + 1;
}

void add2(int* num) {
    *num = *num + 1;
}

int main () {
    int a = 0;
    int b = 0;
    add1(a);
    add2(&b);
    
    print(a);    // 结果为0
    print(b);    // 结果为1
}

当时老师告诉我们,第一种是传值,第二种是传引用,传值不能改变原有变量,传引用能够改变原有变量。于是乎我就一直认为带*号的都是能改边原有变量的存在

在c++,虽然记录的都是对象的地址,但指针和引用指代的并不是同一个东西。c++可以支持:传值,传指,传引用。

相同点:

  • 1). 都是地址的概念;
  • 2). 都是“指向”一块内存。指针指向一块内存,它的内容是所指内存的地址;而引用则是某块内存的别名(我就是那一块内存,我指我自己);
  • 3). 引用在内部实现其实是借助指针来实现的,一些场合下引用可以替代指针,比如作为函数形参。

不同点:

  • 1). 指针是一个实体(新增空间地址),而引用仅是个别名(创建多个引用都不会新增地址,因为都是同一地址的别名而已);
  • 2). 引用只能在定义时被初始化一次,之后不可变;指针可变;引用“从一而终”,指针可以“见异思迁”;
  • 3). 引用不能为空,指针可以为空;
  • 4). “sizeof 引用”得到的是所指向的变量(对象)的大小,而“sizeof 指针”得到的是指针本身的大小;
  • 5). 指针和引用的自增(++)运算意义不一样;
  • 6). 引用是类型安全的,而指针不是 (引用比指针多了类型检查)

 

实际踩坑的例子QAQ:

当时我是想要实现这一道算法题,然后我写下以下的代码:

ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
	ListNode* head = new ListNode();
	ListNode* curNode = head;
	while (l1 && l2) {
		ListNode* tmp = l1->val < l2->val ? l1 : l2;
		curNode->next = tmp;
		tmp = tmp->next;
		curNode = curNode->next;
	}
	curNode->next = l1 ? l1 : l2;
	return head->next;
}

我的思路是用tmp来记录当前比较大的结点,然后把该结点的值链接到答案中后,让tmp = tmp->next让当前结点比较大的链表向下移动一个位置。但是问题就出现在这里,因为我的tmp是指针类型,tmp = tmp->next并不能让l1或者l2指向下一位置(引用才能做到这个功能),从而链表一直不往下走造成死循环

ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
	ListNode* head = new ListNode();
	ListNode* curNode = head;
	while (l1 && l2) {
		ListNode** tmp = l1->val < l2->val ? &l1 : &l2;
		curNode->next = *tmp;
		*tmp = (*tmp)->next;
		curNode = curNode->next;
	}
	curNode->next = l1 ? l1 : l2;
	return head->next;
}

 于是乎我修改代码,把tmp改成**类型,变成了l1或者l2的引用,这样子*tmp = (*tmp)->next;就可以让链表移动一个结点,于是能实现功能,详细的解题过程在以下的博客中。

【算法百题之五十三】合并两个有序链表

 

十三. c++编译流程

程序编译的过程中就是将用户的文本形式的源代码(c/c++)转化成计算机可以直接执行的机器代码的过程。主要经过四个过程:预处理、编译、汇编和链接。具体示例如下。

OK,接下来我们先举个例子,然后我们把它揉碎了一步步慢慢分析

为了下面步骤讲解的方便,我们需要一个稍微复杂一点的例子:

假设我们自己定义了一个头文件mymath.h,实现一些自己的数学函数,并把具体实现放在mymath.c当中。然后写一个test.c程序使用这些函数。程序目录结构如下

├── test.c

└── include

          ├── mymath.h

          └── mymath.c

程序代码如下:

test.cpp

// test.c
#include <stdio.h>
#include "mymath.h"// 自定义头文件
int main(){
    int a = 2;
    int b = 3;
    int sum = add(a, b); 
    printf("a=%d, b=%d, a+b=%d\n", a, b, sum);
}

mymath.h

// mymath.h
#ifndef MYMATH_H
#define MYMATH_H
int add(int a, int b);
int sum(int a, int b);
#endif

mymath.c

// mymath.c
int add(int a, int b){
    return a+b;
}
int sub(int a, int b){
    return a-b;
}

(1) 预处理 (Preprocessing)

预处理用于将所有的包含头文件(include),条件编译指令(#ifdef, #endIf)以及宏定义(#define)替换成其真正的内容。我们可以利用gcc -E来让文件执行到预处理阶段就停止,此时输出的文件是以.i结尾

预处理之后的程序还是文本,可以用文本编辑器打开。且预处理后的文件明显变大。

(2) 编译 (Compilation

这里的编译不是指程序从源文件到二进制程序的全部过程,而是指将经过预处理之后的程序转换成特定汇编代码(assembly code)的过程。说到汇编就回想起当初在学这门课的时候,是我们的老院长给我们上的课。在他们那一代的编程人手中,一个8位的寄存器都可拆开两部分来用,前半部分代表操作,后半部分代表数据,说实话真的很厉害。我们可以使用gcc -S让编译器的编译阶段之后停止,生成的汇编代码以.s结尾。

test.s文件:

(3) 汇编 (Assemble)

汇编过程将上一步的汇编代码转换成机器码(machine code),这一步产生的文件叫做目标文件,是二进制格式,此时的文件已经不能用文本编辑器打开了。我们可以使用gcc -c指令让编译器执行到这个阶段,生成.obj二进制文件。

(4) 链接 (Linking)

链接过程将多个目标文件以及所需的库文件链接成最终的可执行文件(executable file)其中库分为动态链接库以及静态链接库

静态链接(lib)

在这种链接方式下,函数的代码将从其所在地静态链接库中被拷贝到最终的可执行程序中(进入exe文件)。这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中。静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。

动态链接 (dll)
在此种方式下,函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息(只记录名称地址)。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码

C/C++编译过程对于可执行文件中的函数调用,可分别采用动态链接或静态链接的方法。使用动态链接能够使最终的可执行文件比较短小,并且当共享对象被多个进程使用时能节约一些内存,因为在内存中只需要保存一份此共享对象的代码。但并不是使用动态链接就一定比使用静态链接要优越。在某些情况下动态链接可能带来一些性能上损害。

 

十四. c++程序编译时内存布局

C/C++程序编译时内存分为5大存储区

(1)栈区(stack):由编译器自动分配释放,存放函数的参数值,局部变量值等,其操作方法类似数据结构中的栈。
(2)堆区(heap):一般由程序员分配释放(new,malloc),与数据结构中的堆毫无关系,分配方式类似于链表。
(3)全局/静态区(static):全局变量和静态变量的存储是放在一起的,在程序编译时分配。
(4)文字常量区:存放常量字符串。
(5)程序代码区:存放函数体(类的成员函数、全局函数)的二进制代码

举个例子:

int a=0;                     // 全局初始化区
char *p1;                    // 全局未初始化区
void main()
{
	int b;                   // 栈
	char s[]="bb";           // 栈
	char *p2;                // 栈
	const char *p3="123";          // 其中,“123\0”常量区,p3在栈区
	static int c=0;          // 全局区
	p1=(char*)malloc(10);    // 10个字节区域在堆区
	Node* node = new Node(); // 堆区
}

在全局静态区生成的int默认为0,在堆栈区生成的不会被赋予初始值。

堆和栈的区别

  • 1).堆存放动态分配的对象——即那些在程序运行时动态分配的对象,比如 new 出来的对象(其生存期由程序控制);
  • 2).栈用来保存定义在函数内的非static对象,如局部变量,仅在其定义的程序块运行时才存在(生命周期同函数)
  • 3).静态内存用来保存static对象,类static数据成员以及定义在任何函数外部的变量,static对象在使用之前分配,程序结束时销毁(生命周期同程序);
  • 4).栈和静态内存的对象由编译器自动创建和销毁。堆是需要程序员手动申请和释放
  • 5) 栈的效率比堆高很多,且不容易产生内存碎片

 

十五. const和define区别

两者都可以常用于定义常量,所以会被比较。它们有以下三点不同:

数据类型:const 常量有数据类型,而宏常量没有数据类型,编译器可以对前者进行类型安全检查。而对后者只进行字符替换,没有类型安全检查

处理阶段:#define定义的宏变量在预处理时进行替换,const所定义的变量在编译时确定其值

地址访问:#define定义的常量是不可以用指针去指向,const定义的常量可以用指针去指向该常量的地址

目的不同:一般来说我们使用define是想让这个数字变得可读易理解(比如#define MIN 0,#define MAX 100),而const的目的则是更侧重想让这个数据不被破坏,想让编译器帮我们检测

const int c = 2;
cout << &MIN << endl;    // 会报错
cout << &c << endl;

define和Typedef

我们会使用define来给一个常量一个别名,但是如果我们想要给一个类型一个别名就要用到Typedef

typedef int C_NT;

 

十六. static静态

什么是static关键字?

C与C++#的static有两种用法:面向过程程序设计中的static和面向对象程序设计中的static。前者应用于普通变量和函数,不涉及类;后者主要说明static在中的作用。 

面向过程板块

静态全局变量

在全局变量前,加上关键字static,该变量就被定义成为一个静态全局变量。静态全局变量只能被当前文件访问,存放在全局静态区,不能被其他文件访问,哪怕用extern也不行!!!编译器会报错

静态局部变量

局部变量前,加上关键字static,该变量就被定义成为一个静态局部变量。如果说全局静态变量是为了不让其他文件访问到,那么局部静态变量的意义又是什么呢?

通常,在函数体内定义了一个变量,每当程序运行到该语句时都会给该局部变量分配栈内存。但随着程序退出函数体,系统就会收回栈内存,局部变量也相应失效(比如说有一个函数是生产商品,我想用一个变量记录总共生产了多少产品)。但有时候我们需要在两次调用之间对变量的值进行保存。通常的想法是定义一个全局变量来实现。但这样一来,变量已经不再属于函数本身了,不再仅受函数的控制,给程序的维护带来不便。

int addProduct() {
    static int num = 0;    // 只会初始化一次
    ...
}

静态局部变量正好可以解决这个问题。静态局部变量保存在全局数据区,而不是保存在栈中,每次的值保持到下一次调用,直到下次赋新值。它只会被初始化一次,而且生命周期跟随整个程序。

静态函数

在函数的返回类型前加上static关键字,函数即被定义为静态函数。静态函数与普通函数不同,作用和全局静态变量相似,它只能在声明它的文件当中可见,不能被其它文件使用。这样的好处是静态函数不能被其它文件所用;其它文件中可以定义相同名字的函数,不会发生冲突

#include <iostream>
using namespace std;
static void fn();//声明静态函数
int main()
{
    fn();
}
static void fn()//定义静态函数
{
    int n = 10;
    cout<<n<<endl;
}

面向对象板块

静态数据成员

在类内数据成员的声明前加上关键字static,该数据成员就是类内的静态数据成员。先举一个静态数据成员的例子。

class Myclass
{
    public:
        Myclass(int a, int b, int c);
        void GetSum();
    private:
        int a;
        int b;
        int c;
        static int Sum;//声明静态数据成员
};

这个实现效果有点像局部的静态变量,其值是被所有实例化的类共用,静态数据成员和普通数据成员一样遵从public,protected,private访问规则;

静态成员函数

与静态数据成员一样,我们也可以创建一个静态成员函数,它为类的全部服务而不是为某一个类的具体对象服务。静态成员函数与静态数据成员一样,都是类的内部 实现,属于类定义的一部分。它不具有this指 针。从这个意义上讲,它无法访问属于类对象的非静态数据成员,也无法访问非静态成员函数,它只能调用其余的静态成员函数。

常见静态问题:

(1)在头文件把一个变量申明为static变量,那么引用该头文件的源文件能够访问到该变量吗。

答:可以。声明static变量一般是为了在本cpp文件中的static变量不能被其他的cpp文件引用,但是对于头文件,因为cpp文件中包含了头文件,故相当于该static变量在本cpp文件中也可以被见到。当多个cpp文件包含该头文件中,这个static变量将在各个cpp文件中将是独立的,彼此修改不会对相互有影响。

(2)为什么静态成员函数不能申明为const

const是表明:函数不会修改该函数访问的目标对象的数据成员。既然一个静态成员函数根本不访问非静态数据成员(没有this指针),那么就没必要使用const了。

(3)为什么不能在类的内部定义以及初始化static成员变量,而必须要放到类的外部定义

答:因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的。

 

十七. i++和++i的区别是什么?

我们可以直接看代码,看看i++和++i的实现方式

//i++实现代码为:
int operator++(int)
{
    int temp = *this;
    ++*this;
    return temp;
}//返回一个int型的对象本身

// ++i实现代码为:
int& operator++()
{
    *this += 1;
    return *this;
}//返回一个int型的对象引用

i++是返回int型的对象本身,此时的temp是记录了自增之前的值,而++i是返回int型对象的引用,引用是对象的别名,所以返回的是自增之后的值

可以不停的嵌套++i。
这里有很多的经典笔试题,一起来观摩下:

int main()
{
	int i = 1;
	printf("%d,%d\n", ++i, ++i);    //3,3
	printf("%d,%d\n", ++i, i++);    //5,3
	printf("%d,%d\n", i++, i++);    //6,5
	printf("%d,%d\n", i++, ++i);    //8,9
	system("pause");
}

这个答案好像怎么看都不对是不是!!!但是人家就是编译器输出的结果

首先是函数的参数入栈顺序从右向左入栈的,计算顺序也是从右往左计算的,不过都是计算完以后再进行的压栈操作
对于第1个printf,首先执行++i,返回值是i,这时i的值是2,再次执行++i,返回值是i,得到i=3,将i压入栈中,此时i为3,也就是压入3,3;
对于第2个printf,首先执行i++,返回值是原来的i,也就是3,再执行++i,返回值是i,依次将3,5压入栈中得到输出结果
对于第3个printf,首先执行i++,返回值是5,再执行i++返回值是6,依次将5,6压入栈中得到输出结果
对于第4个printf,首先执行++i,返回i,此时i为8,再执行i++,返回值是8,此时i为9,依次将i,8也就是9,8压入栈中,得到输出结果。

 

十八. 三目运算符

什么是三目运算符

三目运算符,又称条件运算符,可以理解为条件 ? 结果1 : 结果2,是计算机语言的重要组成部分。它是唯一有3个操作数的运算符,有时又称为三元运算符。一般来说,三目运算符的结合性是右结合的。条件运算符是右结合的,也就是说,从右向左分组计算。例如,a ? b : c ? d : e将按a ? b : (c ? d : e)执行

三目运算符在c和c++之间的区别

在C中三目运算符(? :)的结果仅仅可以作为右值,比如如下的做法在C编译器下是会报错的,但是C++中却是可以是通过的。这个进步就是通过引用来实现的,因为下面的三目运算符的返回结果是一个引用,然后对引用进行赋值是允许的。

 

十九. 在C++程序中调用被C编译器编译后的函数,为什么要加extern“C”

典型的,一个C++程序包含其它语言编写的部分代码。类似的,C++编写的代码片段可能被使用在其它语言编写的代码中。不同语言编写的代码互相调用是困难的,甚至是同一种编写的代码但不同的编译器编译的代码。例如,不同语言和同种语言的不同实现可能会在注册变量保持参数和参数在栈上的布局,这个方面不一样。

为了使它们遵守统一规则,可以使用extern指定一个编译和连接规约。extern "C"的真实目的是实现类C和C++的混合编程。在C++源文件中的语句前面加上extern “C”,表明它按照类C的编译和连接规约来编译和连接,而不是C++的编译的连接规约。extern "C"指令中的C,表示的一种编译和连接规约,而不是一种语言。C表示符合C语言的编译和连接规约的任何语言,如Fortran、assembler等。还有要说明的是,extern "C"指令仅指定编译和连接规约,但不影响语义。例如在函数声明中,指定了extern “C”,仍然要遵守C++的类型检测、参数转换规则

假设某个函数原型为:

void foo(int x, int y);

该函数被C编译器编译后在库中的名字为 _foo, 而C++编译器则会产生像: _foo_int_int 之类的名字。为了解决此类名字匹配的问题,C++提供了C链接交换指定符号 extern “C”。

我们之前使用c++程序调用lua的库文件的时候(由纯C编写),就需要使用到extern "C"

extern "C" {
  #include "lua.h"
  #include "lualib.h"
  #include "lauxlib.h"
}

 

二十.Vector向量

什么是vector

  • vector向量,其实是顺序表的封装,数组的升级版,属于集合(可重复)的一种存储方式,主要特征和数组一样,随机访问,当然比数组强大的地方在于支持很多其他操作,并有自动内存管理功能,不受定长限制,有效避免使用数组常见的异常错误。

  • 进行vector操作前应添加头文件#include <vector>

常见的Vector操作 

(1) 初始化一个向量

	vector<int> array1 = { 1, 2, 3 };    // 声明一个含有三个元素的向量
    vector<int> array2(10);              // 声明一个含有10个元素但是没赋值的向量
	vector<int> array3( 10, -1 );        // 声明一个含有10个元素且赋值为-1的向量
    vector<int> array4( array3, array3 + 6);        // 声明一个含有6个元素的向量

我们可以使用向量.capacity()来返回向量在内存中总共可以容纳的元素个数,.size()来获取当前向量的大小

(2) 遍历一个向量

要遍历一个向量我们可以使用两种方式进行访问:分别是迭代的方法以及访问下标的方法。

void printArray1(vector<int> array)
{
	// 常见的迭代输出方式
	for (vector<int>::iterator it = array.begin(); it != array.end(); it++)
	{
		cout << *it << endl;
	}
}
 
void printArray2(vector<int> array)
{
	// 常见的访问下标输出方式
	for (int i = 0; i < array.size(); i++)
	{
		cout << array[i] << endl;
	}
}

值得一体的是,vector<int>::iterator标准库中的类,它具有指针的功能,*iterator是对其进行解引用从而访问元素实际的值

(3) 插入和删除

// 插入相关
向量.push_back(5);            // 在a的最后一个向量后插入一个元素,其值为5
向量.insert(a.begin()+1,5);   // 在a的第1个元素(从第0个算起)的位置插入数值5,如a为1,2,3,4,插入元素后为1,5,2,3,4
向量.insert(a.begin()+1,3,5); // 在a的第1个元素(从第0个算起)的位置插入3个数,其值都为5
向量.insert(a.begin()+1,b+3,b+6);// b为数组,在a的第1个元素(从第0个算起)的位置插入b的第3个元素到第5个元素

我们可以通过push_backinsert来向向量插入一个元素

我们可以通过pop_backerase来向向量删除一个元素

(4)其他一些常用的操作

向量.resize(10);//将向量的现有元素个数调至10个,多则删,少则补,其值随机
向量.reserve(100);//将向量的容量(capacity)扩充至100,也就是说现在测试a.capacity();的时候返回值是100.这种操作只有在需要给a添加大量数据的时候才 显得有意义,因为这将避免内存多次容量扩充操作(当a的容量不足时电脑会自动扩容,当然这必然降低性能) 
向量.clear();//清空向量中的元素
向量.empty();//判断a是否为空,空则返回ture,不空则返回false

 我们可以看到,clear()只是把值给清空了,但是申请的内存100并没有进行释放,如果想要清空且释放内存,我们可以通过这种方式:vector<int>().swap(vec),此时vec的capacity也变为0了

(5)利用算法进行排序和数据倒置

由于vector中并没有给我们提供排序和倒叙等算法,所以若想对向量进行排序,我们可以利用algorithm算法库实现功能。

vector常见的面试题

(1) reserve和resize的区别

reserve的作用是把向量的容量扩大到一定的值,减少内存扩容时的多次操作,提高效率。当参数内的值小于现在的容量时,则不会改变其空间(只能放大)

resize是重新设定向量的大小(放大缩小都可以),若放大则把新增的元素内容设定为参数val的值,若没有传val的参,则值随机。若缩小则把多余的元素给抛弃。

(2) vector的内存扩容

 当空间不够装下数据(vec.push_back(val))时,会自动申请配置另一片更大的空间(vs2015带的是1.5倍,GCC以2倍扩容),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间

因为我们每次内容扩充的时候都需要新建,移动,销毁这个过程,所以如果要执行多次的话(比如从2增内存到100),其实是很耗的 ,这也解释了为什么我们要有resize(),reserve()函数,一次性扩充至我们想要的内存大小则省了很多时间。

此时我们难免会萌生出一个问题:为什么扩容因子要选则1.5或者2??

从空间上来分析,扩容因子越大,意味着预留空间越大,浪费的空间也越多,所以从空间考虑,扩容因子因越小越好。然后进过大佬们的计算,计算出k等于1.5/2的时候,效率最高。

 

二十一.list链表

什么是list

(1)初始化一个链表

list是一种序列式容器,完成的功能实际上和数据结构中的双向链表是极其相似的。即:头节点的前驱元素指针域保存的是链表中尾元素的首地址,list的尾节点的后继元素指针域则保存了头节点的首地址,这样,list实际上就构成了一个双向循环链。list也具有链表的主要优点,即:在链表的任一位置进行元素的插入、删除操作都是快速的。与vector相比,由于list元素节点并不要求在一段连续的内存中,显然在list中是不支持快速随机存取的,因此对于迭代器,只能通过“++”或“--”操作将迭代器移动到后继/前驱节点元素处。而不能对迭代器进行+n或-n的操作。值得一提的是,如果vector进行插入删除操作引起了内存重新分配,那么原先指向的迭代器将不再生效,但是对于list来说,因为每个内存地址是独立的,所以并不会引起迭代器失效的状况

常见的List操作

list<int> list1;              // 声明一个不设大小的链表
list<int> list2(10);          // 声明链表长度为10的链表
list<int> list3(10, 1);       // 声明长度为10且初始化为1的链表
list<int> list4(list3);       // 构造一个和list3一摸一样的链表

我们可以使用list.size() 返回容器中实际数据的个数

(2)遍历一个链表

void printList(list<int> listTest)
{
	// 常见的链表迭代输出方式
	for (list<int>::iterator it = listTest.begin(); it != listTest.end(); it++)
	{
		cout << *it << endl;
	}
}

 我们可以利用链表的迭代器来对链表进行遍历操作,it.begin指向的是第一个元素,it.end指向的是最后一个元素。

 我们可以看看刚才我们构建的链表内容:

我们可以看到:如果声明了链表长度,哪怕不给它赋值,其默认会赋值0,所以判断是否为空的时候会返回0(不为空)

(3)插入&删除

list.push_back(数据)	    // 在尾部加入一个数据
list.pop_back()	        // 删除尾部数据
list.push_front(数据)	    // 在头部插入一个数据
list.pop_front()	    // 删除头部数据
list.insert(迭代器, 数据)  // 任意位置插入
list.erase(迭代器)       // 任意位置删除

(4)其他一些常用的操作

list.unique     // 去重
list.merge      // 合并两个有序链表
list.resize     // 重新定义链表大小
list.reversr    // 单链表的逆置

值得一提的是,链表中的resize和vector一样,比原来空间小的时候则保留前n个数据,比原来大的话则用0补齐

(三)List的排序

和stl算法库中的sort使用快速排序不一样,list自带的排序方式实际是使用归并排序。

把数据一直划分为两段,直到最小为止。然后将前两个元素合并,再将后两个元素合并,然后合并这两个子序列成4个元素的子序列,重复这一过程,得到8个,16个,...,子序列,最后得到的就是排序后的序列。

	template<class _Pr2>
		iterator _Sort(iterator _First, iterator _Last, _Pr2& _Pred,
			size_type _Size)
		{	// order [_First, _Last), using _Pred, return new first
			// _Size must be distance from _First to _Last
		if (_Size < 2)
			return (_First);	// nothing to do
 
		iterator _Mid = _STD next(_First, static_cast<difference_type>(_Size >> 1));
		_First = _Sort(_First, _Mid, _Pred, _Size >> 1);
		_Mid = _Sort(_Mid, _Last, _Pred, _Size - (_Size >> 1));
		iterator _Newfirst = _First;
 
		for (bool _Initial_loop = true; ; _Initial_loop = false)
			{	// [_First, _Mid) and [_Mid, _Last) are sorted and non-empty
			if (_DEBUG_LT_PRED(_Pred, *_Mid, *_First))
				{	// consume _Mid
				if (_Initial_loop)
					_Newfirst = _Mid;	// update return value
				splice(_First, *this, _Mid++);
				if (_Mid == _Last)
					return (_Newfirst);	// exhausted [_Mid, _Last); done
				}
			else
				{	// consume _First
				++_First;
				if (_First == _Mid)
					return (_Newfirst);	// exhausted [_First, _Mid); done
				}
			}
		}

 

二十二.vector和list的对比

    vector在内存中是分配一段连续的内存空间进行存储其迭代器采用原生指针即可,因此其支持随机访问和存储,支持下标操作符,节省空间。但是其在分配的内存不够的情况下,需要对容器整体进行重新分配、拷贝和释放等操作,而且在vector中间插入或删除元素设计元素的移动,效率很低。

    而list是以节点形式来存放数据,使用的是非连续的内存空间来存放数据,因此,在其内部插入和删除元素的时间复杂度都是O(1),但是其不支持随机访问和存取,不支持下标,而且比vector占用的内存要多。

 

二十三.deque容器

   什么是deque

    deque是一个双向开口的容器,所谓双向开口就是再头尾两端均可以做元素的插入和删除操作而且deque没有容量的概念,其内部采用分段连续内存空间来存储元素,在插入元素的时候随时都可以重新增加一段新的空间并链接起来。deque提供了Ramdon Access Iterator,同时也支持随机访问和存取,但是它也为此付出了昂贵的代价,其复杂度不能跟vector的原生指针迭代器相提并论。

   deque的中控器

   deque为了维持整体连续的假象,设计一个中控器,其用来记录deque内部每一段连续空间的地址。大体上可以理解为deque中的每一段连续空间分布在内存的不连续空间上,然后用一个所谓的map作为主控,记录每一段内存空间的入口,从而做到整体连续的假象。其布局大概如下抛弃型别定义,我们可以看到map实际上就是一个指向指针的指针(T**),map所指向的是一个指针,该指针指向型别为T的一块内存空间。

  

  deque的扩容机制

当我们往deque插入一个元素的时候,需要进行以下的判断:

  • 如果备用空间足够(当前缓存区未满),就直接push进去(如果往前端插入但是前端不够位置后端有位置的话,则会先调整位置,然后再插入),改变缓存区中的start,cur,last的值
  • 如果备用空间不足,就要考虑配置一个新的缓冲区

配置新缓冲区的时候,还需要考虑map空间是否足够 

  • 如果map空间足够,就直接配置一块新的缓冲区,链接到map中,修改start和finish的值
  • 如果map空间不足,就需要考虑重新配置一块map,大小为map_size + max(map_size, nodes_to_add) + 2,把数据都转移过去(浅拷贝),并让start和finish处于新map的中间

 

博客参考:

【C++面试题汇总 (一)】

【C++面试题精简整理】

【2020年C++面试题(含答案)】

【C/C++常见面试知识点总结附面试真题----20190407更新】

【2018秋招C/C++面试题总结】

【常见C++笔试面试题整理】

【C/C++ 最常见50道面试题】

【c++常见面试题30道】

【c++面试题(线程与进程篇)】

【阿里面试必会20道C++面试题!】

 

未完待续......

  • 15
    点赞
  • 62
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lampard杰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值