在之前的 【C++】深入理解String类(一)里,我们讲解了string类的相关知识与其中部分库函数的使用方法。

这次我们要根据string的用法,模仿实现写一个string类。

注:我们模拟实现这个类,不是为了完美复制源码,而是熟悉string框架,加深对string的理解,我会用我们已经学习过的有限知识,来简单还原string.

1. 创建一块自定义的命名空间

我们平常在写c++的时候,在开头都会写:

using namespace std;

这段代码的意思就是展开std命名空间,展开后,我们就可以随时使用std里的库函数。

所以,我们也可以模仿自定义一段命名空间:

namespace yyk
{
  //string 模拟实现 区域
}

2. 确定基本框架

string 实际上就是一个类,我们在使用时,实例化这个类,并且调用其中的函数。

我们将成员变量私有化,成员函数公有化,留作接口,供外部使用:

class string
{
 public:
     //成员函数
 private:
     char*_str;
     size_t _size;
     size_t _capacity;
     
     static const size_t npos;
};

这里有两个需要解释的点:

  1. 什么是size_t类型? 为什么要使用 size_t 类型?

size_t其实就是一个8字节的长整数

在32位架构中被普遍定义为:
typedef unsigned int size_t;

而在64位架构中被定义为:
typedef unsigned long size_t;

size_t在32位架构上是4字节,在64位架构上是8字节,在不同架构上>>进行编译时需要注意这个问题。而int在不同架构下都是4字节,与size_t不>同;且int为带符号数,size_t为无符号数

我们之所以使用 size_t 而不是 int ,是因为使用size_t可能会提高代码的可移植性、有效性或者可读性

与int固定四个字节不同有所不同,size_t的取值range是目标平台下最大可能的数组尺寸,一些平台下size_t的范围小于int的正数范围,又或者大于unsigned int. 使用Int既有可能浪费,又有可能范围不够大

  1. npos 是什么? 为什么需要npos?

string::npos参数 —— npos 是一个常数,用来表示不存在的位置

自定义一个result类_c++


可以看出,npos =-1,但由于size_t是无符号整形,所以npos 等于size_t的上限,也就是4294967295。在使用string 时,我们的_size不可能达到这个数,最多为4294967294,也就是“不存在的位置”

至于为什么要设置这个常量,暂时先不讲,我们在用到的时候再讲解。

3. 完善功能

1. 重载 [ ]

我们知道字符串的底层实际就是一个字符数组,既然是数组,我们就可以通过以下方式去访问:

string a="hello world"
 cout<<a[1]; //e

所以,要实现这种访问方式,我们要进行运算符重载:

char& operator[](size_t pos)
 {
   assert(pos< _size);
   return _str[pos]; //等价于 *(_str+pos)
 }

上面的是可读可写的,我们还可以设置一个只可读的:

const char& operator[](size_t pos)const //只读
		{
			assert(pos < _size);
			return _str[pos]; //*(_str + pos)
		}

ps: 为了接下来访问字符串方便,我们先实现这个功能



2.构造函数 与 析构函数

对于任何类,使用构造器初始化都是第一步

string (char* str="")
   :_size(strlen(str)
   :_capacity(_size)
   :_str(str)
 {}

这样写对吗
不对


我们使用一段代码来看一下:

string s1 ("hello");
 s1[0] = 'x';

我们运行这段代码,发现s1并没有被改变,为什么?

因为 “hello” 这段字符串 是常量字符串,存在常量区。我们初始化的时候将str赋给了_str,那么_str也就指向了这段常量区,可以访问但无法改变。


所以我们要想对字符串进行增删查改,得先另外开辟一段空间(在堆上),再将str指向的字符串拷贝到这段空间:

string (const char* str="")
    :_size(strlen(str))
    :_capacity(_size)
   {
     _str = new char[_capacity+1];
     strcpy(_str , str);//将str指向的字符串拷贝到_str里
   }

这里我们要注意两个点:

  1. 我们要对形参设置缺省值。当我们没有给string对象赋值,我们默认初始化为空,但这里str实际上里还有一个’\0’.
  2. 我们在给_str 分配空间的时候,要分配_capacity个,因为此时_capacity=_size=strlen(str). strlen()计算字符串长度的时候不包含’\0’,所以我们实际开空间要多开一个留给’\0’

析构函数比较简单:

~string()
{
  delete[] _str;
}


3. str迭代器

对于string 迭代器,实际上就是指针。

  1. begin() 返回首字符的地址
  2. end() 返回最后一个字符,即’\0’的地址

但是,注意,不是所有的迭代器都是指针(比如list),因为不是所有容器的存储都是空间连续的。

同时,我们要设置两种迭代器,一种是可读可写,另一种const_iterator只能读。

自定义一个result类_ci_02


自定义一个result类_字符串_03

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;
   }

