手撕 C++ string 类:从构造到现代写法
本文基于命名空间
Confidence,逐个接口模拟实现std::string,最后讲解拷贝构造与赋值运算符重载的现代写法。
目录
- [1. 总体浏览](#1. 总体浏览)
- [2. string 类模拟](#2. string 类模拟)
- [2.1 构造函数](#2.1 构造函数)
- [2.2 拷贝构造函数](#2.2 拷贝构造函数)
- [2.3 析构函数](#2.3 析构函数)
- [2.4 赋值运算符重载](#2.4 赋值运算符重载)
- [2.5 短接口合集](#2.5 短接口合集)
- [2.6 迭代器 begin 和 end](#2.6 迭代器 begin 和 end)
- [2.7 扩容的地基 reserve](#2.7 扩容的地基 reserve)
- [2.8 尾插与拼接](#2.8 尾插与拼接)
- [2.9 插入](#2.9 插入)
- [2.10 删除与查找](#2.10 删除与查找)
- [2.11 比较运算符](#2.11 比较运算符)
- [2.12 流运算符重载](#2.12 流运算符重载)
- [3. 拷贝构造与赋值运算符的现代写法](#3. 拷贝构造与赋值运算符的现代写法)
- [3.1 现代写法指的是什么](#3.1 现代写法指的是什么)
- [3.2 旧写法的四个问题](#3.2 旧写法的四个问题)
- [3.3 核心思想](#3.3 核心思想)
- [3.4 swap 成员函数](#3.4 swap 成员函数)
- [3.5 拷贝构造](#3.5 拷贝构造)
- [3.6 赋值运算符](#3.6 赋值运算符)
- [3.7 类内初始化](#3.7 类内初始化)
- [3.8 为什么一次解决四个问题](#3.8 为什么一次解决四个问题)
- [3.9 完整代码](#3.9 完整代码)
- [3.10 逐场景内存图](#3.10 逐场景内存图)
- [3.11 代价与收益](#3.11 代价与收益)
- [3.12 易错点清单](#3.12 易错点清单)
- [3.13 测试代码](#3.13 测试代码)
- [3.14 一句话总结](#3.14 一句话总结)
1. 总体浏览
先看完整代码,后面再逐个接口拆解。
1.1 string.h
cpp
#pragma once
#include <iostream>
#include <assert.h>
#include <cstring>
using namespace std;
namespace Confidence
{
class string
{
public:
// string s("hello world");
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 拷贝构造(现代写法)
string(const string& s)
{
string tmp(s._str);
swap(tmp);
}
// 赋值运算符(现代写法)
string& operator=(string tmp)
{
swap(tmp);
return *this;
}
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
size_t size() const { return _size; }
size_t capacity() const { return _capacity; }
void clear()
{
_str[0] = '\0';
_size = 0;
}
const char* c_str() const { return _str; }
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
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; }
void reserve(size_t n);
void push_back(char ch);
void append(const char* s);
string& operator+=(char s);
string& operator+=(const char* s);
void insert(size_t pos, char ch);
void insert(size_t pos, const char* s);
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 = 0, size_t len = npos);
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
static const size_t npos;
};
bool operator<(const string& s1, const string& s2);
bool operator<=(const string& s1, const string& s2);
bool operator>(const string& s1, const string& s2);
bool operator>=(const string& s1, const string& s2);
bool operator==(const string& s1, const string& s2);
bool operator!=(const string& s1, const string& s2);
ostream& operator<<(ostream& out, const string& s);
istream& operator>>(istream& in, string& s);
}
1.2 string.cpp
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include "string.h"
namespace Confidence
{
const size_t string::npos = -1;
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::push_back(char ch)
{
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
_str[_size++] = ch;
_str[_size] = '\0';
}
void string::append(const char* s)
{
size_t len = strlen(s);
if (_size + len > _capacity)
{
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
strcpy(_str + _size, s);
_size += len;
}
string& string::operator+=(char s)
{
push_back(s);
return *this;
}
string& string::operator+=(const char* s)
{
append(s);
return *this;
}
void string::insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
// '\0' 也要往后移,所以要挪 _size - pos + 1 个
size_t count = _size - pos + 1;
for (size_t i = 0; i < count; i++)
{
_str[_size + 1 - i] = _str[_size - i];
}
_str[pos] = ch;
_size++;
}
void string::insert(size_t pos, const char* s)
{
assert(pos <= _size);
size_t len = strlen(s);
if (_size + len > _capacity)
{
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
size_t count = _size - pos + 1;
for (size_t i = 0; i < count; i++)
{
_str[_size + len - i] = _str[_size - i];
}
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = s[i];
}
_size += len;
}
void string::erase(size_t pos, size_t len)
{
assert(pos < _size);
if (len >= _size - pos)
{
_str[pos] = '\0';
_size = pos;
return;
}
// 按需删除
size_t count = _size - pos - len + 1;
for (size_t i = 0; i < count; i++)
{
_str[pos + i] = _str[pos + len + 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[i] == ch)
{
return i;
}
}
return npos;
}
size_t string::find(const char* str, size_t pos)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);
if (ptr == nullptr)
{
return npos;
}
return ptr - _str;
}
string string::substr(size_t pos, size_t len)
{
assert(pos < _size);
// len 大于剩余字符长度,更新 len
if (len > _size - pos)
{
len = _size - pos;
}
string sub;
sub.reserve(len);
for (size_t i = 0; i < len; i++)
{
sub += _str[pos + i];
}
return sub;
}
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
bool operator<=(const string& s1, const string& s2)
{
return (s1 < s2 || s1 == s2);
}
bool operator>(const string& s1, const string& s2)
{
return !(s1 <= s2);
}
bool operator>=(const string& s1, const string& s2)
{
return !(s1 < s2);
}
bool operator!=(const string& s1, const string& s2)
{
return !(s1 == s2);
}
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
istream& operator>>(istream& in, string& s)
{
s.clear();
const int N = 256;
char buff[N];
int i = 0;
char ch;
ch = in.get();
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
}
这里的
operator>>是教学版,存在 EOF 处理等问题,改进版见 2.12.3。
2. string 类模拟
2.1 构造函数
cpp
#pragma once
#include <iostream>
#include <assert.h>
#include <cstring>
using namespace std;
namespace Confidence
{
class string
{
public:
// string s("hello world");
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
思路
以 string s("hello world") 为例:
- 先用
strlen算出size,才知道要开多大的空间; _size和_capacity都赋成这个值;new一块_capacity + 1的空间;- 用
strcpy拷贝过去。
为什么开 capacity + 1 个空间?
"hello world" 的 size 是 11,但要开 12 个字节,多出来的 1 个给 '\0'。strlen 计算长度时不包含 '\0'。
为什么缺省值是空字符串 ""?
初学者容易这样写无参构造:
cpp
string()
:_str(nullptr)
, _size(0)
, _capacity(0)
{}
看似没问题,但创建 string s 后 _str 是空指针,此时调用 c_str() 再去使用,就会解引用空指针,后续还有很多隐患。标准库的 string 也不是这样设计的。
那就开一个空间存放 '\0':
cpp
string()
:_str(new char[1]{'\0'})
, _size(0)
, _capacity(0)
{}
但再单独写一个无参构造显得麻烦,直接给带参构造一个缺省值即可:
cpp
string(const char* str = "")
这样无参、带参一个函数全搞定。
2.2 拷贝构造函数
传统写法(深拷贝):
cpp
string(const string& str)
{
_size = str._size;
_capacity = str._capacity;
_str = new char[_capacity + 1];
strcpy(_str, str._str);
}
- 浅拷贝:两个指针指向同一块空间,一个修改,另一个也跟着变,析构时还会重复释放。编译器默认生成的拷贝构造就是浅拷贝。
- 深拷贝:再开一块空间,让两个对象各指各的,最后把内容拷贝过去。
现代写法见 [第 3 章](#第 3 章)。
2.3 析构函数
cpp
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
释放 _str,并置空避免野指针,最后把 _size 和 _capacity 清零。
2.4 赋值运算符重载
传统写法:
cpp
string& operator=(const string& str)
{
if (this != &str) // 防止自己给自己赋值
{
delete[] _str;
_size = str._size;
_capacity = str._capacity;
_str = new char[_capacity + 1];
strcpy(_str, str._str);
}
return *this;
}
现代写法见 [第 3 章](#第 3 章)。
2.5 短接口合集:size / capacity / c_str / clear / operator\[\]
cpp
size_t size() const
{
return _size;
}
size_t capacity() const
{
return _capacity;
}
void clear()
{
_str[0] = '\0';
_size = 0;
}
const char* c_str() const
{
return _str;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
| 接口 | 说明 |
|---|---|
size / capacity |
分别返回 _size 和 _capacity |
clear |
只需把首元素置为 '\0',再把 _size 置 0,使用时就是空串 |
c_str |
返回 _str |
operator[] |
返回下标位置的字符。设计两个版本:非 const 版本可修改,const 版本只读 |
2.6 迭代器 begin / end
cpp
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; }
目前可以浅浅地把迭代器理解为指针(实际不完全是,这里先这样看)。
begin返回_str;end返回_str + _size,即最后一个元素的下一个位置 ,因为区间是左闭右开的。
范围 for 的底层就是调用 begin() 和 end(),后面会详细说明,这里先按下不表。
2.7 扩容的地基:reserve
cpp
void string::reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
扩容的流程和拷贝构造类似:
- 按新容量开一块新空间
tmp; - 把旧数据拷贝过去;
- 释放旧空间;
_str指向新空间,更新_capacity。
如果 n 小于 _capacity 会怎样?
上面的实现里直接什么都不做,不会缩容。不同平台的标准库实现有差异:
| 平台 | 行为 |
|---|---|
| Linux(C++20 之前) | 可能触发缩容;扩容为 2 倍 |
| VS | 无论 n 多大都不缩容;扩容约为 1.5 倍 |
2.8 尾插与拼接:push_back / append / +=
cpp
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* s)
{
size_t len = strlen(s);
if (_size + len > _capacity)
{
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
strcpy(_str + _size, s);
_size += len;
}
string& string::operator+=(const char* s)
{
append(s);
return *this;
}
2.8.1 push_back
- 满了就扩容。
_capacity为 0 时先扩到 4(避免 0 的 2 倍还是 0),否则扩为 2 倍; - 在
_size位置写入字符,_size++; - 别忘了 在新的末尾补
'\0'。
2.8.2 append
- 先用
strlen算出要追加的长度len; - 若
_size + len > _capacity需要扩容。扩容大小用三目运算符:如果_size + len比 2 倍容量还大,就直接扩到_size + len,否则 2 倍扩容; strcpy到末尾,更新_size。
2.8.3 operator+=
直接复用 append(字符版复用 push_back),最后返回 *this。
2.9 插入:insert
2.9.1 标准版(end 指针法)
cpp
void string::insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
// end 为 '\0' 的下一个位置,因为 '\0' 也要往后移
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
end--;
}
_str[pos] = ch;
_size++;
}
void string::insert(size_t pos, const char* s)
{
assert(pos <= _size);
size_t len = strlen(s);
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] = s[i];
}
_size += len;
}

以字符串版 insert 为例,理解循环条件:
pos + len - 1是插入内容最后一个字符的位置,这段空位要留出来;- 所以只有
end > pos + len - 1时才继续往后挪; - 挪动方式:从
'\0'开始,每个元素往后跳len步;挪完一个,end--,处理前一个; - 挪完后,循环
len次,从pos开始依次写入新内容。
2.9.2 直白计数版
cpp
void string::insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
size_t count = _size - pos + 1;
for (size_t i = 0; i < count; i++)
{
_str[_size + 1 - i] = _str[_size - i];
}
_str[pos] = ch;
_size++;
}
void string::insert(size_t pos, const char* s)
{
assert(pos <= _size);
size_t len = strlen(s);
if (_size + len > _capacity)
{
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
size_t count = _size - pos + 1;
for (size_t i = 0; i < count; i++)
{
_str[_size + len - i] = _str[_size - i];
}
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = s[i];
}
_size += len;
}
count 就是需要挪动的元素个数,为 _size - pos + 1。
举例:_size = 5,pos = 2,需要挪动 5 - 2 = 3 个有效字符,再加上 '\0',一共 4 个,正好是 5 - 2 + 1。
挪动时从 '\0' 开始,先往后跳 len 个位置存放,再用 -i 逐个往前处理;挪完后,在 pos 位置开始用 +i 写入新内容。
不理解的时候,一定要画图!
2.10 删除与查找:erase / find / substr
2.10.1 erase
cpp
void string::erase(size_t pos, size_t len)
{
assert(pos < _size);
if (len >= _size - pos)
{
_str[pos] = '\0';
_size = pos;
return;
}
// 下面为按需删除
size_t count = _size - pos - len + 1;
for (size_t i = 0; i < count; i++)
{
_str[pos + i] = _str[pos + len + i];
}
_size -= len;
}
情况一:len >= _size - pos
后面的内容全部删除,直接把 pos 位置置为 '\0',并把 _size 更新为 pos。
情况二:只删除一部分
需要把 pos + len 之后的内容整体前移,循环次数为:
count = _size - pos - len + 1
其中 +1 是为了把 '\0' 也一起挪过来。

2.10.2 find
cpp
size_t string::find(char ch, size_t pos)
{
assert(pos < _size);
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return npos;
}
size_t string::find(const char* str, size_t pos)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);
if (ptr == nullptr)
{
return npos;
}
else
{
return ptr - _str;
}
}
- 字符版 :从
pos开始逐个比较,相等返回下标,找不到返回npos。 - 字符串版 :借助
strstr,它在第一个字符串中查找第二个字符串。返回空指针表示没找到;找到了则返回ptr - _str,指针相减就是下标。
2.10.3 substr
cpp
string string::substr(size_t pos, size_t len)
{
assert(pos < _size);
// len 大于剩余字符长度,更新 len
if (len > _size - pos)
{
len = _size - pos;
}
string sub;
sub.reserve(len);
for (size_t i = 0; i < len; i++)
{
sub += _str[pos + i];
}
return sub;
}
- 判断
len是否超过剩余长度,超过就修正; - 给
sub预留len个空间; - 从
pos开始,把字符逐个尾插到sub。
2.11 比较运算符:== != < <= > >=
cpp
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
bool operator<=(const string& s1, const string& s2)
{
return (s1 < s2 || s1 == s2);
}
bool operator>(const string& s1, const string& s2)
{
return !(s1 <= s2);
}
bool operator>=(const string& s1, const string& s2)
{
return !(s1 < s2);
}
bool operator!=(const string& s1, const string& s2)
{
return !(s1 == s2);
}
只需要实现 < 和 == 两个,其余四个都可以由它们组合得到。
2.12 流运算符:<< 和 >>
cpp
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
istream& operator>>(istream& in, string& s)
{
s.clear();
const int N = 256;
char buff[N];
int i = 0;
char ch;
ch = in.get();
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
这两个都是全局函数 ,位于 string 类所在的命名空间内:
operator<<:流插入,用于输出,如cout << soperator>>:流提取,用于输入,如cin >> s
2.12.1 operator<<(输出)
函数签名
| 部分 | 含义 |
|---|---|
ostream& 返回值 |
返回流对象本身,支持链式输出:cout << a << b |
ostream& out |
输出流,用引用,因为流不能拷贝 |
const string& s |
要输出的字符串,const 表示不修改,引用避免拷贝 |
为什么写成全局函数?
cout << s 中,左操作数是 cout(ostream 类型),不是 string。如果写成 string 的成员函数,左操作数只能是 string 对象,调用方式会变成 s << cout,不符合习惯。
函数体
for (auto ch : s):范围for遍历每个字符。要求 string 类提供begin()和end(),否则编译不过。out << ch:ch是char,调用的是标准库的operator<<(ostream&, char),不会递归。
2.12.2 operator>>(输入)
函数签名
| 部分 | 含义 |
|---|---|
istream& 返回值 |
支持链式输入:cin >> a >> b |
istream& in |
输入流,引用传递 |
string& s |
接收数据的字符串,非 const 引用,因为要修改它 |
变量说明
| 变量 | 作用 |
|---|---|
s.clear() |
清空目标字符串,保证是"覆盖"而不是"追加" |
N = 256 |
缓冲区大小 |
buff[N] |
临时缓冲区,攒一批字符再一次性追加到 s |
i |
buff 当前的写入下标 |
ch |
当前读到的字符 |
为什么用缓冲区?
如果每读一个字符就 s += ch,可能频繁触发扩容(申请新空间、拷贝旧数据)。先存入 buff,满了再一次性追加,扩容次数少,效率更高。
为什么用 in.get() 而不是 in >> ch?
| 方式 | 是否跳过空白 |
|---|---|
in >> ch |
会自动跳过空格、换行 |
in.get() |
不跳过,原样读出每个字符 |
要靠空格、换行判断单词结束,所以必须用 get(),否则分隔符被吞掉,循环无法正确结束。
in >> ch调用的是标准库读char的版本,与自己实现的 string 版本互不相干,不会递归。它的问题不在语法,而在行为:会跳过空白。
主循环流程
cpp
ch = in.get(); // 先读第一个字符,作为启动
while (ch != ' ' && ch != '\n') // 不是分隔符就继续
{
buff[i++] = ch; // ① 当前字符放入 buff
if (i == N - 1) // ② buff 满了(留 1 位给 '\0')
{
buff[i] = '\0'; // 补结尾
s += buff; // 追加到 s
i = 0; // 下标归零,下次从头覆盖
}
ch = in.get(); // ③ 读下一个字符(每轮必须执行)
}
节奏是"先用后读 ":进入循环时使用上一次读到的 ch,循环末尾再读下一个。
i = 0 管的是缓冲区 的写入位置,in.get() 管的是输入流 的读取,两者互不相干。ch = in.get() 在 if 外面,每一轮都必须执行,否则 ch 不变会死循环。
循环后的收尾
cpp
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
循环结束时 buff 里可能还有残余字符,在这里补上。i > 0 避免追加空内容。
数据流向
输入流 in --(get 逐字符)--> buff --(+=)--> s
buff 的内容来自输入流,不是来自 s,所以不会出现"自己加自己"的重复问题:
i = 0之后,新字符是覆盖写入旧内容;'\0'决定了每次+=追加的长度,残留的旧数据在'\0'后面,不会被追加。
执行示例
输入 hello world\n:
| 步骤 | ch |
buff / i |
说明 |
|---|---|---|---|
| 1 | h | h / 1 |
|
| 2 | e | he / 2 |
|
| 3 | l | hel / 3 |
|
| 4 | l | hell / 4 |
|
| 5 | o | hello / 5 |
|
| 6 | ' ' |
遇到空格,退出循环 |
收尾:buff = "hello\0",s += buff,最终 s = "hello",world\n 仍留在流中。
长输入示例(300 个字符)
| 阶段 | 追加内容 | s 的 size |
|---|---|---|
开始(clear 后) |
0 | |
第一次 +=(缓冲区满) |
第 1~255 个字符 | 255 |
第二次 +=(收尾) |
第 256~300 个字符 | 300 |
2.12.3 operator>> 的问题与改进
教学版存在以下问题:
| 问题 | 说明 |
|---|---|
| 没有处理 EOF | get() 返回 EOF 时循环永不结束,还会越界写 buff |
char 接收 get() |
get() 返回 int,用 char 会丢失 EOF 信息 |
| 不跳过前导空白 | 标准 cin >> s 会先跳过开头的空白 |
| 只识别空格和换行 | '\t'、'\r' 等没有处理 |
没设置 failbit |
while (cin >> s) 无法正常结束 |
改进版本(需要 <cctype>):
cpp
istream& operator>>(istream& in, string& s)
{
s.clear();
const int N = 256;
char buff[N];
int i = 0;
int ch; // 用 int 接收,才能识别 EOF
// 跳过前导空白
while ((ch = in.get()) != EOF && isspace(ch))
;
// 读取一个单词
while (ch != EOF && !isspace(ch))
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
if (s.empty()) // 什么都没读到,标记失败
in.setstate(ios::failbit);
return in;
}
2.12.4 小结
| 函数 | 作用 | 要点 |
|---|---|---|
operator<< |
把 s 的每个字符输出到流 |
全局函数;依赖 string 提供 begin() / end() |
operator>> |
从流中读取一个单词存入 s |
用 get() 逐字符读;buff 分批追加减少扩容;s.clear() 保证覆盖 |
3. 拷贝构造与赋值运算符的现代写法
目录
- 「现代」指的是什么
- 旧写法的四个问题
- 核心思想:用 swap 换掉一切
- swap 成员函数
- 拷贝构造(借鸡生蛋)
- 赋值运算符(传值 + swap)
- 必须配套:类内初始化
- 为什么它一次解决了四个问题
- 完整代码
- 逐场景内存图
- 代价与收益
- 易错点清单
- 测试代码
- 一句话总结
3.1 「现代」指的是什么
这里的"现代写法"指的是:不再手写"释放旧空间、申请新空间、拷贝数据"的流程,而是借助一个临时对象和 swap,让编译器和析构函数替我们完成资源管理。
3.2 旧写法的四个问题
| # | 问题 | 说明 |
|---|---|---|
| ① | 自赋值 | s = s 时,先 delete[] 再拷贝自己,数据已经没了,所以必须手写 if (this != &str) |
| ② | 内存泄漏 | 释放旧空间全靠手写 delete[],容易漏 |
| ③ | 异常安全 | 先 delete 后 new,中间 new 抛异常,对象就被破坏了 |
| ④ | 隐式转换 | s = "abc" 这类赋值需要额外考虑 |
3.3 核心思想:用 swap 换掉一切
不要亲手删旧资源------把它换到一个临时对象里,让临时对象的析构函数替你收尸。
整套写法由三块组成:
- 一个
swap成员函数; - 拷贝构造:借构造函数造副本,再
swap; - 赋值运算符:参数传值 ,再
swap。
3.4 swap 成员函数
cpp
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
三个成员整体对调,只交换指针和数字,不拷贝字符数据,开销很小。
3.5 拷贝构造(借鸡生蛋)
cpp
string(const string& s)
{
string tmp(s._str); // ① 借用另一个构造函数,造一份副本
swap(tmp); // ② 把副本换过来
} // ③ tmp 出了作用域,析构
为什么可以 string tmp(s._str)?
因为已经有这个构造函数:
cpp
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
s._str 是 char*,正好匹配 const char*。
这就是「借鸡生蛋」:不在拷贝构造里重写一遍分配内存的逻辑,而是借用已经写好的构造函数。
执行过程
初始状态(this 是一个「空壳」,因为类内初始化):
this: _str = nullptr _size = 0 _capacity = 0
s : _str = [hello world...] _size = 11 _capacity = 11
① string tmp(s._str);
this: _str = nullptr _size = 0 _capacity = 0
s : _str = [hello world...] _size = 11 _capacity = 11
tmp : _str = [hello world...] _size = 11 _capacity = 11
↑ 另申请的一块内存(深拷贝)
② swap(tmp);
this: _str = [hello world...] _size = 11 _capacity = 11 ← 好东西归 this
tmp : _str = nullptr _size = 0 _capacity = 0 ← 拿到了 this 的旧值
③ } tmp 析构 ------ delete[] nullptr
标准规定:delete[] nullptr 是【安全无操作】的
关键:delete[] nullptr 必须安全
如果 _str 是未初始化的垃圾值:
this: _str = 0xCDCDCDCDCDCDCDCD(栈上的垃圾)
swap 后 → tmp._str = 0xCDCDCDCDCDCDCDCD
tmp 析构 → delete[] 0xCDCDCDCDCDCDCDCD → 堆损坏 / 崩溃
这就是 3.7 要讲的内容。
3.6 赋值运算符(传值 + swap)
cpp
string& operator=(string tmp) // ★ 参数是【值】,不是引用!
{
swap(tmp);
return *this;
}
| 代码 | 含义 |
|---|---|
string tmp |
参数传值 ,调用时自动用拷贝构造拷一份 |
swap(tmp) |
交换:this 拿到新数据,tmp 拿到 this 的旧数据 |
return *this |
返回引用,支持 a = b = c 链式赋值 |
} |
tmp 出作用域析构,自动释放 this 的旧内存 |
全过程图解(s1 = s3)
s1 : _str = [old old...] ← 旧数据,待会儿要丢掉
s3 : _str = [hello world...]
① 进入函数,参数 tmp 由 s3【拷贝构造】:
s1 : _str = [old old...]
s3 : _str = [hello world...]
tmp: _str = [hello world...] ← 独立内存的副本
② swap(s1, tmp):
s1 : _str = [hello world...] ← 拿到新数据
tmp: _str = [old old...] ← 接管了旧的垃圾
③ return *this(函数结束,tmp 析构):
tmp 析构 → delete[] [old old...] ← 旧内存被自动回收
最终:
s1 : _str = [hello world...]
s3 : _str = [hello world...](不变)
全程没有一行 delete[],也没有一行 if (this != &str)。
3.7 必须配套:类内初始化
cpp
private:
char* _str = nullptr; // ★
size_t _size = 0; // ★
size_t _capacity = 0; // ★
static const size_t npos;
什么是类内初始化
在成员声明处直接给默认值(C++11 特性)。任何构造函数在进入函数体之前,都会先用这些值初始化成员(除非初始化列表里显式写了别的)。
cpp
string(const char* str = "")
{
// 进函数体之前,成员已经自动是 nullptr / 0 / 0 了
_size = strlen(str); // 然后被覆盖
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
这里用的是赋值(不是初始化列表),所以是"先初始化成默认值,再被覆盖",没有冲突。
为什么它是现代写法的必需品
回到拷贝构造:
cpp
string(const string& s)
{
string tmp(s._str);
swap(tmp); // ← 把 this 的【当前值】换给 tmp
}
构造函数里,this 的成员还没被赋值,所以"当前值"就是类内初始化的值。
| 有没有类内初始化 | swap 后 tmp._str |
tmp 析构时 |
|---|---|---|
| ❌ 没有 | 垃圾值(如 0xCDCDCDCD) |
delete[] 垃圾 → 崩溃 |
✅ 有 = nullptr |
nullptr |
delete[] nullptr → 安全 |
结论:「现代写法」和「类内初始化」是一套的。只抄现代写法、不给成员初始化,第一个拷贝构造就可能崩。
3.8 为什么它一次解决了四个问题
问题 ①:自赋值 → 不用判断了
cpp
s = s;
① 参数 tmp 由 s 拷贝构造 → tmp 是 s 的完整副本(独立内存)
s : [hello world] (原内存)
tmp: [hello world] (新内存,副本)
② swap(s, tmp)
s : [hello world] (新内存)
tmp: [hello world] (原内存)
③ tmp 析构 → 释放原内存
s 用的是新内存,完好无损
本质 :「s 自己的旧内存」是被 tmp 带走的。哪怕 s = s,也只是"先复制一份 → 交换 → 让副本把旧的收走",全程没有"自己删自己的内存"这种危险动作。
问题 ②:内存泄漏 → 自动回收
旧写法的 delete[] _str 靠手写;新写法里,旧内存的释放发生在 tmp 的析构函数里,编译器保证一定执行。
这就是 RAII (Resource Acquisition Is Initialization,资源获取即初始化)的思想:把资源的释放交给对象的生命周期,而不是交给你的记性。
问题 ③:异常安全 → 天然具备
cpp
string& operator=(string tmp) // ← 所有「危险操作」都在参数构造阶段
{
swap(tmp); // ← 进入函数体时,已经万事俱备
return *this;
}
危险操作(new 内存)发生在【进入函数体之前】:
① 构造参数 tmp → new char[...] ← 如果内存不足,在这里抛异常
↓
函数体根本没执行,this 一点没被碰过
② 函数体里只有 swap + return ← 不会抛异常
对比旧写法:delete 在前、new 在后,中间抛异常对象就坏了。
问题 ④:s = "abc" → 顺理成章
cpp
s = "abc";
"abc" 是 const char*
↓ 需要转成 string 才能传给参数 string tmp
string(const char*) 不是 explicit → 可以隐式转换
↓
造一个临时 string("abc") → 用它构造出 tmp(可能被编译器优化掉)
↓
swap → 完成
另外,C++11 之后,如果传入的是右值 ,参数 tmp 会用移动构造 创建,于是这一个函数同时兼任拷贝赋值和移动赋值。
3.9 完整代码
cpp
#pragma once
#include <iostream>
#include <assert.h>
#include <cstring>
#include <utility> // std::swap
using namespace std;
namespace Confidence
{
class string
{
public:
// 构造函数(现代写法的「蛋」来自这里)
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 交换:三个成员整体对调
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 拷贝构造(现代写法)
string(const string& s)
{
string tmp(s._str); // 借构造函数造副本
swap(tmp); // 换过来
}
// 赋值运算符(现代写法):参数是【值】
string& operator=(string tmp)
{
swap(tmp);
return *this;
}
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
// ... 其余接口略 ...
private:
char* _str = nullptr; // ★ 现代写法的必需品
size_t _size = 0; // ★
size_t _capacity = 0; // ★
static const size_t npos;
};
}
⚠️ 旧的拷贝构造和旧的
operator=(const string&)一定要删掉,否则:
- 两个拷贝构造签名相同 →
error C2535(重复定义)- 两个赋值运算符 →
error C2668(重载调用不明确)
3.10 逐场景内存图
场景 1:构造
cpp
Confidence::string s("hello");
s._str ──→ [h e l l o \0] _size=5 _capacity=5
场景 2:拷贝构造
cpp
Confidence::string s2(s);
s._str ──→ [h e l l o \0] _size=5 _capacity=5
s2._str ──→ [h e l l o \0] _size=5 _capacity=5
↑ 另一块内存(地址不同 = 深拷贝成功)
场景 3:赋值
cpp
Confidence::string a("aaa"), b("bbbbbbbbbbbb");
a = b;
赋值前:
a._str ──→ [a a a \0] _size=3 _capacity=3
b._str ──→ [b b b b ... \0] _size=12 _capacity=12
operator= 过程:
tmp 拷贝 b → tmp 有了 [b b b b...]
swap(a, tmp) → a 拿到新内容,tmp 拿到 [a a a]
tmp 析构 → delete[] [a a a](a 的旧内存被回收)
赋值后:
a._str ──→ [b b b b ... \0] _size=12 _capacity=12
b._str ──→ [b b b b ... \0] _size=12 _capacity=12
场景 4:自赋值
cpp
a = a;
① tmp 拷贝 a → tmp 有独立的一份 [a a a]
② swap(a, tmp) → a 拿到新内存(内容一样),tmp 拿到 a 原来的内存
③ tmp 析构 → 释放 a 原来的内存
结果:a 内容不变、没崩、没泄漏(不需要 if 判断)
场景 5:链式赋值
cpp
x = y = z;
等价于:x = (y = z)
① y = z → 返回 y 的引用(*this)
② x = (y 的引用) → 用 y 拷一份给 tmp,swap
靠的就是 return *this; 返回引用(不是返回值,否则会多一次拷贝)。
3.11 代价与收益
| 项目 | 说明 |
|---|---|
| 代价:多一次拷贝 | 参数传值带来一次深拷贝 |
| 收益:代码极短 | 拷贝构造、赋值各约 4 行 |
| 收益:四个问题一次解决 | 自赋值、泄漏、异常安全、隐式转换 |
| 收益:一个函数兼两职 | C++11 后,operator=(string tmp) 同时支持拷贝赋值和移动赋值 |
| 收益:与标准库思路一致 | std::string 的实现思路类似 |
3.12 易错点清单
| # | 易错点 | 后果 |
|---|---|---|
| ① | 忘了给成员加类内初始化 = nullptr / = 0 |
拷贝构造里 swap 后 tmp 会 delete 垃圾指针,崩溃 |
| ② | 旧的拷贝构造 / 旧的 operator= 没删 |
C2535(重复定义)/ C2668(重载歧义),编译不过 |
| ③ | operator= 的参数写成 const string& |
退化成旧写法,必须是值 string tmp |
| ④ | swap 里漏掉某个成员 |
只交换一半,两个对象状态错乱 |
| ⑤ | swap 里不写 std:: |
一般也能编过,但依赖 using namespace std;,不规范 |
| ⑥ | 忘了 #include <utility> |
std::swap 找不到 |
| ⑦ | return *this 写成 return this |
返回指针而非引用,编译不过 |
| ⑧ | 自赋值判断没删干净 | 不影响正确性,但说明没理解现代写法 |
3.13 测试代码
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include <cstdio>
#include "string.h"
void TestModern()
{
Confidence::string s1("hello world");
// ① 深拷贝:两个地址必须不同
Confidence::string s2(s1);
printf("s1 = %p\n", s1.c_str());
printf("s2 = %p\n", s2.c_str()); // 应和 s1 不同
cout << "s2 = " << s2 << endl;
// ② 自赋值:不能崩、内容不变(最关键的一组)
s1 = s1;
cout << "s1 = " << s1 << endl;
// ③ 链式赋值
Confidence::string s3, s4;
s3 = s4 = s1;
cout << "s3 = " << s3 << " s4 = " << s4 << endl;
// ④ 字面量赋值(走隐式转换)
s3 = "abc";
cout << "s3 = " << s3 << endl;
// ⑤ 构造 / 赋值来回走几遍
Confidence::string s5 = s1;
s5 = s2;
cout << "s5 = " << s5 << endl;
}
int main()
{
TestModern();
return 0;
}
预期输出(地址每次运行不同):
s1 = 000001F2A3B4C010
s2 = 000001F2A3B4C050 ← 地址不同(深拷贝)
s2 = hello world
s1 = hello world
s3 = hello world s4 = hello world
s3 = abc
s5 = hello world
3.14 一句话总结
现代写法 = ① 一个
swap(三个成员对调)+ ② 拷贝构造借构造函数造副本再换 + ③ 赋值运算符参数传值 + ④ 成员加类内初始化。灵魂:不要亲手删旧资源------把它换到一个临时对象里,让临时对象的析构函数替你收尸。
代价 :多一次拷贝。
回报:自赋值、内存泄漏、异常安全、隐式转换四个问题一次解决,而且代码只有几行。