第二章 表达式、函数、数组与字符串
第二章 表达式、函数、数组与字符串
1. 前置 ++i 和后置 i++ 有什么区别?
问题分析
这道题不只是让你罗列名词,而是考查能否从求值顺序、参数语义与对象生命周期做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:前置自增先修改对象,再产生修改后的左值;
- 再讲机制:围绕“语义差异”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:字节客户端一面
问题讲解
一、语义差异
前置自增先修改对象,再产生修改后的左值;后置自增先保存旧值,再修改对象,表达式结果是修改前的值。对于内建整数,优化器通常能消除不使用结果时的差别;对于用户自定义迭代器,后置形式通常需要构造旧值副本。
不需要旧值时,泛型代码习惯使用 ++it,因为它对复杂迭代器不会要求无意义的旧值副本。不能据此断言整数 i++ 在发布构建中一定更慢。
#include <iostream>
int main() {
int i = 5;
int old = i++;
int now = ++i;
std::cout << old << ' ' << now << ' ' << i << '\n';
}

左右分别从i=5开始,是两个独立示例。
二、常见错误
- 把“先使用后增加”误解为语句执行完后不增加。
- 在同一表达式中多次修改同一变量,制造难读甚至未定义的代码。
- 断言后置自增在所有类型上都一定更慢。
- 重载后置
operator++时忘记用于区分形式的int哑参数。
回答自检
- 两种自增的执行顺序和结果值。
- 内建整数与用户类型的性能差异。
- 运算符重载如何区分前置和后置形式。
- 为什么不应在复杂表达式中堆叠副作用。
面试官可能追问的问题
- 后置自增为何返回值而不是引用? 它要表示修改前的独立值。
- 前置自增重载通常返回什么? 返回
T&,允许链式操作并避免复制。 - 为何迭代器推荐前置? 不需要旧值时可避免潜在副本。
2. 函数参数按值、指针和引用传递应该如何选择?
问题分析
这道题不只是让你罗列名词,而是考查能否从求值顺序、参数语义与对象生命周期做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:按值传递会创建独立参数,适合小型可复制类型,或函数本来就需要保存一份副本;
- 再讲机制:围绕“选择原则”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:百度日常实习一面
问题讲解
一、选择原则
按值传递会创建独立参数,适合小型可复制类型,或函数本来就需要保存一份副本;const T& 适合只读访问大型对象;T& 表示参数必须存在且函数会修改它;T* 常表示可为空或需要 C 接口语义。智能指针只在函数参与所有权时出现。
| 形式 | 可为空 | 能否修改调用者对象 | 是否表达所有权 |
|---|---|---|---|
T | 否 | 否 | 获得独立值 |
const T& | 否 | 否 | 借用 |
T& | 否 | 是 | 借用 |
const T* | 是 | 否 | 通常借用 |
T* | 是 | 是 | 通常借用 |
unique_ptr<T> | 可为空 | 是 | 转移所有权 |
shared_ptr<T> | 可为空 | 是 | 共享所有权 |
现代 C++ 还常使用“按值接收再移动到成员”的 sink 参数,调用者传右值时移动、传左值时复制,接口简单但要考虑额外移动成本。
二、常见错误
- 为避免拷贝而把所有参数都改成指针。
- 用
shared_ptr传递普通借用,模糊所有权并增加计数开销。 - 返回或保存对局部变量的引用。
- 认为引用在 ABI 层一定不占空间;语言只规定语义。
回答自检
- 参数形式如何表达可空和可修改。
- 借用与所有权的区别。
const T&和按值传递的选择依据。- 为什么不能单纯按“是否拷贝”设计接口。
面试官可能追问的问题
- 指针传递是否也是值传递? 是,函数得到的是地址值的副本。
- 小对象为何常按值传递? 一两个机器字的复制可能比间接访问更便宜且语义清楚。
- 输出参数用指针还是引用? 必须有对象时用引用,可选输出时用指针;更推荐直接返回结果对象。
3. 什么是短路求值?&& 和 & 能否互换?
问题分析
这道题考查是否建立了求值顺序、参数语义与对象生命周期的清晰概念边界,并能把类别、职责和限制对应起来。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:内建逻辑与
&&从左到右求值:左侧为假时结果已经确定,右侧不再执行。 - 再讲机制:围绕“短路规则 → 边界”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:腾讯 WXG 客户端一面
问题讲解
一、短路规则
内建逻辑与 && 从左到右求值:左侧为假时结果已经确定,右侧不再执行。逻辑或 || 左侧为真时同理。位与 & 对整数逐位运算,也可对布尔值运算,但不会提供短路语义,因此不能随意替换。
典型安全写法是 ptr != nullptr && ptr->ready():只有指针非空才解引用。右侧若包含必要业务副作用,则不应依赖它“顺便执行”,应拆成清晰语句。
二、边界
重载的 operator&& 和 operator|| 是函数调用,不再具有内建运算符相同的短路规则。位运算通常用于标志位和掩码,而逻辑运算产生布尔语义。
三、常见错误
- 把
&当成“不短路版本的 &&”而忽略整数位运算。 - 在右操作数隐藏必须执行的副作用。
- 认为重载逻辑运算符仍会短路。
- 写出
a < b < c,误以为等价于两个范围比较。
回答自检
&&与||的短路条件。&的位运算语义。- 短路如何保护指针访问。
- 运算符重载为何改变短路特性。
面试官可能追问的问题
||如何短路? 左侧为真时不求值右侧。- 短路能否替代线程同步? 不能,它只规定当前表达式求值。
- 位与常见用途? 掩码检测,例如
(flags & Readable) != 0。
4. 函数重载需要满足什么条件?返回值能否区分重载?
问题分析
这道题考查能否用求值顺序、参数语义与对象生命周期解释题目中的语义,而不是停留在定义记忆。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:同一作用域中的函数可以同名,但参数数量、参数类型、参数顺序或成员函数的 cv/ref 限定必须形成不同签名。
- 再讲机制:围绕“重载条件”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:美团 C++ 后端一面
问题讲解
一、重载条件
同一作用域中的函数可以同名,但参数数量、参数类型、参数顺序或成员函数的 cv/ref 限定必须形成不同签名。返回类型单独不同不能构成重载,因为调用 f(1) 时编译器通常不能仅从接收结果的上下文唯一决定调用哪个函数。
默认参数不属于函数类型,它只在调用处补足实参。一个无参重载与一个具有全默认参数的重载可能让 f() 产生二义性。普通函数、函数模板和转换构造同时参与时,重载决议会更加复杂。
#include <iostream>
#include <string_view>
void print(int value) { std::cout << "int " << value << '\n'; }
void print(double value) { std::cout << "double " << value << '\n'; }
void print(std::string_view value) { std::cout << "text " << value << '\n'; }
int main() {
print(7);
print(3.5);
print(std::string_view{"hello"});
}
二、常见错误
- 只改变返回类型就声明两个同名函数。
- 让默认参数造成调用二义性。
- 忽略整数提升和用户定义转换也会参与匹配。
- 派生类声明同名函数后,以为基类所有重载仍自动可见。
回答自检
- 哪些参数差异可形成重载。
- 为什么返回值不能单独区分重载。
- 默认参数如何造成二义性。
- 重载决议的候选、可行和最佳匹配阶段。
面试官可能追问的问题
- 成员函数能按
const重载吗? 可以,f()与f() const可分别服务非 const 和 const 对象。 - 如何重新引入基类重载? 在派生类中写
using Base::name;。 - 函数模板和普通函数谁优先? 当匹配同样好时通常优先普通函数,但必须结合完整重载规则。
5. 宏、inline 函数和 constexpr 函数如何选择?
问题分析
这道题不只是让你罗列名词,而是考查能否从求值顺序、参数语义与对象生命周期做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:函数式宏在预处理阶段做文本替换,不理解类型、作用域和求值次数;
- 再讲机制:围绕“三种机制 → 边界”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:百度搜索架构一面
问题讲解
一、三种机制
函数式宏在预处理阶段做文本替换,不理解类型、作用域和求值次数;inline 函数是正常 C++ 函数,具备类型检查,其重要语言含义是允许跨翻译单元出现满足 ODR 的等价定义;constexpr 函数可以在满足条件时用于常量表达式,并不保证每次调用都在编译期执行。
| 机制 | 处理阶段 | 类型检查 | 主要用途 |
|---|---|---|---|
| 宏 | 预处理 | 否 | 条件编译、平台开关、少量代码生成 |
inline | 编译 | 是 | 头文件定义和 ODR;内联与否由优化器决定 |
constexpr | 编译/运行 | 是 | 同时支持常量求值和普通调用 |

