当前位置:   article > 正文

C++基础语法_c++ 语法#ifndef

c++ 语法#ifndef

阿秀的学习笔记:https://interviewguide.cn/

C++基础语法

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

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

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

main函数执行之后

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

update1:https://github.com/forthespada/InterviewGuide/issues/2 ,由stanleyguo0207提出 - 2021.03.22

2、结构体内存对齐问题?

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

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

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

// alignas 生效的情况

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

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

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

std::cout << sizeof(Info2) << std::endl;   // 8  4 + 4
std::cout << alignof(Info2) << std::endl;  // 4
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

alignas将内存对齐调整为4个字节。所以sizeof(Info2)的值变为了8。

// alignas 失效的情况

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

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

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

std::cout << sizeof(Info2) << std::endl;   // 12  4 + 4 + 4
std::cout << alignof(Info2) << std::endl;  // 4
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

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

  • 如果想使用单字节对齐的方式,使用alignas是无效的。应该使用#pragma pack(push,1)或者使用__attribute__((packed))
#if defined(__GNUC__) || defined(__GNUG__)
  #define ONEBYTE_ALIGN __attribute__((packed))
#elif defined(_MSC_VER)
  #define ONEBYTE_ALIGN
  #pragma pack(push,1)
#endif

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

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

std::cout << sizeof(Info) << std::endl;   // 6 1 + 4 + 1
std::cout << alignof(Info) << std::endl;  // 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 确定结构体中每个元素大小可以通过下面这种方法:
#if defined(__GNUC__) || defined(__GNUG__)
  #define ONEBYTE_ALIGN __attribute__((packed))
#elif defined(_MSC_VER)
  #define ONEBYTE_ALIGN
  #pragma pack(push,1)
#endif

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

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

std::cout << sizeof(Info) << std::endl;   // 2
std::cout << alignof(Info) << std::endl;  // 1
  • 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

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

update1:https://github.com/forthespada/InterviewGuide/issues/2 ,由stanleyguo0207提出 - 2021.03.22

3、指针和引用的区别

  • 指针是一个变量,存储的是一个地址,引用跟原来的变量实质上是同一个东西,是原变量的别名
  • 指针可以有多级,引用只有一级
  • 指针可以为空,引用不能为NULL且在定义时必须初始化
  • 指针在初始化后可以改变指向,而引用在初始化之后不可再改变
  • sizeof指针得到的是本指针的大小,sizeof引用得到的是引用所指向变量的大小
  • 当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以。
  • 引用本质是一个指针,同样会占4字节内存;指针是具体变量,需要占用存储空间(,具体情况还要具体分析)。
  • 引用在声明时必须初始化为另一变量,一旦出现必须为typename refname &varname形式;指针声明和定义可以分开,可以先只声明指针变量而不初始化,等用到时再指向具体变量。
  • 引用一旦初始化之后就不可以再改变(变量可以被引用为多次,但引用只能作为一个变量引用);指针变量可以重新指向别的变量。
  • 不存在指向空值的引用,必须有具体实体;但是存在指向空值的指针。
void test(int *p)
{
  int a=1;
  p=&a;
  cout<<p<<" "<<*p<<endl;
}

int main(void)
{
    int *p=NULL;
    test(p);
    if(p==NULL)
    	cout<<"指针p为NULL"<<endl;
    return 0;
}
//运行结果为:
//0x22ff44 1
//指针p为NULL


void testPTR(int* p) {
	int a = 12;
	p = &a;

}

void testREFF(int& p) {
	int a = 12;
	p = a;

}
void main()
{
	int a = 10;
	int* b = &a;
	testPTR(b);//改变指针指向,但是没改变指针的所指的内容
	cout << a << endl;// 10
	cout << *b << endl;// 10

	a = 10;
	testREFF(a);
	cout << a << endl;//12
}
  • 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

在编译器看来, int a = 10; int &b = a; 等价于 int * const b = &a; 而 b = 20; 等价于 *b = 20; 自动转换为指针和自动解引用.

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

  • 需要返回函数内局部变量的内存的时候用指针。使用指针传参需要开辟内存,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引用是没有意义的

  • 对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小

    • 引用是C++的一种复合类型,是已定义的变量的别名,与原变量在同一个内存地址。引用常常作为函数的形参,此时函数将使用原始数据(而不是原始数据的副本)。

  • 类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式

5、堆和栈的区别

  • 申请方式不同。
    • 栈由系统自动分配。
  • 堆是自己申请和释放的。
  • 申请大小限制不同。
    • 栈顶和栈底是之前预设好的,栈是向栈底扩展,大小固定,可以通过ulimit -a查看,由ulimit -s修改。
    • 堆向高地址扩展,是不连续的内存区域,大小可以灵活调整。
  • 申请效率不同。
    • 栈由系统分配,速度快,不会有碎片。
    • 堆由程序员分配,速度慢,且会有碎片。

栈空间默认是4M, 堆区一般是 1G - 4G

管理方式堆中资源由程序员控制(容易产生memory leak)栈资源由编译器自动管理,无需手工控制
内存管理机制系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序
(大多数系统会在这块内存空间首地址记录本次分配的大小,这样delete才能正确释放本内存空间,另外系统会将多余的部分重新放入空闲链表中)
只要栈的剩余空间大于所申请空间,系统为程序提供内存,否则报异常提示栈溢出。
(这一块理解一下链表和队列的区别,不连续空间和连续空间的区别,应该就比较好理解这两种机制的区别了)
空间大小堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的),
堆大小受限于计算机系统中有效的虚拟内存(32bit 系统理论上是4G),所以堆的空间比较灵活,比较大
栈是一块连续的内存区域,大小是操作系统预定好的,windows下栈大小是2M(也有是1M,在 编译时确定,VC中可设置)
碎片问题对于堆,频繁的new/delete会造成大量碎片,使程序效率降低对于栈,它是有点类似于数据结构上的一个先进后出的栈,进出一一对应,不会产生碎片。
(看到这里我突然明白了为什么面试官在问我堆和栈的区别之前先问了我栈和队列的区别)
生长方向堆向上,向高地址方向增长。栈向下,向低地址方向增长。
分配方式堆都是动态分配(没有静态分配的堆)栈有静态分配和动态分配,静态分配由编译器完成(如局部变量分配),动态分配由alloca函数分配,
但栈的动态分配的资源由编译器进行释放,无需程序员实现。
分配效率堆由C/C++函数库提供,机制很复杂。所以堆的效率比栈低很多。栈是其系统提供的数据结构,计算机在底层对栈提供支持,分配专门寄存器存放栈地址,栈操作有专门指令。

形象的比喻

栈就像我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。

堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。

6、你觉得堆快一点还是栈快一点?

毫无疑问是栈快一点。

因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。

而堆的操作是由C/C++函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。

7、区别以下指针类型?

int *p[10]
int (*p)[10]
int *p(int)
int (*p)(int)
  • 1
  • 2
  • 3
  • 4
  • int *p[10]表示指针数组,强调数组概念,是一个数组变量,数组大小为10,数组内每个元素都是指向int类型的指针变量。
  • int (*p)[10]表示数组指针,强调是指针,只有一个变量,是指针类型,不过指向的是一个int类型的数组,这个数组大小是10。
  • int *p(int)是函数声明,函数名是p,参数是int类型的,返回值是int *类型的。
  • int (*p)(int)是函数指针,强调是指针,该指针指向的函数具有int类型参数,并且返回值是int类型的。

8、new / delete 与 malloc / free的异同

相同点

  • 都可用于内存的动态申请和释放

不同点

  • 前者是C++运算符,后者是C/C++语言标准库函数
  • new自动计算要分配的空间大小,malloc需要手工计算
  • new是类型安全的,malloc不是。例如:
int *p = new float[2]; //编译错误
int *p = (int*)malloc(2 * sizeof(double));//编译无错误
  • 1
  • 2
  • new调用名为operator new的标准库函数分配足够空间并调用相关对象的构造函数,delete对指针所指对象运行适当的析构函数;然后通过调用名为operator delete的标准库函数释放该对象所用内存。后者均没有相关调用
  • 后者需要库文件支持,前者不用
  • new是封装了malloc,直接free不会报错,但是这只是释放内存,而不会析构对象

9、new和delete是如何实现的?

  • new的实现过程是:首先调用名为operator new的标准库函数,分配足够大的原始为类型化的内存,以保存指定类型的一个对象;接下来运行该类型的一个构造函数,用指定初始化构造对象;最后返回指向新分配并构造后的的对象的指针
  • delete的实现过程:对指针指向的对象运行适当的析构函数;然后通过调用名为operator delete的标准库函数释放该对象所用内存

10、malloc和new的区别?

  • malloc和free是标准库函数,支持覆盖;new和delete是运算符,支持重载。
  • malloc仅仅分配内存空间,free仅仅回收空间,不具备调用构造函数和析构函数功能,用malloc分配空间存储类的对象存在风险;new和delete除了分配回收功能外,还会调用构造函数和析构函数。
  • malloc和free返回的是void类型指针(必须进行类型转换),new和delete返回的是具体类型指针。

11、既然有了malloc/free,C++中为什么还需要new/delete呢?直接用malloc/free不好吗?

  • malloc/free和new/delete都是用来申请内存和回收内存的。
  • 在对非基本数据类型的对象使用的时候,对象创建的时候还需要执行构造函数,销毁的时候要执行析构函数。而malloc/free是库函数,是已经编译的代码,所以不能把构造函数和析构函数的功能强加给malloc/free,所以new/delete是必不可少的。

12、被free回收的内存是立即返还给操作系统吗?

不是的,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。

13、宏定义和函数有何区别?

  • 宏在预处理阶段完成替换,之后被替换的文本参与编译,相当于直接插入了代码,运行时不存在函数调用,执行起来更快;函数调用在运行时需要跳转到具体调用函数。
  • 宏定义属于在结构中插入代码,没有返回值;函数调用具有返回值。
  • 宏定义参数没有类型,不进行类型检查;函数参数具有类型,需要检查类型。
  • 宏定义不要在最后加分号。

14、宏定义和typedef区别?

  • 宏主要用于定义常量及书写复杂的内容;typedef主要用于定义类型别名。
  • 宏替换发生在编译阶段之前,属于文本插入替换;typedef是编译的一部分。
  • 宏不检查类型;typedef会检查数据类型。
  • 宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。
  • 注意对指针的操作,typedef char * p_char#define p_char char *区别巨大。
    • typedef作用于编译阶段,而 #define 作用域预编译阶段
    • 如果使用#define p_char char *定义两个字符串(p_char a,b),第二个变量 b 的类型就是 char 类型,而不是 char * 了,因为宏定义只是将 p_char 替换成 char *

15、变量声明和定义区别?

  • 声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间;定义要在定义的地方为其分配存储空间。
  • 相同变量可以在多处声明(外部变量extern),但只能在一处定义。

16、strlen和sizeof区别?

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

    const char* str = "name";

    sizeof(str); // 取的是指针str的长度,是8(64位系统中指针长度是8;32为系统中指针长度是4)
    strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

16.2、(补充题)一个指针占多少字节?

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

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

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

还有疑问的,可以自行打开Visual Studio编译器自己实验一番。

17、常量指针和指针常量区别?

  • 指针常量是一个指针,读成常量的指针,指向一个只读变量,也就是后面所指明的int const 和 const int,都是一个常量,可以写作int const *p或const int *p。
  • 常量指针是一个不能给改变指向的指针。指针是个常量,必须初始化,一旦初始化完成,它的值(也就是存放在指针中的地址)就不能在改变了,即不能中途改变指向,如int *const p。

update1:https://www.nowcoder.com/discuss/597948 ,网友“ 牛客191489444号 ”指出笔误,感谢!

update2:《C++ Primer 5th》 P56页有明确说明常量指针和指针常量,阿秀特意去确认了-20210521。

多说一句,网上关于指针常量和常量指针的说法很多跟书本上都不一致,甚至百度百科上跟《C++ Primer 5th》书上在指针常量和常量指针的说法刚好相反,鉴于百度百科是人人都可以去编辑,因此我信书。

也希望各位遇到问题时要多去查阅资料,多去确认,不要因为某些博客或者文章说了就确认无疑。

18、a和&a有什么区别?

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

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

19、C++和Python的区别

包括但不限于:

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

20、C++和C语言的区别

  • C++ 中 new 和 delete 是对内存分配的运算符,取代了 C 中的 malloc 和 free 。
  • 标准 C++ 中的字符串类取代了标准 C 函数库头文件中的字符数组处理函数( C 中没有字符串类型)。
  • C++ 中用来做控制态输入输出的 iostream 类库替代了标准 C 中的 stdio 函数库。
  • C++ 中的 try/catch/throw 异常处理机制取代了标准 C 中的 setjmp() 和 longjmp() 函数。
  • 在 C++ 中,允许有相同的函数名,不过它们的参数类型不能完全相同,这样这些函数就可以相互区别开来。而这在 C 语言中是不允许的。也就是 C++ 可以重载,C 语言不允许。
  • C++ 语言中,允许变量定义语句在程序中的任何地方,只要在是使用它之前就可以;而 C 语言中,必须要在函数开头部分。
  • 在 C++ 中,除了值和指针之外,新增了引用。引用型变量是其他变量的一个别名,我们可以认为他们只是名字不相同,其他都是相同的。
  • C++ 相对与 C 增加了一些关键字,如:bool、using、dynamic_cast、namespace等等

21、C++与Java的区别

语言特性

  • Java 语言给开发人员提供了更为简洁的语法;完全面向对象,由于 JVM 可以安装到任何的操作系统上,所以说它的可移植性强
  • Java语言中没有指针的概念,引入了真正的数组。不同于 C++ 中利用指针实现的“伪数组”,Java 引入了真正的数组,同时将容易造成麻烦的指针从语言中去掉,这将有利于防止在 C++ 程序中常见的因为数组操作越界等指针操作而对系统数据进行非法读写带来的不安全问题
  • C++ 也可以在其他系统运行,但是需要不同的编码(这一点不如 Java ,只编写一次代码,到处运行),例如对一个数字,在 windows 下是大端存储,在 unix 中则为小端存储。Java 程序一般都是生成字节码,在 JVM 里面运行得到结果
  • Java 用接口(Interface)技术取代 C++ 程序中的抽象类。接口与抽象类有同样的功能,但是省却了在实现和维护上的复杂性

垃圾回收

  • C++ 用析构函数回收垃圾,写 C 和 C++ 程序时一定要注意内存的申请和释放
  • Java 语言不使用指针,内存的分配和回收都是自动进行的,程序员无须考虑内存碎片的问题

应用场景

  • Java 在桌面程序上不如 C++ 实用,C++ 可以直接编译成 exe 文件,指针是 C++ 的优势,可以直接对内存的操作,但同时具有危险性 。(操作内存的确是一项非常危险的事情,一旦指针指向的位置发生错误,或者误删除了内存中某个地址单元存放的重要数据,后果是可想而知的)
  • Java 在 Web 应用上具有 C++ 无可比拟的优势,具有丰富多样的框架
  • 对于底层程序的编程以及控制方面的编程,C++ 很灵活,因为有句柄的存在

22、C++中struct和class的区别

相同点

  • 两者都拥有成员函数、公有和私有部分
  • 任何可以使用 class 完成的工作,同样可以使用 struct 完成

不同点

  • 两者中如果不对成员不指定公私有,struct 默认是公有的,class 则默认是私有的
  • class 默认是 private 继承, 而 struct 默认是 public 继承

引申:C++ 和 C 的 struct 区别

  • C 语言中:struct 是用户自定义数据类型(UDT);C++中 struct 是抽象数据类型(ADT),支持成员函数的定义,( C++ 中的 struct 能继承,能实现多态)
  • C 中 struct 是没有权限的设置的,且 struct 中只能是一些变量的集合体,可以封装数据却不可以隐藏数据,而且成员不可以是函数
  • C++ 中,struct 增加了访问权限,且可以和类一样有成员函数,成员默认访问说明符为 public(为了与C兼容)
  • struct 作为类的一种特例是用来自定义数据结构的。一个结构标记声明后,在 C 中必须在结构标记前加上 struct ,才能做结构类型名(除:typedef struct class{};);C++ 中结构体标记(结构体名)可以直接作为结构体类型名使用,此外结构体 struct 在 C++ 中被当作类的一种特例

23、define宏定义和const的区别

编译阶段

  • define 是在编译的预处理阶段起作用,而 const 是在编译、运行的时候起作用

安全性

  • define 只做替换,不做类型检查和计算,也不求解,容易产生错误,一般最好加上一个大括号包含住全部的内容,要不然很容易出错
  • const 常量有数据类型,编译器可以对其进行类型安全检查

内存占用

  • define 只是将宏名称进行替换,在内存中会产生多分相同的备份。const 在程序运行中只有一份备份,且可以执行常量折叠,能将复杂的的表达式计算出结果放入常量表
  • 宏替换发生在编译阶段之前,属于文本插入替换;const 作用发生于编译过程中。
  • 宏不检查类型;const 会检查数据类型。
  • 宏定义的数据没有分配内存空间,只是插入替换掉;const 定义的变量只是值不能改变,但要分配内存空间。

24、C++ 中 const 和 static 的作用

