C++ 八股文查漏补缺

这里只是把我不知道的或不常用的八股文写进来了,其他我已经会的就不写了11 当前,如果写了那肯定是写全了,而且每一个都经过ai的审核,确保没有遗漏的或不说人话的部分。所以不能只把我的这个当成背诵文档。从这个网址背的:C++ 面试题

感觉背八股文本身就是错的,完全没有必要,可是现在的公司在面试的时候都在问八股,也就只能背了。

常量指针与指针常量

文章里写的很复杂,其实核心就两个规则:

  1. const 左结合
  2. 如果左边没有类型,则右结合

体现:

比如常量指针:const int * p;int const * p。都是与 int 结合,没有与 * 结合,所以指针本身(*)是可以更改的,但是指向的值(int)无法更改。

而指针常量:int * const p:这个与 * 结合,所以指针不可以更改,而指向的值可以更改。

这个规则忘记是从哪里看到的了,不过如果我没有记错,应该是从 《C++20高级编程》中看到的。

static 的作用

static 可以定义静态变量,静态函数。

改变 全局变量 的作用域

对于全局变量来说,如果没有 static ,则其作用域是全局作用域。其他文件可以通过 extern 关键字来访问到该文件中定义的全局变量。而如果加上了 static,则会将其作用域限制到了文件作用域中。此时,其他的文件无法再访问到该全局变量。在这个情况下,即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。

未初始化的全局静态变量会被编译器将其默认初始化为0。因为全局静态变量存放在内存的 BSS 段,程序启动时系统会自动将该段所有数据清零。

改变 局部变量 的生命周期

正常来说,一个局部变量会随着其所在作用域的消失而消失,当函数执行结束时,变量会被自动销毁。但是对于静态变量来说,它在程序运行期间只会初始化一次,在程序结束运行时才会销毁。当函数多次调用时,它会保留上一次调用结束时的数值。

更改类的成员变量与成员函数的相关性

在没有定义 static 时,类的成员变量与成员函数都描述的是与一个对象的一系列的信息与动作,而加上了 static 以后,该描述的是与类型相关的信息与动作。此时,不需要定义对象,可以直接通过类来访问静态成员与静态函数。

这里有需要注意的:

  1. 类的静态成员函数只能访问静态成员变量与静态成员函数
  2. 不可以将静态成员函数定义成虚函数
  3. 静态成员变量在类内声明,需要在类外定义与初始化。

Union 联合体

Union 由若干个数据类型不同的数据成员组成。使用时联合体只有一个有效的成员。对联合体不同的成员赋值,会覆盖其他成员的值。其内存分配规则为:其内部所有变量的最大值,按照最大类型的倍数进行分配大小。

比如:

typedef union {
char c[10];
double d;
} u33;

由于这里 double 是8个字节,所以按该类型的倍数分配大小。所以其最终的大小为:24 字节(8 * 3)。

volatile

原文说的那些是完全的错误

在 c++中,volatile 正确的使用场景主要有:

  1. 内存映射 I/O:在嵌入式开发或驱动开发中,访问硬件寄存器。由于硬件的状态随时会更改,所以必须要求编译器每次都真切的读地址上的值,不可以优化。
  2. 信号处理:使用 sig_atomic_t 类型的变量在信号处理函数与主程序之间通信时,需要加 volatile。

为什么是错的?

如果两个线程同时访问同一个非原子变量(且其中一个是写操作),发该操作就是 c++标准中定义的“数据竞争”,这是未定义行为(UB)。而编译器的所有的优化性能的操作,都是通过UB来优化的22 只要标准中没有定义,那么编译器就可以做任何的事

怎么做才是对的?

在 C++11标准中。如果要实现上述的功能,可以使用:原子变量或锁。

为什么一般将析构函数设置为虚函数?

这里原文介绍的比较的浅,但是我也记不清了,所以正好去查一查资料来回忆一下。

