C++面试题整理

一、基础知识

1. C++和C语言的区别

  1. C是面向过程的语言,是一个结构化的语言,考虑如何通过一个过程对输入进行处理得到输出;C++是面向对象的语言,主要特征是“封装、继承和多态”。封装隐藏了实现细节,使得代码模块化;派生类可以继承父类的数据和方法,扩展了已经存在的模块,实现了代码重用;多态则是“一个接口,多种实现”,通过派生类重写父类的虚函数,实现了接口的重用。
  2. C和C++动态管理内存的方法不一样,C是使用malloc/free,而C++除此之外还有new/delete关键字。
  3. C++支持函数重载,C不支持函数重载 (函数重载:C++允许在同一范围中声明几个功能类似的同名函数,但是这些同名函数的形式参数(指参数的个数、类型或者顺序)必须不同)
  4. C++中有引用,C中不存在引用的概念
  5. C提供了标准库(如 stdio.hstdlib.h),主要用于输入输出和内存管理。C++除了C标准库,还引入了丰富的标准库(如 iostreamstringvector),支持面向对象编程和泛型编程。
  6. C:没有命名空间的概念。C++:引入了命名空间(namespace),用于组织代码,避免名称冲突。
  7. C:没有内置的异常处理机制,通常通过错误代码来处理错误。C++:引入了异常处理机制(try, catch, throw),提供了更优雅的错误处理方式。

2. C++中指针与引用的区别

  1. 指针是一个变量,存储了另一个变量的地址。通过访问这个地址,可以读取或修改该地址所指向的变量。引用是一个已有变量的别名,对引用的任何操作都是对该变量本身的操作。
  2. 指针可以有多级(例如,指向指针的指针,int**),可以用来表示复杂的数据结构。引用只有一级,不能有引用的引用。
  3. 指针传参的时候,还是值传递,指针本身的值不可以修改,需要通过解引用才能对指向的对象进行操作。引用传参的时候,传进来的就是变量本身,因此变量可以被修改。
  4. 指针可以被初始化为指向nullptr,而引用必须在定义时初始化,且必须指向一个已有的对象,不能是空引用。
  5. 如果返回动态内存分配对象,最好使用指针,引用析构时会不会释放内存。但是可以使用delete &ref来释放内存。
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
#include <iostream>
using namespace std;

int64_t* createDynamicInt() {
int64_t* p = new int64_t(10); // 动态分配内存
return p; // 返回指针
}

int64_t& createDynamicIntRef() {
int64_t* p = new int64_t(10); // 动态分配内存
return *p; // 返回引用
}

int main() {
for(int i = 0; i < 100000000; i++) {
int64_t* ptr = createDynamicInt();
delete ptr;
}
//memory used: 6220

for(int i = 0; i < 100000000; i++) {
int64_t& ref = createDynamicIntRef();
}
// memory used:3131188

for(int i = 0; i < 100000000; i++) {
int64_t& ref = createDynamicIntRef();
delete &ref;
}
// memory used: 6220

while(1);
return 0;
}

3. const知道吗?解释一下其作用

  1. 修饰变量,说明该变量一旦被初始化就不能再被修改。
  2. 修饰指针,分为指向常量的指针(pointer to const)指针所指向的对象不能通过该指针修改,但指针本身可以修改,const A *p = &a; // 指针变量,指向常对象。和自身是常量的指针(常量指针,const pointer);指针本身不能修改,但可以通过该指针修改所指向的对象char *const p3 = greeting; // 自身是常量的指针
  3. 修饰引用,指向常量的引用(reference to const),用于形参类型,即避免了拷贝,又避免了函数对值的修改;
  4. 修饰成员函数,表示该成员函数不能修改类中的任何数据成员,并且不能调用其他非 const 的成员函数。可以使用 mutable 关键字声明一些成员变量,即使在 const 成员函数中也能修改。
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
// 类
class A {
private:
const int a; // 常对象成员,可以使用初始化列表或者类内初始化
public:
// 构造函数
A() : a(0) {};
A(int x) : a(x) {}; // 初始化列表
// const可用于对重载函数的区分
int getValue(); // 普通成员函数
int getValue() const; // 常成员函数,不得修改类中的任何数据成员的值
};

void function() {
// 对象
A b; // 普通对象,可以调用全部成员函数
const A a; // 常对象,只能调用常成员函数
const A *p = &a; // 指针变量,指向常对象
const A &q = a; // 指向常对象的引用

// 指针
char greeting[] = "Hello";
char *p1 = greeting; // 指针变量,指向字符数组变量
const char *p2 = greeting; // 指针变量,指向字符数组常量(const 后面是 char,说明指向的字符(char)不可改变)
char *const p3 = greeting; // 自身是常量的指针,指向字符数组变量(const 后面是 p3,说明 p3 指针自身不可改变)
const char *const p4 = greeting; // 自身是常量的指针,指向字符数组常量
}

// 函数
void function1(const int Var); // 传递过来的参数在函数内不可变
void function2(const char *Var); // 参数指针所指内容为常量
void function3(char *const Var); // 参数指针为常量
void function4(const int &Var); // 引用参数在函数内为常量

// 函数返回值
const int function5(); // 返回一个常数
const int *function6(); // 返回一个指向常量的指针变量,使用:const int *p = function6();
int *const function7(); // 返回一个指向变量的常指针,使用:int* const p = function7();

4. static

注意和const的区别!!!const强调值不能被修改,而static强调唯一的拷贝,对所有类对象都只有一份

作用:

  1. 修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,在 main 函数运行前就分配了空间,如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它。
  2. 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命名空间里的函数重名,可以将函数定位为 static。
  3. 修饰成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员。
  4. 修饰成员函数,修饰成员函数使得不需要生成对象就可以访问该函数,但是在 static 函数内不能访问非静态成员。

作用范围:

  1. 函数体内: static 修饰的局部变量作用范围为该函数体,不同于auto变量,其内存只被分配一次,因此其值在下次调用的时候维持了上次的值;
  2. 模块内:static修饰全局变量或全局函数,可以被模块内的所有函数访问,但是不能被模块外的其他函数访问,使用范围限制在声明它的模块内;
  3. 类中:修饰成员变量,表示该变量属于整个类所有,对类的所有对象只有一份拷贝;
  4. 类中:修饰成员函数,表示该函数属于整个类所有,不接受this指针,只能访问类中的static成员变量;

C语言的static和C++的static有什么区别:

在C中static用来修饰局部静态变量和外部静态变量、函数。而C++中除了上述功能外,还用来定义类的成员变量和函数。

5. #include<file.h> 和 #include “file.h” 的区别

前者是从标准库路径开始寻找;

后者是从当前工作目录(也就是包含这个指令的源文件所在的目录)中查找 file.h。如果在当前工作目录中找不到该文件,编译器才会继续在标准库路径中查找。这种方式通常用于包含用户定义的头文件。

这样做的好处是明确区分标准库头文件和用户自定义头文件,避免潜在的命名冲突。此外,使用尖括号包含标准库头文件有助于编译器更高效地定位文件,提高编译速度。

6. 声明和定义的区别

声明是告诉编译器变量的类型和名字,不会为变量分配空间;

1
2
extern int myVariable;  // 声明一个外部变量
int add(int a, int b); // 声明一个函数

定义需要分配空间,同一个变量可以被声明多次,但是只能被定义一次;

1
2
3
4
int myVariable = 10;  // 定义并初始化变量
int add(int a, int b) { // 定义函数
return a + b;
}

7. C++文件编译与执行的四个阶段

首先是 预处理阶段(preprocessing) -> 编译阶段(compilation) -> 汇编阶段(assembly) -> 链接阶段(linking)

1
2
3
4
5
6
7
8
9
#include <iostream>
#define MAX 100
int main() {
std::cout << "Hello, World!" << std::endl;
int a = 10;
a = a + MAX;
std::cout << a << std::endl;
return 0;
}

预处理阶段,编译器对文件包含关系进行检查(头文件和宏),将其作相应替换,生成.i文件;

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
# 0 "aaa.cpp"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "aaa.cpp"
# 1 "/usr/include/c++/14.1.1/iostream" 1 3
...
namespace std __attribute__ ((__visibility__ ("default")))
{

# 62 "/usr/include/c++/14.1.1/iostream" 3
extern istream cin;
extern ostream cout;
extern ostream cerr;
extern ostream clog;


extern wistream wcin;
extern wostream wcout;
extern wostream wcerr;
extern wostream wclog;
# 82 "/usr/include/c++/14.1.1/iostream" 3
__extension__ __asm (".globl _ZSt21ios_base_library_initv");



}
# 2 "aaa.cpp" 2


# 3 "aaa.cpp"
int main() {
std::cout << "Hello, World!" << std::endl;
int a = 10;
a = a + 100;
std::cout << a << std::endl;
return 0;
}

编译阶段,将预处理生成的.i 文件转化为汇编文件.s;

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
	.file	"aaa.cpp"
.text
#APP
.globl _ZSt21ios_base_library_initv
.section .rodata
.LC0:
.string "Hello, World!"
#NO_APP
.text
.globl main
.type main, @function
main:
.LFB2012:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
leaq .LC0(%rip), %rax
movq %rax, %rsi
leaq _ZSt4cout(%rip), %rax
movq %rax, %rdi
call _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@PLT
movq _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_@GOTPCREL(%rip), %rdx
movq %rdx, %rsi
movq %rax, %rdi
call _ZNSolsEPFRSoS_E@PLT
movl $10, -4(%rbp)
addl $100, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, %esi
leaq _ZSt4cout(%rip), %rax
movq %rax, %rdi
call _ZNSolsEi@PLT
movq _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_@GOTPCREL(%rip), %rdx
movq %rdx, %rsi
movq %rax, %rdi
call _ZNSolsEPFRSoS_E@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE2012:
.size main, .-main
.section .rodata
.type _ZNSt8__detail30__integer_to_chars_is_unsignedIjEE, @object
.size _ZNSt8__detail30__integer_to_chars_is_unsignedIjEE, 1
_ZNSt8__detail30__integer_to_chars_is_unsignedIjEE:
.byte 1
.type _ZNSt8__detail30__integer_to_chars_is_unsignedImEE, @object
.size _ZNSt8__detail30__integer_to_chars_is_unsignedImEE, 1
_ZNSt8__detail30__integer_to_chars_is_unsignedImEE:
.byte 1
.type _ZNSt8__detail30__integer_to_chars_is_unsignedIyEE, @object
.size _ZNSt8__detail30__integer_to_chars_is_unsignedIyEE, 1
_ZNSt8__detail30__integer_to_chars_is_unsignedIyEE:
.byte 1
.ident "GCC: (GNU) 14.1.1 20240522"
.section .note.GNU-stack,"",@progbits

汇编阶段,将汇编文件见转化为二进制机器码,对应后缀是.o(Linux), .obj(Windows);

image-20240726234732937

链接阶段,将多个目标文件及所需要的库链接成可执行文件,.out(Linux), .exe(Windows);

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

静态绑定和动态绑定是C++多态性的一种特性, 只有虚函数才使用的是动态绑定,其他的全部是静态绑定

1)对象的静态类型和动态类型

  • 静态类型:对象在声明时采用的类型,依赖于对象的静态类型, 在编译时确定;

  • 动态类型:当前对象所指的类型,在运行期决定,对象的动态类型可变,静态类型无法更改

2)静态绑定和动态绑定

  • 静态绑定:绑定的是对象的静态类型,函数依赖于对象的静态类型,在编译期确定

  • 动态绑定:绑定的是对象的动态类型,函数依赖于对象的动态类型,在运行期确定

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

可以实现动态绑定。因为引用(或指针)既可以指向基类对象也可以指向派生类对象,这一特性是动态绑定的关键。通过引用(或指针)调用的虚函数在运行时根据引用(或指针)所指对象的实际类型来确定具体调用哪个函数。这种机制允许程序在运行时确定调用的具体实现,从而实现多态性。

在C++中,动态绑定依赖于虚函数和多态性。虚函数表(vtable)用于在运行时动态查找和调用正确的函数实现。当通过基类的引用或指针调用虚函数时,程序会查找vtable中相应的函数指针,并调用实际对象的函数实现。因此,引用可以实现动态绑定。

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
#include <iostream>
using namespace std;

class Base {
public:
virtual void show() {
cout << "Base class show function" << endl;
}
};

class Derived : public Base {
public:
void show() override {
cout << "Derived class show function" << endl;
}
};

void display(Base& obj) {
obj.show();
}

int main() {
Base baseObj;
Derived derivedObj;

display(baseObj); // 输出:Base class show function
display(derivedObj); // 输出:Derived class show function

return 0;
}

10. 深拷贝和浅拷贝的区别

它们的主要区别在于对资源的处理方式:

  1. 浅拷贝:浅拷贝仅仅复制对象的所有成员变量,但不复制对象所引用的资源。也就是说,新对象和原对象共享同一块资源。这可能导致当一个对象改变资源时,另一个对象也会受到影响。例如,如果一个类包含一个指针成员变量,那么浅拷贝会复制指针的值,而不是指针所指向的实际内容。
  2. 深拷贝:深拷贝不仅复制对象的所有成员变量,还会复制对象所引用的资源。这样,新对象就拥有了自己独立的一份资源,不再与原对象共享。因此,即使一个对象修改了资源,另一个对象也不会受到影响。对于包含指针成员变量的类,深拷贝会创建一个新指针,并分配一块新的内存,复制指针所指向的内容。

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

在C++中,拷贝构造函数是在以下三种情况下被调用的:

  1. 用类的一个对象去初始化另一个对象的时候 当我们用已有对象去创建一个新的对象时,系统会调用拷贝构造函数。例如:

    1
    2
    MyClass obj1;
    MyClass obj2 = obj1; // 这里会调用拷贝构造函数
  2. 函数的参数是类的对象时(值传递) 当函数参数是类的对象而不是引用时,函数调用会触发拷贝构造函数。例如:

    1
    2
    3
    4
    5
    void func(MyClass obj) {
    // 这里 obj 是值传递,会调用拷贝构造函数
    }
    MyClass obj1;
    func(obj1); // 调用 func 时会触发拷贝构造函数
  3. 函数的返回值是类的对象时 当函数返回值是类的对象而不是引用时,返回时会调用拷贝构造函数。例如:

    1
    2
    3
    4
    5
    MyClass func() {
    MyClass obj;
    return obj; // 这里返回对象,会调用拷贝构造函数
    }
    MyClass obj2 = func(); // 调用 func 时会触发拷贝构造函数

在编写类时,如果没有定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数会对类的所有成员进行逐成员复制。如果类中包含了指针成员或需要深拷贝的资源管理情况,建议手动实现拷贝构造函数以避免潜在的问题。

**12. C++的类型转换 **

隐式类型转换

隐式类型转换比较常见,在混合类型表达式中经常发生;

1
2
char c = 'A'; int i = 10;
int result = c + i; // 隐式类型转换:char 转换为 int, result = 65 + 10 = 75

类类型可以通过构造函数或转换运算符实现隐式转换。

1
2
3
4
5
6
7
8
9
10
11
12
class Integer {
public:
int value;
// 构造函数实现隐式转换
Integer(int v) : value(v) {}
// 转换运算符实现隐式转换
operator int() const { return value; }
};

Integer obj = 42; // 隐式调用构造函数
int num = obj; // 隐式调用转换运算符
cout << "num: " << num << endl; // 输出: num: 42

强制类型转换

1)static_cast

1
static_cast < newtype > ( expression )

static_cast 在编译期进行静态类型检查。它将 expression 转换为 new-type 类型,并在转换时进行必要的检查(如指针越界、类型检查)。它通常是相对安全的。

2)dynamic_cast

dynamic_cast 用于运行时类型检查,特别是在继承体系中进行安全的向下转换(downcast),即基类指针或引用转换为派生类指针或引用。

1
dynamic_cast<newtype>(expression)

dynamic_cast 是唯一一个依赖运行时类型信息(RTTI)的转换操作符,如果转换失败,它会返回 nullptr。源类必须包含虚函数以支持多态性,才能使用 dynamic_cast。虚函数使得类的实例包含了一个指向虚函数表(vtable)的指针,而这个表在运行时包含了类型信息,使得运行时类型识别(RTTI)成为可能。

3)const_cast

const_cast 用于移除对象的 const 属性,使其可以修改:

1
const_cast<newtype>(expression)

它还可以用于移除或增加 volatile 属性。

1
2
3
volatile int* volatilePtr = const_cast<volatile int*>(&n); // add
volatile int v = 5;
int* nonVolatilePtr = const_cast<int*>(&v); // remove

4)reinterpret_cast

reinterpret_cast 通常用于将一种数据类型转换为另一种不同的数据类型,它不执行任何类型检查,使用时需要格外小心。

1
reinterpret_cast<newtype>(expression)

5)bad_cast

dynamic_cast 如果用于引用类型转换失败,将抛出 bad_cast 异常:

1
2
3
4
5
try {
Circle& ref_circle = dynamic_cast<Circle&>(ref_shape);
} catch (const bad_cast& b) {
cout << "Caught: " << b.what();
}

static_cast和reinterpret_cast它们的区别知道吗?

static_cast 用于大多数的安全类型转换,编译时进行检查,常用于基本类型之间的转换、类层次结构中的指针或引用转换、void* 与其他指针类型的转换。

reinterpret_cast 用于低级别、不安全的类型转换。仅重新解释位模式,不进行任何检查。常用于指针与不相关类型之间的转换、指针与整数类型之间的转换,将一个类型的指针转换为完全不相关的另一个类型的指针。

**13. 调试程序的方法 **

windows下直接使用vs的debug功能。linux下直接使用gdb,我们可以在其过程中给程序添加断点,监视等辅助手段,监控其行为是否与我们设计相符。

14. extern“C”作用

C++ 支持函数重载,因此编译器会对函数名进行改编(name mangling),以便区分重载函数。而 C 语言不支持函数重载,函数名不会被改编。extern "C" 告诉 C++ 编译器按 C 的方式处理指定的代码块,防止名称改编,从而确保 C++ 能够正确地调用 C 函数。extern "C" 使得 C++ 代码能够调用 C 函数,反之亦然。这在需要混合编程时非常有用,例如使用已有的 C 库时。

1
2
3
4
5
6
7
#ifdef __cplusplus
extern "C" {
#endif
void *memset(void *, int, size_t);
#ifdef __cplusplus
}
#endif

15. typdef和define区别

#define 是一个预处理命令,用于在预处理阶段执行简单的文本替换,不进行正确性检查。

typedef 则是在编译时处理的,用于在其作用域内为已经存在的类型创建一个别名。

  • 作用域控制typedef 受限于其作用域,而 #define 则作用于整个文件,这意味着 #define 的影响范围更广,可能会导致意想不到的冲突和错误。
  • 调试和错误定位:使用 typedef 的代码在编译时更容易进行类型检查和错误定位,而 #define 仅仅是文本替换,调试和错误排查会更加困难。
  • 代码可读性和维护性typedef 提升了代码的可读性和维护性,因为它明确指出了新类型的意图和使用场景,而 #define 只是简单的替换,容易导致代码难以理解。
1
2
3
4
5
6
7
typedef    (int*) pINT;
#define pINT2 int*
// 效果相同?实则不同!实践中见差别:
pINT a,b; // 的效果等同于
int *a; int *b; //表示定义了两个整型指针变量。而
pINT2 a,b; //的效果等同于
int *a, b; //表示定义了一个整型指针变量a和整型变量b。

16. volatile关键字在程序设计中有什么作用

volatile关键字在C和C++编程中用于解决变量在共享环境下可能出现的读取错误问题。它的含义是“易变的”或“不稳定的”。下面是一个简单的例子:

1
volatile int i = 10; 

使用volatile关键字声明的变量表示该变量可能会被一些编译器未知的因素(如操作系统、硬件或其他线程)更改。因此,volatile关键字告诉编译器不要对这种变量进行优化。

具体作用包括:

  • 避免编译器优化: volatile关键字声明的变量在每次访问时都必须从内存中取值,而不是从CPU寄存器中取值。这样可以确保获取到的是最新值,而不是被优化后的缓存值。
  • 适用于硬件寄存器和信号处理: 例如,只读的状态寄存器可以声明为const volatile
  • 指针修饰: 指针本身也可以是volatile,表示指针指向的对象可能会被外部因素更改。

下面是几个实际应用场景:

  1. 多线程编程: 在多线程环境下,使用volatile可以确保一个线程修改的变量能够被其他线程及时看到。
  2. 硬件编程: 在嵌入式系统中,经常需要直接操作硬件寄存器,声明这些寄存器为volatile可以防止编译器对其进行优化,从而确保每次读取的都是最新值。

补充:虽然volatile关键字在某些情况下非常有用,但它不能替代锁机制和内存屏障。对于复杂的并发控制,仍然需要使用专门的同步机制。

参考资料:C/C++ 中 volatile 关键字详解

17. 引用作为函数参数以及返回值的好处

对比值传递,引用传参具有以下优势:

  1. 可修改性:在函数内部可以对引用参数进行修改,直接影响到原始变量。
  2. 效率提升:避免了传值和生成副本的时间和空间开销,提高了函数调用和运行的效率。

对于值传递,函数参数实际上是形参,形参的作用域仅限于函数体内部。实参和形参是两个不同的实体,函数调用时通过“形参=实参”实现值的传递,生成实参的副本。即使在函数内部修改了参数,这些修改也仅限于形参的副本,实参不会受到影响。函数结束后,形参的生命周期也随之结束,所有修改也不会影响任何外部变量。

使用引用作为返回值的主要好处是避免在内存中生成返回值的副本。但需要注意不能返回局部变量的引用,因为函数返回后局部变量会被销毁,导致引用指向无效内存。

18. C语言的函数调用过程

在 C++ 程序运行时,函数调用的过程涉及到多个步骤,包括参数压栈、函数跳转、执行函数代码、以及函数返回。这些步骤保证了程序能够正确地从调用者(caller)跳转到被调用者(callee),执行完毕后再返回调用者。以下是详细的函数调用过程:

1. 参数压栈和返回地址压栈

当调用一个函数时,首先需要将函数的参数按照调用约定依次压入栈中,通常情况下是从右向左依次压入栈中。接下来,将函数调用的返回地址压入栈,以便函数执行完毕后可以返回到正确的位置。

2. 函数跳转

参数和返回地址压栈完毕后,使用 call 指令跳转到被调用函数的入口地址。

