4、C++高频面试真题|31-40

厨子大约 21 分钟编程语言原创技术面试题解析C++面试程序员求职程序厨

C++ 面试真题(31-40)

31. 🌟C++ 中虚函数的原理?

回答重点

虚函数是 C++ 中实现多态的一个关键机制。简单来说,虚函数允许你在基类里通过 virtual 声明一个函数,然后在派生类里对其进行重新定义。

通过使用虚函数,C++ 可以根据对象的实际类型(而不是引用或指针的静态类型)调用派生类的函数实现。实现虚函数的关键在于虚函数表(vtable)和虚函数表指针(vptr)。

每个含有虚函数的类都有一张虚函数表,表中存有该类的虚函数的地址。每个对象都有一个虚函数表指针,指向这个类的虚函数表。当调用虚函数时,程序会通过对象的虚函数表指针找到相应的虚函数地址,然后进行函数调用。

扩展知识

1)虚函数的实现原理:

  • **虚函数表(vtable):**是一个存储虚函数地址的数组。每个包含虚函数的类会有一个虚函数表。表里存有该类或者基类中重写虚函数的实际地址。
  • **虚函数表指针(vptr):**每个对象在内存布局中会有一个指向虚函数表的指针。编译器会自动管理这个指针的初始化和赋值。

2)多态的实现:

虚函数是实现多态的一种手段,允许程序在运行时决定调用哪个类的函数,实现动态绑定。当基类指针或引用指向派生类对象时,调用虚函数会根据实际对象类型选择合适的函数实现,下面是示例代码:

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

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

// 用例
Base *b;
Derived d;
b = &d;
b->show();  // 将会调用 Derived 的 show 方法

3)注意点:

  • 虚函数的调用比普通函数多了一个 vtable 查找过程,运行时略有开销。
  • 析构函数如果需要在派生类中被正确的调用,应该声明为虚函数。

4)虚函数和纯虚函数:

  • 如果类中包含纯虚函数,那么这个类就是一个抽象类,不能实例化,只能被继承。
class Abstract {
public:virtual void pure_virtual_func() = 0;  // 纯虚函数
};

5)常见误区:

  • 静态绑定的成员函数(static 关键字)不能是虚函数。

32. 🌟C++ 中构造函数可以是虚函数吗?

回答重点

构造函数不能是虚函数。

虚函数的机制依赖于虚函数表,而虚表对象的建立需要在调用构造函数之后才能完成。因为构造函数是用来初始化对象的,而在对象的初始化阶段虚表对象还没有被建立,如果构造函数是虚函数,就会导致对象初始化和多态机制的矛盾,因此,构造函数不能是虚函数。

扩展知识

1) 析构函数可以是虚函数

虽然构造函数不能是虚函数,但是析构函数应当是虚函数,特别是在基类中。这样做的目的是为了确保在删除一个指向派生类对象的基类指针时,能正确调用派生类对象的析构函数,从而避免资源泄露。

2) 其他特殊成员函数也不是虚函数

除了构造函数外,静态成员函数和友元函数也不能是虚函数。静态成员函数与类而不是与某个对象相关联,而友元函数则不属于类的成员函数,它们不具备多态性所需的对象上下文。

3) 解决方案

如果需要在对象创建时实现多态性,可以考虑工厂模式等设计模式来间接实现多态性。这些设计模式可以通过一些间接的手段,在对象创建过程中提供多态行为。

33. 🌟C++ 中析构函数一定要是虚函数吗?

回答重点

C++ 中析构函数并不一定要是虚函数,但在多态条件下,我是建议一定将其声明为虚函数。

如果一个类可能会被继承,并且你需要在删除指向派生类对象的基类指针时确保正确调用派生类的析构函数,那么基类的析构函数必须是虚函数。如果没有这样做,可能会导致资源泄漏或者未能正确释放派生类对象的资源。

扩展知识

1)虚函数的机制及其应用场景

