一、前言
▶在c语言中我们来描述一个字符串是用const char* p=”abcdef”;然后我们也学习了许多关于字符串的操作函数。
▶那么,在学习了面向对象的c++语言之后,我们可以定义一个字符(string),这个类可以定义一个字符串对象;并且可以对该对象内存储的字符串进行各种操作;
▶接下来,我们模拟实现一个简单的string类;只需要实现★构造,★拷贝构造和★赋值运算符重载★析构函数即可;
二、各部分函数精讲
(1)成员变量
因为只需要保存字符串,所以写一个字符类指针指向字符串即可保存:
char* _str;
(2)构造函数
①常见错误写法
String(const char* str)//构造函数
:_str(str)
{
}
分析:
这样写构造函数不对,因为传进来的str指针的值是一个常量字符串的首地址,然后把这个首地址给类中的_str这样会导致类的_str指针也指向常量字符串的首地址,那么如果用这个常量字符串再次构造一个string类对象时,就会让两个类中的指针同时指向一块空间,如果一个类先释放了空间,第二个类再次是释放时,就会释放NULL;程序出错;
▏见图(1)▏
②正确写法
String(const char* str="")//构造函数
:_str(new char[strlen(str)+1])
{
strcpy(_str,str);
}
分析:
先给_str开辟一段和常量字符串长度+1相等的空间,然后把常量字符串的内容(包含\n)拷贝到_str指向的新开辟的空间,这样,每个对象的数据成员都有属于自己的空间存放字符串,释放的时候自己释放自己空间;不会出现一块内存被多次释放造成的错误;
(▌特别说明▌,当构造对象的时候没有传参时,此时的str默认为只有一个字符的字符串”\0”空字符串;因为字符串的最后一个字符”\0”不写出来,系统默认加在后面,所以就写成”“,特别注意,不能写成默认为空,如果默认我空NULL;那么要是用这个对象来拷贝构造另一个对象时,在开辟空的时候使用strlen函数的时候,会出错,因为对空指针解引用是错误的)
▏见图(2)▏
(3)拷贝构造函数
①浅拷贝(值拷贝)
String(const String& s)//拷贝构造函数---浅拷贝(值拷贝)
{
_str=s._str;
}
分析:
▌在没有引用计数时这样写拷贝构造函数不对▌▶因为这样是把一个对象的_str的地址赋给的另一个对象的_str;这样会出现两个类的_str指向同一块常量的内存空间;这样会出现两个错误:
★当一个类对自己的字符串进行操作时,就算另一个类什么也没做,他的字符串也会被改变;
★当释放一个对象的_str所指向的空间之后,根据析构函数的写法,释放另一个类的对象的_str的时候,此时这个类的_str不为空,值为常量字符串的地址,但是进去释放的时候,这块空间已经被前一个类的析构函数释放了,这样就会一块内存被两次释放,出现错误;
▏见图(3)▏
②深拷贝
String(const String& s)//拷贝构造函数---深拷贝
:_str(new char[strlen(s._str)+1])
{
strcpy(_str,s._str);
}
分析:
为了解决浅拷贝的不足,那我们就用身拷贝;所谓深拷贝,就是说,每次用对象str1构造对象str2的时候;我们不是只把str1的_str给str2的_str,而是给str2重新开辟一段大小相等的空间;然后,把str1的_str里面的内容拷贝到str2的_str中去,这样每个类的对象的数据成员都有自己空间存放字符串;自己释放自己的空间;不争不抢;
▏见图(4)▏
(4)赋值运算符重载
①常规写法
String& operator=(const String& s)//赋值运算符重载
{
if (this!=&s)
{
delete[] _str;
_str=new char[strlen(s._str)+1];
strcpy(_str,s._str);
}
}
分析:
赋值运算符的重载,是拿一个对象赋值给另一个对象;比如说str3=str2;就是将str2赋值给str3;
首先;我们先要判断是不是对象自己给自己赋值,如果是自己给自己赋值,直接返回对象自己;为什么要判断呢?因为如果不判断遇到这种情况str3=str3就会出错;如果说自己给自己赋值,那么delete[] _str后,对象的数据成员所对应的空间已经被释放而且_str=NULL;这时候在进行下一步开辟空间时_str=new char[strlen(s._str)+1];因为此时 s._str=NULL;用空指针左strlen的参数时,会报错;
然后,如果不是自己给自己赋值,那么就是两个不同的对象赋值;然后开辟空间,拷贝字符串,返回对象;
▏见图(5)▏
②现代写法:
String& operator=(String s)//赋值运算符重载---现代写法
{
std::swap(_str,s._str);
return *this;
}
分析:
首先,现代写法的赋值运算符重载函数形参不能带引用;因为引用的含义是一个变量的别名,这个别名和原对象指的同一块内存;如果将对象以引用的方式接收,那么在函数内部进行std::swap(_str,str._str)操作时;虽然这个时候,要赋值的对象的_str确实指向了字符串的空间,但是这个时候,用来赋值的对象的内容却改变了;变为了要赋值的对象里面的内容;这样做没有达到赋值操作的目的;
当我们用值传递的方式传进来参数时;这时,这个用来赋值的对象会调用类的拷贝构造函数,重新构造一个临时对象s;这个对象因为是调用拷贝构造函数得来的;所以里面也会有一个_str,这个_str也会有自己的空间,存放字符串;当进std::swap(_str,str._str)操作时,swap会将两个指针的值进行交换;这两个指针的值就是各自指向的字符串内存的首地址;当赋值函数调用完成的时候,因为s是一个临时变量;所以在出函数的时候,临时变量String类的对象s会调用String类的析构函数将自己的数据成员指向的内存释放(其实就是被赋值的对象原始的空间),并将自己的_str置空(避免成为野指针);
★个人觉得现代写法的赋值运算符重载函数就想一个强盗,把别人的东西拿来自己用,还把自己不好的东西让别人丢掉;最后在让这个人消失★
▏见图(6)▏
(5)析构函数
~String()
{
if (NULL!=_str)
{
delete[] _str;
_str=NULL;
}
}
分析:略
三、代码实现
#pragma once
#include<iostream>
using namespace std;
#include<cstring>
class String
{
friend ostream& operator<<(ostream& os,String& s);
public:
String(const char* str="")//构造函数
:_str(new char[strlen(str)+1])//strlen统计字符串字符个数(不包括"\0")
{
strcpy(_str,str);//strcpy从字符串第一个字符开始,一直到"\0"结束(包括"\0")
}
String(const String& s)//拷贝构造---深拷贝
:_str(new char[strlen(s._str)+1])
{
strcpy(_str,s._str);
}
String& operator=(const String& s)//赋值操作符重载---常规写法
{
if (this!=&s)
{
delete[] _str;
_str=new char[strlen(s._str)+1];
strcpy(_str,s._str);
}
return *this;
}
/* String& operator=(String s)//赋值操作符重载---现代写法 { std::swap(_str,s._str); return *this; }*/
~String()
{
if (NULL!=_str)
{
delete[] _str;
_str=NULL;
}
}
public:
char* _str;
};
//因为模拟实现的string不是内置类型,所以要重载输出运算符,才能输出String类对象的内容
ostream& operator<<(ostream& os,String& s)
{
os<<s._str;
return os;
}
测试代码:
void Test()
{
String s1("abcdef");
String s2("ABCDEF");
String s3(s1);
String s4;
s4=s1;
cout<<"s1->"<<s1<<endl;
cout<<"s2->"<<s2<<endl;
cout<<"s3->"<<s3<<endl;
cout<<"s4->"<<s4<<endl;
}
四、运行结果
后面会写到引用计数器的写时拷贝的实现;
O(∩_∩)O