返回文章索引

C++ 软牛科技一面复盘:从类型转换到链接模型

整理软牛科技 C++ 一面的 11 个问题,覆盖有符号比较、隐式转换、多态、链接方式以及指针和引用的底层语义。

GitHub 原文

这次一面的问题集中在 C++ 基础语义、面向对象、编译链接和底层表示。题目本身不偏,但回答时不能只背结论,还要说明规则为什么成立、哪些边界会出错。

1. 自我介绍

自我介绍建议控制在一到两分钟,并围绕岗位需要组织,而不是按简历时间线逐项朗读。

一个可复用的回答结构是:

  1. 我是谁:专业、方向和当前阶段。
  2. 我做过什么:选择一到两个最能体现 C++ 能力的项目。
  3. 我解决过什么问题:性能、并发、网络、内存或工程化方面的具体难点。
  4. 我为什么适合:把已有经验与岗位要求连接起来。

示例:

我主要使用 C++ 做系统与网络方向的开发,熟悉现代 C++、Linux、网络编程以及常见的构建工具。最近完成了一个基于 Drogon 的 Git 驱动博客,负责服务端渲染、Markdown 安全处理、Docker 部署和 GitHub 自动发布。在这个过程中,我重点处理了内容边界、资源缓存和容器权限问题。我希望继续参与对性能、稳定性和工程质量要求较高的 C++ 项目。

项目细节必须能够继续展开。凡是自我介绍中主动提到的技术,都要准备好回答设计选择、故障排查和取舍。

2. JSON 库中 uintint 比较的问题

当有符号整数和无符号整数参与比较时,会发生通常算术转换。若无符号类型的转换等级不低于有符号类型,有符号值会被转换成无符号值。

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_lessstd::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 是否兼容。

因此可以给出一个直接结论:源文件成功编译成目标文件后,无论采用静态链接还是动态链接,后续链接过程都不再需要原来的头文件;但任何需要重新编译的源码仍然需要对应的公开头文件。

排查顺序:

  1. 确认依赖是否完整安装或随源码检出,例如 Git submodule 是否初始化。
  2. 确认构建系统是否正确添加头文件搜索路径,如 CMake 的 target_include_directories
  3. 检查头文件名大小写、相对路径和平台差异,Linux 文件系统通常区分大小写。
  4. 检查依赖版本,当前源文件可能引用了另一版本才存在的头文件。
  5. 如果依赖是可选功能,使用构建选项或条件编译关闭该功能。

不能为了让编译通过就随意写一个同名空头文件。只有在明确知道所需声明和 ABI,并准备实现兼容层时,才可以编写最小替代头文件。

需要进一步区分:

  • 头文件缺失或声明不可见:编译阶段错误。
  • 头文件存在,但函数实现对应的库没有参与链接:链接阶段出现 undefined reference 或 unresolved external symbol。
  • 库已经链接,但运行时找不到动态库:程序装载阶段错误。

10. 指针和引用的区别

比较项 指针 引用
是否可以为空 可以为 nullptr 合法引用必须绑定有效对象
是否可以重新指向 可以 初始化后不能改绑
是否需要显式解引用 使用 *-> 语法上像对象别名
是否支持算术运算 支持指针算术 不支持
是否必须初始化 可以暂不初始化,但危险 必须初始化
sizeof 的含义 指针本身的大小 被引用对象类型的大小

选择原则:

  • 参数必须存在且函数不接管所有权时,优先使用引用。
  • 参数允许缺省时,使用指针、智能指针或 std::optional<std::reference_wrapper<T>> 表达可空语义。
  • 需要表达所有权时,使用 std::unique_ptrstd::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 托管。