C++ string 类全攻略:从入门到深扒底层原理
一、为什么我们一定要用 string 类?
1.1 C 语言字符串的痛点
先回忆一下 C 语言中我们是如何操作字符串的:
char str[20];
strcpy(str, "hello");
strcat(str, " world");
printf("%s\n", str);
C 标准库提供了 strlen、strcpy、strcat、strcmp 等函数,但它们存在几个致命问题:
-
与数据分离:字符串数据存储在字符数组中,操作函数是独立的,不符合 OOP 的封装思想。
-
手动管理内存:开发者必须自己保证数组大小足够,否则就会发生缓冲区溢出,造成严重安全隐患。
-
边界检查缺失:很多
str函数不检查越界,稍不留神就可能踩踏内存。
1.2 C++ string 的优势
C++ 的 std::string 类完美解决了上述问题:
-
自动内存管理:字符串长度改变时,自动分配或释放内存。
-
安全边界:通过
size()和[]运算符(带检查)避免越界。 -
面向对象接口:使用成员函数如
find、substr、append,语义清晰。 -
支持运算符重载:
+、+=、==、<等,使用起来非常自然。 -
高效实现:现代标准库采用小字符串优化(SSO)、写时拷贝(COW)等技巧,兼顾性能和灵活性。
在实际开发、笔试面试中,string 几乎无处不在。接下来,我们就从零开始学习 string 的标准用法。
二、string 基础:构造、容量与遍历
使用 string 需要包含头文件 <string>,并引入 std 命名空间。
2.1 常见构造方式
#include <iostream>
#include <string>
using namespace std;
int main() {
string s1; // 空字符串
string s2("hello"); // 从 C 字符串构造
string s3(s2); // 拷贝构造
string s4(5, 'A'); // 5 个 'A': "AAAAA"
cout << s2 << endl; // hello
cout << s4 << endl; // AAAAA
return 0;
}
2.2 容量相关操作
| 函数 | 作用 |
|---|---|
size() |
返回字符串有效字符个数(不含 '\0') |
length() |
同 size(),历史原因保留 |
capacity() |
返回当前分配的内存大小(可容纳字符数) |
empty() |
判断是否为空 |
clear() |
清空字符串,但一般不释放内存 |
resize(n) |
将有效字符数改为 n,多出的用 '\0' 填充 |
reserve(n) |
预留至少 n 个字符的空间(不改变 size) |
重要区别:resize 会改变 size(),而 reserve 只影响 capacity()。合理使用 reserve 可以减少多次扩容带来的性能开销。
示例:
string s = "hello";
cout << s.size() << " " << s.capacity() << endl; // 5 15(具体值依编译器而定)
s.reserve(100);
cout << s.capacity() << endl; // 至少 100
s.resize(10, '!');
cout << s << endl; // "hello!!!!!" size 变为 10
2.3 访问与遍历
有三种主流方式遍历 string:
① 使用 [] 运算符(或 at())
string s = "world";
for (size_t i = 0; i < s.size(); ++i) {
cout << s[i] << " ";
}
at() 会做边界检查,越界时抛出异常,但效率稍低。
② 使用迭代器
for (string::iterator it = s.begin(); it != s.end(); ++it) {
cout << *it << " ";
}
③ C++11 范围 for
for (char ch : s) {
cout << ch << " ";
}
范围 for 底层就是迭代器,书写最简洁,推荐使用。
三、string 的修改操作:拼接、查找、截取
3.1 追加字符串
string s = "hello";
s.push_back(' '); // 追加单个字符
s.append("world"); // 追加字符串
s += "!"; // 最常用,支持字符和字符串
cout << s << endl; // "hello world!"
3.2 查找与替换
find 函数用于查找子串或字符,返回第一次出现的位置(size_t 类型),若找不到则返回 string::npos(通常为 -1)。
string s = "C++ is powerful, C++ is fun.";
size_t pos = s.find("C++");
while (pos != string::npos) {
cout << "found at " << pos << endl;
pos = s.find("C++", pos + 1);
}
rfind 从右向左查找。substr(pos, len) 用于截取子串:
string sub = s.substr(0, 3); // "C++"
3.3 获取 C 风格字符串
c_str() 返回一个 const char*,用于与旧 C 函数交互:
string s = "hello";
printf("%s\n", s.c_str());
四、牛刀小试:几个经典 string 算法题
4.1 仅仅反转字母(LeetCode 917)
只反转字母字符,保持非字母的位置不变。思路:双指针向中间靠拢,遇到字母才交换。
class Solution {
public:
bool isLetter(char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
}
string reverseOnlyLetters(string S) {
int left = 0, right = S.size() - 1;
while (left < right) {
while (left < right && !isLetter(S[left])) ++left;
while (left < right && !isLetter(S[right])) --right;
swap(S[left++], S[right--]);
}
return S;
}
};
4.2 字符串相加(LeetCode 415)
模拟竖式加法,从末尾逐位相加,注意进位。
string addStrings(string num1, string num2) {
int i = num1.size() - 1, j = num2.size() - 1, carry = 0;
string result;
while (i >= 0 || j >= 0 || carry) {
int sum = carry;
if (i >= 0) sum += num1[i--] - '0';
if (j >= 0) sum += num2[j--] - '0';
result.push_back(sum % 10 + '0');
carry = sum / 10;
}
reverse(result.begin(), result.end());
return result;
}
五、深入底层:string 的内存布局(vs 和 g++)
了解不同标准库实现下的 string 内存结构,有助于写出高效代码。
5.1 VS(微软编译器)下的 string
在 32 位平台下,sizeof(string) = 28 字节。其内部设计采用了 小字符串优化(SSO):
-
当字符串长度小于 16 时,存储在对象内部的固定数组
_Buf[16]中,不进行堆分配。 -
当长度 >= 16 时,在堆上分配内存,
_Ptr指向堆空间。
联合体 _Bxty 同时容纳 _Buf 和 _Ptr,配合 size 和 capacity 字段,实现了短字符串零开销。
5.2 g++ 下的 string
g++ 采用 写时拷贝(Copy-On-Write) 技术(早期版本,较新的 g++ 已改为 SSO)。其对象只占 4 字节(一个指针),指向堆上一个 _Rep 结构,该结构包含:
-
_M_length:字符串长度 -
_M_capacity:容量 -
_M_refcount:引用计数
多个 string 对象可以共享同一块堆内存,当需要修改时才真正复制。这种设计减少了不必要的拷贝,但在多线程环境下引用计数的维护有额外开销。
注意:C++11 标准之后,COW 实现被禁止,目前主流编译器(libstdc++ 高版本、libc++)均已转向 SSO。不同平台 SSO 的阈值略有差异。
六、手撕 string:模拟实现及深拷贝剖析
面试官最爱问:“请手写一个简单的 String 类,实现构造、析构、拷贝构造和赋值运算符。” 这背后考察的是对浅拷贝与深拷贝的理解。
6.1 浅拷贝的灾难
如果不显式定义拷贝构造和赋值,编译器生成的默认版本只是逐字节复制成员变量(位拷贝)。对于含有指针成员的类,这会导致多个对象指向同一块堆内存。析构时该内存被多次释放,程序崩溃。
class String {
public:
String(const char* str = "") {
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
~String() { delete[] _str; }
private:
char* _str;
};
String s1("hello");
String s2 = s1; // 浅拷贝!s2._str 和 s1._str 指向同一块内存
// 程序结束时会 double free
6.2 深拷贝的传统写法
深拷贝的核心:为每个对象独立分配资源,拷贝时复制内容。
class String {
public:
String(const char* str = "") {
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 拷贝构造
String(const String& s) {
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
// 赋值运算符重载(传统写法)
String& operator=(const String& s) {
if (this != &s) {
delete[] _str;
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
return *this;
}
~String() { delete[] _str; }
private:
char* _str;
};
6.3 现代写法(拷贝交换)
利用 swap 和临时对象,使代码更简洁且异常安全。
class String {
public:
String(const char* str = "") {
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 拷贝构造:传值?不对,应该是 const& 配合临时对象
String(const String& s) : _str(nullptr) {
String tmp(s._str); // 调用构造创建临时对象
swap(_str, tmp._str);
}
// 赋值:使用传值,直接复用拷贝构造
String& operator=(String s) {
swap(_str, s._str);
return *this;
}
~String() { delete[] _str; }
private:
char* _str;
};
赋值运算符的参数 String s 是传值,会调用拷贝构造生成一份副本,然后与当前对象的 _str 交换,临时对象销毁时释放旧内存。这种方式代码简洁且自动处理了自我赋值。
6.4 写时拷贝(COW)简介
写时拷贝是一种优化策略:在浅拷贝的基础上添加引用计数。多个对象共享同一块内存,只有当某个对象试图修改内容时才真正执行深拷贝。这样可以节省大量拷贝开销,尤其适合频繁复制但很少修改的场景。
// 伪代码示意
struct StringRep {
size_t refCount;
size_t size;
char data[1];
};
当 String 对象拷贝时,只是增加引用计数;修改前若发现引用计数 > 1,则复制一份新内存并减少原计数。不过现代 C++ 中由于移动语义和 SSO 的普及,COW 已不再主流。
七、C++11 的两个小语法:auto 和范围 for
在学习 string 过程中,我们经常看到 auto 和范围 for 的身影,这里简单提一下。
7.1 auto 类型推导
auto 让编译器根据初始值自动推导类型,极大地简化了冗长的迭代器声明:
map<string, string> dict;
// 原本:map<string, string>::iterator it = dict.begin();
auto it = dict.begin(); // 简洁多了
注意:auto 不能用于函数参数,不能声明数组,且同一行定义的变量类型必须一致。
7.2 范围 for
本质上是一种语法糖,编译器会将其展开为基于迭代器的循环。它可以遍历任何拥有 begin/end 成员函数的容器以及原生数组。
int arr[] = {1,2,3,4};
for (int& x : arr) x *= 2; // 修改原数组
for (int x : arr) cout << x << " ";
八、总结与建议
-
优先使用
std::string:不要再用char*手动管理字符串内存,string安全且高效。 -
熟悉常用接口:构造、
size/capacity、reserve、resize、c_str、find、substr、append、+=、[]等。 -
合理使用
reserve:预分配空间避免反复扩容。 -
理解深浅拷贝:面试常考,写自己的 String 类时务必实现深拷贝或使用现代写法。
-
掌握范围 for 和 auto:提升代码简洁性,但也要清楚其底层原理。
string 类虽小,却是 C++ 标准库中最常用的组件之一。希望这篇文章能帮你打通从使用到原理的任督二脉,在开发与面试中更加游刃有余。
练习推荐:LeetCode 上字符串分类的题目,如“反转字符串中的单词”、“字符串相乘”、“最长公共前缀”等,都是巩固
string操作的好机会。
下一篇预告:我们将深入 vector 容器,讲解动态数组的实现与迭代器失效问题。敬请期待!
更多推荐

所有评论(0)