C++知识点

C++知识点

C++ 知识点

说一下你理解的 C++ 中的四种智能指针

面试官你好,⾸先,说一下为什么要使用智能指针:智能指针其作用是管理一个指针,避免程序员申请的空间在函数结束时忘记释放,造成内存泄漏这种情况的发生。

然后使用智能指针可以很大程度上的避免这个问题,因为智能指针就是一个类,当超出了类的作用域是,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要⼿动释放内存空间。

常用接口

1
2
3
4
5
T* get();
T& operator*(); T* operator->();
T& operator=(const T& val);
T* release();
void reset (T* ptr = nullptr);
  • T 是模板参数, 也就是传入的类型;
  • get() 用来获取 auto_ptr 封装在内部的指针, 也就是获取原生指针;
  • operator() 重载 , operator->() 重载了->, operator=()重载了=;
  • realease() 将 auto_ptr 封装在内部的指针置为 nullptr, 但并不会破坏指针所指向的内容, 函数返回的是内部指针置空之前的值;
  • 直接释放封装的内部指针所指向的内存, 如果指定了 ptr 的值, 则将内部指针初始化为该值 (否则将其设置为 nullptr;

下面分别说一下哪四种:

1. auto_ptr(C++98 的方案,C11 已抛弃)采用所有权模式

1
2
3
auto_ptr<std::string> p1 (new string ("hello"));
auto_ptr<std::string> p2;
p2 = p1; //auto_ptr 不会报错.

此时不会报错,p2 剥夺了 p1 的所有权,但是当程序运行时访问 p1 将会报错。所以 auto_ptr 的缺点是:存在潜在的内存崩溃问题!

2. unique_ptr(替换 auto_ptr )

unique_ptr 实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露特别有用。

采用所有权模式,还是上面那个例子

1
2
3
unique_ptr<string> p3 (new string (auto));//#4 
unique_ptr<string> p4;//#5
p4 = p3;//此时会报错

编译器认为 p4=p3 ⾮法,避免了 p3 不再指向有效数据的问题。

因此,unique_ptr ⽐ auto_ptr 更安全。

3. shared_ptr(共享型,强引用)

shared_ptr 实现共享式拥有概念,多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字 share 就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。

可以通过成员函数 use_count() 来查看资源的所有者个数,除了可以通过 new 来构造,还可以通过传入auto_ptr, unique_ptr,weak_ptr 来构造。当我们调用 release() 时,当前指针会释放资源所有权,计数减一。当计数等于 0时,资源会被释放。

shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性 (auto_ptr 是独占的),在使用引用计数的机制上提供了可以共享所有权的智能指针。

4. weak_ptr(弱引用)

weak_ptr 是一种不控制对象生命周期的智能指针,它指向一个 shared_ptr 管理的对象。进行该对象的内存管理的是那个强引用的 shared_ptr。

weak_ptr 只是提供了对管理对象的一个访问⼿段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针,来协助 shared_ptr 工作,它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造,它的构造和析构不会引起引用记数的增加或减少。

weak_ptr 是用来解决 shared_ptr 相互引用时的死锁问题,如果说两个 shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为0,也就是资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和 shared_ptr 之间可以相互转化,shared_ptr 可以直接赋值给它,它可以通过调用 lock 函数来获得 shared_ptr。

当两个智能指针都是 shared_ptr 类型的时候,析构时两个资源引用计数会减一,但是两者引用计数还是为 1,导致跳出函数时资源没有被释放(的析构函数没有被调用),解决办法:把其中一个改为weak_ptr就可以。

详细介绍智能指针见C++智能指针

make_shared与直接创建shared_ptr的区别

shared_ptr结构

shared_ptr源码如下(截取片段);

1
2
3
4
5
6
7
8
9
10
template <class _Ty>
class shared_ptr : public _Ptr_base<_Ty> { // class for reference counted resource management
}

template <class _Ty>
class _Ptr_base { // base class for shared_ptr and weak_ptr
private:
element_type* _Ptr{nullptr};
_Ref_count_base* _Rep{nullptr};
}
img
img

(1)直接创建shared_ptr是异常不安全的,可能会发生内存泄漏;而make_shared是异常安全的。

参考下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//不好:可能会泄漏
//异常不安全的,编译器可能会把两个用以创建函数的两个参数的表达式重新排序。
//可能会首先为两个对象先进行内存分配,然后再试图调用二者的Widget构造函数。
//一旦其中一个构造函数调用抛出了异常,那么另一个对象的内存可能永远不会被释放了!

//异常不安全
fun(shared_ptr<Widget>(new Widget(a,b)),
shared_ptr<Widget>(new Widget(c,d)));

//异常不安全
auto* p1 = new Widget(a,b);
auto* p2 = new Widget(c,d);
shared_ptr<Widget>(p1);
shared_ptr<Widget>(p2);

//异常安全,但写法不干净
shared_ptr<Widget> sp1(new Widget(a,b));
shared_ptr<Widget> sp2(new Widget(c,d));
fun(sp1,sp2);

//异常安全,性能最好,也很干净
fun(make_shared<Widget>(a,b), make_shared<Widget>(c,d));

2)make_shared 只需要分配一次内存,而直接创建 shared_ptr 需要分配两次内存。

img

采用直接创建shared_ptr的方式,那么首先需要在堆上为原始对象分配内存,其次根据上面 shared_ptr 的构造函数可知,new _Ref_count<_Ux>(_Px) 时还需要为控制块分配一次内存。

然后我们再来看看 make_shared 源码,它采用placement new的方式,动态分配了一个 _Ref_count_obj2,这也是_Ref_count_base的一个派生类,union {_Wrap<_Ty> _Storage;}_Ty 类型的对象分配了内存空间,所以对象和控制块都被放在了一块。

(3)make_shared也存在缺陷,只有当 _Weaks 为 0 时,控制块才会调用 _Delete_this() 释放自己,weak_ptr会拖延整块内存释放时间。

上面我们提到,在_Ref_count_base的实现中,只有当 _Weaks 为 0 时,控制块才会调用 _Delete_this() 释放自己,然而**由于现在对象也被放在了控制块中,所以即使 _Uses 为 0 时,对象空间也没有算被释放,只有当 _Weaks 为 0 时, 才算是真的被释放了**。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <iostream>
#include <memory>
using namespace std;

class MyClass {
public:
MyClass() { cout << "MyClass()" << endl; }
~MyClass() { cout << "~MyClass()" << endl; }
private:
double x, y, z;
};

int alloc_times = 0;
int dealloc_times = 0;
int allocated = 0;

void* operator new(size_t size) {
void* p = std::malloc(size);
cout << "new " << size << " byte(s)" << endl;

allocated += size;
alloc_times++;
return p;
}

void operator delete(void* p) noexcept {
cout << "deleted memory" << endl;

dealloc_times++;
return std::free(p);
}

int main() {
weak_ptr<MyClass> w;
cout << "分配尺寸:" << allocated << endl;
cout << "分配次数" << alloc_times << endl;
cout << "释放次数" << dealloc_times << endl;
{
//shared_ptr<MyClass> s{new MyClass()};
shared_ptr s = std::make_shared<MyClass>();
w = s;

} //1. s析构、释放原始对象、但不释放控制块
cout << "block end--------" << endl;
cout << "分配尺寸:" << allocated << endl;
cout << "分配次数" << alloc_times << endl;
cout << "释放次数" << dealloc_times << endl;

w.reset(); //2. w析构、释放控制块
cout << "分配尺寸:" << allocated << endl;
cout << "分配次数" << alloc_times << endl;
cout << "释放次数" << dealloc_times << endl;
}

当采用直接创建shared_ptr的方式时,输入如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
分配尺寸:0
分配次数0
释放次数0
new 24 byte(s)
MyClass()
new 24 byte(s)
~MyClass()
deleted memory
block end--------
分配尺寸:48
分配次数2
释放次数1
deleted memory
分配尺寸:48
分配次数2
释放次数2

当采用make_shared的方式时,输入如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
分配尺寸:0
分配次数0
释放次数0
new 40 byte(s)
MyClass()
~MyClass()
block end--------
分配尺寸:40
分配次数1
释放次数0
deleted memory
分配尺寸:40
分配次数1
释放次数1

智能指针的引用计数是普通成员变量还是指针变量?使用static可不可以?

指针变量;

不可以用static变量,在只有一个对象的时候,该方法是合适的。但是static变量是属于类的,如下图所示,当出现第二个对象,此时新增一个shared_ptr指针,此时内部的count就变成了4,既不满足指向对象1的指针数量,也不满足指向对象2的指针数量。

在这里插入图片描述

C++ 中内存分配情况

栈:由编译器管理分配和回收,存放局部变量和函数参数。

堆:由程序员管理,需要⼿动 new malloc delete free 进行分配和回收,空间较大,但可能会出现内存泄漏和空闲碎⽚的情况。

全局/静态存储区:分为初始化和未初始化两个相邻区域,存储初始化和未初始化的全局变量和静态变量。

常量存储区:存储常量,一般不允许修改。

代码区:存放程序的⼆进制代码。

C++中指针参数传递和引用参数传递

指针参数传递本质上是值传递,它所传递的是一个地址值。值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。

引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。

从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。

C++ 中 const 和 static 关键字(定义,用途)

static 作用:控制变量的存储方式和可⻅性

作用一,修饰局部变量:一般情况下,对于局部变量在程序中是存放在栈区的,并且局部的生命周期在包含语句块执行结束时便结束了。但是如果用 static 关键字修饰的话,该变量便会存放在静态数据区,其生命周期会一直延续到整个程序执行结束。但是要注意的是,虽然用 static 对局部变量进行修饰之后,其生命周期以及存储空间发生了变化,但其作用域并没有改变,作用域还是限制在其语句块。

作用二,修饰全局变量:C++中全局变量是对整个项目都可见的,只要你在另一个文件中使用的时候加上extern关键字,如果你想让这个全局变量作用域改为仅当前文件可见,那么可以在定义的时候在前面加上static关键字。也就是说,C++中static关键字和extern关键字是冲突的。如果你在一个文件中声明了一个static全局变量,它将无法在其他文件中通过extern关键字访问。static全局变量的生命周期是整个程序的执行期间,但它的链接属性是内部的,意味着它只能在定义它的翻译单元(通常是一个源文件)中被访问

1
2
3
4
5
6
7
8
9
// header.h
static int staticVarGlobal = 10;

// source1.cpp
#include "header.h"
// staticVarGlobal 可以在这里访问,因为有include

// source2.cpp
extern int staticVarGlobal; // 错误:staticVarGlobal 在这个文件中不可见

作用三,修饰函数:用 static 修饰函数,情况和修饰全局变量类似,也是改变了函数的作用域。

作用四,修饰类的成员变量/成员函数:如果 C++ 中对类中的某个函数用 static 修饰,则表示该函数属于一个类而不是属于此类的任何特定对象;如果对类中的某个变量进行 static 修饰,则表示该变量以及所有的对象共有,存储空间中只存在一个副本,可以通过;类和对象去调用。

(补充:静态非常量数据成员,其只能在类外定义和初始化,在类内仅是声明而已。)

作用五,函数体/类成员函数内声明 static:

  • 函数体内 static 变量的作用范围为该函数体,不同于 auto 变量,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;
  • 在模块内的 static 全局变量可以被模块内所用函数访问,但不能被模块外其它函数访问;
  • 在模块内的 static 函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内;
  • 在类中的 static 成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;
  • 在类中的 static 成员函数属于整个类所拥有,这个函数不接收 this 指针,因而只能访问类的 static 成员变量。
  • static 类对象必须要在类外进行初始化,static 修饰的变量先于对象存在,所以 static 修饰的变量要在类外初始化;
  • 由于 static 修饰的类成员函数属于类,不属于对象,因此 static 类成员函数是没有 this 指针,this 指针是指向本对象的指针,正因为没有 this 指针,所以 static 类成员函数不能访问⾮ static 的类成员,只能访问 static修饰的类成员;
  • static 成员函数不能被 virtual 修饰,static 成员不属于任何对象或实例,所以加上 virtual 没有任何实际意义;静态成员函数没有 this 指针,虚函数的实现是为每一个对象分配一个 vptr 指针,而 vptr 是通过 this 指针调用的,所以不能为 virtual;虚函数的调用关系,this->vptr->ctable->virtual function。

const 关键字:含义及实现机制

const 修饰基本类型数据类型:基本数据类型,修饰符 const 可以用在类型说明符前,也可以用在类型说明符后,其结果是一样的。在使用这些常量的时候,只要不改变这些常量的值即可。

const 修饰指针变量和引用变量:如果 const 位于小星星的左侧,也叫顶层const,则 const 就是用来修饰指针所指向的变量,即指针指向为常量;如果 const 位于小星星的右侧,也叫底层const,则 const 就是修饰指针本身,即指针本身是常量

const 应用到函数中:作为参数的 const 修饰符:调用函数的时候,用相应的变量初始化 const 常量,则在函数体中,按照 const 所修饰的部分进行常量化,保护了原对象的属性。

attention

参数 const 通常用于参数为指针或引用的情况; 作为函数返回值的 const 修饰符:声明了返回值后,const 按照"修饰原则"进行修饰,起到相应的保护作用。

const 在类中的用法:const 成员变量,只在某个对象生命周期内是常量,而对于整个类而⾔是可以改变的。因为类可以创建多个对象,不同的对象其 const 数据成员值可以不同。所以不能在类的声明中初始化 const 数据成员,因为类的对象在没有创建时候,编译器不知道 const 数据成员的值是什么。const 数据成员的初始化只能在类的构造函数的初始化列表中进行。const 成员函数:const 成员函数的主要目的是防⽌成员函数修改对象的内容。要注意,const 关键字和 static 关键字对于成员函数来说是不能同时使用的,因为 static 关键字修饰静态成员函数不含有 this 指针,即不能实例化,const 成员函数⼜必须具体到某一个函数

const 修饰类对象,定义常量对象:常量对象只能调用常量函数,别的成员函数都不能调用。

note

const 成员函数中如果实在想修改某个变量,可以使用 mutable 进行修饰。成员变量中如果想建立在整个类中都恒定的常量,应该用类中的枚举常量来实现或者 static const。

C ++ 中的 const类成员函数(用法和意义):常量对象可以调用类中的 const 成员函数,但不能调用非 const 成员函数; (原因:const 成员函数承诺不修改对象的状态。因此,即使在常量对象上调用,它们也不会违反对象的不可变性。因此,对于常量对象来说,只有 const 成员函数是安全的,并且可以被调用。而非 const 成员函数没有保证不修改对象的状态。因此,在常量对象上调用非 const 成员函数会导致潜在的对象状态修改,这与常量对象的本质相违背,因此编译器不允许这样的调用。)

info

const修饰变量是也与static有一样的隐藏作用。只能在该文件中使用,其他文件不可以引用声明使用。 因此在头文件中声明const变量是没问题的,因为即使被多个文件包含,链接性都是内部的,不会出现符号冲突。

C 和 C++ 区别 (函数/类/struct/class)

C 和 C++ 在基本语句上没有过大的区别,但在语法和关键字上,C++ 有许多新增的内容。以下是一些主要的区别:

首先,C++与C在语法和关键字上有显著区别。C++允许命名空间的概念,这使得我们能够定义自己的作用域,而在C中是不允许的。此外,在关键字方面,C++采用不同的内存管理方式,引入了newdelete来动态管理内存,同时也在指针的基础上增加了引用的概念。C++还引入了一些新的关键字,例如autoexplicit,以及dynamic_cast用于增强类型安全性。

在函数方面,C++引入了函数重载虚函数的概念。函数重载允许同名函数拥有不同的参数列表,以提供更多灵活性,而C不支持函数重载。另外,C++的虚函数概念用于实现多态性。

在类方面,C语言的struct和C++中的class有着显著区别。在C++中,struct不仅可以包含成员变量,还可以包含成员函数,并且引入了成员访问权限的概念。在C++中,struct的默认成员访问权限和默认继承权限都是public,而class的默认成员访问权限和默认继承权限都是private

C++还引入了模板的概念,允许代码的重用,并且提供了更强大的STL标准库

总体来说,C语言更加侧重于算法和数据结构,强调通过代码和过程对输入进行运算处理并输出。而C++更加注重对象模型的构建,利用对象的状态信息来得到输出。因此,C的struct更适合看作是数据结构的实现体,而C++的class更适合看作是对象的实现体。

C++ 和 Java的区别

  • 指针:Java 语⾔让程序员没法找到指针来直接访问内存,没有指针的概念,并有内存的自动管理功能,从而有效的防⽌了 C++ 语⾔中的指针操作失误的影响。但并⾮ Java 中没有指针,Java 虚拟机内部中还是用了指针,保证了 Java 程序的安全。
  • 多重继承:C++ ⽀持多重继承但 Java 不⽀持,但⽀持一个类继承多个接口,实现了 C++ 中多重继承的功能,⼜避免了 C++ 的多重继承带来的不便。
  • 数据类型和类:Java 是完全面向对象的语⾔,所有的函数和变量必须是类的一部分。除了基本数据类型之外,其余的都作为类对象,对象将数据和方法结合起来,把它们封装在类中,这样每个对象都可以实现自⼰的特点和行为。 Java 中取消了 C++ 中的 struct 和 union 。
  • 自动内存管理:Java 程序中所有对象都是用 new 操作符建立在内存堆栈上,Java 自动进行无用内存回收操作,不需要程序员进行⼿动删除。而 C++ 中必须由程序员释放内存资源,增加了程序设计者的负担。Java 中当一个对象不再被用到时, 无用内存回收器将给他们加上标签。Java ⾥无用内存回收程序是以线程方式在后台运行的,利用空闲时间工作来删除。
  • Java 不⽀持操作符重载。操作符重载被认为是 C++ 的突出特性。
  • Java 不⽀持预处理功能。C++ 在编译过程中都有一个预编译阶段,Java 没有预处理器,但它提供了 import 与 C++预处理器具有类似功能。
  • 类型转换:C++ 中有数据类型隐含转换的机制,Java 中需要限时强制类型转换。
  • 字符串:C++中字符串是以 Null 终⽌符代表字符串的结束,而 Java 的字符串 是用类对象(string 和 stringBuffer)来实现的。
  • Java 中不提供 goto 语句,虽然指定 goto 作为关键字,但不⽀持它的使用,使程序简洁易读。
  • Java 的异常机制用于捕获例外事件,增强系统容错能⼒。

说一下 C++ 里是怎么定义常量的?常量存放在内存的哪个位置?

  • 对于局部常量,存放在栈区;
  • 对于全局常量,编译期一般不分配内存,放在符号表中以提高访问效率;
  • 字面值常量,⽐如字符串,放在常量区。

C++ 中重载、重写、重定义的区别

重载:翻译自 overload,是指同一可访问区内被声明的几个具有不同参数列表的同名函数,依赖于 C++函数名字的修饰会将参数加在后面,可以是参数类型,个数,顺序的不同。根据参数列表决定调用哪个函数,重载不关⼼函数的返回类型。

重写:翻译自 override,派生类中重新定义父类中除了函数体外完全相同的虚函数,注意被重写的函数不能是 static 的,一定要是虚函数,且其他一定要完全相同。要注意,重写和被重写的函数是在不同的类当中的,重写函数的访问修饰符是可以不同的,尽管 virtual 中是 private 的,派生类中重写可以改为 public。

重定义(隐藏):派生类重新定义父类中相同名字的非虚函数,参数列表 和返回类型都可以不同,即父类中除了定义成 virtual 且完全相同的同名函数才不会被派生类中的同名函数所隐藏(重定义)。

重写和隐藏的区别在于基类函数是否是虚函数

介绍 C++ 所有的构造函数

类的对象被创建时,编译系统为对象分配内存空间,并自动调用构造函数,由构造函数完成成员的初始化工作。即构造函数的作用:初始化对象的数据成员

无参数构造函数:即默认构造函数,如果没有明确写出无参数构造函数,编译器会自动生成默认的无参数构造函数,函数为空,什么也不做,如果不想使用自动生成的无参构造函数,必需要自⼰显示写出一个无参构造函数。

一般构造函数:也称重载构造函数,一般构造函数可以有各种参数形式,一个类可以有多个一般构造函数,前提是参数的个数或者类型不同,创建对象时根据传入参数不同调用不同的构造函数。

拷贝构造函数:拷贝构造函数的函数参数为对象本身的引用,用于根据一个已存在的对象复制出一个新的该类的对象,一般在函数中会将已存在的对象的数据成员的值一一复制到新创建的对象中。如果没有显示的写拷贝构造函 数,则系统会默认创建一个拷贝构造函数,但当类中有指针成员时,最好不要使用编译器提供的默认的拷贝构造函数,最好自⼰定义并且在函数中执行深拷贝。

类型转换构造函数:根据一个指定类型的对象创建一个本类的对象,也可以算是一般构造函数的一种,这⾥提出来,是想说有的时候不允许默认转换的话,要记得将其声明为 explict 的,来阻⽌一些隐式转换的发生。

赋值运算符的重载:注意,这个类似拷贝构造函数,将=右边的本类对象的值复制给=左边的对象,它不属于构造函数,=左右两边的对象必需已经被创建。如果没有显示的写赋值运算符的重载,系统也会生成默认的赋值运算符,做一些基本的拷贝工作。

这里区分赋值构造函数和拷贝构造函数:

1
2
A a1, A a2; a1 = a2;    // 调用赋值运算符
A a3 = a1; // 调用拷贝构造函数

C++ 的四种强制转换

static_cast:明确指出类型转换,一般建议将隐式转换都替换成显示转换,因为没有动态类型检查,上行转换(派生类->基类)安全,下行转换(基类->派生类) 不安全,所以主要执行⾮多态的转换操作

dynamic_cast:专门用于派生类之间的转换,type-id 必须是类指针,类引用或 void*,对于下行转换是安全的,当类型不一致时,转换过来的是空指针,而static_cast,当类型不一致时,转换过来的事错误意义的指针,可能造成⾮法访问等问题。

const_cast:专门用于 const 属性的转换,去除 const 性质,或增加 const 性质, 是四个转换符中唯一一个可以操作常量的转换符。

reinterpret_cast不到万不得已,不要使用这个转换符,高危操作。使用特点: 从底层对数据进行重新解释,依赖具体的平台,可移植性差; 可以将整形转 换为指针,也可以把指针转换为数组;可以在指针和引用之间进行肆无忌惮的转换。

指针和引用的区别

指针和引用都是一种内存地址的概念,区别呢,指针是一个实体,引用只是一个别名。在程序编译的时候,将指针和引用添加到符号表中。

指针它指向一块内存,指针的内容是所指向的内存的地址,在编译的时候,则是将“指针变量名-指针变量的地址”添加到符号表中,所以说,指针包含的内容是可以改变的,允许拷贝和赋值,有 const 和⾮ const 区别,甚⾄可以为空,sizeof 指针得到的是指针类型的大小。

而对于引用来说,它只是一块内存的别名,在添加到符号表的时候,是将"引用变量名-引用对象的地址"添加到符号表中,符号表一经完成不能改变,所以引用必须而且只能在定义时被绑定到一块内存上,后续不能更改,也不能为空,也没有 const 和⾮ const 区别。