二、边界
宏参数可能被重复求值,MAX(i++, j++) 会产生难以控制的副作用。常量、泛型和小函数分别优先使用 constexpr 变量、模板和普通函数。inline 不是性能承诺,大函数、递归或取地址都不妨碍其 ODR 含义。
一个没有未定义行为的例子:若宏写为 #define SQUARE(x) x * x,SQUARE(1 + 2) 展开为 1 + 2 * 1 + 2,按运算优先级得到 5;普通函数 int square(int value) { return value * value; } 接收先算好的 3,结果为 9。这个差别来自文本替换,不是函数计算出错;即使给宏补足括号,也仍要考虑实参重复求值。
三、常见错误
- 宏参数缺少括号,改变运算优先级。
- 把
inline写在声明上就认为汇编一定没有调用。 - 认为
constexpr函数只能接收编译期参数。 - 用宏定义常量,丢失类型和作用域。
回答自检
- 宏和函数处于不同处理阶段。
inline的 ODR 含义。constexpr的双重求值能力。- 何时仍需要预处理宏。
面试官可能追问的问题
- 类内定义的成员函数是否隐含 inline? 通常是,模块附着规则另有细节。
- C++17 的 inline 变量解决什么? 允许变量在头文件中出现等价定义并表示同一实体。
- 宏还有合理用途吗? 有,例如 include guard、条件编译和编译器特性封装。
6. std::array、原生数组和 std::vector 如何选择?
问题分析
这道题不只是让你罗列名词,而是考查能否从求值顺序、参数语义与对象生命周期做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:原生数组
T[N]大小是类型的一部分,适合底层 ABI、嵌入式缓冲区和与 C 接口交互; - 再讲机制:围绕“三种连续存储”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:百度搜索架构一面
问题讲解
一、三种连续存储
原生数组 T[N] 大小是类型的一部分,适合底层 ABI、嵌入式缓冲区和与 C 接口交互;std::array<T,N> 同样固定长度且内嵌存储,但提供 size()、迭代器和标准容器接口;std::vector<T> 在运行期管理连续动态存储,能够扩容。
| 特性 | 原生数组 | std::array | std::vector |
|---|---|---|---|
| 长度 | 编译期固定 | 编译期固定 | 运行期变化 |
| 整体连续 | 是 | 是 | 是 |
| 保存长度 | 类型中有 N | 提供 size() | 运行期成员 |
| 可赋值 | 数组整体不可赋值 | 可以 | 可以 |
| 扩容 | 不可 | 不可 | 可以 |