当一个类的成员函数被声明为虚函数时,C++ 会为该类生成一个虚函数表,这个表存储指向虚函数的指针。在运行时,基于当前对象的实际类型,虚函数表指针用于动态绑定,调用正确的函数版本。

场景:假设有一个基类 Base 和一个派生类 Derived

class Base {
public:
    virtual ~Base() { std::cout << "Base destructor" << std::endl; }
};

class Derived : public Base {
public:
    ~Derived() { std::cout << "Derived destructor" << std::endl; }
};

如果基类的析构函数不是虚函数,那么以下代码可能会产生问题:

Base *obj = new Derived();
delete obj;  // 只调用了 Base 的析构函数,可能导致内存泄漏或未释放资源

2)默认情况下析构函数的行为

如果基类的析构函数不是虚函数,当你通过基类指针删除派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这种情况下,派生类中分配的资源可能不会及时释放,导致资源泄漏。

3)什么时候不需要虚析构函数

如果一个类不设计为基类,或者不会通过基类指针删除派生类对象,那么就不需要将析构函数声明为虚函数。

深入理解

首先我们先来理解一下什么是多态,多态性允许我们通过基类指针或引用来操作派生类对象。

这使得我们可以编写更通用、可扩展的代码,因为我们可以将派生类对象视为基类对象,并使用相同的接口处理它们。多态性主要通过虚函数实现。

将析构函数设计为虚函数主要是保证多态性,当我们使用基类指针或引用操作派生类对象时,如果基类的析构函数不是虚函数,此时则只会调用基类的析构函数,这样则有可能会导致未定义行为或者内存泄露。

但是当我们设置成虚函数时,并 override 之后,销毁派生类对象时,将自动调用相应的派生类析构函数,已实现派生类资源的释放。

##include<iostream>
class Base {
public:
    Base() {
        std::cout << "Base constructor"<< std::endl;
    }
    virtual ~Base() {
        std::cout << "Base destructor"<< std::endl;
    }
};
class Derived : public Base {
public:
    Derived() {
        std::cout << "Derived constructor"<< std::endl;
    }
    ~Derived() {
        std::cout << "Derived destructor"<< std::endl;
    }
};
int main() {
    Base* basePtr = new Derived(); // 使用基类指针操作派生类对象
    delete basePtr; // 销毁对象时调用正确的派生类析构函数
    return 0;
}

34. 🌟 什么是 C++ 的函数重载?它的优点是什么?和重写有什么区别?

回答重点

在 C++ 中,函数重载是指在同一个作用域内允许存在多个同名函数,它们的参数个数不同或者参数类型不同,注意函数的返回类型不同不能算作重载。

函数重载的优点是:

  1. 增强了代码的可读性。使用同名的函数,而不用为不同的功能选择完全不同的函数名,程序员可以更直观地理解代码。
  2. 改善了程序的可维护性。函数重载让我们可以定义一个通用接口,让同名函数实现不同的功能,减轻了函数命名的负担。

函数重载和函数重写(覆盖)的区别:

  1. 函数重载可以发生在同一个类中,而函数重写发生在继承关系的子类中。
  2. 函数重载要求参数列表必须不同,而函数重写要求方法签名(包括参数列表和返回类型)必须与父类方法一致。
  3. 函数重载在编译时决定调用哪个函数(静态绑定),而函数重写在运行时决定调用哪个函数(动态绑定)。

扩展知识

下面是函数重载的实例代码:

##include <iostream>  
##include <string>  

// 重载print函数以打印整数  
void print(int i) {  
    std::cout << "Printing int: " << i << std::endl;  
}  
  
// 重载print函数以打印浮点数
void print(double f) {  
    std::cout << "Printing float: " << f << std::endl;  
}  
  
// 重载print函数以打印字符串  
void print(const std::string& s) {  
    std::cout << "Printing string: " << s << std::endl;  
}  
  
// 重载print函数以打印字符  
void print(char c) {  
    std::cout << "Printing char: " << c << std::endl;  
}  
  
