二次修订于date:2024:3:16
string类的模拟实现可以加深对于string类的理解。
一般实现简单的string类就是string的默认成员函数中的四个,构造函数,拷贝构造,赋值运算符重载,析构函数。
string类的成员变量和声明
namespace lz
{
class string
{
public:
//迭代器
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;
}
//重载输入输出符
//友元声明
friend ostream& operator<<(ostream& out, const string& s);
friend istream& operator>>(istream& in, string& s);
private:
char* _str;
int _size;
int _capacity;
static const size_t npos = -1;
};
}
string类放在了一个单独的命名空间,防止与std::string发生命名冲突。这里的_size和_capacity最好是实现size_t版本,这里我是实现的是int版本,虽然类型不同但是后面的函数时通用的。只需要改变一下类型就可以了。使用size_t类型要注意和0比较的时候容易出现问题。
在声明部分重命名了迭代器,声明了输入输出的友元函数重载,注意在string和vector里面iterator可以用原生指针。但是在后续如list就需要对原生指针进行封装。
迭代器的特性:
- 像指针一样的行为
- 通用的访问形式
默认成员函数实现
//默认成员函数
//构造
string(const char* s = "")
{
_size = strlen(s);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, s);
}
//拷贝构造(传统写法)
//string(string& s)
//{
// _str = new char[strlen(s._str) + 1];
// memcpy(_str, s._str,strlen(s.str) + 1);
// _size = s._size;
// _capacity = s._capacity;
//}
//拷贝构造(现代写法)
string(const string& s)
:_str(nullptr)
{
string tmp(s._str);
swap(tmp);
}
//赋值运算符重载(传统版本)
//string& operator=(const string& s)
//{
// char* tmp = new char[strlen(s._str) + 1];
// strcpy(tmp, s._str);
// _str = tmp;
// _size = s._size;
// _capacity = s._capacity;
// return *this;
//}
//赋值运算符重载。
string& operator=(string s)
{
swap(s);
return *this;
}
//析构
~string()
{
delete[] _str;
_size = 0;
_capacity = 0;
}
拷贝构造和赋值运算符重载都有两个版本一个是现代版本一个是传统版本。
传统版本的思想是:想要拷贝一个对象,先new出一个大小相同的空间,然后用拷贝字符串到新空间,释放旧空间,让新空间成为对象的_str。更新_size和_capacity(需要注意这里new空间需要多一个用来存放\0。_capacity是代表有效字符空间,并不包含\0的空间。
现代版本的思想:拷贝构造就让一个中间对象tmp调用构造函数来构造一个对象,然后这个tmp对象就是this想要的,只需要交换他们的内容,就可以了。这里this的_str一定要初始化成nullptr,如果是随机值,那么和tmp交换之后,tmp生命周期结束就会调用析构对随机地址进行释放就会崩溃。
赋值运算符重载也是一样,先拷贝构造一个s然后,交换this和s,这里使用的swap是string类中实现的swap,并不是algorithm里面的。因为库里面的那个swap是三次深拷贝,来完成交换,string的swap只需要将对象的成员变量交换就可以了,效率更高。但是仅限在c++98中,C++11引入了右值引用转移语义,这两个效率就差不多了。
在实现string类进行字符串拷贝的时候要时刻注意一种情况,那就是拷贝的字符串中间存在\0的时候不能使用strcpy或者strncpy,因为他们遇到\0都会停止。应该使用memcpy。
如"hello\0kisskernel";
string类的深浅拷贝问题
浅拷贝,string类里系统默认生成的拷贝构造函数完成的就是浅拷贝,什么是浅拷贝,这里有两个_str指针,第一个指针指向一块空间,浅拷贝就是将这个空间的地址拷贝给另一个指针,这两个指针指向了同一块空间,一个改变了内容,另一个也会改变,最后在对象销毁的时候调用析构函数,会对同一块空间析构两次,这时候程序就崩溃了。
什么是深拷贝?
深拷贝就是计算出_str指向的字符串的长度,然后new出len+1个空间,将_str的内容拷贝到这个新空间,最后将这个新空间的地址给到拷贝构造出来的对象的_str。
字符串访问函数
//重载下标访问符
char& operator[](int t)
{
assert(t < _size);
return _str[t];
}
const char& operator[](int t)const
{
assert(t < _size);
return _str[t];
}
可读可写的方法要实现两个版本保证普通对象和const对象都可以调用,普通对象调可读可写,const对象调只读。
只要函数内不修改成员变量,就实现成const,普通对象也可调用,const对象也可以调用。
字符串修改函数
//字符串修改(const对象不能调用,所有对象不需要const修饰)
//尾插
void push_back(char ch)
{
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
_str[_size] = ch;
_str[_size + 1] = '\0';
_size++;
}
void append(const char* s)
{
int len = strlen(s);
if (_size + len >= _capacity)
{
reserve(_size + len);
}
memcpy(_str + _size, s,_size + len + 1);
_size += len;
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* s)
{
append(s);
return *this;
}
//自定义string类交换
//不适用算法库内的swap是因为算法库内的swap用了三次深拷贝
//效率低,但是在C++11中使用了右值引用的转移语义。
void swap(string& tmp)
{
::swap(_str, tmp._str);//这里的域作用限定符限制访问全局的swap
::swap(_size, tmp._size);
::swap(_capacity, tmp._capacity);
}
void clear()
{
_str[0] = '\0';
_size = 0;
}
//返回c形式的字符串
const char* c_str()const
{
return _str;
}
这里的reserve也是自己实现的。不可以直接扩容到_capacity*2,因为_capaicty 可能是0。
字符串容量函数
//容量函数
int size()const
{
return _size;
}
int capacity() const
{
return _capacity;
}
void reserve(int t = 0)
{
char* tmp = new char[t + 1];//多开一个空间给\0
if (tmp == nullptr)
exit(-1);
memcpy(tmp, _str,_size);
delete[] _str;
_str = tmp;
_capacity = t;
}
bool empty() const
{
return _size == 0;
}
void resize(int n, char c = '\0')
{
if (_size >= n)
{
_size = n;
_str[_size] = '\0';
}
else
{
while (_size != n)
{
push_back(c);
}
}
}
resize作用是开辟空间并且初始化。如果原有字符串大于n就删除超出的字符串长度,限制_size和n相等。如果小于n就补字符c使得字符串的_size==n
字符串比较函数
//字符串比较大小
bool operator>(string& s)
{
return strcmp(this->_str, s._str)==1;
}
bool operator==(string& s)
{
return strcmp(this->_str, s._str) == 0;
}
bool operator!=(string& s)
{
return !(*this == s);
}
bool operator>=(string& s)
{
return *this > s || *this == s;
}
bool operator<(string& s)
{
return !(*this >= s);
}
bool operator<=(string& s)
{
return !(*this > s);
}
只需要实现一个大于函数和一个等于函数,其他的函数复用即可,但是要注意实现顺序,复用的函数必须已经实现了。
字符串查找插入删除函数
int find(char c, int pos = 0) const
{
for (int i = pos; i < size(); i++)
{
if (_str[i] == c) return i;
}
return npos;
}
int find(const char* s, int pos = 0) const
{
int i = pos;
int len = strlen(s);
int j = 0;
while (_str[i])
{
i = pos;
j = 0;
while (_str[i] == s[j] && _str[i] != '\0')
{
i++;
j++;
}
if (j == len)
return pos;
pos++;
}
return npos;
}
string& insert( int pos ,char c)
{
if (_size == _capacity)
{
reserve(_capacity * 2);
}
for (int i = _size-1;i>=pos;i--)
{
_str[i + 1] = _str[i];
}
_str[pos] = c;
_size++;
_str[_size] = '\0';
return *this;
}
string& insert(int pos , const char* s)
{
int len = strlen(s);
if (_size + len > _capacity)
{
reserve(_size + len);
}
for (int i = _size - 1; i >= pos; i--)
{
_str[i + len] = _str[i];
}
strncpy(_str + pos, s,len);//防止拷贝\0进去
_size += len;
_str[_size] = '\0';
return *this;
}
string& erase(int pos, size_t len = npos)
{
assert(pos < _size);
if (len == npos || len >= _size - pos + 1)
{
_size = pos;
_str[pos] = '\0';
}
else
{
strcpy(_str + pos, _str + len);
}
return *this;
}
类外实现的函数
//外部实现函数
//重载输入输出符
ostream& operator<<(ostream& out, const string& s)
{
assert(&s);
cout << s._str ;
return out;
}
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
s.push_back(ch);
ch = in.get();
}
s.push_back('\0');
return in;
}
输入输出函数的重载必须实现在类外,因为这两个函数的第一个参数都是cin或者cout,如果在类内,那么第一个参数必然是隐含的this指针。
关于写时拷贝(copy-on-write)
写时拷贝原理
这里有一个string类对象s1和一个用s1拷贝构造出来的s2,如果是vs上面使用的是深拷贝,就算不使用s2也会完成深拷贝,要知道new开辟空间是一个不小的性能消耗。但是写时拷贝就可以优化这个问题。
写时拷贝原理是在第一次拷贝的时候,s1和s2是指向同一块内存空间的,也即是两个对象共享空间,同时有一个计数器cnt来记录有多少个对象共享这块空间,共享空间无非就是两个问题:
第一:在修改一个对象的时候另一个对象也会改变。
这个写时拷贝是如何解决的呢?正如名字一样,copy on write在写的时候拷贝,就是要修改共享空间的字符串的时候会先给cnt–然后若cnt == 1就进行修改,否则深拷贝出一个字符串再进行修改。
第二:在析构的时候对于同一块内存空间析构多次导致崩溃。
写时拷贝也是通过cnt来控制。当析构的时候先判断cnt是不是>1的,如果是>1那么就让cnt–,只有当cnt==1的时候才真正的释放这块空间。
写时拷贝非常依赖cnt那么cnt存在哪里呢?
将cnt存在对象里面?这显然不可以,因为做不到共享。
将cnt设置成全局变量或者静态变量?这样显然也不可以,因为这样所有对象都公用一个计数器了,我们要的是共享空间的对象共享一个计数器。那么既然共享了空间,又共享计数器,为什么不将计数器放在共享空间内呢?
所以cnt一般都是存放在共享空间内的,一般都是在共享空间的开头位置。如下图所示:
代码验证是不是真的有写时拷贝这个东西,下面这段代码来自陈皓大佬的博客,可以验证出来之不是真的发生了写时拷贝。下面是vs2019测试是没有写时拷贝的。
#include <stdio.h>
#include <string>
using namespace std;
int main()
{
string str1 = "hello world";
string str2 = str1;
printf("Sharing the memory:\n");
printf("/tstr1's address: %p\n", str1.c_str());
printf("/tstr2's address: %p\n", str2.c_str());
str1[1] = 'q';
str2[1] = 'w';
printf("After Copy-On-Write:\n");
printf("/tstr1's address: %p\n", str1.c_str());
printf("/tstr2's address: %p\n", str2.c_str());
return 0;
}
下面在Liunx上面试试,发现Linux的g++是有写时拷贝的。
vs上面和Linux不同这是很正常的,c++标准只规定stl需要实现的接口函数,并没有规定内部实现的方式,vs使用的是PJ版本,g++使用的是SGI版本。
写时拷贝的缺点
写时拷贝并不是完美的,在动态链接库这里有时还会导致程序crash。这里放的是陈皓大佬的个人博客链接,本篇内容关于写时拷贝部分基本来自该文章