测试:

//对于print函数,我们希望对字符串只读,所以调用const迭代器
void print(const string& s)
	{
		string::const_iterator  it = s.begin();
		while (it != s.end())
		{
			cout << *it << endl;
			++it;
		}
		cout << endl;
	}

4. 拷贝(重难点)

现在 在我们之前程序的基础上 测试这一段代码:

string s1("hello");
//使用s1去初始化s2,也就是“拷贝构造”
string s2(s1);

很显然,根据我们的构造函数,这是写法是浅拷贝,s1和s2 中的_str指向同一块堆上的空间:


自定义一个result类_c++_04


浅拷贝会导致严重的错误

  • 当我们执行完程序之后,我们需要调用析构函数 释放堆上的这段空间,s2先调,空间被释放,s1后调,同一块空间再次被释放,很显然发生了错误,一段空间被析构两次
  • 同时,由于指向同一段空间,当我改变 s1或s2 的时候,另外一个也会被修改。

如何解决?
很显然,使用深拷贝。

深拷贝就是 拷贝对象,新开一块和原对象一样大的空间,再把原对象空间上的值拷贝过来。


自定义一个result类_字符串_05


讲完原因,现在我们来实现深拷贝

string (const string& s)
   :_str(new char [strlen(s._str)+1])
{
   strcpy(_str, s._str);
}

这里有两个点需要注意:

  • 当涉及开空间的时候,我们都可以使用初始化列表
  • 传参的时候我们传s的引用。如果我们直接传 s,实际上是把s1深拷贝给临时对象s,与直接传s 的别名(引用)相比,效率太低。同时我们为了防止s1引用被改变而导致s1的改变,还加了const

以上是深拷贝的传统写法,其实我们还有其他写法:

string (const string& s) 
   :str(nullptr)
{
   string tmp(s._str);
   swap(_str , tmp._str);
}

这段代码是啥意思?我们可以理解为“坐享其成”。

我们先创建一个对象tmp,调用构造函数,使用s._str去初始化tmp,也就是_str开辟的空间的内容与s相同。(注意:这里不是拷贝函数,构造的行参是指针,拷贝的行参是对象)

那么这个时候 tmp指向的空间就是我们 s2 想要的,所以我用swap 将s2._st(也就是this ->_str),和tmp._str交换,此时s2就指向了tmp原来指向的空间,效果上实现了深拷贝。

此时,tmp就指向了s2._str原来指向的空间,而s2在之前被我们初始化为nullptr了,所以在之后析构tmp的时候不会报错(delete nullptr 是被允许的)

但是,同学们会发现,这个写法好像没什么优化。

s2是偷懒了,调包了指向的空间。但整体上我们并没有占什么便宜,与深拷贝差不多。其实不是的,如果我们拷贝的是 list(链表) 或者map 之类的容器,此时使用s2自身去深拷贝是很“累”的。而且,在未来的学习中,这种写法会体现更大的价值,这里暂时无法体现。



5.赋值

对于赋值语句来说,简单来说就是对 = 的重载:

string s1="hello";
string s2="hello yyk";
s1=s2;

我们能将s2指向的字符串直接赋值到s1指向的空间吗?
显然不对

因为会出现下面两种情况:

  • s2中的字符串长度超出s1的_capacity,造成越界。我们在赋值前要对s1进行扩容。
  • s1中的空间很大,我们将s1的内容拷贝过来会有大量闲置空间余留,可能造成大量浪费

