返回

春招 | 知识点留档

春节已然过去了,处于人生的又一个十字路口且充满后顾之忧的我决定开始准备春招,时间看着是比较紧的,我也不知道自己最后能够准备到什么程度。

1、重载、隐藏、重写(覆盖)三者的区别

重载:是指同一可访问区内被声明的几个具有不同参数列(参数的类型、个数、顺序不同)的同名函数,根据参数列表确定调用哪个函数。

隐藏:指的是派生类类型的对象、指针引用访问基类和派生类都有的同名函数时 (只要求同名,不管参数列表是否相同),访问的是派生类的函数,即隐藏了基类的同名函数。

重写(覆盖):指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同(花括号内),派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有virtual修饰。

此外,重载的参数不同,函数体不同;隐藏的参数可以不同,函数体不同;重写或覆盖仅仅函数体不同。

三者更为深入的一些问题

1.1 重载为什么改变参数就可以实现调用不同函数?

因为C++在编译的时候会对函数进行重命名,保证函数命名的唯一性,而函数的参数列表不同便会导致函数被命名为不同的函数。

1.2 构造函数可以被重载吗?析构函数呢?

构造函数可以被重载,因为可以存在有参和无参的构造函数;析构函数不能被重载,因为析构函数只能有一个并且不带参数。

2、new和malloc的区别

据说是C++的经典问题,在综合了网络上的一些博文后,得出以下几点不同。

(1)、申请内存所在区域。new操作符从自由存储区 (不仅可以是堆还可以是静态存储区) 上为对象动态分配内存,而malloc从堆上分配内存,这块区域是操作系统维护的一块特殊内存,用于程序动态分配。

(2)、返回类型。new操作符返回的是对象类型指针,不需要进行强制转换;malloc内存分配成功则是返回void*,需要通过强制类型转换成指定类型。

(3)、内存分配失败时的返回值。new内存分配失败时,会抛出bad_alloc异常,不会返回NULLmalloc分配内存失败时返回NULL

(4)、是否需要指定内存大小。 使用new操作符时进行内存分配时无需指定内存块的大小,使用malloc需要显式的指定内存的大小。

class A {...}
A *ptr = new A;
A *ptr = (A*)malloc(sizeof(A));

(5)、是否调用构造函数。 使用new操作符分配对象内存时经历如下的几个步骤:

  • ·调用operator new函数(数组是operator new[])来分配内存空间

  • ·编译器运行相应的构造函数创建对象,并赋初值

  • ·返回指向这个对象的指针

使用delete操作符释放内存空间时会经历如下步骤:

  • ·调用对象的析构函数

  • ·编译器调用operator deleteoperator delete[])释放内存空间

使用malloc分配内存则不会调用构造函数

(6)、对数组的处理。 C++提供了new []delete []来专门处理数组类型,而malloc需要自己指定数组的大小

int *ptr = (int*)malloc(sizeof(int) * 10)  // 分配一个十个int大小的数组

(7)、是否能够重新分配内存。 使用malloc分配内存后,如果在使用过程中发现内存不足,可以使用realloc函数进行内存的重新分配内存(先判断当前指针所指内存是否有足够的的连续空间,如果有,原地扩大可分配的地址,并且返回原来的指针;如果空间不够,先按照指定大小分配空间,将原有数据从头到尾拷贝到新分配的内存区域,而后释放原来的内存)。new没有扩充内存的配套操作

(8)、客户处理内存分配不足。new会有异常机制,而malloc只能返回NULL

3、虚函数

虚函数一般在继承下发挥作用,基类声明一个虚函数,子类重载这一函数,这样当使用基类指针指向子类,并希望调用这个函数时,得到的就是子类重载过的函数。(在多态中还会有关于虚函数的讲解),通过使用虚函数来完成运行时决议,与传统的编译时决定有本质区别。

虚函数的实现是由两个部分组成的,虚函数指针和虚函数表。

当使用类的指针调用成员函数时,普通函数由指针类型决定,而虚函数由指针指向的实际类型决定。

3.1、虚函数指针

本质上就是一个指向函数的指针,指向用户定义的虚函数。在一个被实例化的对象中,它总是被存放在该对象的地址首位。 只有拥有虚函数的类才会拥有虚函数指针 ,每个虚函数也都会对应一个虚函数指针。

3.2、A、B两个类,类中有虚函数。C继承AB,有几张虚函数表?

两张,多继承就会有多个虚函数表,因为每个父类的虚函数是不同的,指针也是不同的。如果公用一张虚函数表,就分不清子类到底实例化的哪个基类函数。

