C 风格字符串

在《C 风格数组简介》中,我们介绍了 C 风格数组,它允许我们定义一段连续的元素集合:

int testScore[30] {}; // 一个包含 30 个 int 的数组,下标 0 至 29

在《字面量》中,我们将字符串定义为“一串连续字符”(如 "Hello, world!"),并引入了 C 风格字符串字面量。我们还指出,字面量 "Hello, world!" 的类型为 const char[14]——13 个显式字符外加 1 个隐含的空字符终止符。

如果此前尚未意识到,现在应当清楚:C 风格字符串其实就是元素类型为 charconst char 的 C 风格数组。

尽管 C 风格字符串字面量可直接使用,但 C 风格字符串对象在现代 C++ 中已失宠:它们难以使用且易出错(现代替代品为 std::stringstd::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())获取数组长度,但有两点注意:

  1. 仅对未退化字符串有效。
  2. 返回的是数组总长度,而非字符串逻辑长度。
#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,但要求反向打印字符串。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"