为了在使用“基类指针指向派生类对象”并进行 delete 操作时,可以正确触发动态绑定,从而先调用派生类的析构函数,再调用基类的析构函数,防止内存泄漏或资源未释放。

以下是具体的例子:

class Base {
public:
// 假设这里没加 virtual
~Base() { cout << "Base dest" << endl; }
};

class Derived : public Base {
public:
int* data;
Derived() { data = new int[100]; }
~Derived() {
delete[] data;
cout << "Derived dest" << endl;
}
};

int main() {
Base* ptr = new Derived(); // 基类指针指向派生类对象
delete ptr; // 关键点在这里!
}

如果没有加virtual,那么编译器就会认为这个ptr是一个Base类型的对象。此时就会直接调用 Base::~Base() 函数,即:只调用基类的析构函数。如果 Derived 中也定义了一些对象,需要依靠析构函数来清除,那么这就会使得内存泄漏。

如果添加了virtual,那么编译器会生成 vtable 虚函数表 与 vptr 虚表指针。BaseDerived 都会有一个虚函数表。表中存放着各自虚函数的地址(包括析构函数)。同时,每个对象补全在内存的最开始位置会多出一个隐藏指针 vptr,指向该对象所属类的 vtable。

在这个情况下,当编译器看到 delete ptr且析构函数是虚函数时。它不会像之前一样,直接调用指令,而是生成一段间接调用的逻辑:

  1. 先读取ptr指向对象的内存首地址,得到vptr。
  2. 通过 vptr 找到 vtable
  3. 通过 vtable 找到析构函数实在存在的地址(由于该对象是 Derived,Derived 的 vptr 指向的是 Derived 的 vtable ,所以这里找到的就是 Derived::~Derived() 的地址)
  4. 调用该析构函数 Derived::~Derived()
  5. 释放内存空间

诶,这就结束了吗,基类不需要调用吗?是的,因为派生类的析构函数Derived::~Derived()的末尾会被编译器自动插入调用其父类析构函数Base::~Base()的指令。这里也可以解释:析构顺序是 Derived -> Base,先拆子,后拆父。

为什么一般不把构造函数设置为虚函数

这个问题是上面问题的一个镜像问题。

在语法上,C++ 禁止将构造函数设置为 virtual。

从虚函数的调用过程中可以看到,如果要调用 ptr->func() 的时候,程序会通过 ptr 指向的对象内存中找到 vptr,再借此找到 vtable,进而找到具体的地址。但是 vptr 是在对象构造期间被初始化的。如果构造是虚函数,那么调用构造函数就需要通过 vptr 来进行虚函数的动态分发。但是这个时候对象还没有实例化,内存中根本没有 vptr,或者是 vptr还没有赋值。这是一个矛盾的点。

而且虚函数的核心意义是多态。即:程序知道这是一个基类的指针,但是希望根据它实际指向的对象类型来执行动作。这里有一个前提:对象已经存在,只是不知道它的具体的类型。

C++ 的4种类型转换方式

static_cast 静态转换

它主要有3种用途:

  1. 相关类型之间的转换33 比如,基本类型的转换,枚举与整型,还有用户自定义的类型转换(通过构造函数,转换运算符)等等
  2. void* 指针与具体类型指针(比如 int*)之间的转换
  3. 类层级结构中的上行转换,比如子类指针转父类
  4. 类层级结构中的下行转换,将父类转成子类,但是它不会在运行时检查转换的对象是不是正确的。如果强行转换,则会产生未定义行为。

它是在编译时进行类型检查的,所以速度很快

但是它不能在两个完全不相关的类指针之间转换,也不可以去掉const 属性。

dynamic_cast 动态转换

它是用于处理类层级结构中的安全性检查。

它可以进行下行转换,即:父类指针转子类指针。

它是在运行时检查的,同时要求基类必须至少有一个虚函数(因为该函数底层是通过 vtable 来确定的,如果没有虚函数,则没有 vtable)。同时,由于它是在运行时检查的,它会有一定的开销。

如果它在转换失败时,如果是指针,则会返回 nullptr,如果是引用,则会抛出 std::bad_cast 异常。