sizeof 引用得到代表对象的大小。而 sizeof 指针得到的是指针本身的大小。另外在参数传递中,指针需要被解引用后才可以对对象进行操作,而直接对引用进行的修改会直接作用到引用对象上。

作为参数时也不同,传指针的实质是传值,传递的值是指针指向内存的地址;传引用的实质是传地址,传递的是变量的地址。

野(wild)指针与悬空(dangling)指针有什么区别?如何避免?

野指针(wild pointer):就是没有被初始化过的指针。用 gcc -Wall编译, 会出现 used uninitialized 警告。

悬空指针:是指针最初指向的内存已经被释放了的一种指针。

无论是野指针还是悬空指针,都是指向无效内存区域(这⾥的无效指的是"不安全不可控")的指针。 访问"不安全可控"(invalid)的内存区域将导致"Undefined Behavior"。

如何避免使用野指针?在平时的编码中,养成在定义指针后且在使用之前完成初始化的习惯或者使用智能指针。

说一下 const 修饰指针如何区分?

下面都是合法的声明,但是含义大不同:

1
2
3
const int * p1;  //指向整形常量 的指针,它指向的值不能修改
int * const p2; //指向整形的常量指针 ,它不能在指向别的变量,但指向(变量)的值可以修改。
const int *const p3; //指向整形常量 的 常量指针 。它既不能再指向别的常量,指向的值也不能修改。

理解这些声明的技巧在于,查看关键字const右边来确定什么被声明为常量 ,如果该关键字的右边是类型,则值是常量;如果关键字的右边是指针变量,则指针本身是常量。

理解这些声明的技巧在于,查看关键字const右边来确定什么被声明为常量 ,如果该关键字的右边是类型,则值是常量;如果关键字的右边是指针变量,则指针本身是常量。

简单说一下函数指针

从定义和用途两方面来说一下自⼰的理解:

⾸先是定义:函数指针是指向函数的指针变量。函数指针本身⾸先是一个指针变量,该指针变量指向一个具体的函数。这正如用指针变量可指向整型变量、字符型、数组一样,这⾥是指向函数。

在编译时,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样,在这些概念上是大体一致的。

其次是用途:调用函数和做函数的参数,⽐如回调函数。

示例:

1
2
3
4
char * fun(char * p) {…} // 函数fun
char * (*pf)(char * p); // 函数指针pf
pf = fun; // 函数指针pf指向函数fun
pf(p); // 通过函数指针pf调用函数fun

堆和栈区别

:由编译器进行管理,在需要时由编译器自动分配空间,在不需要时候自动回收空间,一般保存的是局部变量和函数参数等。

连续的内存空间,在函数调用的时候,⾸先入栈的主函数的下一条可执行指令的地址,然后是函数的各个参数。

大多数编译器中,参数是从右向左入栈(原因在于采用这种顺序,是为了让程序员在使用C/C++的“函数参数⻓度可变”这个特性时更方便。如果是从左向右压栈,第一个参数(即描述可变参数表各变量类型的那个参数)将被放在栈底,由于可变参的函数第一步就需要解析可变参数表的各参数类型,即第一步就需要得到上述参数,因此,将它放在栈底是很不方便的。)本次函数调用结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的地址,程序由该点继续运行,不会产生碎⽚。

栈是高地址向低地址扩展,栈底高地址,空间较小。

:由程序员管理,需要⼿动 new malloc delete free 进行分配和回收,如果不进行回收的话,会造成内存泄漏的问题。

不连续的空间,实际上系统中有一个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第一个大于等于申请大小的空间分配给程序,一般在分配程序的时候,也会空间头部写入内存大小,方便 delete 回收空间大小。当然如果有剩余的,也会将剩余的插入到空闲链表中,这也是产生内存碎⽚的原因。

堆是低地址向高地址扩展,空间较大,较为灵活。

函数传递参数的几种方式

值传递:形参是实参的拷贝,函数内部对形参的操作并不会影响到外部的实参。

指针传递:也是值传递的一种方式,形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进行操作。

引用传递:实际上就是把引用对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上面。

new / delete ,malloc / free 区别

都可以用来在堆上分配和回收空间。new / delete 是操作符,malloc/free 是库函数

执行 new 实际上执行两个过程:1.分配未初始化的内存空间(malloc);2.使用对象的构造函数对空间进行初始化;返回空间的⾸地址。如果在第一步分配空间中出现问题,则抛出 std::bad_alloc 异常,或被某个设定的异常处理函数捕获处理;如果在第⼆步构造对象时出现异常,则自动调用 delete 释放内存。

执行 delete 实际上也有两个过程:1. 使用析构函数对对象进行析构;2.回收内存空间(free)。以上也可以看出 new 和 malloc 的区别,new 得到的是经过初始化的空间,而 malloc 得到的是未初始化的空间。所以 new 是 new 一个类型,而 malloc 则是malloc 一个字节⻓度的空间。delete 和 free 同理,delete 不仅释放空间还析构对象,delete 一个类型,而 free 是释放一个字节⻓度的空间。

为什么有了 malloc/free 还需要 new/delete?因为对于⾮内部数据类型而⾔,光用 malloc/free 无法满足动态对象的要求。对象在创建的同时需要自动执行构造函数,对象在消亡以前要自动执行析构函数。由于 mallo/ free 是库函数而不是运算符,不在编译器控制权限之内,不能够把执行的构造函数和析构函数的任务强加于 malloc/free,所以有了 new/delete 操作符。

volatile 和 extern 关键字

volatile 三个特性:

  • 易变性:在汇编层面反映出来,就是两条语句,下一条语句不会直接使用上一条语句对应的 volatile 变量的寄存器内容,而是重新从内存中读取。
  • 不可优化性:volatile 告诉编译器,不要对我这个变量进行各种激进的优化,甚⾄将变量直接消除,保证程序员写在代码中的指令,一定会被执行。
  • 顺序性:能够保证 volatile 变量之间的顺序性,编译器不会进行乱序优化。

extern:

在 C 语⾔中,修饰符 extern 用在变量或者函数的声明前,用来说明 “此变量/函数是在别处定义的,要在此处引用”。

注意 extern 声明的位置对其作用域也有关系,如果是在 main 函数中进行声明的,则只能在 main 函数中调用,在其它函数中不能调用。其实要调用其它文件中的函数和变量,只需把该文件用 #include 包含进来即可,为啥要用 extern?因为用 extern 会加速程序的编译过程,这样能节省时间。

在 C++ 中 extern 还有另外一种作用,用于指示 C 或者 C++函数的调用规范。⽐如在 C++ 中调用 C 库函数,就需要在 C++ 程序中用 extern “C” 声明要引用的函数。这是给链接器用的,告诉链接器在链接的时候用C 函数规范来链接。主要原因是 C++ 和 C 程序编译完成后在目标代码中命名规则不同,用此来解决名字匹配的问题。不单单是C++可以调用C,在C中也可以用extern关键字调用C++函数。

1
2
3
4
5
6
7
8
9
10
11
12
// C++调用C函数
extern int add(...)

//xx.c
int add(){

}

//xx.cpp
extern "C" {
#include "xx.h"
}
1
2
3
4
5
6
7
8
9
10
11
// C调用C++函数

//xx.h
extern "C"{
int add();
}
//xx.cpp
int add(){
}
//xx.c
extern int add();

define 和 const 的区别(编译阶段、安全性、内存占用等)

对于 define 来说,宏定义实际上是在预编译阶段进行处理,没有类型,也就没有类型检查,仅仅做的是遇到宏定义进行字符串的展开,遇到多少次就展开多少次,而且这个简单的展开过程中,很容易出现边界效应,达不到预期的效果。因为 define 宏定义仅仅是展开,因此运行时系统并不为宏定义分配内存,但是从汇编 的⻆度来讲, define 却以立即数的方式保留了多份数据的拷贝。

对于 const 来说,const 是在编译期间进行处理的,const 有类型,也有类型检查,程序运行时系统会为 const 常量分配内存,而且从汇编的⻆度讲,const 常量在出现的地方保留的是真正数据的内存地址,只保留了一份数据的拷贝,省去了不必要的内存空间。而且,有时编译器不会为普通的 const 常量分配内存,而是直接将 const 常量添加到符号表中,省去了读取和写入内存的操作,效率更高。

此外C++ 11 中还引入了 constexpr 的关键字(并在C++ 14中进行改进),它也用于定义常量,但它更强调在编译时期求值的常量。const 变量可以在编译时期或运行时期进行赋值,而 constexpr 只能在编译时期赋值。

const 和 constexpr 的区别?见下条

const 和 constexpr 的区别

在 C++ 中,const 和 constexpr 都用于创建常量,但它们在使用和编译时期的特性上有一些区别:

  • const 可以修饰变量或者指针,表示其值不可以被修改,而 constexpr 更强调在编译时期求值的变量
  • const 变量可以在编译时期或运行时期进行赋值,而 constexpr 只能在编译时期赋值。
  • const 变量的值在编译时期不需要确定,可以是运行时期确定的值,只要在使用前初始化即可;而constexpr 变量的值必须在编译时期确定,其初始化必须是一个常量表达式。
  • 在修饰函数时,const 只能用于非静态成员函数,它保证成员函数不修改任何非静态数据;而constexpr 函数是在使用需要它的代码时,可在编译时计算其返回值的函数,当其自变量为 constexpr 值时, constexpr 函数将生成编译时 constant(常数)。 使用非 constexpr 自变量调用时,或者编译时不需要其值时,它将与正则函数一样,在运行时生成一个值。 (此双重行为使你无需编写同一函数的 constexpr 和非 constexpr 版本。)

面向对象的三大特性,并举例说明

C++ 面向对象的三大特征是:封装、继承、多态。

所谓封装

就是把客观事物封装成抽象的类,并且类可以把自⼰的数据和方法只让信任的类或者对象操作,对不可信的进行信息隐藏。一个类就是一个封装了数据以及操作这些数据的代码的逻辑实体。在一个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种方式,对象对内部数据提供了不同级别的保护,以防⽌程序中无关的部分意外的改变或错误的使用了对象的私有部分。

所谓继承

是指可以让某个类型的对象获得另一个类型的对象的属性的方法。它⽀持按级分类的概念。继承是指这样一种能⼒:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。通过继承创建的新类称为“子类”或者“派生类”,被继承的类称为“基类”、“父类”或“超类”。继承的过程,就是从一般到特殊的过程。要实现继承,可以通过“继承”和“组合”来实现。

继承概念的实现方式有两类:

实现继承:实现继承是指直接使用基类的属性和方法而无需额外编码的能⼒。

接口继承:接口继承是指仅使用属性和方法的名称、但是子类必需提供实现的能⼒。

所谓多态

指的是通过一个基类指针或引用调用一个虚函数时,会根据具体对象的类型来调用该虚函数的不同实现1。在多态中,相同的操作可以作用于不同的对象,而具体执行的操作则取决于对象的类型和特性1。

多态与⾮多态的实质区别就是函数地址是早绑定还是晚绑定的。如果函数的调用,在编译器编译期间就可以确定函数的调用地址,并产生代码,则是静态的,即地址早绑定。而如果函数调用的地址不能在编译器期间确定,需要在运行时才确定,这就属于晚绑定

多态的实现

多态其实一般就是指继承加虚函数实现的多态,对于重载来说,实际上基于的原理是,编译器为函数生成符号表时的不同规则,重载只是一种语⾔特性,与多态无关,与面向对象也无关,但这⼜是 C++中增加的新规则,所以也算属于 C++,所以如果⾮要说重载算是多态的一种,那就可以说:多态可以分为静态多态动态多态

静态多态其实就是重载,因为静态多态是指在编译时期就决定了调用哪个函数,根据参数列表来决定;

动态多态是指通过子类重写父类的虚函数来实现的,因为是在运行期间决定调用的函数,所以称为动态多态,一般情况下我们不区分这两个时所说的多态就是指动态多态。动态多态的实现与虚函数表,虚函数指针相关。

扩展:子类是否要重写父类的虚函数?子类继承父类时,父类的纯虚函数必须重写,否则子类也是一个虚类不可实例化。 定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。

虚函数相关(虚函数表,虚函数指针),虚函数的实现原理

⾸先我们来说一下,C++中多态的表象,在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数,如果是基类,就调用基类的函数。

实际上,当一个类中包含虚函数时,编译器会为该类生成一个虚函数表,保存该类中虚函数的地址,同样,派生类继承基类,派生类中自然一定有虚函数,所以编译器也会为派生类生成自⼰的虚函数表。当我们定义一个派生类对象时,编译器检测该类型有虚函数,所以为这个派生类对象生成一个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数中完成的。

后续如果有一个基类类型的指针,指向派生类,那么当调用虚函数时,就会根据所指真正对象的虚函数表指针去寻找虚函数的地址,也就可以调用派生类的虚函数表中的虚函数以此实现多态。

补充:如果基类中没有定义成 virtual,那么进行 Base B; Derived D; Base *p = D; p->function(); 这种情况下调用的则是 Base 中的 function()。因为基类和派生类中都没有虚函数的定义,那么编译器就会认为不用留给动态多态的机会,就事先进行函数地址的绑定(早绑定),详述过程就是,定义了一个派生类对象,⾸先要构造基类的空 间,然后构造派生类的自身内容,形成一个派生类对象,那么在进行类型转换时,直接截取基类的部分的内存,编译器认为类型就是基类,那么(函数符号表[不同于虚函数表的另一个表]中)绑定的函数地址也就是基类中函数的地址,所以执行的是基类的函数。

编译器处理虚函数表应该如何处理

对于派生类来说,编译器建立虚函数表的过程其实一共是三个步骤:

  1. 拷贝基类的虚函数表,如果是多继承,就拷贝每个有虚函数基类的虚函数表
  2. 当然还有一个基类的虚函数表和派生类自身的虚函数表共用了一个虚函数表,也称为某个基类为派生类的主基类
  3. 查看派生类中是否有重写基类中的虚函数, 如果有,就替换成已经重写的虚函数地址;查看派生类是否有自身的虚函数,如果有,就追加自身的虚函数到自身的虚函数表中。
1
Derived *pd = new D(); B *pb = pd; C *pc = pd;

其中 pb,pd,pc 的指针位置是不同的,要注意的是派生类的自身的内容要追加在主基类的内存块后。

虚函数

析构函数一般写成虚函数的原因

直观的讲:是为了降低内存泄漏的可能性。举例来说就是,一个基类的指针指向一个派生类的对象,在使用完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那么编译器根据指针类型就会认为当前对象的类型是基类,调用基类的析构函数,该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执行基类的析构,派生类的自身内容将无法被析构,造成内存泄漏。

如果基类的析构函数定义成虚函数,那么编译器就可以根据实际对象,执行派生类的析构函数,再执行基类的析构函数,成功释放内存。

构造函数为什么一般不定义为虚函数

  • 虚函数调用只需要知道“部分的”信息,即只需要知道函数接口,而不需要知道对象的具体类型。但是,我们要创建一个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函数不应该被定义成虚函数;
  • 而且从目前编译器实现虚函数进行多态的方式来看,虚函数的调用是通过实例化之后对象的虚函数表指针来找到虚函数的地址进行调用的,如果说构造函数是虚的,那么虚函数表指针则是不存在的,无法找到对应的虚函数表来调用虚函数,那么这个调用实际上也是违反了先实例化后调用的准则。

构造函数或析构函数中调用虚函数会怎样

实际上是不应该在构造函数或析构函数中调用虚函数的,因为这样的调用其实并不会带来所想要的效果。

举例来说就是,有一个动物的基类,基类中定义了一个动物本身行为的虚函数 action_type(),在基类的构造函数中调用了这个虚函数。

派生类中重写了这个虚函数,我们期望着根据对象的真实类型不同,而调用各自实现的虚函数,但实际上当我们创建一个派生类对象时,⾸先会创建派生类的基类部分,执行基类的构造函数,此时,派生类的自身部分还没有被初始化,对于这种还没有初始化的东⻄,C++选择当它们还不存在作为一种安全的方法。

也就是说构造派生类的基类部分是,编译器会认为这就是一个基类类型的对象,然后调用基类类型中的虚函数实现,并没有按照我们想要的方式进行。即对象在派生类构造函数执行前并不会成为一个派生类对象。

在析构函数中也是同理,派生类执行了析构函数后,派生类的自身成员呈现未定义的状态,那么在执行基类的析构函数中是不可能调用到派生类重写的方法的。所以说,我们不应该在构在函数或析构函数中调用虚函数,就算调用一般也不会达到我们想要的结果。

析构函数的作用,如何起作用?

构造函数只是起初始化值的作用,但实例化一个对象的时候,可以通过实例去传递参数,从主函数传递到其他的函数⾥面,这样就使其他的函数⾥面有值了。规则,只要你一实例化对象,系统自动回调用一个构造函数,就是你不写,编译器也自动调用一次。

析构函数与构造函数的作用相反,用于撤销对象的一些特殊任务处理,可以是释放对象分配的内存空间;特点:析构函数与构造函数同名,但该函数前面加~。

析构函数没有参数,也没有返回值,而且不能重载,在一个类中只能有一个析构函数。当撤销对象时,编译器也会自动调用析构函数。

每一个类必须有一个析构函数,用户可以自定义析构函数,也可以是编译器自动生成默认的析构函数。一般析构函数定义为类的公有成员。

构造函数的执行顺序?析构函数的执行顺序?

构造函数顺序

  • 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。
  • 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。
  • 派生类构造函数。

析构函数顺序

  • 调用派生类的析构函数;
  • 调用成员类对象的析构函数;
  • 调用基类的析构函数。

纯虚函数 (应用于接口继承和实现继承)

实际上,纯虚函数的出现就是为了让继承可以出现多种情况:

  • 有时我们希望派生类只继承成员函数的接口
  • 有时我们⼜希望派生类既继承成员函数的接口,⼜继承成员函数的实现,而且可以在派生类中可以重写成员函数以实现多态
  • 有的时候我们⼜希望派生类在继承成员函数接口和实现的情况下,不能重写缺省的实现。

其实,声明一个纯虚函数的目的就是为了让派生类只继承函数的接口,而且派生类中必需提供一个这个纯虚函数的实现,否则含有纯虚函数的类将是抽象类,不能进行实例化。

对于纯虚函数来说,我们其实是可以给它提供实现代码的,但是由于抽象类不能实例化,调用这个实现的唯一方式是在派生类对象中指出其 class 名称来调用。

静态绑定和动态绑定的介绍

说起静态绑定和动态绑定,我们⾸先要知道静态类型和动态类型,静态类型就是它在程序中被声明时所采用的类型,在编译期间确定。动态类型则是指“目前所指对象的实际类型”,在运行期间确定。

静态绑定,⼜名早绑定,绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期间。

动态绑定,⼜名晚绑定,绑定的是动态类型,所对应的函数或属性依赖于动态类型,发生在运行期间。

⽐如说,virtual 函数是动态绑定的,⾮虚函数是静态绑定的,缺省参数值也是静态绑定的。这⾥呢,就需要注意,我们不应该重新定义继承而来的缺省参数,因为即使我们重定义了,也不会起到效果。因为一个基类的指针指向一个派生类对象,在派生类的对象中针对虚函数的参数缺省值进行了重定义, 但是缺省参数值是静态绑定的,静态绑定绑定的是静态类型相关的内容,所以会出现一种派生类的虚函数实现方式结合了基类的缺省参数值的调用效果,这个与所期望的效果不同。

深拷贝和浅拷贝的区别(举例说明深拷贝的安全性)

浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。

当出现类的等号赋值时,会调用拷贝函数,在未定义显示拷贝构造函数的情况下, 系统会调用默认的拷贝函数-即浅拷贝,它能够完成成员的一一复制。当数据成员中没有指针时,浅拷贝是可行的。

但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针指向同一个地址,当对象快要结束时,会调用两次析构函数,而导致指野指针的问题。

所以,这时必需采用深拷贝。深拷贝与浅拷贝之间的区别就在于深拷贝会在堆内存中另外申请空间来存储数据,从而也就解决来野指针的问题。简而⾔之,当数据成员中有指针时,必需要用深拷贝更加安全。

引用是否能实现动态绑定,为什么可以实现?

可以。

引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

class Base
{
public:
virtual void fun()
{
cout << "base :: fun()" << endl;
}
};

class Son : public Base
{
public:
virtual void fun()
{
cout << "son :: fun()" << endl;
}
void func()
{
cout << "son :: not virtual function" <<endl;
}
};

int main()
{
Son s;
Base& b = s; // 基类类型引用绑定已经存在的Son对象,引用必须初始化
s.fun(); //son::fun()
b.fun(); //son :: fun()
return 0;
}

需要说明的是虚函数才具有动态绑定,上面代码中,Son类中还有一个非虚函数func(),这在b对象中是无法调用的,如果使用基类指针来指向子类也是一样的。

什么情况下会调用拷贝构造函数(三种情况)

  • 一个对象以值传递的方式传入函数体,需要拷贝构造函数创建一个临时对象压入到栈空间中。
  • 一个对象以值传递的方式从函数返回,需要执行拷贝构造函数创建一个临时对象作为返回值。
  • 一个对象需要通过另外一个对象进行初始化。

为什么拷贝构造函数必需时引用传递,不能是值传递?

为了防⽌递归调用。当一个对象需要以值方式进行传递时,编译器会生成代码调用它的拷贝构造函数生成一个副本,如果类 A 的拷贝构造函数的参数不是引用传递,而是采用值传递,那么就⼜需要为了创建传递给拷贝构造函数的参数的临时对象,而⼜一次调用类 A 的拷贝构造函数,这就是一个无限递归。

结构体内存对齐方式和为什么要进行内存对齐?

