在《C 风格数组简介》中,我们介绍了 C 风格数组,它允许我们定义一段连续的元素集合:
int testScore[30] {}; // 一个包含 30 个 int 的数组,下标 0 至 29
在《字面量》中,我们将字符串定义为“一串连续字符”(如 "Hello, world!"),并引入了 C 风格字符串字面量。我们还指出,字面量 "Hello, world!" 的类型为 const char[14]——13 个显式字符外加 1 个隐含的空字符终止符。
如果此前尚未意识到,现在应当清楚:C 风格字符串其实就是元素类型为 char 或 const char 的 C 风格数组。
尽管 C 风格字符串字面量可直接使用,但 C 风格字符串对象在现代 C++ 中已失宠:它们难以使用且易出错(现代替代品为 std::string 与 std::string_view)。尽管如此,旧代码中仍可能遇到 C 风格字符串对象,我们亦不能完全不提。
因此,本章将概述现代 C++ 中 C 风格字符串对象的关键要点。
定义 C 风格字符串
欲定义 C 风格字符串变量,只需声明一个 char(或 const char / constexpr char)的 C 风格数组:
char str1[8]{};                    // 8 个 char 的数组,下标 0–7
const char str2[]{ "string" };     // 7 个 char 的数组,下标 0–6
constexpr char str3[] { "hello" }; // 6 个 const char 的数组,下标 0–5
注意需额外留出一个字符存放隐式空字符终止符。
当初始化 C 风格字符串时,强烈建议省略数组长度而让编译器推导。这样若初始值日后变动,无需手动更新长度,也绝不会忘记为终止符预留空间。
C 风格字符串会退化
在《C 风格数组退化》中,我们讨论过 C 风格数组在大多数情境下会退化为指针。由于 C 风格字符串即 C 风格数组,它们同样会退化:
- 字符串字面量退化为 const char*;
- C 风格字符串数组依其是否 const退化为const char*或char*。
退化后,字符串长度(原本编码在类型中)丢失。
正因长度信息丢失,C 风格字符串才需要空字符终止符:通过计数从字符串开始到空字符之间的字符数,可(低效地)重新获得长度。
输出 C 风格字符串
使用 std::cout 输出 C 风格字符串时,遇到空字符即停止。空字符标记字符串结束,使得已退化、失去长度信息的字符串仍可被打印。
#include <iostream>
void print(char ptr[])
{
    std::cout << ptr << '\n'; // 输出字符串
}
int main()
{
    char str[]{ "string" };
    std::cout << str << '\n'; // 输出 string
    print(str);
    return 0;
}
若字符串缺少空字符(如空字符被覆盖),则行为未定义。最可能的结果是:打印完字符串后继续把相邻内存解释为字符,直到遇到值为 0 的字节(视作空字符)!
输入 C 风格字符串
假设要求用户掷骰子若干次并连续输入所有结果(如 524412616)。我们无法预知用户会输入多少字符。
由于 C 风格字符串为定长数组,解决方法是声明一个足够大的数组:
#include <iostream>
int main()
{
    char rolls[255] {}; // 可容纳 254 字符 + 空字符
    std::cout << "Enter your rolls: ";
    std::cin >> rolls;
    std::cout << "You entered: " << rolls << '\n';
    return 0;
}
在 C++20 之前,std::cin >> rolls 会尽可能提取字符到 rolls(遇前导空白即停)。若用户输入超过 254 字符(无论无意或恶意),数组将溢出,导致未定义行为。
关键洞察
数组溢出(或称缓冲区溢出)是一种安全漏洞:当写入数据超出存储容量时,相邻内存被覆盖,可能造成未定义行为。攻击者可利用此漏洞篡改内存,从而改变程序行为。
自 C++20 起,operator>> 仅能对未退化的 C 风格字符串使用,从而最多提取数组可容纳的字符数,防止溢出;退化后的字符串已无法再用 >> 直接输入。
推荐做法如下:
#include <iostream>
#include <iterator> // std::size
int main()
{
    char rolls[255] {};
    std::cout << "Enter your rolls: ";
    std::cin.getline(rolls, std::size(rolls));
    std::cout << "You entered: " << rolls << '\n';
    return 0;
}
cin.getline() 最多读取 254 字符(含空格),多余字符被丢弃。由于 getline 接受长度参数,我们可传入数组大小。对未退化数组,可用 std::size();退化数组则需另行获知长度;若长度有误,程序可能出错或出现安全隐患。
现代 C++ 中,若需存储用户输入文本,使用 std::string 更安全,因其可自动扩容。
修改 C 风格字符串
C 风格字符串遵循与 C 风格数组相同的规则:创建时可初始化,之后不可再用赋值运算符整体赋值!
char str[]{ "string" }; // 合法
str = "rope";           // 非法!
这使得使用 C 风格字符串颇为不便。
由于它们是数组,可用 [] 修改单个字符:
#include <iostream>
int main()
{
    char str[]{ "string" };
    std::cout << str << '\n';
    str[1] = 'p';
    std::cout << str << '\n';
    return 0;
}
输出:
string
spring
获取 C 风格字符串长度
由于 C 风格字符串即 C 风格数组,可使用 std::size()(C++20 起亦可用 std::ssize())获取数组长度,但有两点注意:
- 仅对未退化字符串有效。
- 返回的是数组总长度,而非字符串逻辑长度。
#include <iostream>
int main()
{
    char str[255]{ "string" }; // 6 字符 + 空字符
    std::cout << "length = " << std::size(str) << '\n'; // 输出 255
    char *ptr { str };
    std::cout << "length = " << std::size(ptr) << '\n'; // 编译错误
    return 0;
}
另一方案是使用 <cstring> 中的 strlen(),它对退化数组亦有效,返回不含空字符的字符串长度:
#include <cstring> // std::strlen
#include <iostream>
int main()
{
    char str[255]{ "string" };
    std::cout << "length = " << std::strlen(str) << '\n'; // 输出 6
    char *ptr { str };
    std::cout << "length = " << std::strlen(ptr) << '\n'; // 输出 6
    return 0;
}
但 strlen() 效率较低,需遍历整个数组直到空字符。
其他 C 风格字符串操作函数
由于 C 风格字符串是 C 的主要字符串类型,C 提供了大量操作函数,C++ 通过 <cstring> 继承:
- strlen()—— 返回字符串长度
- strcpy(), strncpy(), strcpy_s()—— 复制字符串
- strcat(), strncat()—— 拼接字符串
- strcmp(), strncmp()—— 比较字符串(相等返回 0)
除 strlen() 外,其余函数在现代 C++ 中建议避免使用。
避免非常量 C 风格字符串对象
除非有明确且充分的理由,否则应避免使用非常量 C 风格字符串对象:它们使用不便,且极易溢出,导致未定义行为及安全隐患。
若确实需要在固定缓冲区中操作字符串(如内存受限设备),建议使用经过充分测试的第三方定长字符串库。
最佳实践
避免非常量 C 风格字符串对象,优先使用 std::string。
测验
问题 1
编写函数,逐一字符打印 C 风格字符串,使用指针及指针运算步进。在 main 中用字面量 "Hello, world!" 测试。
问题 2
重做问题 1,但要求反向打印字符串。
