跳到主要内容C++ string 类入门与实现详解 | 极客日志C++AIjava
C++ string 类入门与实现详解
围绕 C++ 标准库 string 的常用构造、容量管理、遍历、修改与非成员函数做了系统梳理,并结合 auto、范围 for、getline、find、substr 等接口说明实际用法。文章还分析了 string 底层实现差异、浅拷贝与深拷贝问题,以及面试中自定义 String 类的正确资源管理写法,帮助读者从“会用”走到“理解为什么这样设计”。
wanderer0 浏览 一、为什么学习 string 类
C 语言里的字符串,很多人第一次学的时候都会碰到同样几个问题:长度要自己算,结尾要自己盯着,稍不注意就越界。std::string 正是为了解决这些麻烦而生的。它把'字符串管理'这件事包装成了更安全、更顺手的对象,也让后面的字符串处理、容器使用、面试题都变得更好写。
二、C++ 标准库中的 string 类
使用 string 之前,记得包含头文件并引入标准命名空间:
#include <iostream>
#include <string>
using namespace std;
2.1 auto 和范围 for
auto 的作用很直接:让编译器根据初始值自动推导类型,但变量必须有初始值。
auto x = 10;
auto y = 3.14;
auto ptr = &x;
如果是引用,写法要特别留心。auto 默认会丢掉引用属性,想保留引用,必须显式写成 auto&。
int x = 10;
int& ref = x;
auto c = ref;
auto& d = ref;
c = 20;
d = 30;
同一行声明多个变量时,编译器会按同一种推导结果处理,所以类型不能乱混。
auto a = 10, b = 20;
auto m = 5, *p = &m;
auto n = 5, q = 3.14;
auto 不能直接用来声明数组,也不适合作为普通函数参数类型;函数返回值虽然可以用,但要注意返回分支的类型一致。
范围 for 是 C++11 之后非常顺手的遍历方式,尤其适合数组和容器:
int arr[] = {1, 2, 3, 4, 5};
for (int x : arr) {
cout << x << ' ';
}
for (auto& e : arr) {
e *= 2;
}
string str("hello world");
for (auto ch : str) {
cout << ch << ' ';
}
2.2 string 类的常用接口
1)常用构造
| 构造函数 | 说明 |
|---|
string() | 构造空字符串 |
string(const char* s) | 用 C 字符串构造 |
string(size_t n, char c) | 构造包含 n 个字符 c 的字符串 |
string(const string& s) | 拷贝构造 |
#include <iostream>
#include <string>
using namespace std;
int main() {
string s1;
cout << "s1: [" << s1 << "]" << endl;
string s2("Hello World");
cout << "s2: " << s2 << endl;
string s3(10, '*');
cout << "s3: " << s3 << endl;
string s4(s2);
cout << "s4: " << s4 << endl;
return 0;
}
2)容量相关操作
| 函数 | 说明 |
|---|
size() | 返回有效字符个数 |
length() | 返回有效字符个数 |
capacity() | 返回当前底层容量 |
empty() | 判断是否为空 |
clear() | 清空有效字符 |
reserve() | 预留空间 |
resize() | 调整有效字符个数,不足部分用指定字符填充 |
size() 和 length() 本质上是同一件事,只是命名习惯不同。size() 更符合其他容器的接口风格。
string s = "hello";
cout << s.size() << endl;
cout << s.length() << endl;
clear() 只是清空内容,不一定会把底层空间一并释放。resize() 则会直接改变有效字符数量:变长时会补字符,变短时会截断。
string s = "hello";
s.resize(10, 'x');
cout << s << endl;
s.resize(3);
cout << s << endl;
reserve() 的重点是提前申请空间,减少后续扩容次数,但它不会改变当前字符串长度。
string s = "hel";
s.reserve(100);
cout << s.size() << endl;
3)访问与遍历
| 函数/方式 | 说明 |
|---|
operator[] | 通过下标访问字符 |
begin() / end() | 正向迭代 |
rbegin() / rend() | 反向迭代 |
| 范围 for | 更简洁的遍历方式 |
#include <iostream>
#include <string>
using namespace std;
int main() {
string s = "Hello";
cout << s[1] << endl;
s[0] = 'h';
for (string::iterator it = s.begin(); it != s.end(); ++it) {
cout << *it << ' ';
}
cout << endl;
for (string::reverse_iterator rit = s.rbegin(); rit != s.rend(); ++rit) {
cout << *rit << ' ';
}
cout << endl;
for (char c : s) {
cout << c << '-';
}
cout << endl;
return 0;
}
iterator 适合正向遍历,能读能改
const_iterator 只能读
reverse_iterator 适合倒序遍历,能读能改
const_reverse_iterator 只能读
4)修改操作
| 函数 | 说明 |
|---|
push_back() | 尾插单个字符 |
append() | 追加字符串 |
operator+= | 追加字符或字符串 |
c_str() | 返回 C 风格字符串 |
find() | 查找字符或子串 |
rfind() | 反向查找 |
substr() | 截取子串 |
#include <iostream>
using namespace std;
int main() {
string s = "Hello";
s.push_back('!');
s.append(" Welcome");
s += " to C++";
const char* cStr = s.c_str();
cout << cStr << endl;
size_t pos = s.find("Welcome");
if (pos != string::npos) {
cout << "Found at: " << pos << endl;
}
size_t last_space = s.rfind(' ');
cout << "Last space: " << last_space << endl;
string sub = s.substr(7, 7);
cout << sub << endl;
return 0;
}
char c = '!';
s.push_back(c);
s.append(1, c);
s += c;
s += " Hello";
如果能提前预估字符串会增长到多大,reserve() 很值得用。它不是'手动管理内存',而是在告诉 string:后面大概率会长,先把坑位留出来。
5)非成员函数
| 函数 | 说明 |
|---|
operator+ | 字符串拼接,但频繁使用可能带来额外拷贝 |
operator>> | 输入字符串 |
operator<< | 输出字符串 |
getline() | 读取整行字符串 |
| 关系运算符 | 按字典序比较字符串 |
string full_name;
getline(cin, full_name);
cout << "你好, " << full_name << endl;
string str1 = "apple";
string str2 = "banana";
if (str1 < str2) {
cout << "apple 在字典序上排在 banana 前面" << endl;
}
6)不同编译器下 string 的结构差异
string 的底层实现并不是标准强制规定的,不同编译器会有不同策略。你在学习时更应该关注设计思想,而不是死记具体布局。
有的实现会使用短字符串优化:短串直接放在对象内部,长串再去堆上开空间;有的早期实现还会引入写时拷贝。理解这些差异,能帮助你解释为什么 reserve()、拷贝构造、赋值运算符这些接口会有不同的性能表现。
2.3 string 类的模拟实现
面试里常见的题目之一,就是让你手写一个简化版 String。重点不是把所有接口都做出来,而是把资源管理做对:构造、拷贝构造、赋值运算符重载、析构函数,这四个环节不能出错。
class String {
public:
String(const char* str = "") {
if (nullptr == str) {
assert(false);
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
~String() {
if (_str) {
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
void TestString() {
String s1("hello bit!!!");
String s2(s1);
}
这段代码的问题在于:你没有自己定义拷贝构造函数。编译器生成的默认版本只会做浅拷贝,也就是简单复制指针值。于是 s1 和 s2 指向同一块堆内存,函数结束时就会发生二次释放,程序崩溃。
修正方法很直接:自己实现深拷贝,让每个对象都拥有独立资源。
String(const String& s) {
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
1)构造函数的缺省值要谨慎
String(const char* str = nullptr)
原因很简单:构造函数内部通常会调用 strlen(str)。而 strlen(nullptr) 会直接触发非法访问。
String(const char* str = "\0")
这也不够理想。空字符串字面量本身就已经带有结束符了,额外写一个 "\0",很容易让人误解成'默认内容是一个空字符'。如果目标是空串,最自然的默认值仍然是 ""。
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;
};
现代写法则更偏向'拷贝并交换'思路,代码更稳,也更容易写对:
class String {
public:
String(const char* str = "") {
if (str != nullptr) {
_str = new char[strlen(str) + 1];
strcpy(_str, str);
} else {
assert(false);
return;
}
}
String(const String& s)
: _str(nullptr) {
String tmp(s._str);
swap(_str, tmp._str);
}
String& operator=(String s) {
std::swap(_str, s._str);
return *this;
}
~String() {
if (_str) {
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
这里的关键点是:先构造临时对象,再交换资源。这样即使中间发生异常,当前对象也更容易保持在可控状态。
3)写时拷贝
写时拷贝(COW)是一种很经典的延迟复制策略:读的时候大家共用一份数据,写的时候才真正复制。
它的好处是省内存、减少不必要的拷贝;缺点也很明显,一旦触发写操作,开销可能会突然变大。在系统、数据库、容器和某些并发容器里,都能看到它的影子。
不过放到现代 std::string 里,是否采用 COW 已经不是你背结论的重点了。更重要的是理解这种思想,以及它为什么适合'读多写少'的场景。
2.4 面试中 string 的正确写法
面试里真正希望你写出来的,通常不是完整的 std::string,而是一个能正确管理资源的字符串类。它至少要满足几件事:
- 能像普通变量一样定义、复制和赋值
- 能作为函数参数和返回值
- 能在标准容器里正常使用
这些要求背后,其实都在考察一件事:你是否掌握了资源管理的基本原则,尤其是拷贝控制。
2.5 STL 中的 string 类
STL 里的 string 并不是'一个简单的字符数组包装'。它在接口设计、性能优化、异常安全等方面都做了大量工程化处理。很多看起来平平无奇的成员函数,背后都对应着非常实际的工程取舍。
结尾
学 string 最怕的不是接口多,而是只会背函数名,不知道它为什么这么设计。真正把它吃透,后面无论是字符串处理、容器使用,还是模拟实现一个自己的 String,思路都会顺很多。资源怎么管、拷贝怎么做、什么时候该预留空间,这些问题一旦建立起直觉,C++ 的很多基础难点都会慢慢变得清晰。
相关免费在线工具
- RSA密钥对生成器
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- Mermaid 预览与可视化编辑
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online