为什么一般将析构函数设置为虚函数?
将析构函数设置为虚函数,核心目的是为了确保通过基类指针删除派生类对象时,能够正确、完整地调用整个继承链上的所有析构函数,从而安全地释放资源。
这关系到 C++ 中多态行为下的对象销毁机制。
当一个类被设计为基类(即有派生类),并且你计划通过基类指针来管理派生类对象的生命周期时,虚析构函数就变得至关重要。
假设我们有以下类结构:
cpp
#include <iostream>
using namespace std;
class Base {
public:
~Base() { // 注意:这里没有 virtual
cout << "Base 析构" << endl;
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived 析构" << endl;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // 问题来了:会调用哪个析构函数?
return 0;
}
如果析构函数不是虚函数
在上述代码中,如果 Base 的析构函数不是虚函数,delete ptr 语句只会静态地调用 Base 类的析构函数。
- 输出结果 :
Base 析构 - 问题 :
Derived类的析构函数根本没有被调用 !如果Derived类中分配了动态内存、打开了文件或持有其他资源,这些资源将无法被释放,导致内存泄漏或未定义行为。
如果析构函数是虚函数
现在,我们将 Base 的析构函数声明为 virtual:
cpp
class Base {
public:
virtual ~Base() { // 加上 virtual
cout << "Base 析构" << endl;
}
};
// Derived 类保持不变
此时,delete ptr 会触发动态绑定。程序会在运行时根据 ptr 实际指向的对象类型(Derived)来决定调用哪个析构函数。
- 析构顺序 :
- 首先调用最派生类
Derived的析构函数。 Derived的析构函数执行完毕后,会自动调用其基类Base的析构函数。
- 首先调用最派生类
- 输出结果:
cpp
Derived 析构
Base 析构
为什么一般将析构函数设置为虚函数?
析构函数被设为虚函数主要是为了解决基类指针指向派生类对象时的资源释放问题。
如果我们有一个基类指针,它实际上指向一个派生类对象,当我们删除这个基类指针时,如果析构函数不是虚函数,那么就只会调用基类的析构函数,而不会调用派生类的析构函数。这可能会导致派生类对象的一些资源没有被正确释放,从而引发内存泄漏等问题。
如果我们将析构函数设置为虚函数,那么在删除基类指针时,会首先调用派生类的析构函数,然后再调用基类的析构函数,从而确保所有的资源都能被正确释放。
析构函数为什么通常是会做成一个虚函数呢?
如果一个类有虚函数,就应该为其定义一个虚析构函数。这是因为在使用delete操作符释放一个指向派生类对象的基类指针时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这样就会导致内存泄漏和未定义行为的问题。通过将析构函数定义为虚函数,可以确保在释放派生类对象时,先调用派生类的析构函数,再调用基类的析构函数,从而避免内存泄漏和未定义行为的问题。
为什么析构函数一般写为虚函数?
如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。
所以在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。
为什么构造函数不写为虚函数?
从存储空间角度:虚函数对应一个vtable,可是这个vtable其实是存储在对象的内存空间的。问题出来了,如果构造函数是虚的,就需要通过 vtable来调用,可是对象还没有实例化,也就是内存空间还没有,无法找到vtable,所以构造函数不能是虚函数。
从使用角度:虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
什么是内联函数?
在C++中,使用关键字"inline"可以声明一个内联函数。声明为内联函数的函数会在编译时被视为候选项,编译器会尝试将其展开,将函数体直接插入到调用点处。这样可以避免函数调用的开销,减少了函数调用的栈帧等额外开销,从而提高程序的执行效率。
宏定义(define)和内联函数(inline)的区别是什么?
- 内联函数是在编译时展开,而宏在编译预处理时展开;在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换。
- 内联函数是真正的函数,和普通函数调用的方法一样,在调用点处直接展开,避免了函数的参数压栈操作,减少了调用的开销。而宏定义编写较为复杂,常需要增加一些括号来避免歧义。
- 宏定义只进行文本替换,不会对参数的类型、语句能否正常编译等进行检查。而内联函数是真正的函数,会对参数的类型、函数体内的语句编写是否正确等进行检查。
| 对比维度 | 宏定义 (#define) |
内联函数 (inline) |
|---|---|---|
| 处理阶段 | 预处理阶段(编译前) | 编译阶段 |
| 处理机制 | 文本替换(纯字符拼凑) | 代码嵌入(保留函数语义) |
| 类型安全 | ❌ 无类型检查,容易出错 | ✅ 严格类型检查,安全 |
| 调试能力 | ❌ 难以调试(代码已被替换) | ✅ 支持调试(可打断点) |
| 副作用 | ⚠️ 容易出现(如参数自增) | ✅ 避免副作用(按值传递) |
include " " 和 <> 的区别是什么?
- 查找文件的位置 :
include<文件名>在标准库头文件所在的目录中查找,如果没有,再到当前源文件所在目录下查找;#include"文件名"在当前源文件所在目录中进行查找,如果没有;再到系统目录中查找。 - 使用习惯 :对于标准库中的头文件常用
include<文件名>,对于自己定义的头文件,常用#include"文件名"
void*是什么?
void* 是一种通用的指针类型,被称为"无类型指针"。它可以用来表示指向任何类型的指针,因为 void* 指针没有指定特定的数据类型。
由于 void* 是无类型的,它不能直接进行解引用操作,也不能进行指针运算。在使用 void* 指针时,需要将其转换为具体的指针类型才能进行操作。
void* 指针常用于需要在不同类型之间进行通用操作的情况,例如在函数中传递任意类型的指针参数或在动态内存分配中使用。
malloc的参数列表 void*怎么转化为int*的?
malloc返回的void*指针想要转化为int*,最直接的方法就是使用强制类型转换。在 C 语言中,
malloc的原型是void* malloc(size_t size);。它返回一个指向分配内存起始地址的通用指针(void*),因为此时编译器还不知道这块内存将来要用来存什么类型的数据。
cppint* p = (int*)malloc(sizeof(int));
- 分配内存 :调用
malloc(sizeof(int)),系统会在堆区开辟一块大小足以存放一个整数的内存。- 获取通用指针 :
malloc返回这块内存的首地址,类型为void*。- 强制转换 :
(int*)告诉编译器:"请把后面这个地址当作一个指向int类型的指针来处理。"- 赋值 :将转换后的地址赋值给
int*类型的指针变量。
cpp#include <stdio.h> #include <stdlib.h> // malloc 所在的头文件 int main() { // 1. 分配内存并直接进行类型转换(推荐写法) int* pInt = (int*)malloc(sizeof(int)); // 检查内存是否分配成功 if (pInt == NULL) { printf("内存分配失败\n"); return 1; } // 2. 现在 pInt 已经是 int* 类型,可以正常解引用赋值 *pInt = 100; printf("存储的整数是: %d\n", *pInt); // 3. 使用完后记得释放内存 free(pInt); pInt = NULL; // 避免悬空指针 return 0; }注意事项(C 与 C++ 的区别)
- 在 C 语言中 :
void*可以隐式转换为任何其他类型的指针。也就是说,写int* p = malloc(sizeof(int));也是合法的,不加(int*)也可以。但为了代码的清晰性和兼容性,通常习惯加上。- 在 C++ 中 :类型检查更严格,
void*必须 显式强制转换为int*,否则编译器会报错。
sizeof 和 strlen 的区别是什么?
| 对比维度 | sizeof |
strlen |
|---|---|---|
| 类型 | 运算符 | 库函数 |
| 计算时机 | 编译时 | 运行时 |
| 计算目标 | 内存占用大小(字节) | 字符串有效长度(字符数) |
看待 \0 |
计算在内 | 作为结束标志,不计入长度 |
| 参数类型 | 可以是类型、变量、数组等 | 必须是 const char* 类型的字符串 |
-
sizeof- 计算内容 :计算变量或数据类型在内存中占用的总字节数。
- 规则 :它会计算所有字节,包括数组的预留空间、结构体的内存对齐填充字节等。对于字符数组,它会把末尾的
\0也计算在内。
-
strlen- 计算内容 :计算一个以
\0结尾的字符串中,有效字符的个数。 - 规则 :从起始地址开始计数,直到遇到第一个
\0为止,但不包含\0本身。
- 计算内容 :计算一个以
cpp
#include <stdio.h>
#include <string.h>
int main() {
char str1[] = "hello";
char str2[10] = "hello";
// sizeof 计算数组总大小
printf("sizeof(str1): %zu\n", sizeof(str1)); // 输出 6 ('h','e','l','l','o','\0')
printf("sizeof(str2): %zu\n", sizeof(str2)); // 输出 10 (数组定义的大小)
// strlen 计算有效字符串长度
printf("strlen(str1): %zu\n", strlen(str1)); // 输出 5
printf("strlen(str2): %zu\n", strlen(str2)); // 输出 5
return 0;
}
explicit 的作用是什么?
explicit 是 C++ 中一个用于增强类型安全的关键字,它的核心作用是禁止编译器执行非预期的隐式类型转换和隐式对象构造。
strcpy 函数有什么缺陷?
strcpy函数最核心、最致命的缺陷在于它不检查目标缓冲区的大小。这种"盲目"的复制行为使其成为 C/C++ 编程中缓冲区溢出漏洞的主要源头之一。
strcpy的设计哲学是"信任程序员",它假设调用者已经为目标缓冲区分配了足够的空间。因此,它的函数原型char *strcpy(char *dest, const char *src);中根本没有用于传递目标缓冲区大小的参数。它的工作机制非常简单:从源字符串
src的首地址开始,逐字节复制,直到遇到字符串结束符\0为止。这个过程完全不关心目标缓冲区dest的边界在哪里。后果:缓冲区溢出
当源字符串的长度(包括末尾的
\0)超过目标缓冲区的容量时,strcpy会继续向dest之后的内存地址写入数据。这种行为被称为缓冲区溢出。这会带来一系列严重后果:
- 程序崩溃:溢出的数据会覆盖栈或堆上的其他变量,导致程序状态混乱,最终崩溃。
- 数据损坏:相邻内存中的重要数据被破坏,导致程序逻辑错误。
- 安全漏洞 :这是最危险的情况。攻击者可以精心构造超长字符串,利用溢出的数据覆盖函数调用栈上的返回地址,将程序的执行流劫持到攻击者植入的恶意代码上,从而实现任意代码执行(即"栈溢出攻击")。
cpp#include <string.h> void vulnerable_function(char *input) { char buffer; // 在栈上分配一个64字节的缓冲区 // 危险!如果 input 长度超过63个字符(留一个给'\0'),就会发生溢出 strcpy(buffer, input); }如果
input指向的字符串长度超过64字节,strcpy就会越界写入,覆盖buffer之后的栈内存,可能包括保存的帧指针和函数的返回地址。
使用 strncpy 的正确姿势
strncpy 虽然限制了复制的最大字节数,但它有一个陷阱:如果源字符串长度大于等于指定的长度 n,它不会 在目标缓冲区末尾自动添加 \0。因此,必须手动添加。
cpp
#include <string.h>
#include <stdio.h>
int main() {
char dest;
const char *src = "Hello World";
// 1. 限制复制的字节数,最多复制 sizeof(dest) - 1 个字符
strncpy(dest, src, sizeof(dest) - 1);
// 2. 手动确保字符串以 '\0' 结尾
dest[sizeof(dest) - 1] = '\0';
printf("Copied string: %s\n", dest);
return 0;
}
C++的编译过程介绍一下?
C++的编译过程经过了预处理、编译、汇编和链接四个主要阶段:
- 预处理 :预处理阶段会对源代码进行处理,主要包括展开宏定义、处理条件编译指令(如
#include、#define、#ifdef等)以及删除注释等。预处理的结果是生成一个经过宏展开和条件处理后的纯C++源代码文件。- 编译(Compilation):编译阶段将预处理后的源代码翻译为汇编语言,生成汇编代码。编译器会进行词法分析、语法分析和语义分析,检查代码的正确性,并生成中间代码表示。
- 汇编:汇编阶段将汇编代码转换为机器可以执行的目标文件。汇编器会将汇编代码转化为机器指令,并生成与机器硬件平台相关的目标文件(通常以".obj"或".o"为扩展名)。
- 链接:链接阶段将目标文件与其他必要的库文件链接在一起,生成可执行程序。链接器会解析目标文件中的符号引用,将其与其他目标文件或库文件中的符号定义进行匹配,最终生成一个完整的可执行文件。在链接阶段,还会进行地址重定位、符号解析、符号表生成等操作,确保程序的正确执行。
静态链接库和动态链接库有什么区别?
- 链接方式:静态链接库在编译链接时会被完整地复制到可执行文件中,成为可执行文件的一部分;而动态链接库在编译链接时只会在可执行文件中包含对库的引用,实际的库文件在运行时由操作系统动态加载。
- 文件大小:静态链接库会使得可执行文件的大小增加,因为库的代码被完整地复制到可执行文件中;而动态链接库不会增加可执行文件的大小,因为库的代码在运行时才会被加载。
- 内存占用:静态链接库在运行时会被完整地加载到内存中,占用固定的内存空间;而动态链接库在运行时才会被加载,可以在多个进程之间共享,减少内存占用。
- 可扩展性:动态链接库的可扩展性更好,可以在不修改可执行文件的情况下替换或添加新的库文件,而静态链接库需要重新编译链接。
| 对比维度 | 静态链接库 | 动态链接库 |
|---|---|---|
| 链接时机 | 编译时 | 运行时 |
| 文件依赖 | 无,程序独立 | 有,依赖外部库文件 |
| 可执行文件大小 | 较大 | 较小 |
| 内存占用 | 较高(多副本) | 较低(可共享) |
| 更新维护 | 需重新编译程序 | 替换库文件即可 |
| 典型格式 | .a (Linux), .lib (Windows) |
.so (Linux), .dll (Windows) |