3. 设置栈帧

在被调用函数开始执行时,首先保存当前的帧指针(EBP),然后将栈指针(ESP)的值复制到EBP寄存器。这一步骤创建了一个新的栈帧,用于管理当前函数的局部变量和参数。接下来,调整ESP以给局部变量分配空间。

4. 执行函数代码

函数的主要逻辑在这一步执行。此时,函数可以访问传递进来的参数,并在栈帧中存储局部变量。在汇编代码中,这通常通过相对于EBP的偏移量来访问。

5. 恢复栈帧和返回

函数执行完毕后,需要恢复调用者的执行环境。首先,将函数的返回值存储在寄存器(如EAX)中。接着,恢复之前保存的寄存器值,包括帧指针(EBP)。然后,通过 ret 指令弹出返回地址,并跳转回调用者。

6. 清理栈

根据调用约定(calling convention),调用者或被调用者负责清理栈上的参数。这一步骤确保函数调用完成后,栈恢复到调用前的状态。

示例代码和过程

以下是一个简单的 C++ 函数调用示例及其过程分析:

1
2
3
4
5
6
7
8
9
#include <iostream>
void func(int a, int b) {
int sum = a + b;
std::cout << "Sum: " << sum << std::endl;
}
int main() {
func(5, 10);
return 0;
}

过程分析

  1. 参数压栈:调用 func(5, 10) 时,参数 105 从右到左压入栈中。
  2. 返回地址压栈:将 main 函数调用 func 后的下一条指令地址压入栈中。
  3. **跳转到 func**:call func 跳转到 func 函数的入口。
  4. 设置栈帧
    • 压入当前 EBP:push ebp
    • 更新 EBP:mov ebp, esp
    • 调整 ESP:sub esp, <local_vars_size>
  5. 执行函数代码
    • 访问参数 ab:通过 [ebp + 8][ebp + 12]
    • 计算和输出 sum
  6. 恢复栈帧
    • 恢复 EBP:mov esp, ebppop ebp
  7. **返回到 main**:通过 ret 指令,弹出返回地址并跳转。

19. 请简单介绍下,栈帧是什么? EBP和ESP是什么?调用约定是什么?如何设置调用约定?

栈帧(Stack Frame)

栈帧是函数调用期间在栈上分配的一块内存区域,用于存储函数的局部变量、传递给函数的参数、函数的返回地址和调用者的上下文信息。每当一个函数被调用时,会在栈上创建一个新的栈帧,当函数执行完毕后,栈帧会被销毁。栈帧帮助维护函数调用的有序性和作用域分离。

EBP 和 ESP

  • **EBP (Extended Base Pointer)**:EBP 是帧指针寄存器,用于指向当前栈帧的基址。函数调用期间,EBP 的值通常保存调用者的 EBP 值,这样函数可以通过 EBP 访问其参数和局部变量。进入一个新函数时,通常会执行 push ebp 保存当前 EBP 值,并执行 mov ebp, esp 更新 EBP 指向新的栈帧基址。
  • **ESP (Extended Stack Pointer)**:ESP 是栈指针寄存器,指向当前栈的栈顶。ESP 的值随着数据的入栈(push)和出栈(pop)操作而改变。在函数调用过程中,ESP 用于分配和释放栈上的空间。

调用约定(Calling Convention)

调用约定定义了函数如何传递参数、返回值,以及如何进行栈帧管理等规则。它规定了:

  • 参数传递的顺序和方式(通过寄存器或栈)
  • 函数返回值的传递方式
  • 谁负责清理栈(调用者或被调用者)
  • 使用哪些寄存器保存临时值,哪些寄存器在函数调用前后需要保持不变

常见的调用约定

  1. cdecl(C Declaration)
    • 参数从右到左压栈
    • 调用者负责清理栈
    • 函数返回值通过 EAX 返回
    • 常见于 C/C++ 默认调用约定
  2. stdcall(Standard Call)
    • 参数从右到左压栈
    • 被调用者负责清理栈
    • 函数返回值通过 EAX 返回
    • 常见于 WinAPI 函数调用
  3. fastcall
    • 部分参数通过寄存器传递(如 ECX 和 EDX)
    • 剩余参数从右到左压栈
    • 被调用者负责清理栈
    • 常见于性能优化场景

设置调用约定

在不同的编译器中,可以通过函数声明的修饰符来指定调用约定。例如,在 Microsoft Visual C++ 中,可以使用 __cdecl__stdcall__fastcall 修饰符:

1
2
3
void __cdecl cdeclFunction(int a, int b);
void __stdcall stdcallFunction(int a, int b);
void __fastcall fastcallFunction(int a, int b);

在gcc和clang中使用__attribute__(())指定不同的调用约定:

1
2
3
4
5
6
7
8
void cdeclFunction(int a)  // 默认支持cdecl, 一般不需要显式指定
void __attribute__((stdcall)) my_stdcall_function(int a);
void __attribute__((fastcall)) my_fastcall_function(int a) ;
void __attribute__((regparm(3))) my_regparm_function(int a, int b, int c); //regparm 是 gcc 特有的一种调用约定,用于指定使用寄存器传递参数。
void __attribute__((naked)) my_naked_function() { // naked 属性允许定义不进行函数前后堆栈调整的函数。这对于嵌入式编程和编写低级别系统代码非常有用。
// Custom assembly code can go here
__asm__("nop"); // Example no-operation instruction
}

注意事项

  • 兼容性:不同的调用约定可能在不同平台和编译器版本中有不同的支持情况,使用时需要确认特定版本的编译器文档。
  • 混合使用:在跨平台和跨编译器项目中使用不同调用约定时,务必小心,以避免因调用约定不匹配导致的运行时错误。

可以参考下

20. C++中的基本数据类型及派生类型

  1. **整型 (int)**:用于存储整数。它是最常用的数据类型之一。
  2. 浮点型:包括单精度(float)和双精度(double),用于存储带小数点的数值。单精度(float):通常占用32位内存空间(4字节),提供大约7位十进制的精度。这意味着它能够精确表示的小数位数大约是7位。双精度(double):通常占用64位内存空间(8字节),提供大约15-16位十进制的精度。因此,double类型比float类型能提供更高的数值精度。
  3. **字符型 (char)**:用于存储单个字符,例如字母或数字。
  4. **逻辑型 (bool)**:只能取两个值:truefalse,用于逻辑判断。
  5. **空类型 (void)**:通常用于指定没有返回值的函数或无类型指针。

基本类型的功能可以通过添加类型修饰符来扩展或特定化。派生类型是通过在基本类型(如charintfloatdouble)前添加类型修饰符来形成的。这些修饰符包括:

  • **short**:短整型,用于创建占用内存更小的整数。short通常会缩小一半,变成2字节整形;
  • **long**:长整型,可用于存储较大的整数。根据系统位数不同,long可能不改变内存大小,可能会扩大一倍,变成8字节整形。
  • **signed**:有符号类型,允许存储负值和正值。
  • **unsigned**:无符号类型,只存储正值,增加了正数的最大可能范围。

21. 求下面函数的返回值(二进制中1的个数 –from 微软)

1
2
3
4
5
6
7
8
9
10
int func(x) 
{
int countx = 0;
while(x)
{
countx ++;
x = x&(x-1);
}
return countx;
}

假定x = 9999。答案:8

思路:将x转化为2进制,看含有的1的个数。如果数字范围非常大,可以考虑并行计算,将一个数字分成多个部分,并行地去计算汉明重量。

22. C++是不是类型安全的?

答案:不是。两个不同类型的指针之间可以强制转换(用reinterpret cast)。C#是类型安全的。

同时C++提供了一些类型安全的机制:

  1. static_castdynamic_castconst_cast 都提供了类型转换功能,但它们相对更安全。特别是 dynamic_cast 在使用 RTTI(运行时类型识别)时,可以保证在多态类型间的转换是安全的。如果转换失败,dynamic_cast 会返回 nullptr,而不会引发未定义行为。
  2. C++ 还通过 const 修饰符和模板机制提供了一些类型安全的编程方式。例如,使用 const 可以防止意外修改变量,而模板可以确保编译时的类型检查。
  3. C++ 标准库中的一些新特性,比如 std::variantstd::any,提供了一些额外的类型安全性。这些工具允许在编译时或运行时安全地管理类型不同的数据,而不会导致类型不安全的问题。

23. main 函数执行以前,还会执行什么代码?

在C++程序中,以下代码会在 main 函数之前执行:

  1. 全局对象和静态对象的构造函数

    • 在编译单元(通常是一个 .cpp 文件)中定义的全局对象和静态对象的构造函数会在 main 函数之前执行。这是因为这些对象需要在程序开始执行时就已经初始化好。
    1
    2
    3
    4
    5
    6
    7
    8
    class GlobalObject {
    public:
    GlobalObject() {
    std::cout << "GlobalObject constructed!" << std::endl;
    }
    };

    GlobalObject globalObj; // 全局对象的构造函数会在main之前执行
  2. __attribute__((constructor)) 修饰的函数

    • __attribute__((constructor)) 是GNU扩展(适用于GCC和Clang等编译器),它告诉编译器在 main 函数之前执行标记的函数。这个机制通常用于在程序初始化时执行一些必须的设置或初始化代码。
    1
    2
    3
    __attribute__((constructor)) void before() {
    std::cout << "This function runs before main!" << std::endl;
    }
    • 原因:C++运行时库(runtime library)会在 main 函数之前执行一系列初始化步骤。这些步骤包括初始化全局和静态对象,执行使用 __attribute__((constructor)) 修饰的函数,以及可能的动态链接库(DLL/共享库)的初始化。通过使用这种属性,开发者可以确保某些初始化逻辑在任何其他代码执行之前运行。

24. inline 内联函数

特征

  • 相当于把内联函数里面的内容写在调用内联函数处;
  • 相当于不用执行进入函数的步骤,直接执行函数体;
  • 相当于宏,却比宏多了类型检查,真正具有函数特性;
  • 编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数;
  • 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数。

使用

inline 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 声明加 inline,建议使用
inline int functionName(int first, int second,...);

// 声明不加 inline
int functionName(int first, int second,...);
// 定义加inline
inline int functionName(int first, int second,...) {/****/};

// 类内定义,隐式内联
class A {
int doA() { return 0; } // 隐式内联
}

// 类外定义,需要显式内联
class A {
int doA();
}
inline int A::doA() { return 0; } // 需要显式内联

编译器对 inline 函数的处理步骤

  1. 将 inline 函数体复制到 inline 函数调用点处;
  2. 为所用 inline 函数中的局部变量分配内存空间;
  3. 将 inline 函数的的输入参数和返回值映射到调用方法的局部变量空间中;
  4. 如果 inline 函数有多个返回点,将其转变为 inline 函数代码块末尾的分支(使用 GOTO)。

优缺点

优点

  1. 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
  2. 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
  3. 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
  4. 内联函数在运行时可调试,而宏定义不可以。

缺点

  1. 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
  2. inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。
  3. 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。

虚函数(virtual)可以是内联函数(inline)吗?

Are “inline virtual” member functions ever actually “inlined”?

  • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
  • 内联是在编译器建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
  • inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。
  • GCC可以通过-fopt-info选项生成优化报告文件,g++ -O2 -fopt_info xxx.cpp

虚函数内联使用

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
#include <iostream>  
using namespace std;
class Base
{
public:
inline virtual void who()
{
cout << "I am Base\n";
}
virtual ~Base() {}
};
class Derived : public Base
{
public:
inline void who() // 不写inline时隐式内联
{
cout << "I am Derived\n";
}
};

int main()
{
// 此处的虚函数 who(),是通过类(Base)的具体对象(b)来调用的,编译期间就能确定了,所以它可以是内联的,但最终是否内联取决于编译器。
Base b;
b.who();

// 此处的虚函数是通过指针调用的,呈现多态性,需要在运行时期间才能确定,所以不能为内联。
Base *ptr = new Derived();
ptr->who();

// 因为Base有虚析构函数(virtual ~Base() {}),所以 delete 时,会先调用派生类(Derived)析构函数,再调用基类(Base)析构函数,防止内存泄漏。
delete ptr;
ptr = nullptr;

system("pause");
return 0;
}

25. sizeof()

  • 使用 sizeof 对数组操作时,会得到整个数组所占的内存空间大小,而不是数组中元素的数量。例如,若数组定义为 int arr[10];,则 sizeof(arr) 的结果是 10 * sizeof(int),即数组的总大小。

  • 对指针使用 sizeof 操作时,得到的是指针本身所占的内存大小,并不是指针所指向的内存大小。例如,在32位系统中,任何类型的指针的 sizeof 值通常是 4 字节,在64位系统中则通常是 8 字节。

sizeof 与 strlen 的区别

概念和用途

  • sizeof 是一个编译时的操作符,用于计算一个变量或类型在内存中所占的字节数。它可以作用于任何数据类型,包括基本类型、数组、结构体等。
  • strlen 是一个运行时的库函数,用于计算以 ‘\0’ 结尾的字符串的长度(不包括 ‘\0’)。该函数的参数必须是字符指针,且指向一个合法的、以 ‘\0’ 结尾的字符串。

编译时与运行时

  • sizeof 的计算是在编译时完成的,不需要程序运行。因此,sizeof 的结果是确定的。
  • strlen 必须在程序运行时执行,因为它需要遍历字符串来计算长度。

参数差异

  • sizeof 可以接受类型名(如 sizeof(int))或变量(如 sizeof(x))作为参数。
  • strlen 的参数必须是字符指针,且指向的字符串必须以 ‘\0’ 结尾。

数组与指针

  • sizeof 用于数组时,它计算的是整个数组的大小。例如,sizeof(arr) 其中 arr 是一个数组,结果是整个数组的大小。
  • 当数组传递给 strlen 时,数组会退化为指向其第一个元素的指针,因此 strlen(arr) 将计算从数组第一个元素开始到第一个 ‘\0’ 字符的字符数。

26. struct 和 typedef struct

在 C 语言中

在 C 语言中,struct 定义结构体类型,而 typedef 用于为类型定义新名字。如下例所示:

1
2
3
4
// C
typedef struct Student {
int age;
} S;

这段代码定义了一个 struct Student 结构体,并使用 typedef 给它创建了一个新名字 S。这样,我们可以直接使用 S 作为类型名来定义变量,而无需每次都使用 struct Student

这种写法等价于:

1
2
3
4
5
6
// C
struct Student {
int age;
};

typedef struct Student S;

在这里,struct Student 定义了结构体类型,随后 typedef struct Student S; 为这个结构体类型定义了一个新名字 S

另外,C 语言支持在同一个作用域内,使用 Student 作为函数名,这不会与 struct Student 冲突,因为它们属于不同的标识符名称空间:

1
2
// C
void Student() {} // 定义一个名为 Student 的函数

在 C++ 中

C++ 对标识符的处理比 C 语言更灵活,它不需要显式地使用 struct 关键字来定义结构体变量:

1
2
3
4
5
6
// C++
struct Student {
int age;
};

void f(Student me); // 正确,可以省略 "struct" 关键字

在 C++ 中,如果已经定义了结构体 Student,那么在声明变量时可以直接使用 Student,而不必非要使用 struct Student

然而,如果在结构体定义之后,定义了一个同名的函数,则这个名称仅代表该函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// C++
typedef struct Student {
int age;
} S;

void Student() {} // 定义一个函数,此后 "Student" 只代表此函数

// void S() {} // 错误,"S" 已经作为 "struct Student" 的别名

int main() {
Student(); // 调用 Student 函数
struct Student me; // 需要使用 "struct Student" 或 "S" 来定义结构体变量
return 0;
}

在这种情况下,为避免混淆,使用 struct Student 或别名 S 来定义变量会更明确。这展示了 C++ 中类型和函数共享同一名称空间时可能引发的混淆问题。

27. C++ 中 struct 和 class

总的来说,struct 更适合看成是一个数据结构的实现体,class 更适合看成是一个对象的实现体。

区别

  • 最本质的一个区别就是默认的访问控制
    1. 默认的继承访问权限。struct 是 public 的,class 是 private 的。
    2. struct 作为数据结构的实现体,它默认的数据访问控制是 public 的,而 class 作为对象的实现体,它默认的成员变量访问控制是 private 的。

28. union 联合

联合(union)是一种节省空间的特殊的类,一个 union 可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当某个成员被赋值后其他成员变为未定义状态。联合有如下特点

  • 默认访问控制符为 public
  • 可以含有构造函数、析构函数
  • 不能含有引用类型的成员
  • 不能继承自其他类,不能作为基类
  • 不能含有虚函数
  • 匿名 union 在定义所在作用域可直接访问 union 成员
  • 匿名 union 不能包含 protected 成员或 private 成员
  • 全局匿名联合必须是静态(static)的
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
#include <iostream>
#include <cstring>

using namespace std;

union Data {
int i;
float f;
char str[20];
};

int main() {
Data data;

// 存储整数
data.i = 10;
std::cout << "Data.i = " << data.i << std::endl;

// 存储浮点数,这会覆盖整数值
data.f = 220.5;
std::cout << "Data.f = " << data.f << std::endl;

// 存储字符串,这会覆盖浮点数值
strncpy(data.str, "Hello World", sizeof(data.str));
std::cout << "Data.str = " << data.str << std::endl;

// 试图访问之前存储的整数或浮点数将得到未定义的结果
std::cout << "Data.i (undefined) = " << data.i << std::endl;
std::cout << "Data.f (undefined) = " << data.f << std::endl;

return 0;
}

29 .explicit(显式)关键字

  • explicit 修饰构造函数时,可以防止隐式转换和复制初始化
  • explicit 修饰转换函数时,可以防止隐式转换,但 按语境转换 除外

explicit 使用

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
struct A
{
A(int) { }
operator bool() const { return true; }
};

struct B
{
explicit B(int) {}
explicit operator bool() const { return true; }
};

void doA(A a) {}

void doB(B b) {}

int main()
{
A a1(1); // OK:直接初始化
A a2 = 1; // OK:复制初始化
A a3{ 1 }; // OK:直接列表初始化
A a4 = { 1 }; // OK:复制列表初始化
A a5 = (A)1; // OK:允许 static_cast 的显式转换
doA(1); // OK:允许从 int 到 A 的隐式转换
if (a1); // OK:使用转换函数 A::operator bool() 的从 A 到 bool 的隐式转换
bool a6(a1); // OK:使用转换函数 A::operator bool() 的从 A 到 bool 的隐式转换
bool a7 = a1; // OK:使用转换函数 A::operator bool() 的从 A 到 bool 的隐式转换
bool a8 = static_cast<bool>(a1); // OK :static_cast 进行直接初始化

B b1(1); // OK:直接初始化
B b2 = 1; // 错误:被 explicit 修饰构造函数的对象不可以复制初始化
B b3{ 1 }; // OK:直接列表初始化
B b4 = { 1 }; // 错误:被 explicit 修饰构造函数的对象不可以复制列表初始化
B b5 = (B)1; // OK:允许 static_cast 的显式转换
doB(1); // 错误:被 explicit 修饰构造函数的对象不可以从 int 到 B 的隐式转换
if (b1); // OK:被 explicit 修饰转换函数 B::operator bool() 的对象可以从 B 到 bool 的按语境转换
bool b6(b1); // OK:被 explicit 修饰转换函数 B::operator bool() 的对象可以从 B 到 bool 的按语境转换
bool b7 = b1; // 错误:被 explicit 修饰转换函数 B::operator bool() 的对象不可以隐式转换
bool b8 = static_cast<bool>(b1); // OK:static_cast 进行直接初始化

return 0;
}

30. using

using 声明

一条 using 声明 语句一次只引入命名空间的一个成员。它使得我们可以清楚知道程序中所引用的到底是哪个名字。如:

1
using namespace_name::name;

构造函数的 using 声明

在 C++11 中,派生类能够重用其直接基类定义的构造函数。

1
2
3
4
5
class Derived : Base {
public:
using Base::Base;
/* ... */
};

如上 using 声明,对于基类的每个构造函数,编译器都生成一个与之对应(形参列表完全相同)的派生类构造函数。生成如下类型构造函数:

1
Derived(parms) : Base(args) { }

using 指示

using 指示 使得某个特定命名空间中所有名字都可见,这样我们就无需再为它们添加任何前缀限定符了。如:

1
using namespace_name name;

尽量少使用 using 指示 污染命名空间

一般说来,使用 using 命令比使用 using 编译命令更安全,这是由于它只导入了指定的名称。如果该名称与局部名称发生冲突,编译器将发出指示。using编译命令导入所有的名称,包括可能并不需要的名称。如果与局部名称发生冲突,则局部名称将覆盖名称空间版本,而编译器并不会发出警告。另外,名称空间的开放性意味着名称空间的名称可能分散在多个地方,这使得难以准确知道添加了哪些名称。

using 使用

尽量少使用 using 指示

1
using namespace std;

应该多使用 using 声明

1
2
3
int x;
std::cin >> x ;
std::cout << x << std::endl;

或者

1
2
3
4
5
6
using std::cin;
using std::cout;
using std::endl;
int x;
cin >> x;
cout << x << endl;

31. :: 范围解析运算符

分类

  1. 全局作用域符(::name):用于类型名称(类、类成员、成员函数、变量等)前,表示作用域为全局命名空间
  2. 类作用域符(class::name):用于表示指定类型的作用域范围是具体某个类的
  3. 命名空间作用域符(namespace::name):用于表示指定类型的作用域范围是具体某个命名空间的

:: 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int count = 11;         // 全局(::)的 count

class A {
public:
static int count; // 类 A 的 count(A::count)
};
int A::count = 21;

void fun()
{
int count = 31; // 初始化局部的 count 为 31
count = 32; // 设置局部的 count 的值为 32
}

int main() {
::count = 12; // 测试 1:设置全局的 count 的值为 12

A::count = 22; // 测试 2:设置类 A 的 count 为 22

fun(); // 测试 3

return 0;
}

32. decltype

decltype 关键字用于检查实体的声明类型或表达式的类型及值分类。语法:

1
decltype ( expression )

decltype 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 尾置返回允许我们在参数列表之后声明返回类型
template <typename It>
auto fcn(It beg, It end) -> decltype(*beg)
{
// 处理序列
return *beg; // 返回序列中一个元素的引用
}
// 为了使用模板参数成员,必须用 typename
template <typename It>
auto fcn2(It beg, It end) -> typename remove_reference<decltype(*beg)>::type
{
// 处理序列
return *beg; // 返回序列中一个元素的拷贝
}

