重生之从0开始学习c++之string(下)

手撕 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") 为例:

  1. 先用 strlen 算出 size,才知道要开多大的空间;
  2. _size 和 _capacity 都赋成这个值;
  3. new 一块 _capacity + 1 的空间;
  4. 用 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;
	}
}

扩容的流程和拷贝构造类似:

  1. 按新容量开一块新空间 tmp;
  2. 把旧数据拷贝过去;
  3. 释放旧空间;
  4. _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

  1. 满了就扩容。_capacity 为 0 时先扩到 4(避免 0 的 2 倍还是 0),否则扩为 2 倍;
  2. 在 _size 位置写入字符,_size++;
  3. 别忘了 在新的末尾补 '\0'。

2.8.2 append

  1. 先用 strlen 算出要追加的长度 len;
  2. 若 _size + len > _capacity 需要扩容。扩容大小用三目运算符:如果 _size + len 比 2 倍容量还大,就直接扩到 _size + len,否则 2 倍扩容;
  3. 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;
}
  1. 判断 len 是否超过剩余长度,超过就修正;
  2. 给 sub 预留 len 个空间;
  3. 从 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 << s
  • operator>>:流提取,用于输入,如 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. 拷贝构造与赋值运算符的现代写法

目录

  1. 「现代」指的是什么
  2. 旧写法的四个问题
  3. 核心思想:用 swap 换掉一切
  4. swap 成员函数
  5. 拷贝构造(借鸡生蛋)
  6. 赋值运算符(传值 + swap)
  7. 必须配套:类内初始化
  8. 为什么它一次解决了四个问题
  9. 完整代码
  10. 逐场景内存图
  11. 代价与收益
  12. 易错点清单
  13. 测试代码
  14. 一句话总结

3.1 「现代」指的是什么

这里的"现代写法"指的是:不再手写"释放旧空间、申请新空间、拷贝数据"的流程,而是借助一个临时对象和 swap,让编译器和析构函数替我们完成资源管理。

3.2 旧写法的四个问题

# 问题 说明
① 自赋值 s = s 时,先 delete[] 再拷贝自己,数据已经没了,所以必须手写 if (this != &str)
② 内存泄漏 释放旧空间全靠手写 delete[],容易漏
③ 异常安全 先 delete 后 new,中间 new 抛异常,对象就被破坏了
④ 隐式转换 s = "abc" 这类赋值需要额外考虑

3.3 核心思想:用 swap 换掉一切

不要亲手删旧资源------把它换到一个临时对象里,让临时对象的析构函数替你收尸。

整套写法由三块组成:

  1. 一个 swap 成员函数;
  2. 拷贝构造:借构造函数造副本,再 swap;
  3. 赋值运算符:参数传值 ,再 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(三个成员对调)+ ② 拷贝构造借构造函数造副本再换 + ③ 赋值运算符参数传值 + ④ 成员加类内初始化。

灵魂:不要亲手删旧资源------把它换到一个临时对象里,让临时对象的析构函数替你收尸。

代价 :多一次拷贝。

回报:自赋值、内存泄漏、异常安全、隐式转换四个问题一次解决,而且代码只有几行。

相关推荐
kmomo..1 小时前
Linux 驱动开发学习笔记:UART、触摸屏与 ADC 驱动实战
linux·驱动开发·学习
youdexiang2 小时前
如何建立个人学习资料库?
学习
人工智能培训2 小时前
具身智能环境交互与学习范式原理
大数据·linux·人工智能·学习·算法·机器学习
Tj8105232 小时前
企业培训如何真正落地:从组织能力建设到培训ROI评估的完整指南
学习·学习方法
~kiss~2 小时前
LangGraph 和 ToT,Tree of Thoughts 同一步骤多条路线并行探索,用LLM做评判和减枝
学习
wuminyu2 小时前
Continuation::freeze与thaw的x86_64汇编Stack Chunk拆解源码分析
java·linux·c语言·汇编·jvm·c++
kunicoding2 小时前
C++ 策略模式实战:写一个可以“一键换输出“的日志系统
开发语言·c++·策略模式
xiaoye-duck2 小时前
《C++ 项目:手写大模型接入 SDK》项目背景、目标及名词解释
服务器·网络·c++·语言模型
艾莉丝努力练剑2 小时前
【AI大模型接入SDK】ChatServer接口设计规范
网络·c++·人工智能·学习·大模型·设计规范