所以,我们这样解决:我们先释放s1的原有空间,再重新开一块和s1一样大的新空间。此时我们再将s2的数据拷贝到s1指向的新空间中。

string & operator = (const string& s)
{
  //内容相同不赋值
  if(this !=&s) 
  {
    delete[] _str;
    _str = new char[_size+1];//_szie等价于strlen(s._str)
    strcpy(_str , s._str);
  }
  return *this;
}

但是其实我们最好先开新空间,再释放原空间。因为如果我们开空间失败(虽然不常见),程序抛异常,这个时候我们原空间也找不到了,可以说是“赔了夫人又折兵”。

所以这样写比较好:

string & operator = (const string& s)
{
  //内容相同不赋值
  if(this !=&s) 
  {
    char*tmp = new char[_size+1];//_szie等价于strlen(s._str)
    delete[] _str;
    _str=tmp;
    strcpy(_str , s._str);
  }
  return *this;
}

拷贝有 其他写法,其实赋值也有其他的写法。

string &operator = (const string& s)
{
  if(this!= &s)
  {
    string tmp(s._str);
    swap(_str,tmp._str);
  }
  return *this;
}

原理和拷贝差不多,也是”坐享其成“,我们对下面这个例子画图来看一下

string s1="hello";
string s2="hello yyk";
s1=s2;

自定义一个result类_ci_06


我们还可以再简化一些:

string& operator(string s)
{
 swap(_str , s._str);
 return *this;
}

其实换汤不换药:我们不传引用,而是直接传值,此时的形参s是由s2深拷贝而来的(s是一个临时对象),所以此时s._str 就是我们s1想要的,我们直接“偷梁换柱”就行了。



6. reserve

reserve() 函数是我在上一篇文章中讲解比较详细的接口。这里我就不再赘述了。

void reserve (size_t n)
{
  if(n>_capacity)
  {
    char*tmp=new char[n+1];
    strcpy(tmp,_str);
    delete[] _str;
    _str = tmp;
  }
  _capacity =n ;
}


7. resize

resize 和 reserve 的区别在于在开辟空间的时候会对空间初始化。所以我们需要在形参列表中加上一个char型字符,用来填充我们新开的空间。

当然,如果n小于_size,我们要将字符串截取到n长度。具体可以看上一篇文章。

void resize(size_t n, char ch='\0')
{
 if(n<=_size)
 {
   _size = n;
   _str[_size]= '\0';
 }
 else
 {
   if(n > _capacity)
   {
     reserve(n)
   }
   for(size_t i=_size; i<n;i++)
   {
     _str[i] = ch;
   }
   _size = n;
   _str[_size] ='\0';
   
   
 }
}


8. push_back

push_back 尾插函数,这个函数比较简单,我们只需要注意需不需要扩容就行了。

void push_back(char ch)
{
  if(_size >=_capacity)
  {
    size_t newcapacity = _capcity==0?4: _capacity*2 ;
    reserve(newcapacity*2);
  }
  _str[_size]=ch;
  ++_size;
  _str[_size]='\0';
}


9. append

append 也是一个尾插函数,但尾插的内容是字符串。

void append(const char*str)
{
  size_t len =strlen(str);
  if(_size+len>_capacity)
  {
    reserve(_size+len);
  }
  strcpy(_str+_len,str);//将数据拷贝到str之后的空间
  _size += len;
}


10.insert

insert 函数既可以 往字符串任意位置插入字符,也可以插入字符串,所以我们要写两种insert.
string& insert(size_t pos ,char ch)
{
  assert(pos <= _size);
  if(_size == _capacity)
  {
    size_t newcapacity =_capacity==0?4 :_capacity*2 ;
    reserve(newcapacity);
  }
  int end=_size+1;
  while(end >=(int)pos)
  {
   _str[end] = _str[end-1];
   --end;
  }
  _str[pos] =ch;
  ++_size;
retunr *this;
}
string& insert(size_t pos ,const char* str)
{
  assert(pos < _size);
  size_t len = strlen(str);
  
  if(len == 0)
  {
    return *this ;
  }
  if(len+_size > _capacity)
  {
   reserve(len+_size);
  }
    
  size_t end = _size+len;
  while(end >= pos+len)
  {
   _str[end] =_str[end-len];
   --end;
  }  
  for(size_t i=0;i<len ;i++)
  {
   _str[pos+i] =str[i];
  }
  _size +=len ;
  
  return *this;
  
}