33. 引用

左值引用

常规引用,一般表示对象的身份。

右值引用

右值引用就是必须绑定到右值(一个临时对象、将要销毁的对象)的引用,一般表示对象的值。

右值引用可实现转移语义(Move Sementics)和精确传递(Perfect Forwarding),它的主要目的有两个方面:

  • 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。
  • 能够更简洁明确地定义泛型函数。

引用折叠

  • X& &X& &&X&& & 可折叠成 X&
  • X&& && 可折叠成 X&&

34. 宏

  • 宏定义可以实现类似于函数的功能,但是它终归不是函数,而宏定义中括弧中的“参数”也不是真的参数,在宏展开的时候对 “参数” 进行的是一对一的替换。

35. 智能指针

C++ 标准库(STL)中

头文件:#include <memory>

C++ 98

1
std::auto_ptr<std::string> ps (new std::string(str));

C++ 11

  1. shared_ptr
  2. unique_ptr
  3. weak_ptr
  4. auto_ptr(被 C++11 弃用)
  • Class shared_ptr 实现共享式拥有(shared ownership)概念。多个智能指针指向相同对象,该对象和其相关资源会在 “最后一个 reference 被销毁” 时被释放。为了在结构较复杂的情景中执行上述工作,标准库提供 weak_ptr、bad_weak_ptr 和 enable_shared_from_this 等辅助类。
  • Class unique_ptr 实现独占式拥有(exclusive ownership)或严格拥有(strict ownership)概念,保证同一时间内只有一个智能指针可以指向该对象。你可以移交拥有权。它对于避免内存泄漏(resource leak)——如 new 后忘记 delete ——特别有用。

shared_ptr

多个智能指针可以共享同一个对象,对象的最末一个拥有着有责任销毁对象,并清理与该对象相关的所有资源。

  • 支持定制型删除器(custom deleter),可防范 Cross-DLL 问题(对象在动态链接库(DLL)中被 new 创建,却在另一个 DLL 内被 delete 销毁)、自动解除互斥锁

weak_ptr

weak_ptr 允许你共享但不拥有某对象,一旦最末一个拥有该对象的智能指针失去了所有权,任何 weak_ptr 都会自动成空(empty)。因此,在 default 和 copy 构造函数之外,weak_ptr 只提供 “接受一个 shared_ptr” 的构造函数。

  • 可打破环状引用(cycles of references,两个其实已经没有被使用的对象彼此互指,使之看似还在 “被使用” 的状态)的问题

unique_ptr

unique_ptr 是 C++11 才开始提供的类型,是一种在异常时可以帮助避免资源泄漏的智能指针。采用独占式拥有,意味着可以确保一个对象和其相应的资源同一时间只被一个 pointer 拥有。一旦拥有着被销毁或编程 empty,或开始拥有另一个对象,先前拥有的那个对象就会被销毁,其任何相应资源亦会被释放。

  • unique_ptr 用于取代 auto_ptr

auto_ptr

被 c++11 弃用,原因是缺乏语言特性如 “针对构造和赋值” 的 std::move 语义,以及其他瑕疵。

auto_ptr 与 unique_ptr 比较

  • auto_ptr 可以赋值拷贝,复制拷贝后所有权转移;unqiue_ptr 无拷贝赋值语义,但实现了move 语义;
  • auto_ptr 对象不能管理数组(析构调用 delete),unique_ptr 可以管理数组(析构调用 delete[] );

36. unique_ptr是如何实现独占式指针?

由于指针或引用在离开作用域是不会调用析构函数的,但对象在离开作用域会调用析构函数。unique_ptr本质是一个类,将复制构造函数赋值构造函数声明为delete就可以实现独占式,只允许移动构造和移动赋值。

具体的实现可以参考类-unique_ptr实现原理

37. shared_ptr是如何实现共享式指针?

shared_ptr通过引用计数,使得多个shared_ptr对象共享一份资源。

如果对象被引用,则计数加1,如果对象被销毁,则计数减1。如果计数为0,表示对象没有被销毁,可以释放该资源。shared_ptr的缺点是存在循环引用的问题。

实现可以参考:面试题:简单实现一个shared_ptr智能指针 - 腾讯云开发者社区

38. 什么是shared_ptr的循环引用问题,如何解决?

img

循环引用示意图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct ListNode
{
int _data;
shared_ptr<ListNode> ptr;
ListNode(int data):_data(data){}
~ListNode(){ cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr<ListNode> node1(new ListNode(1));
shared_ptr<ListNode> node2(new ListNode(2));
cout << node1.use_count() << endl; // 1
cout << node2.use_count() << endl; // 1
node1->ptr = node2;
node2->ptr = node1;
cout << node1.use_count() << endl; // 2
cout << node2.use_count() << endl; // 2
return 0;
}

一个最简单的情况是,某对象存在一个shared_ptr类型的指针ptr,A的ptr指向B,B的ptr指向A。两个智能指针对象指向A,B,再加上他们的ptr分别指向B,A,所以引用计数均为2,造成了循环引用,谁也不会被释放。一般有三种解决方法:

  1. 当剩下最后一个引用时,需要手动打破循环引用释放对象;
  2. 当A的生存周期超过B的生存周期,B改为一个普通指针指向A;
  3. 将共享指针改为弱指针weak_ptr

一般采用第三者办法,原理是弱指针的指针_prev和_next不会增加node1和node2的引用计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ListNode
{
int _data;
weak_ptr<ListNode> ptr;
ListNode(int data):_data(data){}
~ListNode(){ cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr<ListNode> node1(new ListNode());
shared_ptr<ListNode> node2(new ListNode);
cout << node1.use_count() << endl; // 1
cout << node2.use_count() << endl; // 1
node1->_next = node2;
node2->_prev = node1;
cout << node1.use_count() << endl; // 1
cout << node2.use_count() << endl; // 1
// ~ListNode()
return 0;
}

39. 数组与指针的区别?指针数组和数组指针?

数组存放一组元素,而指针指向某一个对象。从底层实现上看,数组也是由base指针和各维度长度等组成,数组元素存放在连续地址上。

指针数组是保存指针的数组,比如int* a[10],而数组指针是 指向数组的指针,比如:

1
2
3
int var[10];
int *ptr = var;
int *ptr = &var[0];//与上面等价

在C++中,数组名代表数组中第一个元素(即序号为0的元素)的地址。如果是二维数组,则可以通过*(*(arr+i)+j)来访问arr[i][j]

40.你知道函数指针吗?讲一讲。

函数指针是指指向函数的指针,在早期C的项目经常能看到。这里是指向函数的入口地址。作用是调用函数作为入口参数,比如回调函数:

1
2
3
4
5
6
7
int foo(){return -1;}

int (*ptrfoo) () = foo;
//不要写成foo()

//作为回调函数
void func(int (*foo)());

有入口参数的情况;

1
2
3
4
int foo(int x);
void func(int (*foo)(int)){

}

41. 什么是注册函数?什么是回调函数?

回调函数无非是对函数指针的应用,用函数指针来调用一个函数,而注册函数则是将函数指针作为参数传进去,便于其它函数调用。

42. 讲一讲int *p[n] 和int (*p)[n]以及int *p()和int (*p)() 的区别。
首先int *p[n] 表示p为指针数组。因为[]的优先级大于*,所以应该理解为 int *(p[n])。

int (*p)[n] 表示p为二维数组指针。int (*p)[10]表示行指针,指向一行有10个元素的指针,其类型与二维数组名相同。如,可以这样使用。

int a[2][10];
int (*p)[10]=a; //p指向数组a的首行。
int *p()表示p为函数,返回值类型为int*;

int (*p)()表示p为函数指针,函数原型int func()。注意函数指针不能++或–。

43. C++是怎么定义常量的?

C++有两个关键字const和constexpr(C++11)可以定义常量,常量必须被初始化。

对于局部常量,通常位于栈区,而对于全局常量,编译器一般不分配内存,放在符号表以提高效率。字面量一般位于常量区。

44. const和constexpr有什么区别?

传统const的问题在于“双重语义”,既有“只读”的含义,又有“常量”(不可改变)的含义,而constexpr严格定义了常量

只读一定不可改变吗?这还真不一定!

1
2
3
4
5
6
7
8
int main()
{
int a = 10;
const int & con_b = a;
cout << con_b << endl; // 10
a = 20;
cout << con_b << endl; // 20
}

可以看到,程序中用 const 修饰了 con_b 变量,表示该变量“只读”,即无法通过变量自身去修改自己的值。但这并不意味着 con_b 的值不能借助其它变量间接改变,通过改变 a 的值就可以使 con_b 的值发生变化。

参考资料:C++11 constexpr和const的区别详解

45. const放在类型/函数前和后有区别吗?

1
2
3
4
5
int b = 1;
const int *a = &b;
int const *a = &b;
int* const a = &b;
const int* const a = &b;

C++规定const在类前和类后是一样的。并且按照“从右向左读”进行理解。2,3行相同。

2/3:一个int*型指向常量的指针;该指针可以指向其它的变量但无法修改它们的值。

4:一个常量的指向int*型的指针;它无法指向别的地址。

5:既不能指向其它变量的地址,也不能修改值。

对于函数而言

1
2
3
const int func(){};
int const func(){};
void func() const{};

1和2作用相同,表示函数返回const int类型;

3通常是在类中,表示该函数不修改成员变量。

1
2
3
4
5
6
7
8
class A{
int a;
const int b;
public:
void test(int x) const{
this->a = 1;//报错,表达式必须是可修改的左值
};
};

对类而言

1
2
3
4
5
6
7
8
9
class A{
public:
void test1() const;
void test2();
};

const A classA;
classA.test1();//正确
classA.test2();//错误,对象含有与成员 函数 "A::test2" 不兼容的类型限定符 -- 对象类型是: const A

该变量只能调用const成员函数。

46. C++如何处理函数返回值?

生成一个临时变量,将它的引用作为函数输入参数。

47. 如何在C++引用C头文件?

采用extern关键字。如果定义了宏__cpluscplus就表示使用了C++的编译器

1
2
3
4
5
6
7
8
9
#ifdef _cplusplus
extern "C"{
#endif _cplusplus
//头文件内容
...

#ifdef _cplusplus
}
#endif _cplusplus

48. 形参和实参有什么不同.

形参,是定义函数时的参数,比如void func(int x)这里的x就是形参。

实参,调用函数实际填入的参数,比如func(1)。

49. 以下四行代码中”123”是否可以修改?

1
2
3
4
const char* a = "123";
char *b = "123";
const char c[] = "123";
char d[] = "123";

第1,2行,”123”位于常量区,加不加const效果一样,都无法修改。而第三行,”123”本来在栈上,但是由于const关键字编译器可能将其优化到常量区,第四行:“123”位于栈区。总结:只有第四行可以修改。

50. 什么叫左值引用,什么叫右值引用。

右值引用是C++11引入的,与之对应C++98中的引用统称为左引用。左引用的一个最大问题就是,它不能对不能取地址的量(比如字面量常量)取引用。比如int &a = 1;就不可以。

为此专门定义了左值和右值,能取地址的都是左值,反之是右值。通过右值引用,可以增长变量的生命周期,避免分配新的内存空间.

并用&&来表示右值引用,这样就可以int &&a = 1;并用&来表示左值引用。

总结:左值引用只能绑定左值;右值引用只能绑右值,但常量左值引用可以绑字面量,比如const int &b = 10;已命名的右值引用,编译器会认为是一个左值;临时对象是左值。

51. 什么是将亡值,什么是纯右值。

所谓纯右值就是临时变量或者字面值,将亡值是C++11新定义的将要被“移动”的变量,比如move返回的变量。

52. 移动语义与完美转发了解吗。

移动语义(move semantic):某对象持有的资源或内容转移给另一个对象。为了保证移动语义, 必须记得用std::move 转化左值对象为右值,以避免调用复制构造函数.

1
2
3
vector<int> a{1,2,3};
vector<int> b = std::move(a);//我们不希望为了b拷贝新的内存空间,采用移动语义C++
// a的元素变为{},b的元素变为{1,2,3}

完美转发(perfect forwarding): 为了解决引用折叠问题,必须写一个任意参数的函数模板,并转发到其他函数. 为了保证完美转发,必须使用std::forward, 我们希望左值转发之后还是左值,右值转发后还是右值.

53. 什么是引用折叠?forward函数的原理。

引用折叠就是,如果间接创建一个引用的引用,那么这些引用就会折叠。规则:

1
2
3
4
&& + &&->&& : 右值的右值引用是右值
&& + &->& : 右值的左值引用是左值
& + &&->& : 左值的右值引用是左值
& + &->& : 左值的左值引用是左值

为此引入了forward函数:

1
2
3
4
5
// 精简了标准库的代码,在细节上可能不完全正确,但是足以让我们了解转发函数 forward 的了
template<typename T>
T&& forward(T &param){
return static_cast<T&&>(param);
}

1.传入 forward 实参是右值类型: 根据以上的分析,可以知道T将被推导为值类型,也就是不带有引用属性,假设为 int 。那么,将T = int 带入forward。

1
2
3
int&& forward(int &param){
return static_cast<int&&>(param);
}

paramforward内被强制类型转换为 int &&,还是右值引用。最终保持了实参的右值属性,转发正确。

2.传入 forward实参是左值类型:

根据以上的分析,可以知道T将被推导为左值引用类型,假设为int&。那么,将T = int& 带入forward。

1
2
3
int& && forward(int& &param){
return static_cast<int& &&>(param);
}

引用折叠一下就是 int &类型,转发正确。

54. 什么是移动构造和移动赋值?

移动构造函数能直接使用临时对象已经申请的资源,它以右值引用为参数 ,拷贝以左值。

由于临时对象是右值,这里就需要使用一个move函数,它的作用的将左值强制转换为右值。

移动赋值是在赋值运算符重载的基础上,将对象右值引用作为形参进行拷贝或者赋值,从而避免创建新对象。

下面的例子展示了拷贝构造函数、赋值运算符重载、移动拷贝和移动赋值运算符重载,请仔细区别:

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
class A{
public:
//拷贝构造函数
A(A& a) : x(a.x)
{
cout << "Copy Constructor" << endl;
}
//赋值运算符
A& operator=(A& a)
{
x = a.x;
cout << "Copy Assignment operator" << endl;
return *this;
}
//移动拷贝
A(A&& a) : x(a.x)
{
cout << "Move Constructor" << endl;
}
//移动赋值
A& operator=(A&& a)
{
x = a.x;
cout << "Move Assignment operator" << endl;
return *this;
}
private:
int x;
}

55. 什么是仿函数?

仿函数(Functor)又称为函数对象(Function Object)是一个能行使函数功能的类。

仿函数的语法几乎和我们普通的函数调用一样,不过作为仿函数的类,都必须重载 operator() 运算符。因为调用仿函数,实际上就是通过类对象调用重载后的 operator() 运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class StringAppend {
public:
explicit StringAppend(const string& str) : ss(str){}
void operator() (const string& str) const {
cout << str << ' ' << ss << endl;
}
private:
const string ss;
};

int main() {
StringAppend myFunctor2("and world!");
myFunctor2("Hello");
}

56. 如何实现++i与i++?

重写int的++运算符;

1
2
3
4
5
6
7
8
9
10
11
//++i
int& int::operator++(){
*this = *this + 1;
return *this;
}
//i++;
const int int::operator++(int){
int old = *this;
*this = *this+1;
return old;
}

57. 写一个函数在main函数之前运行。

1
2
3
__attribute((constructor)) void before(){

}

如果是在之后运行呢?

1
2
3
__attribute((deconstructor)) void after(){

}

58. 两个几乎完全相同的函数,第二个函数仅仅多了const,问这种情况会报错吗?

不会,这相当于函数重载。

59. C++函数栈空间最大多少?如何调整?

和编译器和操作系统有关。VC++默认的栈空间是1M,有两个方法更改

a. link时用/STACK指定它的大小,或者在.def中使用STACKSIZE指定它的大小

b. 使用控制台命令“EDITBIN”更改exe的栈空间大小。 在linux系统可以使用ulimit -a命令修改。

60. 函数参数压栈顺序?

从右到左。

61. 下面的输出是多少?为什么?

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

int main()
{
int i = 5;
void* pInt = &i;
double d = (*(double*)pInt);
cout << d << endl;

return 0;
}

输出不是5,用到了空类型指针void*,类型不安全。

62. namespace有什么作用?

为了解决变量和函数等的作用范围,在C++语言中引入了名空间的概念,并增加了关键字namespace和using

在一个名空间中可以定义一组变量和函数,这些变量和函数的作用范围一致,可以将这些变量和函数称为这个名空间的成员。

通过命名空间,可以在同一个文件中使用相同的变量名或函数名,只要它们属于不同的名空间。另外,名空间可以使得代码操作具有相同名字但属于不同库的变量。而且,名空间也可以提高C语言与C++语言的兼容性。

63. enum 枚举类型

限定作用域的枚举类型

1
enum class open_modes { input, output, append };

不限定作用域的枚举类型

1
2
enum color { red, yellow, green };
enum { floatPrec = 6, doublePrec = 10 };

64. this 指针

  1. this 指针是一个隐含于每一个非静态成员函数中的特殊指针。它指向调用该成员函数的那个对象。

  2. 当对一个对象调用成员函数时,编译程序先将对象的地址赋给 this 指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用 this 指针。

  3. 当一个成员函数被调用时,自动向它传递一个隐含的参数,该参数是一个指向这个成员函数所在的对象的指针。

  4. this 指针被隐含地声明为: ClassName *const this,这意味着不能给 this 指针赋值;在 ClassName 类的 const 成员函数中,this 指针的类型为:const ClassName* const,这说明不能对 this 指针所指向的这种对象是不可修改的(即不能对这种对象的数据成员进行赋值操作);

  5. this 并不是一个常规变量,而是个右值,所以不能取得 this 的地址(不能 &this)。

  6. 在以下场景中,经常需要显式引用this指针:

    1. 为实现对象的链式引用;
    2. 为避免对同一对象进行赋值操作;
    3. 在实现一些数据结构时,如 list

65. #pragma pack(n)

设定结构体、联合以及类成员变量以 n 字节方式对齐

#pragma pack(n) 使用

1
2
3
4
5
6
7
8
9
10
11
#pragma pack(push)  // 保存对齐状态
#pragma pack(4) // 设定为 4 字节对齐

struct test
{
char m1;
double m4;
int m3;
};

#pragma pack(pop) // 恢复对齐状态

66. 位域

1
Bit mode: 2;    // mode 占 2 位

类可以将其(非静态)数据成员定义为位域(bit-field),在一个位域中含有一定数量的二进制位。当一个程序需要向其他程序或硬件设备传递二进制数据时,通常会用到位域。

  • 位域在内存中的布局是与机器有关的
  • 位域的类型必须是整型或枚举类型,带符号类型中的位域的行为将因具体实现而定
  • 取地址运算符(&)不能作用于位域,任何指针都无法指向类的位域

67. 运行时类型信息 (RTTI)

dynamic_cast

  • 用于多态类型的转换

typeid

  • typeid 运算符允许在运行时确定对象的类型
  • type_id 返回一个 type_info 对象的引用
  • 如果想通过基类的指针获得派生类的数据类型,基类必须带有虚函数
  • 只能获取对象的实际类型

type_info

  • type_info 类描述编译器在程序中生成的类型信息。 此类的对象可以有效存储指向类型的名称的指针。 type_info 类还可存储适合比较两个类型是否相等或比较其排列顺序的编码值。 类型的编码规则和排列顺序是未指定的,并且可能因程序而异。
  • 头文件:typeinfo

typeid、type_info 使用

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
#include <iostream>
using namespace std;

class Flyable // 能飞的
{
public:
virtual void takeoff() = 0; // 起飞
virtual void land() = 0; // 降落
};
class Bird : public Flyable // 鸟
{
public:
void foraging() {...} // 觅食
virtual void takeoff() {...}
virtual void land() {...}
virtual ~Bird(){}
};
class Plane : public Flyable // 飞机
{
public:
void carry() {...} // 运输
virtual void takeoff() {...}
virtual void land() {...}
};

class type_info
{
public:
const char* name() const;
bool operator == (const type_info & rhs) const;
bool operator != (const type_info & rhs) const;
int before(const type_info & rhs) const;
virtual ~type_info();
private:
...
};

void doSomething(Flyable *obj) // 做些事情
{
obj->takeoff();

cout << typeid(*obj).name() << endl; // 输出传入对象类型("class Bird" or "class Plane")

if(typeid(*obj) == typeid(Bird)) // 判断对象类型
{
Bird *bird = dynamic_cast<Bird *>(obj); // 对象转化
bird->foraging();
}

obj->land();
}

int main(){
Bird *b = new Bird();
doSomething(b);
delete b;
b = nullptr;
return 0;
}

68. nullptr和NULL区别

nullptr 是一个指针,而 NULL 是一个宏,它表示空指针常量。从 C++11 开始,nullptr 是用来替换 NULL 的,因为它更严格,更易于处理。对于指针类型,nullptr 会被推导为 null pointer,而 NULL 通常会被推导为整型,这会导致一些问题。例如,下面的代码会出错:
void someFunction(int x);
someFunction(NULL); // 错误,NULL 会被推导为整型
因为 someFunction 的参数是一个整型,而 NULL 被推导为整型,所以编译器不会报错。但是这并不是我们想要的,因为我们实际上想传入一个 null pointer。使用 nullptr 可以避免这个问题:
someFunction(nullptr); // 正确,nullptr 会被推导为 null pointer
总之,nullptr 是更好的选择,因为它更严格,更容易阅读,也更容易被编译器正确理解。

69. 某文件中定义的静态全局变量(或称静态外部变量)其作用域是?

本文件,静态全局变量限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其他源文件中不能使用它。由于静态全局变量的作用域局限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。

70. 系统会自动打开和关闭的3个标准的文件是?

(1) 标准输入—-键盘—stdin
(2) 标准输出—-显示器—stdout
(3) 标准出错输出—-显示器—stderr

71. 说明define和const在语法和含义上有什么不同?

(1) #define是C语法中定义符号变量的方法,符号常量只是用来表达一个值,在编译阶段符号就被值替换了,它没有类型;
(2) Const是C++语法中定义常变量的方法,常变量具有变量特性,它具有类型,内存中存在以它命名的存储单元,可以用sizeof测出长度。

72. 处理器标识#error的目的是什么?

编译时输出一条错误信息,并中止继续编译。

73. 一个参数可以既是const又是volatile吗

可以,用const和volatile同时修饰变量,表示这个变量在程序内部是只读的,不能改变的,只在程序外部条件变化下改变,并且编译器不会优化这个变量。每次使用这个变量时,都要小心地去内存读取这个变量的值,而不是去寄存器读取它的备份。注意:在此一定要注意const的意思,const只是不允许程序中的代码改变某一变量,其在编译期发挥作用,它并没有实际地禁止某段内存的读写特性。

74. 宏定义和展开、内联函数区别

内联函数是代码被插入到调用者代码处的函数。如同 #define 宏,内联函数通过避免被调用的开销来提高执行效率,尤其是它能够通过调用(“过程化集成”)被编译器优化。 宏定义不检查函数参数,返回值什么的,只是展开,相对来说,内联函数会检查参数类型,所以更安全。 内联函数和宏很类似,而区别在于,宏是由预处理器对宏进行替代,而内联函数是通过编译器控制来实现的。而且内联函数是真正的函数,只是在需要用到的时候,内联函数像宏一样的展开,所以取消了函数的参数压栈,减少了调用的开销。

宏是预编译器的输入,然后宏展开之后的结果会送去编译器做语法分析。宏与函数等处于不同的级别,操作不同的实体。宏操作的是 token, 可以进行 token的替换和连接等操作,在语法分析之前起作用。而函数是语言中的概念,会在语法树中创建对应的实体,内联只是函数的一个属性。 对于问题:有了函数要它们何用?答案是:一:函数并不能完全替代宏,有些宏可以在当前作用域生成一些变量,函数做不到。二:内联函数只是函数的一种,内联是给编译器的提示,告诉它最好把这个函数在被调用处展开,省掉一个函数调用的开销(压栈,跳转,返回)

内联函数也有一定的局限性。就是函数中的执行代码不能太多了,如果,内联函数的函数体过大,一般的编译器会放弃内联方式,而采用普通的方式调用函数。这样,内联函数就和普通函数执行效率一样

内联函数必须是和函数体申明在一起,才有效。

75. C++中哪些运算符不能重载?

1 .(成员访问运算符)
2 .*(成员指针访问运算符)
3 ::(域运算符)
4 sizeof关键字
5 ?:(条件运算符)

76. 在定义一个宏的时候要注意什么?

定义部分的每个形参和整个表达式都必须用括号括起来,以避免不可预料的错误发生

77. 如何避免“野指针”

指针变量声明时没有被初始化。解决办法:指针声明时初始化,可以是具体的地址值,也可让它指向NULL。
指针p被free或者delete之后,没有置为NULL。解决办法:指针指向的内存空间被释放后指针应该指向NULL。
指针操作超越了变量的作用范围。解决办法:在变量的作用域结束前释放掉变量的地址空间并且让指针指向NULL。

78. 局部变量和全局变量是否可以同名?

能。局部会屏蔽全局。要用全局变量,需要使用”::”(域运算符)。

79. 写一个 “标准”宏MIN

1
#define min(a,b)((a)<=(b)?(a):(b))

80. 写出int 、bool、 float 、指针变量与 “零值”比较的if 语句

1
2
3
4
5
6
7
8
9
10
11
12
//int与零值比较
if ( n == 0 )
if ( n != 0 )
//bool与零值比较
if (flag) // 表示flag为真
if (!flag) // 表示flag为假
//float与零值比较
const float EPSINON = 0.00001;
if ((x >= - EPSINON) && (x <= EPSINON) //其中EPSINON是允许的误差(即精度)。
//指针变量与零值比较
if (p == NULL)
if (p != NULL)

81. strcpy()和memcpy()的区别?

strcpy()和memcpy()都可以用来拷贝字符串,strcpy()拷贝以’\0’结束,但memcpy()必须指定拷贝的长度。

简述strcpy、sprintf 与memcpy 的区别

操作对象不同,strcpy 的两个操作对象均为字符串,sprintf 的操作源对象可以是多种数据类型, 目的操作对象是字符串,memcpy 的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
执行效率不同,memcpy 最高,strcpy 次之,sprintf 的效率最低。
实现功能不同,strcpy 主要实现字符串变量间的拷贝,sprintf 主要实现其他数据类型格式到字 符串的转化,memcpy 主要是内存块间的拷贝。
注意: strcpy 、 sprintf 与memcpy 都可以实现拷贝的功能, 但是针对的对象不同, 根据实际需求, 来 选择合适的函数实现拷贝功能。

82. 当一个类A 中没有生命任何成员变量与成员函数,这时sizeof(A)的值是多少,请解释一下编译器为什么没有让它为零。

为1。举个反例,如果是零的话,声明一个class A[10]对象数组,而每一个对象占用的空间是零,这时就没办法区分A[0],A[1]…了。

83. c语言和c++中struct的区别

虽然长的一样,但是本质上类型不同:C语言中:struct是用户自定义数据类型(UDT);C++中struct是抽象数据类型(ADT),所以下面代码:

1
2
3
4
struct HE
{
int a;
};

在C里面实际上数据类型为 (struct HE),所以定义一个结构HE变量都要 带上struct.

1
struct` `HE a;  ``//C语言 变量方式

而在C++里面实际上数据类型为HE(主要是因为结构体被当成类对待了),所以定义变量不需要 struct.

1
HE a; ``//C++语言 变量

由于C++中的struct是抽象数据类型,所以可以继承也可以实现多态,只是因为有了class 一般不用它。

C++语言将struct当成类来处理的,所以C++的struct可以包含C++类的所有东西,例如构造函数,析构函数,友元等,C++的struct和C++ class 唯一不同就是struct成员默认的是public, C++默认private。

这里不要记混了,C++中的struct为了和C语言兼容,所以默认也是public 的。

而C语言struct不是类,不可以有函数,也不能使用类的特征例如public等关键字 ,也不可以有static关键字,说到底它只是一些变量的集合体,可以封装数据却不可以隐藏数据。

【总结】

struct C语言 C++
成员 没有函数成员,只有数据 函数和数据都可以有
访问权限 没有访问权限的设定,及对外不隐藏数据 有访问权限的设定private,public,protected
是否可以继承 不可以 有继承关系

【补充】

在C里面,你可以

1
2
3
struct S { 
int a, b;
}s_instance;

也可以 这样:

1
2
3
4
typedef struct { 
int a, b;
}S;
S s_instance;

注意:前者struct S {} 是一个类型,中间的S是一个tag,所以只能用1次。
后者typedef把struct {} 定义为类型S,所以S可以多用。

二、面向对象编程

1. 虚函数时怎么实现的?

每一个含有虚函数的类都至少有有一个与之对应的虚函数表,其中存放着该类所有虚函数对应的函数指针(地址),

类的示例对象不包含虚函数表,只有虚指针;

派生类会生成一个兼容基类的虚函数表。

概述

简单地说,每一个含有虚函数(无论是其本身的,还是继承而来的)的类都至少有一个与之对应的虚函数表,其中存放着该类所有的虚函数对应的函数指针。例:
img

其中:

  • B的虚函数表中存放着B::foo和B::bar两个函数指针。
  • D的虚函数表中存放的既有继承自B的虚函数B::foo,又有重写(override)了基类虚函数B::bar的D::bar,还有新增的虚函数D::quz。

提示:为了描述方便,本文在探讨对象内存布局时,将忽略内存对齐对布局的影响。

虚函数表构造过程

编译器的角度来说,B的虚函数表很好构造,D的虚函数表构造过程相对复杂。下面给出了构造D的虚函数表的一种方式(仅供参考):
img

提示:该过程是由编译器完成的,因此也可以说:虚函数替换过程发生在编译时。

虚函数调用过程

以下面的程序为例:
img

编译器只知道pb是B*类型的指针,并不知道它指向的具体对象类型 :pb可能指向的是B的对象,也可能指向的是D的对象。

但对于“pb->bar()”,编译时能够确定的是:此处operator->的另一个参数是B::bar(因为pb是B*类型的,编译器认为bar是B::bar),而B::bar和D::bar在各自虚函数表中的偏移位置是相等的。

无论pb指向哪种类型的对象,只要能够确定被调函数在虚函数中的偏移值,待运行时,能够确定具体类型,并能找到相应vptr了,就能找出真正应该调用的函数。

提示:本人曾在“C/C++杂记:深入理解数据成员指针、函数成员指针”一文中提到:虚函数指针中的ptr部分为虚函数表中的偏移值(以字节为单位)加1。

B::bar是一个虚函数指针, 它的ptr部分内容为9,它在B的虚函数表中的偏移值为8(8+1=9)。

当程序执行到“pb->bar()”时,已经能够判断pb指向的具体类型了:

  • 如果pb指向B的对象,可以获取到B对象的vptr,加上偏移值8((char*)vptr + 8),可以找到B::bar。
  • 如果pb指向D的对象,可以获取到D对象的vptr,加上偏移值8((char*)vptr + 8) ,可以找到D::bar。
  • 如果pb指向其它类型对象…同理…

多重继承

当一个类继承多个类,且多个基类都有虚函数时,子类对象中将包含多个虚函数表的指针(即多个vptr),例:
img

其中:D自身的虚函数与B基类共用了同一个虚函数表,因此也称B为D的主基类(primary base class)。

虚函数替换过程与前面描述类似,只是多了一个虚函数表,多了一次拷贝和替换的过程。

虚函数的调用过程,与前面描述基本类似,区别在于基类指针指向的位置可能不是派生类对象的起始位置,以如下面的程序为例:
img

2. 构造函数为什么一般不定义为虚函数?而析构函数一般写成虚函数的原因 ?

1、构造函数不能声明为虚函数

  • 1)因为创建一个对象时需要确定对象的类型,而虚函数是在运行时确定其类型的。而在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型,是类本身还是类的派生类等等

  • 2)虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数即构造函数了

2、析构函数最好声明为虚函数

首先析构函数可以为虚函数,当析构一个指向派生类的基类指针时,最好将基类的析构函数声明为虚函数,否则可以存在内存泄露的问题。

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

3. 纯虚函数

纯虚函数是只有声明没有实现的虚函数,是对子类的约束,是接口继承

包含纯虚函数的类是抽象类,它不能被实例化,只有实现了这个纯虚函数的子类才能生成对象

普通函数是静态编译的,没有运行时多态

1
virtual int A() = 0;

虚函数、纯虚函数

  • 类里如果声明了虚函数,这个函数是实现的,哪怕是空实现,它的作用就是为了能让这个函数在它的子类里面可以被覆盖(override),这样的话,编译器就可以使用后期绑定来达到多态了。纯虚函数只是一个接口,是个函数的声明而已,它要留到子类里去实现。
  • 虚函数在子类里面可以不重写;但纯虚函数必须在子类实现才可以实例化子类
  • 虚函数的类用于 “实作继承”,继承接口的同时也继承了父类的实现。纯虚函数关注的是接口的统一性,实现由子类完成。
  • 带纯虚函数的类叫抽象类,这种类不能直接生成对象,而只有被继承,并重写其虚函数后,才能使用。抽象类被继承后,子类可以继续是抽象类,也可以是普通类。
  • 虚基类是虚继承中的基类,具体见下文虚继承。

4. 虚继承

虚继承用于解决多继承条件下的菱形继承问题(浪费存储空间、存在二义性)。

底层实现原理与编译器相关,一般通过虚基类指针虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。

实际上,vbptr 指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

虚拟继承是多重继承中特有的概念,

类D继承自类B1,B2,而类B1,B2都继承自类A,

当类A为非虚基类,即类B1,B2非虚继承类A时,继承关系如下图:

A A

↓ ↓

B1 B2

↘ ↙

  D  

代码如下:

1
2
3
4
1 class A{};
2 class B1 : public A{};
3 class B2 : public A{};
4 class D : public B1, public B2{};

因此,为了节省内存空间,可以将类A定义为虚基类,即类B1,B2虚继承类A,继承关系如下:

A

↙ ↘

B1 B2

↘ ↙

  D

代码如下:

1
2
3
4
1 class A{};
2 class B1 : virtual public A{};
3 class B2 : virtual public A{};
4 class D : public B1, public B2{};

5. 友元函数和友元类

友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。

通过友元,一个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。

友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。

1)友元函数

