这次一面的问题集中在 C++ 基础语义、面向对象、编译链接和底层表示。题目本身不偏,但回答时不能只背结论,还要说明规则为什么成立、哪些边界会出错。
1. 自我介绍
自我介绍建议控制在一到两分钟,并围绕岗位需要组织,而不是按简历时间线逐项朗读。
一个可复用的回答结构是:
- 我是谁:专业、方向和当前阶段。
- 我做过什么:选择一到两个最能体现 C++ 能力的项目。
- 我解决过什么问题:性能、并发、网络、内存或工程化方面的具体难点。
- 我为什么适合:把已有经验与岗位要求连接起来。
示例:
我主要使用 C++ 做系统与网络方向的开发,熟悉现代 C++、Linux、网络编程以及常见的构建工具。最近完成了一个基于 Drogon 的 Git 驱动博客,负责服务端渲染、Markdown 安全处理、Docker 部署和 GitHub 自动发布。在这个过程中,我重点处理了内容边界、资源缓存和容器权限问题。我希望继续参与对性能、稳定性和工程质量要求较高的 C++ 项目。
项目细节必须能够继续展开。凡是自我介绍中主动提到的技术,都要准备好回答设计选择、故障排查和取舍。
2. JSON 库中 uint 与 int 比较的问题
当有符号整数和无符号整数参与比较时,会发生通常算术转换。若无符号类型的转换等级不低于有符号类型,有符号值会被转换成无符号值。
int value = -1;
unsigned int size = 1;
bool result = value < size; // 通常为 false
-1 转成 unsigned int 后会成为一个很大的正数,所以结果与直觉相反。JSON 库中常见的触发方式包括:
int index = -1;
Json::ArrayIndex count = array.size(); // 通常是无符号类型
if (index < count)
{
// index 为 -1 时仍可能进不了预期分支
}
更安全的写法是先验证有符号值的范围,再进行显式转换:
if (index >= 0 && static_cast<Json::ArrayIndex>(index) < array.size())
{
// index 已确认非负
}
如果使用 C++20,也可以使用 std::cmp_less、std::cmp_equal 等安全比较函数:
#include <utility>
if (index >= 0 && std::cmp_less(index, array.size()))
{
}
std::cmp_less 能避免混合符号比较造成的意外转换,但不会替业务判断排除负下标,因此这里仍需先检查 index >= 0。
工程上还应开启编译器警告,例如 GCC/Clang 的 -Wsign-compare,不要依赖隐式转换碰巧得到正确结果。
3. 函数调用中的隐式转换
函数调用时,实参需要转换成形参类型。标准转换包括整数提升、数值类型转换、数组退化为指针、派生类指针转基类指针等。
void print(double value);
print(42); // int 隐式转换为 double
类类型还可能发生用户自定义转换:
class Timeout
{
public:
Timeout(int seconds) : seconds_(seconds) {}
private:
int seconds_;
};
void request(Timeout timeout);
request(5); // int -> Timeout
这种转换有时方便,但也可能让错误代码悄悄通过编译。单参数构造函数通常应根据语义考虑加上 explicit:
explicit Timeout(int seconds);
转换运算符同样可能参与隐式转换:
class Result
{
public:
explicit operator bool() const noexcept;
};
回答这类问题时还应提到重载决议:编译器会比较候选函数所需的转换序列,通常优先精确匹配,其次是提升、标准转换,最后才是用户自定义转换。若多个候选同样好,调用会产生二义性。
4. C++ 面向对象的三个特性
通常指封装、继承和多态。
- 封装:把数据和行为放在同一抽象中,通过访问控制维持对象不变量。
- 继承:在满足“is-a”关系时复用或扩展基类接口,但工程中应优先考虑组合是否更合适。
- 多态:同一个接口可以表现出不同实现。C++ 既有虚函数实现的运行时多态,也有模板和重载实现的编译期多态。
一个完整回答不应停留在定义。需要指出继承不是单纯的代码复用工具,多态也不只有虚函数。
5. 重载和重写的区别
重载发生在同一作用域,函数名相同但参数列表不同,由编译器在编译期选择。
void log(int value);
void log(std::string_view value);
重写发生在继承体系中,派生类为基类虚函数提供新的实现,通过基类指针或引用在运行时分派。
class Base
{
public:
virtual void run() const;
};
class Derived : public Base
{
public:
void run() const override;
};
主要区别:
| 比较项 | 重载 | 重写 |
|---|---|---|
| 发生位置 | 通常在同一作用域 | 基类与派生类之间 |
| 参数列表 | 必须不同 | 必须与基类虚函数匹配 |
| 决议时机 | 编译期 | 通常为运行期 |
| 是否要求虚函数 | 不要求 | 要求基类函数为虚函数 |
| 返回类型 | 不能仅靠返回类型区分 | 可使用满足规则的协变返回类型 |
派生类声明同名函数还可能隐藏基类的全部同名重载,可以通过 using Base::run; 把基类重载引入派生类作用域。实际编码应始终使用 override 让编译器检查签名。
6. 构造函数能否为虚函数
不能。虚函数分派需要对象已经具有确定的动态类型,并通过虚表指针找到对应实现;而构造函数的职责正是建立对象,包括初始化虚表指针和各个子对象。
构造期间即使调用虚函数,也只会分派到当前正在构造的类,不会调用尚未构造完成的派生类实现。
如果需要“根据运行时条件构造不同派生对象”,应使用工厂函数:
std::unique_ptr<Base> createObject(ObjectType type);
如果需要多态复制,可以提供虚拟 clone():
virtual std::unique_ptr<Base> clone() const = 0;
7. 析构函数能否为虚函数
可以,而且作为多态基类使用时通常必须是虚析构函数。
class Base
{
public:
virtual ~Base() = default;
};
当通过基类指针删除派生对象时,虚析构保证先调用派生类析构函数,再调用基类析构函数:
Base *object = new Derived;
delete object;
若基类析构函数不是虚函数,上述删除行为是未定义行为,可能造成资源泄漏或对象只被部分销毁。
纯虚析构函数也可以存在,但仍必须提供定义,因为派生对象销毁时最终一定会调用基类析构函数:
Base::~Base() = default;
如果一个类明确不允许通过基类指针删除,也可以使用受保护的非虚析构函数表达这个限制。
8. 静态编译和动态编译(准确地说是静态链接与动态链接)
更准确的说法是静态链接与动态链接,而不是静态编译与动态编译。
静态链接会在链接阶段把所需目标代码复制进最终可执行文件,常见产物是 Linux 的 .a 或 Windows 的 .lib。
优点:部署简单、运行时对外部库依赖少、库版本更可控。缺点:可执行文件更大,多个进程难以共享同一份库代码,更新库后通常需要重新链接程序。
动态链接保留对共享库的依赖,程序装载或运行时由动态链接器解析,常见产物是 Linux 的 .so 或 Windows 的 .dll。
优点:多个程序可以共享库代码,库可以独立升级,程序体积通常更小。缺点:部署时必须保证 ABI、搜索路径和库版本兼容,容易出现“本机能运行、目标机器缺库”的问题。
还需要区分加载时链接与运行时显式加载,例如 Linux 的 dlopen 或 Windows 的 LoadLibrary。
9. 编译时发现依赖的头文件缺失怎么办
头文件在预处理阶段就必须能够找到。缺失头文件时,源文件无法正确编译,这不是链接参数能够解决的问题。
不过,如果问题描述的是“文件已经编译完成,之后进行静态或动态链接时没有原来的头文件”,那么通常不会受到影响。编译、链接和运行是三个不同阶段:
.cpp + 头文件 -> 编译 -> .o/.obj -> 链接 -> 可执行文件
例如源码使用了标准库头文件:
#include <vector>
std::vector<int> values;
编译这段源码时,编译器必须从 C++ 工具链的标准库目录找到 <vector>。成功生成 .o 或 .obj 后,头文件中的声明、模板和内联代码已经参与编译,后面的链接器不会再次读取 <vector>,也不需要项目目录中存在一个 vector.cpp 文件。
静态链接时,链接器需要目标文件以及 .a 或 .lib 静态库,不需要编译时使用的头文件。链接完成后,程序运行时通常也不再需要原静态库:
.o/.obj + .a/.lib -> 静态链接 -> 可执行文件
动态链接阶段同样不读取头文件,而是解析共享库提供的符号。程序运行时还必须能够找到对应的 .so 或 .dll:
.o/.obj + .so/.dll 的符号信息 -> 动态链接 -> 可执行文件
-> 运行时加载共享库
需要注意的是,如果拿到静态库或动态库后还要编写新的调用方源码,那么编译调用方时通常仍需要这个库的公开头文件。例如:
#include "service.h"
int main()
{
return service_start();
}
这里 service.h 用于编译 main.cpp,.a/.lib 或 .so/.dll 用于提供函数实现。头文件负责声明,库负责实现,两者不能互相替代。如果库只在内部使用 std::vector,且公开接口不暴露它,调用方不需要库的内部头文件;如果公开接口出现 std::vector,调用方编译时仍然需要 <vector>,并且要注意编译器、标准库和 ABI 是否兼容。
因此可以给出一个直接结论:源文件成功编译成目标文件后,无论采用静态链接还是动态链接,后续链接过程都不再需要原来的头文件;但任何需要重新编译的源码仍然需要对应的公开头文件。
排查顺序:
- 确认依赖是否完整安装或随源码检出,例如 Git submodule 是否初始化。
- 确认构建系统是否正确添加头文件搜索路径,如 CMake 的
target_include_directories。 - 检查头文件名大小写、相对路径和平台差异,Linux 文件系统通常区分大小写。
- 检查依赖版本,当前源文件可能引用了另一版本才存在的头文件。
- 如果依赖是可选功能,使用构建选项或条件编译关闭该功能。
不能为了让编译通过就随意写一个同名空头文件。只有在明确知道所需声明和 ABI,并准备实现兼容层时,才可以编写最小替代头文件。
需要进一步区分:
- 头文件缺失或声明不可见:编译阶段错误。
- 头文件存在,但函数实现对应的库没有参与链接:链接阶段出现 undefined reference 或 unresolved external symbol。
- 库已经链接,但运行时找不到动态库:程序装载阶段错误。
10. 指针和引用的区别
| 比较项 | 指针 | 引用 |
|---|---|---|
| 是否可以为空 | 可以为 nullptr |
合法引用必须绑定有效对象 |
| 是否可以重新指向 | 可以 | 初始化后不能改绑 |
| 是否需要显式解引用 | 使用 * 或 -> |
语法上像对象别名 |
| 是否支持算术运算 | 支持指针算术 | 不支持 |
| 是否必须初始化 | 可以暂不初始化,但危险 | 必须初始化 |
sizeof 的含义 |
指针本身的大小 | 被引用对象类型的大小 |
选择原则:
- 参数必须存在且函数不接管所有权时,优先使用引用。
- 参数允许缺省时,使用指针、智能指针或
std::optional<std::reference_wrapper<T>>表达可空语义。 - 需要表达所有权时,使用
std::unique_ptr、std::shared_ptr等所有权类型,不要只靠裸指针猜测。 - 连续内存范围优先使用
std::span,避免裸指针加长度分离传递。
引用并不是生命周期管理工具。被引用对象销毁后,引用同样会悬空。
11. 指针和引用在底层、传参与返回值上的差异
在很多 ABI 中,引用参数的机器级传递方式与指针相似:调用方把对象地址放进寄存器或栈槽,函数内部通过地址访问对象。但这是常见实现,不是 C++ 标准要求引用必须以指针形式存储。
void updateByPointer(int *value)
{
if (value != nullptr)
++*value;
}
void updateByReference(int &value)
{
++value;
}
两者可能生成近似汇编,但语义契约不同:指针版本允许空值并需要检查,引用版本要求调用方提供有效对象。
返回指针和返回引用都不会复制目标对象:
Widget *findWidget(); // 可以用 nullptr 表示没有找到
Widget &defaultWidget(); // 保证返回一个有效对象
二者都必须注意生命周期。下面的代码都会产生悬空结果:
Widget &badReference()
{
Widget local;
return local;
}
Widget *badPointer()
{
Widget local;
return &local;
}
返回引用适合返回生命周期由其他对象保证的成员、单例或容器元素。返回指针适合表达“可能不存在”的非拥有关系。返回新建对象的所有权时应返回智能指针;对象较小或能够移动时,直接按值返回通常更简单,编译器还可以应用返回值优化。
最后需要强调:底层实现相似不代表二者可以互换。优化器可能完全消除引用或指针的存储,真正稳定的是语言层面的可空性、改绑能力、生命周期和所有权语义。
总结
这组问题看似都是基础题,实际考察的是能否把语言规则、编译流程和工程实践连起来:
- 混合有符号和无符号类型前先理解通常算术转换。
- 对可能产生歧义的用户自定义转换使用
explicit。 - 区分编译期重载与运行期重写。
- 多态基类需要正确设计虚析构函数。
- 明确预处理、编译、链接和装载四个不同阶段。
- 使用指针和引用时,优先表达清楚可空性、生命周期与所有权。
面试回答的目标不是一次性说出所有细节,而是先给出准确结论,再用一个例子说明规则,最后补充最容易出错的边界。
评论
评论由 GitHub Discussions 托管。