二、常见错误
- 把
reserve当成创建元素。 - 将数组参数写成
T a[]后以为函数仍知道长度。 - 认为
std::array在堆上分配。 - 在 vector 扩容后继续使用旧元素地址。
回答自检
- 固定长度和动态长度的选择。
std::array相比原生数组的接口优势。- vector 的连续性和扩容代价。
- 数组作为函数参数为何丢失长度。
面试官可能追问的问题
std::array<int,0>是否有效? 有效,但不能解引用begin(),其data()值也不应用于访问元素。- 何时用
std::span? C++20 中表达一段连续非拥有视图,可接收多种连续存储。 - vector 是否保证连续? 对非
vector<bool>元素,标准保证连续存储。
7. 数组名、首元素指针和数组指针有什么区别?
问题分析
这道题不只是让你罗列名词,而是考查能否从求值顺序、参数语义与对象生命周期做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:设
int a[3]。 - 再讲机制:围绕“数组衰减 → 边界”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:腾讯后台一面
问题讲解
一、数组衰减
设 int a[3]。在多数表达式中,a 会转换为指向首元素的 int*;但作为 sizeof、取地址和部分类型推导操作数时不会衰减。&a 的类型是 int (*)[3],即指向整个数组的指针。
#include <iostream>
#include <type_traits>
int main() {
int values[3]{10, 20, 30};
int* first = values;
int (*whole)[3] = &values;
static_assert(std::is_same_v<decltype(whole), int (*)[3]>);
std::cout << *(first + 1) << ' ' << (*whole)[2] << ' '
<< sizeof(values) << '\n';
}