3.3、析构函数可以是虚函数吗?

析构函数必须是虚函数。因为如果不是虚函数,当在主函数中用父类的指针new出一个子类对象,最后析构的时候,只会调用父类析构函数而不会调用子类析构函数。而且如果不为虚函数,父类指针就不会调用子类成员函数。

4、什么是多态机制

面向对象的三大特征:封装,继承,多态。

多态就是说同一个名字的函数可以有多种不同的功能。分为编译时的多态和运行时的多态。编译时的多态就是函数重载,包括运算符重载,编译时根据实参确定调用哪个函数。运行时的多态则和虚函数、继承有关。

深入

3.1、多态底层的实现机制

利用虚函数表,先构建一个基类,然后在基类的构造函数中会建立虚函数表,也就是一个储存虚函数地址的数组,内存地址的前四个字节保存指向虚函数表的指针,然后当多个子类继承父类之后,主函数中可以通过父类指针调用子类的继承函数。

虚函数表属于类,也属于它的子类等各种派生类。虚函数表由编译器在编译时生成,保存在.rdata只读数据段。

3.2、父类构造函数中是否可以调用虚函数

可以。不过调用会屏蔽多态机制,最终会把基类中的该虚函数作为普通函数调用,而不会调用派生类中的被重写的函数。这是因为在定义子类对象的时候,会先调用父类的构造函数,而此时虚函数表以及子类函数还没有被初始化,为了避免调用到未初始化的内存,C++标准规范中规定了在这种情况下,**即在构造子类时调用父类的构造函数,而父类的构造函数中又调用了虚成员函数,这个虚成员函数即使被子类重写,也不允许发生多态的行为。**所以使用的是静态绑定,调用了父类的函数。

3.3、构造函数可以是虚函数吗?

不可以,因为虚函数存在的 唯一目的就是为了多态。 而子类并不继承父类的构造函数,所以没有使父类构造函数变成虚函数的必要。另一方面,构造函数为类对象初始化了内存空间,里面保存了指向虚函数的指针,如果构造函数是虚函数,导致没有实例化对象,也就没有内存空间,更不会有虚函数。

3.4、静态函数可以是虚函数吗?

  • static成员不属于任何类对象或实例,所以即使给static函数加上virtual也是没有任何意义的。
  • 静态与非静态成员函数之间有一个主要区别。那就是静态成员函数没有this指针,所以无法访问vptr,进而不能访问虚函数表

5、指针和引用的区别

指针: 指针是一个变量,用于保存另一个变量的地址,指针需要用*来进行解引用,以获取它指向的内存地址上的内容。

引用: 引用是一个已经存在的变量的别名,但引用也是通过存储变量的地址来实现对变量的修改的

