手搓简单 string 库:了解C++ 字符串底层

今天带大家来手搓简单的 string 库了,顺便一起了解它的底层逻辑,有利于后面STL的学习

目录

1.简单实现string的头文件

[2.string类的 默认成员函数](#2.string类的 默认成员函数)

2.1构造函数

2.2拷贝构造函数

2.2.1现代写法

2.3析构函数

[2.4赋值运算符重载 结合 swap 函数 (现代写法)](#2.4赋值运算符重载 结合 swap 函数 (现代写法))

2.4.1更简洁的现代写法

3.各种逻辑简单的小函数

[3.1 C风格字符串 c_str](#3.1 C风格字符串 c_str)

[3.2 清空数据 clear](#3.2 清空数据 clear)

[3.3 访问_size和_capacity的接口](#3.3 访问_size和_capacity的接口)

[3.4operator[ ]](#3.4operator[ ])

[3.5判空 empty](#3.5判空 empty)

[3.6迭代器 iterator](#3.6迭代器 iterator)

4.常见比较类的运算符重载(<,=)

[4.1 operator==](#4.1 operator==)

[4.2 operator<](#4.2 operator<)

4.3其他的重载都可以用这两个复现

[5.扩容 reserve](#5.扩容 reserve)

[6.push_back和append ,operator+= 和 operator+](#6.push_back和append ,operator+= 和 operator+)

6.1push_back

6.2append

[6.3 operator+=和operator+](#6.3 operator+=和operator+)

7.查找find

7.1查找字符

7.2查找子串

8.指定位置插入insert

8.1插入字符

8.2插入字符串

9.截取子串substr

10.erase

[11.getline 和 operator>>,operator<<](#11.getline 和 operator>>,operator<<)

11.1operator<<

11.2operator>>

[11.3 getline](#11.3 getline)

12.简单string的bug

1.简单实现string的头文件

我们用 .h 和 .cpp 文件分离的方式书写,我先给出它的类:

复制代码
#pragma once


#include<string.h>
#include<assert.h>
#include<iostream>
#include<algorithm>

using namespace std;

namespace bit

{
    class string
    {
    public:
        //static不能类内定义,但是static const 可以。
        static const size_t npos = -1;

    private:
        char* _str;
        size_t _capacity;
        size_t _size;
    };
}

string的字符串和C语言的类似,底层也有用指针实现 ,并且调试观察标准库的string可以发现,它还有size和capacity,表示长度和空间 。我们的目的是实现简单功能,以了解它的底层逻辑,那就先用这三个作为私有成员。还有一个npos,用于很多string函数中,提前声明定义一下。

留意一下注释。

命名空间 bit 是为了和标准库的string作区分,也可以给我们的string类改个名字,避免冲突。

2.string类的 默认成员函数

2.1构造函数

我们来实现string的构造函数.

复制代码
 string(const char* str = "");

思路:获取字符串str的长度(strlen获取,但是这样不包括'\0'的长度),然后给_str开辟它长度+1,(为了包含'\0') ,同样给_capacity初始化它的长度,最后用strcpy把str的内容拷贝到_str内

复制代码
	string::string(const char* str)
		:_size(strlen(str))
	{
		_str = new char[_size + 1];
		_capacity = _size;
		strcpy(_str, str);
	}

初始化列表只初始化_size的原因与私有成员声明的顺序有关,不方便全在初始化列表**。会先初始化_str,这就会导致strlen函数会重复调用多次(因为strlen不能先初始化给_size,导致_str只能先自己调用strlen),效率较低。也不方便修改私有成员,随意修改可能导致初始化顺序出错。**

2.2拷贝构造函数

复制代码
string(const string& s);

思路:和构造函数一样,只不过有一点改变:这次是string对象的别名,需要调用他底层的私有成员来拷贝,那么需要调整一下strcpy部分,并且不需要strlen,只需要调用底层的_size,_capacity彻底修改底层三个私有成员就行

复制代码
	string::string(const string& s)
		:_size(s._size)
	{
		_str = new char[_size + 1];
		strcpy(_str, s._str);
		_capacity = s._capacity;
	}

2.2.1现代写法

swap函数,2.4有介绍。

复制代码
	string::string(const string& s)
	{
        string tmp(s._str);
        swap(tmp);
	}

调用构造函数构造一个和s_str构造一样大的空间,一样的值,而这个刚好就是 *this 指针的需求,于是直接用swap调换*this和tmp的资源,这样就完成了拷贝构造。本质上效率没有提升,只是提供了一个新颖的思路,代码更简洁。本质是代码复用

2.3析构函数

复制代码
~string();

思路:析构函数,用delete销毁_str,并且置空就行,_size 和_capacity不需要修改,因为对象销毁了它们没意义了,也没申请资源。 注意:_str本质上是指向一片连续空间(字符串),所以要用delete[ ]

复制代码
	string::~string()
	{
		delete[] _str;
		_str = nullptr;
		//_size = 0;             //可有可无,写了不会错,更直观。
		//_capacity = 0; 
	}

2.4赋值运算符重载 结合 swap 函数 (现代写法)

复制代码
string& operator=(const string& s);

思路:和拷贝构造函数的区别就是,赋值运算符重载针对的是已存在的对象,那思路基本一致,

但需要注意:赋值不是初始化,两边都申请了资源,资源有大小之分,大的赋值给小的,就需要小的开空间,不然就会越界写内存

复制代码
    string& string::operator=(const string& s)
	{
		//排除自己赋值自己的清空,少做无用功
		if (this != &s)
		{
			string tmp(s);
			swap(tmp);
		}
		return *this;
	}

所以,std : : swap函数就很好的解决了这一点:

复制代码
void string::swap(string& s)
{
    std::swap(_str, s._str);
    std::swap(_size, s._size);
    std::swap(_capacity, s._capacity);
}

我们的swap函数通过调用了标准库的swap函数,实现了资源的交换,不需要我们自己麻烦地比较资源大小和交换。 其实这个函数,以前效率十分低下,需要进行三次深拷贝,代价极大。但有了移动构造和右值引用,并且编译器各种超前优化后,效率还很可观,以后再介绍

2.4.1更简洁的现代写法

复制代码
    string& string::operator=(const string tmp)
	{
		swap(tmp);
		return *this;
	}

s1 = s3; tmp在传参的过程中就调用拷贝构造函数,获取了和s3一样的资源 ,这招太狠了。现代写法更注重简洁,直接去除了自我赋值的判断,毕竟很少出现。

3.各种逻辑简单的小函数

3.1 C风格字符串 c_str

复制代码
 //不复杂,不分离
 const char* c_str() const
 {
     return _str;
 }

C风格字符串,而string底层就是C类型字符串**(一片连续数组空间,并且以'\0'结尾)**

直接返回_str

3.2 清空数据 clear

复制代码
  void clear()
  {
      _str[0] = '\0';
      _size = 0;
  }

清空数据,直接在0位置给终止符,并且长度设置为0就行,但请注意不需要清理空间!

3.3 访问_size和_capacity的接口

复制代码
   size_t size() const
   {
       return _size;
   }

   size_t capacity() const
   {
       return _capacity;
   }

直接返回就行。

3.4operator[ ]

复制代码
   char& operator[](size_t index)
   {
       assert(index < _size);
       return _str[index];
   }

   const char& operator[](size_t index) const
   {
       assert(index < _size);
       return _str[index];
   }

库中重载了两个版本,无非就是 const修饰this指针 的区别 ,逻辑很简单,返回_str对应下标的值就行。但需要注意断言检查,避免越界读写

3.5判空 empty

复制代码
 bool empty() const
 {
     return _str == "";
 }

直接判断是否为空。

3.6迭代器 iterator

复制代码
public:

    typedef char* iterator;

    typedef const char* const_iterator;

前面几篇提及过,迭代器是行为逻辑很像指针的东西,那我们直接用让指针 typedef 为 iterator

复制代码
   iterator begin()
   {
       // 返回首地址
       return _str;
   }
   iterator end()
   {
       //返回last+1地址
       return _str + _size;
   }
   const_iterator begin() const
   {
       // 返回首地址
       return _str;    
   }
   const_iterator end() const
   {
       //返回last+1地址
       return _str + _size;
   }

然后如图所示,直接返回。

4.常见比较类的运算符重载(<,=)

标准库中,这些是定义为全局函数的,我们就定义为成员函数,因为主要是为了了解底层

4.1 operator==

复制代码
bool operator==(const string& s) const;

思路:比较是否相等,那就有两种情况,第一种,长度不同,直接false。第二种,长度相同,那就循环比较每一个字符,相同就true,否则false

复制代码
bool string::operator==(const string& s)
{
	//长度不同直接排除
	if (_size != s._size)
	{
		return false;
	}
	//长度相同,比较内容,一旦不同,false,循环结束
	size_t i = 0;
	while (i < _size)
	{
		if (_str[i] != s._str[i])
		{
			return false;
		}
		i++;
	}
	return true;
}

也可以偷懒,直接用strcmp实现:

复制代码
   bool operator==(const string& s) const
    {
      int res = strcmp(_str, s._str);
      if(res == 0)
        return true;
      return false;
    }

4.2 operator<

复制代码
bool operator<(const string& s) const;

思路:循环遍历两串字符(判断条件是两者的_size),比较每个数据的ASCII值,不相等就返回

_str[ i ]<s._str[ i ],循环结束有一种可能:其中一个字符串较短,那就返回_size<s._size

复制代码
bool string::operator<(const string& s) const 
{
	size_t i = 0;
	while (i < _size && i < s._size)
	{
		if (_str[i] != s._str[i])
		{
			return _str[i] < s._str[i];
		}
		i++;
	}
	//有一个较短,出了循环
	return _size < s._size;
}

4.3其他的重载都可以用这两个复现

复制代码
bool operator>(const string& s) const
{
    return !(*this <= s);
}

bool operator>=(const string& s) const
{
    return !(*this < s);
}

bool operator!=(const string& s) const
{
    return !(*this == s);
}

bool operator<=(const string& s) const
{
    return *this < s || *this == s;
}

5.扩容 reserve

复制代码
void reserve(size_t n);

思路:扩容,首先 n 大于需要扩容的对象才扩容,那就先开一个 n+1(预留 '\0' )的空间,把原字符串数据拷贝过去,然后释放旧资源。这时候,新空间就已经开好,只需让 _str 指向这片空间,再把_capacity修改成 n 就完成了扩容

复制代码
    void string::reserve(size_t n)
	{
		if (n > _capacity)   //大才需要扩容,小于不需要扩容
		{
			char* tmp = new char[n + 1];
			strcpy(tmp, _str);
			delete[] _str;
			_str = tmp;
			_capacity = n;
		}
	}

tmp 是局部指针,栈区,出作用域就销毁了,不需要释放。

6.push_back和append ,operator+= 和 operator+

6.1push_back

复制代码
void push_back(char c);

思路:尾插,那就判断空间是否满了,_size==_capacity,满了就需要开空间,那我们就二倍扩容,扩容完把字符 c 插入(此时字符 c 占据了 \0 的位置,没有 \0 了),最后补上 \0

复制代码
	void string::push_back(char c)
	{
		//在字符串末尾+1字符c,需要检查空间是否足够,适当扩容
		if (_size == _capacity)
		{
			reserve(_capacity == 0 ? 4:_capacity * 2);
		}
		_str[_size] = c;
		_size++;
		_str[_size] = '\0';
	}

6.2append

复制代码
void append(const char* str);

思路:尾插字符串,那二倍扩容可能就不适用了,因为插入的可能远不止原字符串的二倍 ,那就换个扩容方案,选二倍扩容或者 _size+len 的长度之中较大的扩。然后strcpy拷贝字符串,最后_size+=len

复制代码
void string::append(const char* str)
{
	size_t len = strlen(str);
	if (_size + len > _capacity)
	{
		//更好的扩容:如果我原来的两倍够插入就行,两倍不够你插入,就加多少扩多少
		reserve(max(_size + len, _capacity * 2));
	}
	//从最后一个字符后开始插入字符串,strcpy会自动拷贝 \0 ,所以不用考虑这个
	strcpy(_str + _size, str);
	_size += len;
}

6.3 operator+=和operator+

这两个直接复用push_back 和 append 就行. 这两个的区别就是 operator+ 不能改变对象,所以需要返回局部变量tmp。

复制代码
  string& operator+=(const char* str)
  {
      append(str);
      return *this;
  }
  string& operator+=(char c)
  {
      push_back(c);
      return *this;
  }
  string string::operator+(const string& s)
  {
      // +的逻辑,复用+=就行,注意不要返回*this,只需要返回结果
      string tmp(*this);
	  tmp += s._str;
	  return tmp;
  }

7.查找find

复制代码
// 返回c在string中第一次出现的位置
size_t find(char c, size_t pos = 0) const;

// 返回子串s在string中第一次出现的位置
size_t find(const char* s, size_t pos = 0) const;

find重载了两个版本,查找字符,查找子串。

7.1查找字符

查找字符:如果pos位置不在_size范围内,直接返回npos。然后从pos位置开始遍历字符串,如果找到相等,就返回位置,遍历结束没找到返回npos。

复制代码
size_t string::find(char c, size_t pos) const
{
	if (pos >= _size)
	{
		return npos;
	}
	for (; pos < _size; pos++)
	{
		if (_str[pos] == c)
		{
			return pos;
		}
	}
	return npos;
}

7.2查找子串

查找子串:先断言检查pos位置是否合法。然后利用strstr函数,查找子串,找到了就返回位置,没找到返回npos。

复制代码
    size_t find(const char* s, size_t pos = 0) const
    {
        assert(pos < _size);
        const char* ptr = strstr(_str+pos,s);  //找子串函数strstr
        if (ptr)    //不为空,就是找到了
        {
            return ptr - _str;  //找到了返回索引
        }
        else
        {
            return npos;
        }
    }

8.指定位置插入insert

复制代码
         //在pos位置上插入字符c/字符串str,并返回引用
string& insert(size_t pos, char c);

string& insert(size_t pos, const char* str);

一样,重载了两个版本,插入字符,插入字符串

8.1插入字符

插入字符:先检查pos位置的合法性,注意,_size+1处也能插入,所以是pos<=_size. 然后检查空间是否足够,不够就二倍扩容。

然后循环,从最后一个元素开始,到pos位置结束,把元素一一向后挪(最后一个元素会覆盖\0 ),然后插入字符,最后补上 \0

复制代码
	string& string::insert(size_t pos, char c)
	{
		assert(pos <= _size);
		//检查空间是否足够插入,不足够二倍增容
		if (_size == _capacity)
		{
			reserve(_capacity == 0 ? 4 : _capacity * 2);
		}
		int end = _size;
		while (end >= (int)pos)
		{
		     //一步一步实现整体往右挪动一位
			_str[end + 1] = _str[end];
			end--;
		}
		_str[pos] = c;
		_size++;
		_str[_size] = '\0';
		return *this;
	}

8.2插入字符串

插入字符串:和插入字符思路一样,整体向后挪动len个字符,也就是最后一个字符挪到_size+len位置,然后依次类推,一个一个往后挪,空出len长度 ,以便插入字符串。注意,不是二倍扩容了,因为字符串可能极长,因此扩容逻辑和append一样。

这两个函数,循环中都用到了强制类型转换 ,因为end>=0的逻辑,会使得end最终变-1.pos原本是size_t类型,表达式进行比较时,会将end转换为size_t类型,此时end为-1,size_t的- 1 就变成INT_MAX了,这时候循环就不会停止,无限循环直到内存溢出

9.截取子串substr

复制代码
string substr(size_t pos = 0, size_t len = npos) const;

思路:同样,先断言检查pos位置合法性。注意到len的缺省值为npos,说明不传参数就是直接取完。同时,len长度过长(从pos位置开始的len已经大于字符串剩余长度),那就等同于直接取完,即len = npos。接下来,建造子串,将pos到len的每个数据,循环赋入子串,最后传值返回子串。

复制代码
	string string::substr(size_t pos, size_t len) const
	{
		assert(pos < _size);
		if (len == npos || len > _size - pos)
		{
			//若pos + len 超过了后面的长度,或者len极大,那就去剩下的字符就行
			len = _size - pos;
		}
		//异常len被修正,接下来正常操作,取出子串:1.建造子串,将pos到len的每个数据,循环赋入子串
		string subStr;
		subStr.reserve(len);
		for (int i = 0; i < len; i++)
		{
			subStr += _str[pos + i];
		}
		return subStr;
	}

10.erase

复制代码
string& erase(size_t pos = 0, size_t len = npos);

思路:先断言检查pos位置是否合法,\0位置不能删,所以范围是(0,_size)。然后,判断pos开始剩余长度是否够删,若不够,则直接在pos位置加终止符\0,_size = pos; (相当于删完了) ,若够删,则利用strcpy把pos后len的位置覆盖到pos位置。并_size-=len

复制代码
    //删除pos位置上的元素,并返回该元素的下一个位置
    string& erase(size_t pos = 0, size_t len = npos)
    {
        assert(pos < _size);
        //在pos要删除的len大于或者等于_size,直接删除到结尾
        if (pos + len >= _size)
        {
            _str[pos] = '\0';
            _size=pos;
        }
        else
        {
            strcpy(_str+pos,_str+pos+len);
            _size -= len;
        }
        return *this;
    }

11.getline 和 operator>>,operator<<

三个全局函数。

11.1operator<<

复制代码
friend ostream& operator<<(ostream& _cout, const bit::string& s);

遍历打印就行,很简单。

复制代码
ostream& operator<<(ostream& _cout, const bit::string& s)
{
	for (auto ch : s)
	{
		_cout << ch;
	}
	return _cout;
}

这里利用了范围for,也可以不用,怎么喜欢怎么来。

11.2operator>>

复制代码
friend istream& operator>>(istream& _cin, bit::string& s);

思路:先clear清空字符串,避免了新输入追加到旧字符串末尾的问题。然后利用get()读取输入的下一个缓冲区字符,再开一个char数组,存放字符。然后while循环插入字符进数组,数组满了就插入进 s ,直到遇到换行符 或者 空格 (cin遇到换行符空格会停止读取)

复制代码
istream& operator>>(istream& _cin, bit::string& s)
{
	//为什么要清空?因为s本身可能还有字符串,比如"xxx",不清空,cin就会在他之后继续读取
	//这就导致原本需要读取"hello",结果输出后变成了"xxxhello"
	s.clear();
	char ch;
	char buff[256];
	int i = 0;
	ch = _cin.get();
	//不能用_cin>>ch!   
	//因为cin的特性是会自动跳过空白和换行,只读取有效字符,所以进入循环的都是有效,无法出循环
	//需要使用get,什么都会读取,让我们在循环中自己判断有效无效,这也刚好符合我们写这循环的意图。
	while (ch != '\n' && ch != ' ')
	{
		buff[i] = ch;
		i++;
		if(i==255)   //buff满了
		{
			buff[i] = '\0';
			s += buff;
			i = 0;
		}
		//s += ch;    //用上面的逻辑更好,不用反复开空间,也不会浪费空间,因为是栈帧的,局部空间。
		ch = _cin.get();
	}
	//i>0 ,说明buff数组没满就读取空格、换行出循环了,此时直接+=剩下的数,但注意先给buff终止符
	if (i > 0)
	{
		buff[i] = '\0';
		s += buff;
	}
	return _cin;
}

具体细节,注释都有解释。char数组的意义是避免+=频繁扩容。

11.3 getline

复制代码
istream& getline(istream& _cin, string& s, char delim = '\n')

思路:区别就是,getline的终止符默认是换行符,遇到空格不会停止读取,并且可以自己设置终止符,所以我们直接复用operator>>

复制代码
istream& getline(istream& _cin, string& s, char delim = '\n')
{
	s.clear();
	char ch;
	char buff[256];
	int i = 0;
	ch = _cin.get();
	while (ch != delim)
	{
		buff[i] = ch;
		i++;
		if(i==255)
		{
			buff[i] = '\0';
			s += buff;
			i = 0;
		}
		ch = _cin.get();
	}
	if (i > 0)
	{
		buff[i] = '\0';
		s += buff;
	}
	return _cin;
}

没封装,所以代码重复率高了点,可以封装内部代码,再将while判断条件单独封装一个bool函数即可。


12.简单string的bug

我们实现的字符串,很多都是有bug的,字符串中可能有\0,比如 "hello\0world" ,C语言的函数遇到\0就会停止,所有会出问题。

解决办法就是比如:

复制代码
//strcpy(_str,s._str)
memcpy(_str,s.str,s._size+1); 

换成memcpy函数。为什么是s._size+1 ? 因为memcpy不像strcpy一样会自动拷贝\0 ,所以要多一位。

我们实现的string,是为了了解底层,能实现大部分功能就行了,毕竟上述情况还是极少出现的。


相关推荐
Elias不吃糖33 分钟前
LeetCode每日一练(3)
c++·算法·leetcode
say_fall34 分钟前
C语言编程实战:每日一题 - day7
c语言·开发语言
LiLiYuan.1 小时前
【Lombok库常用注解】
java·开发语言·python
小龙报1 小时前
《算法通关指南数据结构和算法篇(2)--- 链表专题》
c语言·数据结构·c++·算法·链表·学习方法·visual studio
Charles_go1 小时前
C#中级45、什么是组合优于继承
开发语言·c#
mjhcsp1 小时前
C++ 动态规划(Dynamic Programming)详解:从理论到实战
c++·动态规划·1024程序员节
随意起个昵称1 小时前
【二分】洛谷P2920,P2985做题小记
c++·算法
二川bro1 小时前
数据可视化进阶:Python动态图表制作实战
开发语言·python·信息可视化
q***2512 小时前
java进阶1——JVM
java·开发语言·jvm