int main() {    
    print(5);          // 调用打印整数的print  
    print(500.263);    // 调用打印浮点数的print  
    print("Hello");    // 调用打印字符串的print  
    print('A');        // 调用打印字符的print  
    return 0;  
}

函数重载在实际应用中非常广泛,比如标准库中的 std::cout 就是很多重载的运算符 << 实现的,使我们可以打印不同类型的变量。而且,C++ STL(标准模板库)中的许多算法和容器类方法如 std::sortstd::vector 也采用了重载函数,以此来处理不同类型的输入。

除了函数重载和重写外,C++ 还支持运算符重载,允许对用户自定义的类型重载内置运算符,从而使自定义类型的使用和内置类型一样直观便捷。这个特性在实现复杂数据结构(如矩阵、复数等)时特别有用,使代码更易读、更自然。

特性
函数重载(Overload)
函数重写/覆盖(Override)
作用域
同一个类或作用域内
继承关系的子类中重写父类的虚函数
函数签名要求
函数名相同, 参数列表不同 (参数类型或个数不同)
函数名、参数列表、返回类型 必须完全相同
返回类型
可以不同(但仅返回类型不同不构成重载)
必须相同(协变返回类型除外)
绑定方式
编译时 决定调用哪个函数(静态绑定)
运行时 决定调用哪个函数(动态绑定,需虚函数)
用途
提供同一功能的多种实现方式,适应不同参数类型
子类修改或扩展父类的行为,实现多态
示例
```void print(int); void print(double);```

```virtual void draw() = 0; 在子类中重写 draw()```

35. 🌟C++ 中 using 和 typedef 的区别?

回答重点

using 在 C++11 中引入,usingtypedef 都可以用来为已有的类型定义一个新的名称。最主要的区别在于,using 可以用来定义模板别名,而 typedef 不能。

1)typedef 主要用于给类型定义别名,但是它不能用于模板别名。

typedef unsigned long ulong;
typedef int (*FuncPtr)(double);

2)using 可以取代 typedef 的功能,语法相对简洁。

using ulong = unsigned long;
using FuncPtr = int (*)(double);

3)对于模板别名,using 显得非常强大且直观。

template<typename T>
using Vec = std::vector<T>;

总之,更推荐使用 using,尤其是当你处理模板时。

扩展知识

1)模板别名(Template Aliases):using 在处理模板时,如定义容器模板别名,非常方便。假如我们需要一个模板类 std::vector 的别名:

template<typename T>
using Vec = std::vector<T>;
Vec<int> vecInt; // 相当于 std::vector<int> vecInt;

2)作用范围:using 还可以用于命名空间引入,typedef 没有此功能。

namespace LongNamespaceName {
    int value;
}

using LNN = LongNamespaceName;
LNN::value = 42; // 相当于 LongNamespaceName::value

3)可读性与调试:using 相对 typedef 更易读。

typedef void (*Func)(int, double);
using FuncAlias = void(*)(int, double);

在这个例子中,using 显然定义和解释都更加直观。

**4)现代 C++ 代码规范:**在 C++11 之后,许多代码规范建议优先使用 using 而不是 typedef。这证明了在实际应用和代码维护中,using 更具有优势。

image-20250511161633063
image-20250511161633063

36. 🌟C++ 中 map 和 unordered_map 的区别?分别在什么场景下使用?

回答重点

两者都是常用的关联容器。但有一些区别:

1)底层实现:

  • map:基于有序的红黑树(具体实现依赖于标准库)。
  • unordered_map:基于哈希表。

2)时间复杂度:

  • map:插入、删除、查找的时间复杂度为 O(log n)。
  • unordered_map:插入、删除、查找的时间复杂度为 O(1)(摊销)。

3)元素顺序:

  • map:元素按键值有序排列。
  • unordered_map:元素无序排列。

4)内存使用:

  • map:由于底层是红黑树,内存使用较少。
  • unordered_map:需要额外的空间存储哈希表,但在处理大量数据时,可能具有更好的表现。