图中假设sizeof(int)=4;尾后位置只能表示边界,不能解引用。
二、边界
函数参数中的 void f(int a[3]) 会被调整为 void f(int*),数字 3 不提供运行期长度保证。想保留长度可使用数组引用模板、std::array,或 C++20 std::span。
三、常见错误
- 认为数组名在所有场景都是指针。
- 用
sizeof计算函数数组参数的元素数量。 - 混淆“指针数组”与“数组指针”。
- 让指针算术越过数组允许范围并解引用。
回答自检
- 数组衰减发生和不发生的场景。
a与&a的类型及步长。- 数组参数为何不保留长度。
- 指针数组与数组指针的区别。
面试官可能追问的问题
- 什么是指针数组? 元素是指针的数组,例如
int* p[3]。 - 为什么
&a地址数值常与a相同? 起始位置相同,但类型和指针步长不同。 - 数组能否整体赋值? 原生数组不能,
std::array可以。
8. C 风格字符串和 std::string 有什么区别?
问题分析
这道题不只是让你罗列名词,而是考查能否从求值顺序、参数语义与对象生命周期做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:
sizeof是类型或对象大小运算,数组在没有退化为指针的表达式中会保留完整数组大小; - 再讲机制:围绕“
sizeof与strlen不在同一个层次 → 表示方式”说明规则如何生效,把关键对象、时机或状态变化串起来。 - 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:字节游戏客户端一面
问题讲解
补充:sizeof 与 strlen 不在同一个层次
sizeof 是类型或对象大小运算,数组在没有退化为指针的表达式中会保留完整数组大小;strlen 是运行期函数,只沿着字符序列查找第一个 \0。因此二者不能互换,strlen 也不能用于一般二进制缓冲区。
#include <cstring>
#include <iostream>
int main() {
char text[] = "cpp";
const char* pointer = text;
std::cout << sizeof(text) << ' ' << sizeof(pointer) << ' '
<< std::strlen(text) << '\n';
}
sizeof(text) 包含结尾的空字符,sizeof(pointer) 只得到指针本身的大小。数组传入函数参数后通常退化为指针,此时不能再靠 sizeof 推回元素个数。
一、表示方式
C 字符串是一段以空字符 \0 结束的字符序列,接口通常只接收首地址,因此必须依赖终止符查找长度。std::string 是拥有型容器,保存长度并管理存储,可以包含嵌入的空字符;C++17 保证 data() 指向连续存储并在 data()[size()] 处有终止空字符。
std::string::size() 是常数时间;strlen 通常为线性扫描。需要调用旧 C API 时可使用 c_str(),但修改字符串后原指针可能失效。
#include <cstring>
#include <iostream>
#include <string>
int main() {
const char* c_text = "hello";
std::string text{"he\0llo", 6};
std::cout << std::strlen(c_text) << ' ' << text.size() << '\n';
}

