【C++】经典string类问题
目录
1. 浅拷贝
2. 深拷贝
3. string类传统写法
4. string类现代版写法
5. 自定义类实现swap成员函数
6. 标准库swap函数的调用
7. 引用计数和写时拷贝
1. 浅拷贝
若string类没有显示定义拷贝构造函数与赋值运算符重载,编译器会自动生成默认的,编译器生成的默认版本只会简单的复制指针地址,当用s1构造s2时,s1的_str指针存了"hello"的地址,拷贝给s2后,两者都指向同一块内存,这种拷贝方式叫做浅拷贝。当s1和s2先后析构时,这块内存会被delete两次,一旦其中一个对象释放了这块内存,_str所指的空间被释放掉,另一个对象的_str指针就会变成野指针,再次释放就会导致程序崩溃。
2. 深拷贝
要避免浅拷贝问题,就需要自己实现深拷贝,让s2有独立的内存复制s1的内容,每个对象都有自己独立的内存空间,这样析构时各删各的就不会出问题。
//拷贝构造函数(实现深拷贝)
string(const string& s)
{_str = new char[s._capacity + 1]; //新开辟内存strcpy(_str, s._str); //复制内容_size = s._size;_capacity = s._capacity;
}
如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显示给出来。
3. string类传统写法
class string
{
public://默认构造函数string(const char* str=""){if (str == nullptr) //strlen(nullptr)会触发未定义行为可能导致程序崩溃{str = ""; //将nullptr转为空字符串}_size = strlen(str);_str = new char[_size + 1];_capacity = _size;strcpy(_str, str);}//拷贝构造函数(深拷贝)string(const string& s):_str(new char[s._capacity + 1]), _size(s._size), _capacity(s._capacity){ strcpy(_str, s._str); //拷贝字符串内容 }//赋值运算符重载(深拷贝)string& operator=(const string& s){if (this != &s)//防止自己给自己赋值{delete[] _str;_str = new char[s._capacity + 1];strcpy(_str, s._str);_size = s._size;_capacity = s._capacity;return *this;}}//析构函数~string(){delete[] _str;_str = nullptr;_size = _capacity = 0;}private:char* _str = nullptr;size_t _size = 0;size_t _capacity = 0;
};
4. string类现代版写法
class string
{
public://默认构造函数string(const char* str=""){if (str == nullptr){str = ""; //将nullptr转为空字符串}_size = strlen(str);_str = new char[_size + 1];_capacity = _size;strcpy(_str, str);}//swap成员函数void swap(string& s){std::swap(_str, s._str);std::swap(_size, s._size);std::swap(_capacity, s._capacity);}//拷贝构造函数优化 s2 = s1string(const string& s) //没有在成员初始化列表进行显式初始化时,使用了默认成员初始化器,防止默认初始化成随机值。{string tmp(s._str); //用s的字符串数据创建局部对象tmp 这里也可以调用拷贝构造string tmp(s);swap(tmp); //交换当前对象s2和tmp tmp出了作用域调用析构函数销毁}赋值运算符重载优化 s2 = s1//string& operator=(const string& s)//{ // string tmp(s._str);// swap(tmp); // return *this;//}//赋值运算符重载再优化 s2 = s1string& operator=(string tmp) //传值传参触发拷贝构造!使用s1构造局部对象tmp{swap(tmp); //交换当前对象s2和tmp return *this;}//析构函数~string(){delete[] _str;_str = nullptr;_size = _capacity = 0;}private:char* _str = nullptr;size_t _size = 0;size_t _capacity = 0;
};
5. 自定义类实现swap成员函数
当自定义string类实现了swap成员函数,执行 std::swap(s1,s2) 时会通过模版机制自动转发到swap成员函数即s1.swap(s2),完成高效交换(交换内部指针,长度等,避免深拷贝),实现高效交换。如果自定义类中不实现成员swap,std::swap会走模版逻辑:
- C++98:T c(a); a=b; b=c; 走“1次拷贝构造+2次拷贝赋值”的逻辑,3次深拷贝开销。
- C++11:T c(std::move(a)); a=std::move(b); b=std::move(c);移动构造+两次移动赋值,依赖类的移动语义。
std::swap是一个模版函数,它的标准实现大致如下:
namespace std {// 默认模板:通过三次赋值实现交换(对于无swap成员函数的类类型交换:走深拷贝 对于内置类型的交换:无性能损耗)template <class T>void swap(T& a, T& b) {T temp = std::move(a);a = std::move(b);b = std::move(temp);}// 特化模板:若类型T存在swap成员函数,则调用该成员函数template <class T>void swap(T& a, T& b, std::enable_if_t<std::is_class_v<T> && {a.swap(b); //……检测T是否有swap成员函数,如果有就调用它。} }
这个模版的特别之处在于:它会自动检测类型T是否存在swap成员函数,若类型T未定义swap成员函数,std::swap会使用默认模版逻辑。
注意:std::swap模版的核心机制是严格匹配成员函数名swap,若成员函数名叫其它名字(如my_swap)无法转发调用成员函数,只能走模版逻辑。
总结:所以,只要类有swap成员函数,调用std::swap(s1,s2)最终会转调s1.swap(s2)。
//swap成员函数void swap(string& s){std::swap(_str, s._str);std::swap(_size, s._size);std::swap(_capacity, s._capacity);}
示例:
int main() {string s1("hello world");string s2("xxxxxxxxxxxxxxxxxxx");std::swap(s1, s2);//通过模版机制自动转发调用成员函数s1.swap(s2)cout << s1 << endl;cout << s2 << endl;s1.swap(s2); //调用成员函数cout << s1 << endl;cout << s2 << endl;return 0; }
运行结果:
std::string类自身实现了swap成员函数,用于高效交换两个字符串的内部数据,直接交换内部的指针、大小、容量,相当于只交换“容器的壳”,数据本体不动,效率接近O(1),几乎是“零拷贝”操作,比默认的拷贝逻辑快得多。
6. 标准库swap函数的调用
当调用 std::swap(basic_string) 这个全局函数时,它内部实际上会转发调用 basic_string 类的成员函数swap,也就是 std::basic_string::swap 。 所以,当swap(s1,s2)时,会匹配到特化版本,实际执行的是string对象成员的swap逻辑,等价于s1.swap(s2),例:
#include <string> #include <iostream> using namespace std;int main() {string s1 = "Hello";string s2 = "World";//调用全局 swap(匹配特化版本)swap(s1, s2); //内部转发调用s1.swap(s2);cout << "s1: " << s1 << ", s2: " << s2 << endl; // 输出 s1: World, s2: Helloreturn 0; }
简单来说,怎么方便怎么写,想写什么写什么,它们最终执行的是同一个逻辑。
成员函数风格:s1.swap(s2)
全局函数风格:swap(s1,s2) 或 std::(s1,s2)
7. 引用计数和写时拷贝
引用计数
用来记录有多少个对象正在引用该资源。在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源, 如果计数为1,说明该对象时资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。
写时拷贝
当对象被复制时,采用浅拷贝的方式,不立即拷贝实际数据(“写时”才拷贝),此时多个对象共享同一资源。普通浅拷贝若多个对象共享数据,修改时会影响所有对象,所以当某个对象需要修改数据时,会触发数据的深拷贝,确保修改不会影响其他共享该数据的对象。
比如:先构造s1,再调用拷贝构造构造s2,我们走浅拷贝,将引用计数+1变成2,销毁s2的时候,不需要释放资源,只需要将引用计数-1变成1,对象的资源留给最后一个使用者释放。但是如果要修改s2,就需要引用计数-1,再执行深拷贝,修改s2的数据,对s2的修改不影响s1。
引用计数和写时拷贝通过“延迟拷贝”和“共享资源”减少开销,适用于读多写少的场景。