场景选择

1)map:当需要按键值有序访问元素时,适合使用 map,例如按顺序遍历键值对。 2)unordered_map:当主要关注查找速度、不关心元素顺序时,使用 unordered_map 会更高效,例如需要高效的键值存储和快速查找的场景。

扩展知识

**1)迭代器稳定性:**在 map 中,由于基于红黑树,其迭代器在插入和删除元素时通常依然有效(除了指向被删除元素的迭代器),但 unordered_map 中,插入和删除操作可能会使所有迭代器失效。

**2)复杂数据类型的键:**如果键是复杂数据类型(需要自定义比较函数),可以在 map 中利用自定义键比较器的排序规则:

struct MyKey {
 int id;
 std::string name;
 bool operator<(const MyKey& other) const {
     return id < other.id; // 按id排序
 }
};
std::map<MyKey, int>m;

3)哈希函数的定制:在 unordered_map 中,如果键类型是用户自定义类型,需要自行提供哈希函数和比较器:

struct MyKey {
 int id;
 std::string name;
};
struct HashFunction {
 std::size_t operator()(const MyKey& k) const {
     return std::hash<int>()(k.id) ^ std::hash<std::string>()(k.name);
 }
};
struct KeyEqual {
 bool operator()(const MyKey& lhs, const MyKey& rhs) const {
     return lhs.id == rhs.id && lhs.name == rhs.name;
 }
};
std::unordered_map<MyKey, int, HashFunction, KeyEqual> um;
image-20250511161831525
image-20250511161831525

37. 什么是 C++ 中的 RAII?它的使用场景?

回答重点

RAII,全称是 "Resource Acquisition Is Initialization"(资源获取即初始化)。

它的核心思想是将资源的获取与对象的生命周期绑定,通过构造函数获取资源(如内存、文件句柄、网络连接等),通过析构函数释放资源。这样,即使程序在执行过程中抛出异常或多路径返回,也能确保资源最终得到正确释放,特别是可以避免内存泄漏。

扩展知识

1)使用场景:

  • **内存管理:**标准库中的 std::unique_ptrstd::shared_ptr 是 RAII 的经典实现,用于智能管理动态内存。
  • 文件操作:std::fstream 类在打开文件时获取资源,在析构函数中关闭文件。
  • 互斥锁:std::lock_guardstd::unique_lock 用于在多线程编程中自动管理互斥锁的锁定和释放。

2)示例代码:

##include <iostream>
##include <fstream>

class FileHandler {
public:
    FileHandler(const std::string& filename) : file(filename) { // 资源获取
        if (!file.is_open()) {
           throw std::runtime_error("Unable to open file");
        }
    }
    
    ~FileHandler() {
        file.close(); // 资源释放
    }
    
    void write(const std::string& data) {
        if (file.is_open()) {
            file << data << std::endl;
        }
    }

private:
    std::ofstream file;
};

int main() {
    try {
        FileHandler fh("example.txt");
        fh.write("Hello, RAII!");
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
    }return 0;
}

在这个示例中,FileHandler 类在构造函数中打开文件,在析构函数中关闭文件。即使 main 函数中发生异常或提前返回,析构函数也会自动调用,确保文件被正确关闭。

3)RAII 的好处:

  • **异常安全:**使用 RAII 能够确保在异常发生时自动释放资源,避免资源泄漏。
  • **简化资源管理:**将资源的获取和释放逻辑封装在类内,使代码更加简洁且方便维护。

4)与智能指针的结合:

std::unique_ptr<int> ptr(new int(5));

5)扩展应用:

  • 锁管理:通过 std::lock_guard 对锁进行管理,确保锁在作用范围内被正确释放。
std::mutex mtx;
{
   std::lock_guard<std::mutex> lock(mtx);
   // 临界区代码
} // mtx 在此处自动释放
image-20250511161717071
image-20250511161717071