⾸先我们来说一下结构体中内存对齐的规则:

  • 对于结构体中的各个成员,第一个成员位于偏移为 0 的位置,以后的每个数据成员的偏移量必须是min(#pragma pack()制定的数,数据成员本身⻓度) 的倍数。
  • 在所有的数据成员完成各自对齐之后,结构体或联合体本身也要进行对齐,整体⻓度是 min(#pragma pack()制定的数,⻓度最⻓的数据成员的⻓度) 的倍数。

那么内存对齐的作用是什么呢?

  • 经过内存对齐之后,CPU 的内存访问速度大大提升。因为 CPU 把内存当成是一块一块的,块的大小可以是 2,4,8,16 个字节,因此 CPU 在读取内存的时候是一块一块进行读取的,块的大小称为内存读取粒度。⽐如说 CPU 要读取一个 4 个字节的数据到寄存器中(假设内存读取粒度是 4),如果数据是从 0 字节开始的,那么直接将 0-3 四个字节完全读取到寄存器中进行处理即可。
  • 如果数据是从 1 字节开始的,就⾸先要将前 4 个字节读取到寄存器,并再次读取 4-7 个字节数据进入寄存 器,接着把 0 字节,5,6,7 字节的数据剔除,最后合并 1,2,3,4 字节的数据进入寄存器,所以说,当内存没有对齐时,寄存器进行了很多额外的操作,大大降低了 CPU 的性能。
  • 另外,还有一个就是,有的 CPU 遇到未进行内存对齐的处理直接拒绝处理,不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。所以内存对齐还有利于平台移植。

内存泄漏的定义,如何检测与避免?

定义:内存泄漏简单的说就是申请了一块内存空间,使用完毕后没有释放掉。 它的一般表现方式是程序运行时间越长,占用内存越多,最终用尽全部内存,整个系统崩溃。由程序申请的一块内存,且没有任何一个指针指向它,那么这块内存就泄漏了。

如何检测内存泄漏

  • ⾸先可以通过观察猜测是否可能发生内存泄漏,Linux 中使用 swap 命令观察还有多少可用的交换空间,在一两分钟内键入该命令三到四次,看看可用的交换区是否在减少。
  • 还可以使用 其他一些 /usr/bin/stat 工具如 netstat、vmstat 等。如发现波段有内存被分配且从不释放,一个可能的解释就是有个进程出现了内存泄漏。
  • 当然也有用于内存调试,内存泄漏检测以及性能分析的软件开发工具 如linux的valgrind 、windows下CRT库这样的工具来进行内存泄漏的检测。

说一下平衡二叉树、高度平衡二叉树(AVL)

⼆叉树:任何节点最多只允许有两个子节点,称为左子节点和右子节点,以递归的方式定义⼆叉树为,一个⼆叉树如果不为空,便是由一个根节点和左右两个子树构成,左右子树都可能为空。

⼆叉搜索树:⼆叉搜索树可以提供对数时间的元素插入和访问。节点的放置规则是:任何节点的键值一定大于其左子树的每一个节点的键值,并小于其右子树中的每一个节点的键值。因此一直向左⾛可以取得最小值,一直向右⾛可以得到最大值。插入:从根节点开始,遇键值较大则向左,遇键值较小则向右,直到尾端,即插入点。删除:如果删除点只有一个子节点,则直接将其子节点连⾄父节点。如果删除点有两个子节点,以右子树中的最小值代替要删除的位置。

平衡⼆叉树:其实对于树的平衡与否没有一个绝对的标准,“平衡”的大致意思是:没有任何一个节点过深,不同的平衡条件会造就出不同的效率表现。以及不同的实现复杂度。有数种特殊结构例如 AVL-tree, RB-tree, AA-tree,均可以实现平衡⼆叉树。

AVL-tree :高度平衡的平衡⼆叉树(严格的平衡⼆叉树)AVL-tree 是要求任何节点的左右子树高度相差最多为 1 的平衡⼆叉树。 当插入新的节点破坏平衡性的时候,从下往上找到第一个不平衡点,需要进行单旋转,或者双旋转进行调整。

说一下红黑树(RB-tree)

红黑树的定义:

性质1:每个节点要么是黑色,要么是红色。

性质2:根节点是黑色。

性质3:每个叶子节点(NIL)是黑色。

性质4:每个红色结点的两个子结点一定都是黑色

性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点

说一下 define、const、typedef、inline 使用方法?

  1. const 与 #define 的区别

类型检查:const 定义的常量是带类型的变量,而 #define 定义的只是一个不带类型的常数。这意味着 const 在编译时会进行类型检查,可以避免一些低级错误,而 #define 只是简单的字符串替换,没有类型检查。

预处理和编译:#define 只在预处理阶段起作用,进行简单的文本替换,而 const 在编译、链接过程中起作用。

内存占用:#define 预处理后,占用代码段空间,而 const 占用数据段空间。

重定义:const 不能重定义,而 #define 可以通过 #undef 取消某个符号的定义,进行重定义。

文件重复引用:#define 有独特的功能,比如可以用来防止文件重复引用。

总的来说,const 和 #define 都可以用来定义常量,但它们的工作方式和用途有所不同。

  1. #define 和别名 typedef 的区别

执行时间不同,typedef 在编译阶段有效,typedef 有类型检查的功能;#define 是宏定义,发生在预处理阶段,不进行类型检查;

功能差异:typedef 用来定义类型的别名,定义与平台无关的数据类型,与 struct 的结合使用等。#define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。

作用域不同:#define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。而 typedef 有自⼰的作用域。

  1. define 与 inline 的区别

#define是关键字,inline是函数;

宏定义在预处理阶段进行文本替换,inline 函数在编译阶段进行替换;inline 函数有类型检查,相⽐宏定义⽐较安全;

预处理,编译,汇编,链接程序的区别

一段高级语⾔代码经过四个阶段的处理形成可执行的目标⼆进制代码:预处理器→编译器→汇编器→链接器

这⾥采用《深入理解计算机系统》的说法。

预处理阶段:写好的高级语⾔的程序文本⽐如 hello.c,预处理器根据 #开头的命令,修改原始的程序,如#include<stdio.h> 将把系统中的头文件插入到程序文本中,通常是以 .i 结尾的文件。

编译阶段:编译器将 hello.i 文件翻译成文本文件 hello.s,这个是汇编语⾔程序。高级语⾔是源程序。所以注意概念之间的区别。汇编语⾔程序是⼲嘛的?每条语句都以标准的文本格式确切描述一条低级机器语⾔指令。不同的高级语⾔翻译的汇编语⾔相同。

汇编阶段:汇编器将 hello.s 翻译成机器语⾔指令。把这些指令打包成可重定位目标程序,即 .o文件。hello.o是一个⼆进制文件,它的字节码是机器语⾔指令,不再是字符。前面两个阶段都还有字符。

链接阶段:⽐如 hello 程序调用 printf 程序,它是每个 C 编译器都会提供的标准库 C 的函数。这个函数存在于一个名叫 printf.o 的单独编译好的目标文件中,这个文件将以某种方式合并到 hello.o 中。链接器就负责这种合并。得到的是可执行目标文件

fork

说一下 fork,wait,exec 函数

父进程产生子进程使用 fork 拷贝出来一个父进程的副本,此时只拷贝了父进程的⻚表,两个进程都读同一块内存。

当有进程写的时候使用写实拷贝机制分配内存,exec 函数可以加载一个 elf 文件去替换父进程,从此父进程和子进程就可以运行不同的程序了。

fork 从父进程返回子进程的 pid,从子进程返回 0,调用了 wait 的父进程将会发生阻塞,直到有子进程状态改变,执行成功返回 0,错误返回 -1。exec 执行成功则子进程从新的程序开始运行,无返回值,执行失败返回 -1。

动态编译与静态编译

静态编译,编译器在编译可执行文件时,把需要用到的对应动态链接库中的部分提取出来,连接到可执行文件中去,使可执行文件在运行时不需要依赖于动态链接库;

动态编译,可执行文件需要附带一个动态链接库,在执行时,需要调用其对应动态链接库的命令。所以其优点一方面是缩小了执行文件本身的体积,另一方面是加快了编译速度,节省了系统资源。缺点是哪怕是很简单的程序,只用到了链接库的一两条命令,也需要附带一个相对庞大的链接库;⼆是如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行

动态链接和静态链接区别

静态连接库就是把 (lib) 文件中用到的函数代码直接链接进目标程序,程序运行的时候不再需要其它的库文件;动态链接就是把调用的函数所在文件模块(DLL)和调用函数在文件中的位置等信息链接进目标程序,程序运行的时候再从 DLL 中寻找相应函数代码,因此需要相应 DLL 文件的⽀持。

静态链接库与动态链接库都是共享代码的方式,如果采用静态链接库,则无论你愿不愿意,lib 中的指令都全部被直接包含在最终生成的 EXE 文件中了。但是若使用 DLL,该 DLL 不必被包含在最终 EXE 文件中,EXE 文件执行时可以“动态”地引用和卸载这个与 EXE 独立的 DLL 文件。

静态链接库和动态链接库的另外一个区别在于静态链接库中不能再包含其他的动态链接库或者静态库,而在动态链接库中还可以再包含其他的动态或静态链接库。

动态库就是在需要调用其中的函数时,根据函数映射表找到该函数然后调入堆栈执行。如果在当前工程中有多处对dll文件中同一个函数的调用,那么执行时,这个函数只会留下一份拷贝。但如果有多处对 lib 文件中同一个函数的调用,那么执行时该函数将在当前程序的执行空间⾥留下多份拷贝,而且是一处调用就产生一份拷贝。

动态联编与静态联编

在 C++ 中,联编是指一个计算机程序的不同部分彼此关联的过程。按照联编所进行的阶段不同,可以分为静态联编动态联编

静态联编是指联编工作在编译阶段完成的,这种联编过程是在程序运行之前完成的,⼜称为早期联编。要实现静态联编,在编译阶段就必须确定程序中的操作调用(如函数调用)与执行该操作代码间的关系,确定这种关系称为束定,在编译时的束定称为静态束定。静态联编对函数的选择是基于指向对象的指针或者引用的类型。其优点是效率高,但灵活性差。

动态联编是指联编在程序运行时动态地进行,根据当时的情况来确定调用哪个同名函数,实际上是在运行时虚函数的实现。这种联编⼜称为晚期联编,或动态束定。动态联编对成员函数的选择是基于对象的类型,针对不同的对象类型将做出不同的编译结果。

C++中一般情况下的联编是静态联编,但是当涉及到多态性和虚函数时应该使用动态联编。动态联编的优点是灵活性强,但效率低。动态联编规定,只能通过指向基类的指针或基类对象的引用来调用虚函数,其格式为:指向基类的指针变量名->虚函数名(实参表)或基类对象的引用名.虚函数名(实参表)

实现动态联编三个条件

  1. 必须把动态联编的行为定义为类的虚函数;
  2. 类之间应满足子类型关系,通常表现为一个类从另一个类公有派生而来
  3. 必须先使用基类指针指向子类型的对象,然后直接或间接使用基类指针调用虚函数

怎么用C++代码判断16位、32位还是64位系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int main() {
if (sizeof(void*) == 2) {
std::cout << "This is a 16-bit system." << std::endl;
} else if (sizeof(void*) == 4) {
std::cout << "This is a 32-bit system." << std::endl;
} else if (sizeof(void*) == 8) {
std::cout << "This is a 64-bit system." << std::endl;
} else {
std::cout << "Unknown architecture." << std::endl;
}

return 0;
}

info

16位是2字节是因为16位下地址空间有2^16,一个地址要用16位表示,所以就是2字节。32位和64位同理。

类和数据抽象

类之间的关系

has-A 包含关系:⽤以描述⼀个类由多个部件类构成,实现 has-A 关系⽤类的成员属性表示,即⼀个类的成员属性是另⼀个已经定义好的类;

use-A,⼀个类使⽤另⼀个类,通过类之间的成员函数相互联系,定义友元或者通过传递参数的方式来实现;

is-A,继承关系,关系具有传递性.

什么是类的继承?

所谓的继承就是⼀个类继承了另⼀个类的属性和方法,这个新的类包含了上⼀个类的属性和方法,被称为子类或者派⽣类,被继承的类称为父类或者基类。

继承的特点:子类拥有父类的所有属性和方法,子类可以拥有父类没有的属性和方法,子类对象可以当做父类对象使⽤;

继承的兼容性原则:继承的兼容性原则是指在需要基类对象的任何地方,都可以使用公有派生类的对象来代替。具体来说,继承的兼容性原则包括以下几点:

  • 子类对象可以当做父类对象使用。
  • 子类对象可以直接赋值给父类对象。
  • 子类对象可以直接初始化父类对象。
  • 父类指针可以直接指向子类对象。
  • 父类引用可以直接引用子类对象。

什么是组合

⼀个类⾥面的数据成员是另⼀个类的对象,即内嵌其他类的对象作为⾃⼰的成员;创建组合类的对象:⾸先创建各个内嵌对象,难点在于构造函数的设计。创建对象时既要对基本类型的成员进⾏初始化,⼜要对内嵌对象进⾏初始化。

创建组合类对象,构造函数的执⾏顺序:先调⽤内嵌对象的构造函数,然后按照内嵌对象成员在组合类中的定义顺序,与组合类构造函数的初始化列表 顺序⽆关。然后执⾏组合类构造函数的函数体,析构函数调⽤顺序相反

C++中的虚函数表存储在哪里?

存储在常量区,参考Where are virtual tables stored in C++?

在main执行之前和之后执行的代码可能是什么?

main函数执行之前,主要就是初始化系统相关资源:

  • 设置栈指针
  • 初始化静态static变量和global全局变量,即.data段的内容
  • 将未初始化部分的全局变量赋初值:数值型shortintlong等为0boolFALSE,指针为NULL等等,即.bss段的内容
  • 全局对象初始化,在main之前调用构造函数,这是可能会执行前的一些代码
  • 将main函数的参数argcargv等传递给main函数,然后才真正运行main函数
  • __attribute__((constructor))

main函数执行之后

  • 全局对象的析构函数会在main函数之后执行;
  • 可以用 atexit 注册一个函数,它会在main 之后执行;
  • __attribute__((destructor))

结构体内存对齐问题?

  • 结构体内成员按照声明顺序存储,第一个成员地址和整个结构体地址相同。
  • 未特殊说明时,按结构体中size最大的成员对齐(若有double成员,按8字节对齐。)

c++11以后引入两个关键字 alignas (opens new window)alignof (opens new window)。其中alignof可以计算出类型的对齐方式,alignas可以指定结构体的对齐方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// alignas 生效的情况
struct Info {
uint8_t a;
uint16_t b;
uint8_t c;
};

std::cout << sizeof(Info) << std::endl; // 6 2 + 2 + 2
std::cout << alignof(Info) << std::endl; // 2

struct alignas(4) Info2 {
uint8_t a;
uint16_t b;
uint8_t c;
};

std::cout << sizeof(Info2) << std::endl; // 8 4 + 4
std::cout << alignof(Info2) << std::endl; // 4

为什么sizeof(info2)是8?

alignas(4) 指定了整个结构体的对齐方式为 4 字节。这意味着结构体的起始地址和大小都必须是 4 的倍数。由于结构体中的成员也需要按照特定的对齐方式排列,因此编译器会在成员之间添加填充字节。

  • uint8_t a; 占用 1 字节。
  • ab 之间添加 1 字节的填充,以确保 b 在 2 字节边界上对齐。
  • uint16_t b; 占用 2 字节。
  • uint8_t c; 占用 1 字节。
  • c 之后添加 3 字节的填充,以确保整个结构体的大小是 4 字节的倍数。
这里声明了`alignas`之后每个成员变量之间仍然要考虑自然对齐,`uint16_t` 类型的变量自然对齐是 2 字节,这意味着它应该从一个 2 字节边界开始。所以`a`声明之后要有1字节的对齐而不是3字节的对齐。

但是alignas在某些情况下是不能使用的,具体见下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// alignas 失效的情况

struct Info {
uint8_t a;
uint32_t b;
uint8_t c;
};

std::cout << sizeof(Info) << std::endl; // 12 4 + 4 + 4
std::cout << alignof(Info) << std::endl; // 4

struct alignas(2) Info2 {
uint8_t a;
uint32_t b;
uint8_t c;
};

std::cout << sizeof(Info2) << std::endl; // 12 4 + 4 + 4
std::cout << alignof(Info2) << std::endl; // 4

alignas小于自然对齐的最小单位,则被忽略。

info

如果想使用单字节对齐的方式,使用alignas是无效的。应该使用#pragma pack(push,1)或者使用attribute((packed))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#if defined(__GNUC__) || defined(__GNUG__)
#define ONEBYTE_ALIGN __attribute__((packed))
#elif defined(_MSC_VER)
#define ONEBYTE_ALIGN
#pragma pack(push,1)
#endif

struct Info {
uint8_t a;
uint32_t b;
uint8_t c;
} ONEBYTE_ALIGN;

#if defined(__GNUC__) || defined(__GNUG__)
#undef ONEBYTE_ALIGN
#elif defined(_MSC_VER)
#pragma pack(pop)
#undef ONEBYTE_ALIGN
#endif

std::cout << sizeof(Info) << std::endl; // 6 1 + 4 + 1
std::cout << alignof(Info) << std::endl; // 1

确定结构体中每个元素大小可以通过下面这种方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#if defined(__GNUC__) || defined(__GNUG__)
#define ONEBYTE_ALIGN __attribute__((packed))
#elif defined(_MSC_VER)
#define ONEBYTE_ALIGN
#pragma pack(push,1)
#endif

/**
* 0 1 3 6 8 9 15
* +-+---+-----+---+-+-------------+
* | | | | | | |
* |a| b | c | d |e| pad |
* | | | | | | |
* +-+---+-----+---+-+-------------+
*/
struct Info {
uint16_t a : 1;
uint16_t b : 2;
uint16_t c : 3;
uint16_t d : 2;
uint16_t e : 1;
uint16_t pad : 7;
} ONEBYTE_ALIGN;

#if defined(__GNUC__) || defined(__GNUG__)
#undef ONEBYTE_ALIGN
#elif defined(_MSC_VER)
#pragma pack(pop)
#undef ONEBYTE_ALIGN
#endif

std::cout << sizeof(Info) << std::endl; // 2
std::cout << alignof(Info) << std::endl; // 1

这种处理方式是alignas处理不了的。

在传递函数参数时,什么时候该使用指针,什么时候该使用引用呢 ?

  • 需要返回函数内局部变量的内存的时候用指针(注意该局部变量一定要是动态分配内存的,否则函数结束后该局部变量就会被销毁)。使用指针传参需要开辟内存,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引用是没有意义的
  • 对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小
  • 类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式

宏定义和typedef区别?

  • 宏主要用于定义常量及书写复杂的内容;typedef主要用于定义类型别名。
  • 宏替换发生在编译阶段之前,属于文本插入替换;typedef是编译的一部分。
  • 宏不检查类型;typedef会检查数据类型。
  • 宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。
  • 注意对指针的操作,typedef char * p_char和#define p_char char *区别巨大。

info

在使用 typedef 时,如果你声明了多个变量,它们都会被视为指针类型。例如:

1
2
typedef char* p_char;
p_char a, b, c; // a, b, c 都是 char 类型的指针

而使用 #define 时,只有第一个变量会被视为指针类型,其余的将被视为普通变量。例如:

1
2
#define p_char char*
p_char x, y, z; // x 是 char 类型的指针,y 和 z 是 char 类型的变量

因此,typedef 更适合用于定义新类型,而 #define 可以用于定义值的别名,但在定义指针类型时可能会导致混淆。在现代C++中,建议尽可能避免使用宏(#define),而是使用 typedef 或 using 语句来定义类型别名。

strlen和sizeof区别

  • sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。
  • sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是'\0'的字符串。
  • 因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。
1
2
3
4
5
6
7
8
9
int main(int argc, char const *argv[])
{

const char* str = "name";

sizeof(str); // 取的是指针str的长度,是8
strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4
return 0;
}

一个指针占多少字节?

上面有提到sizeof(str)的值为8,是在64位的编译环境下的,指针的占用大小为8字节;

而在32位环境下,指针占用大小为4字节。

一个指针占内存的大小跟编译环境有关,而与机器的位数无关。

a和&a有什么区别?

假设数组int a[10]; int (*p)[10] = &a;其中:

  • a是数组名,是数组首元素地址,+1表示地址值加上一个int类型的大小,如果a的值是0x00000001,加1操作后变为0x00000005。*(a + 1) = a[1]
  • &a是数组的指针,其类型为int (*)[10](就是前面提到的数组指针),其加1时,系统会认为是数组首地址加上整个数组的偏移(10个int型变量),值为数组a尾元素后一个元素的地址。
  • (int *)p ,此时输出 *p时,其值为a[0]的值,因为被转为int *类型,解引用时按照int类型大小来读取。

note

注意&a的类型为int ()[10],int ()[10]int [10]是不一样的,int ()[10]表示一个指向int数组的指针,这个数组的长度为10,而int [10]表示一个int 类型的数组,数组的长度是10.

C++和Python的区别

包括但不限于:

  • Python是一种脚本语言,是解释执行的,而C++是编译语言,是需要编译后在特定平台运行的。python可以很方便的跨平台,但是效率没有C++高。
  • Python使用缩进来区分不同的代码块,C++使用花括号来区分
  • C++中需要事先定义变量的类型,而Python不需要,Python的基本数据类型只有数字,布尔值,字符串,列表,元组等等
  • Python的库函数比C++的多,调用起来很方便

数组名和指针(这里为指向数组首元素的指针)区别?

  • 二者均可通过增减偏移量来访问数组中的元素。
  • 数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。
  • 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了

拷贝初始化和直接初始化

  • 当用于类类型对象时,初始化的拷贝形式和直接形式有所不同:直接初始化直接调用与实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数。拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。举例如下
1
2
3
4
string str1("I am a string");//语句1 直接初始化
string str2(str1);//语句2 直接初始化,str1是已经存在的对象,直接调用拷贝构造函数对str2进行初始化
string str3 = "I am a string";//语句3 拷贝初始化,先为字符串”I am a string“创建临时对象,再把临时对象作为参数,使用拷贝构造函数构造str3
string str4 = str1;//语句4 拷贝初始化,这里相当于隐式调用拷贝构造函数,而不是调用赋值运算符函数
  • 为了提高效率,允许编译器跳过创建临时对象这一步,直接调用构造函数构造要创建的对象,这样就完全等价于直接初始化了

    (语句1和语句3等价),但是需要辨别两种情况。

    • 当拷贝构造函数为private时:语句3和语句4在编译时会报错
    • 使用explicit修饰构造函数时:如果构造函数存在隐式转换,编译时会报错

初始化和赋值的区别

  • 对于简单类型来说,初始化和赋值没什么区别
  • 对于类和复杂数据类型来说,这两者的区别就大了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class A{
public:
int num1;
int num2;
public:
A(int a=0, int b=0):num1(a),num2(b){};
A(const A& a){};
//重载 = 号操作符函数
A& operator=(const A& a){
num1 = a.num1 + 1;
num2 = a.num2 + 1;
return *this;
};
};
int main(){
A a(1,1);
A a1 = a; //拷贝初始化操作,调用拷贝构造函数
A b;
b = a;//赋值操作,对象a中,num1 = 1,num2 = 1;对象b中,num1 = 2,num2 = 2
return 0;
}

内联函数和宏定义的区别

  • 在使用时,宏只做简单字符串替换(编译前)。而内联函数可以进行参数类型检查(编译时),且具有返回值。
  • 内联函数在编译时直接将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率,并且进行参数类型检查,具有返回值,可以实现重载。
  • 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
  • 内联函数有类型检测、语法判断等功能,而宏没有

内联函数适用场景:

  • 使用宏定义的地方都可以使用 inline 函数。
  • 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率。

public,protected和private访问和继承权限/public/protected/private的区别?

访问权限

  • public的变量和函数在类的内部外部都可以访问。
  • protected的变量和函数只能在类的内部和其派生类中访问。
  • private修饰的元素只能在类内访问。
img

继承权限

派生类继承自基类的成员权限有四种状态:public、protected、private、不可见,排序为 public > protected > private。

派生类对基类成员的访问权限取决于两点:

  • 继承方式;
  • 基类成员在基类中的访问权限

基类成员在派生类中的访问权限不得高于继承方式中指定的权限,高于继承方式中指定的权限则下降为继承权限,低于继承权限则不调整,基类 private 成员在任何继承方式下都是不可见的。例如:

  • public 继承 + private 成员 => 不可见
  • public 继承 + protected 成员 => protected
  • protected 继承 + public 成员 => protected
  • private 继承 + protected 成员 => private
  • private 继承 + public 成员 => private

如何用代码判断大小端存储?

大端存储:字数据的高字节存储在低地址中

小端存储:字数据的低字节存储在低地址中

例如:32bit的数字0x12345678

小端模式中的存储方式为:

img

大端模式中的存储方式为:

img

所以在Socket编程中,往往需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输

方法一:使用强制类型转换

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
using namespace std;
int main()
{
int a = 0x1234;
//由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分
char c = (char)(a);
if (c == 0x12)
cout << "big endian" << endl;
else if(c == 0x34)
cout << "little endian" << endl;
}

方法二:巧用union联合体

什么是联合体

C++中的联合体(union)是一种特殊的数据结构,允许在同一内存位置存储不同的数据类型。在联合体中,所有成员共享同一块内存空间,占用的大小取决于联合体中最大的成员。

1
2
3
4
5
6
// 定义联合体
union MyUnion {
int intValue;
double doubleValue;
char charValue;
};

在上述示例中,MyUnion联合体有三个成员:intValue(整数)、doubleValue(双精度浮点数)和charValue(字符)。这三个成员共享相同的内存空间,因此对一个成员的修改会影响到其他成员。

需要注意的是,联合体在某一时刻只能存储其中的一个成员的值。在上面的示例中,赋值给intValue后,doubleValuecharValue的值将变得不确定,因为它们共享相同的内存位置。

联合体常用于需要在不同数据类型之间进行快速切换的情况,但在使用时需要格外小心,确保正确地理解和处理数据的类型。

使用union联合体判断大小端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;
//union联合体的重叠式存储,endian联合体占用内存的空间为每个成员字节长度的最大值
union endian
{
int a;
char ch;
};
int main()
{
endian value;
value.a = 0x1234;
//a和ch共用4字节的内存空间
if (value.ch == 0x12)
cout << "big endian"<<endl;
else if (value.ch == 0x34)
cout << "little endian"<<endl;
}

volatile、mutable和explicit关键字的用法

volatile

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

volatile 指针和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念,修饰由指针指向的对象、数据是 const 或 volatile 的:

1
const char* cpch;volatile char* vpch;

指针自身的值——一个代表地址的整数变量,是 const 或 volatile 的:

1
char* const pchc;char* volatile pchv;

attention

  • 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。
  • 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
  • C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集,只能调用那些被类的实现者明确允许在volatile对象上调用的成员函数。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile向const一样会从类传递到它的成员。

多线程下的volatile:有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。

mutable

mutable的中文意思是“可变的,易变的”,跟constant(既C++中的const)是反义词。在C++中,mutable也是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。但是,有些时候,我们需要在const函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable来修饰,并且放在函数后后面关键字位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// example1.cpp
class person
{
int m_A;
mutable int m_B;//特殊变量 在常函数里值也可以被修改
public:
void add() const//在函数里不可修改this指针指向的值 常量指针
{
m_A = 10;//错误 不可修改值,this已经被修饰为常量指针
m_B = 20;//正确
}
};

// example2.cpp
class person
{
public:
int m_A;
mutable int m_B;//特殊变量 在常函数里值也可以被修改
};

int main()
{
const person p = person();//修饰常对象 不可修改类成员的值
p.m_A = 10;//错误,被修饰了指针常量
p.m_B = 200;//正确,特殊变量,修饰了mutable
}

explicit

explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显式的方式进行类型转换,注意以下几点:

  • explicit 关键字只能用于类内部的构造函数声明上
  • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换

什么情况下会调用拷贝构造函数

  • 用类的一个实例化对象去初始化另一个对象的时候(注意初始化和赋值的区别)
  • 函数的参数是类的对象时(非引用传递)
  • 函数的返回值是函数体内局部对象的类的对象时 ,此时虽然发生(Named return Value优化)NRV优化,但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数

第三种情况在Linux g++ 下则不会发生拷贝构造函数,不仅如此即使返回局部对象的引用,依然不会发生拷贝构造函数

总结就是:即使发生NRV优化的情况下,Linux+ g++的环境是不管值返回方式还是引用方式返回的方式都不会发生拷贝构造函数,而Windows + VS2019在值返回的情况下发生拷贝构造函数,引用返回方式则不发生拷贝构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class A
{
public:
A() {};
A(const A& a)
{
cout << "copy constructor is called" << endl;
};
~A() {};
};

void useClassA(A a) {}

A getClassA()//此时会发生拷贝构造函数的调用,虽然发生NRV优化,但是依然调用拷贝构造函数
{
A a;
return a;
}


//A& getClassA2()// VS2019下,此时编辑器会进行(Named return Value优化)NRV优化,不调用拷贝构造函数 ,如果是引用传递的方式返回当前函数体内生成的对象时,并不发生拷贝构造函数的调用
//{
// A a;
// return a;
//}


int main()
{
A a1,a3,a4;
A a2 = a1; //调用拷贝构造函数,对应情况1
useClassA(a1);//调用拷贝构造函数,对应情况2
a3 = getClassA();//发生NRV优化,但是值返回,依然会有拷贝构造函数的调用 情况3
a4 = getClassA2(a1);//发生NRV优化,且引用返回自身,不会调用
return 0;
}

C++中有几种类型的new

在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new.

plain new

言下之意就是普通的new,就是我们常用的new,在C++中定义如下:

1
2
void* operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();

因此plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL,因此通过判断返回值是否为NULL是徒劳的,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
using namespace std;
int main()
{
try
{
char *p = new char[10e11];
delete p;
}
catch (const std::bad_alloc &ex)
{
cout << ex.what() << endl;
}
return 0;
}
//执行结果:bad allocation

nothrow new

nothrow new在空间分配失败的情况下是不抛出异常,而是返回NULL,定义如下:

1
2
void * operator new(std::size_t,const std::nothrow_t&) throw();
void operator delete(void*) throw();

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <string>
using namespace std;

int main()
{
char *p = new(nothrow) char[10e11];
if (p == NULL)
{
cout << "alloc failed" << endl;
}
delete p;
return 0;
}
//运行结果:alloc failed

placement new

这种new允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:

1
2
void* operator new(size_t,void*);
void operator delete(void*,void*);

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <string>
using namespace std;
class ADT{
int i;
int j;
public:
ADT(){
i = 10;
j = 100;
cout << "ADT construct i=" << i << "j="<<j <<endl;
}
~ADT(){
cout << "ADT destruct" << endl;
}
};
int main()
{
char *p = new(nothrow) char[sizeof ADT + 1];
if (p == NULL) {
cout << "alloc failed" << endl;
}
ADT *q = new(p) ADT; //placement new:不必担心失败,只要p所指对象的的空间足够ADT创建即可
//delete q;//错误!不能在此处调用delete q;
q->ADT::~ADT();//显示调用析构函数
delete[] p;
return 0;
}
//输出结果:
//ADT construct i=10j=100
//ADT destruct

C++的异常处理的方法

在程序执行过程中,由于程序员的疏忽或是系统资源紧张等因素都有可能导致异常,任何程序都无法保证绝对的稳定,常见的异常有:

  • 数组下标越界
  • 除法计算时除数为0
  • 动态分配空间时空间不足
  • ···

如果不及时对这些异常进行处理,程序多数情况下都会崩溃。

try、throw和catch关键字

C++中的异常处理机制主要使用trythrowcatch三个关键字,其在程序中的用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
using namespace std;
int main()
{
double m = 1, n = 0;
try {
cout << "before dividing." << endl;
if (n == 0)
throw - 1; //抛出int型异常
else if (m == 0)
throw - 1.0; //拋出 double 型异常
else
cout << m / n << endl;
cout << "after dividing." << endl;
}
catch (double d) {
cout << "catch (double)" << d << endl;
}
catch (...) {
cout << "catch (...)" << endl;
}
cout << "finished" << endl;
return 0;
}
//运行结果
//before dividing.
//catch (...)
//finished

代码中,对两个数进行除法计算,其中除数为0。可以看到以上三个关键字,程序的执行流程是先执行try包裹的语句块,如果执行过程中没有异常发生,则不会进入任何catch包裹的语句块,如果发生异常,则使用throw进行异常抛出,再由catch进行捕获,throw可以抛出各种数据类型的信息,代码中使用的是数字,也可以自定义异常class。catch根据throw抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用catch(...)的方式捕获任何异常(不推荐)。当然,如果catch了异常,当前函数如果不进行处理,或者已经处理了想通知上一层的调用者,可以在catch里面再throw异常。

函数的异常声明列表

有时候,程序员在定义函数的时候知道函数可能发生的异常,可以在函数声明和定义时,指出所能抛出异常的列表,写法如下:

1
int fun() throw(int,double,A,B,C){...};

这种写法表名函数可能会抛出int,double型或者A、B、C三种类型的异常,如果throw中为空,表明不会抛出任何异常,如果没有throw则可能抛出任何异常

C++标准异常类 exception

C++ 标准库中有一些类代表异常,这些类都是从 exception 类派生而来的,如下图所示

img
  • bad_typeid:使用typeid运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    #include <iostream>
    #include <typeinfo>
    using namespace std;

    class A{
    public:
    virtual ~A();
    };

    using namespace std;
    int main() {
    A* a = NULL;
    try {
    cout << typeid(*a).name() << endl; // Error condition
    }
    catch (bad_typeid){
    cout << "Object is NULL" << endl;
    }
    return 0;
    }
    //运行结果:bject is NULL

  • bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常

  • bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常

  • out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常

delete p、delete [] p、allocator都有什么作用?

  1. 动态数组管理new一个数组时,[]中必须是一个整数,但是不一定是常量整数,普通数组必须是一个常量整数;

  2. new动态数组返回的并不是数组类型,而是一个元素类型的指针;

  3. delete[]时,数组中的元素按逆序的顺序进行销毁;

  4. new在内存分配上面有一些局限性,new的机制是将内存分配和对象构造组合在一起,同样的,delete也是将对象析构和内存释放组合在一起的。allocator将这两部分分开进行,allocator申请一部分内存,不进行初始化对象,只有当需要的时候才进行初始化操作。

new和delete的实现原理, delete是如何知道释放内存的大小的?

在分配内存的时候,编译器已经在内存块的前面存储了这些信息。

malloc申请的存储空间能用delete释放吗?

不能,malloc /free主要为了兼容C,new和delete 完全可以取代malloc /free的。

malloc /free的操作对象都是必须明确大小的,而且不能用在动态类上。

new 和delete会自动进行类型检查和大小,malloc/free不能执行构造函数与析构函数,所以动态对象它是不行的。

当然从理论上说使用malloc申请的内存是可以通过delete释放的。不过一般不这样写的。而且也不能保证每个C++的运行时都能正常。

malloc与free的实现原理?

  1. 在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk、mmap、,munmap这些系统调用实现的;

  2. brk是将「堆顶」指针向高地址移动,获得新的内存空间,mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系;

  3. malloc小于128k的内存,使用brk分配内存,将「堆顶」指针往高地址推;malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配;brk分配的内存需要等到高地址内存释放以后才能释放,而mmap分配的内存可以单独释放。当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。在上一个步骤free的时候,发现最高地址空闲内存超过128K,于是内存紧缩。

  4. malloc是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。

malloc、realloc、calloc的区别

  1. malloc函数

    1
    2
    void* malloc(unsigned int num_size);
    int *p = malloc(20*sizeof(int));申请20int类型的空间;

  2. calloc函数

    1
    2
    void* calloc(size_t n,size_t size);
    int *p = calloc(20, sizeof(int));

    省去了人为空间计算;malloc申请的空间的值是随机初始化的,calloc申请的空间的值是初始化为0的;

  3. realloc函数

    1
    void realloc(void *p, size_t new_size);

    给动态分配的空间分配额外的空间,用于扩充容量。

类成员初始化方式?构造函数的执行顺序 ?为什么用成员初始化列表会快一些?

  1. 赋值初始化,通过在函数体内进行赋值初始化;列表初始化,在冒号后使用初始化列表进行初始化。

这两种方式的主要区别在于:

函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。

列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。

  1. 一个派生类构造函数的执行顺序如下:

① 虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。

② 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。

③ 类类型的成员对象的构造函数(按照成员对象在类中的定义顺序)

④ 派生类自己的构造函数。

  1. 方法一是在构造函数当中做赋值的操作,而方法二是做纯粹的初始化操作。我们都知道,C++的赋值操作是会产生临时对象的。临时对象的出现会降低程序的效率。

有哪些情况必须用到成员列表初始化?作用是什么?

必须使用成员初始化的四种情况:

  1. 当初始化一个引用成员时;

  2. 当初始化一个常量成员时;

  3. 当调用一个基类的构造函数,而它拥有一组参数时;

  4. 当调用一个成员类的构造函数,而它拥有一组参数时;

note

成员初始化列表做了什么?编译器会一一操作初始化列表,以适当的顺序在构造函数之内安插初始化操作,并且在任何显示用户代码之前;初始化列表中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表的顺序决定的

C++中新增了string,它与C语言中的 char *有什么区别吗?它是如何实现的?

string继承自basic_string,其实是对char进行了封装,封装的string包含了char数组,容量,长度等等属性。

string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2*n),然后将原字符串拷贝过去,并加上新增的内容。

对象复用的了解,零拷贝的了解

对象复用

对象复用其本质是一种设计模式:Flyweight享元模式。

通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。

零拷贝

零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。

零拷贝技术可以减少数据拷贝和共享总线操作的次数。

在C++中,vector的一个成员函数emplace_back()很好地体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:使用push_back()函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入的元素原地构造(原地构造指的是直接在容器的末尾添加一个元素,不用创建临时元素),不需要触发拷贝构造和转移构造,效率更高。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <vector>
#include <string>
#include <iostream>
using namespace std;

struct Person
{
string name;
int age;
//初始构造函数
Person(string p_name, int p_age): name(std::move(p_name)), age(p_age)
{
cout << "I have been constructed" <<endl;
}
//拷贝构造函数
Person(const Person& other): name(std::move(other.name)), age(other.age)
{
cout << "I have been copy constructed" <<endl;
}
//转移构造函数
Person(Person&& other): name(std::move(other.name)), age(other.age)
{
cout << "I have been moved"<<endl;
}
};

int main()
{
vector<Person> e;
cout << "emplace_back:" <<endl;
e.emplace_back("Jane", 23); //不用构造类对象

vector<Person> p;
cout << "push_back:"<<endl;
p.push_back(Person("Mike",36));
return 0;
}
//输出结果:
//emplace_back:
//I have been constructed
//push_back:
//I have been constructed
//I am being moved.

成员初始化列表的概念,为什么用它会快一些?

成员初始化列表的概念

在类的构造函数中,不在函数体内对成员变量赋值,而是在构造函数的花括号前面使用冒号和初始化列表赋值

效率

用初始化列表会快一些的原因是,对于自定义类型,它少了一次调用构造函数的过程,而在函数体中赋值则会多一次调用。而对于内置数据类型则没有差别。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
using namespace std;
class A
{
public:
A()
{
cout << "默认构造函数A()" << endl;
}
A(int a)
{
value = a;
cout << "A(int "<<value<<")" << endl;
}
A(const A& a)
{
value = a.value;
cout << "拷贝构造函数A(A& a): "<<value << endl;
}
int value;
};

class B
{
public:
B() : a(1)
{
b = A(2);
}
A a;
A b;
};
int main()
{
B b;
}

//输出结果:
//默认构造函数A()
//A(int 2)
//拷贝构造函数A(A& a): 2


// 注:
// 在B的构造函数中,先初始化了B类的成员变量a(使用A类的默认构造函数),然后初始化了成员变量b,通过调用A类的带有int参数的构造函数(A(2))。
// A类的带有int参数的构造函数被调用,输出"A(int 2)"。
// 接着,A类的拷贝构造函数被调用,因为在B类的构造函数中,要将A类对象b初始化为A类对象(A(2)),这会触发拷贝构造函数。拷贝构造函数输出"拷贝构造函数A(A& a): 2"。

从代码运行结果可以看出,在构造函数体内部初始化的对象b多了一次构造函数的调用过程,而对象a则没有。由于对象成员变量的初始化动作发生在进入构造函数之前,对于内置类型没什么影响,但如果有些成员是类,那么在进入构造函数之前,会先调用一次默认构造函数,进入构造函数后所做的事其实是一次赋值操作(对象已存在),所以如果是在构造函数体内进行赋值的话,等于是一次默认构造加一次赋值,而初始化列表只做一次赋值操作。

C++函数调用的压栈过程

  1. 当前函数运行状态压栈
  2. 被调用函数的返回地址压栈
  3. 被调用函数的参数(从右到左压栈)
  4. 被调用函数的变量压栈

形参在函数未调用之前都是没有分配存储空间的,在函数调用结束之后,形参弹出栈空间,清除形参空间。

数组作为参数的函数调用方式是地址传递,形参和实参都指向相同的内存空间,调用完成后,形参指针被销毁,但是所指向的内存空间依然存在,不能也不会被销毁。

当函数有多个返回值的时候,不能用普通的 return 的方式实现,需要通过传回地址的形式进行,即地址/指针传递。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

int f(int n)
{
cout << n << endl;
return n;
}

void func(int param1, int param2)
{
int var1 = param1;
int var2 = param2;
printf("var1=%d,var2=%d", f(var1), f(var2));//如果将printf换为cout进行输出,输出结果则刚好相反
}

int main(int argc, char* argv[])
{
func(1, 2);
return 0;
}
//输出结果
//2
//1
//var1=1,var2=2

当函数从入口函数main函数开始执行时,编译器会将我们操作系统的运行状态main函数的返回地址main的参数mian函数中的变量、进行依次压栈

当main函数开始调用func()函数时,编译器此时会将main函数的运行状态进行压栈再将func()函数的返回地址func()函数的参数从右到左func()定义变量依次压栈

当func()调用f()的时候,编译器此时会将func()函数的运行状态进行压栈,再将的返回地址、f()函数的参数从右到左、f()定义变量依次压栈

从代码的输出结果可以看出,函数f(var1)、f(var2)依次入栈,而后先执行f(var2),再执行f(var1),最后打印整个字符串,将栈中的变量依次弹出,最后主函数返回。

写C++代码时有一类错误是 coredump ,很常见,你遇到过吗?怎么调试这个错误?

coredump是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做core的文件,这个core文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。

调试

使用gdb命令对core文件进行调试

以下例子在Linux上编写一段代码并导致segment fault 并产生core文件

1
2
mkdir coredumpTest
vim coredumpTest.cpp

在编辑器内键入

1
2
3
4
5
6
7
#include<stdio.h>
int main(){
int i;
scanf("%d",i);//正确的应该是&i,这里使用i会导致segment fault
printf("%d\n",i);
return 0;
}

编译:

1
g++ coredumpTest.cpp -g -o coredumpTest

运行:

1
./coredumpTest

使用gdb调试:

1
gdb [可执行文件名] [core文件名]

gdb怎么调试怎么打断点

gdb调试见上一条

1
2
3
4
5
6
7
g++ Test.cpp -g -o Test # 注意编译的时候要加上-g选项

gdb ./Test

break main:10 # 在main的第10行添加断点,也可以用缩写b

run

gdb怎么查看一个很多元素数组的后部分内容?

假设有一个数组:

1
int arr[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

编译并调试:

1
2
3
4
5
6
7
8
9
g++ Test.cpp -g -o Test # 注意编译的时候要加上-g选项

gdb ./Test

break main:10 # 在合适的地方打上断点

run # 运行到合适的位置之后可以开始查看数组的元素了

x/5dw &arr[5] #

x/5dw 命令的含义是查看从 &arr[5](数组的第6个元素)开始的5个整数(d 表示以十进制显示,w 表示以4字节为单位显示,可以根据实际情况调整)。

注意:&arr[5] 是数组的第6个元素的地址,可以根据实际情况选择数组中的任何一个元素。

说说移动构造函数

  1. 如果我们用对象a初始化对象b,构造出对象b之后对象a我们就不再使用了,但是对象a的空间在析构之前还在,既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;

  2. 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。

    所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间;

  3. 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。

C++中将临时变量(局部变量)作为返回值时的处理过程

首先需要明白一件事情,临时变量,在函数调用过程中是被压到程序进程的栈中的,当函数退出时,临时变量出栈,即临时变量已经被销毁,临时变量占用的内存空间没有被清空,但是可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了

C语言里规定:16bit程序中,返回值保存在ax寄存器中,32bit程序中,返回值保持在eax寄存器中,如果是64bit返回值,edx寄存器保存高32bit,eax寄存器保存低32bit

由此可见,函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中与临时变量的生命周期没有关系

如果我们需要返回值,一般使用赋值语句就可以了。

如何获得结构成员相对于结构开头的字节偏移量

使用<stddef.h>头文件中的,offsetof宏。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <stddef.h>
using namespace std;

struct S
{
int x;
char y;
int z;
double a;
};
int main()
{
cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 12
return 0;
}

在Visual Studio 2019 + Win10 下的输出情况如下

1
2
3
4
cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 16 这里是 16的位置,因为 double是8字节,需要找一个8的倍数对齐,

当然了,如果加上 #pragma pack(4) 指定4字节对齐方式就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#pragma pack(4)
struct S
{
int x;
char y;
int z;
double a;
};
void test02()
{
cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 12
}

S结构体中各个数据成员的内存空间划分如下所示,需要注意内存对齐

img

全局变量和局部变量有什么区别?

生命周期不同:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;

使用方式不同:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。

操作系统和编译器通过内存分配的位置可以区分两者,全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。

指针加减计算要注意什么?

指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址,如果再进行操作就会很危险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

int main()
{
int *a, *b, c;
a = (int*)0x500;
b = (int*)0x520;
c = b - a;
printf("%d\n", c); // 8
a += 0x020;
c = b - a;
printf("%d\n", c); // -24
return 0;
}

首先变量a和b都是以16进制的形式初始化,将它们转成10进制分别是1280(516^2=1280)和1312(516^2+2*16=1312), 那么它们的差值为32,也就是说a和b所指向的地址之间间隔32个位,但是考虑到是int类型占4位,所以c的值为32/4=8

a自增16进制0x20之后,其实际地址变为1280 + 2164 = 1408,(因为一个int占4位,所以要乘4),这样它们的差值就变成了1312 - 1280 = -96,所以c的值就变成了-96/4 = -24

遇到指针的计算,需要明确的是指针每移动一位,它实际跨越的内存间隔是指针类型的长度,建议都转成10进制计算,计算结果除以类型长度取得结果

怎样判断两个浮点数是否相等?

对两个浮点数判断大小和是否相等不能直接用==来判断,会出错!明明相等的两个数比较反而是不相等!对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!浮点数与0的比较也应该注意。与浮点数的表示方式有关。

类如何实现只能静态分配和只能动态分配

只能静态分配:把newdelete运算符重载为private属性

只能动态分配:把构造函数、析构函数设为protect属性,再用子类动态创建

note

建立类的对象有两种方式:静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;动态建立A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象。只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有。

### 如果想将某个类用作基类,为什么该类必须定义而非声明?

派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。所以必须定义而非声明。

继承机制中对象之间如何转换?指针和引用之间如何转换?

向上类型转换:将派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向上类型转换是安全的。

向下类型转换:将基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,因为一个基类对应几个派生类,所以向下类型转换时不知道对应哪个派生类,所以在向下类型转换时必须加动态类型识别技术。RTTI技术,用dynamic_cast进行向下类型转换。

知道C++中的组合吗?它与继承相比有什么优缺点吗?

继承

继承是Is a 的关系,比如说Student继承Person,则说明Student is a Person。继承的优点是子类可以重写父类的方法来方便地实现对父类的扩展。

继承的缺点有以下几点:

①:父类的内部细节对子类是可见的。

②:子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。

③:如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。

组合

组合是一种对象关系,它表示一个对象包含另一个对象作为其部分

组合的优点

  1. 当前对象只能通过所包含的那个对象去调用其方法,所以被包含的对象的内部细节对当前对象时不可见的。

  2. 当前对象与包含的对象是一个低耦合关系,如果修改被包含对象的类中代码不需要修改当前对象类的代码。

  3. 当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。

组合的缺点

  1. 容易产生过多的对象.
  2. 为了能组合多个对象,必须仔细对接口进行定义。

函数指针

什么是函数指针?

函数指针指向的是特殊类型的指针,用于存储函数的地址,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。

函数指针的声明方法

1
int (*pf)(const int&, const int&);

上面的pf就是一个函数指针,指向函数的返回类型为int,并带有两个const int&参数的函数。注意*pf两边的括号是必须的,否则上面的定义就变成了:

1
int *pf(const int&, const int&);

声明了一个函数,函数的返回值是int *,参数是const int&const int&

为什么有函数指针

  1. 抽象和封装:函数指针可以使得函数的具体实现被抽象和封装,使得代码更加模块化。通过函数指针,我们可以将函数作为参数传递给其他函数,或者将函数作为结构体或类的成员。
  2. 回调函数:函数指针是实现回调函数的一种常见方式。回调函数是在某个特定事件或条件发生时被调用的函数。通过函数指针,我们可以动态地指定需要在特定事件发生时执行的函数,增加了程序的灵活性和可扩展性。
  3. 动态函数调用函数指针的值可以在运行时进行更改,从而实现动态函数调用。这可以用于实现多态和类似接口的概念,使得程序可以根据运行时的上下文,选择合适的函数进行调用。
  4. 运行时多态:函数指针可以实现运行时多态。这种数据成为专门的数据类型时就是函数指针。

一个函数名就是一个指针,它指向函数的代码

一个函数地址是该函数的进入点,也就是调用函数的地址。函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变元传递给其他函数;

两种方法赋值

指针名 = 函数名; 指针名 = &函数名

说一说你理解的内存对齐以及原因

在C++中,内存对齐是将变量的起始地址调整为其自身大小或者某个值的倍数,这个值称为“对齐系数”。

内存对齐的原因主要有以下几点:

  1. 提高程序性能:大部分处理器并不是按字节块来存取内存的,而是以双字节、四字节、8字节、16字节甚至32字节为单位来存取内存。如果没有内存对齐机制,数据可以任意存放,处理器在取数据时可能需要做很多额外的操作,而有了内存对齐的机制后,处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,从而提高了效率。
  2. 平台移植:不同的硬件平台对内存对齐的要求可能不同,内存对齐可以帮助代码在不同的平台上运行。
  3. SIMD优化:SIMD(Single Instruction Multiple Data)技术要求数据在内存中是对齐的,内存对齐可以帮助实现这种优化。

你知道printf函数的实现原理是什么吗?

在C/C++中,对函数参数的扫描是从右向左的。

C/C++的函数参数是通过压入堆栈的方式来给函数传参数的(堆栈是一种先进后出的数据结构),最先压入的参数最后出来,在计算机的内存中,数据有2块,一块是堆,一块是栈(函数参数及局部变量在这里),而栈是从内存的高地址向低地址生长的,控制生长的就是堆栈指针了,最先压入的参数是在最上面,就是说在所有参数的最后面,最后压入的参数在最下面,结构上看起来是第一个,所以最后压入的参数总是能够被函数找到,因为它就在堆栈指针的上方。

printf的第一个被找到的参数就是那个字符指针,就是被双引号括起来的那一部分,函数通过判断字符串里控制参数的个数来判断参数个数及数据类型,通过这些就可算出数据需要的堆栈指针的偏移量了,下面给出printf("%d,%d",a,b);(其中a、b都是int型的)的汇编代码

为什么模板类一般都是放在一个h文件中

在分离式编译的环境下,编译器编译某一个.cpp文件时并不知道另一个.cpp文件的存在,也不会去查找(当遇到未决符号时它会寄希望于链接器)。这种模式在没有模板的情况下运行良好,但遇到模板时就傻眼了,因为模板仅在需要的时候才会实例化出来。

所以,当编译器只看到模板的声明时,它不能实例化该模板,只能创建一个具有外部连接的符号并期待链接器能够将符号的地址决议出来。

然而当实现该模板的.cpp文件中没有用到模板的实例时,编译器懒得去实例化,所以,整个工程的.obj中就找不到一行模板实例的二进制代码,于是链接器也黔驴技穷了。

cout和printf有什么区别?

很多人认为cout<<是一个函数,其实不是的,它是类std::ostream全局对象

cout<<后可以跟不同的类型是因为cout<<已存在针对各种类型数据的重载,所以会自动识别数据的类型。

输出过程会首先将输出字符放入缓冲区,然后输出到屏幕。

cout是有缓冲输出:

1
2
3
4
5
6
cout < < "abc " < <endl;  	// 这行代码会输出字符串 "abc ",然后输出一个换行符,并且立即刷新输出缓冲区。这意味着,一旦执行这行代码,字符串 "abc " 就会立即显示在屏幕上。

// 等价于
cout < < "abc\n "; // 也会输出字符串 "abc ",然后输出一个换行符,但是它不会立即刷新输出缓冲区。这意味着,如果输出缓冲区没有满,字符串 "abc " 可能不会立即显示在屏幕上,所以加上fulsh才和上面等价
cout < <flush;

flush立即强迫缓冲输出。

printf是行缓冲输出,不是无缓冲输出

你知道重载运算符吗?

  1. 只能重载已有的运算符,而无权发明新的运算符;对于一个重载的运算符,其优先级和结合律与内置类型一致才可以;不能改变运算符操作数个数;
  2. 两种重载方式:成员运算符和非成员运算符,成员运算符比非成员运算符少一个参数,因为成员成员运算符有一个隐藏的参数就是对象本身;
  3. 引入运算符重载,是为了实现类的多态性;
  4. 当重载的运算符是成员函数时,this绑定到左侧运算符对象;
  5. 当运算符既是一元运算符又是二元运算符(+,-,*,&)时,从参数的个数推断到底定义的是哪种运算符;
  6. 下标运算符必须是成员函数,下标运算符通常以所访问元素的引用作为返回值,同时最好定义下标运算符的常量版本和非常量版本;
  7. 箭头运算符必须是类的成员,解引用通常也是类的成员;重载的箭头运算符必须返回类的指针;

当程序中有函数重载时,函数的匹配原则和顺序是什么?

  1. 名字查找
  2. 确定候选函数
  3. 寻找最佳匹配

如何消除隐式转换?

C++中提供了explicit关键字,在构造函数声明的时候加上explicit关键字,能够禁止隐式转换。

C++如何处理多个异常

在C++中,你可以使用try-catch语句块来处理多个异常。当你的代码可能会抛出多种类型的异常时,你可以在try-catch语句块中列出多个catch语句,每个catch语句用于捕获一种特定类型的异常。

定义和声明的区别

从编译原理上来说,声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。而定义就是分配了内存。

如何在不使用额外空间的情况下,交换两个数?你有几种方法

1
2
3
4
5
6
7
8
9
10
1)  算术

x = x + y;
y = x - y;
x = x - y;

2) 异或
x = x^y;// 只能对int,char..
y = x^y;
x = x^y;

