C++ string类详解:从使用到模拟实现
C++ string类详解:从使用到模拟实现
一、前言
在C语言里,操作字符串是一件让人头疼的事。你得手动管理内存,小心翼翼地调用strcpy、strcat、strlen这些函数,稍不留神就可能越界访问或者内存泄漏。更别提这些函数和字符串本身是分离的,用起来一点也不面向对象。
C++引入了string类,把字符串的存储、操作、内存管理全部封装好了。你只需要#include <string>,就能用一套简洁优雅的接口来处理字符串。
这篇文章会带你全面了解string类,从基本用法到模拟实现,再到经典OJ题目。学完之后,你对string的理解会上一个台阶。
二、为什么需要string类?
2.1 C语言字符串的痛点
C语言中,字符串是以'\0'结尾的字符数组。标准库提供了一些str系列的函数来操作字符串:
char str1[20] = "hello";
char str2[20];
strcpy(str2, str1); // 拷贝
strcat(str1, " world"); // 追加
int len = strlen(str1); // 求长度
看起来还行,但实际用起来有几个大问题:
- 底层空间需要用户自己管理。你得确保目标数组足够大,否则
strcpy、strcat都会越界。忘了分配或者分配不够,程序直接崩。 - 函数和字符串分离。
strlen、strcpy这些函数不是字符串"自己的方法",而是外部函数,不符合面向对象的思想。 - 容易出错。C字符串没有边界检查,越界访问是家常便饭。
2.2 string类的优势
string类解决了上面所有问题:
- 自动管理内存,不需要手动分配和释放
- 提供了丰富的成员函数,操作字符串就像操作普通变量一样自然
- 有边界检查,安全性更高
- 支持
+、==、<<、>>等运算符,使用极其方便
在OJ刷题和实际开发中,string类几乎是处理字符串的首选。C库的字符串函数基本可以放一边了。
三、两个C++11小语法:auto和范围for
在正式讲string之前,先补两个C++11的语法糖,后面会频繁用到。
3.1 auto关键字
C++11给auto赋予了全新的含义:它不再表示"自动存储期",而是作为一个类型指示符,告诉编译器"根据初始化表达式自动推导变量的类型"。
int a = 10;
auto b = a; // b的类型推导为int
auto c = 'a'; // c的类型推导为char
auto d = 3.14; // d的类型推导为double
使用auto的注意事项:
- auto定义的变量必须有初始值,否则编译器无法推导类型:
auto e; // 编译错误!无法推导e的类型
- 用
auto声明指针时,auto和auto*没有区别:
int x = 10;
auto y = &x; // y是int*
auto* z = &x; // z也是int*,和上面等价
- 声明引用时必须加
&:
auto m = x; // m是int,是x的一份拷贝
auto& n = x; // n是int&,是x的引用
- 同一行声明多个变量时,类型必须一致:
auto aa = 1, bb = 2; // OK,都是int
auto cc = 3, dd = 4.0; // 编译错误!int和double不一致
auto不能做函数参数,不能声明数组:
void func(auto a) {} // 编译错误
auto arr[] = {1, 2, 3}; // 编译错误
auto真正好用的场景是类型特别长的时候:
#include <map>
#include <string>
using namespace std;
map<string, string> dict = {{"apple", "苹果"}, {"orange", "橙子"}};
// 不用auto,类型名长得离谱
map<string, string>::iterator it = dict.begin();
// 用auto,简洁多了
auto it2 = dict.begin();
3.2 范围for循环
C++11引入了范围for(也叫for-each),用来遍历有范围的集合:
int array[] = {1, 2, 3, 4, 5};
// C++98的写法
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
{
cout << array[i] << " ";
}
// C++11范围for,简洁得多
for (auto e : array)
{
cout << e << " ";
}
范围for的语法是for (变量 : 集合),编译器会自动迭代、自动取数据、自动判断结束。
如果要修改元素,用引用:
// 修改数组中每个元素,乘以2
for (auto& e : array)
{
e *= 2;
}
// 只读遍历,用值即可
for (auto e : array)
{
cout << e << " ";
}
范围for可以作用于数组和所有STL容器。底层其实就是用迭代器实现的,只是写法更简洁。
string str("hello world");
// 用范围for遍历string的每个字符
for (auto ch : str)
{
cout << ch << " "; // 输出: h e l l o w o r l d
}
四、string类的常用接口
string类的接口非常多,这里只讲最常用的那些。用到了再查文档就行。
4.1 构造函数
| 构造函数 | 功能说明 |
|---|---|
string() |
构造空字符串 |
string(const char* s) |
用C字符串构造 |
string(size_t n, char c) |
构造包含n个字符c的字符串 |
string(const string& s) |
拷贝构造 |
string s1; // 空字符串
string s2("hello bit"); // 用C字符串构造
string s3(10, 'a'); // "aaaaaaaaaa"
string s4(s2); // 拷贝构造,s4和s2一样
4.2 容量操作
| 函数 | 功能说明 |
|---|---|
size() |
返回有效字符长度 |
length() |
返回有效字符长度(和size一样) |
capacity() |
返回底层空间总大小 |
empty() |
检测是否为空串 |
clear() |
清空有效字符 |
reserve(n) |
预留空间(不改变有效字符个数) |
resize(n) |
改变有效字符个数为n |
几个需要注意的点:
-
size()和length()底层实现完全相同。引入size()是为了和其他STL容器接口保持一致。实际开发中基本都用size()。 -
clear()只清空有效字符,不改变底层空间大小:
string s("hello world");
cout << s.size() << endl; // 11
cout << s.capacity() << endl; // 15(或更大,取决于实现)
s.clear();
cout << s.size() << endl; // 0
cout << s.capacity() << endl; // 15,容量没变
resize改变元素个数时,如果增大可能会改变底层容量:
string s("hello");
s.resize(10, 'x'); // 扩大到10个字符,多出的用'x'填充
// s = "helloxxxxx"
s.resize(3); // 缩小到3个字符
// s = "hel",底层容量不变
reserve只预留空间,不改变有效字符个数,且当参数小于当前容量时不会缩容:
string s;
s.reserve(100); // 预留100个字符的空间
cout << s.size() << endl; // 0,有效字符没变
cout << s.capacity() << endl; // >=100
s.reserve(50); // 50 < 100,什么也不做
cout << s.capacity() << endl; // 还是>=100
实用建议:如果你能预估字符串大概要多长,先reserve预留空间,可以减少多次扩容带来的开销。
4.3 元素访问和遍历
有三种方式访问string中的字符:
方式1:operator[] (最常用)
string s("hello");
cout << s[0] << endl; // 'h'
cout << s[4] << endl; // 'o'
// 修改字符
s[0] = 'H';
cout << s << endl; // "Hello"
operator[]有两种重载:普通版本返回char&(可读可写),const版本返回const char&(只读)。
方式2:迭代器
string s("hello");
// 正向迭代器
string::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
// 反向迭代器
string::reverse_iterator rit = s.rbegin();
while (rit != s.rend())
{
cout << *rit << " ";
++rit;
}
迭代器是STL的通用遍历方式,所有容器都支持。begin()指向第一个元素,end()指向最后一个元素的下一个位置。
方式3:范围for (最简洁)
string s("hello");
// 只读遍历
for (auto ch : s)
{
cout << ch << " ";
}
// 修改每个字符
for (auto& ch : s)
{
ch = toupper(ch); // 转大写
}
cout << s << endl; // "HELLO"
4.4 修改操作
| 函数 | 功能说明 |
|---|---|
push_back(c) |
尾插一个字符 |
append(str) |
尾插一个字符串 |
operator+=(str) |
尾插字符串或字符 |
c_str() |
返回C格式的字符串 |
find(str, pos) |
从pos位置向后查找,返回位置 |
rfind(str) |
从后向前查找,返回位置 |
substr(pos, n) |
从pos位置截取n个字符 |
追加字符串的三种方式:
string s("hello");
s.push_back('!'); // "hello!"
s.append(" world"); // "hello! world"
s += " C++"; // "hello! world C++"
这三种方式效果差不多,但实际开发中+=用得最多,因为它既能追加字符,也能追加字符串,写法最简洁。
查找和截取:
string s("hello world hello");
// find:从前往后找,返回第一个匹配的位置
size_t pos = s.find("world");
if (pos != string::npos) // npos是string的一个静态常量,表示"没找到"
{
cout << "找到world,位置:" << pos << endl; // 位置:6
}
// rfind:从后往前找
size_t rpos = s.rfind("hello");
cout << "最后一个hello的位置:" << rpos << endl; // 位置:12
// substr:截取子串
string sub = s.substr(6, 5); // 从位置6开始截取5个字符
cout << sub << endl; // "world"
find找不到时返回string::npos,这是一个size_t类型的最大值,通常用!= string::npos来判断是否找到。
c_str的使用场景:
有些C语言的接口只接受const char*参数,这时候需要用c_str():
string filename("test.txt");
// C语言的文件操作接口需要const char*
FILE* fp = fopen(filename.c_str(), "r");
4.5 非成员函数
| 函数 | 说明 |
|---|---|
operator+ |
拼接字符串(尽量少用,效率低) |
operator>> |
输入运算符(遇空格结束) |
operator<< |
输出运算符 |
getline |
读取一整行(包括空格) |
关系运算符 |
==、!=、<、>等 |
输入的区别:
string s1, s2;
cin >> s1; // 遇到空格就停止,"hello world"只读到"hello"
getline(cin, s2); // 读取整行,包括空格,"hello world"完整读取
cin >>遇到空格、制表符、换行都会停止。如果你想读取包含空格的一整行内容,用getline。
operator+为什么尽量少用?
因为+返回的是一个新的string对象,涉及深拷贝,效率较低。能用+=就用+=:
string s = "hello";
s = s + " world"; // 创建临时对象,深拷贝,效率低
s += " world"; // 直接在原对象上追加,效率高
五、string的底层结构
了解string的底层结构有助于理解它的行为。不同编译器的实现方式不同,这里介绍两种主流实现。
5.1 VS下的string结构
VS的string总共占28个字节(32位平台),内部包含:
- 一个16字节的联合体(小字符串存储区 或 堆指针)
- 一个
size_t字段保存字符串长度(4字节) - 一个
size_t字段保存堆空间总容量(4字节) - 一个指针做其他事情(4字节)
关键在于那个联合体:
union _Bxty
{
char _Buf[16]; // 小字符串直接存在这里
char* _Ptr; // 大字符串用指针指向堆空间
} _Bx;
当字符串长度小于16时,直接存在内部的_Buf数组里,不需要去堆上分配内存,效率很高。当长度大于等于16时,才在堆上分配空间,_Ptr指向那块内存。
这种设计很聪明——大多数字符串都比较短,直接用内部缓冲区就搞定了,省去了堆分配的开销。
5.2 g++下的string结构
g++的实现完全不同,string对象只占4个字节,内部只有一个指针,指向堆上的一块空间。那块空间里存放了:
struct _Rep_base
{
size_t _M_length; // 字符串有效长度
size_t _M_capacity; // 空间总大小
int _M_refcount; // 引用计数
};
g++采用了写时拷贝(Copy-on-Write)技术。拷贝string时不会立即复制数据,而是共享同一块内存,引用计数加1。只有当某个对象试图修改字符串内容时,才会真正复制一份独立的数据。
这种方式的优点是拷贝开销小,缺点是修改时可能触发额外的拷贝,而且在多线程环境下引用计数的维护比较复杂。现代g++版本已经逐渐放弃了写时拷贝。
六、string类的模拟实现
面试中经常被要求手写string类。这里从浅拷贝的问题讲起,逐步实现一个完整的string。
6.1 浅拷贝的陷阱
先看一个"有问题"的String类:
class String
{
public:
String(const char* str = "")
{
if (nullptr == str)
{
assert(false);
return;
}
_str = new char[strlen(str) + 1]; // +1留给'\0'
strcpy(_str, str);
}
~String()
{
if (_str)
{
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
void TestString()
{
String s1("hello");
String s2(s1); // 拷贝构造
}
问题出在哪?这个类没有自己定义拷贝构造函数,编译器会生成一个默认的——浅拷贝。浅拷贝只是把s1._str的值(指针)复制给s2._str,结果两个对象指向同一块堆内存。
当TestString结束时,s2先析构,释放了那块内存。然后s1析构,又试图释放同一块内存——同一块空间被释放两次,程序崩溃。
打个比方:两个孩子共用一个玩具,一个孩子把玩具摔坏了,另一个孩子就没得玩了。
6.2 深拷贝:传统写法
解决办法就是深拷贝——每个对象都有自己独立的一份资源:
class String
{
public:
// 构造函数
String(const char* str = "")
{
if (nullptr == str)
{
assert(false);
return;
}
_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) // 防止自己给自己赋值
{
// 先开新空间,再释放旧空间,避免异常安全问题
char* pStr = new char[strlen(s._str) + 1];
strcpy(pStr, s._str);
delete[] _str;
_str = pStr;
}
return *this;
}
// 析构函数
~String()
{
if (_str)
{
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
赋值运算符的实现中,先开辟新空间再释放旧空间,这是为了防止new失败时原来的数据也丢了。这种写法安全但稍显啰嗦。
6.3 深拷贝:现代写法
现代C++有一种更简洁的写法,利用了拷贝构造和swap:
class String
{
public:
String(const char* str = "")
{
if (nullptr == str)
{
assert(false);
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 拷贝构造:先构造一个临时对象,再交换
String(const String& s)
: _str(nullptr) // 重要!初始化为nullptr
{
String strTmp(s._str); // 用s的字符串构造临时对象
swap(_str, strTmp._str); // 交换指针,临时对象析构时释放旧资源
}
// 赋值运算符:参数传值,自动调用拷贝构造
String& operator=(String s) // 注意:参数是值,不是引用
{
swap(_str, s._str); // 交换this和s的资源
return *this; // s析构时自动释放this原来的资源
}
~String()
{
if (_str)
{
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
现代写法的精髓在于:
- 拷贝构造:先构造一个临时对象(临时对象有自己的独立空间),然后用
swap把临时对象的指针和当前对象的指针交换。临时对象析构时,自动释放当前对象原来的资源。 - 赋值运算符:参数按值传递,调用拷贝构造创建了一个局部副本
s,然后swap交换指针。函数结束时s析构,自动释放this原来的资源。
代码更简洁,而且天然处理了自赋值的情况(自己给自己赋值时,swap之后还是自己)。
6.4 写时拷贝(了解)
写时拷贝是g++早期采用的策略。核心思想是:拷贝时不立即复制数据,而是共享同一块内存,用一个引用计数记录有多少对象共享这块资源。
- 构造时,引用计数为1
- 拷贝时,引用计数+1,不复制数据
- 析构时,引用计数-1;如果减到0,才真正释放资源
- 修改时,如果引用计数>1,先复制一份独立的数据,再修改
这种方式的优点是拷贝开销极低(只复制指针和计数),缺点是每次修改都要检查引用计数,多线程下还要加锁,反而可能降低性能。现代STL实现已经很少用写时拷贝了。
七、经典OJ题目实战
7.1 仅仅反转字母
给定一个字符串,只反转其中的字母,其他字符保持原位。
思路:双指针,一个从头往后找字母,一个从后往前找字母,找到后交换。
class Solution {
public:
// 判断是否是字母
bool isLetter(char ch)
{
return (ch >= 'a' && ch <= 'z')
|| (ch >= 'A' && ch <= 'Z');
}
string reverseOnlyLetters(string S)
{
if (S.empty())
return S;
int begin = 0, end = S.size() - 1;
while (begin < end)
{
// begin跳过非字母字符
while (begin < end && !isLetter(S[begin]))
++begin;
// end跳过非字母字符
while (begin < end && !isLetter(S[end]))
--end;
// 交换两个字母
swap(S[begin], S[end]);
++begin;
--end;
}
return S;
}
};
7.2 找字符串中第一个只出现一次的字符
思路:用数组统计每个字符出现的次数,然后遍历字符串找第一个次数为1的字符。
class Solution {
public:
int firstUniqChar(string s)
{
// 用数组统计每个字符出现次数,ASCII字符共256个
int count[256] = {0};
int size = s.size();
// 第一遍:统计次数
for (int i = 0; i < size; ++i)
count[s[i]] += 1;
// 第二遍:找第一个只出现一次的字符
for (int i = 0; i < size; ++i)
if (1 == count[s[i]])
return i;
return -1; // 没找到
}
};
时间复杂度O(n),空间复杂度O(1)(固定大小的数组)。这里用数组下标对应字符的ASCII值,是一种常见的哈希思想。
7.3 字符串最后一个单词的长度
从标准输入读取一行,输出最后一个单词的长度。
#include <iostream>
#include <string>
using namespace std;
int main()
{
string line;
// 用getline读取整行,不能用cin>>(遇空格就停了)
while (getline(cin, line))
{
// rfind从后往前找空格
size_t pos = line.rfind(' ');
// 最后一个单词的长度 = 总长度 - 空格位置 - 1
cout << line.size() - pos - 1 << endl;
}
return 0;
}
rfind(' ')返回最后一个空格的位置。如果字符串是"hello world",rfind返回5,长度就是11 - 5 - 1 = 5,正好是"world"的长度。
7.4 验证回文串
只考虑字母和数字,忽略大小写,验证字符串是否是回文。
class Solution {
public:
// 判断字符是否是字母或数字
bool isLetterOrNumber(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; // 小写转大写
}
int begin = 0, end = s.size() - 1;
while (begin < end)
{
// 跳过非字母数字字符
while (begin < end && !isLetterOrNumber(s[begin]))
++begin;
while (begin < end && !isLetterOrNumber(s[end]))
--end;
if (s[begin] != s[end])
return false;
++begin;
--end;
}
return true;
}
};
还是双指针的套路,一个从前往后,一个从后往前,跳过无关字符后比较。
7.5 字符串相加
给定两个用字符串表示的非负整数,计算它们的和。
思路:从后往前逐位相加,处理进位,最后反转结果。
class Solution {
public:
string addStrings(string num1, string num2)
{
int end1 = num1.size() - 1;
int end2 = num2.size() - 1;
int next = 0; // 进位
string addret;
while (end1 >= 0 || end2 >= 0)
{
// 取当前位的数字,如果已经遍历完就取0
int value1 = (end1 >= 0) ? num1[end1--] - '0' : 0;
int value2 = (end2 >= 0) ? num2[end2--] - '0' : 0;
int valueRet = value1 + value2 + next;
// 处理进位
if (valueRet > 9)
{
next = 1;
valueRet -= 10;
}
else
{
next = 0;
}
// 把当前位的结果追加到字符串
addret += (valueRet + '0');
}
// 处理最后的进位
if (next == 1)
addret += '1';
// 因为我们是从低位到高位追加的,需要反转
reverse(addret.begin(), addret.end());
return addret;
}
};
这里用+=尾插再reverse的方式,比用insert头插效率高得多。insert在头部插入需要移动所有后续元素,时间复杂度O(n),而+=尾插是均摊O(1)。
八、总结
这篇文章从使用到原理,系统地讲解了C++的string类:
核心知识点回顾:
- C语言字符串的缺陷催生了
string类,它封装了内存管理,提供了丰富的操作接口 auto和范围for是C++11的语法糖,能让代码更简洁string的常用接口包括构造、容量管理、元素访问、修改操作、查找截取等- 不同编译器下
string的底层结构不同,VS用了小缓冲区优化,g++早期用写时拷贝 - 模拟实现
string的关键是正确处理深拷贝,现代写法利用swap更简洁 - OJ题目中双指针、哈希统计是处理字符串的常用技巧
使用建议:
- 优先使用
+=而不是+来拼接字符串 - 能预估长度时先用
reserve预留空间 - 读取含空格的整行用
getline,不要用cin >> - 查找时注意判断
npos
string是C++中最常用的类之一,熟练掌握它的用法对日常开发和刷题都很有帮助。多写多练,遇到不会的接口随时查文档,慢慢就熟了。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)