有元函数是可以访问类的私有成员的非成员函数。它是定义在类外的普通函数,不属于任何类,但是需要在类的定义中加以声明。

将全局函数声明为友元的写法如下:

friend 返回值类型 函数名(参数表);

将其他类的成员函数声明为友元的写法如下:

friend 返回值类型 其他类的类名::成员函数名(参数表);

一个函数可以是多个类的友元函数,只需要在各个类中分别声明。

2)友元类

友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。

friend class 类名;

使用友元类时注意:

(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明

6. 成员初始化列表

好处

  • 更高效:少了一次调用默认构造函数的过程。
  • 有些场合必须要用初始化列表:
    1. 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
    2. 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
    3. 没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Weapon
{
private:
string name;
const string type;
const string model;
public:
Weapon(string& name, string& type, string& model) :name(name), type(type), model(model)
{
name = "Cloud";
}
string getProfile()
{
return "name: " + name + "\ntype: " + type + "\nmodel: " + model;
}
};

7. initializer_list 列表初始化

用花括号初始化器列表初始化一个对象,其中对应构造函数接受一个 std::initializer_list 参数.

initializer_list 使用

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
#include <iostream>
#include <vector>
#include <initializer_list>

template <class T>
struct S {
std::vector<T> v;
S(std::initializer_list<T> l) : v(l) {
std::cout << "constructed with a " << l.size() << "-element list\n";
}
void append(std::initializer_list<T> l) {
v.insert(v.end(), l.begin(), l.end());
}
std::pair<const T*, std::size_t> c_arr() const {
return {&v[0], v.size()}; // 在 return 语句中复制列表初始化
// 这不使用 std::initializer_list
}
};

template <typename T>
void templated_fn(T) {}

int main()
{
S<int> s = {1, 2, 3, 4, 5}; // 复制初始化
s.append({6, 7, 8}); // 函数调用中的列表初始化

std::cout << "The vector size is now " << s.c_arr().second << " ints:\n";

for (auto n : s.v)
std::cout << n << ' ';
std::cout << '\n';

std::cout << "Range-for over brace-init-list: \n";

for (int x : {-1, -2, -3}) // auto 的规则令此带范围 for 工作
std::cout << x << ' ';
std::cout << '\n';

auto al = {10, 11, 12}; // auto 的特殊规则

std::cout << "The list bound to auto has size() = " << al.size() << '\n';

// templated_fn({1, 2, 3}); // 编译错误!“ {1, 2, 3} ”不是表达式,
// 它无类型,故 T 无法推导
templated_fn<std::initializer_list<int>>({1, 2, 3}); // OK
templated_fn<std::vector<int>>({1, 2, 3}); // 也 OK
}

8. 面向对象编程的基本特性。

封装、继承和多态

9. 什么是基类,父类,超类和派生类?

基类就是父类,任何一个类都可以通过继承派生一个新类,称之为派生类。父类又称为“超类”。

10. 了解析构函数吗?需要注意些什么?

析构函数和构造函数相对应,在对象生命周期结束,自动完成对象回收与销毁。用[~类名]表示,它没有参数,返回值,也无法被重载

如果类中动态分配了空间,就需要在析构函数中释放指针。

11. 指针和对象有何区别。

指针指向内存中存放的类对象(包括一些成员变量所赋的值). 在堆中赋值。

对象是利用类的构造函数在内存中分配一块内存(包括一些成员变量所赋的值). 用的是内存栈,是个局部的临时变量.

在应用时:

1.引用成员: 对象用” . “操作符; 指针用” -> “操作符.

2.生命期: 若是成员变量,则是类的析构函数来释放空间;若是函数中的临时变量,则作用域是该函数体内.而指针,则需利用delete 在相应的地方释放分配的内存块.

注意:用new ,一定要delete.. 如果要实现多态,或者离开作用域还要继续使用变量,只能用指针实现

参考链接:类里面对象和指针的区别

12. 抽象类(接口)是什么?

抽象类(接口)是一种特殊的类,不能定义对象,需要满足以下条件:

  • 类中没有定义任何的成员变量
  • 所有的成员函数都是公有的
  • 所有的成员函数都是纯虚函数

子类继承接口,需要实现接口的全部的方法

13. 重载与重写。

重载(overload)是指重名的两个函数或方法,参数列表或返回值不同,这个时候编译器自动根据上下文判断最合适的函数。此外还有运算符重载,用以实现类的运算。

1
2
3
4
5
6
class A
{
void fun() {};
void fun(int i) {};
void fun(int i, int j) {};
};

重写(override)是指基类的虚函数,在子类更改了功能,这个叫重写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A
{
public:
virtual void fun()
{
cout << "A";
}
};
class B :public A
{
public:
virtual void fun()
{
cout << "B";
}
};

14. C++中拷贝/赋值函数的形参能否进行值传递。

不能。在默认情况下,编译器会自动生成一个拷贝构造函数赋值运算符,用户可以用delete来不生成。

如果采用值传递,调用拷贝构造函数,先将实参传递给形参,这个传递又要调用拷贝函数,会导致不断循环直到调用栈满

15. 拷贝构造函数、赋值构造函数的定义?

拷贝构造函数是一种构造函数,和类同名,参数通过类的对象引用传递,无返回值。

赋值构造函数是通过重载=运算符实现的,也是通过类的对象引用传递。

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
//不生成拷贝构造函数的例子
class Person {
public:
Person(const Person& p) = delete;
Person& operator=(const Person& p) = delete;
private:
int age;
string name;
};
//生成拷贝构造函数
class A {
public:
//拷贝构造函数
explicit A(A& a) : x(a.x)
{
cout << "Copy Constructor" << endl;
}
//赋值函数
A& operator=(A& a)
{
x = a.x;
cout << "Copy Assignment operator" << endl;
return *this;
}
private:
int x;
}

16. 多态

  • 多态,即多种状态(形态)。简单来说,我们可以将多态定义为消息以多种形式显示的能力。

  • 多态的作用?

    • 隐藏实现细节,使得代码能够模块化;扩展代码模块,实现代码重用;
    • 接口重用:为了类在继承和派生的时候,保证使用家族中任一类的实例的某一属性时的正确调用
  • 多态是以封装和继承为基础的。

  • C++ 多态分类及实现:

    1. 重载多态(Ad-hoc Polymorphism,编译期):函数重载、运算符重载
    2. 子类型多态(Subtype Polymorphism,运行期):虚函数
    3. 参数多态性(Parametric Polymorphism,编译期):类模板、函数模板
    4. 强制多态(Coercion Polymorphism,编译期/运行期):基本类型转换、自定义类型转换

The Four Polymorphisms in C++

静态多态(编译期/早绑定)

函数重载

1
2
3
4
5
6
class A
{
public:
void do(int a);
void do(int a, int b);
};

动态多态(运行期期/晚绑定)

  • 虚函数:用 virtual 修饰成员函数,使其成为虚函数
  • 动态绑定:当使用基类的引用或指针调用一个虚函数时将发生动态绑定

注意:

  • 可以将派生类的对象赋值给基类的指针或引用,反之不可
  • 普通函数(非类成员函数)不能是虚函数
  • 静态函数(static)不能是虚函数
  • 构造函数不能是虚函数(因为在调用构造函数时,虚表指针并没有在对象的内存空间中,必须要构造函数调用完成后才会形成虚表指针)
  • 内联函数不能是表现多态性时的虚函数,解释见:虚函数(virtual)可以是内联函数(inline)吗?

动态多态使用

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
class Shape                     // 形状类
{
public:
virtual double calcArea()
{
...
}
virtual ~Shape();
};
class Circle : public Shape // 圆形类
{
public:
virtual double calcArea();
...
};
class Rect : public Shape // 矩形类
{
public:
virtual double calcArea();
...
};
int main()
{
Shape * shape1 = new Circle(4.0);
Shape * shape2 = new Rect(5.0, 6.0);
shape1->calcArea(); // 调用圆形类里面的方法
shape2->calcArea(); // 调用矩形类里面的方法
delete shape1;
shape1 = nullptr;
delete shape2;
shape2 = nullptr;
return 0;
}

17. 类的声明和实现的分开的好处?

  1. 起保护作用;
  2. 提高编译的效率。这样可以提高编译效率,因为分开的话只需要编译一次生成对应的.obj文件后,再次应用该类的地方,这个类就不会被再次编译,从而大大的提高了编译效率。

18. C++的空类有哪些成员函数

  • 缺省构造函数。
  • 缺省拷贝构造函数。
  • 缺省析构函数。
  • 缺省赋值运算符。
  • 缺省取址运算符。
  • 缺省取址运算符 const 。

注意: 有些书上只是简单的介绍了前四个函数。 没有提及后面这两个函数。 但后面这两个函数也是 空类的默认函数。 另外需要注意的是, 只有当实际使用这些函数的时候, 编译器才会去定义它们。

19. 什么时候必须重写拷贝构造函数?

当构造函数涉及到动态存储分配空间时,要自己写拷贝构造函数,并且要深拷贝。

20. 是不是一个父类写了一个virtual 函数,如果子类覆盖它的函数不加virtual ,也能实现多态?

virtual修饰符会被隐形继承的。
virtual可加可不加,子类覆盖它的函数不加virtual ,也能实现多态。

21. C++类内可以定义引用数据成员吗?

可以,必须通过成员函数初始化列表初始化。

22. 几种情况必须用到初始化成员列表?

1.类的成员是常量成员初始化;
2.类的成员是对象成员初始化,而该对象没有无参构造函数。
3.类的成员为引用时。

23. 构造函数的调用顺序是什么?

1.先调用基类构造函数
2.按声明顺序初始化数据成员
3.最后调用自己的构造函数。

24. 内联函数、构造函数、静态成员函数可以是虚函数吗

inline, static, constructor三种函数都不能带有virtual关键字。 inline是编译时展开,必须有实体; static属于class自己的,也必须有实体; virtual函数基于vtable(内存空间),constructor函数如果是virtual的,调用时也需要根据vtable寻找,但是constructor是virtual的情况下是找不到的,因为constructor自己本身都不存在了,创建不到class的实例,没有实例,class的成员(除了public static/protected static for friend class/functions,其余无论是否virtual)都不能被访问了。

虚函数实际上不能被内联:虚函数运行时所需的代价主要是虚函数不能是内联函。这也是非常好理解的,是因为内联函数是指在编译期间用被调用的函数体本身来代替函数调用的指令,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。”但虚函数的运行时多态特性就是要在运行时才知道具体调用哪个虚函数,所以没法在编译时进行内联函数展开。当然如果通过对象直接调用虚函数它是可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。 因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。

构造函数不能是虚函数。而且,在构造函数中调用虚函数,实际执行的是父类的对应函数,因为自己还没有构造好, 多态是被disable的。

静态的对象是属于整个类的,不对某一个对象而言,同时其函数的指针存放也不同于一般的成员函数,其无法成为一个对象的虚函数的指针以实现由此带来的动态机制。

25. 拷贝构造函数在哪几种情况下会被调用?

1.当类的一个对象去初始化该类的另一个对象时;
2.如果函数的形参是类的对象,调用函数进行形参和实参结合时;
3.如果函数的返回值是类对象,函数调用完成返回时。

三、内存管理

1. C++ 内存管理

在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

  • 栈:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。  
  • 堆:就是那些由 new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
  • 自由存储区:就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。
  • 全局/静态存储区(.bss 段和 .data 段):全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。data段(.data):保存已初始化的全局变量和静态变量。bss段(.bss):存储未初始化的全局变量。
  • 常量存储区:这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

image-20240122001651774

img

2. C++ 堆和栈的区别

  1. 管理方式不同:栈,由编译器自动管理,无需程序员手工控制;堆:产生和释放由程序员控制。
  2. 空间大小不同:栈的空间有限;堆内存可以达到4G,。
  3. 能否产生碎片不同:栈不会产生碎片,因为栈是种先进后出的队列。堆则容易产生碎片,多次的new/delete会造成内存的不连续,从而造成大量的碎片。
  4. 生长方向不同:堆的生长方式是向上的,栈是向下的。
  5. 分配方式不同:堆是动态分配的。栈可以是静态分配和动态分配两种,但是栈的动态分配由编译器释放。
  6. 分配效率不同:栈是机器系统提供的数据结构,计算机底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令。堆则是由C/C++函数库提供,库函数会按照一定的算法在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。
  • 堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家 尽量用栈,而不是用堆。
  • 栈和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。
  • 无论是堆还是栈,都要防止越界现象的发生。

3. C++ 字节对齐

字节对齐的原因

1)平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
2)硬件原因:经过内存对齐之后,CPU的内存访问速度大大提升,帮助cpu寻址。

