C++初阶-string类的模拟实现与改进
目录
1.序言
2.std::operator>>(istream& in, string& s)的改进
2.1原始代码
2.2问题分析
2.3最终改进
3.getline(istream& is,string& str,char delim='\n')的模拟实现
4.string(const string& s)的改进(拷贝构造)
4.1原始版本
4.2现代版本
5.string::swap(string& s)的模拟实现
6.string::operator=(const string& s)的改进
6.1原始版本
6.2现代版本
7.*引用计数的写时拷贝(扩展)
(1) 什么是写时拷贝?
(2) 适用场景
(1) 引用计数(Reference Counting)
示例:简单 COW 字符串
(2) 智能指针 + COW
8.总结
1.序言
我们在用之前的代码的时候总是会出现这个或那个限制或问题,所以需要优化代码,提升效率,这一讲将围绕>>运算符、swap、拷贝构造、=运算符重载等部分进行改进或模拟实现,并且会额外介绍一下其余的知识比如:写时拷贝。这讲内容还是比较重要的,所以建议还是看一下。
2.std::operator>>(istream& in, string& s)的改进
2.1原始代码
istream& operator>>(istream& in, string& s)
{char ch;in >> ch;while (ch != ' ' && ch != '\n'){s += ch;in >> ch;}return in;
}
2.2问题分析
用以下代码进行测试
void test1()
{string s;cin >> s;cout << s << endl;string s1;cin >> s1;cout << s1 << endl;
}
如果输入:
那么是不会结束输入的,因为我们那样写,不管怎么样都拿不到空格和换行,因为本身cin和scanf是拿不到空格和换行的!,所以我们不能这样写,要用另外一个函数叫get(),这个函数的解释我们可以通过deepseek来了解:
我们现在只要了解第一个即可,所以我们可以把这个形式改为:
istream& operator>>(istream& in, string& s)
{char ch = in.get();while (ch != ' ' && ch != '\n'){s += ch;ch = in.get();}return in;
}
用刚刚的代码进行测试,则结果为:
第一行为输入的,后面两行是输出的。但是如果我们用+=的方式是会出问题的,只是在这里演示不出来,如果在其他编译器上这样输入,可能会导致输入Hello world结果打印
Hello
Hello world world
了,所以我们要在开始加:s.clear();
所以变成:
istream& operator>>(istream& in, string& s)
{s.clear();char ch = in.get();while (ch != ' ' && ch != '\n'){s += ch;ch = in.get();}return in;
}
2.3最终改进
如果我们一个一个字符加,要不断扩容,有效率的损失,我们可以参考之前的string的优化,加一个buff来进行存储,但是这个buff,我们把buff这个数组的空间开辟成1024个字符即:char buff[1024];因为若提前s.reserve(1024)则可能会有空间的浪费,未知扩容我们最好还是别做;所以最终代码可以改为:
istream& operator>>(istream& in, string& s)
{s.clear();//好处// 输入短串,不会浪费空间// 输入长串,避免不断扩容const size_t N = 1024;char buff[N];int i = 0;char ch = in.get();while (ch != ' ' && ch != '\n'){buff[i++] = ch;if (i == 1023){buff[1023] = '\0';s += buff;i = 0;//这样就不用扩容了}}//可能还有数据没有插入if (i > 0){buff[i] = '\0';s += buff;}return in;
}
3.getline(istream& is,string& str,char delim='\n')的模拟实现
这个实现之前是讲不了的,因为这个函数也是需要用到我刚刚讲过的知识的,这个函数与>>的区别仅仅是遇到delim时就停止输入了,所以我们只要稍微改变一点代码即可:
//.cpp
istream& getline(istream& in, string& s, char delim)
{s.clear();//好处// 输入短串,不会浪费空间// 输入长串,避免不断扩容const size_t N = 1024;char buff[N];int i = 0;char ch = in.get();while (ch != delim){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;
}
这两个函数(>>和getline)我们都可以不置为友元函数。
用以下函数进行测试:
//.cpp
void test1()
{string s;td::getline(cin, s);cout << s << endl;string s1;td::getline(cin, s1, '!');cout << s1 << endl;
}
则运行结果为:
4.string(const string& s)的改进(拷贝构造)
4.1原始版本
//.cpp
string::string(const string& s)
{//我们需要多申请一块空间,因为我们还要存储\0_str = new char[s._capacity + 1];strcpy(_str, s._str);_size = s._size;_capacity = s._capacity;
}
这个拷贝构造是用深拷贝的方式,借用了strcpy函数,俗称传统写法,而现代写法更好一些(里面用了swap函数等下我会实现string类专门的swap函数):
4.2现代版本
//.cpp
//现代版本
string::string(const string& s)
{string tmp(s._str);swap(_str, tmp._str);swap(_size, tmp. _size);swap(_capacity, tmp._capacity);
}
这种方法没有提高效率,只是创建了一个临时对象,由于通过tmp来进行交换,所以tmp的改变不会影响s,但是如果我们直接string tmp(s)则会造成死循环!
如果我们用之后实现的swap那么就可以直接这样写:
//.cpp
//现代版本(改进)
string::string(const string& s)
{string tmp(s._str);swap(tmp);
}
我们要使用的时候可以直接string s;string s1(s);即可。
5.string::swap(string& s)的模拟实现
这个东西的实现如果直接创建三个临时变量(_str,_capcity,_size的类型),这样写法太麻烦了,而且我们实现的时候可以用库里面的swap,不过一定要指定类域,否则会直接从该类域找函数了,这样会导致参数不匹配而报错!
//.h
void swap(string& s);
//.cpp
void string::swap(string& s)
{std::swap(_str, s._str);std::swap(_size, s._size);std::swap(_capacity, s._capacity);
}
6.string::operator=(const string& s)的改进
我们也可以根据拷贝构造的改进写出赋值运算符重载函数的改进:
6.1原始版本
//.cpp
//原始版本
string& string::operator=(const string& s)
{//开辟新空间,释放旧空间delete[] _str;_str = new char[s._capacity + 1];strcpy(_str, s._str);_size = s._size;_capacity = s._capacity;return *this;
}
6.2现代版本
//.cpp
//现代版本
string& string::operator=(const string& s)
{//开辟新空间,释放旧空间if (this != &s){string tmp(s._str);swap(tmp);}return *this;
}
当然还有第二种方式:
//.cpp
//现代版本(2)
string& string::operator=(string s)
{swap(s);return *this;
}
因为s是形参,不是实参的别名,故可以直接交换,建议用第二种写法。但是一定不要把第二种写法的形参改为引用了!
7.*引用计数的写时拷贝(扩展)
这里涉及到的内容非常多,到deepseek的搜索结果后可以跳过到总结部分。
我们先看引用计数,这个的意思是有几个对象指向一块相同的空间就用引用计数,也就是说假设s1、s2都是s3的别名,那么这个就是s3的引用计数就是2,这样拷贝对象的代价就低很多了,因为不用开辟空间拷贝数据了,而且析构时也不是直接delete[]了,而是看是否有另外一个对象指向这块空间了,若s1、s2指向同一块空间,若s2先析构,则引用计数--,如果引用计数不是0,则代表还有对象指向这个空间,则这个时候不能释放该空间。这种方式保证了不会一块空间被释放多次的问题了!
但是这样还会存在一个对象修改还会影响另外一个的问题,如果s1修改了,那s2也修改了,我们不希望这样的情况发生,这个就需要写时拷贝,假设我要把s1修改,然后要看引用计数,如果引用计数为1,则能直接修;否则s1改变,我就需要拷贝,把引用计数--后再进行深拷贝。写时拷贝就是谁写谁先深拷贝再修改,本质就是延迟拷贝。但是这样很麻烦了。
因此这种方式本质上是一种赌博,只要对象不修改就赚大了(拷贝后不进行修改就赚了),因为若编译器未在拷贝构造时优化,比如我们string func(){string ret="abcdef";return ret;}再用string s = func();那么就会有三次拷贝,这个之前说过(讲类的构造的时候,之前写过博客);如果编译器没有优化,那么这种引用计数的写时拷贝就赚麻了,因为它不一定会修改的,就算要修改要深拷贝,代价也差不多。
在string部分,这个技术已经被淘汰了,但是其他的地方可能还会用,比如Liunx、智能指针里面会用到!
如果想要详细了解这个部分的话我们可以看一下Deepseek的搜索结果:
写时拷贝(Copy-On-Write, COW)是一种延迟拷贝的优化技术,在对象被多个引用共享时,只有在真正需要修改数据时才进行实际的拷贝。这种技术广泛应用于字符串、智能指针、文件系统等场景,以减少不必要的内存复制。
(1) 什么是写时拷贝?
-
默认共享:多个对象引用同一块内存(避免立即拷贝)。
-
修改时分离:当某个对象尝试修改数据时,才进行真正的拷贝(深拷贝)。
-
节省资源:减少不必要的内存复制,提高性能。
(2) 适用场景
-
不可变数据(如字符串、只读文件)。
-
多引用共享(如
std::shared_ptr
的引用计数)。 -
大对象优化(避免频繁深拷贝)。
实现方式
(1) 引用计数(Reference Counting)
-
每个对象维护一个引用计数器,记录有多少个引用指向它。
-
引用增加:计数器
+1
。 -
引用减少:计数器
-1
,如果count == 0
,释放内存。 -
修改时检查:如果
count > 1
,先拷贝再修改。
示例:简单 COW 字符串
#include <iostream>
#include <cstring>class CowString {
private:char* data; // 实际字符串数据int* ref_count; // 引用计数器public:// 构造函数CowString(const char* str = "") {data = new char[strlen(str) + 1];strcpy(data, str);ref_count = new int(1); // 初始引用计数=1}// 拷贝构造函数(共享数据,不拷贝)CowString(const CowString& other) : data(other.data), ref_count(other.ref_count) {(*ref_count)++; // 引用计数+1}// 写时拷贝:修改数据时才真正拷贝char& operator[](size_t index) {if (*ref_count > 1) { // 如果被共享,先拷贝char* new_data = new char[strlen(data) + 1];strcpy(new_data, data);(*ref_count)--; // 旧数据引用-1data = new_data; // 指向新数据ref_count = new int(1); // 新数据引用=1}return data[index]; // 返回可修改的引用}// 析构函数~CowString() {if (--(*ref_count) == 0) { // 引用计数归零时释放delete[] data;delete ref_count;}}// 打印字符串和引用计数void print() const {std::cout << "String: " << data << " (Refs: " << *ref_count << ")" << std::endl;}
};int main() {CowString s1 = "Hello"; // s1 引用计数=1CowString s2 = s1; // s2 和 s1 共享数据,引用计数=2s1.print(); // String: Hello (Refs: 2)s2.print(); // String: Hello (Refs: 2)s2[0] = 'J'; // 写时拷贝!s2 现在独立,s1 仍指向原数据s1.print(); // String: Hello (Refs: 1)s2.print(); // String: Jello (Refs: 1)return 0;
}
输出:
String: Hello (Refs: 2)
String: Hello (Refs: 2)
String: Hello (Refs: 1)
String: Jello (Refs: 1)
(2) 智能指针 + COW
std::shared_ptr
本身已经实现了引用计数,可以结合 COW 优化:
#include <memory>
#include <iostream>class BigObject {
public:BigObject() { std::cout << "BigObject created\n"; }~BigObject() { std::cout << "BigObject destroyed\n"; }void modify() { std::cout << "BigObject modified\n"; }
};int main() {std::shared_ptr<BigObject> ptr1 = std::make_shared<BigObject>();std::shared_ptr<BigObject> ptr2 = ptr1; // 共享,引用计数=2if (!ptr1.unique()) { // 检查是否唯一引用ptr1 = std::make_shared<BigObject>(*ptr1); // 写时拷贝}ptr1->modify(); // 现在 ptr1 是独立的return 0;
}
也可以进入以下链接去看内容:
https://coolshell.cn/articles/12199.html
8.总结
该讲主要是把一些补充的知识添加了一下,真正讲解的不是很多,到此,所有关于string类的内容已经讲解完全了,下一讲内容是vector,需要到下一周去了,之后可能会把一些string类的练习题的博客发出来,这个是不确定时间的!
喜欢的可以一键三连哦!下讲再见!