strcpy和memcpy的区别

  1. strcpy只能复制字符串,而memcpy可以复制任意内容
  2. strcpy不需要指定长度,它复制到‘\0’自动结束,而memcpy需要指定第三个参数来确定复制的长度

程序在执行int main(int argc, char *argv[])时的内存结构,你了解吗?

参数的含义是程序在命令行下运行的时候,需要输入argc 个参数每个参数是以char 类型输入的,依次存在数组里面,数组是 argv[],所有的参数在指针

char * 指向的内存中,数组的中元素的个数为 argc 个,第一个参数为程序的名称

strcpy函数和strncpy函数的区别?哪个函数更安全?

他们都是C语言中的字符串复制函数,但是他们之间存在一些重要的区别。

  1. strcpy函数:
    • 作用: 用于将一个字符串复制到另一个字符串。
    • 参数: strcpy(destination, source),其中destination是目标字符串,source是要复制的源字符串。
    • 特点: 复制源字符串的所有字符直到遇到空字符('\0')。
    • 安全性: 如果源字符串比目标字符串长,可能会导致缓冲区溢出,因此需要确保目标字符串足够大。
1
2
3
char destination[20];
char source[] = "Hello, World!";
strcpy(destination, source);
  1. strncpy函数:
    • 作用: 用于将一个字符串的指定数量的字符复制到另一个字符串。
    • 参数: strncpy(destination, source, n),其中destination是目标字符串,source是要复制的源字符串,n是要复制的字符数。
    • 特点: 复制源字符串的前n个字符,如果源字符串长度小于n,则用空字符填充剩余的空间。
    • 安全性: 相对于strcpy更安全,因为可以限制复制的字符数,防止缓冲区溢出。
