C++技术岗面试经验总结

🎬 胖咕噜的稞达鸭个人主页
🔥 个人专栏 : 《数据结构《C++初阶高阶》
《Linux系统学习》
《算法日记》

⛺️技术的杠杆,撬动整个世界!


1. 右值引用和左值引用的区别

左值是我们平常使用的函数对象,表达式结束后依旧存在的持久对象;

右值是表达式结束后就会销毁的临时对象。

  • 左值引用(T&) 主要用于绑定左值,本质是给变量取别名,常用于函数传参避免拷贝。
  • 右值引用(T&&) 用于绑定右值,主要目的是实现移动语义(不复制资源,本质上是转移资源的所有权),提高性能。

在实际使用中,右值引用通常会配合 std::move 使用。

需要注意的是,std::move 本质上只是一个类型转换,它将左值转换为右值引用,使对象可以被移动,但并不会真正移动资源。

补充几点关键规则

  1. 右值引用不能绑定左值,但可以通过 std::move 转换。
  2. 左值引用不能直接绑定右值,但 const T& 可以。
  3. 右值引用变量本身是左值。
  4. 右值引用的核心价值是减少拷贝,提高性能。

1.1 手写 string 类,实现构造函数、移动构造和移动赋值

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

class MyString
{
public:
    // 1. 构造函数
    MyString(const char* str = "")
    {
        _size = strlen(str);
        _data = new char[_size + 1];
        strcpy(_data, str);
        cout << "构造函数" << endl;
    }

    // 2. 析构函数
    ~MyString()
    {
        delete[] _data;
        _data = nullptr;
        _size = 0;
        cout << "析构函数" << endl;
    }

    // 3. 拷贝构造
    MyString(const MyString& other)
    {
        _size = other._size;
        _data = new char[_size + 1];
        strcpy(_data, other._data);
        cout << "拷贝构造" << endl;
    }

    // 4. 拷贝赋值
    MyString& operator=(const MyString& other)
    {
        cout << "拷贝赋值" << endl;
        if (this != &other)
        {
            delete[] _data;
            _size = other._size;
            _data = new char[_size + 1];
            strcpy(_data, other._data);
        }
        return *this;
    }

    // 5. 移动构造
    MyString(MyString&& other) noexcept
    {
        _data = other._data;
        _size = other._size;
        other._data = nullptr;
        other._size = 0;
        cout << "移动构造" << endl;
    }

    // 6. 移动赋值
    MyString& operator=(MyString&& other) noexcept
    {
        cout << "移动赋值" << endl;
        if (this != &other)
        {
            delete[] _data;
            _data = other._data;
            _size = other._size;
            other._data = nullptr;
            other._size = 0;
        }
        return *this;
    }

    void print() const
    {
        cout << (_data ? _data : "nullptr") << endl;
    }

private:
    char* _data;
    size_t _size;
};

2. 智能指针有哪些?

C++11 中主要有三种智能指针:
unique_ptr:独占所有权,不允许拷贝
shared_ptr:共享所有权,使用引用计数管理资源
weak_ptr:弱引用,不增加引用计数,主要用于解决循环引用问题

unique_ptrshared_ptr 区别
unique_ptr 是独占所有权,同一时间只能有一个指针指向资源,不能拷贝,只能移动。
shared_ptr 是共享所有权,通过引用计数来管理资源,多个指针可以指向同一对象。

它的引用计数是线程安全的(原子操作),但是对象本身不是线程安全的。

当引用计数为 0 时自动释放资源。

为什么要有智能指针?

在传统的 C++ 编程中,用 new 申请一块内存空间,最后要用 delete 释放这块内存空间。

当我们忘记 delete,或者 delete 没有正常执行的时候,就会造成内存泄漏。

更本质的问题在于,C++ 是手动管理内存的,如果程序在执行过程中发生异常、提前返回,或者逻辑分支复杂,就可能导致 delete 没有被执行。

为了解决这个问题,C++ 引入了 RAII 机制 和 智能指针,通过对象生命周期自动管理资源,从而避免内存泄漏。

