在 C++ 开发中,字符串是最常用的数据类型之一。C 语言中以\0结尾的字符数组不仅使用繁琐,还需手动管理内存,容易引发越界或泄漏问题。而 C++ 标准库提供的string类,通过封装字符串操作、自动管理内存,彻底解决了这些痛点。本文将从string类的核心价值出发,详解其常用接口、底层实现差异,并手把手教你模拟实现string类,同时结合 OJ 例题巩固实战能力,帮你彻底掌握这一高频使用类。
一、为什么要学习 string 类?C 语言字符串的痛点与解决方案
在学习string类之前,我们先明确其存在的意义 —— 解决 C 语言字符串的固有缺陷。
1.1 C 语言字符串的三大痛点
C 语言中,字符串本质是 “以\0结尾的字符数组”,配合strlen、strcpy、strcat等库函数使用,但存在明显问题:
- 内存管理繁琐:需手动
malloc/free动态内存,稍不注意就会导致内存泄漏(如忘记free)或野指针(如free后未置空)。 - 接口与数据分离:库函数与字符串是分离的(如
strcpy(dst, src)需显式传入两个参数),不符合面向对象(OOP)思想,代码可读性低。 - 安全性差:
strcpy、strcat等函数不检查目标空间大小,容易导致缓冲区溢出(越界访问),引发程序崩溃。
1.2 string 类的核心优势
C++ 的string类本质是 “封装了字符数组的类”,通过成员函数封装字符串操作,核心优势包括:
自动内存管理:无需手动申请 / 释放内存,构造函数、析构函数自动处理,避免泄漏。
丰富的接口:提供增删查改、容量调整、遍历等一站式接口(如push_back、find、substr),无需依赖库函数。
安全性高:接口内部会检查边界(如operator[]配合at函数),减少越界风险。
兼容 C 语言:提供c_str()接口,可将string转为 C 风格字符串(const char*),适配旧代码。
无论是 OJ 刷题(如字符串反转、相加)还是日常开发(如配置文件读取、日志输出),string类都是首选工具,掌握它是 C++ 开发者的必备技能。
二、string 类的基础:C++11 语法糖(auto 与范围 for)
在使用string类前,先补充两个 C++11 的实用语法 ——auto和范围 for,它们能大幅简化string的遍历与操作代码。
2.1 auto:让编译器自动推导类型
auto在 C++11 中不再是 “自动存储类型指示符”,而是 “类型推导关键字”—— 编译器会根据变量的初始化值,自动推导其类型。
2.1.1 auto 的核心规则
指针与引用:推导指针时,auto与auto*等价;推导引用时,必须加&(如auto& ref = var)。
多变量声明:同一行声明的多个变量必须是同一类型(编译器仅推导第一个变量的类型)。
限制场景:不能作为函数参数(如void func(auto a)错误)、不能直接声明数组(如auto arr[] = {1,2}错误)。
2.1.2 auto 的实用场景:简化迭代器声明
string或map等容器的迭代器类型冗长(如std::string::iterator),auto可大幅简化代码:
#include <string>
#include <map>
using namespace std;
int main() {
map<string, string> dict = {{"apple", "苹果"}, {"orange", "橙子"}};
// 传统写法:迭代器类型冗长
map<string, string>::iterator it1 = dict.begin();
// auto写法:编译器自动推导为map<string, string>::iterator
auto it2 = dict.begin();
// 遍历字典
while (it2 != dict.end()) {
cout << it2->first << ":" << it2->second << endl;
++it2;
}
return 0;
}
2.2 范围 for:简化容器遍历
C++11 引入的 “范围 for” 循环,适用于 “有明确范围的集合”(如数组、string、STL 容器),无需手动控制索引或迭代器,自动遍历所有元素。
2.2.1 范围 for 的语法
for (auto 迭代变量 : 被遍历的范围) {
// 循环体
}
迭代变量:每次循环取范围中的一个元素(值传递,若需修改元素需加&,如auto& e)。
被遍历的范围:需支持begin()和end()接口(string和 STL 容器均支持)。
2.2.2 范围 for 遍历 string 示例
#include <string>
#include <iostream>
using namespace std;
int main() {
string str = "hello world";
// 遍历并打印每个字符(值传递,不修改原字符串)
for (auto ch : str) {
cout << ch << " "; // 输出:h e l l o w o r l d
}
cout << endl;
// 遍历并修改字符(引用传递,原字符串会被修改)
for (auto& ch : str) {
ch -= 32; // 小写转大写(ASCII码差值)
}
cout << str << endl; // 输出:HELLO WORLD
return 0;
}
底层原理:范围 for 本质是 “迭代器的语法糖”,编译器会自动将其替换为begin()/end()迭代器遍历,因此支持所有提供迭代器的容器。
三、string 类的常用接口:从构造到修改的核心操作
string类提供了大量接口,本节聚焦最常用的核心接口,按 “构造→容量→遍历→修改” 的逻辑分类讲解,配合代码示例帮助理解。
3.1 字符串构造(constructor)
string类的构造函数支持多种初始化方式,重点掌握以下 4 种:
| 构造函数 | 功能说明 | 示例代码 |
|---|---|---|
string() | 构造空字符串(默认构造) | string s1;(s1 为空串) |
string(const char* s) | 用 C 风格字符串(const char*)构造 | string s2("hello");(s2="hello") |
string(size_t n, char c) | 构造包含 n 个字符 c 的字符串 | string s3(5, 'a');(s3="aaaaa") |
string(const string& s) | 拷贝构造(用已有 string 对象构造新对象) | string s4(s2);(s4="hello") |
代码示例:
void TestStringConstruct() {
string s1; // 空串
string s2("hello"); // 用C字符串构造
string s3(5, 'a'); // 5个'a'
string s4(s2); // 拷贝s2
cout << "s1: " << s1 << "(空串)" << endl;
cout << "s2: " << s2 << endl; // 输出hello
cout << "s3: " << s3 << endl; // 输出aaaaa
cout << "s4: " << s4 << endl; // 输出hello
}
3.2 容量操作:size、capacity、reserve 与 resize
容量操作用于管理string的内存空间,避免频繁扩容导致的性能损耗,重点掌握以下接口:
| 接口名 | 功能说明 |
|---|---|
size() | 返回字符串有效字符长度(不包含\0),与length()功能完全一致(推荐用size()) |
capacity() | 返回字符串底层空间总大小(可存储的最大字符数,不包含\0) |
empty() | 判断字符串是否为空(有效字符长度为 0),返回bool值 |
clear() | 清空有效字符(将size()置 0),不释放底层空间(capacity()不变) |
reserve(n) | 为字符串预留 n 个字符的空间(仅扩容,不缩容;不改变size()) |
resize(n, c) | 将有效字符数调整为 n:- 若 n > 当前size():用字符 c 填充新增空间(默认填\0)- 若 n < 当前size():截断字符串(capacity()不变) |
关键注意点:
- size vs capacity:
size()是 “已使用的字符数”,capacity()是 “底层分配的总空间”(如string s("hello")的size()=5,capacity()可能为 15(VS 下)或 5(GCC 下))。 - reserve 的作用:若提前知道字符串的大致长度(如读取 1000 个字符),先用
reserve(1000)预留空间,可避免频繁扩容(每次扩容需申请新内存、拷贝旧数据、释放旧内存,性能损耗大)。 - resize 与 reserve 的区别:
resize()改变size()(有效字符数),reserve()仅改变capacity()(空间大小)。
代码示例:
void TestStringCapacity() {
string s("hello");
cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 5, 15(VS下)
s.reserve(20); // 预留20个字符空间
cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 5, 20
s.resize(8, 'x'); // 有效字符数改为8,新增3个'x'
cout << "s: " << s << ", size: " << s.size() << endl; // helloxxx, 8
s.clear(); // 清空有效字符
cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 0, 20(capacity不变)
}
3.3 访问与遍历:operator []、迭代器与范围 for
string提供三种遍历方式,可根据场景选择:
3.3.1 operator [](最常用)
operator[]重载了 “下标访问”,支持通过索引获取或修改字符,用法与数组一致。
普通对象:char& operator[](size_t pos)(可修改字符)。
const 对象:const char& operator[](size_t pos) const(仅读取,不可修改)。
代码示例:
void TestStringAccess() {
string s("hello");
// 修改第3个字符(索引从0开始)
s[2] = 'L';
cout << s << endl; // 输出heLlo
// 遍历所有字符
for (size_t i = 0; i < s.size(); ++i) {
cout << s[i] << " "; // 输出h e L l o
}
}
3.3.2 迭代器(iterator)
迭代器是 “连接容器与算法的桥梁”,string的迭代器支持正向、反向遍历,常用接口:
begin():返回指向第一个字符的迭代器。
end():返回指向最后一个字符的下一个位置的迭代器(不指向有效字符)。
rbegin():返回指向最后一个字符的反向迭代器。
rend():返回指向第一个字符的前一个位置的反向迭代器。
代码示例:
void TestStringIterator() {
string s("hello");
// 正向迭代器:遍历并修改
string::iterator it = s.begin();
while (it != s.end()) {
*it -= 32; // 小写转大写
++it;
}
cout << s << endl; // 输出HELLO
// 反向迭代器:从后往前遍历
string::reverse_iterator rit = s.rbegin();
while (rit != s.rend()) {
cout << *rit << " "; // 输出O L L E H
++rit;
}
}
3.3.3 范围 for(最简洁)
如 2.2 节所示,范围 for 是遍历string的最简方式,无需关心索引或迭代器,适合 “遍历所有元素” 的场景。
3.4 修改操作:+=、push_back、find 与 substr
修改操作是string的核心功能,包括尾插、追加、查找、截取等,重点掌握以下接口:
| 接口名 | 功能说明 |
|---|---|
push_back(char c) | 在字符串末尾插入单个字符 c |
operator+=(const string& str) | 在末尾追加字符串(支持string或const char*,最常用) |
c_str() | 返回 C 风格字符串(const char*),用于适配 C 语言接口 |
find(char c, size_t pos=0) | 从 pos 位置开始向后找字符 c,返回其索引;找不到返回string::npos(一个很大的数,代表 “不存在”) |
rfind(char c, size_t pos=npos) | 从 pos 位置开始向前找字符 c,返回其索引;找不到返回string::npos |
substr(size_t pos=0, size_t n=npos) | 从 pos 位置开始截取 n 个字符,返回新string;n 默认取到字符串末尾 |
关键注意点:
- 尾插效率:
push_back(c)、s += c、append(1, c)效率相近,但+=支持追加字符串(如s += "world"),功能更全面,推荐优先使用。 - find 的返回值:
find找不到字符时返回string::npos(本质是static const size_t npos = -1),判断时需用if (s.find(c) == string::npos)。 - substr 的应用:常用于截取子串(如从文件路径中提取文件名、从 URL 中提取参数)。
代码示例:
void TestStringModify() {
string s("hello");
// 尾插与追加
s.push_back(' '); // 插入空格:hello
s += "world"; // 追加字符串:hello world
cout << s << endl; // 输出hello world
// 查找字符
size_t pos = s.find(' '); // 找空格的位置,返回5
if (pos != string::npos) {
// 截取空格后的子串(world)
string sub = s.substr(pos + 1);
cout << sub << endl; // 输出world
}
// 适配C语言接口(如printf)
const char* cstr = s.c_str();
printf("C style: %s\n", cstr); // 输出hello world
}
3.5 非成员函数:输入输出与比较
string的非成员函数主要用于输入、输出和字符串比较,常用接口:
输入输出:operator>>(输入,遇空格 / 换行结束)、operator<<(输出)、getline(cin, s)(读取一行,包括空格)。
比较:operator==、operator!=、operator<等(按 ASCII 码比较,支持string与const char*比较)。
代码示例:
void TestStringIO() {
string s1, s2;
// 用cin>>输入:遇空格结束
cin >> s1; // 若输入"hello world",s1仅为"hello"
cout << "s1: " << s1 << endl;
// 用getline读取一行(需先处理cin>>留下的换行符)
cin.ignore(); // 忽略缓冲区中的换行符
getline(cin, s2); // 读取整行,包括空格
cout << "s2: " << s2 << endl;
// 字符串比较
string s3("apple"), s4("banana");
if (s3 < s4) {
cout << s3 << " < " << s4 << endl; // 输出apple < banana(ASCII码a < b)
}
}
四、string 类的底层实现:VS 与 GCC 的差异
不同编译器对string的底层实现不同,核心差异在于 “内存分配策略”,了解这些差异有助于排查跨平台问题。
4.1 VS 下的 string 结构(32 位平台)
VS 的string采用 “小字符串优化(SSO)” 策略,即:
当字符串长度小于 16时,使用内部固定的 16 字节数组存储(无需申请堆内存,效率高)。
当字符串长度大于等于 16时,从堆上申请内存存储。
VS 的string对象共占28 字节,结构如下:
union _Bxty { // 联合体:要么用固定数组,要么用堆指针
char _Buf[16]; // 小字符串存储(16字节)
char* _Ptr; // 大字符串堆指针
};
class string {
private:
_Bxty _Bx; // 16字节(联合体)
size_t _Mysize; // 4字节(有效字符长度)
size_t _Myres; // 4字节(底层空间大小,不包含\0)
void* _Ptr; // 4字节(其他用途,如调试信息)
};
// 总大小:16 + 4 + 4 + 4 = 28字节
4.2 GCC 下的 string 结构(32 位平台)
GCC 的string采用 “写时拷贝(Copy-On-Write, COW)” 策略,即:
多个string对象共享同一块堆内存,通过 “引用计数” 记录使用者个数。
当某个对象修改字符串时,才会拷贝一份新内存(避免无修改时的冗余拷贝)。
GCC 的string对象仅占4 字节,结构如下:
// 堆内存中的数据结构
struct _Rep_base {
size_t _M_length; // 有效字符长度
size_t _M_capacity; // 底层空间大小
_Atomic_word _M_refcount; // 引用计数(使用者个数)
};
class string {
private:
char* _M_data; // 4字节指针,指向堆内存(_Rep_base + 字符数组)
};
写时拷贝的逻辑:
- 构造时:申请堆内存,引用计数设为 1。
- 拷贝构造时:仅拷贝指针,引用计数 + 1(浅拷贝)。
- 修改时:若引用计数 > 1,先拷贝一份新内存(深拷贝),引用计数调整为 1,再修改新内存。
- 析构时:引用计数 - 1,若计数为 0,释放堆内存。
五、string 类的模拟实现:从浅拷贝到深拷贝
面试中,string的模拟实现是高频考点,核心是实现 “构造、拷贝构造、赋值运算符重载、析构函数”,解决浅拷贝问题。
5.1 问题引入:浅拷贝的缺陷
若仅简单实现string(仅封装char*,未处理拷贝),会导致 “浅拷贝” 问题 —— 多个对象共享同一块内存,析构时重复释放,程序崩溃。
错误示例(浅拷贝):
class String {
public:
// 构造:用C字符串初始化
String(const char* str = "") {
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 析构:释放内存
~String() {
delete[] _str; // 若多个对象共享_str,会重复释放
_str = nullptr;
}
private:
char* _str;
};
void Test() {
String s1("hello");
String s2(s1); // 浅拷贝:s2._str = s1._str(共享内存)
} // 析构时:先析构s2(释放_str),再析构s1(释放已释放的内存,崩溃)
5.2 解决方案:深拷贝
深拷贝的核心是 “每个对象拥有独立的内存”,拷贝时不仅拷贝指针,还拷贝指针指向的内容。以下是两种主流的深拷贝实现方式:
5.2.1 传统版实现(手动申请内存 + 拷贝)
传统版通过 “申请新内存→拷贝内容→释放旧内存” 实现深拷贝,逻辑直观:
class String {
public:
// 构造
String(const char* str = "") {
if (str == nullptr) str = ""; // 处理nullptr
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 拷贝构造(深拷贝)
String(const String& s) {
// 申请与s相同大小的内存
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str); // 拷贝内容
}
// 赋值运算符重载(深拷贝)
String& operator=(const String& s) {
if (this != &s) { // 避免自赋值(s = s)
// 1. 申请新内存
char* tmp = new char[strlen(s._str) + 1];
strcpy(tmp, s._str);
// 2. 释放旧内存
delete[] _str;
// 3. 指向新内存
_str = tmp;
}
return *this; // 支持连续赋值(s1 = s2 = s3)
}
// 析构
~String() {
delete[] _str;
_str = nullptr;
}
private:
char* _str;
};
5.2.2 现代版实现(利用构造函数 + swap)
现代版通过 “临时对象构造 + swap 交换” 简化代码,避免手动申请 / 释放内存,逻辑更优雅:
class String {
public:
// 构造(同传统版)
String(const char* str = "") {
if (str == nullptr) str = "";
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 拷贝构造(现代版)
String(const String& s) : _str(nullptr) {
String tmp(s._str); // 用s的字符串构造临时对象tmp(tmp拥有独立内存)
swap(_str, tmp._str); // 交换this->_str和tmp._str:this指向tmp的内存,tmp指向原this->_str(nullptr)
}
// 赋值运算符重载(现代版1:参数传值,自动拷贝构造临时对象)
String& operator=(String s) { // s是实参的拷贝(深拷贝)
swap(_str, s._str); // 交换后,s指向原this->_str(析构时释放)
return *this;
}
// 赋值运算符重载(现代版2:参数传const引用,手动构造临时对象)
/*
String& operator=(const String& s) {
if (this != &s) {
String tmp(s); // 构造临时对象
swap(_str, tmp._str);
}
return *this;
}
*/
// 析构(同传统版)
~String() {
delete[] _str;
_str = nullptr;
}
private:
char* _str;
};
现代版优势:无需手动申请 / 释放内存,借助临时对象的构造 / 析构自动完成深拷贝,代码更简洁,且避免了自赋值判断(现代版 1)。
六、string 类实战:OJ 例题巩固
掌握string的接口后,通过 OJ 例题实战是巩固知识的最佳方式。以下是 4 道经典例题,覆盖字符串反转、查找、相加等核心场景。
6.1 例题 1:仅反转字母(LeetCode 917)
题目:给定一个字符串,反转其中的字母(非字母字符位置不变)。思路:双指针(左指针从左找字母,右指针从右找字母,交换后移动指针)。代码:
#include <string>
using namespace std;
class Solution {
public:
// 判断是否为字母
bool isLetter(char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
}
string reverseOnlyLetters(string S) {
size_t 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]);
++left;
--right;
}
return S;
}
};
6.2 例题 2:字符串相加(LeetCode 415)
题目:给定两个非负整数的字符串(如 "123"+"45"),返回它们的和(字符串形式)。思路:双指针从后往前加,处理进位,结果尾插后反转(避免头插效率低)。代码:
#include <string>
#include <algorithm>
using namespace std;
class Solution {
public:
string addStrings(string num1, string num2) {
int i = num1.size() - 1, j = num2.size() - 1;
int carry = 0; // 进位
string res;
while (i >= 0 || j >= 0 || carry > 0) {
// 取当前位的值(越界则为0)
int val1 = (i >= 0) ? (num1[i--] - '0') : 0;
int val2 = (j >= 0) ? (num2[j--] - '0') : 0;
int sum = val1 + val2 + carry;
carry = sum / 10; // 更新进位
res += (sum % 10) + '0'; // 尾插当前位(字符形式)
}
reverse(res.begin(), res.end()); // 反转结果(尾插的是逆序)
return res;
}
};
6.3 例题 3:验证回文串(LeetCode 125)
题目:给定一个字符串,验证它是否是回文串(只考虑字母和数字,忽略大小写)。思路:双指针跳过非字母数字,统一大小写后比较。代码:
#include <string>
using namespace std;
class Solution {
public:
// 判断是否为字母或数字
bool isAlphaNum(char ch) {
return (ch >= '0' && ch <= '9')
|| (ch >= 'a' && ch <= 'z')
|| (ch >= 'A' && ch <= 'Z');
}
bool isPalindrome(string s) {
// 统一为小写(或大写)
for (auto& ch : s) {
if (ch >= 'A' && ch <= 'Z') {
ch += 32; // 大写转小写(ASCII差值)
}
}
int left = 0, right = s.size() - 1;
while (left < right) {
// 跳过非字母数字
while (left < right && !isAlphaNum(s[left])) ++left;
while (left < right && !isAlphaNum(s[right])) --right;
if (s[left] != s[right]) {
return false;
}
++left;
--right;
}
return true;
}
};
6.4 例题 4:最后一个单词的长度(LeetCode 58)
题目:给定一个字符串,返回最后一个单词的长度(单词以空格分隔)。思路:从后往前找第一个非空格字符,再找前一个空格,计算长度。代码:
#include <string>
using namespace std;
class Solution {
public:
int lengthOfLastWord(string s) {
// 从后往前跳过空格
int right = s.size() - 1;
while (right >= 0 && s[right] == ' ') {
--right;
}
if (right < 0) return 0; // 全是空格
// 找最后一个单词的左边界
int left = right;
while (left >= 0 && s[left] != ' ') {
--left;
}
return right - left; // 长度 = 右边界 - 左边界
}
};
七、总结:string 类的核心要点与学习建议
string类是 C++ 中最基础、最常用的类之一,其核心价值在于 “封装字符串操作、自动管理内存”。掌握string的关键在于:
1. 核心要点回顾
接口使用:熟练掌握构造、容量(reserve/resize)、修改(+=/find/substr)接口,理解size与capacity的区别。
底层差异:了解 VS 的 “小字符串优化” 和 GCC 的 “写时拷贝”,避免跨平台问题。
模拟实现:掌握深拷贝的两种实现方式(传统版、现代版),理解浅拷贝的缺陷。
实战能力:通过 OJ 例题巩固接口使用,培养字符串处理的思维(如双指针、反转、进位处理)。
2. 学习建议
- 多查文档:
string的接口众多,无需死记硬背,使用时参考 C++ 标准文档(如cppreference)。 - 重视内存:虽然
string自动管理内存,但需注意reserve的使用(避免频繁扩容),以及c_str()的生命周期(不要用已释放string的c_str())。 - 多练实战:字符串是 OJ 高频考点,通过 LeetCode 字符串专题(简单→中等)逐步提升,培养代码熟练度。
1225

被折叠的 条评论
为什么被折叠?