11.erase

对于erase函数来说,我们确定pos位置之后,如果不传缺省参数,那么就把pos之后的字符全部删完,如果我们指定了缺省参数len,我们会把pos位置后的len个字符删除。当len>pos之后的长度,我们就认为删除pos之后的所有值。

对于缺省参数的默认值,我们选择npos,也就是size_t类型的上限,这样写,也就等价于删除所有数字。

string& erase(size_t pos,size_t len=npos)
{
  assert(pos<_size);
  if(len ==npos || pos+len >_size)
  {
    _str[pos] ='\0';
    _size=pos;
  }
  else
  {
    strcpy(_str+pos,str+pos+len);
    _size -=len;
  }
  
}

12 c_str

c_str()函数很简单,就是返回对象地址。

有时候使用c_str很方便。

const char* c_str()
		{
			return _str;
		}

13. size() 和 capacity()

size_t size()const
		{
			return _size;
		}
		size_t capacity()const
		{
			return _capacity;
		}

14. 一些运算符的重载

string& operator+=(char ch)
		{
			push_back(ch);
			return *this;
		}
		string& operator+=(const char* str)
		{
			append(_str);
			return *this;
		}
		string& operator+=(const string& s)
		{
			*this += s._str;
			return *this;
		}

//存在深拷贝对象,尽量少用
	string operator+(const string& s1, char ch)
	{
		string ret = s1;
		ret += ch;
		return ret;
	}
	string operator+(const string& s1, const char* str)
	{
		string ret = s1;
		ret += str;
		return ret;
	}

	//不涉及私有成员,不用加友元
	ostream& operator <<(ostream& out, const string& s)
	{
		for (size_t i = 0; i < s.size(); ++i)
		{
			out << s[i];
		}
		return out;
	}

	istream& operator >>(istream& in, string& s)
	{
		s.clear();
		char ch;
		ch = in.get();
		while (ch != ' ' && ch != '\n')
		{
			s += ch;
			ch = in.get();
		}

		return in;
	}
	istream& getline(istream& in, string& s)
	{
		s.clear();
		char ch;
		ch = in.get();
		while (ch != '\n')
		{
			s += ch;
			ch = in.get();
		}

		return in;
	}
	//s1 > s2
	bool operator > (const string& s1, const string& s2)
	{
		size_t i1 = 0, i2 = 0;
		while (i1 < s1.size() && i2 < s2.size())
		{
			if (s1[i1]>s2[i2])
			{
				return true;
			}
			else if (s1[i1] < s2[i2])
			{
				return false;
			}
			else
			{
				++i1;
				++i2;
			}
		}
		if (i1 == s1.size())
		{
			return false;
		}
		else
		{
			return true;
		}
	}
	bool operator == (const string& s1, const string& s2)
	{
		size_t i1 = 0, i2 = 0;
		while (i1 < s1.size() && i2 < s2.size())
		{
			if (s1[i1] != s2[i2])
			{
				return true;
			}
			else
			{
				++i1;
				++i2;
			}
		}
	

		if (i1 == s1.size() && i2 == s2.size())
		{
			return true;
		}
		else
		{
			return false;
		}
	}

	inline bool operator != (const string& s1, const string& s2)
	{
		return !(s1 == s2);
	}

	inline bool operator >= (const string& s1, const string& s2)
	{
		return s1 > s2 || s1 == s2;
	}
	inline bool operator < (const string& s1, const string& s2)
	{
		return !(s1 >= s2);
	}
	inline bool operator <= (const string& s1, const string& s2)
	{
		return !(s1 > s2);
	}


String 类里其实还有许多接口,这里限于篇幅只能实现一些常用的,有兴趣的小伙伴可以参考源码实现以下其他的接口。