1
2
3
4
char destination[20];
char source[] = "Hello, World!";
strncpy(destination, source, sizeof(destination) - 1);
destination[sizeof(destination) - 1] = '\0'; // 确保目标字符串以空字符结尾

安全性比较:

  • 在处理已知长度的字符串时,strncpy 更安全,因为它允许明确指定要复制的字符数,避免了缓冲区溢出。
  • 但需要注意,strncpy 有一个缺点,即当源字符串长度大于或等于n时,目标字符串可能不以空字符结尾,因此需要手动添加空字符。

static_cast比C语言中的转换强在哪里?

  1. 更加安全;
  2. 更直接明显,能够一眼看出是什么类型转换为什么类型,容易找出程序中的错误;可清楚地辨别代码中每个显式的强制转;可读性更好,能体现程序员的意图

成员函数里memset(this,0,sizeof(*this))会发生什么

  1. 有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。对于这种情形可以很好的工作,但是下面几种情形是不可以这么使用的;
  2. 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;
  3. 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存

你知道回调函数吗?它的作用?

  1. 当发生某种事件时,系统或其他函数将会自动调用你定义的一段函数;
  2. 回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数;
  3. 回调函数就相当于一个中断处理函数,由系统在符合你设定的条件时自动调用。为此,你需要做三件事:1,声明;2,定义;3,设置触发条件,就是在你的函数中把你的回调函数名称转化为地址作为一个参数,以便于系统调用;
  4. 因为可以把调用者与被调用者分开。调用者不关心谁是被调用者,所有它需知道的,只是存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调用函数。

为什么友元函数必须在类内部声明?

友元函数必须要在类内部声明,表明这是在类内的的授权,但是在正式使用友元函数之前,还需要在类外部或者其他头文件中进行一次正式的函数定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <cmath>
using namespace std;

class Point {
private:
double x, y; //点的坐标
public:
Point(double x, double y) { //构造函数
this->x = x;
this->y = y;
}
friend double distance(Point &p1, Point &p2); //友元函数声明
};

//友元函数类外声明,可以省略
//double distance(Point &p1, Point &p2);

//友元函数定义
double distance(Point &p1, Point &p2) {
double dx = p1.x - p2.x; //可以直接访问Point类的私有成员
double dy = p1.y - p2.y;
return sqrt(dx * dx + dy * dy);
}

int main() {
Point p1(3, 4); //创建两个点对象
Point p2(6, 8);
cout << "The distance between p1 and p2 is " << distance(p1, p2) << endl; //调用友元函数
return 0;
}

用C语言实现C++的继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>

using namespace std;

//C++中的继承与多态
struct A
{

virtual void fun() //C++中的多态:通过虚函数实现
{
cout<<"A:fun()"<<endl;
}

int a;

};

struct B:public A //C++中的继承:B类公有继承A类
{
virtual void fun() //C++中的多态:通过虚函数实现(子类的关键字virtual可加可不加)
{
cout<<"B:fun()"<<endl;
}
int b;

};

//C语言模拟C++的继承与多态

typedef void (*FUN)(); //定义一个函数指针来实现对成员函数的继承

struct _A //父类
{
FUN _fun; //由于C语言中结构体不能包含函数,故只能用函数指针在外面实现
int _a;
};

struct _B //子类
{
_A _a_; //在子类中定义一个基类的对象即可实现对父类的继承
int _b;
};

void _fA() //父类的同名函数
{
printf("_A:_fun()\n");
}

void _fB() //子类的同名函数
{
printf("_B:_fun()\n");
}

void Test()
{
//测试C++中的继承与多态
A a; //定义一个父类对象a
B b; //定义一个子类对象b

A* p1 = &a; //定义一个父类指针指向父类的对象
p1->fun(); //调用父类的同名函数
p1 = &b; //让父类指针指向子类的对象
p1->fun(); //调用子类的同名函数

//C语言模拟继承与多态的测试

_A _a; //定义一个父类对象_a
_B _b; //定义一个子类对象_b
_a._fun = _fA; //父类的对象调用父类的同名函数
_b._a_._fun = _fB; //子类的对象调用子类的同名函数

_A* p2 = &_a; //定义一个父类指针指向父类的对象
p2->_fun(); //调用父类的同名函数
p2 = (_A*)&_b; //让父类指针指向子类的对象,由于类型不匹配所以要进行强转
p2->_fun(); //调用子类的同名函数

}

为什么不能把所有的函数写成内联函数?

内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:

  • 函数体内的代码比较长,将导致内存消耗代价
  • 函数体内有递归调用的话,函数执行时间要比函数调用开销大

为什么C++没有垃圾回收机制?这点跟Java不太一样

  • 首先,实现一个垃圾回收器会带来额外的空间和时间开销。你需要开辟一定的空间保存指针的引用计数和对他们进行标记mark。然后需要单独开辟一个线程在空闲的时候进行free操作。
  • 垃圾回收会使得C++不适合进行很多底层的操作。

C++的多态是如何实现的

虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表

虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针

img
img

上图中展示了虚表和虚表指针在基类对象和派生类对象中的模型,下面阐述实现多态的过程:

  1. 编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址
  2. **编译器会在每个对象的前四个字节中保存一个虚表指针,即*vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数
  3. 在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表
  4. 当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面

为什么析构函数一般写成虚函数

由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。

如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。

所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// test1.cpp 不将析构函数声明为虚函数
#include <iostream>
using namespace std;

class Parent{
public:
Parent(){
cout << "Parent construct function" << endl;
};
~Parent(){
cout << "Parent destructor function" <<endl;
}
};

class Son : public Parent{
public:
Son(){
cout << "Son construct function" << endl;
};
~Son(){
cout << "Son destructor function" <<endl;
}
};

int main()
{
Parent* p = new Son();
delete p;
p = NULL;
return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Parent destructor function



// test2.cpp 将析构函数声明为虚函数
#include <iostream>
using namespace std;

class Parent{
public:
Parent(){
cout << "Parent construct function" << endl;
};
virtual ~Parent(){
cout << "Parent destructor function" <<endl;
}
};

class Son : public Parent{
public:
Son(){
cout << "Son construct function" << endl;
};
~Son(){
cout << "Son destructor function" <<endl;
}
};

int main()
{
Parent* p = new Son();
delete p;
p = NULL;
return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Son destructor function
//Parent destructor function

但存在一种特例,在CRTP模板中,不应该将析构函数声明为虚函数,理论上所有的父类函数都不应 该声明为虚函数,因为这种继承方式,不需要虚函数表。

构造函数能否声明为虚函数或者纯虚函数,析构函数呢?

析构函数:

  • 析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。
  • 只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),才能准确销毁数据。
  • 析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。

构造函数:

  • 根据《effective C++》的条款09:绝不在构造和析构过程中调用虚函数可知,在构造函数中虽然可以调用虚函数,但是强烈建议不要这样做。因为基类的构造的过程中,虚函数不能算作是虚函数。若构造函数中调用虚函数,可能会导致不确定行为的发生.
  • 虚函数对应一个vtable(虚函数表),类中存储一个vptr指向这个vtable。如果构造函数是虚函数,就需要通过vtable调用,可是对象没有初始化就没有vptr,无法找到vtable,所以构造函数不能是虚函数。

基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间

虚函数表的特征:

  • 一个类的虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
  • 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段
  • 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中

根据以上特征,虚函数表类似于类中静态成员变量.静态成员变量也是全局共享,大小确定,因此最有可能存在全局数据区。测试结果显示:

虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),这与微软的编译器将虚函数表存放在常量段存在一些差别。

C++中 虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

模板函数和模板类的特例化

引入原因:编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化

定义:对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上

模板函数特例化

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T> //模板函数
int compare(const T &v1,const T &v2)
{
if(v1 > v2) return -1;
if(v2 > v1) return 1;
return 0;
}
//模板特例化,满足针对字符串特定的比较,要提供所有实参,这里只有一个T
template<>
int compare(const char* const &v1,const char* const &v2)
{
return strcmp(p1,p2);
}

本质:特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。例如,此处如果是compare(3,5),则调用普通的模板,若为compare(“hi”,”haha”)则调用特例化版本(因为这个cosnt char*相对于T,更匹配实参类型),注意二者函数体的语句不一样了,实现不同功能。

注意:模板及其特例化版本应该声明在同一个头文件中,且所有同名模板的声明应该放在前面,后面放特例化版本。

类模板特例化

原理类似函数模板,不过在类中,我们可以对模板进行特例化,也可以对类进行部分特例化。对类进行特例化时,仍然用template<>表示是一个特例化版本,例如:

1
2
3
4
5
6
7
template<>
class hash<sales_data>
{
size_t operator()(sales_data& s);
//里面所有T都换成特例化类型版本sales_data
//按照最佳匹配原则,若T != sales_data,就用普通类模板,否则,就使用含有特定功能的特例化版本。
};

类模板的部分特例化:不必为所有模板参数提供实参,可以指定一部分而非所有模板参数,一个类模板的部分特例化本身仍是一个模板,使用它时还必须为其特例化版本中未指定的模板参数提供实参(特例化时类名一定要和原来的模板相同,只是参数类型不同,按最佳匹配原则,哪个最匹配,就用相应的模板)

特例化类中的部分成员

可以特例化类中的部分成员函数而不是整个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename T>
class Foo
{
void Bar();
void Barst(T a)();
};

template<>
void Foo<int>::Bar()
{
//进行int类型的特例化处理
cout << "我是int型特例化" << endl;
}

Foo<string> fs;
Foo<int> fi;//使用特例化
fs.Bar();//使用的是普通模板,即Foo<string>::Bar()
fi.Bar();//特例化版本,执行Foo<int>::Bar()
//Foo<string>::Bar()和Foo<int>::Bar()功能不同

C++模板是什么,你知道底层怎么实现的?

  1. 编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译
  2. 这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。

构造函数和析构函数可以调用虚函数吗,为什么

  1. 在C++中,提倡不在构造函数和析构函数中调用虚函数;
  2. 构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;
  3. 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;
  4. 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义

构造函数、析构函数的执行顺序?构造函数和拷贝构造的内部都干了啥?

1) 构造函数顺序

  1. 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。

  2. 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。

  3. 派生类构造函数。

2) 析构函数顺序

  1. 调用派生类的析构函数;

  2. 调用成员类对象的析构函数;

  3. 调用基类的析构函数。

构造函数析构函数可否抛出异常

  1. C++只会析构已经完成的对象,对象只有在其构造函数执行完毕才算是完全构造妥当。在构造函数中发生异常,控制权转出构造函数之外。因此,在对象b的构造函数中发生异常,对象b的析构函数不会被调用。因此会造成内存泄漏。因此在构造函数中抛出异常要考虑释放已经申请的资源。比如用auto_ptr对象来取代指针类成员,便对构造函数做了强化,免除了抛出异常时发生资源泄漏的危机,不再需要在析构函数中手动释放资源;
  2. 如果控制权基于异常的因素离开析构函数,而此时正有另一个异常处于作用状态,C++会调用terminate函数让程序结束;
  3. 如果异常从析构函数抛出,而且没有在当地进行捕捉,那个析构函数便是执行不全的。如果析构函数执行不全,就是没有完成他应该执行的每一件事情.

总结

在构造函数和析构函数中抛出异常时都要注意释放已经申请的资源.

类什么时候会析构?

  1. 对象生命周期结束,被销毁时;
  2. delete指向对象的指针时,或delete指向对象的基类类型指针,而其基类虚构函数是虚函数时;
  3. 对象i是对象o的成员,o的析构函数被调用时,对象i的析构函数也被调用。

构造函数的几种关键字

default

default关键字可以显式要求编译器生成合成构造函数,防止在调用时相关构造函数类型没有定义而报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

class CString
{
public:
CString() = default; //语句1
//构造函数
CString(const char* pstr) : _str(pstr){}
void* operator new() = delete;//这样不允许使用new关键字
//析构函数
~CString(){}
public:
string _str;
};


int main()
{
auto a = new CString(); //语句2
cout << "Hello World" <<endl;
return 0;
}
//运行结果
//Hello World

delete

delete关键字可以删除构造函数、赋值运算符函数等,这样在使用的时候会得到友善的提示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;

class CString
{
public:
void* operator new() = delete;//这样不允许使用new关键字
//析构函数
~CString(){}
};


int main()
{
auto a = new CString(); //语句1
cout << "Hello World" <<endl;
return 0;
}

在执行语句1时,会提示new方法已经被删除,如果将new设置为私有方法,则会报惨不忍睹的错误,因此使用delete关键字可以更加人性化的删除一些默认方法

0