38. C++ 的 function、bind、lambda 都在什么场景下会用到?

回答重点

三者都用于处理函数和可调用对象:

1)std::function:用于存储和调用任意可调用对象(函数指针、Lambda、函数对象等)。常用场景包括回调函数、事件处理、作为函数参数和返回值。

2)std::bind:用于绑定函数参数,生成函数对象,特别是当函数参数不完全时。常见于将已有函数适配为接口要求的回调、将成员函数与对象绑定。

3)Lambda 表达式:用于定义匿名函数,通常在短期和局部使用函数时比如一次性回调函数、算法库中的自定义操作等。

扩展知识

std::function

std::function 在 C++11 中引入,它是一个类模板,用于封装任何形式的可调用对象。使用 std::function 可以很方便地存储各种不同类型的函数,以便后面调用。

常见使用场景:

1)回调函数:在图形用户界面程序或网络编程中,经常需要定义回调函数。

2)事件处理:在观察者模式中,可以用 std::function 存储和调用事件处理函数。

3)作为函数参数和返回值:方便传递函数或存储函数以在其他地方调用。

示例:

##include <functional>
##include <iostream>
##include <vector>

void exampleFunction(int num) {
    std::cout << "Number: " << num << std::endl;
}

int main() {
    std::function<void(int)> 
    func = exampleFunction;func(42);
    return 0;
}
std::bind

std::bind 是一个函数模板,用于从一个可调用对象(如函数或成员函数)和其部分参数创建新的函数对象。这在处理不完全的函数参数或需要绑定特定对象的时候特别有用。

常见使用场景:

1)适配接口:当接口要求的函数签名与现有函数不匹配时,可以通过 std::bind 进行参数适配。

2)绑定成员函数:通过 std::bind 可以绑定类的成员函数与具体的实例对象,从而创建可以调用的对象。

示例:

##include <functional>
##include <iostream>

void exampleFunction(int a, int b) {
    std::cout << "Sum: " << a + b << std::endl;
}

int main() {
    auto boundFunction = std::bind(exampleFunction, 10, std::placeholders::_1);
    boundFunction(32); // Output: Sum: 42
    return 0;
}
Lambda 表达式

Lambda 表达式是一种匿名函数,它可以在定义的地方直接使用,通常用于简单的计算。如果某个函数逻辑仅在某个特定范围内有用,使用 Lambda 表达式可以使代码更简洁。

常见使用场景:

1)一次性回调函数:与算法和容器一起使用,以简化代码。

2)自定义操作:在标准库算法(如 std::for_each, std::transform 等)中,使用 Lambda 表达式进行自定义操作。

示例:

##include <algorithm>
##include <iostream>
##include <vector>

int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    std::for_each(vec.begin(), vec.end(), [](int &n) { n *= 2; });

    for (int n : vec) {
        std::cout << n << " ";
    }
    std::cout << std::endl;
    return 0;
}

39. 🌟C++ 中堆内存和栈内存的区别?

回答重点

堆内存和栈内存的区别主要体现在分配方式、管理方式、生命周期和性能等方面:

1)分配方式:

  • 栈内存:由编译器在程序运行时自动分配和释放。典型的例子是局部变量的分配。
  • 堆内存:需要程序员手动分配和释放,使用 newdelete 操作符。在 C++11 之后,也可以使用智能指针来管理堆内存。

2)管理方式:

  • 栈内存:由编译器自动管理,程序员无需担心内存泄漏,生命周期由作用域决定。
  • 堆内存:由程序员手动管理,如果没有正确释放内存,会导致内存泄漏。

3)生命周期:

  • 栈内存:变量在离开作用域之后自动销毁。
  • 堆内存:只要不手动释放,内存会持续存在,直到程序终止。

4)性能:

  • 栈内存:内存分配和释放速度极快,性能上优于堆内存。
  • 堆内存:涉及到复杂的内存管理和分配机制,性能上较慢。

扩展知识

