22、必考 | C++18 个重点笔记
1. C++的 const 比 C 语言#define 更好的原因?
在C++中,const
关键字相比于 C 语言中的 #define
预处理器指令,提供了更为强大、安全且易于维护的常量定义方式。以下是 const
比 #define
更好的几个主要原因:
-
类型安全:
const
关键字可以指定常量的类型,这有助于编译器进行类型检查,从而减少因类型不匹配导致的错误。#define
只是简单的文本替换,不进行类型检查,容易导致类型错误。
-
作用域:
const
常量具有块作用域(局部变量)、文件作用域(静态局部变量或全局变量,使用static
修饰)或命名空间作用域,这有助于控制常量的可见性和生命周期。#define
指令定义的宏没有作用域的概念,一旦定义,在整个源文件中都有效,除非显式使用#undef
取消定义,这可能会导致意外的名字冲突。
-
调试和可读性:
- 使用
const
可以让代码更加清晰,易于理解,因为const
常量在调试器中可见,具有类型信息。 #define
宏在预处理阶段就被替换为文本,调试时看不到宏的名称,只能看到替换后的结果,这增加了调试的难度。
- 使用
-
存储方式:
const
常量可能被编译器优化,例如,如果编译器能够确定const
常量的值在编译时已知,它可能会将这个值直接嵌入到使用它的代码中,从而避免内存访问开销。#define
宏的替换结果完全取决于宏的定义和使用方式,编译器很难对其进行优化。
-
内联函数与模板参数:
const
常量可以用作内联函数和模板的参数,而#define
宏通常不用于这些目的,因为它们不遵循C++的类型系统和作用域规则。
-
内存占用:
- 对于简单的
const
变量,编译器可能会将其存储在只读数据段中,从而节省内存空间。 #define
宏仅仅是文本替换,不直接涉及内存分配,但如果宏定义的是大型结构体或数组,可能会导致代码膨胀。
- 对于简单的
综上所述,const
关键字在C++中提供了比 #define
更为安全、高效和易于维护的常量定义方式,是现代C++编程中推荐使用的常量定义方法。
2. 不能简单地将整数赋给指针
在C++中,不能简单地将整数(如int类型的值)赋给指针,这是因为整数和指针在内存中的表示方式和用途有着本质的区别。下面我将详细解释这一点,并给出一些相关的上下文和例子。
整数与指针的区别
-
内存表示:
- 整数:在内存中直接存储数值,例如
int
类型可能占用4个字节(这取决于编译器和平台),并直接表示一个整数值。 - 指针:在内存中存储的是一个地址,这个地址指向另一个内存位置,该位置可以是变量的存储位置、函数的地址等。指针的大小(即地址的大小)也取决于平台和编译器,通常是4字节(32位系统)或8字节(64位系统)。
- 整数:在内存中直接存储数值,例如
-
用途:
- 整数:用于算术运算、条件判断等,表示数量、索引等。
- 指针:用于间接访问内存中的数据或函数,允许程序动态地操作内存中的数据。
为什么不能将整数赋给指针
将整数直接赋给指针在C++中是类型不安全的,因为整数表示的是一个具体的数值,而不是内存地址。如果允许这样做,那么指针将会指向一个随机的内存位置,这可能导致未定义行为,包括但不限于访问违规、数据损坏或程序崩溃。
示例
int main() {
int number = 42;
int* ptr;
// 错误:尝试将整数直接赋给指针
// ptr = number; // 这会导致编译错误
// 正确的做法是将整数的地址赋给指针
ptr = &number; // 使用取地址符&获取number的地址,然后赋给ptr
// 现在ptr指向了number的内存地址
// 可以通过ptr来访问或修改number的值
*ptr = 100; // 修改number的值为100
return 0;
}
结论
因此,在C++中不能简单地将整数赋给指针,这是出于类型安全和内存保护的考虑。如果需要让指针指向某个具体的内存位置,应该使用该位置的地址(通常是通过取地址符&
获得)。这样做可以确保指针的正确性和程序的稳定性。
3. 为什么说前缀++/–比后缀++/–的效率高?
在C++中,关于前缀递增/递减操作符(++x
/--x
)与后缀递增/递减操作符(x++
/x--
)的效率差异,实际上是一个复杂但有趣的话题。首先,需要明确的是,这种效率差异主要源自于操作符的语义和实现方式,而不是因为前缀操作符在语法上更简洁或更底层。
前缀++/– 操作符
- 语义:前缀递增/递减操作符直接作用于变量,并立即返回递增/递减后的新值。
- 实现:由于不需要保存操作前的值,编译器在实现时可以直接对变量进行修改,并立即返回新值。这种实现方式通常更为直接和高效。
后缀++/– 操作符
- 语义:后缀递增/递减操作符也是作用于变量,但返回的是递增/递减前的旧值。
- 实现:为了实现这个语义,编译器需要采取一些额外的步骤。一种常见的实现方式是,在递增/递减变量之前,先保存变量的当前值,然后递增/递减变量,最后返回之前保存的旧值。这种额外的步骤(即保存旧值)是后缀操作符相对于前缀操作符而言效率较低的主要原因。
效率差异的实际考量
- 现代编译器优化:值得注意的是,现代C++编译器(如GCC、Clang、MSVC等)非常智能,它们能够识别出许多情况下前缀和后缀操作符的使用不会对性能产生实际影响,并进行相应的优化。因此,在某些情况下,前缀和后缀操作符的效率差异可能并不明显,甚至不存在。
- 上下文依赖:效率差异是否显著还取决于具体的上下文。例如,在表达式求值中,如果后缀操作符的返回值没有被使用(即被丢弃了),那么编译器可能会进行优化,消除不必要的保存旧值的步骤。
总结
虽然从理论上讲,前缀递增/递减操作符由于不需要保存操作前的值而可能更高效,但这种差异在现代C++编程实践中往往被编译器的优化所掩盖。因此,在选择使用前缀还是后缀操作符时,更应该关注的是代码的清晰性和可读性,而不是微小的效率差异。在大多数情况下,遵循团队的编码规范或个人的编码习惯即可。
最后,对于C++ Primer这样的经典书籍来说,它可能更多地是从语言规范和实现原理的角度来讨论这个问题,而不是从实际应用中的性能差异出发。因此,在面试中回答这个问题时,可以结合上述分析,既展示对语言规范的理解,也表达对现代编译器优化能力的认识。
4. 逗号运算符
在C++中,逗号运算符(,
)是一个二元运算符,它用来顺序执行两个表达式,并返回第二个表达式的值。逗号运算符的优先级是最低的,仅高于赋值运算符(=
)。这意味着,如果在表达式中使用逗号运算符,并且没有括号明确指定运算顺序,逗号运算符会最后执行。
基本用法
逗号运算符的基本形式是:
expression1, expression2
这个表达式首先评估expression1
,然后丢弃其结果(即使expression1
有副作用,比如修改了某个变量的值),接着评估expression2
,并返回expression2
的结果。
示例
#include <iostream>
int main() {
int a = 5, b = 10, c;
c = (a++, a * 2, a + b); // 注意这里的括号,它们确保逗号运算符按预期顺序执行
std::cout << "c = " << c << ", a = " << a << std::endl;
// 输出:c = 16, a = 6
// 因为 a++ 首先执行,a 变为 6,然后 a * 2(结果为 12,但被丢弃),最后 a + b(结果为 16,赋值给 c)
return 0;
}
使用场景
逗号运算符在C++中的使用场景相对较少,因为大多数情况下,我们可以通过更清晰的方式(如分号分隔的语句、函数调用链等)来表达相同的逻辑。然而,在一些特定情况下,比如for
循环的初始化部分,逗号运算符非常有用,因为它允许我们在循环开始前同时执行多个操作。
注意事项
- 逗号运算符的返回值是第二个表达式的值,而不是一个包含两个表达式值的某种特殊类型。
- 由于逗号运算符的优先级非常低,如果它与其他运算符混合使用,可能需要使用括号来明确指定运算顺序。
- 在编写代码时,应谨慎使用逗号运算符,以避免写出难以理解和维护的代码。在某些情况下,使用分号分隔的语句或函数调用可能更清晰。
结论
逗号运算符是C++中的一个基本但相对不常用的运算符。它允许开发者在单个表达式中顺序执行多个操作,并返回最后一个操作的结果。然而,由于其优先级较低且可能导致代码可读性下降,因此在日常编程中应谨慎使用。
5. 有用的字符函数库
在C++岗位面试中,当面试官提出关于“有用的字符函数库”的问题时,主要考察的是对C++标准库中与字符处理相关功能的了解。以下是一个准确、全面且深入的回答:
C++中的字符函数库
C++从C语言继承了一个非常有用的字符函数库,这些函数主要定义在头文件<cctype>
(C++中也常通过<ctype.h>
引入,但推荐使用C++风格的<cctype>
)中。这些函数可以简化诸如判断字符类型(如大写字母、小写字母、数字、标点符号等)、字符转换(如大小写转换)等任务。
主要字符函数
-
类型判断函数
isalnum(int c)
: 如果c是字母或数字,则返回非零值(true)。isalpha(int c)
: 如果c是字母,则返回非零值(true)。isdigit(int c)
: 如果c是数字('0'-'9'),则返回非零值(true)。islower(int c)
: 如果c是小写字母,则返回非零值(true)。isupper(int c)
: 如果c是大写字母,则返回非零值(true)。isspace(int c)
: 如果c是空白字符(如空格、制表符、换行符等),则返回非零值(true)。ispunct(int c)
: 如果c是标点符号,则返回非零值(true)。iscntrl(int c)
: 如果c是控制字符(如回车、换行符等),则返回非零值(true)。
-
字符转换函数
tolower(int c)
: 如果c是大写字母,则返回其小写形式;否则,返回c本身。toupper(int c)
: 如果c是小写字母,则返回其大写形式;否则,返回c本身。
使用示例
以下是一个简单的使用示例,展示了如何统计输入文本中的字母、数字、空格、标点符号以及其他字符的数量:
#include <iostream>
#include <cctype> // 包含字符处理函数
int main() {
std::cout << "Enter text for analysis, and type @ to terminate input.\n";
char ch;
int whitespace = 0, digits = 0, chars = 0, punct = 0, others = 0;
while (std::cin.get(ch) && ch != '@') {
if (isalpha(ch)) {
chars++;
} else if (isspace(ch)) {
whitespace++;
} else if (isdigit(ch)) {
digits++;
} else if (ispunct(ch)) {
punct++;
} else {
others++;
}
}
std::cout << chars << " letters, "
<< whitespace << " whitespace, "
<< digits << " digits, "
<< punct << " punctuations, "
<< others << " others.\n";
return 0;
}
面试建议
在面试中,除了展示对这些函数的了解外,还可以进一步讨论字符函数库在实际编程中的应用场景,比如文本处理、数据验证等。此外,也可以提及C++标准库中的其他与字符处理相关的部分,如<string>
头文件中的std::string
类及其成员函数,这些也是处理字符串数据时非常有用的工具。
综上所述,对C++中的字符函数库有深入的了解,并在面试中能够准确、全面地展示其用法和应用场景,将有助于提升面试的表现。
6. 快排中中值的选取
在C++或任何算法相关的面试中,关于快速排序(Quick Sort)中中值(median)或基准值(pivot)的选取是一个常见的问题。快速排序算法的效率很大程度上依赖于基准值的选择。理想情况下,基准值应该接近数据的中位数,这样可以使得划分后的两个子数组大小相近,从而达到最好的时间复杂度。然而,找到中位数本身是一个相对昂贵的操作,特别是对于未排序的数组。因此,快速排序中通常采用一些启发式方法来选择基准值。
常见的基准值选取策略
-
固定位置选择:
- 最简单的方法是总是选择数组的第一个元素、最后一个元素或中间元素作为基准值。这种方法简单但不一定高效,特别是当数组已经接近排序状态(例如,几乎有序或完全逆序)时。
-
随机选择:
- 从数组中随机选择一个元素作为基准值。这种方法在平均情况下表现良好,因为它减少了特定输入模式(如已排序或逆序数组)对算法性能的影响。
-
三数中值分割法(Median-of-Three):
- 选择数组的第一个元素、最后一个元素和中间元素,然后计算这三个数的中值作为基准值。这种方法比简单选择第一个或最后一个元素更健壮,因为它考虑了数组两端的元素。
-
五数中值分割法(Median-of-Five):
- 类似于三数中值分割法,但选择更多的元素(如第一个、中间、最后一个,以及它们两侧的元素)来找到中值。这种方法进一步提高了基准值选择的健壮性,但增加了计算成本。
-
九数中值分割法(或其他更大的数):
- 类似于五数中值分割法,但选择更多的元素来找到中值。这种方法在理论上可以提高基准值的代表性,但计算成本也更高。
-
使用外部算法:
- 在极端情况下,可以使用部分排序算法(如堆排序的变体)或选择算法(如快速选择算法)来找到数组中第k小的元素作为基准值。然而,这种方法通常过于昂贵,不适合作为快速排序的基准值选择策略。
面试中的回答策略
在面试中,你可以首先提到固定位置选择和随机选择这两种简单方法,并指出它们的优缺点。然后,重点介绍三数中值分割法,因为它是一个在效率和健壮性之间取得良好平衡的选择策略。你可以简要解释为什么选择这种方法,并给出它的实现思路(即先找到三个候选元素,然后比较它们以找到中值,最后将中值作为基准值进行分区)。
如果面试官对这个问题特别感兴趣,你还可以提到五数中值分割法或九数中值分割法,但强调这些方法的计算成本更高,通常只在特定场景下使用。
最后,记得强调快速排序的性能不仅取决于基准值的选择,还取决于分区的质量和递归调用的优化(如尾递归优化和小数组处理策略)。
7. C++存储方案
在C++中,存储方案主要涉及到数据的存储持续性、作用域和链接性。这些概念对于理解C++程序中变量的生命周期、可见性和可访问性至关重要。以下是关于C++存储方案的详细解答:
1. 存储持续性
C++提供了三种不同的存储持续性方案,它们决定了变量在程序中的生命周期:
-
自动存储持续性(Automatic Storage Duration):在函数定义中声明的变量(包括函数参数)具有自动存储持续性。这些变量的生命周期从它们被创建时开始,到包含它们的块执行完毕时结束。它们通常存储在栈上。
-
静态存储持续性(Static Storage Duration):在函数定义外定义的变量,以及使用
static
关键字声明的变量,具有静态存储持续性。这些变量的生命周期贯穿整个程序执行期间。它们可以存储在程序的静态数据区,对于未初始化的静态变量,其所有位都被设置为0。 -
动态存储持续性(Dynamic Storage Duration):使用
new
操作符分配的内存具有动态存储持续性。这种内存会一直存在,直到使用delete
操作符将其释放,或者程序结束。这种内存有时也被称为自由存储(free store)。
2. 作用域
作用域描述了名称在文件(或翻译单元)中的可见范围。C++中主要有以下几种作用域:
-
函数作用域:在函数内部声明的变量具有函数作用域,它们仅在该函数内部可见。
-
块作用域:在代码块(如大括号
{}
包围的区域)内声明的变量具有块作用域,它们仅在该块内部可见。 -
全局作用域:在函数定义外部声明的变量具有全局作用域,它们在程序的任何地方都可见(除非被更内层的作用域隐藏)。
-
命名空间作用域:在命名空间内声明的名称具有命名空间作用域,它们仅在该命名空间内部可见,但可以通过使用
using
声明或using
编译指令在外部访问。
3. 链接性
链接性决定了变量或函数是否可以在不同的编译单元(如不同的源文件)之间共享。C++中的链接性主要分为以下几种:
-
外部链接性:默认情况下,全局变量和函数具有外部链接性,意味着它们可以在不同的编译单元之间共享。
-
内部链接性:使用
static
关键字声明的全局变量或函数具有内部链接性,意味着它们只能在定义它们的编译单元内部访问。 -
无链接性:具有自动存储持续性的变量和函数参数没有链接性,因为它们只存在于它们被声明的块或函数中。
4. 其他概念
-
寄存器变量:使用
register
关键字声明的变量(尽管在现代编译器中,register
关键字更多地被视为一种提示而非强制要求)旨在存储在CPU的寄存器中,以提高访问速度。由于寄存器变量没有内存地址,因此不能将地址操作符用于它们。 -
作用域解析符:
::
作用域解析符用于指定特定的变量或函数,以避免名称冲突或访问全局变量。
综上所述,C++的存储方案涉及存储持续性、作用域和链接性等多个方面,它们共同决定了程序中变量的生命周期、可见性和可访问性。理解这些概念对于编写高效、可维护的C++程序至关重要。
8. 自己写 string 类注意事项
在C++岗位面试中,如果面试官要求你讨论自己编写一个string
类时需要注意的事项,这实际上是在考察你对C++字符串处理、内存管理、异常安全、性能优化以及标准库std::string
实现原理的理解。以下是一些关键注意事项:
1. 内存管理
- 动态内存分配:
string
类需要能够动态地增加或减少其内部字符数组的大小。这通常涉及到使用new
和delete
(或智能指针如std::unique_ptr
,但在这里直接管理内存可能更合适以模拟标准string
的行为)。 - 内存复制与移动:提供深拷贝构造函数、拷贝赋值运算符和移动构造函数、移动赋值运算符,确保资源正确管理,避免浅拷贝导致的重复释放或野指针问题。
- 小字符串优化(SSO):考虑实现小字符串优化,即对于较短的字符串,直接在
string
对象内部存储字符,避免小字符串时频繁的内存分配和释放。
2. 性能优化
- 缓存局部性:尽量保持数据局部性,减少内存访问延迟。
- 避免不必要的内存重新分配:使用预留空间(
reserve
)机制,减少因字符串增长而导致的频繁内存分配。 - 常量时间复杂度操作:确保长度获取(
length
或size
)等操作为常量时间复杂度。
3. 异常安全
- 异常规范(C++11之前):虽然C++11及以后废弃了异常规范,但了解如何设计不抛出异常的函数,或在异常发生时能正确释放资源是很重要的。
- RAII(Resource Acquisition Is Initialization):利用RAII原则,确保在构造函数中分配的资源在析构函数中正确释放,即使发生异常也能保证资源不泄露。
4. 线程安全
- 非线程安全默认:标准库中的
std::string
不是线程安全的,除非明确说明(如C++17的std::pmr::string
)。你的自定义string
类也应该遵循这一原则,除非有特别的线程安全需求。 - 同步机制:如果需要在多线程环境下使用,考虑提供同步机制(如互斥锁)来保护共享数据。
5. 接口设计
- 与标准库兼容:尽可能模仿
std::string
的接口,如length()
,size()
,empty()
,append()
,substr()
,find()
等,这有助于用户迁移和代码复用。 - 异常与错误处理:明确哪些操作会抛出异常,哪些会返回错误码或进行错误处理。
6. 特殊字符处理
- 空字符串:确保能够正确处理空字符串。
- 特殊字符:如
\0
(字符串终结符),在你的string
类中如何存储和表示。
7. 测试
- 单元测试:编写全面的单元测试,覆盖各种边界情况和异常场景。
- 性能测试:比较你的
string
类与标准std::string
的性能,找出可能的优化点。
总之,编写一个自己的string
类是一个复杂而富有挑战性的任务,需要对C++的多个方面有深入的理解。在面试中,展示你对上述问题的思考和理解,将有助于给面试官留下深刻印象。
9. 何时调用拷贝(复制)构造函数
在C++中,拷贝(复制)构造函数是一个特殊的构造函数,它在以下情况下被自动调用:
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
【C/C++面试必考必会】专栏,直击面试核心,精选C/C++及相关技术栈中面试官最爱的必考点!从基础语法到高级特性,从内存管理到多线程编程,再到算法与数据结构深度剖析,一网打尽。助你快速构建知识体系,轻松应对技术挑战。希望专栏能让你在面试中脱颖而出,成为技术岗的抢手人才。