1. 为什么 string 值得单独讲
C++ 标准库中的 std::string 是日常开发里使用频率最高的容器之一。它本质上是对字符序列的封装,但比 C 风格字符串多了自动内存管理、长度记录、丰富的成员函数以及和 STL 算法良好的配合能力。
学 STL 时习惯从 vector 开始,其实 string 更适合作为第一个"手写容器"的练手对象:它结构简单、接口直观,同时又完整覆盖了构造、拷贝、扩容、迭代器、运算符重载等核心知识点。把 string 吃透,再去看 vector 会轻松很多。
本文围绕四个关键词展开:auto 与范围 for 、常用接口 、模拟实现 、浅拷贝与深拷贝(含写时拷贝)。
2. auto 与范围 for:让遍历更简洁
2.1 auto 的基本用法
auto 让编译器在编译期根据初始化表达式推导变量类型,减少冗长的类型书写。对于 string 的迭代器尤其有用,因为迭代器类型名往往又长又难写。
cpp
#include <iostream>
#include <string>
using namespace std;
//auto + 范围for
void func1(auto e) //auto 不能作函数参数
{
cout << "e"<< endl;
}
auto func2(int a) //auto 能作函数返回值,但是需要谨慎使用,比如想要知道返回的参数类型是啥,但是这个函数里面继续套娃其他函数,所以要谨慎使用
{
cout << a << endl;
}
int main()
{
int a = 0;
auto b = a;
double c = 1.0;
auto d = c;
cout << typeid(b).name() << endl;
cout << typeid(d).name() << endl;
auto a = 1, b = 2.0;//同一行的auto推导类型,参数必须是同一类型
auto _a[] = { 1,2,3,4 };//auto 不能用来推导数组元素类型
auto e;//必须进行初始化;
return 0;
}
int main() {
string s = "hello";
// 传统写法:类型名冗长
string::iterator it1 = s.begin();
// auto 写法:类型由编译器推导
auto it2 = s.begin();
cout << *it1 << " " << *it2 << endl; // h h
return 0;
}
需要注意,auto 推导时会丢弃顶层 const 和引用 。如果希望拿到引用,必须显式写 auto& 或 const auto&。
2.2 范围 for 的语法与本质(自动赋值,自动迭代)
范围 for(range-based for)的语法形式是 for (声明 : 表达式),编译器会把它展开成基于 begin() 和 end() 的迭代器循环。
cpp
#include <iostream>
#include <string>
using namespace std;
int main() {
string s = "hello";
// 只读遍历:推荐 const auto&,避免拷贝
for (const auto& ch : s) {
cout << ch << ' ';
}
cout << endl;
return 0;
}
这里有一个非常容易踩的坑:如果写成 for (auto ch : s),ch 是每个字符的拷贝,修改它不会影响原字符串。想修改元素,一定要写 auto&。
2.3 三种写法对比
| 写法 | 是否拷贝 | 能否修改原串 | 适用场景 |
|---|---|---|---|
auto ch |
拷贝 | 否 | 字符很小,只读且不在意拷贝 |
auto& ch |
不拷贝 | 是 | 需要修改字符 |
const auto& ch |
不拷贝 | 否 | 只读遍历,最推荐 |
小结:只读用
const auto&,要改用auto&,尽量别用auto值拷贝。
3. string 的常用接口
3.1 构造与赋值
cpp
#include <string>
using namespace std;
int main() {
string s1; // 空串
string s2("hello"); // 由 C 字符串构造
string s3(s2); // 拷贝构造
string s4(5, 'a'); // "aaaaa"
string s5(s2, 1, 3); // 从下标 1 起取 3 个字符:"ell"
string s6 = s2 + " world"; // 拼接
s1 = s2; // 赋值
s1.assign("abc"); // 重新赋值
return 0;
}
3.2 容量相关接口
| 接口 | 作用 |
|---|---|
size() / length() |
返回字符个数,二者等价 |
capacity() |
返回当前已分配空间能容纳的字符数,注意大小不包含'\0' |
empty() |
判断是否为空 |
clear() |
清空内容,不释放容量 |
reserve(n) |
预分配至少 n 的空间,避免频繁扩容(n<size,不会改变现有内容,不会缩小;n>capacity,扩容;size<n<capacity,行为不确定) |
resize(n, ch) |
把有效字符数改为 n,多出的位置用 ch 填充 |
cpp
#include <iostream>
#include <string>
using namespace std;
int main() {
string s = "hello";
cout << s.size() << " " << s.capacity() << endl;
s.reserve(100); // 提前扩容,避免多次 reallocate
cout << s.capacity() << endl; // 至少 100
s.resize(8, 'x'); // "helloxxx"
cout << s << endl;
s.clear(); // 内容清空,容量保留
cout << s.size() << " " << s.capacity() << endl;
return 0;
}
size 与 capacity 的区别 :size 是"已经用了多少",capacity 是"最多能装多少而不重新分配"。reserve 只改 capacity,resize 改 size。
3.3 访问与修改
cpp
#include <iostream>
#include <string>
using namespace std;
int main() {
string s = "hello";
cout << s[0] << endl; // 不检查越界
cout << s.at(1) << endl; // 越界抛 out_of_range
cout << s.front() << endl; // 首字符
cout << s.back() << endl; // 尾字符
s[0] = 'H'; // 修改
s.push_back('!'); // 尾插字符
s.append(" ok"); // 尾插字符串
s += " fine"; // 运算符重载
s.insert(0, ">> "); // 指定位置插入
s.erase(0, 3); // 删除 [0,3) 区间
cout << s << endl;
return 0;
}
3.4 查找与子串
cpp
#include <iostream>
#include <string>
using namespace std;
int main() {
string s = "hello world, hello cpp";
size_t pos = s.find("hello"); // 首次出现位置
cout << pos << endl; // 0
pos = s.find("hello", 1); // 从下标 1 开始找
cout << pos << endl; // 13
pos = s.rfind("hello"); // 最后一次出现
cout << pos << endl; // 13
cout << s.find("C") << endl; // string::npos
string sub = s.substr(6, 5); // 从 6 开始取 5 个
cout << sub << endl; // world
return 0;
}
注意 find 找不到时返回 string::npos,它是一个很大的无符号数,判断时不要写成 if (pos > 0),而应写 if (pos != string::npos)。
3.5 迭代器接口
4. string 的模拟实现
4.1 类的基本框架
模拟实现的核心是三个成员:字符指针 _str、有效长度 _size、容量 _capacity。为了和标准库区分,这里命名为 String。
这里 _capacity 表示"能存的有效字符数",实际申请空间是 _capacity + 1,多出的 1 个字节留给 '\0'。
4.2 扩容与尾插
扩容策略采用"二倍扩容",这是均摊 O(1) 的关键。注意扩容后必须手动补 '\0',否则 c_str() 会越界读取。
4.3 运算符重载
提供 const 和非 const 两个版本的 operator[],是为了让 const 对象也能下标访问,同时保证 const 对象不能被修改。
4.4 迭代器
string 的迭代器本质上就是原生指针,这也是为什么范围 for 能直接作用于它。
String.h
#pragma once
#include<iostream>
#include<assert.h>
using namespace std;
class String
{
public:
typedef char* iterator;
typedef const char* const_iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
String(const char* str = "") //注意·中间不能有空格
{
_size = _capacity = strlen(str);
_str = new char[_capacity + 1];
strcpy(_str, str);
}
void swap(String& s2)
{
std::swap(_str, s2._str);
std::swap(_size, s2._size);
std::swap(_capacity, s2._capacity);
}
String& operator=(String s) //这里要是使用const String s,就涉及权限放大问题
{
swap(s);
return *this;
}
String(const String& s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
~String()
{
if (_str)
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
}
void reserve(size_t n);
void insert(size_t pos, char ch);
void insert(size_t pos, const char* str);
void push_back(char ch);
void append(const char* str);
String& operator+=(char ch);
String& operator+=(const char* str);
void erase(size_t pos, size_t len = npos);
size_t find(char ch, size_t pos = 0);
size_t find(const char* str, size_t pos = 0);
String substr(size_t pos, size_t len);
bool operator<(const String& s2);
bool operator>(const String& s2);
bool operator<=(const String& s2);
bool operator>=(const String& s2);
bool operator!=(const String& s2);
bool operator==(const String& s2);
private:
char* _str;
size_t _size;
size_t _capacity;
static size_t npos;
};
size_t String::npos = -1;
ostream& operator<<(ostream& out, const String& s);
cpp
String.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include"String.h"
using namespace std;
void String::reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void String::insert(size_t pos, char ch)
{
assert(pos < _size);
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
size_t end = _size;
while (end > pos)
{
_str[end] = _str[end-1];
--end;
}
_str[pos] = ch;
_str[++_size] = '\0'; //!!!
}
void String::insert(size_t pos, const char* str)
{
assert(pos < _size);
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
for (size_t i = 0;i < len;i++)
{
_str[pos+i] = *(str + i);
}
_size += len;
_str[_size] = '\0';
}
void String::push_back(char ch)
{
if (_size == _capacity)
reserve(_capacity == 0 ? 4 : 2 * _capacity);
_str[_size++] = ch;
_str[_size] = '\0';
}
void String::append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
for (size_t i = 0;i < len;i++)
{
push_back(*(str + i));
}
}
String& String::operator+=(char ch)
{
push_back(ch);
return *this;
}
String& String::operator+=(const char* str)
{
append(str);
return *this;
}
void String::erase(size_t pos, size_t len)// 默认参数在定义时,不需要重复给,否则会出现参数重定义
{
assert(pos < _size);
if (len >= _size - pos)
{
pos = _size;
_str[_size] = '\0';
}
else
{
for (int i = pos+len;i < _size;i++)
{
_str[i - len] = _str[i];
}
_size -= len;
}
}
size_t String::find(char ch, size_t pos)
{
assert(pos < _size);
for (size_t i = pos;i < _size;i++)
{
if (_str[pos] == ch)
return i;
}
return npos;
}
size_t String::find(const char* str, size_t pos)
{
assert(pos < _size);
char* ptr = strstr(_str+pos, str);
if (ptr == nullptr)
return npos;
else
return ptr - _str;
}
String String::substr(size_t pos, size_t len)
{
assert(pos < _size);
if (len > _size - pos)
len = _size - pos;
String sub;
sub.reserve(len);
strcpy(sub._str, _str + pos);
sub._size = len;
return sub;
}
bool String::operator<(const String& s2)
{
return strcmp(_str, s2._str) < 0;
}
bool String::operator>(const String& s2)
{
return !(*this <= s2);
}
bool String::operator<=(const String& s2)
{
return (*this < s2) || (*this == s2);
}
bool String::operator>=(const String& s2)
{
return !(*this < s2);
}
bool String::operator!=(const String& s2)
{
return !(*this == s2);
}
bool String::operator==(const String& s2)
{
return strcmp(_str, s2._str) == 0;
}
ostream& operator<<(ostream& out,const String& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
int main()
{
String s;
String s1("hello");
String s2;
s2 = s1;
s2.insert(2, 'x');//hexllo
cout << s2 << endl;
s2.append("aaa");//hexlloaaa
cout << s2 << endl;
s2.erase(3, 1);//hexloaaa
cout << s2 << endl;
String s3 = s2.substr(2, 8);
if (s3 == s2)
cout << "s3 == s2" << endl;
else
cout << "s3 != s2" << endl;// s3 != s2
cout << s3 << endl;//xlloaaa
return 0;
}
5. 浅拷贝与深拷贝
5.1 默认拷贝构造的问题
如果类里含有指针成员,而你没有自己写拷贝构造函数,编译器生成的默认版本会做逐字节的值拷贝 ,也就是把指针地址原样复制过去。这就是浅拷贝。
cpp
MyString s1("hello");
MyString s2(s1); // 浅拷贝:s1._str 和 s2._str 指向同一块内存
浅拷贝会带来两个致命问题:
- 重复释放 :两个对象析构时都会
delete[]同一块内存,程序崩溃。 - 相互影响:修改 s2 的内容会连带改变 s1,违背值语义。
5.2 深拷贝的实现
深拷贝的思路是:为新对象单独申请一块内存,再把原内容复制过去,两个对象各自持有独立空间。
cpp
// 传统写法
MyString(const MyString& s)
: _size(s._size), _capacity(s._capacity) {
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
MyString& operator=(const MyString& s) {
if (this != &s) { // 防止自赋值
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str; // 释放旧空间
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
赋值运算符里先申请新空间、再释放旧空间,是为了防止 new 抛异常时对象处于半销毁状态。
5.3 现代写法:拷贝并交换
cpp
void swap(MyString& s) {
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
MyString(const MyString& s)
: _str(nullptr), _size(0), _capacity(0) {
MyString tmp(s._str); // 复用构造函数
swap(tmp);
}
MyString& operator=(MyString s) { // 传值,天然得到一份拷贝
swap(s);
return *this;
}
这种写法把"拷贝"和"释放旧资源"交给临时对象完成,代码更短,异常安全性也更好,是 C++ 中推荐的惯用法。
5.4 浅拷贝与深拷贝对比
| 对比项 | 浅拷贝 | 深拷贝 |
|---|---|---|
| 内存关系 | 多个对象共享同一块堆内存 | 每个对象独立申请内存 |
| 析构风险 | 重复释放,程序崩溃 | 各自释放,安全 |
| 修改影响 | 互相影响 | 互不影响 |
| 实现成本 | 编译器默认生成 | 需手写拷贝构造与赋值 |
6. 写时拷贝(Copy-On-Write)
6.1 基本思想
深拷贝每次拷贝都要复制整块内存,如果拷贝后只读不写,这份开销就浪费了。写时拷贝 的思路是:拷贝时只复制指针,并维护一个引用计数;只有当某个对象要修改内容时,才真正为它复制一份独立内存。
6.2 优缺点分析
- 优点:只读场景下拷贝代价极低,节省内存。
- 缺点 :引用计数需要额外空间;多线程下计数需要原子操作,否则有竞态;
operator[]等接口需要区分读写,实现复杂度上升。
补充:C++11 之后,标准库实现普遍放弃了 string 的写时拷贝(早期 libstdc++ 曾使用),转而采用 SSO(短字符串优化)加移动语义。原因是 COW 在多线程下容易出问题,且与迭代器失效规则冲突。写时拷贝如今更多作为理解"引用计数 + 延迟复制"思想的教学案例。
7. 常见坑与最佳实践
- 范围 for 修改元素必须用
auto&,只读推荐const auto&。 find的返回值要和string::npos比较,不要和 0 比较。- 已知大致长度时先
reserve,避免多次扩容。 c_str()返回的指针不要跨修改保存。- 含指针成员的类,务必遵守"三法则":需要自定义析构函数时,通常也需要自定义拷贝构造和拷贝赋值。
- 优先使用拷贝并交换实现赋值,代码简洁且异常安全。
8. 总结
本文从 auto 与范围 for 的遍历技巧出发,梳理了 string 在构造、容量、访问、查找、迭代器等方面的常用接口,并手写了一个简化版 MyString。在此基础上重点讨论了浅拷贝导致的重复释放问题、深拷贝的两种实现方式,以及写时拷贝的引用计数思想。