二、常见错误
- 对没有终止符的缓冲区调用
strlen。 - 认为
std::string不能包含\0。 - 保存
c_str()指针后继续修改字符串。 - 用
strcpy写入容量不足的数组。
回答自检
- C 字符串的终止符协议。
- string 的长度、容量和所有权。
- 与 C API 互操作的正确方式。
- 嵌入空字符对两类接口的影响。
面试官可能追问的问题
size()与length()区别? 对std::string语义相同。data()能否修改? C++17 起非 const string 的非 constdata()返回可修改字符指针,但不能破坏对象不变式或写越界。- 二进制数据能否放 string? 可以,但
vector<std::byte>往往更清楚地表达字节语义。
9. std::string_view 解决什么问题?有哪些生命周期风险?
问题分析
这道题考查是否建立了求值顺序、参数语义与对象生命周期的清晰概念边界,并能把类别、职责和限制对应起来。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:只读解析函数若接收
const std::string&,字符串字面量或字符数组可能先构造临时 string。 - 再讲机制:围绕“为什么需要视图? → 生命周期与边界”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:腾讯客户端二面
问题讲解
一、为什么需要视图?
只读解析函数若接收 const std::string&,字符串字面量或字符数组可能先构造临时 string。std::string_view 通常只保存指针和长度,可统一观察 string、字面量和一段字符区间,不拥有也不复制字符。
#include <iostream>
#include <string>
#include <string_view>
void print_prefix(std::string_view text) {
const auto count = text.size() < 3 ? text.size() : 3;
std::cout << text.substr(0, count) << '\n';
}
int main() {
std::string owned = "abcdef";
print_prefix(owned);
print_prefix("literal");
}

二、生命周期与边界
视图不延长底层存储生命周期,不能返回局部 string 的视图,也不应保存指向临时 string 的视图。底层 string 扩容或修改使相关位置失效时,视图同样失效。data() 不保证视图末尾就是 \0,不能无条件传给只接收 C 字符串的函数。
三、常见错误
std::string_view v = std::string("temp");后继续使用。- 把
v.data()当成必然以空字符终止。 - 在容器中长期保存指向短生命周期输入的视图。
- 需要拥有数据时仍使用视图。
回答自检
- string_view 的指针加长度模型。
- 非拥有视图为何能避免复制。
- 临时对象、扩容和子串带来的悬空风险。
- 为什么
data()不等于安全 C 字符串。
面试官可能追问的问题
- 参数何时用 string_view? 函数只在调用期间读取字符且不保存时。
- 返回 string_view 是否安全? 只有底层存储寿命明确长于返回视图时。
- string_view 能否表示子串? 可以,通过调整指针和长度,无需复制。
10. struct、class 和 union 有什么区别?
问题分析
这道题不只是让你罗列名词,而是考查能否从求值顺序、参数语义与对象生命周期做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:在 C++ 中,
struct和class都能拥有构造函数、成员函数、模板、继承和虚函数。 - 再讲机制:围绕“struct 与 class → 边界”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合求值顺序、参数语义与对象生命周期说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:美团客户端一面
问题讲解
一、struct 与 class
在 C++ 中,struct 和 class 都能拥有构造函数、成员函数、模板、继承和虚函数。语言差别只有默认成员访问和默认继承方式:struct 默认 public,class 默认 private。工程习惯常用 struct 表达简单值对象,用 class 表达需要维护不变式的封装对象,但这不是语言限制。union 的所有非静态数据成员共享同一块存储,同一时刻通常只有一个活动成员。它适合受控的低层表示,但手动管理非平凡成员生命周期容易出错,封闭类型集合通常优先使用 std::variant。

二、边界
从 union 中读取非活动成员通常不是通用的安全类型双关方法。需要检查对象表示时使用 memcpy,C++20 可考虑 std::bit_cast。union 大小至少能容纳最大成员,并满足所有成员所需的对齐。
三、常见错误
- 认为 struct 不能有成员函数或 private 成员。
- 认为 class 对象一定比 struct 大。
- 写入一个 union 成员后随意读取另一个成员。
- 用 union 替代需要明确访问检查的业务变体。
回答自检
- struct 与 class 的两个默认差异。
- 二者为何具有相同的类能力。
- union 的共享存储和活动成员概念。
- union 与 variant 的适用边界。
面试官可能追问的问题
- struct 与 class 能否互相继承? 可以,默认继承权限由派生声明使用的关键字决定。
- union 能否含 string? 可以,但需要正确管理活动成员的构造和析构。
- 为何推荐 variant? 它记录活动替代类型并提供受检查的访问接口。