3. 什么是 RAII 机制?

RAII 通过"==对象构造时获取资源,对象析构时释放资源"==的机制,将所有需要手动管理的资源(内存、文件、锁等)与对象生命周期绑定,从而从根源上避免资源泄漏,包括异常场景下的泄漏。

智能指针满足了 RAII 的设计思路,还方便资源的访问。智能指针代管资源,模拟指针的行为,访问和修改资源。

RAII只能管理内存吗?

RAII不仅用于内存管理,还广泛应用于各种资源的管理。

  • 例如,文件句柄、数据库连接和锁等资源,都可以通过RAII进行管理。
  • 文件句柄:可以通过自定义类或智能指针,在对象析构时自动关闭文件。
  • 锁:使用 std::lock_guardstd::unique_lock 来自动加锁和解锁,避免死锁和忘记释放锁的情况。
  • 数据库连接:可以使用RAII来确保数据库连接在操作结束后自动关闭,避免连接泄漏。

4. 智能指针会引发什么问题?

4.1 循环引用问题

shared_ptr 采用引用计数的方式管理对象生命周期。正常情况下,当引用计数减为 0 时,对象会被自动释放。

但是如果两个对象互相持有对方的 shared_ptr,就会形成循环引用 。一旦形成循环引用,这两个对象的引用计数都无法降为 0,最终导致对象不能被释放,从而产生内存泄漏

4.2 为什么会发生内存泄漏?

原因在于:

  • shared_ptr 会让引用计数加 1
  • 对象之间互相持有 shared_ptr
  • 即使外部的 shared_ptr 已经销毁,对象内部仍然彼此引用
  • 所以引用计数始终不为 0
  • 析构函数无法被调用,资源也就无法释放

4.3 如何解决循环引用问题?

解决方法就是:

把其中一边的 shared_ptr 改成 weak_ptr

因为 weak_ptr 是一种弱引用

  • 它可以观察 shared_ptr 管理的对象
  • 但是不会增加引用计数
  • 不参与对象生命周期管理

这样就可以打破强引用环,避免内存泄漏。

4.4 weak_ptr 的特点

weak_ptr 不能直接通过 ->* 来访问对象。

如果想访问资源,需要先调用 lock()

  • 如果对象还存在,lock() 会返回一个有效的 shared_ptr
  • 如果对象已经释放,lock() 返回空对象

所以在使用 weak_ptr 访问资源时,一般要先判断 lock() 的结果是否为空。


5. vector 和 list 的应用场景有什么不同?

5.1 二者的本质区别

  • vector 的底层是动态数组
  • list 的底层是双向链表

因此它们在访问方式、插入删除效率以及内存布局上都不一样。

5.2 vector 的特点

vector 适合以下场景:

  • 需要高效随机访问
  • 经常在尾部插入元素
  • 对缓存友好,性能较好
  • 内存开销相对较小

因为 vector 底层是连续空间,所以可以通过下标快速访问元素,随机访问效率高。

5.3 list 的特点

list 更适合以下场景:

  • 频繁在中间位置插入或删除元素
  • 不要求随机访问
  • 更关注插入删除的灵活性

由于 list 底层是链表,所以插入删除时不需要大规模搬移元素,但它不能像 vector 一样高效随机访问,同时内存开销也更大。

5.4 实际怎么选?

通常情况下:

大多数场景优先选择 vector

因为它的综合性能更好。

只有在频繁中间插入和删除 时,list 才更合适。

5.5 vector 的扩容机制

vector 的底层是动态数组,它有两个重要概念:

  • size:当前元素个数
  • capacity:当前容量

当插入元素时:

  • 如果 size < capacity,就可以直接插入,不会扩容
  • 如果 size == capacity,说明空间已经满了,这时就会触发扩容

5.6 扩容触发条件

扩容的本质触发条件是:

  • _finish == _end_of_storage

也就是当前已经没有剩余空间可以继续存放新元素。

5.7 vector 扩容时会发生什么?

