返回文章索引

C++ 面试重点复习:类型转换、并发、Linux、Git 与回溯

系统复习四种 C++ cast、信号与信号量、条件变量、Linux 性能排查、Git 脏工作区同步、回溯剪枝去重和 const 对象的 this 指针。

GitHub 原文

C++ 面试重点复习:类型转换、并发、Linux、Git 与回溯

本文先介绍 C++ 的四种命名类型转换,再补充并发、Linux 运维排查、Git 协作、回溯算法和 const 对象等面试高频问题。回答时不仅要说出名词,还要说明使用边界、失败表现和实际排查步骤。

一、四种 cast 的快速对比

转换方式 主要用途 是否运行时检查 典型风险
static_cast 相关类型的编译期转换、数值转换、显式调用转换构造函数 向下转型时实际类型不匹配会产生未定义行为
dynamic_cast 多态继承体系中的安全向下转型、交叉转型 需要多态源类型;失败时返回空指针或抛异常
const_cast 添加或移除 const/volatile 属性 修改原本定义为 const 的对象会产生未定义行为
reinterpret_cast 低级别地重新解释指针、整数或函数指针 对齐、生命周期、严格别名和可移植性问题
C 风格转换 兼容旧代码的综合转换 视实际转换而定 规则不透明,可能隐藏危险转换

优先使用能够表达意图的命名 cast。看到 reinterpret_castconst_cast 时,应额外检查其使用是否有充分理由。

二、static_cast

static_cast 主要在编译期完成检查,适用于编译器能够根据静态类型判断的转换。

1. 数值类型转换

double price = 19.99;
int value = static_cast<int>(price); // value 为 19,小数部分被截断

数值转换可能丢失精度或发生溢出,因此“能够编译”不等于“数值一定正确”。

int count = 100;
std::size_t size = static_cast<std::size_t>(count);

从有符号类型转换到无符号类型时,负数会按照无符号规则转换,必须确认输入范围。

2. 派生类向基类转换

class Base {};
class Derived : public Base {};

Derived derived;
Base* base = static_cast<Base*>(&derived); // 向上转型,安全

3. 基类向派生类转换

Base* base = /* 实际指向 Derived 对象,或其他对象 */;
Derived* derived = static_cast<Derived*>(base);

这种转换只根据静态类型编译,不检查 base 实际指向的对象是否真的是 Derived。如果实际对象不是 Derived,后续通过 derived 访问派生类成员可能产生未定义行为。

因此,多态场景下通常应该使用 dynamic_cast

4. 枚举和整数转换

enum class State { Ready = 0, Running = 1 };

int raw = 1;
State state = static_cast<State>(raw);

转换不会自动验证 raw 是否对应有效枚举值,必要时应先自行检查范围。

5. 显式调用转换构造函数或转换运算符

class UserId {
public:
    explicit UserId(int value) : value_(value) {}

private:
    int value_;
};

UserId id = static_cast<UserId>(42);

面试表达:

static_cast 主要进行编译期可确定的转换,不做运行时多态检查。数值转换和向下转型虽然可能编译成功,但仍然需要调用者保证范围和实际类型正确。

三、dynamic_cast

dynamic_cast 用于带继承关系的类型之间进行运行时安全转换,典型场景是多态基类指针向派生类指针的向下转型,或者两个兄弟派生类之间的交叉转型。

class Base {
public:
    virtual ~Base() = default; // 使 Base 成为多态类型
};

class Cat : public Base {};
class Dog : public Base {};

Base* object = new Cat;

Cat* cat = dynamic_cast<Cat*>(object); // 成功
Dog* dog = dynamic_cast<Dog*>(object); // 失败,得到 nullptr

delete object;

1. 虚函数要求

进行运行时类型识别时,源类型必须是多态类型,也就是至少包含一个虚函数:

class NonPolymorphicBase {};
class Child : public NonPolymorphicBase {};