static

  • 不考虑类的情况

    • 隐藏。所有不加 static 的全局变量和函数具有全局可见性,可以在其他文件中使用,加了之后只能在该文件所在的编译模块中使用
    • 默认初始化为 0,包括未初始化的全局静态变量与局部静态变量,都存在全局未初始化区
    • 静态变量在函数内定义,始终存在,且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不能使用
  • 考虑类的情况

    • static 成员变量:只与类关联,不与类的对象关联。定义时要分配空间,不能在类声明中初始化,必须在类定义体外部初始化,初始化时不需要标示为 static ;可以被非static成员函数任意访问。

      • class Student
        {
        public:
        	int a = 10;
        	static int age;
        };
        
        int Student::age = 20;
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
    • static 成员函数:不具有 this 指针,无法访问类对象的非 static 成员变量和非 static 成员函数;**不能被声明为 const 、虚函数 和 volatile **;可以被非 static 成员函数任意访问

const

  • 不考虑类的情况

    • const 常量在定义时必须初始化,之后无法更改

    • const 形参可以接收 const 和非 const 类型的实参,例如: i 可以是 int 型或者 const int 型

      • void fun(const int& i){
            //...
        }
        
        • 1
        • 2
        • 3
  • 考虑类的情况

    • const 成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其 const 数据成员的值可以不同,所以不能在类中声明时初始化

    • const 成员函数:const 对象不可以调用非 const 成员函数;非 const 对象都可以调用;不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值

      • #include<iostream>
        using namespace std;
        
        class Student
        {
        public:
        	int a = 10;
        	static int age;
        	void hello()
        	{
        		cout << "hello" << endl;
        	}
        	void  world() const
        	{
        		cout << "world" << endl;
        	}
        };
        
        int Student::age = 20;
        
        int main()
        {
        	Student* s = new Student;
        	cout << s->a << endl;
        	cout << Student::age << endl;
        	s->hello();
        	s->world();
        
        	const Student* s2 = new Student;
        	cout << s2->a << endl;
        	cout << Student::age << endl;
        	//s2->hello();
        	s2->world();
        }
        
        • 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

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

25、C++ 的顶层 const 和底层 const

概念区分

  • 顶层const:指的是const修饰的变量本身是一个常量,无法修改,指的是指针,就是 * 号的右边
  • 底层const:指的是const修饰的变量所指向的对象是一个常量,指的是所指变量,就是 * 号的左边

举个例子

int a = 10;int* const b1 = &a;        //顶层const,b1本身是一个常量
const int* b2 = &a;       //底层const,b2本身可变,所指的对象是常量
const int b3 = 20; 		   //顶层const,b3是常量不可变
const int* const b4 = &a;  //前一个const为底层,后一个为顶层,b4不可变
const int& b5 = a;		   //用于声明引用变量,都是底层const
  • 1
  • 2
  • 3
  • 4
  • 5

区分作用

  • 执行对象拷贝时有限制,常量的底层const不能赋值给非常量的底层const
  • 使用命名的强制类型转换函数const_cast时,只能改变运算对象的底层const
const int a;int const a;const int *a;int *const a;   
  • 1
  • int const a和const int a均表示定义常量类型a。
  • const int *a,其中a为指向int型变量的指针,const在 * 左侧,表示a指向不可变常量。(看成const (*a),对引用加const)
  • int *const a,依旧是指针类型,表示a为指向整型数据的常指针。(看成const(a),对指针const)

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

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

27、final 和 override 关键字

override

当在父类中使用了虚函数时候,你可能需要在某个子类中对这个虚函数进行重写,以下方法都可以:

class A
{
	virtual void foo();
};
class B : public A
{
	void foo(); //OK
	virtual void foo(); // OK
	void foo() override; //OK
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

如果不使用override,当你手一抖,将 foo() 写成了 f00() 会怎么样呢?结果是编译器并不会报错,因为它并不知道你的目的是重写虚函数,而是把它当成了新的函数。如果这个虚函数很重要的话,那就会对整个程序不利。所以,override的作用就出来了,它指定了子类的这个虚函数是重写的父类的,如果你名字不小心打错了的话,编译器是不会编译通过的:

class A
{
	virtual void foo();
};
class B : public A
{
	virtual void f00(); //OK,这个函数是B新增的,不是继承的
	virtual void f0o() override; //Error, 加了override之后,这个函数一定是继承自A的,A找不到就报错
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

final

当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。例子如下:

class Base
{
	virtual void foo();
};

class A : public Base
{
	void foo() final override; // foo 被override并且是最后一个override,在其子类中不可以重写
};

class B final : A // 指明B是不可以被继承的
{
	void foo() override; // Error: 在A中已经被final了
};

class C : B // Error: B is final
{
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

28、拷贝初始化和直接初始化

当用于类类型对象时,初始化的拷贝形式和直接形式有所不同:

  • 直接初始化直接调用与实参匹配的构造函数
  • 拷贝初始化总是调用拷贝构造函数

拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。举例如下

string str1("I am a string");//语句1 直接初始化
string str2(str1);//语句2 直接初始化,str1是已经存在的对象,直接调用拷贝构造函数对str2进行初始化
string str3 = "I am a string";//语句3 拷贝初始化,先为字符串”I am a string“创建临时对象,再把临时对象作为参数,使用拷贝构造函数构造str3
string str4 = str1;//语句4 拷贝初始化,这里相当于隐式调用拷贝构造函数,而不是调用赋值运算符函数
  • 1
  • 2
  • 3
  • 4
  • 为了提高效率,允许编译器跳过创建临时对象这一步,直接调用构造函数构造要创建的对象,这样就完全等价于直接初始化了

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

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

感谢网友勘误:”string str2(str1); // 使用的是拷贝构造函数初始化而且直接初始化“,已修正,2021.10.12 -https://github.com/forthespada/InterviewGuide/issues/14

29、初始化和赋值的区别

  • 对于简单类型来说,初始化和赋值没什么区别
  • 对于类和复杂数据类型来说,这两者的区别就大了,举例如下:
class A{
public:
    int num1;
    int num2;
public:
    A(int a=0, int b=0):num1(a),num2(b){};
    A(const A& a){};
    //重载 = 号操作符函数
    A& operator=(const A& a){
        num1 = a.num1 + 1;
        num2 = a.num2 + 1;
        return *this;
    };
};
int main(){
    A a(1,1);
    A a1 = a; //拷贝初始化操作,调用拷贝构造函数
    A b;
    b = a;//赋值操作,对象a中,num1 = 1,num2 = 1;对象b中,num1 = 2,num2 = 2
    return 0;
}

/*
输出结果:
a、b 构造函数
A 构造函数
a、b 构造函数
operator= 函数
*/
  • 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、extern “C” 的用法

为了能够正确的在 C++ 代码中调用 C 语言的代码:在程序中加上 extern “C” 后,相当于告诉编译器这部分代码是 C 语言写的,因此要按照 C 语言进行编译,而不是 C++ ;

哪些情况下使用 extern “C” :

(1)C++ 代码中调用 C 语言代码;

(2)在 C++ 中的头文件中使用;

(3)在多个人协同开发时,可能有人擅长 C 语言,而有人擅长 C++ ;

举个例子,C++ 中调用 C 代码:

#ifndef _MY_HANDLE_H_
#define _MY_HANDLE_H_
extern "C" {
    typedef unsigned int result_t;
    typedef void* my_handler_t;

    my_handle_t create_handle(const char* name);
    result_t operate_on_handle(my_handle_t handle);
    void close_handle(my_handle_t handle);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

综上,总结出使用方法**,在 C 语言的头文件中,对其外部函数只能指定为 extern 类型,C 语言中不支持 extern “C” 声明,在 .c 文件中包含了 extern “C” 时会出现编译语法错误。**所以使用 extern “C” 全部都放在于 cpp 程序相关文件或其头文件中。

总结出如下形式:

(1)C++调用C函数:

//xx.h
extern int add(...)
    
//xx.c
int add()
{
    
}

//xx.cpp
extern "C"{
    #include "xx.h"
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

(2)C调用C++函数

//xx.h
extern "C"{
    int add();
}
//xx.cpp
int add(){
    
}
//xx.c
extern int add();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

31、野指针和悬空指针

都是是指向无效内存区域(这里的无效指的是"不安全不可控")的指针,访问行为将会导致未定义行为。

  • 野指针:野指针,指的是没有被初始化过的指针
int main(void) {
    int* p; // 未初始化
    std::cout << *p << std::endl; // Error,未初始化就用
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5

因此,为了防止出错,对于指针初始化时都是赋值为 nullptr,这样在使用时编译器就会直接报错,产生非法内存访问。

  • 悬空指针:悬空指针,指针最初指向的内存已经被释放了的一种指针。
int main(void) {
    int* p = nullptr;
    int* p2 = new int;
    p = p2;
    delete p2;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

此时 p和p2就是悬空指针,指向的内存已经被释放。继续使用这两个指针,行为不可预料。需要设置为p=p2=nullptr。此时再使用,编译器会直接保错。 避免野指针比较简单,但悬空指针比较麻烦。C++ 引入了智能指针,C++ 智能指针的本质就是避免悬空指针的产生。

产生原因及解决办法:

野指针:指针变量未及时初始化 => 定义指针变量及时初始化,要么置空。

悬空指针:指针 free 或 delete 之后没有及时置空 => 释放操作后立即置空。

32、C和C++的类型安全

什么是类型安全?

类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。“类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。

类型安全的编程语言与类型安全的程序之间,没有必然联系。好的程序员可以使用类型不那么安全的语言写出类型相当安全的程序,相反的,差一点儿的程序员可能使用类型相当安全的语言写出类型不太安全的程序。绝对类型安全的编程语言暂时还没有。

(1)C 的类型安全

C 只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式类型转换。然而,C 中相当多的操作是不安全的。以下是两个十分常见的例子:

  • printf格式输出
#include<stdio.h>
int main()
{
    printf("整型输出:%d\n",10);
    printf("浮点型输出:%f\n",10);
    return 0;
}
/*
[root@localhost linux]# ./a.out 
整型输出:10
浮点型输出:0.000000
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

上述代码中,使用 %d 控制整型数字的输出,没有问题,但是改成 %f 时,明显输出错误,再改成 %s 时,运行直接报 segmentation fault 错误

#include<stdio.h>
int main()
{
    printf("整型输出:%d\n",10);
    // %f 改成 %s 时,运行直接报 segmentation fault 错误
    printf("浮点型输出:%s\n",10);
    return 0;
}
/*
[root@localhost linux]# ./a.out 
整型输出:10
段错误
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • malloc 函数的返回值

malloc 是 C 中进行内存分配的函数,它的返回类型是 void* 即空类型指针,常常有这样的用法char* pStr=(char*)malloc(100*sizeof(char)),这里明显做了显式的类型转换。

类型匹配尚且没有问题,但是一旦出现int* pInt=(int*)malloc(100*sizeof(char))就很可能带来一些问题,而这样的转换 C 并不会提示错误。

(2)C++ 的类型安全

如果 C++ 使用得当,它将远比 C 更有类型安全性。相比于 C 语言,C++ 提供了一些新的机制保障类型安全:

  • 操作符 new 返回的指针类型严格与对象匹配,而不是void*
  • C 中很多以void*为参数的函数可以改写为 C++ 模板函数,而模板是支持类型检查的;
  • 引入 const 关键字代替 #define constants,它是有类型、有作用域的,而#define constants只是简单的文本替换
  • 一些 #define宏 可被改写为 inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全
  • C++ 提供了dynamic_cast关键字,使得转换过程更加安全,因为 dynamic_cast 比 static_cast 涉及更多具体的类型检查。

例1:使用 void* 进行类型转换

#include<iostream>
using namespace std;
int main() {
	int i = 5;
    void* pInt = &i;
    double d = (*(double *)pInt);
    cout << "转换后输出:" << d << endl;
}
/*
[root@localhost linux]# ./a.out 
转换后输出:-3.69447e+134
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

例2:不同类型指针之间转换

#include<iostream>
using namespace std;

class Parent
{
    
};

class Child1 : public Parent
{
public:
    int i;
    Child1(int e) : i(e) {
        
    }
};

class Child2 : public Parent
{
public:
    double d;
    Child2(double e):d(e) {
        
    }
};

int main() {
    Child1 c1(5);
    Child2 c2(4.1);
    Parent* pp;
    Child1* pc1;
    
    pp = &c1;
    pc1 = (Child1*)pp; // 类型向下转换 强制转换,由于类型仍然为 Child1 不造成错误
    cout << pc1->i << endl; // 输出5
    
    pp = &c2;
    pc1 = (Child1*)pp;// 强制转换,且类型发生变化,将造成错误
    cout << pc1->i << endl;// 输出:1717986918
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

上面两个例子之所以引起类型不安全的问题,是因为程序员使用不得当。

  • 第一个例子用到了空类型指针void*
  • 第二个例子则是在两个类型指针之间进行强制转换

因此,想保证程序的类型安全性,应尽量避免使用空类型指针void*,尽量不对两种类型指针做强制转换。

33、C++中的重载、重写(覆盖)和隐藏的区别

(1)重载(overload)

重载是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否是虚函数无关。举个例子:

class A{
    //...
    virtual int fun();
    void fun(int);
    void fun(double,double);
    static int fun(char);
    //...
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

(2)重写(覆盖)(override)

重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体要求基类函数必须是虚函数且:

  • 与基类的虚函数有相同的参数个数
  • 与基类的虚函数有相同的参数类型
  • 与基类的虚函数有相同的返回值类型

举个例子:

class Parent 
{
public:
    virtual int fun(int a) {
        
    }
};

class Son : public Parent
{
public:
    // 重写,一般加 override 可以确保是重写父类的函数
    virtual int fun(int a) override {
        
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

重载与重写的区别:

  • 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
  • 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
  • 重写关系中,调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体

(3)隐藏(hide)

隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包括以下情况:

  • 两个函数参数相同,但是基类函数不是虚函数。**和重写的区别在于基类函数是否是虚函数。**举个例子:
// 父类
class Parent 
{
public:
    void fun(int a) {
        cout << "Parent 中的 fun 函数" << endl;
    }
};

// 子类
class Son : public Parent 
{
public:
    // 隐藏父类的 fun 函数
    void fun(int a) {
        cout << "Son 中的 fun 函数" << endl;
    }
};

int main() {
    Son son;
    son.fun(2); // 调用的是Son中的fun函数
    son.Parent::fun(2); // 调用Parent中的fun函数
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中。举个例子:
// 父类
class Parent
{
public:
    virtual void fun(int a) {
        cout << "Parent 中的 fun 函数" << endl;
    }
};
// 子类
class Son : public Parent 
{
public:
    // 隐藏父类的 fun 函数
    virtual void fun(char* a) {
        cout << "Son 中的 fun 函数" << endl;
    }
};
int main() {
    Son son;
    son.fun(2); // 报错,调用的是 Son 中的 fun 函数,但是参数类型不对
    son.Parent::fun(2); // 调用 Parent 中的 fun 函数
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 基类指针指向派生类对象时,基类指针可以直接调用到派生类的覆盖函数,也可以通过 :: 调用到基类被覆盖的虚函数;而基类指针只能调用基类的被隐藏函数,无法识别派生类中的隐藏函数。
// 父类
class Parent{
public:
    virtual void fun(int a) {// 虚函数
    	cout << "This is Parent fun " << a << endl;
    }
    void add(int a,int b) {
        cout << "This is Parent add " << a + b << endl;
    }
};
// 子类
class Son : public Parent {
public:
    void fun(int a) override {// 覆盖
    	cout << "This is Son fun " << a << endl;
    }
    void add(int a) {// 隐藏
    	cout << "This is Son add " << a + a << endl;
    }
};

int main() {
    //基类指针指向派生类对象时,基类指针可以直接调用到派生类的覆盖函数,也可以通过 :: 调用到基类被覆盖的虚函数;而基类指针只能调用基类的被隐藏函数,无法识别派生类中的隐藏函数。
    Parent* p = new Son;
    p->fun(1); // 调用子类 fun 覆盖函数
    p->A::fun(1); // 调用父类 fun
    p->add(1,2); // 调用父类 add
    // p->add(1);      // 错误,识别的是 A 类中的 add 函数,参数不匹配
    // p->B::add(1);   // 错误,无法识别子类 add 函数
}
  • 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

34、C++有哪几种的构造函数

C++中的构造函数可以分为4类:

  • 默认构造函数
  • 初始化构造函数(有参数)
  • 拷贝构造函数
  • 移动构造函数(move和右值引用)
  • 委托构造函数
  • 转换构造函数

举个例子:

#include<iostream>
using namespace std;

class Student {
public:
    // 默认构造函数,没有参数
    Student() {
        this->age = 20;
        this->num = 1000;
    }
    // 初始化构造函数,有参数和参数列表
    Student(int a, int n) : age(a), num(n) {

    }
    // 拷贝构造函数,这里与编译器生成的一致
    Student(const Student& s) {
        this->age = s.age;
        this->num = s.num;
    }
    // 转换构造函数,形参是其他类型,其只有一个形参
    Student(int r) {
        this->age = r;
        this->num = 1002;
    }
    ~Student() {

    }
public:
    int age;
    int num;
};

int main() {
    Student s1;
    Student s2(18, 1001);
    int a = 10;
    Student s3(a);
    Student s4(s3);

    printf("s1 age:%d, num:%d\n", s1.age, s1.num);
    printf("s2 age:%d, num:%d\n", s2.age, s2.num);
    printf("s3 age:%d, num:%d\n", s3.age, s3.num);
    printf("s2 age:%d, num:%d\n", s4.age, s4.num);
    return 0;
}
/*
s1 age:20, num:1000
s2 age:18, num:1001
s3 age:10, num:1002
s2 age:10, num:1002
*/
  • 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
  • 默认构造函数和初始化构造函数在定义类的对象,完成对象的初始化工作
  • 复制构造函数用于复制本类的对象
  • 转换构造函数用于将其他类型的变量,隐式转换为本类对象

35、浅拷贝和深拷贝的区别

浅拷贝

浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。

深拷贝

深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。

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

class Student
{
private:
    int num;
    char* name;
public:
    Student() {
        name = new char(20);
        cout << "Student" << endl;
    }
    ~Student() {
        cout << "~Student " << &name << endl;
        delete name;
        name = NULL;
    }
    Student(const Student& s) { // 拷贝构造函数
        // 浅拷贝,当前对象的name和传入对象的name指向相同的地址
        //name = s.name;

        // 深拷贝
        name = new char(20);
        memcpy(name, s.name, strlen(s.name));

        cout << "copy Student" << endl;
    }
};
int main() {
    // 花括号让 s1和s2变成局部对象,方便测试
    {
        Student s1;
        Student s2(s1); // 调用拷贝构造函数,复制对象
    }
    system("pause");
    return 0;
}
/*
//浅拷贝执行结果:
Student
copy Student
~Student 0x7ffc7e932e08
~Student 0x7ffc7e932e18
*** Error in `./a.out': double free or corruption (fasttop): 0x0000000002369c20 ***

//深拷贝执行结果:
Student
copy Student
~Student 0x7fff9a1be258
~Student 0x7fff9a1be268
*/
  • 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

从执行结果可以看出,浅拷贝在对象的拷贝创建时存在风险,即被拷贝的对象析构释放资源之后,拷贝对象析构时会再次释放一个已经释放的资源,深拷贝的结果是两个对象之间没有任何关系,各自成员地址不同。

36、内联函数和宏定义的区别

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

内联函数适用场景:

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

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

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

(一)访问权限

派生类可以继承基类中除了构造/析构、赋值运算符重载函数之外的成员,但是这些成员的访问属性在派生过程中也是可以调整的,三种派生方式的访问权限如下表所示:注意外部访问并不是真正的外部访问,而是在通过派生类的对象对基类成员的访问。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iNIMxAdT-1668393620910)(1-C++基础.assets/202205212341241.png)]

派生类对基类成员的访问形象有如下两种:

  • 内部访问:由派生类中新增的成员函数对从基类继承来的成员的访问
  • 外部访问:在派生类外部,通过派生类的对象对从基类继承来的成员的访问

(二)继承权限

public继承

公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,都保持原有的状态,而基类的私有成员任然是私有的,不能被这个派生类的子类所访问

protected继承

保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然是私有的,访问规则如下表

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wTBDOQNe-1668393620911)(1-C++基础.assets/202205212341074.png)]

private继承

私有继承的特点是基类的所有公有成员和保护成员都成为派生类的私有成员,并不被它的派生类的子类所访问,基类的成员只能由自己派生类访问,无法再往下继承,访问规则如下表

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mdNoVbuM-1668393620911)(1-C++基础.assets/202205212341430.png)]

总结

一、访问权限

访问权限外部派生类内部
public
protected
private

public、protected、private 的访问权限范围关系:

public > protected > private

二、继承权限