【注意】(对齐位数跟处理器位数和编译器都有关)VS, VC等编译器默认是#pragma pack(8),所以测试我们的规则会正常;注意gcc默认是#pragma pack(4),并且gcc只支持1,2,4对齐。

对齐规则

每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。gcc中默认#pragma pack(4),可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。

有效对齐值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位

(1) 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。

(2) 结构体的总大小为 有效对齐值 的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

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
//32位系统
#pragma pack(4)
#include<stdio.h>
struct
{
int i;
char c1;
char c2;
}x1;

struct{
char c1;
int i;
char c2;
}x2;

struct{
char c1;
char c2;
int i;
}x3;

int main()
{
printf("%d\n",sizeof(x1)); // 输出8
printf("%d\n",sizeof(x2)); // 输出12
printf("%d\n",sizeof(x3)); // 输出8
return 0;
}

img

如果前面加上#pragma pack(1),那么此时有效对齐值为1字节,此时根据对齐规则,不难看出成员是连续存放的,三个结构体的大小都是6字节。

img

如果前面加上#pragma pack(2),有效对齐值为2字节,此时根据对齐规则,三个结构体的大小应为6,8,6。内存分布图如下:

img

4. 描述内存分配方式以及它们的区别?

1) 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。

2) 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集。

3) 从堆上分配,亦称动态内存分配。程序在运行的时候用malloc 或new 申请任意多少的内存,程序员自己负责在何时用free 或delete 释放内存。动态内存的生存期由程序员决定,使用非常灵活,但问题也最多。

5. newmalloc的10点区别

  1. 申请的内存所在位置
    new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。
    自由存储区是否能够是堆(问题等价于new是否能在堆上动态分配内存),这取决于operator new 的实现细节。自由存储区不仅可以是堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。特别的,new甚至可以不为对象分配内存

  2. 返回类型安全性:
    new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。

  3. 内存分配失败时的返回值
    new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。

  4. 是否需要指定内存大小
    使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算,而malloc则需要显式地指出所需内存的尺寸。

  5. 是否调用构造函数/析构函数
    使用new操作符来分配对象内存时会经历三个步骤:

    • 第一步:调用operator new 函数(对于数组是operator new[])分配一块足够大的,原始的,未命名的内存空间以便存储特定类型的对象。
    • 第二步:编译器运行相应的构造函数以构造对象,并为其传入初值。
    • 第三部:对象构造完成后,返回一个指向该对象的指针。

    使用delete操作符来释放对象内存时会经历两个步骤:

    • 第一步:调用对象的析构函数。
    • 第二步:编译器调用operator delete(或operator delete[])函数释放内存空间。

    总之来说,new/delete会调用对象的构造函数/析构函数以完成对象的构造/析构。而malloc则不会。

  6. 对数组的处理
    C++提供了new[]与delete[]来专门处理数组类型:

    1
    A * ptr = new A[10];//分配10个A对象

    使用new[]分配的内存必须使用delete[]进行释放:

    1
    delete [] ptr;

    new对数组的支持体现在它会分别调用构造函数函数初始化每一个数组元素,释放对象时为每个对象调用析构函数。注意delete[]要与new[]配套使用,不然会找出数组对象部分释放的现象,造成内存泄漏。

    至于malloc,它并知道你在这块内存上要放的数组还是啥别的东西,反正它就给你一块原始的内存,在给你个内存的地址就完事。所以如果要动态分配一个数组的内存,还需要我们手动自定数组的大小:

    1
    int * ptr = (int *) malloc( sizeof(int) );//分配一个10个int元素的数组
  7. new与malloc是否可以相互调用
    operator new /operator delete的实现可以基于malloc,而malloc的实现不可以去调用new。

  8. 是否可以被重载
    opeartor new /operator delete可以被重载。而malloc/free并不允许重载。

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

  10. 客户处理内存分配不足
    在operator new抛出异常以反映一个未获得满足的需求之前,它会先调用一个用户指定的错误处理函数,这就是new-handler。new_handler是一个指针类型,指向了一个没有参数没有返回值的函数,即为错误处理函数。
    对于malloc,客户并不能够去编程决定内存不足以分配时要干什么事,只能看着malloc返回NULL。

特征 new/delete malloc/free
分配内存的位置 自由存储区
内存分配失败返回值 完整类型指针 void*
内存分配失败返回值 默认抛出异常 返回NULL
分配内存的大小 由编译器根据类型计算得出 必须显式指定字节数
处理数组 有处理数组的new版本new[] 需要用户计算数组的大小后进行内存分配
已分配内存的扩充 无法直观地处理 使用realloc简单完成
是否相互调用 可以,看具体的operator new/delete实现 不可调用new
分配内存时内存不足 客户能够指定处理函数或重新制定分配器 无法通过用户代码进行处理
函数重载 允许 不允许
构造函数与析构函数 调用 不调用

6. delete和delete[]的区别

delete只会调用一次析构函数,而delete[]会调用每个成员的析构函数

用new分配的内存用delete释放,用new[]分配的内存用delete[]释放

7. new、delete、malloc、free关系

malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求。

对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。

因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。

delete会调用对象的析构函数,和delete对应free只会释放内存,new调用构造函数。

8. 什么是内存泄漏?面对内存泄漏和指针越界,你有哪些方法?

动态分配内存所开辟的空间,在使用完毕后未手动释放,导致一直占据该内存,即为内存泄漏。

方法:malloc/free要配套,对指针赋值的时候应该注意被赋值的指针是否需要释放;使用的时候记得指针的长度,防止越界

9. 什么是野指针

野指针不是NULL指针,是未初始化或者未清零的指针,它指向的内存地址不是程序员所期望的,可能指向了受限的内存

成因:

1)指针变量没有被初始化

2)指针指向的内存被释放了,但是指针没有置NULL

3)指针超过了变量了的作用范围,比如b[10],指针b+11

10. 线程安全和线程不安全

线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可以使用,不会出现数据不一致或者数据污染。

线程不安全就是不提供数据访问保护,有可能多个线程先后更改数据所得到的数据就是脏数据。

12. C++中内存泄漏的几种情况

内存泄漏是指己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

1)类的构造函数和析构函数中new和delete没有配套

2)在释放对象数组时没有使用delete[],使用了delete

3)没有将基类的析构函数定义为虚函数,当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确释放,因此造成内存泄露

4)没有正确的清楚嵌套的对象指针

13. 栈溢出的原因以及解决方法

1)函数调用层次过深,每调用一次,函数的参数、局部变量等信息就压一次栈

2)局部变量体积太大。

解决办法大致说来也有两种:

1> 增加栈内存的数目;增加栈内存方法如下,在vc6种依次选择Project->Setting->Link,在Category中选择output,在Reserve中输入16进制的栈内存大小如:0x10000000

2> 使用堆内存;具体实现由很多种方法可以直接把数组定义改成指针,然后动态申请内存;也可以把局部变量变成全局变量,一个偷懒的办法是直接在定义前边加个static,呵呵,直接变成静态变量(实质就是全局变量)

14. 栈内存与文字常量区

1
2
3
4
5
6
7
8
9
10
11
12
char str1[] = "abc";
  char str2[] = "abc";
  const char str3[] = "abc";
  const char str4[] = "abc";
  const char *str5 = "abc";
  const char *str6 = "abc";
  char *str7 = "abc";
  char *str8 = "abc";
  cout << ( str1 == str2 ) << endl;//0 分别指向各自的栈内存
  cout << ( str3 == str4 ) << endl;//0 分别指向各自的栈内存
  cout << ( str5 == str6 ) << endl;//1指向文字常量区地址相同
  cout << ( str7 == str8 ) << endl;//1指向文字常量区地址相同

结果是:0 0 1 1

解答:str1,str2,str3,str4是数组变量,它们有各自的内存空间;而str5,str6,str7,str8是指针,它们指向相同的常量区域。

15. malloc原理。

Malloc函数用于动态分配内存。malloc其采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲块。

malloc采用隐式链表结构将堆区分成连续的、大小不一的块;同时malloc采用显示链表结构来管理所有的空闲块,每一个空闲块记录了一个连续的、未分配的地址。

搜索空闲块最常见的算法有:首次适配,下一次适配,最佳适配。 (其实就是操作系统中动态分区分配的算法)

三者都是系统调用函数

  • brk() 和 sbrk()都是扩展堆的上界。
1
2
3
#include <unistd.h> 
int brk( const void *addr )//参数设置为新的brk上界地址,成功返回1,失败返回0;
void* sbrk ( intptr_t incr );//申请内存的大小,返回heap新的上界brk的地址;
  • mmap采用的是匿名映射
1
2
3
4
5
#include <sys/mman.h>
//mmap的第一种用法是映射此盘文件到内存中;
//第二种用法是匿名映射,不映射磁盘文件,而向映射区申请一块内存。
void *mmap(void *addr, size\_t length, int prot, int flags, int fd, off\_t offset);
int munmap(void *addr, size_t length);//释放内存。

1)当开辟的空间小于 128K 时,调用 brk函数,malloc 的底层实现是系统调用函数 brk,其主要移动指针 _enddata(此时的 _enddata 指的是 Linux 地址空间中堆段的末尾地址,不是数据段的末尾地址)。

  • malloc分配了这块内存,然后如果从不去访问它,那么物理页是不会被分配的。
  • 当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作。

2)当开辟的空间大于 128K 时,mmap系统调用函数来在虚拟地址空间中(堆和栈中间,称为“文件映射区域”的地方)找一块空间来开辟。

其实,很多人开始诟病 glibc 内存管理的实现,特别是高并发性能低下和内存碎片化问题都比较严重,因此,陆续出现一些第三方工具来替换 glibc 的实现,最著名的当属 google 的tcmalloc和facebook 的jemalloc 。

参考链接:

linux环境内存分配原理–虚拟内存 mallocinfowww.cnblogs.com/dongzhiquan/p/5621906.htmlimg

16. delete this 合法吗?

Is it legal (and moral) for a member function to say delete this?

合法,但:

  1. 必须保证 this 对象是通过 new(不是 new[]、不是 placement new、不是栈上、不是全局、不是其他对象成员)分配的
  2. 必须保证调用 delete this 的成员函数是最后一个调用 this 的成员函数
  3. 必须保证成员函数的 delete this 后面没有调用 this 了
  4. 必须保证 delete this 后没有人使用了

17. 如何定义一个只能在堆上(栈上)生成对象的类?

如何定义一个只能在堆上(栈上)生成对象的类?

只能在堆上

方法:将析构函数设置为私有

原因:C++ 是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象。

只能在栈上

方法:将 new 和 delete 重载为私有

原因:在堆上生成对象,使用 new 关键词操作,其过程分为两阶段:第一阶段,使用 new 在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。将 new 操作设置为私有,那么第一阶段就无法完成,就不能够在堆上生成对象。

18. 有了malloc/free为什么还要new/delete ?

malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可以孕育申请动态内存和释放内存。对于非内部数据类型的对象而言,光用malloc./free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前 要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于Malloc/free.。因此C++语言需要一个能动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。

19. 在C++中,使用malloc申请的内存能否通过delete释放?使用new申请的内存能否用free?

不能,malloc/free主要为了兼容C,new和delete完全可以取代malloc /free的。malloc/free的操作对象都是必须明确大小的。而且不能用在动态类上。new和delete会自动进行类型检查和大小,malloc/free不能执行构造函数与析构函数,所以动态对象它是不行的。当然从理论上说使用malloc申请的内存是可以通过delete释放的。不过一般不这样写的。而且也不能保证每个C++的运行时都能正常。

20. CMemoryState主要功能是什么

查看内存使用情况,解决内存泄露问题。

21. delete与 delete []区别:

delete只会调用一次析构函数,而delete[]会调用每一个成员的析构函数。

22. 说出字符常量和字符串常量的区别,并使用运算符sizeof计算有什么不用?

字符常量是指单个字符,字符串常量以‘\0’结束,使用运算符sizeof计算多占一字节的存储空间。

四、STL

1. STL库用过吗?常见的STL容器有哪些?算法用过几个?

STL包括两部分内容:容器和算法

容器即存放数据的地方,比如array, vector,分为两类,序列式容器和关联式容器

序列式容器,其中的元素不一定有序,但是都可以被排序,比如vector,list,queue,stack,heap, priority-queue, slist

关联式容器,内部结构是一个平衡二叉树,每个元素都有一个键值和一个实值,比如map, set, hashtable, hash_set

算法有排序,复制等,以及各个容器特定的算法

迭代器是STL的精髓,迭代器提供了一种方法,使得它能够按照顺序访问某个容器所含的各个元素,但无需暴露该容器的内部结构,它将容器和算法分开,让二者独立设计。

STL 容器

容器 底层数据结构 时间复杂度 有无序 可不可重复 其他
array 数组 随机读改 O(1) 无序 可重复 支持随机访问
vector 数组 随机读改、尾部插入、尾部删除 O(1) 头部插入、头部删除 O(n) 无序 可重复 支持随机访问
deque 双端队列 头尾插入、头尾删除 O(1) 无序 可重复 一个中央控制器 + 多个缓冲区,支持首尾快速增删,支持随机访问
forward_list 单向链表 插入、删除 O(1) 无序 可重复 不支持随机访问
list 双向链表 插入、删除 O(1) 无序 可重复 不支持随机访问
stack deque / list 顶部插入、顶部删除 O(1) 无序 可重复 deque 或 list 封闭头端开口,不用 vector 的原因应该是容量大小有限制,扩容耗时
queue deque / list 尾部插入、头部删除 O(1) 无序 可重复 deque 或 list 封闭头端开口,不用 vector 的原因应该是容量大小有限制,扩容耗时
priority_queue vector + max-heap 插入、删除 O(log2n) 有序 可重复 vector容器+heap处理规则
set 红黑树 插入、删除、查找 O(log2n) 有序 不可重复
multiset 红黑树 插入、删除、查找 O(log2n) 有序 可重复
map 红黑树 插入、删除、查找 O(log2n) 有序 不可重复
multimap 红黑树 插入、删除、查找 O(log2n) 有序 可重复
unordered_set 哈希表 插入、删除、查找 O(1) 最差 O(n) 无序 不可重复
unordered_multiset 哈希表 插入、删除、查找 O(1) 最差 O(n) 无序 可重复
unordered_map 哈希表 插入、删除、查找 O(1) 最差 O(n) 无序 不可重复
unordered_multimap 哈希表 插入、删除、查找 O(1) 最差 O(n) 无序 可重复

STL 算法

算法 底层算法 时间复杂度 可不可重复
find 顺序查找 O(n) 可重复
sort 内省排序 O(n*log2n) 可重复

2. STL中map和set的原理(关联式容器)——红黑树

map和set的底层实现主要通过红黑树来实现

红黑树是一种特殊的二叉查找树

1)每个节点或者是黑色,或者是红色

2)根节点是黑色

3) 每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]

4)如果一个节点是红色的,则它的子节点必须是黑色的

5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。

特性4)5)决定了没有一条路径会比其他路径长出2倍,因此红黑树是接近平衡的二叉树。

set的底层实现实现为什么不用哈希表而使用红黑树?

set中元素是经过排序的,红黑树也是有序的,哈希是无序的
如果只是单纯的查找元素的话,那么肯定要选哈希表了,因为哈希表在的最好查找时间复杂度为O(1),并且
如果用到set中那么查找时间复杂度的一直是O(1),因为set中是不允许有元素重复的。而红黑树的查找时
间复杂度为O(lgn)

3. STL中的vector的实现,是怎么扩容的?

vector使用的注意点及其原因,频繁对vector调用push_back()对性能的影响和原因。
vector就是一个动态增长的数组,里面有一个指针指向一片连续的空间,当空间装不下的时候,会申请一片更大的空间,将原来的数据拷贝过去,并释放原来的旧空间。当删除的时候空间并不会被释放,只是清空了里面的数据。对比array是静态空间一旦配置了就不能改变大小。

vector的动态增加大小的时候,并不是在原有的空间上持续新的空间(无法保证原空间的后面还有可供配置的空间),而是以原大小的两倍另外配置一块较大的空间,然后将原内容拷贝过来,并释放原空间。在VS下是1.5倍扩容,在GCC下是2倍扩容。

在原来空间不够存储新值时,每次调用push_back方法都会重新分配新的空间以满足新数据的添加操作。如果在程序中频繁进行这种操作,还是比较消耗性能的

4. STL中unordered_map和map的区别

map是STL中的一个关联容器,提供键值对的数据管理。底层通过红黑树来实现,实际上是二叉排序树和非严格意义上的二叉平衡树。所以在map内部所有的数据都是有序的,且map的查询、插入、删除操作的时间复杂度都是O(logN)。

unordered_map和map类似,都是存储key-value对,可以通过key快速索引到value,不同的是unordered_map不会根据key进行排序。unordered_map底层是一个防冗余的哈希表,存储时根据key的hash值判断元素是否相同,即unoredered_map内部是无序的。

5. C++标准库vector以及迭代器

每种容器类型都定义了自己的迭代器类型,每种容器都定义了一队命名为begin和end的函数,用于返回迭代器。

迭代器是容器的精髓,它提供了一种方法使得它能够按照顺序访问某个容器所含的各个元素,但无需暴露该容器的内部结构,它将容器和算法分开,让二者独立设计。

6. C++中vector和list的区别

vector和数组类似,拥有一段连续的内存空间。vector申请的是一段连续的内存,当插入新的元素内存不够时,通常以2倍重新申请更大的一块内存,将原来的元素拷贝过去,释放旧空间。因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为o(n)。

list是由双向链表实现的,因此内存空间是不连续的。只能通过指针访问数据,所以list的随机存取非常没有效率,时间复杂度为o(n); 但由于链表的特点,能高效地进行插入和删除。

vector拥有一段连续的内存空间,能很好的支持随机存取,因此vector<int>::iterator支持“+”,“+=”,“<”等操作符。

list的内存空间可以是不连续,它不支持随机访问,因此list<int>::iterator则不支持“+”、“+=”、“<”等

vector<int>::iterator和list<int>::iterator都重载了“++”运算符。

总之,如果需要高效的随机存取,而不在乎插入和删除的效率,使用vector;
如果需要大量的插入和删除,而不关心随机存取,则应使用list。

7. 讲一下STL分配器。内部原理是什么?

STL分配器用于容器内存管理。主要职责是:new申请空间;delete释放空间。

为了精密分工,分配器要将两阶段分开:1. 内存配置先由allocate()(operator new())完成,然后对象构造由构造函数负责;2. 对象析构先由析构函数完成,内存释放由deallocate()(operator delete())完成。注意顺序不要弄错。

参考资料:C++STL学习笔记(4) 分配器(Allocator)

8. STL的两级分配器了解吗?

为了提升内存管理效率,STL采用两级分配器:对于大于128B的内存申请,采用第一级分配器,用malloc(), realloc(), free()进行空间分配;对于小于128B的内存申请,采用内存池技术,采用链表进行管理。

1、第一级配置器

第一级配置器以 malloc(),free(),realloc()等 C 函数执行实际的内存配置、释放、重新配置等操作,并且能在内存需求不被满足的时候,调用一个指定的函数。
一级空间配置器分配的是大于 128 字节的空间如果分配不成功,调用句柄释放一部分内存如果还不能分配成功,抛出异常

2、第二级配置器

在 STL 的第二级配置器中多了一些机制,避免太多小区块造成的内存碎片,小额区块带来的不仅是内存碎片,配置时还有额外的负担。区块越小,额外负担所占比例就越大。

3、分配原则

如果要分配的区块大于 128bytes,则移交给第一级配置器处理。

如果要分配的区块小于 128bytes,则以内存池管理(memory pool),又称之次层配置(sub-allocation):每次配置一大块内存,并维护对应的 16 个空闲链表(free-list)。下次若有相同大小的内存需求,则直接从 free-list 中取。如果有小额区块被释放,则由配置器回收到 free-list 中。

当用户申请的空间小于 128 字节时,将字节数扩展到 8 的倍数,然后在自由链表中查找对应大小的子链表

如果在自由链表查找不到或者块数不够,则向内存池进行申请,一般一次申请 20 块