两者的区别:

  • 引用必须定义时初始化,不能像指针一样,指针可以定义后视情况初始化
  • 引用本身、就不能改变指向因此不存在引用常量(int &const r = a
  • 指针可以有多级,但是引用只能有一级
  • 指针的++--代表下一个数据,而引用的++--则代表数据本身的修改。
  • sizeof(引用)得到的是所指向的变量(对象)的大小,而sizeof(指针)得到的是指针本身的大小
  • 当指针和引用作为函数参数的时候,指针传递参数会生成一个临时变量,引用传递的参数不会产生一个临时变量。

6、static关键字

面向过程的static

(1)、函数中的静态变量。当变量声明为static时,其空间在程序的生命周期内分配,被存放在全局数据区。即使多次调用此函数,静态变量的空间也只分配一次,前一次调用的变量值通过下一次调用传递。

静态变量和全局变量的存储区域是一起的,一旦静态区的内存被分配,静态区的内存直到程序全部结束之后才会被释放。

面向对象的static

(1)、类中的静态变量。声明为static的变量只能被初始化一次,而且必须初始化,且类中的静态变量由对象共享,类中的静态成员变量必须在类内声明,在类外定义(const修饰可以直接定义)。

class A {
public:
	static int num;  // 类内声明
	static const int num_2 = 10; // const可以在声明时就初始化
}
int A::num = 10; // 类外初始化
int main(void) {
    A a;
    cout << a.num; // 输出10
    cout << A::num; // 输出10
}

(2)、类中的静态成员函数。 静态成员函数不依赖于类的对象。允许使用.和类名来调用静态成员函数。静态成员函数只能访问静态成员变量或其它静态成员函数。

此外还有以下特点

  • static成员变量不占用对象的内存,而是在所有对象之外开辟内存
  • static成员变量的内存既不是在声明类时分配,也不是在创建对象时分配,而是在类外初始化时分配,即没有在类外初始化的静态成员变量不能使用

7、const关键字

作用:被其修饰的值不能改变,是只读变量。必须在定义时就赋初值。

const关键字还可以用来修饰指针,如下:

const int* p; // 常量指针
int* const p; // 指针常量 

常量指针

底层const,指针指向的值被指针限定住,不能通过指针改变指向的值。但此时可以修改指针的指向。

指针常量

顶层const,指针指向的地址不能修改,并且声明时必须初始化,但是指针指向的地址的内容可以通过指针修改。

8、STL专题

(1)、vector

可变大小的数组, 支持快速随机访问,在尾部之外的位置插入或删除元素可能很慢

  • 底层原理

底层为动态数组,包括三个迭代器,beginend是已经被使用的空间范围,end_of_storage是整块连续空间包括备用空间的尾部。

当空间不够装下数据时,会自动申请另一片更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的内存空间,而当释放或者删除vector里面的数据时(v.clear()),其存储空间不释放,仅仅是清空了里面的数据。

  • reserve和resize的区别

reserve: 是直接扩充到已经确定的大小,可以减少开辟、释放空间的问题,提高效率。

resize: 可以改变有效空间的大小,因此capacity的大小也会随之改变。

  • size和capacity的区别

size表示当前vector中有多少个元素,而capacity函数表示它已经分配的内存中可以容纳多少元素。

  • vector元素是否可以是引用

vector的底层实现要求连续的 对象排列引用并非对象 ,没有实际地址,因此元素不能是引用。

(2)、list

  • 底层原理

list底层是一个双向链表,以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间,不支持随机存取,适合需要大量的插入和删除操作的数据结构。

一些函数操作:

list.unique()  // 移除数值相同的连续元素
list.sort()  // 对list进行排序,通常可以和上面的函数连用

(3)、deque

  • 底层原理

deque是一个双向开口的连续性空间(双端队列),在头尾两端插入都有理想的时间复杂度。但是整个空间并不是连续的,而是一段一段的,为了维护其整体连续的假象,并提供随机存取的接口。设计了一个中控器,用来记录deque内部每一段连续空间的地址。类似于数据结构中的map。对象的key值为地址,而value则是对应的连续的地址空间。是一个动态数组,一旦需要扩容,就是在首尾配置一段定量连续空间。

(4)、set、map、multiset、multimap

  • 底层原理

这些容器的底层实现都是红黑树。由于采用红黑树实现,因此在插入和删除时,都需要寻找结点,因此会损失一定的效率。

关于红黑树:

1、每个结点是红色或者是黑色

2、根结点是黑色的

3、每个叶结点是黑的

4、如果一个结点是红的,那么它的两个孩子结点都是黑色

5、每个结点到其子孙结点的所有路径上包含相同数目的黑色结点

  • 容器特点

set与multiset容器会根据特定的排序准则自动将元素排序,set中元素不允许重复,multiset中元素允许重复

map与multimap容器则是以keyvalue组成的pair作为元素,根据key的排序准则,自动将元素排序,在map中key不允许重复,而multimap中key值可以重复

(5)、unordered_map、unordered_set

  • 底层原理

底层是一个防冗余的哈希表(采用除留余数法)。能够尽可能的降低数据的存储和查找的时间,若产生哈希冲突,一般采用拉链法来解决冲突

  • 与map、set相比

查找速度比map、set快,通常为常数级别,但会消耗较多的内存,且构造速度较慢。

9、C++内存分区

在C++中分为五大内存分区,分别是 自由存储区全局/静态存储区常量存储区

  • 栈: 由编译器在 需要时分配,在 不需要时自动清除 的变量存储区。里面的变量通常是局部变量,函数参数等。

  • 堆: 操作系统层面的术语,为malloc等分配的内存块,用free结束自己的生命周期。

  • 自由存储区: C++层面上的术语,为new分配的内存块,它们的释放编译器不进行管理,而是由应用程序控制,一般new后需要delete,如果没有释放,操作系统会在程序结束之后回收

    因为 new 的申请是调用 malloc 的,自由存储区就和堆类似,但不等价

  • 全局/静态存储区: 全局变量和静态变量被分配到同一块内存中。

    初始化的全局变量和静态变量在一块区域,未初始化的全局变量和静态变量在相邻的另一块区域,程序结束之后由系统释放。

  • 常量存储区: 一块特殊的存储区,存放 常量 ,这些常量不允许被修改,程序结束后由系统释放

10、智能指针

在C++中,动态内存管理是用一对运算符(newdelete)完成的。但是动态管理经常出现两种问题:一是忘记释放内存,会造成内存泄漏;另一是在尚有指针引用内存的情况下就释放了它,会导致产生引用非法内存的指针。因此引入了智能指针的概念。 智能指针负责自动释放所指向的对象,可以更安全的使用动态内存。 C++中存在三种类型的智能指针,分别为shared_ptrweak_ptr以及unique_ptr

在创建智能指针时,必须提供额外的信息即指针可以指向的类型如:

class A;
shared_ptr<A> ptr;

(1)shared_ptr

其实就是对资源做引用计数——当引用计数为0时自动释放资源。可以使用ptr.use_count()来获取当前的引用数。也可以实现对数组的引用,如下:

shared_ptr<int[]> ptr(new int[13]);  // 引用计数为1
shared_ptr<int[]> prt1 = ptr;  // 引用计数为2,ptr1和ptr共享资源
shared_ptr<int> ptr3 = make_shared<int>(10);  // 这种做法较为高效。
for (int i = 0; i < 10; ++i) {
	ptr[i] = i;
}

智能指针在初始化时还可以指定删除器,如下:

void DeleteIntPtr(int* p) {
	delete p;
	p = nullptr;
}
std::shared_ptr<int> p(new int(10), DeleteIntPtr);

实现原理:

一个shared_ptr对象的内存开销要比裸指针和无自定义deleterunique_ptr对象略大。

shared_ptr需要 维护的信息 有两部分:

  • 指向共享资源的指针
  • 引用计数等共享资源的控制信息——实际上是维护一个指向控制信息的指针

当我们实现一个shared_ptr时,其实现一般如下:

class T;
std::shared_ptr<T> ptr(new T);

在使用shared_ptr时,要注意的时要避免 循环引用 ,循环引用会导致内存泄漏,经典的循环引用如下:

class A;
class B;
class A {
	std::shared_ptr<B> bptr;
	~A() {
		cout << "A is deleted" << endl;
	}
}
class B {
	std::shared_ptr<A> aptr;
	~B() {
		cout << "B is deleted" << endl;
	}
}
void TestPtr {
	std::shared_ptr<A> ap(new A);
	std::shared_ptr<B> bp(new B);
	ap->bptr = bp;
	bp->aptr = ap;
}

这样两个指针最后都不会被删除,循环引用导致apbp的引用计数为2,在离开作用域之后两者的引用计数都为1,因此两个指针都不会被析构。

(2)weak_ptr

弱引用指针weak_ptr是用来监视shared_ptr的,不会使引用计数加1,因此可以通过weak_ptr来解决循环引用的问题。(如可以B类的shared_ptr成员改为weak_ptr成员)它不管理shared_ptr内部的指针,主要使为了监视shared_ptr的生命周期,不能操作资源。使用方法如下:

shared_ptr<int> sp(new int(10));
weak_ptr<int> wp(sp);

同时weak_ptr还有一些特殊的方法:

shared_ptr<int> sp(new int(20));
weak_ptr<int> wp(sp);

wp.expired()  // 判断所观测的资源是否已经被释放
auto spt = wp.lock()  // 获取所监视的shared_ptr

当 shared_ptr 析构并释放共享资源的时候,只要 weak_ptr 对象还存在,控制块就会保留,weak_ptr 可以通过控制块观察到对象是否存活。如图:

(3)unique_ptr

unique_ptr是一个独占型的智能指针,它不允许其它的智能指针共享其内部的指针,不允许通过赋值将一个unique_ptr赋值给另一个unique_ptr。但是可以通过函数返回给其它的unique_ptr,这样它本身就不再拥有原来指针的所有权了。如下:

unique_ptr<T> ptr(new T);
unique_ptr<T> Otherptr = std::move(ptr);

11、函数指针和指针函数

(1)指针函数

简单来说,就是一个返回指针的函数,本质是一个函数。声明格式为*类型标识符 函数名(参数)

class A;
A* fun(params...);

(2)函数指针

本质是一个指针变量,该指针指向这个函数。声明格式为类型标识符 (*函数名)()

class A;
A (*fun)(params...);

函数指针需要把一个函数的地址赋值给它,具体示例如下:

int add(int x, int y) {
	return x + y;
}
int (*fun)(int x, int y);
fun = add;
fun = &add;  // 两种写法都行

可以不用取址符是因为函数名就代表函数的地址

12、操作系统专题

12.1 操作系统特性

  • 并发: 同一段时间内多个程序执行
  • 共享: 系统中的资源可以被内存中多个并发执行的线程共同使用
  • 虚拟: 通过时分复用以及空分复用(如虚拟内存),把一个物理实体虚拟为多个
  • 异步: 系统中的进程以走走停停的方式执行的,且以一种不可预知的速度推进

12.2 动态链接库与静态链接库

  • 静态链接库一般为.lib文件,在项目界面直接加入工程,程序编译时,将文件中的代码加入到程序中,不能手动移除此文件的代码。
  • 动态链接库一般为.dll文件,是程序运行时动态装入内存模块,程序运行时可以随意加载和移除。

12.3 协程

协程是一种比 线程更加轻量级 的存在,正如一个进程可以拥有多个线程一样, 一个线程也可以拥有多个协程 。协程 不被操作系统内核管理 ,完全由程序所控制,运行在 用户态 。协程不是进程也不是线程,而 是一个特殊的函数 ,这个函数可以在某个地方挂起,并且可以重新在挂起处外运行。

13、常见的设计模式

13.1 工厂模式

(1)简单工厂

简单工厂包含以下角色

  • Factory:工厂角色,负责实现创建所有实例的内部逻辑
  • Product:抽象产品角色,是所创建的所有对象的父类,负责描述所有实例所共有的公共接口
  • ConcreteProduct:具体产品角色,是创建目标,所有创建的对象都充当这个角色的某个具体类的实例。

如图:

(2)工厂方法

工厂方法模式包含以下结构

  • Product:抽象产品
  • ConcreteProduct:具体产品
  • Factory:抽象工厂
  • ConcreteFactory:具体工厂

如图:

13.2 单例模式

只对外提供getInstance方法,不提供任何构造函数 ,适用于 全局统一 如图:

用C++实现单例模式如下

class Singleton {
private:
    static Singleton* singleton;
    Singleton() {}
    Singleton(const Singleton& tmp) {}
    Singleton& operator=(const Singleton& tmp) {}
public:
    static Singleton* getInstance() {
        if (singleton == nullptr) {
            singleton = new Singleton();
        }
        else {
            return singleton;
        }
    }
};
Singleton* Singleton::singleton = nullptr;

13.3 装饰模式

适合需要(通过配置,如:diamond)来动态增减对象功能的场景 。装饰模式包含以下角色:

  • Component:抽象构件
  • ConcreteComponent:具体构件
  • Decorator:抽象装饰类
  • ConcreteDecorator:具体装饰类

如图:

注意

  • 一个装饰类的接口必须与被装饰类的接口保持相同,对于客户端来说无论是装饰之前的对象还是装饰之后的对象都可以一致对待。
  • 尽量保持具体构件类Component作为一个“轻”类,也就是说不要把太多的逻辑和状态放在具体构件类中,可以通过装饰类。

13.4 策略模式

适用于一个系统需要动态地在几种可替换算法中选择一种。不希望使用者关心算法细节,将具体算法封装进策略类中。包含以下几个角色:

  • Context:环境类
  • Strategy:抽象策略类
  • ConcreteStrategy:具体策略类

如图:

13.5 代理模式

包括远程代理,虚拟代理等多种代理。

  • 虚拟代理:如果需要创建一个资源消耗较大的对象,先创建一个消耗相对较小的对象来表示,真实对象只在需要时才会被真正创建。

13.6 观察者模式

适用于一对多的的业务场景,一个对象发生变更,会触发N个对象做相应处理的场景。例如:订单调度通知,任务状态变化等。包含以下角色:

  • Subject:目标
  • ConcreteSubject:具体目标
  • Observer:观察者
  • ConcreteObserver:具体观察者

14、C++必用初始化列表的情况

C++中类成员的初始化于初始化列表中完成,先于构造函数体执行,即成员真正的初始化发生在初始化列表中,而不是构造函数体中。所以有以下几种情况必须使用初始化列表。

  • 如果类中有一个成员是引用,由于引用必须赋有初始值,因此,引用必须使用初始化列表
  • const修饰也需要赋有初始值,因此const成员也需要初始化列表
  • 继承类中调用基类初始化构造函数,实际上就是先构造基类对象,必须使用初始化列表。
你要相信流星划过会带给我们幸运,就像现实告诉你我要心存感激
Built with Hugo
Theme Stack designed by Jimmy