  1. 派生类继承自基类的成员权限有四种状态:public、protected、private、不可见
  2. 派生类对基类成员的访问权限取决于两点:一、继承方式;二、基类成员在基类中的访问权限
  3. 派生类对基类成员的访问权限是取以上两点中的更小的访问范围(除了 private 的继承方式遇到 private 成员是不可见外)。例如:
  • public 继承 + private 成员 => private
  • private 继承 + protected 成员 => private
  • private 继承 + private 成员 => 不可见

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

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

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

例如:32bit 的数字 0x12345678

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wuumSnpF-1668393620911)(1-C++基础.assets/202205071832785.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Xx5VC8W-1668393620912)(1-C++基础.assets/202205071832707.png)]

了解了大小端存储的方式,如何在代码中进行判断呢?下面介绍两种判断方式:

方式一:使用强制类型转换-这种法子不错

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

方式二:巧用union联合体

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

39、volatile、mutable和explicit关键字的用法

(1)volatile

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

当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。

volatile 定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。多线程中被几个任务共享的变量需要定义为 volatile 类型。

volatile 指针

volatile 指针和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念

修饰由指针指向的对象、数据是 const 或 volatile 的:

const char* cpch;
volatile char* vpch;
  • 1
  • 2

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

char* const pchc;
char* volatile pchv;
  • 1
  • 2

注意:

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

多线程下的 volatile

有些变量是用 volatile 关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用 volatile 声明,**该关键字的作用是防止优化编译器把变量从内存装入 CPU 寄存器中。**如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile 的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。

(2)mutable

mutable 的中文意思是“可变的,易变的”,跟 constant(即 C++ 中的 const )是反义词。在 C++ 中,mutable 也是为了突破 const 的限制而设置的。被 mutable 修饰的变量,将永远处于可变的状态,即使在一个 const 函数中。

我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成 const 的。但是,有些时候,我们需要在 const 函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被 mutable 来修饰,并且放在函数后后面关键字位置

样例

class Person
{
    int m_A;
    mutable int m_B; // 特殊变量,在常函数里值也可以被修改
public:
    void add() const {
        m_A = 10; // 错误,不可修改值,this 已经被修饰为常量指针
        m_B = 20; // 正确
    }
};