如果内存池空间足够,则取出内存

如果不够分配 20 块,则分配最多的块数给自由链表,并且更新每次申请的块数

如果一块都无法提供,则把剩余的内存挂到自由链表,然后向系统 heap 申请空间,如果申请失败,则看看自由链表还有没有可用的块,如果也没有,则最后调用一级空间配置器

4、二级内存池

二级内存池采用了 16 个空闲链表,这里的 16 个空闲链表分别管理大小为 8、16、24……120、128 的数据块。这里空闲链表节点的设计十分巧妙,这里用了一个联合体既可以表示下一个空闲数据块(存在于空闲链表中)的地址,也可以表示已经被用户使用的数据块(不存在空闲链表中)的地址。

img

空间配置函数 allocate

首先先要检查申请空间的大小,如果大于 128 字节就调用第一级配置器,小于 128 字节就检查对应的空闲链表,如果该空闲链表中有可用数据块,则直接拿来用(拿取空闲链表中的第一个可用数据块,然后把该空闲链表的地址设置为该数据块指向的下一个地址),如果没有可用数据块,则调用 refill 重新填充空间。

空间释放函数 deallocate

首先先要检查释放数据块的大小,如果大于 128 字节就调用第一级配置器,小于 128 字节则根据数据块的大小来判断回收后的空间会被插入到哪个空闲链表。

重新填充空闲链表 refill

在用 allocate 配置空间时,如果空闲链表中没有可用数据块,就会调用 refill 来重新填充空间,新的空间取自内存池。缺省取 20 个数据块,如果内存池空间不足,那么能取多少个节点就取多少个。

从内存池取空间给空闲链表用是 chunk_alloc 的工作,首先根据 end_free-start_free 来判断内存池中的剩余空间是否足以调出 nobjs 个大小为 size 的数据块出去,如果内存连一个数据块的空间都无法供应,需要用 malloc 取堆中申请内存。

假如山穷水尽,整个系统的堆空间都不够用了,malloc 失败,那么 chunk_alloc 会从空闲链表中找是否有大的数据块,然后将该数据块的空间分给内存池(这个数据块会从链表中去除)。

5、总结:

  1. 使用 allocate 向内存池请求 size 大小的内存空间,如果需要请求的内存大小大于128bytes,直接使用 malloc。
  2. 如果需要的内存大小小于 128bytes,allocate 根据 size 找到最适合的自由链表。
    1. 如果链表不为空,返回第一个 node,链表头改为第二个 node。
    2. 如果链表为空,使用 blockAlloc 请求分配 node。
      1. 如果内存池中有大于一个 node 的空间,分配竟可能多的 node(但是最多 20 个),将一个 node 返回,其他的 node 添加到链表中。
      2. 如果内存池只有一个 node 的空间,直接返回给用户。
      3. 若果如果连一个 node 都没有,再次向操作系统请求分配内存。
        1. ①分配成功,再次进行 b 过程。
        2. ②分配失败,循环各个自由链表,寻找空间。
          1. I. 找到空间,再次进行过程 b。
          2. II. 找不到空间,抛出异常。
  3. 用户调用 deallocate 释放内存空间,如果要求释放的内存空间大于 128bytes,直接调
    用 free。
  4. 否则按照其大小找到合适的自由链表,并将其插入。

9. 你刚才提到了C++的内存池技术,能介绍一下吗。

C++默认的内存管理采用malloc(), free() 等,会频繁的在堆动态分配和回收内存,内存空间碎片化严重,导致空间利用率低。内存池很好的解决了这个问题,它是针对小对象而言的,首先申请一定数量,指定大小(通常8B)的内存块,当有新的内存申请就拿出一个块,如果不够再申请。

算法:

  1. 预申请一个内存区chunk,将内存中按照对象大小划分成多个内存块block
  2. 维持一个空闲内存块链表,通过指针相连,标记头指针为第一个空闲块
  3. 每次新申请一个对象的空间,则将该内存块从空闲链表中去除,更新空闲链表头指针
  4. 每次释放一个对象的空间,则重新将该内存块加到空闲链表头
  5. 如果一个内存区占满了,则新开辟一个内存区,维持一个内存区的链表,同指针相连,头指针指向最新的内存区,新的内存块从该区内重新划分和申请

参考资料:C++内存池的简单原理及实现

10. 迭代器是指针吗?

迭代器不是指针,而是类模板。它封装了指针并重载指针的一些运算符,如++,–,等,所以能够遍历部分或全部访问容器元素的对象。*迭代器返回的是对象的引用,所以不能直接访问,需要用*解引用再访问。

11. 讲一下capacity(), size(), reserve(), resize() 函数的区别。

size()用于返回容器当前的元素个数。而capacity()返回容器的容量。

reserve()是为容器预留空间,改变的是capacity,size保持不变。

resize()既改变了capacity,又改变了size。

reserve(x), 只有x>capacity才有用。

resize(x,val),1. x > capacity,那么会在原容器内补充x-capacity个值为val的元素;2. x <= capacity,那么容器内前x个元素值变为为val,其余不变

12. 请你说一下STL迭代器删除元素是怎么做的。

对于顺序容器而言,vector,deque使用erase删除元素的迭代器后,会使后面所有的迭代器会失效,后面每个迭代器都会向前移动一个位置,erase返回下一个有效的迭代器

对于有序关联容器而言,set/multiset, map/multimap,删除元素并不会导致后面迭代器失效,因为他们底层实现是红黑树,所以只需要递增迭代器即可,对于无序关联容器,底层实现是哈希表,删除元素会导致迭代器失效,erase会返回下一个有效的迭代器。

对于list而言,它使用了不连续分配的内存,因此erase会返回下一个有效的迭代器,上面两种方式都可以使用。

13. deque和list用过吗,有什么心得。

两者都属于顺序容器。

deque是双向队列,它底层实现是一个双端队列,可用在头部和尾部添加或删除元素(push_front, push_back, pop_front, pop_back)。

  • deque内部采用分段连续的内存空间来存储元素,在插入元素的时候随时都可以重新增加一段新的空间并链接起来,因此虽然提供了随机访问操作,但访问速度和vector相比要慢。
  • deque并没有data函数,因为deque的元素并没有放在数组中。
  • deque不提供capacity和reserve操作。
  • deque内部的插入和删除元素要比list慢。

list是链表,它在插入删除元素的时间复杂度都是O(1)比deque更好。不支持按下标访问(随机访问)。

14. emplace_back()和push_back()哪个更好,为什么?

emplace_back()更好,因为它调用的是移动构造函数。而push_back()调用的是拷贝构造函数。移动构造函数不需要分配新的内存空间,所以更快一些。

15. vector::push_back()的时间复杂度是多少?

答案:O(1)。

当容器的大小达到容量后,为了保证内存的连续性,就会再开辟一个新的内存,把之前的数据复制过去。每次复制的时间复杂度是O(n),但是因为复制过程极少发生,所以均摊的时间复杂度还是O(1)。

img

推导过程

16. vector数组的底层原理?

通过分析 vector 容器的源代码不难发现,它就是使用 3 个迭代器来表示的:

1
2
3
4
5
6
7
8
9
10
11
12
///_Alloc 表示内存分配器,此参数几乎不需要我们关心
template<typename _Tp, typename _Alloc>
struct _Vector_base
{
struct _Vector_impl
: public _Tp_alloc_type
{
pointer _M_start;
pointer _M_finish;
pointer _M_end_of_storage;
}
}

其中,_Myfirst 指向的是 vector 容器对象的起始字节位置;_Mylast 指向当前最后一个元素的末尾字节;_myend 指向整个 vector 容器所占用内存空间的末尾字节。

img

参考链接:C++ vector(STL vector)底层实现机制(通俗易懂)

17. list底层实现原理

list底层是链表,通过查看 list 容器的源码实现,其对节点的定义如下:

1
2
3
4
5
6
7
8
template<typename T,...>
struct __List_node{
//...
__list_node<T>* prev;
__list_node<T>* next;
T myval;
//...
}

可以看到,list 容器定义的每个节点中,都包含 prev、next 和 myval。其中,prev 指针用于指向前一个节点;next 指针用于指向后一个节点;myval 用于存储当前元素的值。下面是list的定义。

1
2
3
4
5
6
7
8
template <class T,...>
class list
{
//...
//指向链表的头节点,并不存放数据
__list_node<T>* node;
//...以下还有list 容器的构造函数以及很多操作函数
}

18. map的数组模式(operator[])插入和insert()插入的区别.

如果一个key存在, operator[] 对这个key-value进行重写

如果一个key存在, insert 不会对原来的key-value进行重写

19. 讲一下<algorithm>中的sort原理。

STL的sort采用了快速排序、插入排序和堆排序。根据数据量大小选择合适的算法:

  • 当数据量较大,采用快速排序,分段递归;
    • 一旦分段后的数据量小于一个阈值,改为插入排序。
    • 为避免递归深度过深,达到一定递归深度采用堆排序。

img

sort原理示意

20. STL的原理及实现

1、容器(Containers):各种数据结构,如:序列式容器vector、list、deque、关联式容器set、map、multiset、multimap。用来存放数据。从实现的角度来看,STL容器是一种class template。

2、算法(algorithms):各种常用算法,如:sort、search、copy、erase。从实现的角度来看,STL算法是一种 function template。注意一个问题:任何的一个STL算法,都需要获得由一对迭代器所标示的区间,用来表示操作范围。这一对迭代器所标示的区间都是前闭后开区间,例如[first, last)

3、迭代器(iterators):容器与算法之间的胶合剂,是所谓的“泛型指针”。共有五种类型,以及其他衍生变化。从实现的角度来看,迭代器是一种将 operator * 、operator->、operator++、operator- - 等指针相关操作进行重载的class template。所有STL容器都有自己专属的迭代器,只有容器本身才知道如何遍历自己的元素。原生指针(native pointer)也是一种迭代器。

4、仿函数(functors):行为类似函数,可作为算法的某种策略(policy)。从实现的角度来看,仿函数是一种重载了operator()的class或class template。一般的函数指针也可视为狭义的仿函数。

5、配接器(adapters):一种用来修饰容器、仿函数、迭代器接口的东西。例如:STL提供的queue 和 stack,虽然看似容器,但其实只能算是一种容器配接器,因为它们的底部完全借助deque,所有操作都由底层的deque供应。改变 functors接口者,称为function adapter;改变 container 接口者,称为container adapter;改变iterator接口者,称为iterator adapter。

6、配置器(allocators):负责空间配置与管理。从实现的角度来看,配置器是一个实现了动态空间配置、空间管理、空间释放的class template。

这六大组件的交互关系:container(容器) 通过 allocator(配置器) 取得数据储存空间,algorithm(算法)通过 iterator(迭代器)存取 container(容器) 内容,functor(仿函数) 可以协助 algorithm(算法) 完成不同的策略变化,adapter(配接器) 可以修饰或套接 functor(仿函数)

序列式容器:
vector-数组,元素不够时再重新分配内存,拷贝原来数组的元素到新分配的数组中。 list-单链表。 deque-分配中央控制器map(并非map容器),map记录着一系列的固定长度的数组的地址.记住这个map仅仅保存的是数组的地址,真正的数据在数组中存放着.deque先从map中央的位置(因为双向队列,前后都可以插入元素)找到一个数组地址,向该数组中放入数据,数组不够时继续在map中找空闲的数组来存数据。当map也不够时重新分配内存当作新的map,把原来map中的内容copy的新map中。所以使用deque的复杂度要大于vector,尽量使用vector。

stack-基于deque。 queue-基于deque。 heap-完全二叉树,使用最大堆排序,以数组(vector)的形式存放。 priority_queue-基于heap。 slist-双向链表。

关联式容器
set,map,multiset,multimap-基于红黑树(RB-tree),一种加上了额外平衡条件的二叉搜索树。

hash table-散列表。将待存数据的key经过映射函数变成一个数组(一般是vector)的索引,例如:数据的key%数组的大小=数组的索引(一般文本通过算法也可以转换为数字),然后将数据当作此索引的数组元素。有些数据的key经过算法的转换可能是同一个数组的索引值(碰撞问题,可以用线性探测,二次探测来解决),STL是用开链的方法来解决的,每一个数组的元素维护一个list,他把相同索引值的数据存入一个list,这样当list比较短时执行删除,插入,搜索等算法比较快。

hash_map,hash_set,hash_multiset,hash_multimap-基于hashtable。

什么是“标准非STL容器”?

list和vector有什么区别?
vector拥有一段连续的内存空间,因此支持随机存取,如果需要高效的随即存取,而不在乎插入和删除的效率,使用vector。 list拥有一段不连续的内存空间,因此不支持随机存取,如果需要大量的插入和删除,而不关心随即存取,则应使用list。

21. 迭代器失效的情况

插入操作:

对于vector和string,如果容器内存被重新分配,iterators,pointers,references失效;如果没有重新分配,那么插入点之前的iterator有效,插入点之后的iterator失效;

对于deque,如果插入点位于除front和back的其它位置,iterators,pointers,references失效;当我们插入元素到front和back时,deque的迭代器失效,但reference和pointers有效;

对于list和forward_list,所有的iterator,pointer和refercnce有效。删除操作:

对于vector和string,删除点之前的iterators,pointers,references有效;off-the-end迭代器总是失效的;

对于deque,如果删除点位于除front和back的其它位置,iterators,pointers,references失效;当我们插入元素到front和back时,off-the-end失效,其他的iterators,pointers,references有效;

对于list和forward_list,所有的iterator,pointer和refercnce有效。

对于关联容器map来说,如果某一个元素已经被删除,那么其对应的迭代器就失效了,不应该再被使用,否则会导致程序无定义的行为

22. STL线程不安全的情况

在对同一个容器进行多线程的读写、写操作时;
在每次调用容器的成员函数期间都要锁定该容器;
在每个容器返回的迭代器(例如通过调用begin或end)的生存期之内都要锁定该容器;
在每个在容器上调用的算法执行期间锁定该容器

23. 正确释放vector的内存(clear(), swap(), shrink_to_fit())

1
2
3
4
vec.clear() //清空内容,但是不释放内存。
vector.swap(vec) //清空内容,且释放内存,想得到一个全新的vector。
vec.shrink_to_fit() //请求容器降低其capacity和size匹配。
vec.clear();vec.shrink_to_fit(); //清空内容,且释放内存。

五、并发编程

1. C++线程中的几种锁机制

线程之间的锁有:互斥锁、条件锁、自旋锁、读写锁、递归锁。一般而言,锁的功能越强大,性能就会越低。

1)互斥锁

互斥锁用于控制多个线程对他们之间共享资源互斥访问的一个信号量。也就是说是为了避免多个线程在某一时刻同时操作一个共享资源。例如线程池中的有多个空闲线程和一个任务队列。任何是一个线程都要使用互斥锁互斥访问任务队列,以避免多个线程同时访问任务队列以发生错乱。

在某一时刻,只有一个线程可以获取互斥锁,在释放互斥锁之前其他线程都不能获取该互斥锁。如果其他线程想要获取这个互斥锁,那么这个线程只能以阻塞方式进行等待。

头文件:<pthread.h>

类型:pthread_mutex_t,

函数:

1
2
3
4
5
6
7
8
pthread_mutex_init(pthread_mutex_t * mutex, const phtread_mutexattr_t * mutexattr);//动态方式创建锁,相当于new动态创建一个对象
pthread_mutex_destory(pthread_mutex_t *mutex)//释放互斥锁,相当于delete
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//以静态方式创建锁
pthread_mutex_lock(pthread_mutex_t *mutex)//以阻塞方式运行的。如果之前mutex被加锁了,那么程序会阻塞在这里。
pthread_mutex_unlock(pthread_mutex_t *mutex)
int pthread_mutex_trylock(pthread_mutex_t * mutex);
//会尝试对mutex加锁。如果mutex之前已经被锁定,返回非0,;如果mutex没有被锁定,则函数返回并锁定mutex
//该函数是以非阻塞方式运行了。也就是说如果mutex之前已经被锁定,函数会返回非0,程序继续往下执行。

2)条件锁

条件锁就是所谓的条件变量,某一个线程因为某个条件为满足时可以使用条件变量使改程序处于阻塞状态。一旦条件满足以“信号量”的方式唤醒一个因为该条件而被阻塞的线程。最为常见就是在线程池中,起初没有任务时任务队列为空,此时线程池中的线程因为“任务队列为空”这个条件处于阻塞状态。一旦有任务进来,就会以信号量的方式唤醒一个线程来处理这个任务。这个过程中就使用到了条件变量pthread_cond_t。

头文件:<pthread.h>

类型:pthread_cond_t

函数:

1
2
3
4
5
6
7
pthread_cond_init(pthread_cond_t * condtion, const phtread_condattr_t * condattr);//对条件变量进行动态初始化,相当于new创建对象
pthread_cond_destory(pthread_cond_t * condition);//释放动态申请的条件变量,相当于delete释放对象
pthread_cond_t condition = PTHREAD_COND_INITIALIZER;//静态初始化条件变量
pthread_cond_wait(pthread_cond_t * cond, pthread_mutex_t * mutex);//该函数以阻塞方式执行。如果某个线程中的程序执行了该函数,那么这个线程就会以阻塞方式等待,直到收到pthread_cond_signal或者pthread_cond_broadcast函数发来的信号而被唤醒。
pthread_cond_signal(pthread_cond_t * cond);//在另外一个线程中改变线程,条件满足发送信号。唤醒一个等待的线程(可能有多个线程处于阻塞状态),唤醒哪个线程由具体的线程调度策略决定
pthread_cond_broadcast(pthread_cond_t * cond);//以广播形式唤醒所有因为该条件变量而阻塞的所有线程,唤醒哪个线程由具体的线程调度策略决定
pthread_cond_timedwait(pthread_cond_t * cond, pthread_mutex_t * mutex, struct timespec * time);//以阻塞方式等待,如果时间time到了条件还没有满足还是会结束

注意:pthread_cond_wait函数的语义相当于:首先解锁互斥锁,然后以阻塞方式等待条件变量的信号,收到信号后又会对互斥锁加锁。为了防止“虚假唤醒”,该函数一般放在while循环体中。例如

1
2
3
4
5
6
7
pthread_mutex_lock(mutex);//加互斥锁
while(条件不成立)//当前线程中条件变量不成立
{
pthread_cond_wait(cond, mutex);//解锁,其他线程使条件成立发送信号,加锁。
}
...//对进程之间的共享资源进行操作
pthread_mutex_unlock(mutex);//释放互斥锁

3)自旋锁

前面的两种锁是比较常见的锁,也比较容易理解。下面通过比较互斥锁和自旋锁原理的不同,这对于真正理解自旋锁有很大帮助。

假设我们有一个两个处理器core1和core2计算机,现在在这台计算机上运行的程序中有两个线程:T1和T2分别在处理器core1和core2上运行,两个线程之间共享着一个资源。

首先我们说明互斥锁的工作原理,互斥锁是是一种sleep-waiting的锁。假设线程T1获取互斥锁并且正在core1上运行时,此时线程T2也想要获取互斥锁(pthread_mutex_lock),但是由于T1正在使用互斥锁使得T2被阻塞。当T2处于阻塞状态时,T2被放入到等待队列中去,处理器core2会去处理其他任务而不必一直等待(忙等)。也就是说处理器不会因为线程阻塞而空闲着,它去处理其他事务去了。

而自旋锁就不同了,自旋锁是一种busy-waiting的锁。也就是说,如果T1正在使用自旋锁,而T2也去申请这个自旋锁,此时T2肯定得不到这个自旋锁。与互斥锁相反的是,此时运行T2的处理器core2会一直不断地循环检查锁是否可用(自旋锁请求),直到获取到这个自旋锁为止。

从“自旋锁”的名字也可以看出来,如果一个线程想要获取一个被使用的自旋锁,那么它会一致占用CPU请求这个自旋锁使得CPU不能去做其他的事情,直到获取这个锁为止,这就是“自旋”的含义。

当发生阻塞时,互斥锁可以让CPU去处理其他的任务;而自旋锁让CPU一直不断循环请求获取这个锁。通过两个含义的对比可以我们知道“自旋锁”是比较耗费CPU的

头文件:<linux\spinlock.h>

自旋锁的类型:spinlock_t

相关函数:

1
2
3
4
5
spin_lock_init(spinlock_t *x); // 初始化
spin_lock(x); //只有在获得锁的情况下才返回,否则一直“自旋”
spin_trylock(x); //如立即获得锁则返回真,否则立即返回假
spin_unlock(x); //释放锁:
spin_is_locked(x)//  该宏用于判断自旋锁x是否已经被某执行单元保持(即被锁),如果是, 返回真,否则返回假。

注意:自旋锁适合于短时间的的轻量级的加锁机制。

4)读写锁

说到读写锁我们可以借助于“读者-写者”问题进行理解。首先我们简单说下“读者-写者”问题。

计算机中某些数据被多个进程共享,对数据库的操作有两种:一种是读操作,就是从数据库中读取数据不会修改数据库中内容;另一种就是写操作,写操作会修改数据库中存放的数据。因此可以得到我们允许在数据库上同时执行多个“读”操作,但是某一时刻只能在数据库上有一个“写”操作来更新数据。这就是一个简单的读者-写者模型。

2.请你来说一下 fork 函数
考察点:STL
参考回答:
Fork:创建一个和当前进程映像一样的进程可以通过 fork( )系统调用:

1
2
3
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

成功调用 fork( )会创建一个新的进程,它几乎与调用 fork( )的进程一模一样,这两个进程都会继续运行。在子进程中,成功的 fork( )调用会返回 0。在父进程中 fork( )返回子进程的 pid。如果出现错误,fork( )返回一个负值。
最常见的 fork( )用法是创建一个新的进程,然后使用 exec( )载入二进制映像,替换当前进程的映像。这种情况下,派生(fork)了新的进程,而这个子进程会执行一个新的二进制可执行文件的映像。这种“派生加执行”的方式是很常见的。
在早期的 Unix 系统中,创建进程比较原始。当调用 fork 时,内核会把所有的内部数据结构复制一份,复制进程的页表项,然后把父进程的地址空间中的内容逐页的复制到子进程的地址空间中。但从内核角度来说,逐页的复制方式是十分耗时的。现代的 Unix 系统采取了更多的优化,例如 Linux,采用了写时复制的方法,而不是对父进程空间进程整体复制。