NonPolymorphicBase* base = nullptr;
// dynamic_cast<Child*>(base); // 编译错误:源类型不是多态类型

常见做法是在基类中声明虚析构函数:

virtual ~Base() = default;

需要注意,普通的派生类向基类的向上转换是静态可确定的,不依赖运行时类型识别;“所有 dynamic_cast 都要求虚函数”是过于绝对的说法。真正需要多态源类型的是向下转型、交叉转型等运行时检查场景。

2. 指针和引用的失败表现

Base* base = new Cat;

if (Dog* dog = dynamic_cast<Dog*>(base)) {
    // 转换成功
}

try {
    Dog& dog = dynamic_cast<Dog&>(*base);
} catch (const std::bad_cast&) {
    // 引用转换失败
}

delete base;

规则是:

  • 指针转换失败,返回 nullptr
  • 引用转换失败,抛出 std::bad_cast
  • 转换成功后,结果指向同一个对象的对应子对象。

面试表达:

dynamic_cast 在多态继承体系中进行运行时类型检查。向下转型和交叉转型要求源类型具有虚函数。指针失败返回 nullptr,引用失败抛出 std::bad_cast。它比 static_cast 安全,但有运行时开销,也可能说明设计过度依赖类型判断。

四、const_cast

const_cast 只能用于调整类型的 constvolatile 属性,不能把一个整数直接转换成指针,也不能改变对象真正的存储类型。

void print(char* text) {
    // 旧接口错误地要求 char*,但函数实际只读数据
}

const char* message = "hello";
print(const_cast<char*>(message));

上面的调用只有在 print 确实不会修改字符串时才可能安全。更好的设计是修改接口:

void print(const char* text);

关键区别:原对象是否本来就是 const

通过 const_cast 得到非 const 指针并不一定立刻产生未定义行为。关键看原对象的定义:

int value = 10;
const int* readOnlyView = &value;
int* writableView = const_cast<int*>(readOnlyView);
*writableView = 20; // 合法,因为原对象 value 本身不是 const

但如果对象从定义开始就是 const

const int value = 10;
int* writableView = const_cast<int*>(&value);
*writableView = 20; // 未定义行为

面试表达:

const_cast 只调整 constvolatile 属性。移除类型上的 const 不代表可以安全修改对象;如果对象本身定义为 const,通过转换后的指针修改它是未定义行为。优先修正被调用函数的参数类型,而不是滥用 const_cast

五、reinterpret_cast

reinterpret_cast 用于低级别地重新解释一个值的类型,常见于系统编程、内存映射、硬件接口和与 C API 对接。

std::uintptr_t address = 0x1000;
int* pointer = reinterpret_cast<int*>(address);

它通常不执行运行时类型检查,也不保证目标地址满足:

  • 正确的内存对齐;
  • 对象生命周期已经开始;
  • 目标类型允许通过该表达式访问;
  • 指针在当前平台上具有可移植的表示。

因此,“转换成功”与“解引用安全”是两回事。

1. 指针类型重新解释

int value = 42;
unsigned char* bytes = reinterpret_cast<unsigned char*>(&value);

通过字符类型访问对象的底层字节通常有特殊规则,但字节顺序、int 大小和表示方式都可能随平台变化。

2. 不要把它等同于按位拷贝

reinterpret_cast 主要改变表达式的解释方式,并不等于把源对象的比特位复制到一个新对象。

如果目标是安全地复制位模式,应使用 C++20 的 std::bit_cast

#include <bit>
#include <cstdint>

float number = 1.0f;
std::uint32_t bits = std::bit_cast<std::uint32_t>(number);

std::bit_cast 要求两种类型大小相同,并且满足可平凡复制等约束;它表达的是“按位复制”,比指针重解释更清晰。

3. 严格别名和生命周期问题

double value = 3.14;
int* pointer = reinterpret_cast<int*>(&value);
// int result = *pointer; // 不应这样通过不相关类型访问对象