这里有一个要注意的点:

#include <iostream>

class Animal {
public:
virtual void eat() {
std::cout << "animal 吃" << std::endl;
};
};
class Dog : public Animal {
public:
void eat() override {
std::cout << "dog 吃" << std::endl;
}
};

int main()
{

Animal* a = new Dog();
// 成功:因为 Dog 是 Animal 的子类,它们“相关”
Dog* d = dynamic_cast<Dog*>(a);

std::cout << "Hello World!\n";
}

上述的代码中,dynamic_cast 与 static_cast都可以将 animal 转为 dog,那么这两个有什么区别呢?static_cast在编译期检查,而dynamic_cast在运行时检查,所以static_cast的速度很快。由于static_cast不在运行时检查对象的真实类型,所以它是不安全的,而dynamic_cast是安全的,可以看以下的例子:

Animal* a = new Cat(); // 实际上是一只猫
// 危险!static_cast 会强行把“猫”当成“狗”
Dog* d = static_cast<Dog*>(a);
// 这里可能导致崩溃,因为 d 指向的对象内存里根本没有 Dog 的特有数据
d->bark();

const_cast 常量转换

这是4种转换中唯一可以改变 const 属性的转换

它可以移除变量的 const 或 volatile 资格,或者是添加 const 属性44 添加 const 属性往往通过隐藏转换直接完成

这里需要注意的是:如果原始的对象被定义为了 const ,然后使用 const_cast 去掉常量性并修改其值,该执行是未定义行为。在很多编译器中,这样的更改在使用了o2或o3优化后往往是无效的。这里需要再次强调的是:如果一个变量原本不是 const,只是被通过 const int* 引用了,此时用 const_cast 去掉 const 并修改它是安全的。只有当原始变量被声明为 const int x = 10; 时,修改它才是未定义行为。

那么这个有什么用呢?它通常用于调用某些参数类型不匹配,但是可以保证不修改的旧式库函数。也就是用于兼容旧版的库,正常开发时,不需要使用这个转换。

reinterpret_cast 重解释转换

这个是最底层的转换。C++的类型本质上是对一个地址空间的解读。而这个转换就是告诉编译器:这一块地址空间应该更改成另一种解读方式。因此,该转换完全依赖程序员的正确性,无条件相信程序员。

它可以将一种类型的指针转换为另一种完全不相关的类型指针,比如将 int* 转成 char*。

它不可移植,同时很可能违反严格别名规则55 该规则规定编译器可假设不同类型的指针(除 char* 等)绝不指向同一地址。这允许编译器将变量值缓存在寄存器中以加速运行。若通过 reinterpret_cast 让 float* b 指向 int* a 的地址,当你先写 *a = 10 后写 *b = 2.0f 时,编译器会认为修改 b 不可能影响 a,因此在后续读取 *a 时可能直接从寄存器返回旧值 10,而非从内存读取被修改后的位模式。这种逻辑错乱即为未定义行为。。但是,也不是所有的类型都是会违反的。为了方便底层开发,标准规定了几种特殊类型的,它们是可以指向任何其他类型成不违反规则的:

  1. char*unsigned char*std::byte*,这些被视为原始字节类型,。可以使用 char* 指向任何对象,并读取其字节内容。
  2. 兼容类型,比如int*const int*,有符号与无符号的版本。

如果需要操作位,那么最好的方式不是使用 reinterpret_cast,而是使用 memcpy 或std::bit_cast

bit_cast

这个是C++20引入的新特性,专门用于安全的重新解释位,也就是处理上述的问题。

float f = 1.23f;
auto i = std::bit_cast<int>(f); // 优雅且安全

它有两个基本的要求:

  1. sizeof(To) == sizeof(From)(大小严格相等)。
  2. 两个类型都必须是 Trivially Copyable(平凡可拷贝)。 它实际上相当于编译器层面实现的 memcpy,但更优雅且支持 constexpr(可在编译期执行)。