前言
一、String类初识
1.String类出现的原因
C语言中,
字符串通常都是以'\0'结尾得一些字符的集合
,为了操作简单,方便,C标准库提供了一系列的库函数,但是这样使得字符串与处理这些字符串的函数是分开的,不符合OOP思想
,而且底层还需要自己去维护管理
,稍不注意还会出现越界访问。
2.string类的特性
char* 是一个指针,string是一个类
,string封装了char* ,管理这个字符串是一个char* 型的容器,string封装了许多实用的方法
,不用考虑内存释放与越界问题
,string管理char* 所分配的内存,每一次都是string复制,取值都由string类来维护,不用担心复制越界与取值越界
//string to char*
string str = "nihao";
char strr = str.c_str();
//char* to string
char* str = "nihao c++";
string sstr(str);;
string类的意义有两个,第一个是为了处理char类型的数组,并封装了标准C中的一些字符串处理的函数
。而当string类进入了C++标准后,它的第二个意义就是一个容器
总结:
- string是表示字符串的字符串类
- 该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作。
- string在底层实际是:
basic_string模板类的别名,typedef basic_string<char, char_traits, allocator>string;
- 不能操作
多字节或者变长字符
的序列。
在使用string类时,必须包含头文件以及using namespace std;
二、String类的简单赋值浅拷贝的内存分析
1. 什么是浅拷贝?
浅拷贝
,也称位拷贝,编译器只是直接将指针的值拷贝过来
,结果多个对象共用了同一块内存空间
,当一个对象将这块内存释放掉之后,另一些对象不知道该块空间是否已经归还给系统
,以为还有效,所以在对这段内存进行操作的时候,发生了访问违规。
源代码及注释
#include <iostream>
#include <Windows.h>
using namespace std;
class String
{
public:
String(const char *pStr = " ")//构造函数
{
if(pStr != NULL)//字符串不为空
{
_pStr = new char[strlen(pStr) + 1];
strcpy(_pStr,pStr);
}
else//字符串为空
{
_pStr = new char[1];
_pStr = '\0';
}
}
String(const String& s)//拷贝构造函数
{
_pStr = s._pStr;
}
~String()//析构函数
{
if(_pStr == NULL)
return;
else
{
delete[] _pStr;
_pStr = NULL;
}
}
//重载赋值运算符=号
String& operator=(const String& s)
{
if(this != &s)//判断是否自己给自己赋值
{
_pStr = s._pStr;
}
return *this;
}
private:
char *_pStr;
};
void FunTest()
{
String s1("Hello world");
String s2(s1);
String s3 = s2;
String s4 = s3;
}
int main()
{
FunTest();
system("pause");
return 0;
}
开辟空间、释放资源分析
①首先根据创建的对象进行开辟空间。
②在对对象依次进行释放资源时,根据先入后出的顺序依次释放,但是,结果是造成了同时释放,
因为共用同一块内存,造成对象成员全部释放。
③当S3调用析构函数释放之后,共用的空间已经释放,当程序再次调用析构函数释放S2时,程序也必将奔溃,因为不能对同一块空间释放多次。
⑤给出地址空间图,方便大家理解
2. 浅拷贝问题总结
①浅拷贝只是拷贝了指针
,使得两个指针指向同一个地址,这样在对象块结束,调用函数析构
的时候,会造成同一份资源析构多次
,即delete同一块内存多次,造成程序崩溃。
②浅拷贝使得S1、S2和S3指向同一块内存,任何一方的变动都会影响到另一方。
③在释放内存
的时候,会造成原有的内存没有被释放
也不走默认构造函数,走的是默认的拷贝构造函数,造成内存泄露。
三、String类加入引用计数的浅拷贝的分析与实现
1. 引用计数原理
当类里面有指针对象
时,进行简单赋值的浅拷贝
,两个对象指向同一块内存
,存在崩溃
的问题!为了解决这个问题,我们可以采用引用计数
。在引用计数中,每一个对象负责维护对象所有引用的计数值
。当一个新的引用指向对象时,引用计数器就递增
,当去掉一个引用时,引用计数就递减
。当引用计数到零时,该对象就将释放占有的资源。
源代码及注释
#include <iostream>
#include <Windows.h>
using namespace std;
#pragma warning (disable:4996)
class String
{
public:
String(const char* pStr = "")//构造函数
:_pStr(new char[strlen(pStr) + 1])
,_count(0)//引用计数
{
if(NULL == *pStr)
*_pStr = '\0';
else
strcpy(_pStr,pStr);
_count++;//每次创建对象时+1
}
String(String& s)//拷贝构造函数
:_count(s._count)//将已存在的对象计数赋值给当前对象
{
_pStr = s._pStr;
_count = s._count + 1;//将原对象的计数器+1后赋值给当前对象
}
~String()//析构函数
{
if(NULL == _pStr)
return;
else
{
if(--_count == 0)//计数器为0说明无对象指向该空间
{
delete[] _pStr;
_pStr = NULL;
}
}
}
String& operator=(String& s)
{
if(_pStr != s._pStr)
{
_pStr = s._pStr;
_count = s._count + 1;//将已存在的对象+1赋值给当前对象
}
return *this;
}
private:
char *_pStr;
int _count;//计数器
};
void FunTest()
{
String s1("Hello world");
String s2(s1);
String s3 = s2;
}
int main()
{
FunTest();
system("pause");
return 0;
}
开辟空间、释放资源分析
①首先根据创建的对象开辟空间
②调用3次析构函数后,其对应的计数器count令人发指
很遗憾,虽然引用计数的引入
防止了一块内存空间被多次析构释放的可能,但随之而来的计数器count并没有达到计数的统一,因此这样的程序依旧存在严重的Bug。
四、String类加入引用计数及静态成员变量的分析与实现
1. 为什么要加入静态成员变量
如何你阅读了博文的前半部分,就会对引用计数非常谨慎
,引入计数虽然防止了内存被析构释放多次的可能
,但是随之带来的却是计数器count的不统一
,导致内存无法被释放
。为了解决这个问题,我加入了静态成员变量来保证计数器的统一,来方便后面内存空间的释放。
源代码及注释
#include <iostream>
#include <Windows.h>
using namespace std;
#pragma warning (disable:4996)
class String
{
public:
String(const char* pStr = "")//构造函数
:_pStr(new char[strlen(pStr) + 1])
{
if(NULL == *pStr)
*_pStr = '\0';
else
strcpy(_pStr,pStr);
_count++;//每次创建对象时+1
}
String(String& s)//拷贝构造函数
{
_pStr = (char*)s._pStr;
s._count = _count;
_count++;
}
~String()//析构函数
{
if(NULL == _pStr)
return;
else
{
if(--_count == 0)//计数器为0说明无对象指向该空间
{
delete[] _pStr;
_pStr = NULL;
}
}
}
String& operator=(String& s)
{
if(_pStr != s._pStr)
{
_pStr = s._pStr;
s._count = _count;
_count++;
}
return *this;
}
private:
char *_pStr;
static int _count;//计数器
};
int String::_count = 0;
void FunTest()
{
String s1("Hello world");
String s2(s1);
String s3 = s2;
}
int main()
{
FunTest();
system("pause");
return 0;
}
开辟空间、释放资源的分析
①根据创建的对象开辟内存空间
②调用3次析构函数的计数器分析
虽然静态成员变量的引入确实让计数器高度统一
,不过创建3个成员对象却要析构释放4次空间,无疑又是一个严重的Bug
,看来实现一个程序并没有那么容易,又得钻研其他的方法了。
五、String类加入引用计数及指针的分析与实现
1. 为什么要引入指针?
静态成员变量的引入
最终带来的是析构调用次数的增加
,那么指针的引入无疑是为了克服这一难关。但是指针的引入却增加了空间的开销
,大部分程序员都知道引入指针也就相当于让程序存在了安全隐患
,各种资源的释放以及释放之后
防止野指针的问题同样重要。
源代码及注释
#include <iostream>
#include <Windows.h>
using namespace std;
#pragma warning (disable:4996)
class String
{
public:
String(const char* pStr = "")//构造函数
:_pStr(new char[strlen(pStr) + 1])
,_count(new int(0))
{
if(NULL == *pStr)
*_pStr = '\0';
else
strcpy(_pStr,pStr);
*_count = 1;
}
String(String& s)//拷贝构造函数
:_count(s._count)
{
_pStr = (char*)s._pStr;
_count = s._count;
(*_count)++;
}
~String()//析构函数
{
if(NULL == _pStr)
return;
else
{
if(--(*_count) == 0)//计数器为0说明无对象指向该空间
{
delete[] _count;//释放计数器指针
delete[] _pStr;
_pStr = NULL;
_count = NULL;
}
}
}
String& operator=(String& s)
{
if(_pStr != s._pStr)
{
_pStr = s._pStr;
_count = s._count;
(*_count)++;
}
return *this;
}
private:
char *_pStr;
int* _count;//计数器
};
void FunTest()
{
String s1("Hello world");
String s2(s1);
String s3 = s2;
}
int main()
{
FunTest();
system("pause");
return 0;
}
开辟空间、释放资源的分析
①根据创建的3个对象进行开辟空间,查看count指针中保存的数值
②调用3次析构函数,计数器指针count中保存的数值归0
经过多次修改调试,String类的浅拷贝Bug也相继修复完毕
,但是,值得注意的是,count指针的引入带来了空间复杂度的增大
,而且如果忘记释放count指针,以及count指针为指向Null成为野指针都会是程序的一大败笔
。因此,指针的引入确实让程序更好的运行
,最优的算法依旧需要去揣摩去发现。
六、写时拷贝完美诠释String类的浅拷贝
1. 浅拷贝的艰辛历程
从简单的赋值浅拷贝
(内存被多次析构释放)->加入引用计数的浅拷贝实现
(计数器难以统一)->利用引用计数及静态成员变量浅拷贝实现
(忽略了静态成员变量为所有成员所共享)->引用计数利用指针的浅拷贝实现
(功能实现了但开辟了存放指针的空间,且不安全)->写时拷贝
。一代一代的过渡终于可以完美的实现Strng类的浅拷贝了
。
2. 什么是写时拷贝
参考博客:
Linux下写时拷贝
写时拷贝详解
写时拷贝缺陷详解
写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。
写时拷贝(Copy On Write) 使用了引用计数
,在开辟空间时会多开辟4个字节用来保存引用计数的值
。当第一个对象构造时,String的构造函数会根据传入的参数从堆上分配内存
,当有其他对象需要这块内存时,计数器++
,当有对象进行析构释放时,引用计数--,直到最会一个对象被析构,此时计数器为0
。只有这样程序才会真正的释放掉这块从堆上分配的内存。
源代码及注释
#include <iostream>
#include <Windows.h>
#pragma warning (disable:4996)
class String
{
public:
String(const char* pStr = "") //构造函数
:_pStr(new char[strlen(pStr) + 4 + 1])//+4多创建的四个字节用来保存当前地址有几个对象
{
if(NULL == pStr)
{
*((int*)_pStr) = 1;//前4个字节用来计数
_pStr += 4;//向后偏移4个字节
*_pStr = '\0';
}
else
{
*((int *)_pStr) = 1;//前4个字节用来计数
_pStr += 4;//向后偏移4个字节
strcpy(_pStr,pStr);//拷贝字符串
}
}
String(const String& s)//拷贝构造函数
:_pStr(s._pStr)
{
++(*(int*)(_pStr - 4));//向前偏移4个字节将计数+1
}
~String()//析构函数
{
if(NULL == _pStr)
{
return;
}
else
{
if(--(*(int*)(_pStr - 4)) == 0)
{
delete[] (_pStr - 4);
_pStr = NULL;
}
}
}
//重载赋值运算符=
String& operator=(const String& s)
{
if(_pStr != s._pStr)
{
if(--(*(int*)(_pStr - 4)) == 0)//释放旧空间
{
delete[] (_pStr - 4);
_pStr = NULL;
}
_pStr = s._pStr;//指向新空间
++(*(int*)(_pStr - 4));//计数+1
}
return *this;
}
//重载下标访问操作符
char& operator[](size_t t)
{
if(t >= 0 && t < strlen(_pStr))//下标非法判断
{
if((*(int*)(_pStr - 4)) > 1)//多个对象指向同一块空间
{
char *pTemp = new char[strlen(_pStr) + 4 + 1];//开辟临时空间
pTemp += 4;//向后偏移4个字节
strcpy(pTemp,_pStr);//拷贝字符串
--(*(int*)(_pStr - 4));//计数-1
_pStr = pTemp;//将当前的对象指向临时空间
*((int*)(_pStr - 4)) = 1;//将新空间的计数置为1
}
return _pStr[t];
}
}
private:
char *_pStr;
};
void FunTest()
{
String s1("Hello world");
String s2(s1);
String s3 = s2;
s3[3] = 'm';
}
int main()
{
FunTest();
system("pause");
return 0;
}
地址空间图
开辟空间、释放资源的分析
①根据创建的对象开辟空间
②执行s3[3] = ‘m’,改变s3对象中的值
③调用析构函数释放s3
④调用析构函数释放s2
⑤调用析构函数释放s1
大家从监视窗口可以清晰发现,当依次调用析构函数进行资源释放的时候
,创建的对象遵循先入后出的原则
,依次释放,没有发生内存空间的非法访问或内存泄漏问题,因此就目前来说,写时拷贝算是浅拷贝中最合适的方法。