1)内存分配函数:

  • 除了 newdelete,堆内存还可以使用 mallocfree 来管理。区别在于 new 会调用构造函数,而 malloc 只是纯粹的内存分配。

2)内存溢出和内存泄漏:

  • 内存溢出:栈空间是有限的,如果递归过深或者分配的局部变量太大,可能导致栈溢出。
  • 内存泄漏:堆内存如果没有正确释放,会导致内存泄漏,尤其在长时间运行的程序中,会影响系统性能。

3)智能指针:

  • C++11 引入了智能指针 std::unique_ptrstd::shared_ptr,可以自动管理堆内存,大大降低了内存泄漏的风险。

4)虚拟内存:

  • 虚拟内存机制,使物理内存和逻辑内存独立,程序可以看到的是一个巨大的连续地址空间,但实际上可能是分散的物理内存和硬盘上的交换空间。

5)栈与堆的容量:

  • 栈的容量往往较小,通常为几 MB,主要用于局部变量和函数调用管理。
  • 堆的容量通常较大,依赖于系统可用内存,适合动态分配大量内存。
C++ 堆内存与栈内存对比表
image-20250511161750399
image-20250511161750399

40. 什么是 C++ 的回调函数?为什么需要回调函数?

回答重点

回调函数是一种通过函数指针或者函数对象(例如 std::function 或 lambda 表达式)将一个函数作为参数传递给另一个函数的机制。

实际上,就是把函数的调用权从一个地方转移到另一个地方,这个调用会在未来某个时刻进行,而不是立即执行。之所以称为“回调”,可以理解为某种倒叙执行:先安排好函数的调用,不立即执行,等到合适的时机再“回头”执行。

需要回调函数的主要原因包括:

**1)异步编程:**在异步操作中,比如网络请求、文件读取、事件处理等,可以在操作完成后调用回调函数,而主程序可以继续执行其它任务,避免等待操作完成。

**2)解耦代码:**回调函数有助于将代码模块化和解耦,允许我们创建更灵活和可复用的代码。例如,一个通用的排序算法可以接受一个比较函数,允许用户自定义排序逻辑。

**3)事件驱动编程:**在 GUI 或者其他事件驱动程序中,回调函数经常用于处理用户输入事件,如点击、鼠标移动、键盘输入等。

扩展知识

回调函数的实际应用:

1)使用函数指针作为回调函数:

在 C 风格接口中,最常见的回调函数形式就是使用函数指针。例如:

##include <iostream>

// 定义一个函数指针类型
typedef void (*CallbackFunc)(int);

void RegisterCallback(CallbackFunc cb) {
 // 模拟某些操作
 std::cout << "Registering callback...\n";
 cb(42); // 调用回调函数
}

void MyCallback(int value) {
 std::cout << "Callback called with value: " << value << std::endl;
}

int main() {
 RegisterCallback(MyCallback); // 传递回调函数
 return 0;
}

**2)使用 C++11 之后的 **std::function 和 lambda 表达式:

##include <iostream>
##include <functional>

void RegisterCallback(
 std::function<void(int)> cb) {
 std::cout << "Registering callback...\n";
 cb(42); // 调用回调函数
}

int main() {
 auto myCallback = [](int value) {
     std::cout << "Callback called with value: " << value << std::endl;
 };
 RegisterCallback(myCallback); // 传递 lambda 回调函数
 return 0;
}

3)GUI 编程中的回调:

在图形用户界面编程中,回调函数常用于处理用户事件。例如,在一个按钮点击事件中调用用户提供的回调函数。

例如,使用一个假设的 GUI 库:

class Button {
 public:
 void setOnClick(std::function<void()> cb) {
     onClick = cb;
 }

void simulateClick() {
    if (onClick) {
        onClick();
     }
 }
 
private:
 std::function<void()> onClick;
};

int main() {
 Button button;
 button.setOnClick([]() {
     std::cout << "Button clicked!" << std::endl;
 });
 button.simulateClick(); // 模拟一次点击事件
 return 0;
}