将虚函数定义为纯虚函数(纯虚函数无需定义,= 0只能出现在类内部虚函数的声明语句处;当然,也可以为纯虚函数提供定义,函数体可以定义在类的外部也可以定义在内部。

构造函数、拷贝构造函数和赋值操作符的区别

构造函数

对象不存在,没用别的对象初始化,在创建一个新的对象时调用构造函数

拷贝构造函数

对象不存在,但是使用别的已经存在的对象来进行初始化

赋值运算符

对象存在,用别的对象给它赋值,这属于重载“=”号运算符的范畴,“=”号两侧的对象都是已存在的

拷贝构造函数和赋值运算符重载的区别?

  • 拷贝构造函数是函数,赋值运算符是运算符重载。

  • 拷贝构造函数会生成新的类对象,赋值运算符不能。

  • 拷贝构造函数是直接构造一个新的类对象,所以在初始化对象前不需要检查源对象和新建对象是否相同;赋值运算符需要上述操作并提供两套不同的复制策略,另外赋值运算符中如果原来的对象有内存分配则需要先把内存释放掉。

  • 形参传递是调用拷贝构造函数(调用的被赋值对象的拷贝构造函数),但并不是所有出现"="的地方都是使用赋值运算符,如下:

    1
    2
    3
    4
    Student s;
    Student s1 = s; // 调用拷贝构造函数
    Student s2;
    s2 = s; // 赋值运算符操作

note

注:类中有指针变量时要重写析构函数、拷贝构造函数和赋值运算符。

什么是虚拟继承

虚拟继承是面向对象编程中的一个概念,主要出现在支持多重继承的语言中,如 C++。虚拟继承解决了由多重继承引入的菱形继承问题(Diamond Problem)。

菱形继承问题是指一个类同时继承了两个不同路径上的同一个基类,而这两个路径最终汇聚到一个共同的派生类。这样在继承体系中,基类的成员在派生类中出现了多次,可能导致二义性和冗余

虚拟继承通过在派生类对共同基类的继承前添加 virtual 关键字,告诉编译器在继承体系中使用虚拟继承。这样,只会有一个共同基类的实例被派生类继承,从而解决了菱形继承问题。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>

class Animal {
public:
void eat() {
std::cout << "Animal is eating" << std::endl;
}
};

class Mammal : virtual public Animal {
public:
void breathe() {
std::cout << "Mammal is breathing" << std::endl;
}
};

class Bird : virtual public Animal {
public:
void fly() {
std::cout << "Bird is flying" << std::endl;
}
};

class Bat : public Mammal, public Bird {
public:
void navigate() {
std::cout << "Bat is navigating" << std::endl;
}
};

int main() {
Bat bat;
bat.eat(); // Accessing eat from Animal
bat.breathe(); // Accessing breathe from Mammal
bat.fly(); // Accessing fly from Bird
bat.navigate(); // Accessing navigate from Bat
return 0;
}

什么情况会合成默认构造函数?

  1. 带有默认构造函数的类成员对象,如果一个类没有任何构造函数,但它含有一个成员对象,而后者有默认构造函数,那么编译器就为该类合成出一个默认构造函数。不过这个合成操作只有在构造函数真正被需要的时候才会发生;

    如果一个类A含有多个成员类对象的话,那么类A的每一个构造函数必须调用每一个成员对象的默认构造函数而且必须按照类对象在类A中的声明顺序进行;

  2. 带有默认构造函数的基类,如果一个没有任何构造函数的派生类派生自一个带有默认构造函数基类,那么该派生类会合成一个构造函数调用上一层基类的默认构造函数;

  3. 带有一个虚函数的类

  4. 带有一个虚基类的类

  5. 合成的默认构造函数中,只有基类子对象和成员类对象会被初始化。所有其他的非静态数据成员都不会被初始化。

什么时候需要合成拷贝构造函数呢?

有三种情况会以一个对象的内容作为另一个对象的初值:

  1. 对一个对象做显示的初始化操作,X xx = x;
  2. 当对象被当做参数交给某个函数时;
  3. 当函数传回一个类对象时;
  4. 如果一个类没有拷贝构造函数,但是含有一个类类型的成员变量,该类型含有拷贝构造函数,此时编译器会为该类合成一个拷贝构造函数;
  5. 如果一个类没有拷贝构造函数,但是该类继承自含有拷贝构造函数的基类,此时编译器会为该类合成一个拷贝构造函数;
  6. 如果一个类没有拷贝构造函数,但是该类声明或继承了虚函数,此时编译器会为该类合成一个拷贝构造函数;
  7. 如果一个类没有拷贝构造函数,但是该类含有虚基类,此时编译器会为该类合成一个拷贝构造函数;

抽象基类为什么不能创建对象?

抽象基类(Abstract Base Class,简称 ABC)是一种在面向对象编程中用于定义接口和规范的机制。抽象基类本身不能被实例化,主要有以下原因:

  1. 纯虚函数: 抽象基类通常包含至少一个纯虚函数(Pure Virtual Function),这是一种在基类中声明但没有提供实现的函数。这样的函数使得基类成为抽象,因为它没有完整的实现,必须由派生类提供具体实现。由于抽象基类中存在纯虚函数,无法实例化该类。
  2. 接口规范: 抽象基类用于定义一组接口规范,它为派生类提供了一种契约,告诉派生类应该实现哪些方法。这些规范用于确保派生类的一致性,但抽象基类本身并不提供完整的实现,因此不能创建对象。
  3. 设计目的: 抽象基类的设计目的是为了促使子类提供特定的功能,而不是为了直接创建实例。通过强制派生类实现抽象基类中的虚函数,可以确保派生类符合特定的接口规范。

模板类和模板函数的区别是什么?

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

多继承的优缺点,作为一个开发者怎么看待多继承

优点

  1. 灵活性: 多继承允许一个类从多个基类派生,从而获得不同类的功能。这提供了更灵活的设计选择。
  2. 代码重用: 可以通过继承多个类来重用现有代码,避免重复实现相似的功能。
  3. 分层设计: 多继承使得可以通过多个层次结构来建模复杂的关系,更容易表达真实世界的问题。

缺点

多继承是C++中一项强大而灵活的特性,但也容易引入复杂性和潜在的问题。以下是多继承的一些优缺点,以及开发者在使用多继承时应该注意的一些方面:

优点:

  1. 灵活性: 多继承允许一个类从多个基类派生,从而获得不同类的功能。这提供了更灵活的设计选择。
  2. 代码重用: 可以通过继承多个类来重用现有代码,避免重复实现相似的功能。
  3. 分层设计: 多继承使得可以通过多个层次结构来建模复杂的关系,更容易表达真实世界的问题。

缺点:

  1. 菱形继承问题(Diamond Problem): 当一个类从两个不同的类继承,而这两个类又共同继承自同一个类时,可能导致二义性和代码冗余。
  2. 复杂性: 多继承引入了更多的复杂性,增加了理解和维护代码的难度。代码结构可能会变得混乱,难以追踪。
  3. 潜在的命名冲突: 如果多个基类中有相同的成员名,可能会引发命名冲突,需要显式解决。
  4. 维护困难: 随着继承层次的增加,维护和修改代码可能变得更加困难。

开发者应该注意的方面

  1. 设计良好的继承层次结构: 在使用多继承时,设计一个良好的继承层次结构非常重要。避免过于复杂的层次结构和过度深度的继承链。
  2. 使用虚拟继承: 使用虚拟继承(virtual inheritance)来解决菱形继承问题,确保共同的基类只有一个实例。
  3. 避免深度继承链: 尽量避免深度继承链,过多的层次可能导致代码难以理解和维护。
  4. 使用接口和抽象类: 尽量使用接口和抽象类,将接口与实现分离,降低耦合度。
  5. 考虑其他设计模式: 在某些情况下,其他设计模式如组合、委托等可能更合适,可以减轻多继承可能带来的复杂性

模板和实现可不可以不写在一个文件里面?为什么?

不能,因为在编译时模板并不能生成真正的二进制代码,而是在编译调用模板类或函数的CPP文件时才会去找对应的模板声明和实现,在这种情况下编译器是不知道实现模板类或函数的CPP文件的存在,所以它只能找到模板类或函数的声明而找不到实现,而只好创建一个符号寄希望于链接程序找地址。

但模板类或函数的实现并不能被编译成二进制代码,结果链接程序找不到地址只好报错了。 《C++编程思想》第15章(第300页)说明了原因:模板定义很特殊。由template<…>处理的任何东西都意味着编译器在当时不为它分配存储空间,

它一直处于等待状态直到被一个模板实例告知。在编译器和连接器的某一处,有一机制能去掉指定模板的多重定义。所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。

为什么拷贝构造函数必须传引用不能传值?

拷贝构造函数的作用就是用来复制对象的,在使用这个对象的实例来初始化该类一个新的实例。如果传值的方式是非引用,那么构造形参需要调用拷贝构造函数,调用拷贝构造函数的过程又需要调用拷贝构造函数,会造成无限递归.

静态函数能定义为虚函数吗?常函数呢?

  1. static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。

  2. 静态与非静态成员函数之间有一个主要的区别,那就是静态成员函数没有this指针。

虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable.对于静态成员函数,它没有this指针,所以无法访问vptr。

这就是为何static函数不能为virtual,虚函数的调用关系:this -> vptr -> vtable ->virtual function

虚函数的代价是什么?

  1. 带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大类存储空间
  2. 带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小;
  3. 不能再是内联的函数,因为内联函数在编译阶段进行替代,而虚函数表示等待,在运行阶段才能确定到低是采用哪种函数,虚函数不能是内联函数

说一说你了解到的移动构造函数?

  1. 有时候我们会遇到这样一种情况,我们用对象a初始化对象b后对象a我们就不在使用了,但是对象a的资源还要有一个释放的过程,既然a不再使用了,那么为什么我们不能直接让b使用a的空间?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
  2. 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制

构造函数的执行顺序

  1. 在派生类构造函数中,所有的虚基类及上一层基类的构造函数调用;
  2. 如果有成员初始化列表;
  3. 构造函数体;
  4. 构造函数完毕之后,对象的vptr被初始化;

哪些函数不能是虚函数?把你知道的都说一说

  1. 构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;
  2. 内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
  3. 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
  4. 友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
  5. 普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。

unique_ptr实际场景中有哪些应用?

互斥锁、socket会用到unique_ptr。

C++ 11 新特性

C++ 11有哪些新特性?

  • nullptr替代 NULL
  • 引入了 auto 和 decltype 这两个关键字实现了类型推导
  • 基于范围的 for 循环for(auto& i : res){}
  • 类和结构体的中初始化列表
  • Lambda 表达式(匿名函数)
  • std::forward_list(单向链表)
  • 右值引用和move语义

空指针 nullptr

nullptr 出现的⽬的是为了替代 NULL。

在某种意义上来说,传统 C语言会把 NULL、 0 视为同⼀种东⻄,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定 义为 ((void)0),有些则会直接将其定义为 0。C++ 不允许直接将 void 隐式转 换到其他类型,但如果 NULL 被定义为 ((void)0),那么当编译 char ch = NULL; 时,NULL 只好被定义为 0。⽽这依然会产⽣问题,将导致了 C++ 中重载特性 会发⽣混乱,考虑:

1
2
void func(int);
void func(char *);

对于这两个函数来说,如果 NULL ⼜被定义为了 0 那么 func(NULL) 这个语句将 会去调⽤ func(int),从⽽导致代码违反直观。

为了解决这个问题,C++11 引入了 nullptr 关键字,专门⽤来区分空指针、0。nullptr 的类型为nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进⾏相等或者不等的⽐较。

当需要使⽤ NULL 时候,养成直接使⽤ nullptr 的习惯。

Lambda 表达式

详见Lambda表达式用法

右值引用

详见C++右值引用

泛化的常量表达式 constexpr

constexpr 告诉编译器这是⼀个编译期常量,甚⾄可以把⼀个函数声明为编译期常量表达式。

constexpr 和 const 的区别见 C++ 部分 const 和 constexpr 的区别

初始化列表

接下来几个特性属于原有语⾔特性的使⽤性增强。这意味着这些操作原来也是可以实现的, 不过现在语法上更加简洁。⽐如⾸先要介绍的初始化列表。

⽽ C++11 提供了 initializer_list 来接受变⻓的对象初始化列表:

1
2
3
4
5
6
class A{
public:
A(std::initializer_list<int> list);
};

A a = {1, 2, 3};

注意初始化列表特性只是现有语法增强,并不是提供了动态的可变参数。该列表只能静态地构造。

统⼀的初始化语法

不同的数据类型具有不同的初始化语法。如何初始化字符串?如何初始化数组?如何初始化多维数组?如何初始化对象?

C++11给出了统⼀的初始化语法:均可使⽤“{}-初始化变量列表”:

1
2
3
4
5
6
7
8
9
10
11
12
X x1 = X{1,2};
X x2 = {1,2}; // 此处的'='可有可⽆
X x3{1,2};
X* p = new X{1,2};
struct D : X {
D(int x, int y) :X{x,y} { /* … */ };
};

struct S {
int a[3]; // 对于旧有问题的解决方案
S(int x, int y, int z) :a{x,y,z} { /* … */ };
};

auto和decltype类型推导

C++ 提供了 auto 和 decltype 来静态推导类型,在我们知道类型没有问题但⼜不想完整地写出类型的时候, 便可以使⽤静态类型推导。

1
2
3
for(vector<int>::const_iterator it = v.begin(); it != v.end(); ++it);
// 可以改写为
for(auto it = v.begin(); it != v.end(); ++it);

attention

虽然写起来和动态语⾔(如JavaScript的 var )很像,但C++仍然是强类型的,会执⾏静态类型检查的语⾔。 这只是语法上的简化,并未改变C++的静态类型检查

decltype ⽤于获取⼀个表达式的类型,⽽不对表达式进⾏求值(类似于sizeof )。 decltype(e) 规则如下:

  • 若 e 为⼀个⽆括号的变量、函数参数、类成员,则返回类型为该变量/参数/类成员在源程序中的声明类型;
  • 否则的话,根据表达式的值分类(value categories),设设 T 为 e 的类型:
    • 若 e 是⼀个左值(lvalue,即“可寻址值”),返回 T& ;
    • 若 e 是⼀个临终值(xvalue),则返回值为 T&& ;
    • 若 e 是⼀个纯右值(prvalue),则返回值为 T 。
1
2
3
4
5
6
7
8
9
10
11
const std::vector<int> v(1);
const int&& foo(); // 返回临终值:⽣命周期已结束但内存还未拿⾛

auto a = v[0]; // a 为 int
decltype(v[0]) b = 0; // b 为 const int&
// 即 vector<int>::operator[](size_type) const 的返回值类型
auto c = 0; // c, d 均为 int
auto d = c;
decltype(c) e; // e 为 int,即 c 的类型
decltype((c)) f = e; // f 为 int&,因为 c 是左值
decltype(0) g; // g 为 int,因为 0 是右值

基于范围的for循环

Boost 中定义了很多"范围",很多标准库函数都使⽤了范围⻛格的实现。这⼀概念被C++11提了出来:

1
2
3
4
5
6
int arr[5];
std::vector<int> v;
for(int x: arr);
for(const int& x: arr);
for(int x: v);
for(auto &x: v);

构造函数委托

在 C# 和 Java 中,⼀个构造函数可以调⽤另⼀个来实现代码复⽤,但 C++⼀直不允许这样做.

现在可以了,这使得构造函数可以在同⼀个类中⼀个构造函数调⽤另⼀个构造函数,从⽽达到简化代码的⽬的:

1
2
3
4
5
6
7
8
9
class myBase{
int number;
string name;
myBase(int number, string& s) : number(number), name(s){}
public:
myBase() : myBase(0, "invalid"){}
myBase(int number) : myBase(number, "guest"){}
myBase(string& s) : myBase(1, s){PostInit();}
};

final 和 override

C++ 借由虚函数实现运⾏时多态,但 C++ 的虚函数⼜很多脆弱的地方:

  • ⽆法禁⽌子类重写它。可能到某⼀层级时,我们不希望子类继续来重写当前虚函数了。
  • 容易不小⼼隐藏父类的虚函数。⽐如在重写时,不小⼼声明了⼀个签名不⼀致但有同样名称的新函数。

C++11 提供了 final 来禁⽌虚函数被重写/禁⽌类被继承, override 来显示地重写虚函数。如果不使用override,当你手一抖,将foo()写成了f00()会怎么样呢?结果是编译器并不会报错,因为它并不知道你的目的是重写虚函数,而是把它当成了新的函数。

1
2
3
4
5
6
7
8
9
10
11
struct Base1 final { };     
struct Derived1 : Base1 {}; // 编译错:Base1不允许被继承

struct Base2 {
virtual void f1() final;
virtual void f2();
};
struct Derived2 : Base2 {
virtual void f1(); // 编译错:f1不允许重写
virtual void f2(int) override; // 编译错:父类中没有 void f2(int)
};

static_assert

C++ 提供了两种方式来 assert :⼀种是 assert 宏,另⼀种是预处理指令 #error 。 前者在运⾏期起作⽤,⽽后者是预处理期起作⽤。它们对模板都不好使,因为模板是编译期的概念。

static_assert 关键字的使⽤方式如下:

1
2
3
4
template< class T >
struct Check {
static_assert( sizeof(int) <= sizeof(T), "T is not big enough!" ) ;
};

智能指针

接下来介绍 C++11 对于 C++ 标准库的变更。C++11 把 TR1 并入了进来,废弃了 C++98 中的 auto_ptr , 同时将 shared_ptr 和 uniq_ptr 并入 std 命名空间。

智能指针在 [Effective C++: Item 13] 中已经有不少讨论了。这⾥给⼀个例子:

1
2
3
4
5
6
7
8
int main(){    
std::shared_ptr<double> p_first(new double);
{
std::shared_ptr<double> p_copy = p_first;
*p_copy = 21.2;
} // p_copy 被销毁,⾥面的 double 还有⼀个引⽤因此仍然保持
return 0; // p_first 及其⾥面的 double 销毁
}

正则表达式

这个任何⼀门现代的编程语⾔都会提供的特性终于进标准:

1
const char *reg_esp = "[ ,.\\t\\n;:]";std::regex rgx(reg_esp) ;std::cmatch match ;  const char *target = "Polytechnic University of Turin " ; if( regex_search( target, match, rgx ) ) {  const size_t n = match.size();  for( size_t a = 0 ; a < n ; a++ ) {    string str( match[a].first, match[a].second ) ;    cout << str << "\n" ;  }}

增强的元组

在 C++ 中本已有⼀个 pair 模板可以定义⼆元组,C++11 更进⼀步地提供了边⻓参数的tuple 模板:

1
2
3
typedef std::tuple< int , double, string> tuple_1 t1;
typedef std::tuple< char, short , const char * > tuple_2 t2 ('X', 2, "Hola!");
t1 = t2 ; // 隐式类型转换

哈希表

C++ 的 map , multimap , set , multiset 使⽤红⿊树实现, 插入和查询都是 O(lgn) 的复杂度

但 C++11 为这四种模板类提供了(底层哈希实现)以达到 O(1) 的复杂度:

散列表类型 有无关系值 接受相同键值
std::unordered_map
std::unordered_multimap
std::unordered_set
std::unordered_multiset

C++内存管理

类的对象存储空间大小?

  • 非静态成员的数据类型大小之和。
  • 编译器加入的额外成员变量(如指向虚函数表的指针)。
  • 为了边缘对齐优化加入的padding。

空类(无非静态数据成员)的对象的size为1, 当作为基类时, size为0

简要说明C++的内存分区

C++中的内存分区,分别是堆、栈、全局/静态存储区、常量存储区和代码区(CS 225写的是4个区域)。如下图所示

img

:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限

:就是那些由 new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收

全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量和静态变量又分为初始化的和未初始化的,在C++里面没有这个区分了,它们共同占用同一块内存区,在该区定义的变量若没有初始化,则会被自动初始化,例如int型变量自动初始为0

常量存储区:这是一块比较特殊的存储区,这里面存放的是常量,不允许修改

代码区:存放函数体的二进制代码

什么是内存池,如何实现

内存池(Memory Pool) 是一种内存分配方式。通常我们习惯直接使用new、malloc 等申请内存,这样做的缺点在于:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块, 若内存块不够再继续申请新的内存。这样做的一个显著优点是尽量避免了内存碎片,使得内存分配效率得到提升。

实现

这里简单描述一下《STL源码剖析》中的内存池实现机制

allocate 包装 malloc,deallocate包装free,一般是一次20*2个的申请,先用一半,留着一半,C++委员会成员认为20是个比较好的数字,既不大也不小。

  1. 首先客户端会调用malloc()配置一定数量的区块(固定大小的内存块,通常为8的倍数),假设40个32bytes的区块,其中20个区块(一半)给程序实际使用,1个区块交出,另外19个处于维护状态。剩余20个(一半)留给内存池,此时一共有(20 * 32byte)
  2. 客户端之后有有内存需求,想申请(20 * 64bytes)的空间,这时内存池只有(20 * 32bytes),就先将(10 * 64bytes)个区块返回,1个区块交出,另外19个处于维护状态,此时内存池空空如也.
  3. 接下来如果客户端还有内存需求,就必须再调用malloc()配置空间,此时新申请的区块数量会增加一个随着配置次数越来越大的附加量,同样一半提供程序使用,另一半留给内存池。申请内存的时候用永远是先看内存池有无剩余,有的话就用上,然后挂在0-15号某一条链表上,要不然就重新申请。
  4. 如果整个堆的空间都不够了,就会在原先已经分配区块中寻找能满足当前需求的区块数量,能满足就返回,不能满足就向客户端报bad_alloc异常

allocator就是用来分配内存的,最重要的两个函数是allocate和deallocate,就是用来申请内存和回收内存的,外部(一般指容器)调用的时候只需要知道这些就够了。

内部实现,目前的所有编译器都是直接调用的::operator new()和::operator delete(),说白了就是和直接使用new运算符的效果是一样的,所以老师说它们都没做任何特殊处理。

note

new和 operator new 的区别:new是一个关键字,用于为对象动态分配内存,而operate new 是一个分配原始内存的函数。程序员在使用new的时候会调用operate new分配内存,然后它调用正确类型的对象的构造函数,因此结果是在该内存中创建的真实活动对象。

C++中类的数据成员和成员函数内存分布情况

C++类是由结构体发展得来的,所以他们的成员变量(C语言的结构体只有成员变量)的内存分配机制是一样的。下面我们以类来说明问题,如果类的问题通了,结构体也也就没问题啦。 类分为成员变量和成员函数,我们先来讨论成员变量。

一个类对象的地址就是类所包含的这一片内存空间的首地址,这个首地址也就对应具体某一个成员变量的地址。(在定义类对象的同时这些成员变量也就被定义了),举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

class Person
{
public:
Person()
{
this->age = 23;
}
void printAge()
{
cout << this->age <<endl;
}
~Person(){}
public:
int age;
};

int main()
{
Person p;
cout << "对象地址:"<< &p <<endl;
cout << "age地址:"<< &(p.age) <<endl;
cout << "对象大小:"<< sizeof(p) <<endl;
cout << "age大小:"<< sizeof(p.age) <<endl;
return 0;
}
//输出结果
//对象地址:0x7fffec0f15a8
//age地址:0x7fffec0f15a8
//对象大小:4
//age大小:4

从代码运行结果来看,对象的大小和对象中数据成员的大小是一致的,也就是说,成员函数不占用对象的内存。这是因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。

要是成员函数占用类的对象空间,那么将是多么可怕的事情:定义一次类对象就有成员函数占用一段空间。

我们再来补充一下静态成员函数的存放问题:静态成员函数与一般成员函数的唯一区别就是没有this指针,因此不能访问非静态数据成员。

就像前面提到的,所有函数都存放在代码区,静态函数也不例外。所有有人一看到 static 这个单词就主观的认为是存放在全局数据区,那是不对的。

关于this指针你知道什么?全说出来

  • this指针是类的指针,指向对象的首地址
  • this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。
  • this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置。

this指针的用处

一个对象的this指针并不是对象本身的一部分,不会影响 sizeof(对象) 的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。

this指针的使用

一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this;

另外一种情况是当形参数与成员变量名相同时用于区分,如this->n = n (不能写成n = n)

类的this指针有以下特点

this只能在成员函数中使用,全局函数、静态函数都不能使用this。实际上,传入参数为当前对象地址,成员函数第一个参数为T * const this

如:

1
class A{public:	int func(int p){}};

其中,func的原型在编译器看来应该是:

1
int func(A * const this,int p);

由此可见,this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。如:

1
A a;a.func(10);	//此处,编译器将会编译成:A::func(&a,10);

看起来和静态函数没差别,对吗?不过,区别还是有的。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高,例如VC通常是通过ecx(计数寄存器)传递this参数的。

几个this指针的易混问题

this指针是什么时候创建的?

this在成员函数的开始执行前构造,在成员的执行结束后清除。

但是如果class或者struct里面没有方法的话,它们是没有构造函数的,只能当做C的struct使用。采用TYPE xx的方式定义的话,在栈里分配内存,这时候this指针的值就是这块内存的地址。采用new的方式创建对象的话,在堆里分配内存,new操作符通过eax(累加寄存器)返回分配的地址,然后设置给指针变量。之后去调用构造函数(如果有构造函数的话),这时将这个内存块的地址传给ecx,之后构造函数里面怎么处理请看上面的回答

this指针存放在何处?堆、栈、全局变量,还是其他?

this指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。在汇编级别里面,一个值只会以3种形式出现:立即数、寄存器值和内存变量值。不是存放在寄存器就是存放在内存中,它们并不是和高级语言变量对应的。

this指针是如何传递类中的函数的?绑定?还是在函数参数的首参数就是this指针?那么,this指针又是如何找到“类实例后函数的”?

大多数编译器通过ecx(寄数寄存器)寄存器传递this指针。事实上,这也是一个潜规则。一般来说,不同编译器都会遵从一致的传参规则,否则不同编译器产生的obj就无法匹配了。

在call之前,编译器会把对应的对象地址放到eax中。this是通过函数参数的首参来传递的。this指针在调用之前生成,至于“类实例后函数”,没有这个说法。类在实例化时,只分配类中的变量空间,并没有为函数分配空间。自从类的函数定义完成后,它就在那儿,不会跑的。

this指针是如何访问类中的变量的?

如果不是类,而是结构体的话,那么,如何通过结构指针来访问结构中的变量呢?如果你明白这一点的话,就很容易理解这个问题了。

在C++中,类和结构是只有一个区别的:类的成员默认是private,而结构是public。

this是类的指针,如果换成结构体,那this就是结构的指针了。

我们只有获得一个对象后,才能通过对象使用this指针。如果我们知道一个对象this指针的位置,可以直接使用吗?

this指针只有在成员函数中才有定义。因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。

每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?

普通的类函数(不论是成员函数,还是静态函数)都不会创建一个函数表来保存函数指针。只有虚函数才会被放到函数表中。但是,即使是虚函数,如果编译期就能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。正是由于this指针的存在,用来指向不同的对象,从而确保不同对象之间调用相同的函数可以互不干扰。

成员函数中调用delete this会出现什么问题?对象还可以使用吗?

在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。

为什么是不可预期的问题?

delete this之后不是释放了类对象的内存空间了么,那么这段内存应该已经还给系统,不再属于这个进程。照这个逻辑来看,应该发生指针错误,无访问权限之类的令系统崩溃的问题才对啊?这个问题牵涉到操作系统的内存管理策略。delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。

如果在类的析构函数中调用delete this,会发生什么?

会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。

你知道空类的大小是多少吗?

  1. C++空类的大小不为0,不同编译器设置不一样,vs设置为1;
  2. C++标准指出,不允许一个对象(当然包括类对象)的大小为0,不同的对象不能具有相同的地址;
  3. 带有虚函数的C++类大小不为1,因为每一个对象会有一个vptr指向虚函数表,具体大小根据指针大小确定;
  4. C++中要求对于类的每个实例都必须有独一无二的地址,那么编译器自动为空类分配一个字节大小,这样便保证了每个实例均有独一无二的内存地址。

请说一下以下几种情况下,下面几个类的大小各是多少?

1
2
3
4
5
6
7
8
// C++中空类会占一个字节,这是为了让对象的实例能够相互区别
class A {};
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}

