第6章:数组与字符串——C++中最基础的数据容器

数组和字符串,说白了就是C++里最常用的两种数据组织方式。我做了这么多年C++开发,几乎每个项目都离不开它们。今天咱们就好好聊聊这两个基础但极其重要的概念。

6.1 一维数组:最简单的数据集合

一维数组就像一排连续的储物柜。每个柜子有编号,你可以直接通过编号存取东西。在C++里,数组的索引从0开始,这点我刚开始学的时候也经常搞混。

// 声明和初始化
int scores[5];          // 声明一个包含5个整数的数组
int scores[5] = {85, 92, 78, 90, 88};  // 声明并初始化
int scores[] = {85, 92, 78, 90, 88};   // 让编译器自动计算大小

// 访问元素
scores[0] = 95;         // 修改第一个元素
int first = scores[0];  // 读取第一个元素

⚠️ 注意:数组越界是C++中最常见的bug之一。我曾经在一个金融交易系统里遇到过,就因为数组越界导致内存被意外修改,查了整整两天才定位到问题。C++不会检查数组边界,所以你得自己负责。

6.2 二维数组:表格数据的好帮手

二维数组其实就是「数组的数组」。我习惯把它想象成一个Excel表格——行和列。比如存储一个3×4的矩阵:

// 声明一个3行4列的二维数组
int matrix[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

// 访问元素
int value = matrix[1][2];  // 第2行第3列,值为7

你想想看,二维数组在内存里其实是连续存储的。按行优先排列——先存完第一行所有列,再存第二行。这个特性在做图像处理时特别有用,我做过一个图像滤波的项目,就是利用这个特性做了缓存优化,性能提升了将近3倍。

6.3 数组与指针的关系

嗯,这里要重点说一下。数组名其实就是一个指向数组首元素的指针常量。这句话我当年理解了好久。

int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr;  // 数组名arr就是指向第一个元素的指针

// 下面两种写法完全等价
cout << arr[2];    // 输出30
cout << *(ptr + 2); // 也输出30

为什么会这样?因为编译器会把 arr[i] 翻译成 *(arr + i)。说白了,数组下标操作本质上就是指针运算。我个人习惯在需要传递数组给函数时,明确使用指针参数,这样代码意图更清晰。

💡 小技巧:sizeof(arr) / sizeof(arr[0]) 可以计算数组元素个数。但注意,这只在数组定义的作用域内有效。一旦数组退化为指针,sizeof就只返回指针大小了。我踩过这个坑,所以现在都用 std::size()(C++17)或者模板函数来获取数组大小。

6.4 C风格字符串(char[])

C风格字符串其实就是以空字符 '\0' 结尾的字符数组。说实话,用起来挺麻烦的,但很多底层系统代码还在用。

char str1[] = "Hello";      // 自动添加'\0',实际占用6个字节
char str2[6] = {'H', 'e', 'l', 'l', 'o', '\0'};  // 手动添加'\0'

// 常用操作
#include <cstring>
strlen(str1);       // 获取长度(不包括'\0')
strcpy(dest, src);  // 复制字符串
strcat(str1, str2); // 拼接字符串
strcmp(str1, str2); // 比较字符串

⚠️ 注意:C风格字符串最大的问题是缓冲区溢出。我曾经接手过一个遗留项目,里面大量使用 strcpysprintf,结果安全扫描报告了一堆高危漏洞。后来全部改成了 strncpysnprintf。现在C++17已经不建议使用这些C风格函数了。

6.5 C++ string类(std::string)

终于说到 std::string 了。我个人强烈建议:能用string就别用char[]。string类封装了内存管理,自动处理扩容,还提供了丰富的成员函数。

#include <string>
std::string s1 = "Hello";
std::string s2("World");
std::string s3(5, 'A');  // "AAAAA"

// 基本操作
s1.length();        // 获取长度
s1.empty();         // 判断是否为空
s1[0];              // 访问字符(不检查边界)
s1.at(0);           // 访问字符(检查边界,越界抛异常)

我习惯用 at() 而不是 operator[],虽然慢一点点,但至少不会出现静默越界。在调试阶段用 at(),发布版本如果性能敏感再换成 operator[]

6.6 字符串常用操作

拼接

std::string s1 = "Hello";
std::string s2 = "World";

// 方法1:使用+运算符
std::string s3 = s1 + " " + s2;  // "Hello World"

// 方法2:使用append()
s1.append(" ").append(s2);       // 链式调用

// 方法3:使用+=运算符
s1 += " " + s2;

这三种方式里,我推荐用 +=append(),因为它们不会创建临时对象。用 + 运算符会产生中间临时字符串,在循环里大量拼接时性能会差很多。

查找

std::string text = "Hello World, Hello C++";

// 查找子串
size_t pos = text.find("World");  // 返回6,找不到返回std::string::npos

// 从指定位置开始查找
pos = text.find("Hello", 7);     // 从索引7开始找,返回13

// 反向查找
pos = text.rfind("Hello");       // 从后往前找,返回13

// 查找字符集合中的任意字符
pos = text.find_first_of("aeiou");  // 返回1('e'的位置)

// 判断是否找到
if (pos != std::string::npos) {
    std::cout << "找到了,位置:" << pos;
}

查找操作里有个细节:find 返回的是 size_t 类型,而 npos 的值是 -1 转换成 size_t 后的最大值。所以判断时一定要用 != npos,不要用 >= 0,因为 size_t 是无符号的。

替换

std::string text = "I like C++";

// 替换子串
text.replace(7, 3, "Python");  // 从索引7开始,替换3个字符为"Python"
// 结果:"I like Python"

// 替换所有匹配项(需要循环)
std::string target = "C++";
std::string replacement = "Java";
size_t pos = 0;
while ((pos = text.find(target, pos)) != std::string::npos) {
    text.replace(pos, target.length(), replacement);
    pos += replacement.length();
}

💡 经验之谈:如果你需要做大量的字符串替换操作,建议先用 std::stringstream 或者 std::ostringstream 构建新字符串,而不是在原字符串上反复 replace。每次 replace 都可能触发内存重分配,性能开销不小。

知识体系总览

下面这张图把本章的核心知识点串起来了,方便你理解它们之间的关系:

数组与字符串知识体系 数组 一维数组 二维数组 数组与指针 字符串 C风格字符串 std::string 常用操作 拼接 查找 替换 核心:优先使用 std::string,注意数组边界和指针退化

总结

数组和字符串是C++编程的基石。我个人建议你记住几点:

  • 数组:固定大小,连续内存,下标从0开始。注意越界问题。
  • 指针与数组:数组名是指向首元素的指针常量,但 sizeof 行为不同。
  • C风格字符串:以 '\0' 结尾的字符数组,操作麻烦且不安全。
  • std::string:现代C++的首选,自动管理内存,功能丰富。
  • 字符串操作:拼接用 +=append(),查找注意 npos 判断,替换考虑性能。

嗯,这些内容看起来基础,但实际项目中80%的字符串相关bug都出在这些细节上。多写多练,慢慢就熟练了。


专注资料整理