3. 请你说说 fork,wait,exec 函数
参考回答:
父进程产生子进程使用 fork 拷贝出来一个父进程的副本,此时只拷贝了父进程的页表,两个进程都读同一块内存,当有进程写的时候使用写实拷贝机制分配内存,exec 函数可以加载一个 elf文件去替换父进程,从此父进程和子进程就可以运行不同的程序了。fork 从父进程返回子进程的 pid,从子进程返回 0.调用了 wait 的父进程将会发生阻塞,直到有子进程状态改变,执行成功返回 0,错误返回-1。

exec 执行成功则子进程从新的程序开始运行,无返回值,执行失败返回-1

六、其他

1. C++ 11有哪些新特性

C++11不仅包含核心语言的新机能,而且扩展了C++的标准程序库(STL),并入了大部分的C++ Technical Report 1(TR1)程序库。C++11包括大量的新特性:包括lambda表达式,类型推导关键字auto、decltype,和模板的大量改进。

auto

C++11中引入auto第一种作用是为了自动类型推导

auto的自动类型推导,用于从初始化表达式中推断出变量的数据类型。通过auto的自动类型推导,可以大大简化我们的编程工作

decltype

decltype实际上有点像auto的反函数,auto可以让你声明一个变量,而decltype则可以从一个变量或表达式中得到类型,有实例如下:

nullptr

nullptr是为了解决原来C++中NULL的二义性问题而引进的一种新的类型,因为NULL实际上代表的是0,

lambda表达式类似Javascript中的闭包,它可以用于创建并定义匿名的函数对象,以简化编程工作。Lambda的语法如下:

[函数对象参数](操作符重载函数参数)mutable

exception声明->返回值类型{函数体}

2. C++类型安全有什么特点。

C++比C有更高的安全性,这体现在:

  1. 操作符new返回的对象类型严格与对象匹配,而不是void*;
  2. C++模板支持类型检查
  3. 引入了常量const来替代宏定义#define,#define只是简单的文本替换,不支持类型检查
  4. 一些#define宏可以被改写为inline函数,可以在类型安全的前提下支持多种类型
  5. C++提供dynamic_cast,它比static_cast有更多类型检查。

3. C++泛型和模板了解吗。

泛型可以独立于任何特定参数类型进行编程,模板是泛型编程的基础。比如:

  1. 函数模板
1
2
template<typename T>
void func(T a){};
  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
template <typename Type>
class Queue
{
public:
Queue();
Type & front();
const Type & front() const;
void push(const Type &);
void pop();
bool empty() const;
private:
// …
};
//指定默认参数
template<typename T = int, typename Y = char> // 此处指定了模板默认参数,部分指定必须从右到左指定
class Test {
public:
Test(T t, Y y) : t(t), y(y) {
}
void tfunc();
private:
T t;
Y y;
};

模板可以传入形参吗?

可以。模板传入的参数被称为非类型实参。例如template<typename T, int MAXSIZE> ,非类型实参在模板内部被定义为常量值。

C++泛型的原理清楚吗?

泛型的核心是模板。模板是将一个定义里面的类型参数化出来,是宏的改进版本。宏不进行任何变量类型检查,仅仅进行文本替换,这样就可能造成那种难以发现的错误。

下面是两个例子,来描述泛型编程的好处:

1
2
3
4
5
6
7
//不用泛型
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
//使用泛型
template<class RandomAccessIterator, class Compare>
void sort(RandomAccessIterator first, RandomAccessIterator last,
Compare comp);
  1. 类型安全性:如果你调用std::sort(arr, arr + n, comp);那么comp的类型就必须要和arr的数组元素类型一致,否则编译器就会帮你检查出来。而且comp的参数类型再也不用const void*这种不直观的表示了,而是可以直接声明为对应的数组元素的类型。
  2. 通用性:这个刚才已经说过了。泛型的核心目的之一就是通用性。std::sort可以用于一切迭代器,其compare函数可以是一切支持函数调用语法的对象。如果你想要将std::sort用在你自己的容器上的话,你只要定义一个自己的迭代器类(严格来说是一个随机访问迭代器,STL对迭代器的访问能力有一些分类,随机访问迭代器具有建模的内建指针的访问能力),如果需要的话,再定义一个自己的仿函数类即可。
  3. 接口直观性:跟qsort相比,std::sort的使用接口上没有多余的东西,也没有不直观的size参数。一个有待排序的区间,一个代表比较标准的仿函数,仅此而已[4]。
  4. 效率:如果你传给std::sort的compare函数是一个自定义了operator()的仿函数。那么编译器就能够利用类型信息,将对该仿函数的operatpr()调用直接内联。消除函数调用开销。

关于模板更细致的讨论参见:C++ 模板详解 | 菜鸟教程

4. C++有什么优化方法。

宏优化。也就是:

O1优化会消耗少多的编译时间,它主要对代码的分支,常量以及表达式等进行优化。

O2会尝试更多的寄存器级的优化以及指令级的优化,它会在编译期间占用更多的内存和编译时间。

O3在O2的基础上进行更多的优化,例如使用伪寄存器网络,普通函数的内联,以及针对循环的更多优化。

Os主要是对代码大小的优化,我们基本不用做更多的关心。 通常各种优化都会打乱程序的结构,让调试工作变得无从着手。并且会打乱执行顺序,依赖内存操作顺序的程序需要做相关处理才能确保程序的正确性。

-O0: 不做任何优化,这是默认的编译选项。

-O和-O1: 对程序做部分编译优化,对于大函数,优化编译占用稍微多的时间和相当大的内存。使用本项优化,编译器会尝试减小生成代码的尺寸,以及缩短执行时间,但并不执行需要占用大量编译时间的优化。 打开的优化选项:

O2优化能使程序的编译效率大大提升。

从而减少程序的运行时间,达到优化的效果。

C++程序中的O2开关如下所示:

1
#pragma GCC optimize(2)

同理O1、O3优化只需修改括号中的数即可。 只需将这句话放到程序的开头即可打开O2优化开关。

开启O3优化:

1
#pragma GCC optimize(3,"Ofast","inline")

此外还有防止文件被重复引用的

1
#pragma once

5. C++之父是谁?

C++之父是Bjarne Stroustrup

6. 请你来说一下共享内存相关 api

考察点:STL
参考回答:
Linux 允许不同进程访问同一个逻辑内存,提供了一组 API,头文件在 sys/shm.h 中。
1)新建共享内存 shmget
int shmget(key_t key,size_t size,int shmflg);
key:共享内存键值,可以理解为共享内存的唯一性标记。
size:共享内存大小
shmflag:创建进程和其他进程的读写权限标识。
返回值:相应的共享内存标识符,失败返回-1
2)连接共享内存到当前进程的地址空间 shmat
void *shmat(int shm_id,const void *shm_addr,int shmflg);
shm_id:共享内存标识符
shm_addr:指定共享内存连接到当前进程的地址,通常为 0,表示由系统来选择。
shmflg:标志位
返回值:指向共享内存第一个字节的指针,失败返回-1
3)当前进程分离共享内存 shmdt
int shmdt(const void *shmaddr);
4)控制共享内存 shmctl
和信号量的 semctl 函数类似,控制共享内存
int shmctl(int shm_id,int command,struct shmid_ds *buf);
shm_id:共享内存标识符
command: 有三个值

  • IPC_STAT:获取共享内存的状态,把共享内存的 shmid_ds 结构复制到 buf 中。
  • IPC_SET:设置共享内存的状态,把 buf 复制到共享内存的 shmid_ds 结构。
  • IPC_RMID:删除共享内存

buf:共享内存管理结构体。

7. 请你来说一下 reactor 模型组成

参考回答:
reactor 模型要求主线程只负责监听文件描述上是否有事件发生,有的话就立即将该事件通知工作线程,除此之外,主线程不做任何其他实质性的工作,读写数据、接受新的连接以及处理客户请求均在工作线程中完成。其模型组成如下:

img

1)Handle:即操作系统中的句柄,是对资源在操作系统层面上的一种抽象,它可以是打开的文件、一个连接(Socket)、Timer 等。由于 Reactor 模式一般使用在网络编程中,因而这里一般指 Socket Handle,即一个网络连接。

2)Synchronous Event Demultiplexer(同步事件复用器):阻塞等待一系列的 Handle 中的事件到来,如果阻塞等待返回,即表示在返回的 Handle 中可以不阻塞的执行返回的事件类型。这个模块一般使用操作系统的 select 来实现。

3)Initiation Dispatcher:用于管理 Event Handler,即 EventHandler 的容器,用以注册、移除 EventHandler 等;另外,它还作为 Reactor 模式的入口调用 Synchronous Event Demultiplexer 的 select 方法以阻塞等待事件返回,当阻塞等待返回时,根据事件发生的 Handle将其分发给对应的 Event Handler 处理,即回调 EventHandler 中的 handle_event()方法。

4)Event Handler:定义事件处理方法:handle_event(),以供 InitiationDispatcher 回调使用。

5)Concrete Event Handler:事件 EventHandler 接口,实现特定事件处理逻辑。

8. 请自己设计一下如何采用单线程的方式处理高并发

考点:I/O 复用 异步回调
参考回答:

在单线程模型中处理高并发可以采用以下策略:

  1. I/O 复用:使用 I/O 复用技术,例如 selectpollepoll(在Linux下)或者 kqueue(在BSD和macOS下),来监听多个套接字(sockets)或文件描述符(file descriptors)。这样,单线程可以同时监视多个输入/输出事件,而不需要阻塞等待每个连接的数据或请求。
  2. 事件驱动模型:建立一个事件循环,通过监听到的事件触发回调函数来处理请求。这是异步处理的基础。当有数据可读或可写时,相应的回调函数将被调用。
  3. 非阻塞操作:确保所有的 I/O 操作都是非阻塞的,这样当没有数据可读或可写时,线程不会被阻塞,而是可以继续处理其他任务。这可以通过设置套接字或文件描述符为非阻塞模式来实现。
  4. 回调函数:使用回调函数来处理事件。当一个事件发生时,相应的回调函数会被调用来处理该事件,而不是等待阻塞式的响应。这允许单线程同时处理多个请求,提高了并发性能。
  5. 定时器:引入定时器机制,以便能够处理超时事件或周期性任务。这有助于管理连接超时或执行定期清理任务。
  6. 资源池:为了提高效率,可以使用资源池来管理数据库连接、线程池、或其他需要复用的资源,以减少资源的频繁创建和销毁。
  7. 错误处理:在异步回调模型中,要特别注意错误处理,确保异常情况能够被捕获并适当地处理,以防止系统崩溃或不稳定。
  8. 性能优化:对于高并发场景,需要不断优化算法和数据结构,以提高单线程的处理效率。

综上所述,采用单线程的方式处理高并发可以借助 I/O 复用、异步回调和事件驱动模型,以及其他性能优化策略来实现。这种方式可以在不引入多线程或多进程的情况下,有效地处理大量并发请求。

9. 请你说说 select,epoll 的区别,原理,性能,限制都说一说

1、IO 多路复用

IO 复用模型在阻塞 IO 模型上多了一个 select 函数,select 函数有一个参数是文件描述符集合,意思就是对这些的文件描述符进行循环监听,当某个文件描述符就绪的时候,就对这个文件描述符进行处理。

这种 IO 模型是属于阻塞的 IO。但是由于它可以对多个文件描述符进行阻塞监听,所以它的效率比阻塞 IO 模型高效。

img

IO 多路复用就是我们说的 select,poll,epoll。select/epoll 的好处就在于单个 process就可以同时处理多个网络连接的 IO。它的基本原理就是 select,poll,epoll 这个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。

当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会“监视”所有 select负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

I/O 多路复用和阻塞 I/O 其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (select 和 recvfrom),而 blocking IO 只调用了一个 system call (recvfrom)。但是,用 select 的优势在于它可以同时处理多个 connection。

所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在 IO multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select这个函数 block,而不是被 socket IO 给 block。

2、select

select:是最初解决 IO 阻塞问题的方法。用结构体 fd_set 来告诉内核监听多个文件描述符,该结构体被称为描述符集。由数组来维持哪些描述符被置位了。对结构体的操作封装在三个宏定义中。通过轮寻来查找是否有描述符要被处理。

存在的问题:

  1. 内置数组的形式使得 select 的最大文件数受限与 FD_SIZE;
  2. 每次调用 select 前都要重新初始化描述符集,将 fd 从用户态拷贝到内核态,每次调用
    select 后,都需要将 fd 从内核态拷贝到用户态;
  3. 轮寻排查当文件描述符个数很多时,效率很低;

3、poll

poll:通过一个可变长度的数组解决了 select 文件描述符受限的问题。数组中元素是结构体,该结构体保存描述符的信息,每增加一个文件描述符就向数组中加入一个结构体,结构体只需要拷贝一次到内核态。poll 解决了 select 重复初始化的问题。轮寻排查的问题未解决。

4、epoll

epoll:轮寻排查所有文件描述符的效率不高,使服务器并发能力受限。因此,epoll 采用只返回状态发生变化的文件描述符,便解决了轮寻的瓶颈。
epoll 对文件描述符的操作有两种模式:LT(level trigger)和 ET(edge trigger)。LT模式是默认模式

  1. LT 模式
    LT(level triggered)是缺省的工作方式,并且同时支持 block 和 no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的。
  2. ET 模式
    ET(edge-triggered)是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个 EWOULDBLOCK 错误)。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未
    就绪),内核不会发送更多的通知(only once)ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
  3. LT 模式与 ET 模式的区别如下:
    LT 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。
    ET 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。

七、链接装载库

本节部分知识点来自《程序员的自我修养——链接装载库》

1. 内存、栈、堆

一般应用程序内存空间有如下区域:

  • 栈:由操作系统自动分配释放,存放函数的参数值、局部变量等的值,用于维护函数调用的上下文
  • 堆:一般由程序员分配释放,若程序员不释放,程序结束时可能由操作系统回收,用来容纳应用程序动态分配的内存区域
  • 可执行文件映像:存储着可执行文件在内存中的映像,由装载器装载是将可执行文件的内存读取或映射到这里
  • 保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称,如通常 C 语言讲无效指针赋值为 0(NULL),因此 0 地址正常情况下不可能有效的访问数据

栈保存了一个函数调用所需要的维护信息,常被称为堆栈帧(Stack Frame)或活动记录(Activate Record),一般包含以下几方面:

  • 函数的返回地址和参数
  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
  • 保存上下文:包括函数调用前后需要保持不变的寄存器

堆分配算法:

  • 空闲链表(Free List)
  • 位图(Bitmap)
  • 对象池

“段错误(segment fault)” 或 “非法操作,该内存地址不能 read/write”

典型的非法指针解引用造成的错误。当指针指向一个不允许读写的内存地址,而程序却试图利用指针来读或写该地址时,会出现这个错误。

普遍原因:

  • 将指针初始化为 NULL,之后没有给它一个合理的值就开始使用指针
  • 没用初始化栈中的指针,指针的值一般会是随机数,之后就直接开始使用指针

2. 编译链接

各平台文件格式

平台 可执行文件 目标文件 动态库/共享对象 静态库
Windows exe obj dll lib
Unix/Linux ELF、out o so a
Mac Mach-O o dylib、tbd、framework a、framework

编译链接过程

  1. 预编译(预编译器处理如 #include#define 等预编译指令,生成 .i.ii 文件)
  2. 编译(编译器进行词法分析、语法分析、语义分析、中间代码生成、目标代码生成、优化,生成 .s 文件)
  3. 汇编(汇编器把汇编码翻译成机器码,生成 .o 文件)
  4. 链接(连接器进行地址和空间分配、符号决议、重定位,生成 .out 文件)

现在版本 GCC 把预编译和编译合成一步,预编译编译程序 cc1、汇编器 as、连接器 ld

MSVC 编译环境,编译器 cl、连接器 link、可执行文件查看器 dumpbin

目标文件

编译器编译源代码后生成的文件叫做目标文件。目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。

可执行文件(Windows 的 .exe 和 Linux 的 ELF)、动态链接库(Windows 的 .dll 和 Linux 的 .so)、静态链接库(Windows 的 .lib 和 Linux 的 .a)都是按照可执行文件格式存储(Windows 按照 PE-COFF,Linux 按照 ELF)

3. 目标文件格式

  • Windows 的 PE(Portable Executable),或称为 PE-COFF,.obj 格式
  • Linux 的 ELF(Executable Linkable Format),.o 格式
  • Intel/Microsoft 的 OMF(Object Module Format)
  • Unix 的 a.out 格式
  • MS-DOS 的 .COM 格式

PE 和 ELF 都是 COFF(Common File Format)的变种

4. 目标文件存储结构

功能
File Header 文件头,描述整个文件的文件属性(包括文件是否可执行、是静态链接或动态连接及入口地址、目标硬件、目标操作系统等)
.text section 代码段,执行语句编译成的机器代码
.data section 数据段,已初始化的全局变量和局部静态变量
.bss section BSS 段(Block Started by Symbol),未初始化的全局变量和局部静态变量(因为默认值为 0,所以只是在此预留位置,不占空间)
.rodata section 只读数据段,存放只读数据,一般是程序里面的只读变量(如 const 修饰的变量)和字符串常量
.comment section 注释信息段,存放编译器版本信息
.note.GNU-stack section 堆栈提示段

其他段略

链接的接口————符号

在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。

如下符号表(Symbol Table):

Symbol(符号名) Symbol Value (地址)
main 0x100
Add 0x123

5. Linux 的共享库(Shared Library)

Linux 下的共享库就是普通的 ELF 共享对象。

共享库版本更新应该保证二进制接口 ABI(Application Binary Interface)的兼容

命名

1
libname.so.x.y.z
  • x:主版本号,不同主版本号的库之间不兼容,需要重新编译
  • y:次版本号,高版本号向后兼容低版本号
  • z:发布版本号,不对接口进行更改,完全兼容

路径

大部分包括 Linux 在内的开源系统遵循 FHS(File Hierarchy Standard)的标准,这标准规定了系统文件如何存放,包括各个目录结构、组织和作用。

  • /lib:存放系统最关键和最基础的共享库,如动态链接器、C 语言运行库、数学库等
  • /usr/lib:存放非系统运行时所需要的关键性的库,主要是开发库
  • /usr/local/lib:存放跟操作系统本身并不十分相关的库,主要是一些第三方应用程序的库

动态链接器会在 /lib/usr/lib 和由 /etc/ld.so.conf 配置文件指定的,目录中查找共享库

环境变量

  • LD_LIBRARY_PATH:临时改变某个应用程序的共享库查找路径,而不会影响其他应用程序
  • LD_PRELOAD:指定预先装载的一些共享库甚至是目标文件
  • LD_DEBUG:打开动态链接器的调试功能

so 共享库的编写

使用 CLion 编写共享库

创建一个名为 MySharedLib 的共享库

CMakeLists.txt

1
2
3
4
5
6
cmake_minimum_required(VERSION 3.10)
project(MySharedLib)

set(CMAKE_CXX_STANDARD 11)

add_library(MySharedLib SHARED library.cpp library.h)

library.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef MYSHAREDLIB_LIBRARY_H
#define MYSHAREDLIB_LIBRARY_H

// 打印 Hello World!
void hello();

// 使用可变模版参数求和
template <typename T>
T sum(T t)
{
return t;
}
template <typename T, typename ...Types>
T sum(T first, Types ... rest)
{
return first + sum<T>(rest...);
}

#endif

library.cpp

1
2
3
4
5
6
#include <iostream>
#include "library.h"

void hello() {
std::cout << "Hello, World!" << std::endl;
}

so 共享库的使用(被可执行项目调用)

使用 CLion 调用共享库

创建一个名为 TestSharedLib 的可执行项目

CMakeLists.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cmake_minimum_required(VERSION 3.10)
project(TestSharedLib)

# C++11 编译
set(CMAKE_CXX_STANDARD 11)

# 头文件路径
set(INC_DIR /home/xx/code/clion/MySharedLib)
# 库文件路径
set(LIB_DIR /home/xx/code/clion/MySharedLib/cmake-build-debug)

include_directories(${INC_DIR})
link_directories(${LIB_DIR})
link_libraries(MySharedLib)

add_executable(TestSharedLib main.cpp)

# 链接 MySharedLib 库
target_link_libraries(TestSharedLib MySharedLib)

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include "library.h"
using std::cout;
using std::endl;

int main() {

hello();
cout << "1 + 2 = " << sum(1,2) << endl;
cout << "1 + 2 + 3 = " << sum(1,2,3) << endl;

return 0;
}

执行结果

1
2
3
Hello, World!
1 + 2 = 3
1 + 2 + 3 = 6

6. Windows 应用程序入口函数

  • GUI(Graphical User Interface)应用,链接器选项:/SUBSYSTEM:WINDOWS
  • CUI(Console User Interface)应用,链接器选项:/SUBSYSTEM:CONSOLE

_tWinMain 与 _tmain 函数声明

1
2
3
4
5
6
7
8
9
10
Int WINAPI _tWinMain(
HINSTANCE hInstanceExe,
HINSTANCE,
PTSTR pszCmdLine,
int nCmdShow);

int _tmain(
int argc,
TCHAR *argv[],
TCHAR *envp[]);
应用程序类型 入口点函数 嵌入可执行文件的启动函数
处理ANSI字符(串)的GUI应用程序 _tWinMain(WinMain) WinMainCRTSartup
处理Unicode字符(串)的GUI应用程序 _tWinMain(wWinMain) wWinMainCRTSartup
处理ANSI字符(串)的CUI应用程序 _tmain(Main) mainCRTSartup
处理Unicode字符(串)的CUI应用程序 _tmain(wMain) wmainCRTSartup
动态链接库(Dynamic-Link Library) DllMain _DllMainCRTStartup

7. Windows 的动态链接库(Dynamic-Link Library)

部分知识点来自《Windows 核心编程(第五版)》

用处

  • 扩展了应用程序的特性
  • 简化了项目管理
  • 有助于节省内存
  • 促进了资源的共享
  • 促进了本地化
  • 有助于解决平台间的差异
  • 可以用于特殊目的

注意

  • 创建 DLL,事实上是在创建可供一个可执行模块调用的函数
  • 当一个模块提供一个内存分配函数(malloc、new)的时候,它必须同时提供另一个内存释放函数(free、delete)
  • 在使用 C 和 C++ 混编的时候,要使用 extern “C” 修饰符
  • 一个 DLL 可以导出函数、变量(避免导出)、C++ 类(导出导入需要同编译器,否则避免导出)
  • DLL 模块:cpp 文件中的 __declspec(dllexport) 写在 include 头文件之前
  • 调用 DLL 的可执行模块:cpp 文件的 __declspec(dllimport) 之前不应该定义 MYLIBAPI

加载 Windows 程序的搜索顺序

  1. 包含可执行文件的目录
  2. Windows 的系统目录,可以通过 GetSystemDirectory 得到
  3. 16 位的系统目录,即 Windows 目录中的 System 子目录
  4. Windows 目录,可以通过 GetWindowsDirectory 得到
  5. 进程的当前目录
  6. PATH 环境变量中所列出的目录

DLL 入口函数

DllMain 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
switch(fdwReason)
{
case DLL_PROCESS_ATTACH:
// 第一次将一个DLL映射到进程地址空间时调用
// The DLL is being mapped into the process' address space.
break;
case DLL_THREAD_ATTACH:
// 当进程创建一个线程的时候,用于告诉DLL执行与线程相关的初始化(非主线程执行)
// A thread is bing created.
break;
case DLL_THREAD_DETACH:
// 系统调用 ExitThread 线程退出前,即将终止的线程通过告诉DLL执行与线程相关的清理
// A thread is exiting cleanly.
break;
case DLL_PROCESS_DETACH:
// 将一个DLL从进程的地址空间时调用
// The DLL is being unmapped from the process' address space.
break;
}
return (TRUE); // Used only for DLL_PROCESS_ATTACH
}