空类的大小是1, 在C++中空类会占一个字节,这是为了让对象的实例能够相互区别。具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。当该空白类作为基类时,该类的大小就优化为0了,子类的大小就是子类本身的大小。这就是所谓的空白基类最优化。

空类的实例大小就是类的大小,所以sizeof(a)=1字节,如果a是指针,则sizeof(a)就是指针的大小,即4字节。

1
2
3
4
5
6
7
8
// 虚函数的类对象中都有一个虚函数表指针 __vptr,其大小是4字节
class A { virtual void Fun(){} };
int main(){
cout<<sizeof(A)<<endl;// 输出 4(32位机器)/8(64位机器);
A a;
cout<<sizeof(a)<<endl;// 输出 4(32位机器)/8(64位机器);
return 0;
}

因为有**虚函数的类对象中都有一个虚函数表指针 __vptr,其大小是4字节**

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类大小
class A { static int a; };
class B { static int a; int b; };;
class C { int c; };
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;


cout<<sizeof(B)<<endl;// 输出 4;
B b;
cout<<sizeof(b)<<endl;// 输出 4;

cout << sizeof(C) << endl; // 输出4
C c;
cout << sizeof(c) << endl; // 输出4
return 0;
}

静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类大小。

this指针调用成员变量时,堆栈会发生什么变化?

当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。

即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this的。

例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。

类对象的大小受哪些因素影响?

  1. 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小;
  2. 内存对齐另外分配的空间大小,类内的数据也是需要进行内存对齐操作的;
  3. 虚函数的话,会在类对象插入vptr指针,加上指针大小;
  4. 当该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在在派生类中的空间中,也会对派生类进行扩展

菱形继承和虚继承

详细见菱形继承和虚继承,这里只贴出几个重要结论:

  1. 对于虚基类的初始化是由最后的派生类中负责初始化。在最后的派生类中不仅要对直接基类进行初始化,还要负责对虚基类初始化;
  2. 程序运行时,只有最后的派生类执行对基类的构造函数调用,而忽略其他派生类对虚基类的构造函数调用。从而避免对基类数据成员重复初始化。因此,虚基类只会构造一次。

STL模板库

说一下C++左值引用和右值引用

  1. 在C++11中所有的值必属于左值、右值两者之一,右值又可以细分为纯右值、将亡值。在C++11中可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值)。
  2. 左值引用就是对一个左值进行引用的类型。右值引用就是对一个右值进行引用的类型,事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。左值引用通常也不能绑定到右值,但常量左值引用是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。不过常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。
  3. 右值值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std::move()将左值强制转换为右值。

STL中hashtable的实现?

STL中的hashtable使用的是拉链法解决hash冲突问题,如下图所示。

img

hashtable中的bucket所维护的list既不是list也不是slist,而是其自己定义的由hashtable_node数据结构组成的linked-list,而bucket聚合体本身使用vector进行存储。hashtable的迭代器只提供前进操作,不提供后退操作

在hashtable设计bucket的数量上,其内置了28个质数[53, 97, 193,...,429496729],在创建hashtable时,会根据存入的元素个数选择大于等于元素个数的质数作为hashtable的容量(vector的长度),其中每个bucket所维护的linked-list长度也等于hashtable的容量。如果插入hashtable的元素个数超过了bucket的容量,就要进行重建table操作,即找出下一个质数,创建新的buckets vector,重新计算元素在新hashtable的位置。

STL的两级空间配置器

动态开辟内存时,要在堆上申请,但若是我们需要频繁的在堆开辟释放内存,则就会在堆上造成很多外部碎片,浪费了内存空间;每次都要进行调用malloc、free函数等操作,使空间就会增加一些附加信息,降低了空间利用率;随着外部碎片增多,内存分配器在找不到合适内存情况下需要合并空闲块,浪费了时间,大大降低了效率。

于是就设置了二级空间配置器,当开辟内存<=128bytes时,即视为开辟小块内存,则调用二级空间配置器。

关于STL中一级空间配置器和二级空间配置器的选择上,一般默认选择的为二级空间配置器。 如果大于128字节再转去一级配置器器。

一级配置器

一级空间配置器中重要的函数就是allocate、deallocate、reallocate 。 一级空间配置器是以malloc(),free(),realloc()等C函数执行实际的内存配置 。大致过程是:

1、直接allocate分配内存,其实就是malloc来分配内存,成功则直接返回,失败就调用处理函数

2、如果用户自定义了内存分配失败的处理函数就调用,没有的话就返回异常

3、如果自定义了处理函数就进行处理,完事再继续分配试试

img

二级配置器

img
  1. 维护16条链表,分别是0-15号链表,最小8字节,以8字节逐渐递增,最大128字节,你传入一个字节参数,表示你需要多大的内存,会自动帮你校对到第几号链表(如需要13bytes空间,我们会给它分配16bytes大小),在找到第n个链表后查看链表是否为空,如果不为空直接从对应的free_list中拔出,将已经拨出的指针向后移动一位。

  2. 对应的free_list为空,先看其内存池是不是空时,如果内存池不为空: (1)先检验它剩余空间是否够20个节点大小(即所需内存大小(提升后) * 20),若足够则直接从内存池中拿出20个节点大小空间,将其中一个分配给用户使用,另外19个当作自由链表中的区块挂在相应的free_list下,这样下次再有相同大小的内存需求时,可直接拨出。 (2)如果不够20个节点大小,则看它是否能满足1个节点大小,如果够的话则直接拿出一个分配给用户,然后从剩余的空间中分配尽可能多的节点挂在相应的free_list中。 (3)如果连一个节点内存都不能满足的话,则将内存池中剩余的空间挂在相应的free_list中(找到相应的free_list),然后再给内存池申请内存,转到3。

  3. 内存池为空,申请内存 此时二级空间配置器会使用malloc()从heap上申请内存,(一次所申请的内存大小为2 * 所需节点内存大小(提升后)* 20 + 一段额外空间),申请40块,一半拿来用,一半放内存池中。

  4. malloc没有成功 在第三种情况下,如果malloc()失败了,说明heap上没有足够空间分配给我们了,这时,二级空间配置器会从比所需节点空间大的free_list中一一搜索,从比它所需节点空间大的free_list中拔除一个节点来使用。如果这也没找到,说明比其大的free_list中都没有自由区块了,那就要调用一级适配器了。

释放时调用deallocate()函数,若释放的n>128,则调用一级空间配置器,否则就直接将内存块挂上自由链表的合适位置。

二级空间配置器的优缺点

1.因为自由链表的管理问题,它会把我们需求的内存块自动提升为8的倍数,这时若你需要1个字节,它会给你8个字节,即浪费了7个字节,所以它又引入了内部碎片的问题,若相似情况出现很多次,就会造成很多内部碎片

2.二级空间配置器是在堆上申请大块的狭义内存池,然后用自由链表管理,供现在使用,在程序执行过程中,它将申请的内存一块一块都挂在自由链表上,即不会还给操作系统,并且它的实现中所有成员全是静态的,所以它申请的所有内存只有在进程结束才会释放内存,还给操作系统,由此带来的问题有:1.即我不断的开辟小块内存,最后整个堆上的空间都被挂在自由链表上,若我想开辟大块内存就会失败;2.若自由链表上挂很多内存块没有被使用,当前进程又占着内存不释放,这时别的进程在堆上申请不到空间,也不可以使用当前进程的空闲内存,由此就会引发多种问题。

Vector如何释放空间?

由于vector的内存占用空间只增不减,比如你首先分配了10,000个字节,然后erase掉后面9,999个,留下一个有效元素,但是内存占用仍为10,000个。所有内存空间是在vector析构时候才能被系统回收。empty()用来检测容器是否为空的,clear()可以清空所有元素。但是即使clear(),vector所占用的内存空间依然如故,无法保证内存的回收。

如果需要空间动态缩小,可以考虑使用deque。

如果使用vector,可以用swap()来帮助你释放多余内存或者清空全部内存。

1
2
vector(Vec).swap(Vec); //将Vec中多余内存清除; 
vector().swap(Vec); //清空Vec的全部内存;

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <vector>
using namespace std;

int main ()
{
vector<int> vec (100,100); // three ints with a value of 100
vec.push_back(1);
vec.push_back(2);
cout <<"vec.size(): " << vec.size() << endl;
cout <<"vec.capasity(): " << vec.capacity() << endl;

vector<int>(vec).swap(vec); //清空vec中多余的空间,相当于vec.shrink_to_fit();

cout <<"vec.size(): " << vec.size() << endl;
cout <<"vec.capasity(): " << vec.capacity() << endl;

vector<int>().swap(vec); //清空vec的全部空间

cout <<"vec.size(): " << vec.size() << endl;
cout <<"vec.capasity(): " << vec.capacity() << endl;

return 0;
}
/*
运行结果:
vec.size(): 102
vec.capasity(): 200
vec.size(): 102
vec.capasity(): 102
vec.size(): 0
vec.capasity(): 0
*/

STL迭代器如何实现

1、 迭代器是一种抽象的设计理念,通过迭代器可以在不了解容器内部原理的情况下遍历容器,除此之外,STL中迭代器一个最重要的作用就是作为容器与STL算法的粘合剂。

2、 迭代器的作用就是提供一个遍历容器内部所有元素的接口,因此迭代器内部必须保存一个与容器相关联的指针,然后重载各种运算操作来遍历,其中最重要的是*运算符与->运算符,以及++、--等可能需要重载的运算符重载。这和C++中的智能指针很像,智能指针也是将一个指针封装,然后通过引用计数或是其他方法完成自动释放内存的功能。

3、最常用的迭代器的相应型别有五种:value type、difference type、pointer、reference、iterator catagoly;

map、set是怎么实现的,红黑树是怎么能够同时实现这两种容器? 为什么使用红黑树?

  1. 他们的底层都是以红黑树的结构实现,因此插入删除等操作都在O(logn)时间内完成,因此可以完成高效的插入删除;
  2. 红黑树,实现map的红黑树的节点数据类型是key+value,而实现set的节点数据类型是value
  3. 因为map和set要求是自动排序的,红黑树能够实现这一功能,而且时间复杂度比较低。

map插入方式有哪几种?

  1. 用insert函数插入pair数据
1
mapStudent.insert(pair<int, string>(1, "student_one"));  
  1. 用insert函数插入value_type数据
1
mapStudent.insert(map<int, string>::value_type (1, "student_one")); 
  1. 在insert函数中使用make_pair()函数
1
mapStudent.insert(make_pair(1, "student_one")); 
  1. 用数组方式插入数据
1
mapStudent[1] = "student_one"; 

STL中unordered_map(hash_map)和map的区别,hash_map如何解决冲突以及扩容

  1. unordered_map内部元素是无序的,而map会根据key的大小进行排序,map中的元素是按照二叉搜索树存储,进行中序遍历会得到有序遍历。使用时map的key需要定义operator<。而unordered_map需要定义hash_value函数并且重载operator==。如果是自定义类型,那么就需要自己重载operator<或者hash_value()了。

STL中vector的实现

vector是一种序列式容器,其数据安排以及操作方式与array非常类似,两者的唯一差别就是对于空间运用的灵活性,vector使用灵活的动态空间配置,维护一块连续的线性空间,在空间不足时,可以自动扩展空间容纳新元素,做到按需供给。其在扩充空间的过程中仍然需要经历:重新配置空间,移动数据,释放原空间等操作。扩容规则:以原大小的两倍(或1.5倍)配置另外一块较大的空间

note

Vector扩容倍数与平台有关,在Win + VS 下是 1.5倍,在 Linux + GCC 下是 2 倍

1
const size_type len  = old_size + max(old_size, n);

STL中slist的实现

list是双向链表,而slist(single linked list)是单向链表,它们的主要区别在于:前者的迭代器是双向的Bidirectional iterator,后者的迭代器属于单向的Forward iterator。虽然slist的很多功能不如list灵活,但是其所耗用的空间更小,操作更快。

根据STL的习惯,插入操作会将新元素插入到指定位置之前,而非之后,然而slist是不能回头的,只能往后走,因此在slist的其他位置插入或者移除元素是十分不明智的,但是在slist开头却是可取的,slist特别提供了insert_after()和erase_after供灵活应用。考虑到效率问题,slist只提供push_front()操作,元素插入到slist后,存储的次序和输入的次序是相反的

slist的单向迭代器如下图所示:

img

slist默认采用alloc空间配置器配置节点的空间,其数据结构主要代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <class T, class Allco = alloc>
class slist
{
...
private:
...
static list_node* create_node(const value_type& x){}//配置空间、构造元素
static void destroy_node(list_node* node){}//析构函数、释放空间
private:
list_node_base head; //头部
public:
iterator begin(){}
iterator end(){}
size_type size(){}
bool empty(){}
void swap(slist& L){}//交换两个slist,只需要换head即可
reference front(){} //取头部元素
void push_front(const value& x){}//头部插入元素
void pop_front(){}//从头部取走元素
...
}

note

需要注意的是C++标准委员会没有采用slist的名称,forward_list在C++ 11中出现,它与slist的区别是没有size()方法。

slist的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <forward_list>
#include <algorithm>
#include <iostream>
using namespace std;

int main()
{
forward_list<int> fl;
fl.push_front(1);
fl.push_front(3);
fl.push_front(2);
fl.push_front(6);
fl.push_front(5);

forward_list<int>::iterator ite1 = fl.begin();
forward_list<int>::iterator ite2 = fl.end();
for(;ite1 != ite2; ++ite1)
{
cout << *ite1 <<" "; // 5 6 2 3 1
}
cout << endl;

ite1 = find(fl.begin(), fl.end(), 2); //寻找2的位置

if (ite1 != ite2)
fl.insert_after(ite1, 99);
for (auto it : fl)
{
cout << it << " "; //5 6 2 99 3 1
}
cout << endl;

ite1 = find(fl.begin(), fl.end(), 6); //寻找6的位置
if (ite1 != ite2)
fl.erase_after(ite1);
for (auto it : fl)
{
cout << it << " "; //5 6 99 3 1
}
cout << endl;
return 0;
}

STL中list的实现

相比于vector的连续线型空间,list显得复杂许多,但是它的好处在于插入或删除都只作用于一个元素空间,因此list对空间的运用是十分精准的,对任何位置元素的插入和删除都是常数时间。list不能保证节点在存储空间中连续存储,也拥有迭代器,迭代器的“++”、“--”操作对于的是指针的操作,list提供的迭代器类型是双向迭代器:Bidirectional iterators。

list节点的结构见如下源码:

1
2
3
4
5
6
7
template <class T>
struct __list_node{
typedef void* void_pointer;
void_pointer prev;
void_pointer next;
T data;
}

STL中的deque的实现

vector是单向开口(尾部)的连续线性空间,deque则是一种双向开口的连续线性空间,虽然vector也可以在头部进行元素操作,但是其头部操作的效率十分低下(主要是涉及到整体的移动)

img

deque和vector的最大差异一个是deque运行在常数时间内对头端进行元素操作,二是deque没有容量的概念,它是动态地以分段连续空间组合而成,可以随时增加一段新的空间并链接起来

deque虽然也提供随机访问的迭代器,但是其迭代器并不是普通的指针,其复杂程度比vector高很多,因此除非必要,否则一般使用vector而非deque。如果需要对deque排序,可以先将deque中的元素复制到vector中,利用sort对vector排序,再将结果复制回deque。

deque由一段一段的定量连续空间组成,一旦需要增加新的空间,只要配置一段定量连续空间拼接在头部或尾部即可,因此deque的最大任务是如何维护这个整体的连续性。

deque内部有一个指针指向map,map是一小块连续空间,其中的每个元素称为一个节点,node,每个node都是一个指针,指向另一段较大的连续空间,称为缓冲区,这里就是deque中实际存放数据的区域,默认大小512bytes。整体结构如下图所示。

img

deque的迭代器数据结构如下:

1
2
3
4
5
6
7
8
9
struct __deque_iterator
{
...
T* cur;//迭代器所指缓冲区当前的元素
T* first;//迭代器所指缓冲区第一个元素
T* last;//迭代器所指缓冲区最后一个元素
map_pointer node;//指向map中的node
...
}

从deque的迭代器数据结构可以看出,为了保持与容器联结,迭代器主要包含上述4个元素:

img

deque迭代器的“++”、“--”操作是远比vector迭代器繁琐,其主要工作在于缓冲区边界,如何从当前缓冲区跳到另一个缓冲区,当然deque内部在插入元素时,如果map中node数量全部使用完,且node指向的缓冲区也没有多余的空间,这时会配置新的map(2倍于当前+2的数量)来容纳更多的node,也就是可以指向更多的缓冲区。在deque删除元素时,也提供了元素的析构和空闲缓冲区空间的释放等机制。

STL中stack和queue的实现

Stack

stack(栈)是一种先进后出(First In Last Out)的数据结构,只有一个入口和出口,那就是栈顶,除了获取栈顶元素外,没有其他方法可以获取到内部的其他元素,其结构图如下:

img

stack这种单向开口的数据结构很容易由双向开口的deque和list形成,只需要根据stack的性质对应移除某些接口即可实现,stack的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class T, class Sequence = deque<T> >
class stack
{
...
protected:
Sequence c;
public:
bool empty(){return c.empty();}
size_type size() const{return c.size();}
reference top() const {return c.back();}
const_reference top() const{return c.back();}
void push(const value_type& x){c.push_back(x);}
void pop(){c.pop_back();}
};

从stack的数据结构可以看出,其所有操作都是围绕Sequence完成,而Sequence默认是deque数据结构。stack这种“修改某种接口,形成另一种风貌”的行为,成为adapter(配接器)。常将其归类为container adapter而非container

stack除了默认使用deque作为其底层容器之外,也可以使用双向开口的list,只需要在初始化stack时,将list作为第二个参数即可。由于stack只能操作顶端的元素,因此其内部元素无法被访问,也不提供迭代器。

Queue

queue(队列)是一种先进先出(First In First Out)的数据结构,只有一个入口和一个出口,分别位于最底端和最顶端,出口元素外,没有其他方法可以获取到内部的其他元素,其结构图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class T, class Sequence = deque<T> >
class queue
{
...
protected:
Sequence c;
public:
bool empty(){return c.empty();}
size_type size() const{return c.size();}
reference front() const {return c.front();}
const_reference front() const{return c.front();}
void push(const value_type& x){c.push_back(x);}
void pop(){c.pop_front();}
};

从queue的数据结构可以看出,其所有操作都也都是是围绕Sequence完成,Sequence默认也是deque数据结构。queue也是一类container adapter。

同样,queue也可以使用list作为底层容器,不具有遍历功能,没有迭代器。

STL中的heap的实现

heap(堆)并不是STL的容器组件,是priority queue(优先队列)的底层实现机制,因为binary max heap(大根堆)总是最大值位于堆的根部,优先级最高。

binary heap本质是一种complete binary tree(完全二叉树),整棵binary tree除了最底层的叶节点之外,都是填满的,但是叶节点从左到右不会出现空隙,如下图所示就是一颗完全二叉树

img

完全二叉树内没有任何节点漏洞,是非常紧凑的,这样的一个好处是可以使用array来存储所有的节点,因为当其中某个节点位于\(i\)处,其左节点必定位于\(2i\)处,右节点位于\(2i+1\)处,父节点位于\(i/2\)(向下取整)处。这种以array表示tree的方式称为隐式表述法。