class Person {
public:
    int m_A;
    mutable int m_B; // 特殊变量,在常函数里值也可以被修改
};
int main()
{
    const Person p; // 修饰常对象,不可修改类成员的值
    // 修饰常对象,不可修改类成员的值
    p.m_A = 10; // 错误,被修饰了指针常量
    p.m_B = 200; // 正确,特殊变量,修饰了 mutable   
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

(3)explicit

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

  • explicit 关键字只能用于类内部的构造函数声明上
  • explicit 关键字作用于单个参数的构造函数
  • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换
#include<iostream>
using namespace std;
class Test1
{
public:
	/*
	C++提供了关键字explicit,可以阻止不应该允许的经过转换构造函数进行的隐式转换的发生,
	声明为explicit的构造函数不能在隐式转换中使用。

	C++中, 一个参数的构造函数(或者除了第一个参数外其余参数都有默认值的多参构造函数), 承担了两个角色。 
	1 是个构造;2 是个默认且隐含的类型转换操作符。

	所以, 有时候在我们写下如 AAA = XXX, 这样的代码, 
	且恰好XXX的类型正好是AAA单参数构造器的参数类型, 这时候编译器就自动调用这个构造器,创建一个AAA的对象。
	这样看起来好象很酷, 很方便。 但在某些情况下, 却违背了程序员的本意。
	这时候就要在这个构造器前面加上explicit修饰, 指定这个构造器只能被明确的调用/使用, 
	不能作为类型转换操作符被隐含的使用。
	
	解析:explicit构造函数是用来防止隐式转换的。
	*/
	Test1(int num) : n(num) {
		cout << "Test1 构造函数" << endl;
	}
private:
	int n;
};
class Test2
{
public:
	explicit Test2(int num) : n(num) {
		cout << "Test2 构造函数" << endl;
	}
private:
	int n;
};

int main()
{
	// 自动调用 Test1 的构造函数
	Test1 t1 = 12; // Test1 构造函数

	Test2 t2(13); // Test2 构造函数

	//Test2 t3 = 14;// Error,不存在从 int 转换到 Test2 的适当构造函数

	return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47

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

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

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

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

在 C++ 编译器发生 NRV 优化,如果是引用返回的形式则不会调用拷贝构造函数,如果是值传递的方式依然会发生拷贝构造函数。

在VS2019下进行下述实验:

举个例子:

#include<iostream>
using namespace std;

class A
{
public:	
	A() {

	}
	A(const A& a) {
		cout << "copy contructor is called" << endl;
	}
	~A() {

	}
};
void useClassA(A a) {

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

// VS2019下,此时编辑器会进行(Named return Value优化)NRV优化,不调用拷贝构造函数 ,
// 如果是引用传递的方式返回当前函数体内生成的对象时,并不发生拷贝构造函数的调用
A& getClassA2()
{
	A a;
	return a;
}
int main()
{
	A a1, a3,a4;
	A a2 = a1; // 调用拷贝构造函数,对应情况1
	useClassA(a1); // 调用拷贝构造函数,对应情况2
	a3 = getClassA(); //发生 NRV 优化,但是值返回,依然会有拷贝构造函数的调用,对应情况3
	a4 = getClassA2(); // 发生 NRV 优化,且引用返回自身,不会调用
	return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42

情况1比较好理解

情况2的实现过程是,调用函数时先根据传入的实参产生临时对象,再用拷贝构造去初始化这个临时对象,在函数中与形参对应,函数调用结束后析构临时对象

情况3在执行return时,理论的执行过程是:产生临时对象,调用拷贝构造函数把返回对象拷贝给临时对象,函数执行完先析构局部变量,再析构临时对象, 依然会调用拷贝构造函数

NRV优化

下面程序在 Visual Studio 2019里面的结果是:

good bye 3
0
good bye 3
good bye 2
good bye 1
  • 1
  • 2
  • 3
  • 4
  • 5

但是在 g++ 下编译运行结果是:

0
good bye 3
good bye 2
good bye 1
  • 1
  • 2
  • 3
  • 4

同样的程序在不同的编译器下的执行结果不一样,那么,只有一种解释:编译器在幕后给你干了一些事情。

我想到了《深度探索C++对象模型》中的NRV(Named Return Value)优化。

对于函数:

Trans operator + (const Trans& t1, const Trans& t2)
{
	Trans t(3);
	t.a = t1.a + t2.a;
	return t;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

编译器会将它转换为类似这样:

void operator +(Trans *this, const Trans& t1, const Trans& t2, Trans &__result)
{
    Trans t(3);
	t.a = t1.a + t2.a;
	__result(t);
	return;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

以上的代码在变量和函数名上并没有像编译器一样进行转换,这里只关注编译器是如何返回对象的。
如果编译器采用NVR优化,代码就会被转换成类似这样:

void operator +(Trans *this, const Trans& t1, const Trans& t2, Trans &__result)
{
    __result.Trans::Trans(3);
	__result.a = t1.a + t2.a;
	return;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

因此,如果不采用NVR优化,在返回一个对象时,会再创建一个临时对象用于获取返回值,因此,此函数会产生两个对象,如果采用NVR优化,在返回一个对象时,直接用返回值去取代函数内部的局部对象,此时,函数只产生一个对象。所以,对于VS和GCC的不同,或许(这只是我的一种解释这种行为的想法)可以理解为VS没有采用NVR优化,所以会有两个对象析构,而GCC采用了NVR优化,所以只有一个对象被析构。

#include <iostream>
#include <string>
using namespace std;
 
class Trans 
{
public:
	Trans() : a(12) {};
	Trans(int id) : a(0), i(id) {};
	~Trans() {
		cout << "good bye " << i << endl;
	}
	friend std::ostream& operator<<(ostream&, const Trans&);
	friend Trans operator + (const Trans&, const Trans&);
private:
	int a;
	int i;
};
 
std::ostream& operator<<(ostream& os, const Trans& tra)
{
	return os << tra.a << endl;
}
 
Trans operator + (const Trans& t1, const Trans& t2)
{
	Trans t(3);
	t.a = t1.a + t2.a;
	return t;
}
 
int main()
{
	Trans p(1), q(2);
	cout << p + q;
	return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

41、C++ 中有几种类型的 new

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

(1)plain new

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

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

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

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

(2)nothrow new

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

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

举个例子:

#include<iostream>
#include<string>
using namespace std;
int main()
{
    char* p = new(nothrow) char[10e11];
    if(p == NULL) 
    {
        cout << "alloc failed" << endl;
    }
    delete p;
    return 0;
}
//运行结果:alloc failed
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

(3)placement new

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

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

使用 placement new 需要注意两点:

  • palcement new 的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组
  • placement new 构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用 delete ,这是因为 placement new 构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用 delete 会造成内存泄漏或者之后释放内存时出现运行时错误。

举个例子:

#include<iostream>
#include<string>
using namespace std;
class ADT
{
    int i;
    int j;
public:
    ADT()
    {
        i = 10;
        j = 100;
        cout << "ADT construct i = " << i << ",j = " << j << endl;
    }
    ~ADT()
    {
        cout << "ADT destruct" << endl;
    }
};
int main()
{
	char* p = new(nothrow) char[sizeof ADT + 1];
    if(p == NULL)
    {
        cout << "alloc failed" << endl;
    }
    // placement new:不必担心失败,只要 p 所指对象的空间足够ADT创建即可
    ADT *q = new(p) ADT;
    
    // delete q; // 错误,不能在此处调用 delete q;
    q->ADT::~ADT(); // 显示调用析构函数
    delete[] p;
    return 0;
}
/*
ADT construct i = 10,j = 100
ADT destruct
*/
  • 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

42、C++的异常处理的方法

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

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

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

(1)try、throw和catch关键字

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

#include<iostream>
using namespace std;
int main()
{
    double m = 1, n = 0;
    try
    {
        cout << "before dividing." << endl;
        if (n == 0)
            throw -1; // 抛出 int 型异常
        else if (m == 0)
            throw -1.0; // 抛出 double 型异常
        else
            cout << m / n << endl;
        cout << "after dividing." << endl;
    }
    catch (int d)
    {
        cout << "catch (int)" << d << endl;
    }
    catch (double d)
    {
        cout << "catch (double)" << d << endl;
    }
    catch (...)
    {
        cout << "catch (...)" << endl;
    }
    cout << "finished" << endl;
    return 0;
}
/*
before dividing.
catch (int)-1
finished
*/
  • 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

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

(2)函数的异常声明列表

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

int fun() throw(int,double,A,B,C) {
    //...
}
  • 1
  • 2
  • 3

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

(3)C++ 标准异常类 exception

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FLkrjQsA-1668393620912)(1-C++基础.assets/202205212342667.png)]

  • bad_typeid:使用 typeid 运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL ,则会拋出此异常,例如:
#include<iostream>
#include<typeinfo>
using namespace std;

class A
{
public:
    virtual ~A();
};
int main()
{
    A* a = NULL;
    //A* a = new A;
    try {
        cout << typeid(*a).name() << endl;
    }
    catch (bad_typeid) {
        cout << "Object is NULL" << endl;
    }
    return 0;
}
/*
Object is NULL
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常
  • bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常
  • out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常

C++中virtual(虚函数)的用法

在面向对象的C++语言中,虚函数(virtual function)是一个非常重要的概念。

什么是虚函数:虚函数是指一个类中你希望重载的成员函数 ,当你用一个 基类指针或引用 指向一个继承类对象的时候,调用一个虚函数时, 实际调用的是继承类的版本。

#include <iostream>
using  namespace std;
class Parent
{
public:

    char data[20];
    void Function1();
    virtual void Function2();   // 这里声明Function2是虚函数

}parent;

void Parent::Function1()
{
    printf("This is parent,function1\n");
}

void Parent::Function2()
{
    printf("This is parent,function2\n");
}

class Child :public Parent
{
    void Function1();
    void Function2();

} child;

void Child::Function1()
{
    printf("This is child,function1\n");
}

void Child::Function2()

{
    printf("This is child,function2\n");
}

int main(int argc, char* argv[])
{
    Parent* p; // 定义一个基类指针
    if (getchar() == 'c')    // 如果输入一个小写字母c    
        p = &child;        // 指向继承类对象
    else
        p = &parent;       // 否则指向基类对象
    p->Function1(); // 这里在编译时会直接给出Parent::Function1()的入口地址。    
    p->Function2(); // 注意这里,执行的是哪一个Function2?
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

用任意版本的 Visual Studio 编译并运行,输入一个小写字母 c ,得到下面的结果:

c
This is parent,function1
This is child,function2
  • 1
  • 2
  • 3

为什么会有第一行的结果呢?因为我们是用一个 Parent 类的指针调用函数 Fuction1() ,虽然实际上这个指针指向的是 Child 类的对象,但编译器无法知道这一事实(直到运行的时候,程序才可以根据用户的输入判断出指针指向的对象),它只能按照调用 Parent 类的函数来理解并编译,所以我们看到了第一行的结果。

那么第二行的结果又是怎么回事呢?我们注意到,Function2() 函数在基类中被 virtual 关键字修饰,也就是说,它是一个虚函数。虚函数最关键的特点是“动态联编”,它可以在运行时判断指针指向的对象,并自动调用相应的函数。
如果我们在运行上面的程序时任意输入一个非c的字符,结果如下:

a
This is parent,function1
This is parent,function2
  • 1
  • 2
  • 3

请注意看第二行,它的结果出现了变化。程序中仅仅调用了一个 Function2() 函数,却可以根据用户的输入自动决定到底调用基类中的 Function2 还是继承类中的 Function2 ,这就是虚函数的作用。

一定要注意“静态联编”和“ 动态联编”的区别;

对于我来说,若没有亲自去测试,凭自己的感觉,当在键盘中输入 “c” 时,我会觉得由于有 p=&child; 这一句代码,我会认为结果都是:

This is child,function2
This is child,function2
  • 1
  • 2

但是结果却是:

This is parent,function1
This is child,function2
  • 1
  • 2

因为虽然实际上这个指针指向的是 Child 类的对象,但编译器无法知道这一事实,它只能按照调用 Parent 类的函数来理解并编译,所以我们看到了第一行的结果。
第二行中调用了子类的 Function2 ,完全是因为 virtual 的功能,virtual 实现了动态联编,它可以在运行时判断指针指向的对象,并自动调用相应的函数。

p=&parent;  //这一句,该指针很明显的是指向父类,那么肯定调用的是父类的方法
  • 1

43、static的用法和作用?

1、先来介绍它的第一条也是最重要的一条:隐藏。(static 函数,static 变量均可)

当同时编译多个文件时,所有未加 static 前缀的全局变量和函数都具有全局可见性。

2、static 的第二个作用是保持变量内容的持久。( static 变量中的记忆功能和全局生存期)存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和 static 变量,只不过和全局变量比起来,static 可以控制变量的可见范围,说到底 static 还是用来隐藏的。

3、static 的第三个作用是默认初始化为 0(static 变量)

其实全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是 0x00 ,某些时候这一特点可以减少程序员的工作量。

4、static 的第四个作用:C++ 中的类成员声明 static

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

类内:

  1. static 类对象必须要在类外进行初始化,static 修饰的变量先于对象存在,所以 static 修饰的变量要在类外初始化;
  2. 由于 static 修饰的类成员属于类,不属于对象,因此 static 类成员函数是没有 this 指针的, this 指针是指向本对象的指针。正因为没有 this 指针,所以 static 类成员函数不能访问非 static 的类成员,只能访问 static 修饰的类成员;
  3. static 成员函数不能被 virtual 修饰,static 成员不属于任何对象或实例,所以加上 virtual 没有任何实际意义;静态成员函数没有 this 指针,虚函数的实现是为每一个对象分配一个 vptr 指针,而 vptr 是通过 this 指针调用的,所以不能为 virtual ;虚函数的调用关系,this -> vptr -> ctable -> virtual function

44、指针和const的用法

  1. 当 const 修饰指针时,由于 const 的位置不同,它的修饰对象会有所不同。
  2. int *const p2中 const 修饰 p2 的值,所以理解为 p2 的值不可以改变,即 p2 只能指向固定的一个变量地址,但可以通过 *p2 读写这个变量的值。顶层指针表示指针本身是一个常量
  3. int const *p1或者const int *p1两种情况中 const 修饰*p1,所以理解为*p1的值不可以改变,即不可以给*p1赋值改变p1指向变量的值,但可以通过给 p 赋值不同的地址改变这个指针指向。

底层指针表示指针所指向的变量是一个常量。

45、形参与实参的区别?

  1. 形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元。因此,形参只有在函数内部有效。 函数调用结束返回主调函数后则不能再使用该形参变量。
  2. 实参可以是常量、变量、表达式、函数等, 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。 因此应预先用赋值,输入等办法使实参获得确定值,会产生一个临时变量。
  3. 实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生 “类型不匹配” 的错误。
  4. 函数调用中发生的数据传送是单向的。 即只能把实参的值传送给形参,而不能把形参的值反向地传送给实参。 因此在函数调用过程中,形参的值发生改变,而实参中的值不会变化。
  5. 当形参和实参不是指针类型时,在该函数运行时,形参和实参是不同的变量,他们在内存中位于不同的位置,形参将实参的内容复制一份,在该函数运行结束的时候形参被释放,而实参内容不会改变。

46、值传递、指针传递、引用传递的区别和效率

  1. 值传递:有一个形参向函数所属的栈拷贝数据的过程,如果值传递的对象是类对象 或是大的结构体对象,将耗费一定的时间和空间。(传值)
  2. 指针传递:同样有一个形参向函数所属的栈拷贝数据的过程,但拷贝的数据是一个固定为4字节的地址。(传值,传递的是地址值)
  3. 引用传递:同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。(传地址)
  4. 效率上讲,指针传递和引用传递比值传递效率高。一般主张使用引用传递,代码逻辑上更加紧凑、清晰。

47、静态变量什么时候初始化

  1. 初始化只有一次,但是可以多次赋值,在主程序之前,编译器已经为其分配好了内存。
  2. 静态局部变量和全局变量一样,数据都存放在全局区域,所以在主程序之前,编译器已经为其分配好了内存,但在 C 和 C++ 中静态局部变量的初始化节点又有点不太一样。在 C 中,初始化发生在代码执行之前,编译阶段分配好内存之后,就会进行初始化,所以我们看到在 C 语言中无法使用变量对静态局部变量进行初始化,在程序运行结束,变量所处的全局内存会被全部回收。
  3. 而在 C++ 中,初始化时在执行相关代码时才会进行初始化,主要是由于 C++ 引入对象后,要进行初始化必须执行相应构造函数和析构函数,在构造函数或析构函数中经常会需要进行某些程序中需要进行的特定操作,并非简单地分配内存。所以 C++ 标准定为全局或静态对象是有首次用到时才会进行构造,并通过 atexit() 来管理。在程序结束,按照构造顺序反方向进行逐个析构。所以在 C++ 中是可以使用变量对静态局部变量进行初始化的。

48、const关键字的作用有哪些?

  1. 阻止一个变量被改变,可以使用 const 关键字。在定义该 const 变量时,通常需要对它进行初始化,因为以后就没有机会再去改变它了;
  2. 对指针来说,可以指定指针本身为 const ,也可以指定指针所指的数据为 const ,或二者同时指定为 const ;
  3. 在一个函数声明中,const 可以修饰形参,表明它是一个输入参数,在函数内部不能改变其值;
  4. 对于类的成员函数,若指定其为 const 类型,则表明其是一个常函数,不能修改类的成员变量,类的常对象只能访问类的常成员函数;
  5. 对于类的成员函数,有时候必须指定其返回值为 const 类型,以使得其返回值不为“左值”。
  6. const 成员函数可以访问非 const 对象的非 const 数据成员、const 数据成员,也可以访问 const 对象内的所有数据成员;
  7. 非 const 成员函数可以访问 非const 对象的 非const 数据成员、const 数据成员,但不可以访问const对象的任意数据成员;
  8. 一个没有明确声明为 const 的成员函数被看作是将要修改对象中数据成员的函数,而且编译器不允许它为一个 const 对象所调用。因此 const 对象只能调用 const 成员函数。
  9. const 类型变量可以通过类型转换符 const_cast 将 const类型 转换为 非const类型;
  10. const 类型变量必须定义的时候进行初始化,因此也导致如果类的成员变量有 const类型的变量,那么该变量必须在类的初始化列表中进行初始化;
  11. 对于函数值传递的情况,因为参数传递是通过复制实参创建一个临时变量传递进函数的,函数内只能改变临时变量,但无法改变实参。则这个时候无论加不加 const 对实参不会产生任何影响。但是在引用或指针传递函数调用中,因为传进去的是一个引用或指针,这样函数内部可以改变引用或指针所指向的变量,这时 const 才是实实在在地保护了实参所指向的变量。因为在编译阶段编译器对调用函数的选择是根据实参进行的,所以,只有引用传递和指针传递可以用是否加 const 来重载。一个拥有顶层 const 的形参无法和另一个没有顶层 const 的形参区分开来。

49、什么是类的继承?

1、类与类之间的关系

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

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

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

2、继承的相关概念

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

3、继承的特点

子类拥有父类的所有属性和方法,子类可以拥有父类没有的属性和方法,子类对象可以当做父类对象使用;

4、继承中的访问控制

public、protected、private

5、继承中的构造和析构函数

#include<iostream>
using namespace std;
 
class animal{
	private:
		int weight;
	public:
		animal(){
			weight = 0;
			cout << "in the default constructor of the animal" << endl;
		}
 
		animal(int _weight){
			weight = _weight;
			cout << "in the constructor of the animal" << endl;
		}
 
		~animal(){
			cout << "in the destructor of the animal" << endl;
		}
 
};
 
class catamount{
private:
	int weight;
public:
	catamount(){
		weight = 0;
		cout << "in the default constructor of the catamout" << endl;
	}
 
	catamount(int _weight){
		weight = _weight;
		cout << "in the constructor of the catamount" << endl;
	}
 
	~catamount(){
		cout << "in the destructor of the catamount" << endl;
	}
};
 
class tiger:public catamount, public animal{
private:
	int weight;
public:
	tiger():catamount(),animal(){
		weight = 0;
		cout << "in the default constructor of the tiger" << endl;
	}
	tiger(int weight1, int weight2, int weight3):catamount(weight2), animal(weight3){
		cout << "in the constructor of the tiger" << endl;
		weight = weight3;
	}
	~tiger(){
		cout << "in the destructor of the tiger" << endl;
	}
};
 
 
int main(){
	tiger mTiger(60, 60, 60);
	return 0;
}
/*
in the constructor of the catamount
in the constructor of the animal
in the constructor of the tiger
in the destructor of the tiger
in the destructor of the animal
in the destructor of the catamount
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72

6、继承中的兼容性原则

类的兼容性是指在需要基类对象的任何地方,都可以使用公有派生类的对象来替代,通过公有继承,派生类得到了基类中出构造函数、析构函数之外的所有成员。这样,公有派生类实际上就具有了基类的所有功能,凡是基类可以解决的问题,公有派生类都可以解决。继承类有以下五个原则:
1):子类对象可以当做父类对象使用
2):子类对象可以直接赋值给父类对象
3):子类对象可以直接初始化父类对象
4):父类指针可以直接指向子类对象
5):父类引用可以直接引用子类对象

# include <iostream>
using namespace std;

class Animal
{
public:
	void sleep()
	{
		cout << "Animal sleep" << endl;
	}
};
class Dog :public Animal
{
	void bite() 
	{ 
		cout << "Dog bite" << endl; 
	}
};
int main(int argc, char** argv)
{
	//1 子类对象可以当做父类对象使用
	Dog dog;
	dog.sleep();//子类对象可以调用父类方法
	//2 子类对象可以直接赋值给父类对象
	Animal a;
	a = dog;
	//3 子类对象可以直接初始化父类对象
	Animal b = dog;
	//Dog dog1 = a; //父类对象不可以直接初始化子类对象
	//4 父类指针可以直接指向子类对象
	Animal * c = new Dog;
	//Dog * dog3 = new Animal;//子类指针不可以指向父类对象
	//5 父类引用可以直接引用子类对象
	Animal & d = dog;
	return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

50、从汇编层去解释一下引用

9:      int x = 1;

00401048  mov     dword ptr [ebp-4],1

10:     int &b = x;

0040104F   lea     eax,[ebp-4]

00401052  mov     dword ptr [ebp-8],eax
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

x 的地址为 ebp-4 ,b 的地址为 ebp-8 ,因为栈内的变量内存是从高往低进行分配的,所以 b 的地址比 x 的低。

lea eax,[ebp-4] 这条语句将 x 的地址ebp-4放入eax寄存器

mov dword ptr [ebp-8],eax 这条语句将eax的值放入 b 的地址

ebp-8 中上面两条汇编的作用即:将 x 的地址存入变量 b 中,这不和将某个变量的地址存入指针变量是一样的吗?所以从汇编层次来看,的确引用是通过指针来实现的。

51、深拷贝与浅拷贝可以描述一下吗?

浅复制 :只是拷贝了基本类型的数据,而引用类型数据,复制后也是会发生引用,我们把这种拷贝叫做“(浅复制)浅拷贝”,换句话说,浅复制仅仅是指向被复制的内存地址,如果原地址中对象被改变了,那么浅复制出来的对象也会相应改变。

深复制 :在计算机中开辟了一块新的内存地址用于存放复制的对象。

在某些状况下,类内成员变量需要动态开辟堆内存,如果实行位拷贝,也就是把对象里的值完全复制给另一个对象,如 A=B 。这时,如果 B 中有一个成员变量指针已经申请了内存,那 A 中的那个成员变量也指向同一块内存。这就出现了问题:当 B 把内存释放了(如:析构),这时 A 内的指针就是野指针了,出现运行错误。

52、new和malloc的区别

1、 new/delete 是 C++ 关键字,需要编译器支持。malloc/free 是库函数,需要头文件支持;

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

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

4、 new 内存分配失败时,会抛出 bac_alloc 异常。malloc 分配内存失败时返回 NULL。

5、 new 会先调用 operator new 函数,申请足够的内存(通常底层使用 malloc 实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete 先调用析构函数,然后调用 operator delete 函数释放内存(通常底层使用 free 实现)。malloc/free 是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。

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

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

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

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

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

分配器allocator

首先什么是Allocator?Allocator有什么用?

分配器是负责封装堆内存管理的对象,它们在整个标准库中使用,特别是 STL 容器使用它们来管理r容器内部的所有内存分配,大部份情况下,程序员不用理会,标准容器使用默认的分配器称为 std::allocator ,例如当你声明一个简单的 vector 对象时,C++ 编译器默认已经使用了内置的 std::allocator ,在标准库的 vector 模板当中,第二个模板参数 _Alloc 就是 std::allocator ,实际上, std::allocator 也是一个类模板。(https://zhuanlan.zhihu.com/p/185611161)

int main(){
    std::vector<int> a;
}
  • 1
  • 2
  • 3
template <class _Ty, class _Alloc = allocator<_Ty>>
class vector { // varying size array of values
private:
    template <class>
    friend class _Vb_val;
    friend _Tidy_guard<vector>;

    using _Alty        = _Rebind_alloc_t<_Alloc, _Ty>;
    using _Alty_traits = allocator_traits<_Alty>;

public:
    static_assert(!_ENFORCE_MATCHING_ALLOCATORS || is_same_v<_Ty, typename _Alloc::value_type>,
        _MISMATCHED_ALLOCATOR_MESSAGE("vector<T, Allocator>", "T"));
//...
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

先简要回顾一下历史。Alexander Stepanov(STL之父) 提出了分配器的想法,其动机是使容器完全独立于底层内存模型。他打算让分配器完全封装内存模型,但标准委员会发现这很危险,因为这种方法会导致不可接受的效率下降。因此,分配器的当前用途是让程序员控制容器内的内存分配,而不是采用底层硬件的地址模型

从零实现自己的内存分配器,来理解std::allocator的内部运行机制,自定义allocator有很多现实的原因。

  • 有些嵌入式平台没有提供默认的 malloc/free 等底层内存管理函数,你需要继承 std::allocator ,并封装自定义版本的 malloc/free 等更底层的堆内存管理函数。
  • 使用 C++ 实现自己的数据结构,有时我们需要扩展(继承) std::allocator 。
  • 大部分用 C++ 写的游戏程序都有自己重新实现的 allocator 。

每个容器实例中都有一个 Allocator 实例。它向分配器请求存储来存储元素。分配器应具备的基本成员函数如下:

  • **T*allocate(size_t n);**分配足够的存储空间来存储 T 的 n 个实例,并返回指向它的指针
  • void deallocate(T* p, size_t n) 释放分配的内存
  • void construct(T* p, Args … args);使用 p 指向的 args 参数构造一个对象,该接口在 C++20 中已被移除
  • void destroy(T* p);调用 p 指向的对象的析构函数,该接口在 C++20 中已被移除

标准库中的 allocator 还有一些可选的成员,视不同 C++ 编译器版本而异。

  • 指向类型 T 的指针,以及指向类型为 T 的常量指针
  • 类型 T 的引用,以及类型 T 的常量引用
  • 自身类型 T 的别名 value_type
  • 能表达 allocation 模型中最大对象大小的一个无符号整数类型,并且表示在 allocation 模型中两个指针区别(difference_type) 的有符号整数
  • size_type max_size() 返回有关类型 T 的分配内存的最大值。该接口在 C++20 中已被移除

MyAllocator实现

我们下面从 0 实现一个符合定义的 allocator ,首先我们要在调用层代码,在声明一个 vector 实例,显式传入我们自定义的 MyAllocator ,并使用他来分配内存

int main(void){
    std::vector<int,MyAllocator<int>> v;
}
  • 1
  • 2
  • 3

下面是我们对 MyAllocator 的定义,并且定义了标准库规定的所有类型别名,下面的类型别名在标准库的 std::allocator_traints 中都提供了默认值,因此这里列出所有类型别名仅仅为了完整说明一个 allocator 的实现过程。

template<class T>
class MyAllocator{
public:
      using value_type T;
      using pointer=T*;
      using const_pointer=const T*;
      using void_pointer=void*;
      using const_void_pointer=const void*;
      using size_type size_t;
      using difference_type=std::ptrdiff_t
      
      MyAllocator()=default;
      ~MyAllocator()=default;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

allocator的堆内存管理接口

我们跟着下来需要实现的是allocate成员函数和dealloc成员函数,整个allocator的堆内存管理都围绕这两个接口展开。我们需要对 C++ 的 allocator 的堆内存接口调用顺序有个清晰的认识,如下图所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ceiCOZoj-1668393620912)(1-C++基础.assets/v2-59ed19947f0f359e9a7e1ec585958676_720w.webp)]

我们在 MyAllocator 实现的 allocate 和 deallocate 函数是必须的,

template<class T>
class MyAllocator{
public:
      .....
      pointer allocate(size_type numObjs){
            return static_cast<pointer>(operator new(sizeof(T)*numObjs));
      }
      
      pointer allocate(size_type numObjs,const_void_pointer hit){
              return allocate(numObjs);
      }
     void deallocate(pointer p,size_type numObjs){
            operator delete(p);
     }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

我们这里也定义了另一个 allocate 版本的成员函数,接受一个 numObjs 参数,并接受一个已分配堆内存的指针,他是一个只想最近分配的元素的指针。可以使用他来改进已分配内存的释放,只是为了提供缓存性能,在我们的示例中,我们会忽略它并立即返回。

其他的成员函数实现

size_type max_size() const{
    return std::numeric_limits<size_type>::max();
}
  • 1
  • 2
  • 3

对象构造的问题
这是 C++20 中,被丢弃的内容,但为了完整说明,我在本篇中仍然会提及该内容。

C++20 之前的标准库,在 allocator 中,我们有两个的方法用于构造和销毁对象,一种叫 construct ,用于在我们分配的内存中构造类型 T 的实际对象。需要注意的是,当我们调用 new 操作符分配了原始内存,new 操作符并不会执行任何类型 T 的初始化.

我们看看构造函数是如何工作的,首先,对于 allocator 下的 construct 方法,它也是一个类模板的成员函数,如下代码定义,该成员函数接受一个类型 U 的指针 p (已分配了堆内存),construct 的运行原理非常简单,他在 construct 函数内部原地调用类型 U 的构造函数,并且转发外部的任意参数传递给类型 U 的构造函数

template<class T>
class MyAllocator{
 public:
      //类型别名的代码省略
      //其余代码略

     //构造函数
    template<class U,class...Args>
    void construct(U *p,Args &&...){
          new(p) U(std::forward<Args>(args)...);
     }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

同理的 destroy 接口实现如下

template<class T>
class MyAllocator{
 public:
      //类型别名的代码省略
      //其余代码略

     //构造函数
    template<class U,class...Args>
    void destroy(U *p){
        p->~U();
     }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

这两个函数默认情况下是完全可选,我这里只是展示如何完整的 MyAllocator 的自定义实现。

内置绑定机制

上面实现的必选项和可选项的 allocator 的类成员,都是标准库已经规定的接口名称,没什么好说的。而 allocator 的难点就是 rebind 这个内部类成员,而且它也是一个类模板。

rebind 内部类成员
根据标准库的定义,rebind 被定义为 std::allocator 类的结构成员; 此结构定义了 other 成员,该成员定义为专门用于不同参数类型的分配器的实例( other 成员定义了可以创建不同类型的对象的分配器类)

class MyAllocator{
public:
    .....
     template<class U>
     struct rebind{using other=MyAllocator<U>;}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

std::MyAllocator 模板用于获取某种类型T的对象。 容器内部可能需要分配不同类型的对象。 例如,当您有一个std::list<T,MyAllocator>时,分配器MyAllocator用于分配类型 T 的对象,而std::list<T,MyAllocator>实际上需要分配某个节点类型Node<T>的对象,从模板函数的角度来考虑,我们通常会用另为一个模板参数 U 来表示 调用节点类型Node<T>std::list<T,MyAllocator>需要获得类型 U 对象的分配器,该分配器使用 MyAllocator 提供的分配机制。对于程序员在定义自己的数据结构时,需要在自己实现的容器内部定义类似如下的语句

typename MyAllocator::template rebind<U>::other
  • 1

指定相应的类型。 现在,此声明中有一些语法上令人困惑的地方:

  1. 由于 rebind 是 MyAllocator 的成员模板,而 MyAllocator 是 std::list 模板参数,因此 rebind 成为从属名称。 为了表明从属名称是模板,需要在其前面加上 template 前缀。 如果没有 template 关键字,则<将被视为小于运算符。
  2. other 也取决于模板参数,即它也是一个从属名称。 为了表明从属名称是一种类型,需要 typename 关键字。

那么,我们在自定义的数据结构当中可能多次用到类 U 类型的 allocator 实例,那么我们可以在自定义的数据结构容器类的 private 作用域定义一个类型别名

typedef typename MyAllocator::template rebind<U>::other  Elem_alloc_type
  • 1

关于,MyAllocator 说到这里应该是比较全面的了,他的全貌大概如下代码,若要确认该自定义的MyAllocator 和 std::allocator 一样能够对所有 std 的容器起到作用,不妨在增加一些计数器的数据成员

#include <iostream>
#include <limits>
#include <stdlib.h>
template <class T>
class MyAllocator
{
public:
    //下面是个类型别名,实现中的可选项
    using value_type = T;
    using pointer = T *;
    using const_pointer = const T *;

    using void_pointer = void *;
    using const_void_pointer = const void *;

    using size_type = size_t;
    using difference = std::ptrdiff_t;

    //重新绑定函数
    template <class U>
    struct rebind
    {
        using other = MyAllocator<U>;
    };

    MyAllocator() = default;
    ~MyAllocator() = default;

    //分配内存
    pointer allocate(size_type numObjects)
    {
        allocCount += numObjects;
        std::cout << "MyAllocator::allocate,内存分配:" << numObjects << std::endl;
        return static_cast<pointer>(operator new(sizeof(T) * numObjects));
    }

    //分配内存
    pointer allocate(size_type numObjects, const_void_pointer hint)
    {

        return allocate(numObjects);
    }

    //释放内存
    void deallocate(pointer p, size_type numObjects)
    {
        std::cout << "MyAllocator::deallocate,内存释放:" << numObjects << std::endl;
        allocCount = allocCount - numObjects;
        operator delete(p);
    }

    //分配器支持最大的内存数
    size_type max_size() const
    {
        return std::numeric_limits<size_type>::max();
    }

    // //构造对象
    // template <class U, class... Args>
    // void construct(U *p, Args &&... args)
    // {

    //     new (p) U(std::forward<Args>(args)...);
    // }

    // //销毁对象
    // template <class U>
    // void destroy(U *p)
    // {
    //     p->~U();
    // }

    //返回每次分配/删除的内存数
    size_type get_allocations() const
    {
        return allocCount;
    }

private:
    //统计当前内存的使用量
    size_type allocCount;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82

我们的调用代码基本如下

#include "headers/Allocator.hh"
#include <cinttypes>
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <vector>

int main()
{
    std::vector<int, MyAllocator<int>> v(0);

    for (size_t i = 0; i < 30; i++)
    {
        sleep(1);
        v.push_back(i);
        std::cout << "当前容器内存占用量:" << v.get_allocator().get_allocations() << std::endl;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

用 vector 执行下面的测试如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XYYphQFi-1668393620913)(1-C++基础.assets/v2-8070e54be20cce49ed7bd9fb71c06664_720w.webp)]

测试效果

小结

这里没有生硬地罗列一堆 std::allocator 的 api ,而是采用原生实现的 MyAllocator 来说明 std::allocator 的内部机制。

  • 分配器是 STL 容器的内存分配器。该容器可以将内存分配和取消分配与其元素的初始化和销毁分开。 因此,调用向量 vec 的 vec.reserve(n)仅为至少 n 个元素分配内存。 每个元素的构造函数都不会执行。因为我们在最终的 MyAllocator 的 construct 接口和 destoy 接口取消了。
  • 我们可以根据需要的容器来调整分配器,例如,您只希望 vector 内存 resize 操作的比例
  • new 不允许控制调用哪些构造函数,而只能同时构造所有对象。 这是 std:: 分配器优于 new 的优势

vector 和 内存池

最近看内存池有关的东西,又回顾了一下之前看 C++ Primer 时自己写的 vector ,发现只是对基本的 Vector 的成员函数进行了重写,最重要的地方也就是分配器用的都是默认的,所以内存分配这块之前就没弄清楚。

template<class _Ty,
class _Alloc = allocator<_Ty> >
	class vector
		: public _Vector_alloc<_Vec_base_types<_Ty, _Alloc> >
  • 1
  • 2
  • 3
  • 4

从 STL 中 vector 的源码可以看出,vector 分为两个部分,一个是参数类型 _Ty,这里的 class 跟 typename 是同一个东西,没有区别;还有一个是分配器 _Alloc ,默认是 allocator< _Ty > ,内存池实现就是在这里,自带的分配器只是简单的 new 一个内存,vector 实现的时候,设立三个指针:

private:
	std::string *elements;				//分配内存的头指针
	std::string	*first_free;			//第一个空闲位置的指针
	std::string *cap;					//开的内存容量指针
	std::allocator<std::string> alloc;	//分配器
  • 1
  • 2
  • 3
  • 4
  • 5

这样简单的判断size==capacity时,重新分配一个大小是原本两倍的内存,再使用移动赋值将原本的数据迁移到新内存中。

下面的表格是一个分配器所需实现的内存处理成员函数:

成员函数简介
allocate分配未初始化的存储 (公开成员函数)
deallocate解分配存储 (公开成员函数)
construct在分配的存储构造对象 (公开成员函数)
destroy析构在已分配存储中的对象 (公开成员函数)

通过实现重载这些成员函数可以实现内存池。

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

1、 new 简单类型直接调用 operator new 分配内存;

而对于复杂结构,先调用 operator new 分配内存,然后在分配的内存上调用构造函数;

对于简单类型,new[] 计算好大小后调用 operator new ;

对于复杂数据结构,new[] 先调用 operator new[] 分配内存,然后在 p 的前四个字节写入数组大小 n ,然后调用 n 次构造函数,针对复杂类型,new[] 会额外存储数组大小;

① new 表达式调用一个名为 operator new(operator new[]) 函数,分配一块足够大的、原始的、未命名的内存空间;

② 编译器运行相应的构造函数以构造这些对象,并为其传入初始值;

③ 对象被分配了空间并构造完成,返回一个指向该对象的指针。

2、 delete 简单数据类型默认只是调用 free 函数;复杂数据类型先调用 析构函数 再调用 operator delete;针对简单类型,delete 和 delete[] 等同。假设指针 p 指向 new[] 分配的内存。因为要 4 字节存储数组大小,实际分配的内存地址为 [p-4] ,系统记录的也是这个地址。delete[] 实际释放的就是 p-4 指向的内存。而 delete 会直接释放 p 指向的内存,这个内存根本没有被系统记录,所以会崩溃。

3、 需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。

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

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

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

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

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

56、malloc与free的实现原理?

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

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

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

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

57、malloc、realloc、calloc的区别

1、malloc 函数

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

2、calloc 函数

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

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

3、realloc 函数

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

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

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

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

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

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

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

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

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

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

③ 类类型的成员对象的构造函数(按照初始化顺序)

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

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

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

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

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

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

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

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

2、成员初始化列表做了什么

① 编译器会一一操作初始化列表,以适当的顺序在构造函数之内安插初始化操作,并且在任何显示用户代码之前;

② list 中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表的顺序决定的;

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

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

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

61、什么是内存泄露,如何检测与避免

内存泄露

一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用 malloc,、realloc、 new 等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用 free 或 delete 释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了

避免内存泄露的几种方式

  • 计数法:使用 new 或者 malloc 时,让该数 +1 ,delete 或 free 时,该数 -1 ,程序执行完打印这个计数,如果不为 0 则表示存在内存泄露
  • 一定要将基类的析构函数声明为虚函数
  • 对象数组的释放一定要用delete []
  • 有 new 就有 delete ,有 malloc 就有 free ,保证它们一定成对出现

检测工具

  • Linux 下可以使用Valgrind工具(https://zhuanlan.zhihu.com/p/75416381)
  • Windows 下可以使用CRT库

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

对象复用

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

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

零拷贝

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

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

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

#include<vector>
#include<string>
#include<iostream>
using namespace std;

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

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

    vector<Person> p;
    cout << "push_back:" << endl;
    p.push_back(Person("Mike", 35));
    return 0;
}
/*
emplace_back:
I have been constructed
push_back:
I have been constructed
I have been moved
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42

63、介绍面向对象的三大特性,并且举例说明

三大特性:继承、封装和多态

(1)继承

让某种类型对象获得另一个类型对象的属性和方法。

它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展

常见的继承有三种方式:

  1. 实现继承:指使用基类的属性和方法而无需额外编码的能力
  2. 接口继承:指仅使用属性和方法的名称、但是子类必须提供实现的能力
  3. 可视继承:指子窗体(类)使用基窗体(类)的外观和实现代码的能力(C++ 里好像不怎么用)

例如,将人定义为一个抽象类,拥有姓名、性别、年龄等公共属性,吃饭、睡觉、走路等公共方法,在定义一个具体的人时,就可以继承这个抽象类,既保留了公共属性和方法,也可以在此基础上扩展跳舞、唱歌等特有方法

(2)封装

数据和代码捆绑在一起,避免外界干扰和不确定性访问。

封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏,例如:将公共的数据或方法使用 public 修饰,而不希望被访问的数据或方法采用 private 修饰。

(3)多态

同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为**(重载实现编译时多态,虚函数实现运行时多态)**。

多态性是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单一句话:允许将子类类型的指针赋值给父类类型的指针

实现多态有二种方式:覆盖(override),重载(overload)。

覆盖:是指子类重新定义父类的虚函数的做法。

重载:是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。例如:基类是一个抽象对象——人,那教师、运动员也是人,而使用这个抽象对象既可以表示教师、也可以表示运动员。

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

成员初始化列表的概念

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

效率

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

#include<iostream>
using namespace std;
class A
{
public:
    A()
    {
        cout << "默认构造函数A()" << endl;
    }
    A(int a)
    {
        value = a;
        cout << "A(int " << value << ")" << endl;
    }
    A(const A& a)
    {
        value = a.value;
        cout << "拷贝构造函数A(A& a): " << value << endl;
    }
    int value;
};
class B
{
public:
    B() : a(1)
    {
        b = A(2);
    }
    A a;
    A b;
};
int main()
{
	B b;
}
/*
A(int 1)
默认构造函数A()
A(int 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
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

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

65、C++的四种强制转换reinterpret_cast / const_cast / static_cast / dynamic_cast

reinterpret_cast

reinterpret_cast<type-id> (expression)

type-id 必须是一个指针、引用、算术类型、函数指针或者成员指针。它可以用于类型之间进行强制转换。

const_cast

const_cast<type_id> (expression)

该运算符用来修改类型的 const 或 volatile 属性。除了 const 或 volatile 修饰之外, type_id 和 expression 的类型是一样的。用法如下:

  • 常量指针被转化成非常量的指针,并且仍然指向原来的对象
  • 常量引用被转换成非常量的引用,并且仍然指向原来的对象
  • const_cast 一般用于修改底指针。如 const char *p 形式

static_cast

static_cast<type-id> (expression)

该运算符把 expression 转换为 type-id 类型,但没有运行时类型检查来保证转换的安全性。它主要有如下几种用法:

  • 用于类层次结构中基类(父类)和派生类(子类)之间指针或引用引用的转换
    • 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的
    • 进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的
  • 用于基本数据类型之间的转换,如把 int 转换成 char,把 int 转换成 enum 。这种转换的安全性也要开发人员来保证。
  • 把空指针转换成目标类型的空指针
  • 把任何类型的表达式转换成 void 类型

注意:static_cast 不能转换掉 expression 的 const 、volatile 、或者 __unaligned 属性。

dynamic_cast

有类型检查,基类向派生类转换比较安全,但是派生类向基类转换则不太安全

dynamic_cast<type-id> (expression)

该运算符把 expression 转换成 type-id 类型的对象。type-id 必须是类的指针、类的引用或者 void*

如果 type-id 是类指针类型,那么 expression 也必须是一个指针,如果 type-id 是一个引用,那么 expression 也必须是一个引用

dynamic_cast 运算符可以在执行期决定真正的类型,也就是说 expression 必须是多态类型。如果下行转换是安全的(也就说,如果基类指针或者引用确实指向一个派生类对象)这个运算符会传回适当转型过的指针。如果 如果下行转换不安全,这个运算符会传回空指针(也就是说,基类指针或者引用没有指向一个派生类对象)

dynamic_cast 主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换

在类层次间进行上行转换时,dynamic_cast 和 static_cast 的效果是一样的

在进行下行转换时,dynamic_cast 具有类型检查的功能,比 static_cast 更安全

举个例子:

#include<iostream>
using namespace std;
class Base
{
public:
    Base() : b(1)
    {
        
    }
    virtual void fun()
    {
        
    }
    int b;
};
class Son : public Base
{
public:
    Son() : d(2) 
    {
        
    }
    int d;
};
int main()
{
    int n = 97;
    // reinterpret_cast
    int* p = &n;
    // 以下两种效果相同
    char* c = reinterpret_cast<char*>(p);
    char* c2 = (char*)(p);
    cout << "reinterpret_cast输出:" << *c2 << endl;
    
    // const_cast
    const int* p2 = &n;
    int* p3 = const_cast<int*>(p2);
    *p3 = 100;
    cout << "const_cast输出:" << *p3 << endl;
    
    Base* b1 = new Son;
    Base* b2 = new Base;
    
    // static_case
    Son* s1 = static_cast<Son*>(b1); // 同类型安全转换
    Son* s2 = static_cast<Son*>(b2); // 下行转换,不安全
    cout << "static_cast输出:" << endl;
    cout << s1->d << endl;
    cout << s2->d << endl; // 下行转换,原先父对象没有 d 成员,输出垃圾值
    
    // dynamic_cast
    Son* s3 = dynamic_cast<Son*>(b1); // 同类型转换
    Son* s4 = dynamic_cast<Son*>(b2); // 下行转换,安全
    cout << "dynamic_cast输出:" << endl;
    cout << s3->d << endl;
    if(s4 == nullptr) 
        cout << "s4指针为 nullptr" << endl;
    else
        cout << s4->d << endl;
    return 0;
}
/*
reinterpret_cast输出:a
const_cast输出:100
static_cast输出:
2
-33686019
dynamic_cast输出:
2
s4指针为 nullptr
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71

从输出结果可以看出,在进行下行转换时,dynamic_cast 安全的,如果下行转换不安全的话其会返回空指针,这样在进行操作的时候可以预先判断。而使用 static_cast 下行转换存在不安全的情况也可以转换成功,但是直接使用转换后的对象进行操作容易造成错误。

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

以例子进行讲解

从代码入手,解释这个过程:

#include<iostream>
using namespace std;
int f(int n)
{
    cout << n << endl;
    return n;
}

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

int main(int argc,char* argv[])
{
    func(1,2);
    return 0;
}
/*
2
1
var1 = 1,var2 = 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

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

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

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

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

文字化表述

函数的调用过程:

1)从栈空间分配存储空间

2)从实参的存储空间复制值到形参栈空间

3)进行运算

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

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

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

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

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

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

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

mkdir coredumpTest
vim coredumpTest.cpp
  • 1
  • 2

在编辑器内键入

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

编译

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

运行

./coredumpTest
  • 1

使用 gdb 调试 coredump

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

68、说说移动构造函数

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

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

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

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

69、C++ 中将临时变量作为返回值时的处理过程

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

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

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

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

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

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

举个例子:

#include<iostream>
#include<stddef.h>
using namespace std;
struct S
{
    int x;
    char y;
    int z;
    double a;
};
int main()
{
	cout << offsetof(S,x) << endl; // 0
    cout << offsetof(S,y) << endl; // 4
    cout << offsetof(S,z) << endl; // 8
    cout << offsetof(S,a) << endl; // 16 这里是16的位置,因为 double是8字节,需要找一个8的倍数对齐
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

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

#include<iostream>
#include<stddef.h>
using namespace std;

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5YhuMQZt-1668393620913)(1-C++基础.assets/202205220035079.png)]

71、静态类型和动态类型,静态绑定和动态绑定的介绍

  • 静态类型:对象在声明时采用的类型,在编译期既已确定;
  • 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
  • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
  • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

从上面的定义也可以看出,非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。 举个例子:

#include<iostream>
using namespace std;
class A
{
public:
    /*virtual*/ void func()
    {
        std::cout << "A::func()\n" << std::endl;
    }
};
class B : public A
{
public:
    void func()
    {
        std::cout << "B::func()" << std::endl;
    }
};
class C : public A
{
public:
    void func()
    {
        std::cout << "C::func()" << std::endl;
    }
};

int main()
{
    C* pc = new C(); // pc的静态类型是它声明的类型C*,动态类型也是C*
    B* pb = new B(); // pb的静态类型和动态类型也都是B*
    A* pa = pc; // pa的静态类型是它声明的类型A*,动态类型是pa所指向的对象pc的类型C*
    pa = pb; //pa的动态类型可以更改,现在它的类型是B*,但其静态类型仍然是声明时候的A*
    C* pnull = NULL; // pnull 的静态类型是它声明的类型C*,没有动态类型
    
    pa->func(); // A::func() pa的静态类型永远是A*,不管其指向的哪个子类,都是直接调用A::func()
    pc->func(); // C::func() pc的动、静态类型都是C*,因此调用C::func()
    pnull->func(); // C::func() 不用奇怪为什么空指针也能调用函数,因为这在编译器就确定了,和指针是否为空没有关系
    return 0;
}
/*
A::func()
C::func()
C::func()
*/

  • 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

如果将 A 类中的 virtual 注释去掉,则运行结果是:

pa->func();      //B::func() 因为有了virtual虚函数特性,pa的动态类型指向B*,因此先在B中查找,找到后直接调用;
pc->func();      //C::func() pc的动、静态类型都是C*,因此也是先在C中查找;
pnull->func();   //空指针异常,因为是func是virtual函数,因此对func的调用只能等到运行期才能确定,然后才发现pnull是空指针;
  • 1
  • 2
  • 3

在上面的例子中,

  • 如果基类 A 中的 func 不是 virtual 函数,那么不论 pa、pb、pc 指向哪个子类对象,对 func 的调用都是在定义 pa、pb、pc 时的静态类型决定,早已在编译期确定了。
  • 同样的空指针也能够直接调用 no-virtual 函数而不报错(这也说明一定要做空指针检查啊!),因此静态绑定不能实现多态;
  • 如果 func 是虚函数,那所有的调用都要等到运行时根据其指向对象的类型才能确定,比起静态绑定自然是要有性能损失的,但是却能实现多态特性;

本文代码里都是针对指针的情况来分析的,但是对于引用的情况同样适用。

至此总结一下静态绑定和动态绑定的区别:

  • 静态绑定发生在编译期,动态绑定发生在运行期;
  • 对象的动态类型可以更改,但是静态类型无法更改;
  • 要想实现动态,必须使用动态绑定;
  • 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

建议:

绝对不要重新定义继承而来的非虚( non-virtual )函数(《Effective C++ 第三版》条款36),因为这样导致函数调用由对象声明时的静态类型确定了,而和对象本身脱离了关系,没有多态,也这将给程序留下不可预知的隐患和莫名其妙的 BUG ;另外,在动态绑定也即在 virtual 函数中,要注意默认参数的使用。当缺省参数和 virtual 函数一起使用的时候一定要谨慎,不然出了问题怕是很难排查。 看下面的代码:

#include<iostream>
using namespace std;

class E
{
public:
    virtual void func(int i = 0)
    {
        std::cout << "E::func()\t" << i << std::endl;
    }
};

class F : public E
{
public:
    virtual void func(int i = 1)
    {
        std::cout << "F::func()\t" << i << std::endl;
    }
};
int main()
{
    F* pf = new F();
    E* pe = pf;
    pf->func(); // F::func() 1 正常
    pe->func(); // F::func() 0 调用子类的函数,却使用了基类中参数的默认值
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

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

可以。

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

举个例子:

#include<iostream>
using namespace std;

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

int main()
{
    Son s;
    Base& b = s; // 基类类型引用绑定已经存在的Song对象,引用必须初始化
    s.fun(); // Son::fun()
    b.fun(); // Son::fun()
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

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

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

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

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

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

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

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

举个例子:

#include<iostream>
using namespace std;
int main()
{
    int *a,*b,c;
    a = (int*)0x500;
    b = (int*)0x520;
    c = b - a;
    printf("%d\n",c); // 8
    a += 0x020;
    c = b - a;
    printf("%d\n",c); //-24
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

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

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

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

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

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

76、方法调用的原理(栈,汇编)

1、机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。而为单个过程分配的那部分栈称为帧栈;帧栈可以认为是程序栈的一段,它有两个端点,一个标识起始地址,一个标识着结束地址,两个指针结束地址指针esp,开始地址指针ebp;

2、由一系列栈帧构成,这些栈帧对应一个过程,而且每一个栈指针+4的位置存储函数返回地址;每一个栈帧都建立在调用者的下方,当被调用者执行完毕时,这一段栈帧会被释放。由于栈帧是向地址递减的方向延伸,因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。如果将栈指针加上一定的值,也就是向上移动,那么就相当于压缩了栈帧的长度,也就是说内存被释放了。

3、过程实现

① 备份原来的帧指针,调整当前的栈帧指针到栈指针位置;

② 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存;

③ 使用建立好的栈帧,比如读取和写入,一般使用 mov,push 以及 pop 指令等等。

④ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了

⑤ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。

⑥ 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理。

⑦ 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。

⑧ 弹出返回地址,跳出当前过程,继续执行调用者的代码。

4、过程调用和返回指令

① call指令

② leave指令

③ ret指令

77、C++ 中的指针参数传递和引用参数传递有什么区别?底层原理你知道吗?

1) 指针参数传递本质上是值传递,它所传递的是一个地址值。

值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。

值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。

2) 引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。

被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。

因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

3) 引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。

而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。

4) 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。

指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。

符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。

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

1、前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建

2、建立类的对象有两种方式:

① 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;

② 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;

3、只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有。

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

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

所以必须定义而非声明。

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

向上类型转换

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

向下类型转换

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

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

一:继承

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

继承的缺点有以下几点:

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

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

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

二:组合

组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。

组合的优点:

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

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

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

组合的缺点:

①:容易产生过多的对象。

②:为了能组合多个对象,必须仔细对接口进行定义。

82、函数指针?

1、什么是函数指针?

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

一个具体函数的名字,如果后面不跟调用符号(即括号),则该名字就是该函数的指针(注意:大部分情况下,可以这么认为,但这种说法并不很严格)。

2、函数指针的声明方法

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

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

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

而这声明了一个函数 pf ,其返回类型为 int* , 带有两个 const int& 参数。

3、为什么有函数指针

函数与数据项相似,函数也有地址。我们希望在同一个函数中通过使用相同的形参在不同的时间使用产生不同的效果。

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

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

5、两种方法赋值:

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

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

1、 分配内存的顺序是按照声明的顺序。

2、 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。

3、 最后整个结构体的大小必须是里面变量类型最大值的整数倍。

添加了#pragma pack(n)后规则就变成了下面这样:

1、 偏移量要是n和当前变量大小中较小值的整数倍

2、 整体大小要是n和最大变量大小中较小值的整数倍

3、 n值必须为1,2,4,8…,为其他值时就按照默认的分配规则

84、 结构体变量比较是否相等

1、重载了 “==” 操作符

struct foo
{
    int a;
    int b;
    bool operator==(const foo& rhs) // ==操作符重载
    {
        return (a == rhs.a) && (b == rhs.b);
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

2、元素的话,一个个比;

3、指针直接比较,如果保存的是同一个实例地址,则(p1==p2)为真;

85、 函数调用过程栈的变化,返回值和参数变量哪个先入栈?

1、调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;

2、调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);

3、在被调函数中,被调函数会先保存调用者函数的栈底地址(push ebp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov ebp,esp);

4、在被调函数中,从 ebp 的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈;

86、#define、const、typedef、inline 的使用方法?他们之间有什么区别?

一、const 与 #define 的区别:

  1. const 定义的常量是变量带类型,而 #define 定义的只是个常数不带类型;
  2. #define 只在预处理阶段起作用,简单的文本替换,而 const 在编译、链接过程中起作用;
  3. #define 只是简单的字符串替换没有类型检查。而 const 是有数据类型的,是要进行判断的,可以避免一些低级错误;
  4. #define 预处理后,占用代码段空间,const 占用数据段空间;
  5. const 不能重定义,而 #define 可以通过 #undef 取消某个符号的定义,进行重定义;
  6. #define 独特功能,比如可以用来防止文件重复引用。

二、#define 和别名 typedef 的区别

  1. 执行时间不同,typedef 在编译阶段有效,typedef 有类型检查的功能;#define 是宏定义,发生在预处理阶段,不进行类型检查;
  2. 功能差异,typedef 用来定义类型的别名,定义与平台无关的数据类型,与 struct 的结合使用等。#define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。
  3. 作用域不同,#define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。而 typedef 有自己的作用域。

三、#define 与 inline 的区别

  1. #define 是关键字,inline 是函数;
  2. 宏定义在预处理阶段进行文本替换,inline 函数在编译阶段进行替换;
  3. inline 函数有类型检查,相比宏定义比较安全;

inline

引入inline关键字的原因

在 c/c++ 中,为了解决一些频繁调用的小函数大量消耗栈空间(栈内存)的问题,特别的引入了 inline 修饰符,表示为内联函数。

栈空间就是指放置程序的局部数据(也就是函数内数据)的内存空间。

在系统下,栈空间是有限的,假如频繁大量的使用就会造成因栈空间不足而导致程序出错的问题,如,函数的死循环递归调用的最终结果就是导致栈内存空间枯竭。

#include <stdio.h>
//函数定义为inline即:内联函数
inline char* dbtest(int i) {
    return (i % 2 > 0) ? "奇" : "偶";
} 

int main()
{
   int i = 0;
   for (i=1; i < 100; i++) {
       printf("i:%d    奇偶性:%s /n", i, dbtest(i));    
   }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

上面的例子就是标准的内联函数的用法,使用 inline 修饰带来的好处我们表面看不出来,其实,在内部的工作就是在每个for循环的内部任何调用 dbtest(i) 的地方都换成了(i%2>0)?”奇”:”偶”,这样就避免了频繁调用函数对栈内存重复开辟所带来的消耗

inline使用限制

inline 的使用时有所限制的,inline 只适合函数体内部代码简单的函数使用,不能包含复杂的结构控制语句例如 while、switch ,并且不能内联函数本身不能是直接递归函数(即,自己内部还调用自己的函数)。

inline仅是一个对编译器的建议

inline 函数仅仅是一个对编译器的建议,所以最后能否真正内联,看编译器的意思:它如果认为函数不复杂,能在调用点展开,就会真正内联,并不是说声明了内联就会内联,声明内联只是一个建议而已。

建议:inline函数的定义放在头文件中

因为内联函数要在调用点展开,所以编译器必须随处可见内联函数的定义,要不然就成了非内联函数的调用了。所以,这要求:每个调用了内联函数的文件都出现了该内联函数的定义。
因此,将内联函数的定义放在头文件里实现是合适的,省却为每个文件实现一次的麻烦。

声明跟定义要一致:如果在每个文件里都实现一次该内联函数的话,那么,最好保证每个定义都是一样的,否则,将会引起未定义的行为。如果不是每个文件里的定义都一样,那么,编译器展开的是哪一个,那要看具体的编译器而定。所以,最好将内联函数定义放在头文件中。

类中的成员函数与inline

定义在类中的成员函数缺省都是内联的

如果在类定义时就在类内给出函数定义,那当然最好。
如果在类中未给出成员函数定义,而又想内联该函数的话,那在类外要加上 inline ,否则就认为不是内联的。

class A
{
    public:void Foo(int x, int y) {  } // 自动地成为内联函数
}
  • 1
  • 2
  • 3
  • 4

将成员函数的定义体放在类声明之中虽然能带来书写上的方便,但不是一种良好的编程风格,上例应该改成:

// 头文件
class A
{
    public:
    void Foo(int x, int y);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
// 定义文件
inline void A::Foo(int x, int y){} 
  • 1
  • 2
inline 是一种“用于实现的关键字”

关键字 inline 必须与函数定义体放在一起才能使函数成为内联,仅将 inline 放在函数声明前面不起任何作用。

如下风格的函数 Foo 不能成为内联函数

inline void Foo(int x, int y); // inline 仅与函数声明放在一起,不可以!

void Foo(int x, int y){}
  • 1
  • 2
  • 3
  • 而如下风格的函数Foo则可以成为内联函数
void Foo(int x, int y);

inline void Foo(int x, int y) {} // inline 与函数定义体放在一起
  • 1
  • 2
  • 3

因此,inline 是一种“用于实现的关键字”,而不是一种“用于声明的关键字”

一般地,用户可以阅读函数的声明,但是看不到函数的定义。尽管在大多数教科书中内联函数的声明、定义体前面都加了 inline 关键字,但我认为 inline 不应该出现在函数的声明中。这个细节虽然不会影响函数的功能,但是体现了高质量 C++/C 程序设计风格的一个基本原则:声明与定义不可混为一谈,用户没有必要、也不应该知道函数是否需要内联。

慎用 inline

虽然说内联函数可以提高执行效率,但是不可以将所有的函数都定义为内联函数。
内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率
如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
以下的情况不适合用内联函数:

  • 如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
  • 如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。一个好的编译器将会根据函数的定义体,自动地取消不值得的内联(这进一步说明了 inline 不应该出现在函数的声明中)。
小结

内联函数并不是一个增强性能的灵丹妙药。只有当函数非常短小的时候它才能得到我们想要的效果;但是,如果函数并不是很短而且在很多地方都被调用的话,那么将会使得可执行体的体积增大。
最令人烦恼的还是当编译器拒绝内联的时候。在老的实现中,结果很不尽人意,虽然在新的实现中有很大的改善,但是仍然还是不那么完善的。一些编译器能够足够的聪明来指出哪些函数可以内联哪些不能,但是大多数编译器就不那么聪明了,因此这就需要我们的经验来判断。

如果内联函数不能增强性能,就避免使用它!

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

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

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

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

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

1、模板定义很特殊。由template<…>处理的任何东西都意味着编译器在当时不为它分配存储空间,它一直处于等待状态直到被一个模板实例告知。在编译器和连接器的某一处,有一机制能去掉指定模板的多重定义。

所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。

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

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

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

89、C++ 中类成员的访问权限和继承权限问题

1、三种访问权限

① public:用该关键字修饰的成员表示公有成员,该成员不仅可以在类内可以被 访问,在类外也是可以被访问的,是类对外提供的可访问接口;

② private:用该关键字修饰的成员表示私有成员,该成员仅在类内可以被访问,在类体外是隐藏状态;

③ protected:用该关键字修饰的成员表示保护成员,保护成员在类体外同样是隐藏状态,但是对于该类的派生类来说,相当于公有成员,在派生类中可以被访问。

2、三种继承方式

① 若继承方式是public,基类成员在派生类中的访问权限保持不变,也就是说,基类中的成员访问权限,在派生类中仍然保持原来的访问权限;

② 若继承方式是private,基类所有成员在派生类中的访问权限都会变为私有(private)权限;

③ 若继承方式是protected,基类的共有成员和保护成员在派生类中的访问权限都会变为保护(protected)权限,私有成员在派生类中的访问权限仍然是私有(private)权限。

90、cout 和 printf 有什么区别?

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

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

cout是有缓冲输出:

cout << "abc" << endl;
或
cout << "abc\n"; cout << flush; // 这两个才是一样的
  • 1
  • 2
  • 3

flush 立即强迫缓冲输出。

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

91、你知道重载运算符吗?

1、 我们只能重载已有的运算符,而无权发明新的运算符;对于一个重载的运算符,其优先级和结合律与内置类型一致才可以;不能改变运算符操作数个数;

2、 两种重载方式:成员运算符和非成员运算符,成员运算符比非成员运算符少一个参数;下标运算符、箭头运算符必须是成员运算符;

3、 引入运算符重载,是为了实现类的多态性;

4、 当重载的运算符是成员函数时,this 绑定到左侧运算符对象。成员运算符函数的参数数量比运算符对象的数量少一个;至少含有一个类类型的参数;

5、 从参数的个数推断到底定义的是哪种运算符,当运算符既是一元运算符又是二元运算符(+,-,*,&);

6、 下标运算符必须是成员函数,下标运算符通常以所访问元素的引用作为返回值,同时最好定义下标运算符的常量版本和非常量版本;

7、 箭头运算符必须是类的成员,解引用通常也是类的成员;重载的箭头运算符必须返回类的指针;

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

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

93、定义和声明的区别

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

如果是指函数的声明和定义: 声明:一般在头文件里,对编译器说:这里我有一个函数叫function() 让编译器知道这个函数的存在。 定义:一般在源文件里,具体就是函数的实现过程 写明函数体。

94、全局变量和static变量的区别

1、全局变量(外部变量)的说明之前再冠以 static 就构成了静态的全局变量。

全局变量本身就是静态存储方式,静态全局变量当然也是静态存储方式。

这两者在存储方式上并无不同。这两者的区别在于非静态全局变量的作用域是整个源程序,当一个源程序由多个原文件组成时,非静态的全局变量在各个源文件中都是有效的。

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

static 全局变量与普通的全局变量的区别是 static 全局变量只初始化一次,防止在其他文件单元被引用。

2、static 函数与普通函数有什么区别? static 函数与普通的函数作用域不同。尽在本文件中。只在当前源文件中使用的函数应该说明为内部函数(static),内部函数应该在当前源文件中说明和定义。

对于可在当前源文件以外使用的函数应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。 static 函数与普通函数最主要区别是 static 函数在内存中只有一份,普通静态函数在每个被调用中维持一份拷贝程序的局部变量存在于(堆栈)中,全局变量存在于(静态区)中,动态申请数据存在于(堆)

95、 静态成员与普通成员的区别是什么?

1、生命周期

静态成员变量从类被加载开始到类被卸载,一直存在;

普通成员变量只有在类创建对象后才开始存在,对象结束,它的生命期结束;

2、共享方式

静态成员变量是全类共享;普通成员变量是每个对象单独享用的;

3、定义位置

普通成员变量存储在栈或堆中,而静态成员变量存储在静态全局区;

4、初始化位置

普通成员变量在类中初始化;静态成员变量在类外初始化;

5、默认实参

可以使用静态成员变量作为默认实参

96、说一下你理解的 ifdef endif代表着什么?

1、一般情况下,源程序中所有的行都参加编译。但是有时希望对其中一部分内容只在满足一定条件才进行编译,也就是对一部分内容指定编译的条件,这就是“条件编译”。有时,希望当满足某条件时对一组语句进行编译,而当条件不满足时则编译另一组语句。

2、条件编译命令最常见的形式为:

#ifdef 标识符
程序段1
#else
程序段2
#endif
  • 1
  • 2
  • 3
  • 4
  • 5

它的作用是:当标识符已经被定义过(一般是用 #define 命令定义),则对 程序段1 进行编译,否则编译 程序段2 。 其中 #else 部分也可以没有,即:

#ifdef 标识符
程序段1
#endif
  • 1
  • 2
  • 3

3、在一个大的软件工程里面,可能会有多个文件同时包含一个头文件,当这些文件编译链接成一个可执行文件上时,就会出现大量“重定义”错误。

在头文件中使用 #define 、#ifndef 、#ifdef 、#endif 能避免头文件重定义。

97、隐式转换,如何消除隐式转换?

1、C++ 的基本类型中并非完全的对立,部分数据类型之间是可以进行隐式转换的。所谓隐式转换,是指不需要用户干预,编译器私下进行的类型转换行为。很多时候用户可能都不知道进行了哪些转换

2、C++ 面向对象的多态特性,就是通过父类的类型实现对子类的封装。通过隐式转换,你可以直接将一个子类的对象使用父类的类型进行返回。在比如,数值和布尔类型的转换,整数和浮点数的转换等。某些方面来说,隐式转换给 C++ 程序开发者带来了不小的便捷。C++ 是一门强类型语言,类型的检查是非常严格的。

3、 基本数据类型的转换以取值范围的作为转换基础(保证精度不丢失)。隐式转换发生在从小->大的转换中。比如从 char 转换为 int 。从 int->long 。自定义对象子类对象可以隐式的转换为父类对象。

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

5、如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制。可以通过将构造函数声明为 explicit 加以制止隐式类型转换,关键字 explicit 只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为 explicit 。

98、C++如何处理多个异常的?

1、C++ 中的异常情况: 语法错误(编译错误):比如变量未定义、括号不匹配、关键字拼写错误等等编译器在编译时能发现的错误,这类错误可以及时被编译器发现,而且可以及时知道出错的位置及原因,方便改正。 运行时错误:比如数组下标越界、系统内存不足等等。这类错误不易被程序员发现,它能通过编译且能进入运行,但运行时会出错,导致程序崩溃。为了有效处理程序运行时错误,C++ 中引入异常处理机制来解决此问题。

2、C++ 异常处理机制: 异常处理基本思想:执行一个函数的过程中发现异常,可以不用在本函数内立即进行处理, 而是抛出该异常,让函数的调用者直接或间接处理这个问题。 C++ 异常处理机制由 3 个模块组成:try(检查)、throw(抛出)、catch(捕获) 抛出异常的语句格式为:throw 表达式;如果 try块 中程序段发现了异常则抛出异常。

try
{
    可能抛出异常的语句;(检查)
}
catch(类型名[形参名]) // 捕获特定类型的异常
{
    // 处理1
}
catch(类型名[形参名]) // 捕获特定类型的异常
{
    // 处理2
}
catch(...) // 捕获所有类型的异常
{
    // 处理3
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

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

算数

x = x + y;
y = x - y;
x = x - y;
  • 1
  • 2
  • 3

异或

x = x ^ y; // 只能针对int、char...
y = x ^ y;
x = x ^ y;
x ^= y ^= x;
  • 1
  • 2
  • 3
  • 4

100、你知道 strcpy 和 memcpy 的区别是什么吗?

1、复制的内容不同。strcpy 只能复制字符串,而 memcpy 可以复制任意内容,例如字符数组、整型、结构体、类等。

2、复制的方法不同。strcpy 不需要指定长度,它遇到被复制字符的串结束符 “\0” 才结束,所以容易溢出。memcpy 则是根据其第 3 个参数决定复制的长度。

3、用途不同。通常在复制字符串时用 strcpy ,而需要复制其他类型数据时则一般用 memcpy

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

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

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

102、volatile关键字的作用?

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法:int volatile vInt; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。

volatile 用在如下的几个地方:

  1. 中断服务程序中修改的供其它程序检测的变量需要加 volatile ;
  2. 多任务环境下各任务间共享的标志应该加 volatile ;
  3. 存储器映射的硬件寄存器通常也要加 volatile 说明,因为每次对它的读写都可能由不同意义;

103、如果有一个空类,它会默认添加哪些函数?

1Empty(); // 缺省构造函数
2Empty(const Empty&); // 拷贝构造函数
3~Empty(); // 析构函数
4、Empty& operator=(const Empty&); // 赋值运算符
  • 1
  • 2
  • 3
  • 4

104、C++中标准库是什么?

1、C++ 标准库可以分为两部分:

标准函数库: 这个库是由通用的、独立的、不属于任何类的函数组成的。函数库继承自 C 语言。

面向对象类库: 这个库是类及其相关函数的集合。

2、输入/输出 I/O、字符串和字符处理、数学、时间、日期和本地化、动态分配、其他、宽字符函数

3、标准的 C++ I/O 类、String 类、数值类、STL 容器类、STL 算法、STL 函数对象、STL 迭代器、STL 分配器、本地化库、异常处理类、杂项支持库

105、你知道 const char* 与 string 之间的关系是什么吗?

1、string 是 C++ 标准库里面其中一个,封装了对字符串的操作,实际操作过程我们可以用 const char* 给 string 类初始化

2、三者的转化关系如下所示:

string 转 const char*
string s = "abc";
const char* c_s = s.c_str();
  • 1
  • 2
const char* 转 string,直接赋值即可
const char* c_s = "abc";
string s(c_s);
  • 1
  • 2
string 转 char*
string s = "abc";
char* c;
const int len = s.length();
c = new char[len + 1];
strcpy(c,s.c_str());
  • 1
  • 2
  • 3
  • 4
  • 5
char* 转 string
char* c = "abc";
string s(c);
  • 1
  • 2
const char* 转 char*
const char* cpc = "abc";
char* pc = new char[strlen(cpc) + 1];
strcpy(pc,cpc);
  • 1
  • 2
  • 3
char* 转 const char* ,直接赋值即可
char* pc = "abc";
const char* cpc = pc;
  • 1
  • 2

106、你什么情况用指针当参数,什么时候用引用,为什么?

1、使用引用参数的主要原因有两个:

程序员能修改调用函数中的数据对象

通过传递引用而不是整个数据–对象,可以提高程序的运行速度

2、一般的原则: 对于使用引用的值而不做修改的函数:

如果数据对象很小,如内置数据类型或者小型结构,则按照值传递;

如果数据对象是数组,则使用指针(唯一的选择),并且指针声明为指向const的指针;

如果数据对象是较大的结构,则使用const指针或者引用,已提高程序的效率。这样可以节省结构所需的时间和空间;

如果数据对象是类对象,则使用const引用(传递类对象参数的标准方式是按照引用传递);

3、对于修改函数中数据的函数:

如果数据是内置数据类型,则使用指针

如果数据对象是结构,则使用引用或者指针

如果数据是类对象,则使用引用

也有一种说法认为:“如果数据对象是数组,则只能使用指针”,这是不对的,比如

#include<iostream>
using namespace std;
template<typename T, int N>
void func(T(&a)[N])
{
    a[0] = 2;
}
int main()
{
    int a[] = { 1,2,3 };
    func(a);
    cout << a[0] << endl;
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

107、你知道静态绑定和动态绑定吗?讲讲?

1、对象的静态类型:对象在声明时采用的类型。是在编译期确定的。

2、对象的动态类型:目前所指对象的类型。是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。

3、静态绑定:绑定的是对象的静态类型,某特性(比如函数依赖于对象的静态类型,发生在编译期。)

4、动态绑定:绑定的是对象的动态类型,某特性(比如函数依赖于对象的动态类型,发生在运行期。)

108、如何设计一个计算仅单个子类的对象个数?

1、为类设计一个 static 静态变量 count 作为计数器;

2、类定义结束后初始化 count ;

3、在构造函数中对 count 进行 +1 ;

4、 设计拷贝构造函数,在进行拷贝构造函数中进行 count+1 ,操作;

5、设计赋值构造函数,在进行赋值函数中对 count+1 操作;

6、在析构函数中对 count 进行 -1 ;

109、怎么快速定位错误出现的地方?

1、如果是简单的错误,可以直接双击错误列表里的错误项或者生成输出的错误信息中带行号的地方就可以让编辑窗口定位到错误的位置上。

2、对于复杂的模板错误,最好使用生成输出窗口。

多数情况下出发错误的位置是最靠后的引用位置。如果这样确定不了错误,就需要先把自己写的代码里的引用位置找出来,然后逐个分析了。

110、成员初始化列表会在什么时候用到?它的调用过程是什么?

1、当初始化一个引用成员变量时;

2、初始化一个const成员变量时;

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

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

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

111、在进行函数参数以及返回值传递时,可以使用引用或者值传递,其中使用引用的好处有哪些?

对比值传递,引用传参的好处:

1)在函数内部可以对此参数进行修改

2)提高函数调用和运行的效率(因为没有了传值和生成副本的时间和空间消耗)

如果函数的参数实质就是形参,不过这个形参的作用域只是在函数体内部,也就是说实参和形参是两个不同的东西,要想形参代替实参,肯定有一个值的传递。函数调用时,值的传递机制是通过“形参=实参”来对形参赋值达到传值目的,产生了一个实参的副本。即使函数内部有对参数的修改,也只是针对形参,也就是那个副本,实参不会有任何更改。函数一旦结束,形参生命也宣告终结,做出的修改一样没对任何变量产生影响。

用引用作为返回值最大的好处就是在内存中不产生被返回值的副本。

但是有以下的限制:

1)不能返回局部变量的引用。因为函数返回以后局部变量就会被销毁

2)不能返回函数内部 new 分配的内存的引用。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部 new 分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由 new 分配)就无法释放,造成 memory leak (内存溢出)

3)可以返回类成员的引用,但是最好是 const 。因为如果其他对象可以获得该属性的非常量的引用,那么对该属性的单纯赋值就会破坏业务规则的完整性。

112、说一说strcpy、sprintf 与 memcpy 这三个函数的不同之处

1、操作对象不同

① strcpy 的两个操作对象均为字符串

② sprintf 的操作源对象可以是多种数据类型,目的操作对象是字符串

③ memcpy 的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。

2、执行效率不同

memcpy 最高,strcpy 次之,sprintf 的效率最低。

3、实现功能不同

① strcpy 主要实现字符串变量间的拷贝

② sprintf 主要实现其他数据类型格式到字符串的转化

③ memcpy 主要是内存块间的拷贝。

113、将引用作为函数参数有哪些好处?

1、传递引用给函数与传递指针的效果是一样的。

这时,被调函数的形参就成为原来主调函数中的实参变量或对象的一个别名来使用,所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。

2、使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;

而使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;

如果传递的是对象,还将调用拷贝构造函数。因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。

3、使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在被调函数中同样要给形参分配存储单元,且需要重复使用"*指针变量名"的形式进行运算,这很容易产生错误且程序的阅读性较差;

另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。

114、你知道数组和指针的区别吗?

1、数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间:sizeof(数组名);数组大小:sizeof(数组名)/sizeof(数组元素数据类型);

2、用运算符 sizeof 可以计算出数组的容量(字节数)。sizeof§; p 为指针得到的是一个指针变量的字节数,而不是 p 所指的内存容量。

3、编译器为了简化对数组的支持,实际上是利用指针实现了对数组的支持。具体来说,就是将表达式中的数组元素引用转换为指针加偏移量的引用。

4、在向函数传递参数的时候,如果实参是一个数组,那用于接受的形参为对应的指针。也就是传递过去是数组的首地址而不是整个数组,能够提高效率;

5、在使用下标的时候,两者的用法相同,都是原地址加上下标值,不过数组的原地址就是数组首元素的地址是固定的,指针的原地址就不是固定的。

115、如何阻止一个类被实例化?有哪些方法?

1、将类定义为抽象基类或者将构造函数声明为private;

2、不允许类外部创建类对象,只能在类内部创建对象

116、 如何禁止程序自动生成拷贝构造函数?

1、为了阻止编译器默认生成拷贝构造函数和拷贝赋值函数,我们需要手动去重写这两个函数,某些情况下,为了避免调用拷贝构造函数和拷贝赋值函数,我们需要将他们设置成 private ,防止被调用。

2、类的成员函数和 friend 函数还是可以调用 private 函数,如果这个 private 函数只声明不定义,则会产生一个连接错误;

3、针对上述两种情况,我们可以定一个 base 类,在 base 类中将拷贝构造函数和拷贝赋值函数设置成 private ,那么派生类中编译器将不会自动生成这两个函数,且由于 base 类中该函数是私有的,因此,派生类将阻止编译器执行相关的操作。

117、你知道Debug和Release的区别是什么吗?

1、调试版本,包含调试信息,所以容量比 Release 大很多,并且不进行任何优化(优化会使调试复杂化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。Debug 模式下生成两个文件,除了.exe 或 .dll 文件外,还有一个 .pdb 文件,该文件记录了代码中断点等调试信息;

2、发布版本,不对源代码进行调试,编译时对应用程序的速度进行优化,使得程序在代码大小和运行速度上都是最优的。(调试信息可在单独的 PDB 文件中生成)。Release 模式下生成一个文件 .exe 或 .dll 文件。

3、实际上,Debug 和 Release 并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照预定的选项行动。事实上,我们甚至可以修改这些选项,从而得到优化过的调试版本或是带跟踪语句的发布版本。

118、main函数的返回值有什么值得考究之处吗?

程序运行过程入口点 main 函数,main() 函数返回值类型必须是 int ,这样返回值才能传递给程序激活者(如操作系统)表示程序正常退出。

main(int args, char **argv) 参数的传递。参数的处理,一般会调用 getopt() 函数处理,但实践中,这仅仅是一部分,不会经常用到的技能点。

119、模板会写吗?写一个比较大小的模板函数

#include<iostream>
using namespace std;
template<typename type1,typename type2> // 函数模板
type1 Max(type1 a,type2 b)
{
    return a > b ? a : b;
}
void main()
{
    cout << "Max = " << Max(5.5,'a') << endl;
}
/*
Max = 97
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

其实该模板有个比较隐晦的 bug ,那就是 a、b 只有在能进行转型的时候才能进行比较,否则 a > b 这一步是会报错的。

这个时候往往需要对于 > 号进行重载,这代码量瞬间上来了。

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

1、 函数原型

char* strcpy(char* strDest, const char* strSrc);
char* strncpy(char* dest, const char* src, size_t n);
  • 1
  • 2

strcpy 函数: 如果参数 dest 所指的内存空间不够大,可能会造成缓冲溢出(buffer Overflow)的错误情况,在编写程序时请特别留意,或者用 strncpy() 来取代。

strncpy 函数:用来复制源字符串的前 n 个字符,src 和 dest 所指的内存区域不能重叠,且 dest 必须有足够的空间放置 n 个字符。

如果目标长>指定长>源长,则将源长全部拷贝到目标长,自动加上’\0’

如果指定长<源长,则将源长中按指定长度拷贝到目标字符串,不包括’\0’

如果指定长>目标长,运行时错误 ;

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

1、更加安全;

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

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

1、有时候类里面定义了很多 int , char , struct 等 C 语言里的那些类型的变量,我习惯在构造函数中将它们初始化为 0 ,但是一句句的写太麻烦,所以直接就 memset(this, 0, sizeof *this); 将整个对象的内存全部置为 0 。对于这种情形可以很好的工作,但是下面几种情形是不可以这么使用的;

2、类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;

3、类中含有 C++ 类型的对象:例如,类中定义了一个 list 的对象,由于在构造函数体的代码执行之前就对 list 对象完成了初始化,假设 list 在它的构造函数里分配了内存,那么我们这么一做就破坏了 list 对象的内存。

C++ 虚函数

一、概述

为了实现 C++ 的多态,C++ 使用了一种动态绑定的技术。这个技术的核心是虚函数表(下文简称虚表)。本文介绍虚函数表是如何实现动态绑定的。

二、类的虚表

每个包含了虚函数的类都包含一个虚表。

我们知道,当一个类(A)继承另一个类(B)时,类 A 会继承类 B 的函数的调用权。所以如果一个基类包含了虚函数,那么其继承类也可调用这些虚函数,换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有自己的虚表。

我们来看以下的代码。类 A 包含虚函数 vfunc1,vfunc2 ,由于类 A 包含虚函数,故类 A 拥有一个虚表。

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5J5Twfan-1668393620914)(1-C++基础.assets/20210313073257693.jpg)]

虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。

虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。(所以不能使用 memset(this,0,sizeof(*this)) 来将变量全部初始化为 0)

三、虚表指针

虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。

为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tKJ3Bkey-1668393620914)(1-C++基础.assets/20210313073408186.png)]

上面指出,一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。

四、动态绑定

说到这里,大家一定会好奇 C++ 是如何利用虚表和虚表指针来实现动态绑定的。我们先看下面的代码。

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};

class B : public A {
public:
    virtual void vfunc1();
    void func1();
private:
    int m_data3;
};

class C: public B {
public:
    virtual void vfunc2();
    void func2();
private:
    int m_data1, m_data4;
};
  • 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

类 A 是基类,类 B 继承类 A,类 C 又继承类 B。类 A,类 B,类 C,其对象模型如下图 3 所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-66Wz6EKd-1668393620914)(1-C++基础.assets/20210313073446509.png)]

由于这三个类都有虚函数,故编译器为每个类都创建了一个虚表,即类 A 的虚表(A vtbl),类 B 的虚表(B vtbl),类 C 的虚表(C vtbl)。类 A,类 B,类 C 的对象都拥有一个虚表指针,*__vptr,用来指向自己所属类的虚表。

类 A 包括两个虚函数,故 A vtbl 包含两个指针,分别指向 A::vfunc1() 和 A::vfunc2() 。
类 B 继承于类 A,故类 B 可以调用类 A 的函数,但由于类 B 重写了 B::vfunc1() 函数,故 B vtbl 的两个指针分别指向 B::vfunc1() 和 A::vfunc2() 。
类 C 继承于类 B,故类 C 可以调用类 B 的函数,但由于类 C 重写了 C::vfunc2() 函数,故 C vtbl 的两个指针分别指向 B::vfunc1()(指向继承的最近的一个类的函数)和 C::vfunc2() 。

虽然 图 3 看起来有点复杂,但是只要抓住“对象的虚表指针用来指向自己所属类的虚表,虚表中的指针会指向其继承的最近的一个类的虚函数”这个特点,便可以快速将这几个类的对象模型在自己的脑海中描绘出来。

非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。

假设我们定义一个类 B 的对象。由于 bObject 是类 B 的一个对象,故 bObject 包含一个虚表指针,指向类 B 的虚表。

int main() 
{
    B bObject;
}
  • 1
  • 2
  • 3
  • 4

现在,我们声明一个类 A 的指针p来指向对象bObject。虽然p是基类的指针只能指向基类的部分,但是虚表指针亦属于基类部分,所以p可以访问到对象bObject的虚表指针。bObject的虚表指针指向类 B 的虚表,所以p可以访问到 B vtbl。如图 3 所示。

int main() 
{
    B bObject;
    A *p = & bObject;
}
  • 1
  • 2
  • 3
  • 4
  • 5

当我们使用p来调用vfunc1()函数时,会发生什么现象?

int main() 
{
    B bObject;
    A *p = & bObject;
    p->vfunc1();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

程序在执行p->vfunc1()时,会发现 p 是个指针,且调用的函数是虚函数,接下来便会进行以下的步骤。

首先,根据虚表指针p->__vptr来访问对象 bObject 对应的虚表。虽然指针 p 是基类A*类型,但是*__vptr也是基类的一部分,所以可以通过p->__vptr可以访问到对象对应的虚表。

然后,在虚表中查找所调用的函数对应的条目。由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目。对于 p->vfunc1()的调用,B vtbl 的第一项即是 vfunc1 对应的条目。

最后,根据虚表中找到的函数指针,调用函数。从图 3 可以看到,B vtbl 的第一项指向B::vfunc1(),所以 p->vfunc1()实质会调用B::vfunc1() 函数。

如果 p 指向类 A 的对象,情况又是怎么样?

int main() 
{
    A aObject;
    A *p = &aObject;
    p->vfunc1();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

当 aObject 在创建时,它的虚表指针__vptr已设置为指向 A vtbl,这样p->__vptr就指向 A vtbl。vfunc1在 A vtbl 对应在条目指向了A::vfunc1()函数,所以 p->vfunc1()实质会调用A::vfunc1()函数。

可以把以上三个调用函数的步骤用以下表达式来表示:

(*(p->__vptr)[n])(p)
  • 1

可以看到,通过使用这些虚函数表,即使使用的是基类的指针来调用函数,也可以达到正确调用运行中实际对象的虚函数。

我们把经过虚表调用虚函数的过程称为动态绑定,其表现出来的现象称为运行时多态。动态绑定区别于传统的函数调用,传统的函数调用我们称之为静态绑定,即函数的调用在编译阶段就可以确定下来了。

那么,什么时候会执行函数的动态绑定?这需要符合以下三个条件。

  • 通过指针来调用函数
  • 指针 upcast 向上转型(继承类向基类的转换称为 upcast,关于什么是 upcast,可以参考本文的参考资料)
  • 调用的是虚函数

如果一个函数调用符合以上三个条件,编译器就会把该函数调用编译成动态绑定,其函数的调用过程走的是上述通过虚表的机制。

五、总结

封装,继承,多态是面向对象设计的三个特征,而多态可以说是面向对象设计的关键。C++ 通过虚函数表,实现了虚函数与对象的动态绑定,从而构建了 C++ 面向对象程序设计的基石。

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

1、当发生某种事件时,系统或其他函数将会自动调用你定义的一段函数;

2、回调函数就相当于一个中断处理函数,由系统在符合你设定的条件时自动调用。为此,你需要做三件事:1,声明;2,定义;3,设置触发条件,就是在你的函数中把你的回调函数名称转化为地址作为一个参数,以便于系统调用;

3、回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数;

4、因为可以把调用者与被调用者分开。调用者不关心谁是被调用者,所有它需知道的,只是存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调用函数。

124、什么是一致性哈希?

一致性哈希

一致性哈希是一种哈希算法,就是在移除或者增加一个结点时,能够尽可能小的改变已存在key的映射关系

尽可能少的改变已有的映射关系,一般是沿着顺时针进行操作,回答之前可以先想想,真实情况如何处理

一致性哈希将整个哈希值空间组织成一个虚拟的圆环,假设哈希函数的值空间为0~2^32-1,整个哈希空间环如下左图所示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FKiJiZ1e-1668393620914)(1-C++基础.assets/202205212342491.png)]

一致性 hash 的基本思想就是使用相同的 hash 算法将数据和结点都映射到图中的环形哈希空间中,上右图显示了 4 个数据 object1-object4 在环上的分布图

结点和数据映射

假如有一批服务器,可以根据IP或者主机名作为关键字进行哈希,根据结果映射到哈希环中,3 台服务器分别是 nodeA-nodeC

现在有一批的数据 object1-object4 需要存在服务器上,则可以使用相同的哈希算法对数据进行哈希,其结果必然也在环上,可以沿着顺时针方向寻找,找到一个结点(服务器)则将数据存在这个结点上,这样数据和结点就产生了一对一的关联,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9ey0LPaS-1668393620915)(1-C++基础.assets/202205212343455.png)]

移除结点

如果一台服务器出现问题,如上图中的 nodeB ,则受影响的是其逆时针方向至下一个结点之间的数据,只需将这些数据映射到它顺时针方向的第一个结点上即可,下左图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CvDJRb3w-1668393620915)(1-C++基础.assets/202205212343330.png)]

添加结点

如果新增一台服务器 nodeD ,受影响的是其逆时针方向至下一个结点之间的数据,将这些数据映射到 nodeD 上即可,见上右图

虚拟结点

假设仅有 2 台服务器:nodeA 和 nodeC,nodeA 映射了 1 条数据,nodeC 映射了 3 条,这样数据分布是不平衡的。引入虚拟结点,假设结点复制个数为 2 ,则 nodeA 变成:nodeA1 和 nodeA2 ,nodeC 变成:nodeC1 和 nodeC2 ,映射情况变成如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g0On0ad2-1668393620916)(1-C++基础.assets/202205212343034.png)]

这样数据分布就均衡多了,平衡性有了很大的提高

125、C++从代码到可执行程序经历了什么?

(1)预编译

主要处理源代码文件中的以 “#” 开头的预编译指令。处理规则见下:

  1. 删除所有的 #define ,展开所有的宏定义。
  2. 处理所有的条件预编译指令,如 “#if”、“#endif”、“#ifdef”、“#elif”和“#else”。
  3. 处理 “#include” 预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件。
  4. 删除所有的注释,“//”和“/**/”。
  5. 保留所有的 #pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重复引用。
  6. 添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是能够显示行号。

(2)编译

把预编译之后生成的 xxx.i 或 xxx.ii 文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。

  1. 词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。
  2. 语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。
  3. 语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定的语义。
  4. 优化:源代码级别的一个优化过程。
  5. 目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言表示。
  6. 目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等。

(3)汇编

将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器 as 完成。经汇编之后,产生目标文件(与可执行文件格式几乎一样) xxx.o (Linux 下)、xxx.obj (Window下)。

(4)链接

将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链 接:

静态链接

函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。

空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;

更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。

运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西, 在执行的时候运行速度快。

动态链接

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。

共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分副本,而是这多个程序在执行时共享同一份副本;

更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。

性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。

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

因为编译器必须能够读取这个结构的声明以理解这个数据类型的大、行为等方面的所有规则。

有一条规则在任何关系中都很重要,那就是谁可以访问我的私有部分。

勘误

本题问题表达有误,实际上:

友元函数不一定要在类内声明,普通的友元函数可以在类外声明,也可以在类内声明。

只有友元工厂必须用到类内声明友元函数。

127、友元函数和友元类的基本情况

友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,一个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。

1)友元函数

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

#include<iostream>
using namespace std;
class A
{
public:
    friend void set_show(int x, A& a); // 该函数是友元函数声明
private:
    int data;
};
void set_show(int x,A& a) // 友元函数定义,为了访问类A中的成员
{
    a.data = x;
    cout << a.data << endl;
}
int main(void)
{
    class A a;
    set_show(1,a);
    return 0;
}
/*
1
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

一个函数可以是多个类的友元函数,但是每个类中都要声明这个函数。

2)友元类

友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。
但是另一个类里面也要相应的进行声明

#include<iostream>
using namespace std;
class A
{
public:
    friend class C; // 这是友元类的声明
private:
    int data;
};
class C // 友元类定义,为了访问类A中的成员
{
public:
    void set_show(int x,A& a)
    {
        a.data = x;
        cout << a.data << endl;
    }
};
int main(void)
{
   class A a;
   class C c;

   c.set_show(1, a);

   return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

使用友元类时注意:

(1) 友元关系不能被继承。

(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。

(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明

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

#include<iostream>
using namespace std;

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

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

// C语言 模拟 C++ 的继承和多态
typedef void (*FUN)(); // 定义一个函数指针来实现对成员函数的支持

struct _A // 父类
{
public:
    FUN _fun; // 由于C语言中结构体不能包含函数,故只能用函数指针在外面实现
    int _a;
};
class _B // 子类
{
public:
    _A _a_; // 在子类中定义一个基类的对象即可实现对父类的继承
    int _b;
};
void _fA() // 父类的同名函数
{
    printf("_A:_fun()\n");
}
void _fB() // 子类同名函数
{
    printf("_B:_fun()\n");
}
void Test()
{
    // 测试 C++ 中的继承与多态
    A a; // 定义一个父类对象 a
    B b; // 定义一个子类对象 b

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

    // C语言模拟继承与多态的测试
    _A _a; // 定义一个父类对象 _a
    _B _b; // 定义一个子类对象 _b
    
    _a._fun = _fA; // 父类的对象调用父类的同名函数
    _b._a_._fun = _fB; // 子类的对象调用子类的同名函数

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

int main()
{
    Test();
    return 0;
}
/*
A::fun()
B::fun()
_A:_fun()
_B:_fun()
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80

129、动态编译与静态编译

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

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

130、hello.c 程序的编译过程

以下是一个 hello.c 程序:

#include<stdio.h>
int main()
{
    printf("hello world");
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

在 Unix 系统上,由编译器把源文件转换为目标文件。

gcc -o hello hello.c
  • 1

这个过程大致如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vkQBp3Ya-1668393620916)(1-C++基础.assets/202205212343505.png)]

  • 预处理阶段:处理以 # 开头的预处理命令;
  • 编译阶段:翻译成汇编文件;
  • 汇编阶段:将汇编文件翻译成可重定位目标文件;
  • 链接阶段:将可重定位目标文件和 printf.o 等单独预编译好的目标文件进行合并,得到最终的可执行目标文件。

静态链接

静态链接器以一组可重定位目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:

  • 符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。
  • 重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MnvelL4a-1668393620916)(1-C++基础.assets/202205212343130.png)]

目标文件

  • 可执行目标文件:可以直接在内存中执行;
  • 可重定位目标文件:可与其它可重定位目标文件在链接阶段合并,创建一个可执行目标文件;
  • 共享目标文件:这是一种特殊的可重定位目标文件,可以在运行时被动态加载进内存并链接;

动态链接

静态库有以下两个问题:

  • 当静态库更新时那么整个程序都要重新进行链接;
  • 对于 printf 这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。

共享库是为了解决静态库的这两个问题而设计的,在 Linux 系统中通常用 .so 后缀来表示,Windows 系统上它们被称为 DLL。它具有以下特点:

  • 在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中;
  • 在内存中,一个共享库的 .text 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YY3rHkz7-1668393620917)(1-C++基础.assets/202205212343182.png)]

源代码-->预处理-->编译-->优化-->汇编-->链接–>可执行文件

1、预处理

读取c源程序,对其中的伪指令(以#开头的指令)和特殊符号进行处理。包括宏定义替换、条件编译指令、头文件包含指令、特殊符号。 预编译程序所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。.i 预处理后的 c 文件,.ii 预处理后的 C++ 文件。

2、编译阶段

编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。.s 文件

3、汇编过程

汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个 C 语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。.o 目标文件

4、链接阶段

链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够诶操作系统装入执行的统一整体。

131、介绍一下几种典型的锁

读写锁

  • 多个读者可以同时进行读
  • 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
  • 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

互斥锁

一次只能一个线程拥有互斥锁,其他线程只有等待

互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁

条件变量

互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

自旋锁

如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

132、delete 和 delete[] 区别?

  • delete 只会调用一次析构函数。
  • delete[] 会调用数组中每个元素的析构函数。

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

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

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

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

  • 首先,实现一个垃圾回收器会带来额外的空间和时间开销。你需要开辟一定的空间保存指针的引用计数和对他们进行标记 mark 。然后需要单独开辟一个线程在空闲的时候进行 free 操作。
  • 垃圾回收会使得 C++ 不适合进行很多底层的操作。
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/IT小白/article/detail/600689
推荐阅读
相关标签
  

闽ICP备14008679号