扩容一般不会原地进行,而是会执行以下步骤:

  1. 重新申请一块更大的连续内存空间
  2. 将旧空间中的元素拷贝或移动到新空间
  3. 销毁旧空间中的对象
  4. 释放旧空间
  5. 更新内部指针,指向新的内存区域

5.8 为什么不能原地扩容?

因为 vector 要求底层内存必须是连续空间

但操作系统很难保证原有空间后面正好还有一块足够大的连续内存,因此大多数情况下只能重新申请一块新的更大空间。

5.9 常见扩容策略

常见实现中,vector 会按几何方式扩容,例如:

  • GCC 通常按 2 倍 扩容
  • MSVC 通常按 1.5 倍 扩容

这样虽然单次扩容的时间复杂度是 O(n),但从均摊分析来看,push_back() 的均摊时间复杂度仍然是 O(1)

5.10 扩容的影响

vector 一旦扩容,会导致:

  • 原来的迭代器失效
  • 原来的指针失效
  • 原来的引用失效

因为底层地址已经发生了变化。

5.11 如何减少扩容带来的性能损失?

如果提前知道大概需要多少空间,可以使用:

  • reserve(n)

提前预留容量,减少扩容次数,从而提高性能。


6. map 和 unordered_map 的区别

mapunordered_map 都是 C++ 中常见的关联容器,都可以存储键值对,但它们的底层实现和使用场景并不相同。

6.1 底层实现不同

  • map:底层通常基于红黑树
  • unordered_map:底层基于哈希表

这决定了它们在查找效率、元素顺序以及使用场景上的差异。

6.2 时间复杂度不同

map
  • 插入:O(logn)
  • 删除:O(logn)
  • 查找:O(logn)
unordered_map
  • 插入:O(1)(摊销)
  • 删除:O(1)(摊销)
  • 查找:O(1)(摊销)

所以从平均查找效率来看,unordered_map 往往更快。

6.3 元素顺序不同

  • map 中的元素会按照 key 有序排列
  • unordered_map 中的元素是无序存储

如果你需要按键有序遍历,那么 map 更适合。

如果你不关心顺序,只关心查找速度,那么 unordered_map 更合适。

6.4 内存使用不同

  • map 由于底层是红黑树,每个节点都需要维护树结构信息
  • unordered_map 由于底层是哈希表,除了元素本身,还需要桶数组等额外空间

文档里的表述是:

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

6.5 场景选择

适合用 map 的场景

当你需要:

  • 按 key 有序访问元素
  • 顺序遍历键值对
  • 范围查询

这类场景更适合使用 map

适合用 unordered_map 的场景

当你需要:

  • 更高的查找效率
  • 不关心元素顺序
  • 高频键值查找

这类场景更适合使用 unordered_map


7. C++ 多态是怎么实现的?

7.1 什么是多态?

多态的核心含义可以理解为:

同样的接口,调用时表现出不同的行为

在 C++ 中,多态主要依赖虚函数 来实现。

当使用基类指针或者基类引用指向派生类对象时,通过这个统一接口调用函数,就可能表现出不同子类的不同实现。

7.2 多态实现的两个条件

多态成立需要满足两个条件:

  1. 被调用的函数必须是虚函数
  2. 必须通过基类的指针或者引用指向派生类对象

只有同时满足这两个条件,调用时才会发生动态绑定,从而执行子类重写后的函数。

7.3 为什么虚函数能实现多态?

如果父类中的成员函数没有加 virtual,那么通过基类指针调用函数时,会发生静态绑定,调用的是基类版本。

如果加了 virtual,并且子类对该函数进行了重写,那么通过基类指针调用时,会发生动态绑定,最终调用的是子类版本。

所以虚函数解决的是这样一个问题:

父类指针 / 父类引用,如何正确调用子类自己的实现


7.4 为什么基类的析构函数建议写成虚函数?

这是面试中特别高频的一个追问。

如果一个类会被当作基类使用,并且你可能会通过基类指针释放派生类对象,那么基类析构函数就应该写成虚函数。