载入卸载库

LoadLibrary、LoadLibraryExA、LoadPackagedLibrary、FreeLibrary、FreeLibraryAndExitThread 函数声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 载入库
HMODULE WINAPI LoadLibrary(
_In_ LPCTSTR lpFileName
);
HMODULE LoadLibraryExA(
LPCSTR lpLibFileName,
HANDLE hFile,
DWORD dwFlags
);
// 若要在通用 Windows 平台(UWP)应用中加载 Win32 DLL,需要调用 LoadPackagedLibrary,而不是 LoadLibrary 或 LoadLibraryEx
HMODULE LoadPackagedLibrary(
LPCWSTR lpwLibFileName,
DWORD Reserved
);

// 卸载库
BOOL WINAPI FreeLibrary(
_In_ HMODULE hModule
);
// 卸载库和退出线程
VOID WINAPI FreeLibraryAndExitThread(
_In_ HMODULE hModule,
_In_ DWORD dwExitCode
);

显示地链接到导出符号

GetProcAddress 函数声明

1
2
3
4
FARPROC GetProcAddress(
HMODULE hInstDll,
PCSTR pszSymbolName // 只能接受 ANSI 字符串,不能是 Unicode
);

DumpBin.exe 查看 DLL 信息

VS 的开发人员命令提示符 使用 DumpBin.exe 可查看 DLL 库的导出段(导出的变量、函数、类名的符号)、相对虚拟地址(RVA,relative virtual address)。如:

1
DUMPBIN -exports D:\mydll.dll

LoadLibrary 与 FreeLibrary 流程图

LoadLibrary 与 FreeLibrary 流程图

8. LoadLibrary

image-20240122001813133

9. FreeLibrary

image-20240122001837847

DLL 库的编写(导出一个 DLL 模块)

DLL 库的编写(导出一个 DLL 模块) DLL 头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// MyLib.h

#ifdef MYLIBAPI

// MYLIBAPI 应该在全部 DLL 源文件的 include "Mylib.h" 之前被定义
// 全部函数/变量正在被导出

#else

// 这个头文件被一个exe源代码模块包含,意味着全部函数/变量被导入
#define MYLIBAPI extern "C" __declspec(dllimport)

#endif

// 这里定义任何的数据结构和符号

// 定义导出的变量(避免导出变量)
MYLIBAPI int g_nResult;

// 定义导出函数原型
MYLIBAPI int Add(int nLeft, int nRight);

DLL 源文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// MyLibFile1.cpp

// 包含标准Windows和C运行时头文件
#include <windows.h>

// DLL源码文件导出的函数和变量
#define MYLIBAPI extern "C" __declspec(dllexport)

// 包含导出的数据结构、符号、函数、变量
#include "MyLib.h"

// 将此DLL源代码文件的代码放在此处
int g_nResult;

int Add(int nLeft, int nRight)
{
g_nResult = nLeft + nRight;
return g_nResult;
}

DLL 库的使用(运行时动态链接 DLL)

DLL 库的使用(运行时动态链接 DLL)

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
// A simple program that uses LoadLibrary and 
// GetProcAddress to access myPuts from Myputs.dll.

#include <windows.h>
#include <stdio.h>

typedef int (__cdecl *MYPROC)(LPWSTR);

int main( void )
{
HINSTANCE hinstLib;
MYPROC ProcAdd;
BOOL fFreeResult, fRunTimeLinkSuccess = FALSE;

// Get a handle to the DLL module.

hinstLib = LoadLibrary(TEXT("MyPuts.dll"));

// If the handle is valid, try to get the function address.

if (hinstLib != NULL)
{
ProcAdd = (MYPROC) GetProcAddress(hinstLib, "myPuts");

// If the function address is valid, call the function.

if (NULL != ProcAdd)
{
fRunTimeLinkSuccess = TRUE;
(ProcAdd) (L"Message sent to the DLL function\n");
}
// Free the DLL module.

fFreeResult = FreeLibrary(hinstLib);
}

// If unable to call the DLL function, use an alternative.
if (! fRunTimeLinkSuccess)
printf("Message printed from executable\n");

return 0;
}

10. 运行库(Runtime Library)

典型程序运行步骤

  1. 操作系统创建进程,把控制权交给程序的入口(往往是运行库中的某个入口函数)
  2. 入口函数对运行库和程序运行环境进行初始化(包括堆、I/O、线程、全局变量构造等等)。
  3. 入口函数初始化后,调用 main 函数,正式开始执行程序主体部分。
  4. main 函数执行完毕后,返回到入口函数进行清理工作(包括全局变量析构、堆销毁、关闭I/O等),然后进行系统调用结束进程。

一个程序的 I/O 指代程序与外界的交互,包括文件、管程、网络、命令行、信号等。更广义地讲,I/O 指代操作系统理解为 “文件” 的事物。

glibc 入口

1
_start -> __libc_start_main -> exit -> _exit

其中 main(argc, argv, __environ) 函数在 __libc_start_main 里执行。

MSVC CRT 入口

1
int mainCRTStartup(void)

执行如下操作:

  1. 初始化和 OS 版本有关的全局变量。
  2. 初始化堆。
  3. 初始化 I/O。
  4. 获取命令行参数和环境变量。
  5. 初始化 C 库的一些数据。
  6. 调用 main 并记录返回值。
  7. 检查错误并将 main 的返回值返回。

C 语言运行库(CRT)

大致包含如下功能:

  • 启动与退出:包括入口函数及入口函数所依赖的其他函数等。
  • 标准函数:有 C 语言标准规定的C语言标准库所拥有的函数实现。
  • I/O:I/O 功能的封装和实现。
  • 堆:堆的封装和实现。
  • 语言实现:语言中一些特殊功能的实现。
  • 调试:实现调试功能的代码。

C语言标准库(ANSI C)

包含:

  • 标准输入输出(stdio.h)
  • 文件操作(stdio.h)
  • 字符操作(ctype.h)
  • 字符串操作(string.h)
  • 数学函数(math.h)
  • 资源管理(stdlib.h)
  • 格式转换(stdlib.h)
  • 时间/日期(time.h)
  • 断言(assert.h)
  • 各种类型上的常数(limits.h & float.h)
  • 变长参数(stdarg.h)
  • 非局部跳转(setjmp.h)

⭐️ 八、Effective

Effective C++

  1. 视 C++ 为一个语言联邦(C、Object-Oriented C++、Template C++、STL)
  2. 宁可以编译器替换预处理器(尽量以 constenuminline 替换 #define
  3. 尽可能使用 const
  4. 确定对象被使用前已先被初始化(构造时赋值(copy 构造函数)比 default 构造后赋值(copy assignment)效率高)
  5. 了解 C++ 默默编写并调用哪些函数(编译器暗自为 class 创建 default 构造函数、copy 构造函数、copy assignment 操作符、析构函数)
  6. 若不想使用编译器自动生成的函数,就应该明确拒绝(将不想使用的成员函数声明为 private,并且不予实现)
  7. 为多态基类声明 virtual 析构函数(如果 class 带有任何 virtual 函数,它就应该拥有一个 virtual 析构函数)
  8. 别让异常逃离析构函数(析构函数应该吞下不传播异常,或者结束程序,而不是吐出异常;如果要处理异常应该在非析构的普通函数处理)
  9. 绝不在构造和析构过程中调用 virtual 函数(因为这类调用从不下降至 derived class)
  10. operator= 返回一个 reference to *this (用于连锁赋值)
  11. operator= 中处理 “自我赋值”
  12. 赋值对象时应确保复制 “对象内的所有成员变量” 及 “所有 base class 成分”(调用基类复制构造函数)
  13. 以对象管理资源(资源在构造函数获得,在析构函数释放,建议使用智能指针,资源取得时机便是初始化时机(Resource Acquisition Is Initialization,RAII))
  14. 在资源管理类中小心 copying 行为(普遍的 RAII class copying 行为是:抑制 copying、引用计数、深度拷贝、转移底部资源拥有权(类似 auto_ptr))
  15. 在资源管理类中提供对原始资源(raw resources)的访问(对原始资源的访问可能经过显式转换或隐式转换,一般而言显示转换比较安全,隐式转换对客户比较方便)
  16. 成对使用 new 和 delete 时要采取相同形式(new 中使用 []delete []new 中不使用 []delete
  17. 以独立语句将 newed 对象存储于(置入)智能指针(如果不这样做,可能会因为编译器优化,导致难以察觉的资源泄漏)
  18. 让接口容易被正确使用,不易被误用(促进正常使用的办法:接口的一致性、内置类型的行为兼容;阻止误用的办法:建立新类型,限制类型上的操作,约束对象值、消除客户的资源管理责任)
  19. 设计 class 犹如设计 type,需要考虑对象创建、销毁、初始化、赋值、值传递、合法值、继承关系、转换、一般化等等。
  20. 宁以 pass-by-reference-to-const 替换 pass-by-value (前者通常更高效、避免切割问题(slicing problem),但不适用于内置类型、STL迭代器、函数对象)
  21. 必须返回对象时,别妄想返回其 reference(绝不返回 pointer 或 reference 指向一个 local stack 对象,或返回 reference 指向一个 heap-allocated 对象,或返回 pointer 或 reference 指向一个 local static 对象而有可能同时需要多个这样的对象。)
  22. 将成员变量声明为 private(为了封装、一致性、对其读写精确控制等)
  23. 宁以 non-member、non-friend 替换 member 函数(可增加封装性、包裹弹性(packaging flexibility)、机能扩充性)
  24. 若所有参数(包括被this指针所指的那个隐喻参数)皆须要类型转换,请为此采用 non-member 函数
  25. 考虑写一个不抛异常的 swap 函数
  26. 尽可能延后变量定义式的出现时间(可增加程序清晰度并改善程序效率)
  27. 尽量少做转型动作(旧式:(T)expressionT(expression);新式:const_cast<T>(expression)dynamic_cast<T>(expression)reinterpret_cast<T>(expression)static_cast<T>(expression)、;尽量避免转型、注重效率避免 dynamic_casts、尽量设计成无需转型、可把转型封装成函数、宁可用新式转型)
  28. 避免使用 handles(包括 引用、指针、迭代器)指向对象内部(以增加封装性、使 const 成员函数的行为更像 const、降低 “虚吊号码牌”(dangling handles,如悬空指针等)的可能性)
  29. 为 “异常安全” 而努力是值得的(异常安全函数(Exception-safe functions)即使发生异常也不会泄露资源或允许任何数据结构败坏,分为三种可能的保证:基本型、强列型、不抛异常型)
  30. 透彻了解 inlining 的里里外外(inlining 在大多数 C++ 程序中是编译期的行为;inline 函数是否真正 inline,取决于编译器;大部分编译器拒绝太过复杂(如带有循环或递归)的函数 inlining,而所有对 virtual 函数的调用(除非是最平淡无奇的)也都会使 inlining 落空;inline 造成的代码膨胀可能带来效率损失;inline 函数无法随着程序库的升级而升级)
  31. 将文件间的编译依存关系降至最低(如果使用 object references 或 object pointers 可以完成任务,就不要使用 objects;如果能够,尽量以 class 声明式替换 class 定义式;为声明式和定义式提供不同的头文件)
  32. 确定你的 public 继承塑模出 is-a(是一种)关系(适用于 base classes 身上的每一件事情一定适用于 derived classes 身上,因为每一个 derived class 对象也都是一个 base class 对象)
  33. 避免遮掩继承而来的名字(可使用 using 声明式或转交函数(forwarding functions)来让被遮掩的名字再见天日)
  34. 区分接口继承和实现继承(在 public 继承之下,derived classes 总是继承 base class 的接口;pure virtual 函数只具体指定接口继承;非纯 impure virtual 函数具体指定接口继承及缺省实现继承;non-virtual 函数具体指定接口继承以及强制性实现继承)
  35. 考虑 virtual 函数以外的其他选择(如 Template Method 设计模式的 non-virtual interface(NVI)手法,将 virtual 函数替换为 “函数指针成员变量”,以 tr1::function 成员变量替换 virtual 函数,将继承体系内的 virtual 函数替换为另一个继承体系内的 virtual 函数)
  36. 绝不重新定义继承而来的 non-virtual 函数
  37. 绝不重新定义继承而来的缺省参数值,因为缺省参数值是静态绑定(statically bound),而 virtual 函数却是动态绑定(dynamically bound)
  38. 通过复合塑模 has-a(有一个)或 “根据某物实现出”(在应用域(application domain),复合意味 has-a(有一个);在实现域(implementation domain),复合意味着 is-implemented-in-terms-of(根据某物实现出))
  39. 明智而审慎地使用 private 继承(private 继承意味着 is-implemented-in-terms-of(根据某物实现出),尽可能使用复合,当 derived class 需要访问 protected base class 的成员,或需要重新定义继承而来的时候 virtual 函数,或需要 empty base 最优化时,才使用 private 继承)
  40. 明智而审慎地使用多重继承(多继承比单一继承复杂,可能导致新的歧义性,以及对 virtual 继承的需要,但确有正当用途,如 “public 继承某个 interface class” 和 “private 继承某个协助实现的 class”;virtual 继承可解决多继承下菱形继承的二义性问题,但会增加大小、速度、初始化及赋值的复杂度等等成本)
  41. 了解隐式接口和编译期多态(class 和 templates 都支持接口(interfaces)和多态(polymorphism);class 的接口是以签名为中心的显式的(explicit),多态则是通过 virtual 函数发生于运行期;template 的接口是奠基于有效表达式的隐式的(implicit),多态则是通过 template 具现化和函数重载解析(function overloading resolution)发生于编译期)
  42. 了解 typename 的双重意义(声明 template 类型参数是,前缀关键字 class 和 typename 的意义完全相同;请使用关键字 typename 标识嵌套从属类型名称,但不得在基类列(base class lists)或成员初值列(member initialization list)内以它作为 base class 修饰符)
  43. 学习处理模板化基类内的名称(可在 derived class templates 内通过 this-> 指涉 base class templates 内的成员名称,或藉由一个明白写出的 “base class 资格修饰符” 完成)
  44. 将与参数无关的代码抽离 templates(因类型模板参数(non-type template parameters)而造成代码膨胀往往可以通过函数参数或 class 成员变量替换 template 参数来消除;因类型参数(type parameters)而造成的代码膨胀往往可以通过让带有完全相同二进制表述(binary representations)的实现类型(instantiation types)共享实现码)
  45. 运用成员函数模板接受所有兼容类型(请使用成员函数模板(member function templates)生成 “可接受所有兼容类型” 的函数;声明 member templates 用于 “泛化 copy 构造” 或 “泛化 assignment 操作” 时还需要声明正常的 copy 构造函数和 copy assignment 操作符)
  46. 需要类型转换时请为模板定义非成员函数(当我们编写一个 class template,而它所提供之 “与此 template 相关的” 函数支持 “所有参数之隐式类型转换” 时,请将那些函数定义为 “class template 内部的 friend 函数”)
  47. 请使用 traits classes 表现类型信息(traits classes 通过 templates 和 “templates 特化” 使得 “类型相关信息” 在编译期可用,通过重载技术(overloading)实现在编译期对类型执行 if…else 测试)
  48. 认识 template 元编程(模板元编程(TMP,template metaprogramming)可将工作由运行期移往编译期,因此得以实现早期错误侦测和更高的执行效率;TMP 可被用来生成 “给予政策选择组合”(based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码)
  49. 了解 new-handler 的行为(set_new_handler 允许客户指定一个在内存分配无法获得满足时被调用的函数;nothrow new 是一个颇具局限的工具,因为它只适用于内存分配(operator new),后继的构造函数调用还是可能抛出异常)
  50. 了解 new 和 delete 的合理替换时机(为了检测运用错误、收集动态分配内存之使用统计信息、增加分配和归还速度、降低缺省内存管理器带来的空间额外开销、弥补缺省分配器中的非最佳齐位、将相关对象成簇集中、获得非传统的行为)
  51. 编写 new 和 delete 时需固守常规(operator new 应该内涵一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就应该调用 new-handler,它也应该有能力处理 0 bytes 申请,class 专属版本则还应该处理 “比正确大小更大的(错误)申请”;operator delete 应该在收到 null 指针时不做任何事,class 专属版本则还应该处理 “比正确大小更大的(错误)申请”)
  52. 写了 placement new 也要写 placement delete(当你写一个 placement operator new,请确定也写出了对应的 placement operator delete,否则可能会发生隐微而时断时续的内存泄漏;当你声明 placement new 和 placement delete,请确定不要无意识(非故意)地遮掩了它们地正常版本)
  53. 不要轻忽编译器的警告
  54. 让自己熟悉包括 TR1 在内的标准程序库(TR1,C++ Technical Report 1,C++11 标准的草稿文件)
  55. 让自己熟悉 Boost(准标准库)

More Effective c++

  1. 仔细区别 pointers 和 references(当你知道你需要指向某个东西,而且绝不会改变指向其他东西,或是当你实现一个操作符而其语法需求无法由 pointers 达成,你就应该选择 references;任何其他时候,请采用 pointers)
  2. 最好使用 C++ 转型操作符(static_castconst_castdynamic_castreinterpret_cast
  3. 绝不要以多态(polymorphically)方式处理数组(多态(polymorphism)和指针算术不能混用;数组对象几乎总是会涉及指针的算术运算,所以数组和多态不要混用)
  4. 非必要不提供 default constructor(避免对象中的字段被无意义地初始化)
  5. 对定制的 “类型转换函数” 保持警觉(单自变量 constructors 可通过简易法(explicit 关键字)或代理类(proxy classes)来避免编译器误用;隐式类型转换操作符可改为显式的 member function 来避免非预期行为)
  6. 区别 increment/decrement 操作符的前置(prefix)和后置(postfix)形式(前置式累加后取出,返回一个 reference;后置式取出后累加,返回一个 const 对象;处理用户定制类型时,应该尽可能使用前置式 increment;后置式的实现应以其前置式兄弟为基础)
  7. 千万不要重载 &&||, 操作符(&&|| 的重载会用 “函数调用语义” 取代 “骤死式语义”;, 的重载导致不能保证左侧表达式一定比右侧表达式更早被评估)
  8. 了解各种不同意义的 new 和 delete(new operatoroperator newplacement newoperator new[]delete operatoroperator deletedestructoroperator delete[]
  9. 利用 destructors 避免泄漏资源(在 destructors 释放资源可以避免异常时的资源泄漏)
  10. 在 constructors 内阻止资源泄漏(由于 C++ 只会析构已构造完成的对象,因此在构造函数可以使用 try…catch 或者 auto_ptr(以及与之相似的 classes) 处理异常时资源泄露问题)
  11. 禁止异常流出 destructors 之外(原因:一、避免 terminate 函数在 exception 传播过程的栈展开(stack-unwinding)机制种被调用;二、协助确保 destructors 完成其应该完成的所有事情)
  12. 了解 “抛出一个 exception” 与 “传递一个参数” 或 “调用一个虚函数” 之间的差异(第一,exception objects 总是会被复制(by pointer 除外),如果以 by value 方式捕捉甚至被复制两次,而传递给函数参数的对象则不一定得复制;第二,“被抛出成为 exceptions” 的对象,其被允许的类型转换动作比 “被传递到函数去” 的对象少;第三,catch 子句以其 “出现于源代码的顺序” 被编译器检验对比,其中第一个匹配成功者便执行,而调用一个虚函数,被选中执行的是那个 “与对象类型最佳吻合” 的函数)
  13. 以 by reference 方式捕获 exceptions(可避免对象删除问题、exception objects 的切割问题,可保留捕捉标准 exceptions 的能力,可约束 exception object 需要复制的次数)
  14. 明智运用 exception specifications(exception specifications 对 “函数希望抛出什么样的 exceptions” 提供了卓越的说明;也有一些缺点,包括编译器只对它们做局部性检验而很容易不经意地违反,与可能会妨碍更上层的 exception 处理函数处理未预期的 exceptions)
  15. 了解异常处理的成本(粗略估计,如果使用 try 语句块,代码大约整体膨胀 5%-10%,执行速度亦大约下降这个数;因此请将你对 try 语句块和 exception specifications 的使用限制于非用不可的地点,并且在真正异常的情况下才抛出 exceptions)
  16. 谨记 80-20 法则(软件的整体性能几乎总是由其构成要素(代码)的一小部分决定的,可使用程序分析器(program profiler)识别出消耗资源的代码)
  17. 考虑使用 lazy evaluation(缓式评估)(可应用于:Reference Counting(引用计数)来避免非必要的对象复制、区分 operator[] 的读和写动作来做不同的事情、Lazy Fetching(缓式取出)来避免非必要的数据库读取动作、Lazy Expression Evaluation(表达式缓评估)来避免非必要的数值计算动作)
  18. 分期摊还预期的计算成本(当你必须支持某些运算而其结构几乎总是被需要,或其结果常常被多次需要的时候,over-eager evaluation(超急评估)可以改善程序效率)

Google C++ Style Guide

其他