因此我们可以使用一个array和一组heap算法来实现max heap(每个节点的值大于等于其子节点的值)和min heap(每个节点的值小于等于其子节点的值)。由于array不能动态的改变空间大小,可以用vector代替array。

STL中的priority_queue的实现

priority_queue,优先队列,是一个拥有权值观念的queue,它跟queue一样是顶部入口,底部出口,在插入元素时,元素并非按照插入次序排列,它会自动根据权值(通常是元素的实值)排列,权值最高,排在最前面,如下图所示。

img

默认情况下,priority_queue使用一个max-heap完成,底层容器使用的是一般为vector为底层容器,堆heap为处理规则来管理底层容器实现 。priority_queue的这种实现机制导致其不被归为容器,而是一种容器配接器。

priority_queue的所有元素,进出都有一定的规则,只有queue顶端的元素(权值最高者),才有机会被外界取用,它没有遍历功能,也不提供迭代器

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <queue>
#include <iostream>
using namespace std;

int main()
{
int ia[9] = {0,4,1,2,3,6,5,8,7 };
priority_queue<int> pq(ia, ia + 9);
cout << pq.size() <<endl; // 9
for(int i = 0; i < pq.size(); i++)
{
cout << pq.top() << " "; // 8 8 8 8 8 8 8 8 8
}
cout << endl;
while (!pq.empty())
{
cout << pq.top() << ' ';// 8 7 6 5 4 3 2 1 0
pq.pop();
}
return 0;
}

STL中set的实现?

STL中的容器可分为序列式容器(sequence)和关联式容器(associative),set属于关联式容器。

set的特性是,所有元素都会根据元素的值自动被排序(默认升序),set元素的键值就是实值,实值就是键值,set不允许有两个相同的键值

set不允许迭代器修改元素的值,其迭代器是一种constance iterators

标准的STL set以RB-tree(红黑树)作为底层机制,几乎所有的set操作行为都是转调用RB-tree的操作行为,这里补充一下红黑树的特性:

  • 每个节点不是红色就是黑色
  • 根结点为黑色
  • 如果节点为红色,其子节点必为黑
  • 任一节点至(NULL)树尾端的任何路径,所含的黑节点数量必相同

关于红黑树的具体操作过程,比较复杂读者可以翻阅《算法导论》详细了解。

STL中map的实现

map的特性是所有元素会根据键值进行自动排序。map中所有的元素都是pair,拥有键值(key)和实值(value)两个部分,并且不允许元素有相同的key

一旦map的key确定了,那么是无法修改的,但是可以修改这个key对应的value,因此map的迭代器既不是constant iterator,也不是mutable iterator

标准STL map的底层机制是RB-tree(红黑树),另一种以hash table为底层机制实现的称为hash_map。map的架构如下图所示

img

map的在构造时缺省采用递增排序key,也使用alloc配置器配置空间大小,需要注意的是在插入元素时,调用的是红黑树中的insert_unique()方法,而非insert_euqal()(multimap使用)

set和map的区别,multimap和multiset的区别

set只提供一种数据类型的接口,但是会将这一个元素分配到key和value上,而且它的compare_function用的是 identity()函数,这个函数是输入什么输出什么,这样就实现了set机制,set的key和value其实是一样的了。其实他保存的是两份元素,而不是只保存一份元素。

map则提供两种数据类型的接口,分别放在key和value的位置上,他的比较function采用的是红黑树的comparefunction(),保存的确实是两份元素。

他们两个的insert都是采用红黑树的insert_unique() 独一无二的插入。

multiset和multimap的区别也是一样的,只不过multiset和multimap使用insert_equal()。

multimap和map的唯一区别就是:multimap调用的是红黑树的insert_equal(),可以重复插入而map调用的则是独一无二的插入insert_unique(),multiset和set也一样,底层实现都是一样的,只是在插入的时候调用的方法不一样。

红黑树

面试时候现场写红黑树代码的概率几乎为0,但是红黑树一些基本概念还是需要掌握的。

  1. 它是二叉排序树(继承二叉排序树特显):
  • 若左子树不空,则左子树上所有结点的值均小于或等于它的根结点的值。
  • 若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值。
    • 左、右子树也分别为二叉排序树。
  1. 它满足如下几点要求:
  • 树中所有节点非红即黑。
  • 根节点必为黑节点。
  • 红节点的子节点必为黑(黑节点子节点可为黑)。
  • 从根到NULL的任何路径上黑结点数相同。
  1. 查找时间一定可以控制在O(logn)。

STL中unordered_map和map的区别和应用场景

  • map底层实现是红黑树,查询和维护时间复杂度均为O(log n),而unordered_map的底层实现是哈希表,其查询时间复杂度为O(1),维护时间与bucket桶锁维护的list长度有关,到那时建立hash表耗时比较大。

  • map支持键值自动排序,而unordered_map是无序容器

从两者的底层机制和特点可以看出:map适用于有序数据的应用场景,unordered_map适用于高效查询的应用场景

hashtable中解决冲突有哪些方法?

  • 线性探测法:使用hash函数计算出的位置如果已经有元素占用了,则向后依次寻找,找到表尾则回到表头,直到找到一个空位
  • 拉链法:每个表格维护一个list,如果hash函数计算出的格子相同,则按顺序存在这个list中
  • 再散列:发生冲突时使用另一种hash函数再计算一个地址,直到不冲突
  • 二次探测:使用hash函数计算出的位置如果已经有元素占用了,按照\(1^2\)\(2^2\)\(3^2\)...的步长依次寻找,如果步长是随机数序列,则称之为伪随机探测

STL的六大组件

  1. 容器Containers:各种数据结构,如 vector、list、deque、set、map 等。分为两大类,序列式容器和关联式容器。序列式容器(sequential container)为程序员提供了控制元素存储和访问顺序的能力。和序列式容器对应的是关联式容器(associative-container),关联容器中的元素是按关键字来保存和访问的。关联容器支持高效的关键字查找和访问,STL 有两个主要的关联容器:map 和 set。
  2. 算法Algorithms:各种常用算法,提供了执行各种操作的方式,包括对容器内容执行初始化、排序、搜索和转换等操作,比如 sort、search、copy、erase。从实现的角度来看,STL 算法是一种 function template。
  3. 迭代器Iterator:迭代器(Iterators):迭代器用于遍历对象集合的元素,扮演容器与算法之间的胶合剂,是所谓的“泛型指针”。
  4. 仿函数Functors:也称为函数对象(Function object),行为类似函数,可作为算法的某种策略。从实现角度来看,仿函数是一种重载了operator() 的 class 或者 class template。
  5. 适配器Adapters:一种用来修饰容器或者仿函数或迭代器接口的东西。例如 STL 提供的 queue 和 stack,就是一种空间配接器,因为它们的底部完全借助于 deque。本质上,一个适配器是一种机制,能使某种事物的行为看起来像另外一种事物一样。
  6. 分配器allocator:也称为空间配置器,负责空间的配置与管理。从实现的角度来看,配置器是一个实现了动态配置空间、空间管理、空间释放的 class template。

红黑树

红黑树是一种自平衡的二叉搜索树,它在每个节点上增加了一个额外的属性表示节点的颜色(红色或黑色),并通过一组规则来保持树的平衡。这些规则确保了红黑树的高度保持在对数级别,从而保证了各种基本操作的高效性。

红黑树的特性包括:

  1. 节点颜色:
    • 每个节点都被标记为红色或黑色。
  2. 根节点:
    • 根节点是黑色的。
  3. 叶子节点(NIL节点):
    • 所有叶子节点(NIL节点,也称为哨兵节点)都是黑色的。
  4. 红色节点规则:
    • 父子节点之间不能同时为红色,也就是说,红色节点不能有红色的子节点。
  5. 路径黑高度相等:
    • 从任意节点到其每个叶子节点的路径都包含相同数目的黑色节点,这被称为黑高度相等。

这些特性确保了红黑树的平衡,使得在最坏情况下,红黑树的高度是对数级别的。这样做的目的是保证插入、删除和查找等基本操作的时间复杂度始终是对数级别的,保证了红黑树的高效性。

红黑树的自平衡性质使其在动态插入和删除操作时能够有效地维持平衡,而不会退化为一棵高度不平衡的树。这使得红黑树在实际应用中被广泛使用,例如在STL中的std::mapstd::set的实现中。

STL的vector的内存扩容为什么是2倍?3倍可以吗

VS用的是两倍,GCC扩容是1.5倍。为什么不用3倍?(可能3倍太浪费)

标准库并没有提供直接设置容器扩容倍数的接口。所以想要用3倍需要自己实现一个新的容器。

vector的初始容量是多少?

初始容量是0。

初始为0怎么按照1.5或2倍的扩容规则扩容?

初始为0的话第一次扩容是例外,会扩容到1,然后再按照1.5或2倍的规则扩容。

项目

epoll底层实现细节

epoll 在 Linux 内核中申请了一个简易的文件系统,把原先的一个 select 或 poll 调用分成了 3 部分:

1
2
3
int epoll_create(int size);  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
  1. 调用 epoll_create 建立一个 epoll 对象(在 epoll 文件系统中给这个句柄分配资源);
  2. 调用 epoll_ctl 向 epoll 对象中添加这 100 万个连接的套接字;
  3. 调用 epoll_wait 收集发生事件的连接。

这样只需要在进程启动时建立 1 个 epoll 对象,并在需要的时候向它添加或删除连接就可以了,因此,在实际收集事件时,epoll_wait 的效率就会非常高,因为调用 epoll_wait 时并没有向它传递这 100 万个连接,内核也不需要去遍历全部的连接。

当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关,如下所示:

1
2
3
4
5
6
7
8
9
struct eventpoll {
  ...
  /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
  也就是这个epoll监控的事件*/
  struct rb_root rbr;
  /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
  struct list_head rdllist;
  ...
};

我们在调用 epoll_create 时,内核除了帮我们在 epoll 文件系统里建了个 file 结点,在内核 cache 里建了个红黑树用于存储以后 epoll_ctl 传来的 socket 外,还会再建立一个 rdllist 双向链表,用于存储准备就绪的事件,当 epoll_wait 调用时,仅仅观察这个 rdllist 双向链表里有没有数据即可。有数据就返回,没有数据就 sleep,等到 timeout 时间到后即使链表没数据也返回。所以,epoll_wait 非常高效。

所有添加到 epoll 中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做 ep_poll_callback,它会把这样的事件放到上面的 rdllist 双向链表中。

在 epoll 中对于每一个事件都会建立一个 epitem 结构体,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct epitem {
  ...
  //红黑树节点
  struct rb_node rbn;
  //双向链表节点
  struct list_head rdllink;
  //事件句柄等信息
  struct epoll_filefd ffd;
  //指向其所属的eventepoll对象
  struct eventpoll *ep;
  //期待的事件类型
  struct epoll_event event;
  ...
}; // 这里包含每一个事件对应着的信息。

当调用 epoll_wait 检查是否有发生事件的连接时,只是检查 eventpoll 对象中的 rdllist 双向链表是否有 epitem 元素而已,如果 rdllist 链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此 epoll_waitx 效率非常高。epoll_ctl 在向 epoll 对象中添加、修改、删除事件时,从 rbr 红黑树中查找事件也非常快,也就是说 epoll 是非常高效的,它可以轻易地处理百万级别的并发连接。

总结:执行 epoll_create()时,创建了红黑树和就绪链表;执行 epoll_ctl()时,如果增加 socket 句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表(是一个双向链表)中插入数据;执行 epoll_wait()时,如果 rdllist 链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。

LT模式和ET模式分别适合哪种场景?

ET 模式(边缘触发):

在 ET 模式下,当 I/O 状态发生变化时,系统只通知一次,直到下次状态变化才会再次通知。也就是说,ET 模式只在状态从未就绪变为就绪时通知一次。

适用场景:

对于 ET 模式,通常需要使用非阻塞(non-blocking)I/O。因为 ET 模式只在状态变化的瞬间通知一次,如果使用阻塞 I/O,可能在数据未完全处理时就被阻塞。

ET 模式适用于需要及时响应状态变化的场景,例如网络套接字的事件处理,可以在一次通知中处理多个事件。ET 模式更容易实现高性能,因为事件只在状态变化时通知,减少了不必要的上下文切换。

LT 模式(水平触发):

在 LT 模式下,当 I/O 状态处于就绪时,系统会一直通知直到状态变为不可用。也就是说,LT 模式会持续通知应用程序,直到应用程序处理完所有就绪的数据或事件。

适用场景:

LT 模式更适合于使用阻塞 I/O 的场景,因为在 LT 模式下,系统会持续通知应用程序,直到应用程序处理完成,避免了在数据未完全处理时被阻塞。

LT 模式适用于对于状态变化需要持续处理的场景,比如文件 I/O。应用程序可以在每次通知中处理少量的数据,然后等待下一次通知。

线程池怎么实现

先创建并启动一组线程,称为线程池threads_ ,由用户指定其大小maxQueueSize_。每个线程函数都是一样的,在其中会运行一个loop循环:从双端队列取出一个任务对象task,如果非空,就执行之,如此往复。

当有一个用户线程想要通过线程池运行一个用户任务时,就可以将用户任务函数及参数封装成一个可调用对象Task f,然后通过线程池接口,将f加入双端队列末尾。当线程池有线程空闲时(未执行用户任务),就会从双端队列头部取出一个Task对象task,然后执行之。

img

线程池主要由以下几个部分组成:

  1. 工作队列queue_,用双端队列实现,能从尾部加入用户任务对应的可调用对象;
  2. 用户任务Task f,封装了用户任务,包含任务函数和参数;
  3. 线程组threads_,用于管理工作的线程数组;
  4. 工作线程,执行回调函数。

Reactor实现

Reactor 模式主要有 Reactor 和 Event Handler 两个核心模块组成,其中 Reactor 负责监听事件,当事件发生时通过事件分发机制(Acceptor类)将其发给 Event Handler 进行处理。muduo有单reactor单线程和多reactor多线程,这里使用的是多reactor多线程,基于one loop per thread的思想。

事件循环主要由EventLoop, Channel 和 Poller类实现,其中EventLoop 是事件循环的“驱动者”:

  • EventLoop 是整个事件循环的核心。核心功能是驱动 Poller 将事件循环跑起来,并通过其提供的 poll 方法进行事件循环(也就是上面的 IO 多路复用调用)
  • Poller 类是对 IO 多路复用系统调用的一个封装类,可以向其中注册感兴趣的 Channel,阻塞等待事件发生,事件发生之后返回发生的事件(通过 Channel 类)。
  • Channel 类对某个文件描述符、相关事件、处理事件的方法进行了封装。更新 Channel 中的感兴趣事件实际需要通过 EventLoop 进行,EventLoop 通过其持有的 Poller 来实际执行,Channel 和 Poller 并没有直接的联系。

设计⼀个⽀持最⼤并发数的线程池

仿照muduo的线程池,先创建并启动一组线程,称为线程池threads_ ,由用户指定其大小maxQueueSize_。每个线程会运行一个loop循环:从双端队列取出一个任务对象task,如果非空,就执行之,如此往复。

如果有个⾼优先级的事件怎么处理

将高优先级队列插入到双端队列头

如果任务队列的任务之间有依赖关系要怎么处理呢?

将前置任务先插入双端队列,后置任务后插入双端队列。

webserver中buffer是怎么设计的?怎么实现自动增长

注:这里因为muduo的buffer是自适应的,可以实现自动增长。比如它一开始的初始值是 1k,如果程序里边经常收发 10k 的数据,那么用几次之后它的 size() 会自动增长到 10k,然后就保持不变。

回答

Buffer 的内部是一个 vector of char,它是一块连续的内存。此外,Buffer 有两个 data members分别为read_index和write_imdex,指向该 vector 中的元素。这两个 indices 的类型是 int,不是 char*,目的是应对迭代器失效。这两个索引把vector分为三部分,prependable、readable、writable。

其中prependable可以让程序能以很低的代价在数据前面添加几个字节;全部数据读完了,readIndex 和 writeIndex 返回原位以备新一轮使用。

因为使用的是vector所以可以自动增长。

线程池的重要参数有哪些

工作队列的容量,线程池的容量

内存泄漏如何检测和防⽌?

检测

  1. 检查代码:仔细检查代码中的内存分配和释放,确保每次分配内存后都有相应的释放操作。比如 malloc和free、new和delete是否配对使用了。
  2. 使用调试器和工具:有一些工具可以帮助检测内存泄露。例如:
    1. Valgrind(仅限于Linux和macOS):Valgrind是一个功能强大的内存管理分析工具,可以检测内存泄露、未初始化的内存访问、数组越界等问题。使用Valgrind分析程序时,只需在命令行中输入valgrind --leak-check=yes your_program即可。
    2. Visual Studio中的CRT(C Runtime)调试功能:Visual Studio提供了一些用于检测内存泄露的C Runtime库调试功能。例如,_CrtDumpMemoryLeaks函数可以在程序结束时报告内存泄露。
    3. AddressSanitizer:AddressSanitizer是一个用于检测内存错误的编译器插件,适用于GCC和Clang。要启用AddressSanitizer,只需在编译时添加-fsanitize=address选项。

防止

  1. 使用智能指针(C++):在C++中,可以使用智能指针(如std::unique_ptr和std::shared_ptr)来自动管理内存。这些智能指针在作用域结束时会自动释放所指向的内存,从而降低忘记释放内存或者程序异常导致内存泄露的风险。
  2. 异常安全:在C++中,如果程序抛出异常,需要确保在异常处理过程中正确释放已分配的内存。使用try-catch块来捕获异常并在适当的位置释放内存。 或者使用RAII(Resource Acquisition Is Initialization)技术,将资源(如内存)的管理与对象的生命周期绑定。

手写线程池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <functional>

class ThreadPool {
public:
// 构造函数,初始化线程池
ThreadPool(size_t numThreads) : stop(false) {
// 创建指定数量的工作线程,并将它们添加到线程池中
for (size_t i = 0; i < numThreads; ++i) {
workers.emplace_back([this] {
// 线程主循环
while (true) {
std::function<void()> task;

{
// 使用互斥锁保护任务队列
std::unique_lock<std::mutex> lock(queueMutex);

// 使用条件变量等待任务队列不为空或停止信号
// 该wait函数内部会自动调用lock.unlock()对互斥锁解锁,使得其他被阻塞在互斥锁上的线程恢复执行
condition.wait(lock, [this] { return stop || !tasks.empty(); });

// 如果收到停止信号且任务队列为空,线程退出
if (stop && tasks.empty()) {
return;
}

// 取出队列头部的任务
task = std::move(tasks.front());
tasks.pop();
}

// 执行取出的任务
task();
}
});
}
}

// 将任务加入到任务队列
template<class F>
void enqueue(F&& func) {
{
// 使用互斥锁保护任务队列
std::unique_lock<std::mutex> lock(queueMutex);

// 将任务移动到队列中
tasks.emplace(std::forward<F>(func));
}

// 通知一个等待中的线程有新任务可以执行
condition.notify_one();
}

// 析构函数,停止线程池,并等待所有线程结束
~ThreadPool() {
{
// 使用互斥锁保护停止信号
std::unique_lock<std::mutex> lock(queueMutex);
stop = true;
}

// 唤醒所有等待中的线程
condition.notify_all();

// 等待所有线程结束
for (std::thread &worker : workers) {
worker.join();
}
}

private:
// 工作线程
std::vector<std::thread> workers;

// 任务队列
std::queue<std::function<void()>> tasks;

// 互斥锁用于保护对任务队列的访问
std::mutex queueMutex;

// 条件变量用于通知工作线程有新任务
std::condition_variable condition;

// 停止信号,用于通知工作线程停止
bool stop;
};

int main() {
// 创建包含4个工作线程的线程池
ThreadPool threadPool(4);

// 向线程池中提交8个任务
for (int i = 0; i < 8; ++i) {
threadPool.enqueue([i] {
// 打印任务信息和执行线程的ID
std::cout << "Task " << i << " processed by thread " << std::this_thread::get_id() << std::endl;
});
}

// 给予一些时间让任务被处理
std::this_thread::sleep_for(std::chrono::seconds(2));

return 0;
}

算法

海量数据找中位数

方法一:桶排序

  1. 创建多个小文件桶,设定每个桶的取值范围,然后把海量数据元素根据数值分配到对应的桶中,并记录桶中元素的个数

  2. 根据桶中元素的个数,计算出中位数所在的桶(比如 100 亿个数据,第 1 个桶到第 18 个桶一共有 49 亿个数据,第 19 个桶有 2 亿数据,那么中位数一定在第 19 个桶中),然后针对该桶进行排序,就可以求出海量数据中位数的值(如果内存还是不够,可以继续对这个桶进行拆分;或者直接用 BitMap 来排序)

简单用 100 个数据画个图直观理解下:

img

方法二:分治法 + 基于二进制比较

假设这 100 亿数据都是 int 类型,4 字节(32 位)的有符号整数,存在一个超大文件中。

将每个数字用二进制表示,比较二进制的【最高位】 (第 32 位),如果数字的最高位为 0,则将这个数字写入 file_0 文件中;如果最高位为 1,则将该数字写入 file_1 文件中。

最高位为符号位,也就是说 file_1 中的数都是负数,而 file_0 中的数都是正数。

通过这样的操作,这 100 亿个数字分成了两个文件,假设 file_0 文件中有 60 亿个数字,而 file_1 文件中有 40 亿个数字。

这样划分后,思考一下:所求的中位数在哪个文件中?

100 亿个数字的中位数是 100 亿个数排序之后的第 50 亿个数,现在 file_0 有 60 亿个正数,file_1 有 40 亿个负数,file_0 中的数都比 file_1 中的数要大,排序之后的第 50 亿个数是中位数,那么这个中位数一定位于 file_0 中,并且是 file_0 文件中所有数字排序之后的第 10 亿个数字。

现在,我们只需要处理 file_0 文件了(不需要再考虑 file_1 文件)。

而对于 file_0 文件,可以同样的采取上面的措施处理:将 file_0 文件依次读一部分到内存,将每个数字用二进制表示,比较二进制的【次高位】(第 31 位),如果数字的次高位为 0,写入 file_0_0 文件中;如果次高位为 1 ,写入 file_0_1 文件中。

现假设 file_0_0 文件中有 30 亿个数字,file_0_1 中也有 30 亿个数字,则中位数就是:file_0_0 文件中的数字从小到大排序之后的第 10 亿个数字。

抛弃 file_0_1 文件,继续对 file_0_0 文件 根据【次次高位】(第 30 位) 划分,如此反复下去,便可以得到中位数。

方法三:堆排序

要用堆排序直接找到中位数,可以把问题转换为找到第K大的数(K是全部数据量的一半),但是由于数据量过大,所以内存不能设置一个大小为K的堆,所以可以考虑分为多步。首先用小根堆堆找到第k大的数(k < K),然后再找到第2k大的数(有了kth大的数之后,再次遍历数据,大于kth的数直接丢掉,小于的放到小根堆中,最后可以得到2kth大的数),如此循环往复即可。

方法四:外排序

使用外部归并排序


C++知识点
https://gstarmin.github.io/2023/11/23/Cpp面试知识点/
作者
Starmin
发布于
2023年11月23日
更新于
2024年1月20日
许可协议