原因是:

  • 如果基类析构函数不是虚函数
  • 通过基类指针 delete 派生类对象时
  • 只会调用基类析构函数
  • 不会调用派生类析构函数
  • 如果派生类中自己申请了资源,比如堆内存、文件句柄、锁等
  • 这些资源就无法被正确释放,从而造成资源泄漏

而如果基类析构函数是虚函数,那么通过基类指针释放对象时,会先调用派生类析构,再调用基类析构,这样对象才能被完整销毁。

7.5 本质总结

基类析构函数建议写成虚函数,本质原因就是:

保证通过基类指针释放派生类对象时,析构过程完整执行,避免资源泄漏


代码示例

智能指针循环引用示例

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

struct ListNode
{
    int _data;
    shared_ptr<ListNode> _next;
    shared_ptr<ListNode> _prev;

    ~ListNode()
    {
        cout << "~ListNode()" << endl;
    }
};

int main()
{
    // 循环引用 -- 内存泄漏
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);

    cout << "初始引用计数:" << endl;
    cout << "n1.use_count() = " << n1.use_count() << endl; // 1
    cout << "n2.use_count() = " << n2.use_count() << endl; // 1

    n1->_next = n2;
    n2->_prev = n1;

    cout << "互相指向后:" << endl;
    cout << "n1.use_count() = " << n1.use_count() << endl; // 2
    cout << "n2.use_count() = " << n2.use_count() << endl; // 2

    return 0;
}

使用weak_ptr解决循环引用的代码:

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

struct ListNode
{
    int _data;
    shared_ptr<ListNode> _next;
    weak_ptr<ListNode> _prev; // 改成 weak_ptr

    ~ListNode()
    {
        cout << "~ListNode()" << endl;
    }
};

int main()
{
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);

    cout << "初始引用计数:" << endl;
    cout << "n1.use_count() = " << n1.use_count() << endl; // 1
    cout << "n2.use_count() = " << n2.use_count() << endl; // 1

    n1->_next = n2;
    n2->_prev = n1; // weak_ptr 绑定 shared_ptr,不增加 n1 的计数

    cout << "建立关系后:" << endl;
    cout << "n1.use_count() = " << n1.use_count() << endl; // 1
    cout << "n2.use_count() = " << n2.use_count() << endl; // 2

    return 0;
}

weak_ptr访问资源的方式:

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

struct ListNode
{
    int _data;
    shared_ptr<ListNode> _next;
    weak_ptr<ListNode> _prev;

    ListNode(int x) : _data(x) {}
};

int main()
{
    auto n1 = make_shared<ListNode>(10);
    auto n2 = make_shared<ListNode>(20);

    n1->_next = n2;
    n2->_prev = n1;

    // weak_ptr 不能直接用 -> 或 *
    // 要先 lock()
    if (auto sp = n2->_prev.lock())
    {
        cout << "n2 的前驱节点数据: " << sp->_data << endl;
    }
    else
    {
        cout << "前驱节点已经释放" << endl;
    }

    return 0;
}
相关推荐
Wild_Pointer.2 小时前
高效工具实战指南:从零开始编写CMakeLists
c++
PrDf22Iw82 小时前
CPU ↔ DRAM(内存总线)的可持续数据传输带宽
java·运维·网络
众创岛2 小时前
iframe的属性获取
开发语言·javascript·ecmascript
一个处女座的程序猿O(∩_∩)O2 小时前
Python基础知识大全:从零开始掌握Python核心语法
开发语言·python
小陈工2 小时前
Python Web开发入门(十一):RESTful API设计原则与最佳实践——让你的API既优雅又好用
开发语言·前端·人工智能·后端·python·安全·restful
计算机安禾3 小时前
【数据结构与算法】第28篇:平衡二叉树(AVL树)
开发语言·数据结构·数据库·线性代数·算法·矩阵·visual studio
csbysj20203 小时前
网站主机技术概述
开发语言
froginwe113 小时前
jQuery 事件方法详解
开发语言
汤愈韬3 小时前
路由反射器实验、环回接口建立IBGP邻居、更新源检查机制
网络·网络协议·网络安全·security