这种写法可能违反严格别名规则,导致未定义行为。对于对象表示的复制,使用 std::bit_caststd::memcpy 或正确的序列化方案。

面试表达:

reinterpret_cast 是低级别的类型重新解释,不做运行时安全检查。它不是通用的按位拷贝工具,使用时必须确认对齐、对象生命周期、严格别名和平台表示规则,否则可能产生未定义行为。

六、C 风格转换

C 风格转换写法如下:

double value = 3.14;
int result = (int)value;

在 C++ 中,C 风格转换可能尝试执行多种转换,包括类似 const_caststatic_castreinterpret_cast,甚至继承体系中的相关转换。具体规则不直观,不利于代码审查。

建议改成:

int result = static_cast<int>(value);

这样读代码的人可以直接知道作者的意图,也更容易由编译器和工具发现问题。

七、如何选择

可以按照下面的顺序判断:

  1. 只是数值、枚举或明确的相关类型转换:优先 static_cast
  2. 需要在多态继承体系中确认实际对象类型:使用 dynamic_cast
  3. 只是适配旧接口的 const 参数:谨慎使用 const_cast,优先修改接口。
  4. 需要访问底层地址或平台相关表示:才考虑 reinterpret_cast
  5. 不要在新代码中使用无法表达意图的 C 风格转换。

八、面试高频陷阱

陷阱 1:static_cast 向下转型一定安全吗?

不安全。它不检查实际动态类型,只有调用者能够证明对象确实是目标派生类时才可以使用。

陷阱 2:dynamic_cast 一定需要虚函数吗?

运行时向下转型和交叉转型需要源类型是多态类型;普通的向上转换不应简单概括为“必须有虚函数”。

陷阱 3:const_cast 去掉 const 后就能修改对象吗?

不一定。如果对象本身定义为 const,修改它是未定义行为。

陷阱 4:reinterpret_cast 就是复制二进制位吗?

不是。需要按位复制时使用 std::bit_castmemcpy,而不是先把指针转成另一个类型再解引用。

陷阱 5:四种 cast 只是写法不同吗?

不是。它们分别对应编译期转换、运行时多态检查、限定符调整和低级别表示重解释,安全性和适用边界完全不同。

九、最终记忆版

static_cast      编译期、常规转换、不做运行时类型检查
dynamic_cast     多态体系、运行时检查、失败可识别
const_cast       调整 const/volatile,不能改变原对象的本质
reinterpret_cast 低级重解释,风险最高,不等于按位拷贝
C 风格转换      规则混杂,新代码尽量避免

写 C++ 转换代码时,最重要的不是“能不能编译”,而是明确回答两个问题:转换后的对象是否真的具有目标类型,以及是否满足对齐、生命周期、别名和可修改性要求。


十、signal 与 semaphore:信号和信号量不是一回事

1. signal(信号)

信号是操作系统发送给进程或线程的异步事件通知。例如:

信号 含义
SIGINT 终端按下 Ctrl+C
SIGTERM 请求进程正常退出
SIGSEGV 非法内存访问
SIGKILL 强制终止,不能捕获或忽略

进程可以使用默认处理方式、忽略信号,或者通过处理函数响应信号:

#include <csignal>

void onSignal(int signo) {
    // 这里只做异步信号安全的操作
}

int main() {
    std::signal(SIGINT, onSignal);
}

信号处理函数运行在异步时机,不能随意调用 mallocnew、普通锁或复杂的非线程安全库函数。实际项目中通常只在处理函数里设置一个原子标志,再由主循环完成真正的清理工作。

2. semaphore(信号量)

信号量是线程或进程之间的同步原语,本质上是一个受保护的计数器:

  • waitPdown:计数减一,资源不足时阻塞;
  • postVup:计数加一,必要时唤醒等待者。
#include <semaphore>

std::counting_semaphore<3> slots(3);

void work() {
    slots.acquire();
    // 最多三个线程同时使用资源
    slots.release();
}

面试回答可以概括为:

signal 是操作系统的异步事件通知;semaphore 是用于同步和资源计数的并发工具。signal 关注“发生了什么事件”,semaphore 关注“有多少资源或某个事件是否已经发生”。

十一、条件变量为什么必须配合互斥锁

条件变量本身不保存业务条件,它只负责阻塞和唤醒。业务条件由共享状态表示,互斥锁负责保护这个状态。

#include <condition_variable>
#include <mutex>

std::mutex mutex;
std::condition_variable cv;
bool ready = false;

void consumer() {
    std::unique_lock<std::mutex> lock(mutex);
    cv.wait(lock, [] {
        return ready;
    });
    // 获得锁且 ready 为 true
}

void producer() {
    {
        std::lock_guard<std::mutex> lock(mutex);
        ready = true;
    }
    cv.notify_one();
}

wait 会原子地释放锁并进入等待,被唤醒后再重新加锁。这避免了“检查条件后、真正睡眠前恰好错过通知”的竞态。

条件变量允许虚假唤醒,所以必须循环检查条件,不能只写一次 if

while (!ready) {
    cv.wait(lock);
}

带谓词的 cv.wait(lock, predicate) 已经封装了这个循环。互斥锁保护的是 ready 等共享状态,条件变量不负责解决数据竞争。

十二、Linux 线程 CPU 占用高的排查

先找到进程:

top
ps aux --sort=-%cpu | head

假设进程 PID 是 1234,查看进程内各线程:

top -H -p 1234
ps -L -p 1234 -o pid,tid,psr,pcpu,stat,comm
pidstat -t -p 1234 1

top -H 将线程展开显示,先记录 CPU 最高线程的 TID,再用 perf 看热点:

sudo perf top -p 1234
sudo perf record -F 99 -g -p 1234 -- sleep 30
sudo perf report

排查思路:

  1. 线程持续占满 CPU,先看 perf report 中占比最高的函数。
  2. 若热点是业务计算,检查死循环、重复计算和算法复杂度。
  3. 若热点是锁或自旋函数,检查锁竞争和忙等。
  4. 若热点是系统调用,检查频繁 I/O、网络调用或上下文切换。
  5. 没有符号时安装调试符号,或使用 -g 编译;为了获得更可靠的调用栈,可使用 -fno-omit-frame-pointer

十三、Linux 网络流量排查

nethogs 主要按进程统计实时流量:

sudo nethogs
sudo nethogs eth0

如果问题是“哪个远端 IP 或连接流量最大”,使用 iftop

sudo iftop -nP -i eth0

查看套接字、端口和所属进程:

ss -tunap

常见工具的定位如下:

  • nethogs:按进程查看流量;
  • iftop:按连接、IP 和端口查看流量;
  • ss:查看 TCP/UDP 连接状态;
  • sar -n DEV 1:查看网卡整体吞吐量;
  • tcpdump:抓包进行协议级分析。

因此,面试中不要说“nethogs 按 IP 统计流量”;更准确的说法是“nethogs 按进程统计,iftop 按连接/IP 统计”。

十四、Git:本地脏工作区同步远端代码

推荐先确认工作区,再暂存本地修改:

git status
git stash push -u -m "before pulling remote changes"
git pull --rebase
git stash pop
git status

-u 会同时保存未跟踪文件;如果只想保存已跟踪文件,可以省略它。

发生冲突时

冲突文件会包含类似标记:

<<<<<<< Updated upstream
远端内容
=======
本地 stash 内容
>>>>>>> Stashed changes

手工选择并整理内容后:

git add <冲突文件>

如果冲突发生在 git pull --rebase 阶段,继续变基:

git rebase --continue

如果冲突发生在 git stash pop 阶段,解决并 git add 后,把恢复后的修改纳入后续提交。stash pop 发生冲突时,Git 通常不会删除原 stash,可以用下面命令确认:

git stash list

更稳妥的恢复方式是先使用 git stash apply,确认无误后再执行 git stash drop。完整流程的核心顺序是:

git stash → 同步远端代码 → git stash pop/apply → 解决冲突 → 检查并提交。

十五、回溯算法:组合总和、剪枝与去重

以“每个候选数字可以重复使用”为例:

#include <algorithm>
#include <vector>

class Solution {
public:
    std::vector<std::vector<int>> combinationSum(
        std::vector<int>& candidates,
        int target) {
        std::sort(candidates.begin(), candidates.end());
        dfs(candidates, target, 0);
        return result;
    }

private:
    std::vector<std::vector<int>> result;
    std::vector<int> path;

    void dfs(const std::vector<int>& candidates,
             int remaining,
             int start) {
        if (remaining == 0) {
            result.push_back(path);
            return;
        }

        for (int i = start; i < static_cast<int>(candidates.size()); ++i) {
            // 同一树层跳过重复值,避免得到重复组合
            if (i > start && candidates[i] == candidates[i - 1]) {
                continue;
            }

            // 已排序,后面的数也不可能满足条件
            if (candidates[i] > remaining) {
                break;
            }

            path.push_back(candidates[i]);
            // 传 i:当前数字下一层仍然可以继续使用
            dfs(candidates, remaining - candidates[i], i);
            path.pop_back();
        }
    }
};

1. start 如何避免排列重复

假设目标是 8,组合 [2, 3, 3] 只应该出现一次。选择 2 后,下一层仍然从 2 及其后面选择;一旦选择了 3,就不能回头选择 2。这样不会生成 [3, 2, 3][3, 3, 2]

因此:

  • start 限制搜索方向,去掉同一组合的不同排列;
  • i > start && candidates[i] == candidates[i - 1] 跳过同一树层的重复候选值;
  • path.pop_back() 撤销当前选择,恢复现场。

2. 剪枝为什么成立

先排序后,如果 candidates[i] > remaining,那么 i 后面的候选值只会更大,继续搜索不可能得到合法解,可以直接 break

3. 每个数字只能使用一次

如果题目要求每个元素最多使用一次,下一层不能再次使用当前下标,应改为:

dfs(candidates, remaining - candidates[i], i + 1);

这正是组合总和 I 和组合总和 II 中最容易混淆的区别:允许重复时传 i,不允许重复时传 i + 1

十六、const 对象与 this 指针

普通成员函数:

class User {
public:
    void setId(int value);
};

可以近似理解为:

void setId(User* const this, int value);

this 指针本身不能改为指向另一个对象,但可以通过它修改当前对象。

const 成员函数:

class User {
public:
    int id() const;
};

可以近似理解为:

int id(const User* const this);

所以:

const User user;
user.id();       // 正确
user.setId(1);   // 编译错误

本质是 const User 对象只能把自身作为 const User* 传给成员函数,而普通成员函数需要 User*,这会丢失 const 限定,编译器会拒绝。

const 成员函数不能修改普通成员,但可以修改 mutable 成员:

class Cache {
public:
    int value() const {
        ++accessCount;
        return data;
    }

private:
    int data = 0;
    mutable int accessCount = 0;
};

面试回答:

const 成员函数的隐式 this 指针可近似看作 const T* const,因此不能通过它修改普通数据成员。const 对象不能调用普通成员函数,本质是因为 const T* 不能转换成要求修改权限的 T*

十七、最终复习清单

signal             操作系统异步事件通知
semaphore          并发同步和资源计数
condition_variable 配合 mutex 保护条件,并用谓词防止虚假唤醒
top -H             展开查看进程内线程
nethogs            按进程统计网络流量
iftop              按连接/IP/端口观察流量
perf               采样 CPU 热点和调用栈
git stash          保存本地修改
git pull           同步远端代码
git stash pop      恢复本地修改并处理冲突
回溯 start         不回头遍历,避免排列重复
回溯剪枝           排序后超过 remaining 直接 break
const this         const 成员函数中的类型近似为 const T* const

评论

评论由 GitHub Discussions 托管。