C++:string(4)

1. 模拟实现一个简洁的string

我们尝试进行对string模拟实现,并不是为了自己弄出来一个更好的string,而是为了能更好的理解string的底层,去进一步深入了解string,自己把这一条路走一遍。

因为是模拟实现,所以要把声明、定义、测试文件分离,创建三个新的文件:

1.1 string的本质

首先要知道string的底层到底是什么,可以说其实string底层就是一个顺序表:

就像这样,有一个字符指针,一个表示字符长度大小,一个表示字符串的容量大小。但是这里还有一个问题:因为我们放开了std这个标准命名空间,那我们自己模拟实现的这个string和库里面的string会有冲突。这个时候就要用到我们在C++入门知识中学到的,不同类域的知识。我们只需要把我们自己定义的这个string类单独放在一个命名空间就行。就像这样:

1.2 string的构造函数

把自己定义的string类放到了chen这个命名空间里面。这样的话就可以解决命名冲突的问题。现在我们给这个string类里面添加一些东西,先写带参数的构造函数和无参数的构造函数,我们在定义的文件string1.cpp 中实现:

首先对于带参数的构造函数来说,因为_str是用来存储字符串的,所以必须要先有一个空间,那就要new一个有一个空间大小的char类型数组,先存放一个 ' \0 ' ,并且,对于带参数的构造函数来说,因为参数当中被const修饰,而成员变量_str不是const修饰的,如果说直接写成_str( str ),就会涉及权限的放大,所以我们需要开辟一个比str的长度还要大一个空间的空间大小(那一个空间是用来存放 ' \0 '的 ),然后再调用strcpy函数,将str的内容拷贝给_str。并且因为需要调用strcpy函数,这个函数是C语言库里面的函数,所以还需要在头文件string.h中包含一个C语言的头文件<string.h>。

另外在这里其实还有一个小小的问题,我们在这里调用了三次strlen,而strlen这个函数的时间复杂度是O(n),那能不能尝试优化一下呢,就是少调用几次strlen。那我们就可以先去初始化_size,然后用_size的值赋给_capacity和_str,就像这样:

但是这样写的话依旧存在问题,因为我们在类和对象中讲初始化列表的时候提到过,初始化列表中初始化的顺序是按照类里面的pravite限定的成员变量的声明的先后顺序来的:

而类里面的成员变量是先定义的_str,所以在初始化列表中也会先执行_str的初始化,那此时我的代码写的是:_str(new char[_size + 1]) ,此时这个_size是一个随机值,因为_size还没有被初始化呢,所以就会导致越界,因此,最简单的方法就是:修改一下成员变量声明的顺序。

就像这样。但是从这个问题中我们能发现,成员函数声明顺序对我初始化列表初始化的顺序影响太大了,万一以后哪天不小心改了一下成员函数声明顺序,那我的成语又会崩溃了,并且像上图这样的写法,看着也不太舒服,所以最好的办法就是不用初始化列表初始化,而是用函数体内初始化,就像这样:

到这里,带参数的构造函数就写完了。

那现在,我们尝试一下把无参数的构造函数和有参数的构造函数进行合并,用缺省参数的内容就可以解决这个问题。

我们只需要在有参数的构造函数前面给出一个空格的缺省值就可以,当不传参的时候,自动让str为缺省值,然后进行函数调用。

1.3 string的析构函数

析构函数就比较简单,因为上面构造函数new的部分使用了方括号,在这里就要写对格式,然后再把_str置为空,让_size和_capacity的值重新变成0就可以了。

1.4 string的size和operator[ ]

接下来我们再来实现一下size函数和运算符重载。

对于size用了const修饰,是为了保证我们只是求出字符串的长度,对字符串的内容不进行修改。而operator[ ]声明了一个用const修饰和不用const修饰的,意思就是一个是只能以"只读"的形式访问,一个是可以访问并且修改的。

size这个函数比较简单,因为我们在构造函数中定义了_size,所以求字符串长度只需要返回_size就可以了:

对于operator[ ],因为我们是要访问下标为 i 的字符,在执行程序的时候首先要判断我的下标是不是正确的,有没有越界,就可以用assert函数进行判定,当然使用这个函数需要包含一个头文件<assert.h>,并且只要返回_str这个字符串中对应的下标 i 的内容就好了:

1.5 string的c_str函数

对于自定义类型的对象,是没有办法直接使用流插入和流提取的,必须要自定义运算符重载。因为自定义运算符重载这一块涉及的知识点比较多,所以在这里我们还没有定义operator<<,我们用另一个更简单的方式去实现这个操作,就要用到c_str函数。

因为c_str的返回类型是const char* 类型,对于自定义类型是没有办法直接使用流插入和流提取,所以我们就把原字符串转化成别的类型,然后就能用cout<<进行打印操作了。

代码也是非常简洁,直接返回原来的字符串就可以了。

接下来,我们就可以先暂时在test.cpp这个测试文件上测试一下我们自己实现的string类的部分:

在这里我先定义了一个string类对象st1,其内容是123456,然后使用循环,调用了size函数,然后让st1中每个字符的++,最后将变化后的st1打印出来,结果是234567,符合我们的预期,说明我们的代码写的是没有问题的。

1.6 模拟实现范围for和迭代器

这边有一个小问题:既然这边我都用了for循环进行遍历操作,那能不能用范围for呢?范围for在我所写的文章《C++:string(1)》里面提到过:https://blog.csdn.net/2502_91842264/article/details/155826518?fromshare=blogdetail&sharetype=blogdetail&sharerId=155826518&sharerefer=PC&sharesource=2502_91842264&sharefrom=from_link

我们来尝试一下直接使用看看:

这里面报错说,"找不到可调用的begin函数以及end函数",我并没有主动调用begin或者end函数啊,这就说明范围for的底层需要自动调用这两个函数,并且我们在之前的文章中提到过,范围for的本质其实是转化成了对应的迭代器。而迭代器就是一个像指针一样的东西,begin返回的迭代器是字符串开头的位置,end返回的迭代器是字符串结尾的位置的下一个位置,即 ' \0 '。

那既然是像指针一样的东西,我就可以利用指针来实现:

我给char*定义成一个新的名字:iterator(迭代器),然后去定义begin函数和end函数。

要注意这里的写法,因为iterator是重定义的一个新名字还是属于string类里面的,所以要加上域作用限定符,另外的begin函数因为也是在string类里面声明的,所以也需要加上限定符。

这样的话,范围for就可以正常执行了。

既然我们刚刚实现了迭代器,那我们再执行一个迭代器类型的代码试试看:

我们定义了一个迭代器it1,让它等于st3的初始位置,然后就像是用指针的方法去写代码。

大家一定要明白,迭代器不是指针,只是对于string这样的顺序表实现的类型,用迭代器的操作看起来像指针而已,给大家看一下C++库里面真实的迭代器到底是什么:

我的编译器用的是x86也就是32位环境下,我们刚刚自己定义的string里面的类是上面的char*,而C++库里面的类就是下面的这个非常复杂的一个_String_iterator的类,并且这个类的模板也非常复杂。

所以大家要明白,在这里说像指针只是为了方便大家去理解,并不是说迭代器真的就是指针。我们在这里用的使用指针去实现一个迭代器,只是实现迭代器的其中一种方式而已。

1.7 string中的插入修改和扩容

我们先来实现一下尾插:push_back和append。我们知道append其实有很多种参数类型,我们在这里只实现最核心的且最常用的:插入一个字符串。

首先来实现push_back,对于尾插来说,第一步要做的就是先检查容量够不够,如果不够的话就涉及到扩容的问题。如果涉及到扩容问题,就还需要模拟实现一个函数:reserve。

在扩容的时候,一般都是二倍扩容,所以需要原来的容量去乘以2,那就又存在一个问题,如果原来的容量为0怎么办,因为在我们一开始构造函数当中,缺省值给的是一个空格,所以如果初始化的时候不传参,那容量在一开始就是0。这个时候,我们就可以用一个三目运算符来解决:

这就是先创建一个新的变量newcapacity,赋值_capacity,看看_capacity是不是0,如果是0的话,就让newcapacity等于4,不是0的话,就让newcapacity等于2*_capacity。然后再reserve扩容newcapacity的大小。这样的话,就可以开始实现添加字符的代码了:

这里需要注意的是:最后不要忘记加上末尾的' \0 '。

接下来再解决刚刚的reserve扩容的问题,首先上来先需要判断,传值传参的这个值,是否比现在的容量要大,如果大的话才需要扩容,如果小的话就不缩容,返回当前容量。并且在扩容的时候,分为原地扩容和异地扩容,我们在这里展示异地扩容。异地扩容是先开辟一个n大小的空间,把原来的内容拷贝过来,再把原空间的内容清除,然后释放原空间:

接下来给大家测试一下我们刚刚写的代码:

这里先是调用构造函数,创建了一个string类对象st1,然后存储字符串abcde,接着调用push_back函数,在push_back的调用过程中,因为要插入一个字符x,所以还调用了reserve函数扩容,然后调用c_str函数,转化成C语言风格字符串,将其打印,最后调用析构函数,清理资源释放空间。

刚刚模拟实现了push_back,它的作用是插入一个字符,现在继续模拟实现一下append,用于插入一个字符串。这里首先也是要判断容量是否足够,但是存在一个问题,它和push_back不一样,push_back可以直接二倍扩容,因为push_back仅仅是插入一个字符串,二倍扩容肯定够用了。但是append插入的是字符串,一次二倍扩容之后的容量大小可能还是不够。所以要先计算一下插入的字符串有多少,然后再根据具体长度进行reserve扩容。那也有可能一次插入的是一个短串,如果是连续插入的短串,就要频繁的扩容,不太方便,对于这样的话我们就可以加一个判断条件,我一开始先默认是二倍扩容,用于支持短串插入,如果二倍扩容后的结果还是小于我所需要的容量,那就再计算具体长度,然后需要多少就开多少容量。

扩容的操作就像这样,当然这里我这样写只是为了规避一些频繁短串插入的情况,如果要直接写reserve(_size + len)当然也是可以的。然后对于插入的操作,直接用strcpy拷贝,并且要注意的是,是从_str + _size 的位置开始拷贝。最后完整的代码就是这样:

接下来我们来实现operator += 的运算符重载。

直接通过psuh_back和append函数进行实现,然后返回string类对象即可:

接下来我们要实现的是insert和erase。这两个函数的主要特点是,在进行删除或插入的操作之后,字符串会自动进行后移或前移。

先来展示一下insert函数的插入一个字符。首先因为要保证,你选中的要插入字符的位置一定是小于或者等于目前字符串的长度,不然就会越界。所以要用assert判别一下。然后就是扩容操作。接着定义一个变量end,让其等于原字符串长度,然后让原字符串后面一个位置的内容等于前一个位置的内容,即进行内容交换。这样就相当于,给字符ch腾出了一个空位。然后再让第pos个位置的值等于ch。

这里其实会有一个问题,这里end的类型是size_t,是无符号整型,如果我的pos传值传的是,那么当进入while循环的时候,end一直减到0,此时依然满足end >= pos的条件,继续进入循环,然后end就会变成-1,但因为end是无符号整型,此时的-1就代表整型的最大值,那么这个循环的操作就会一直进行,知道end+1大于string对象的容量,然后越界崩溃。 所以要给end的类型改成int。这样按理来说,当end减小到0的时候就不会进入循环。

但是大家要想起来一个点:类型提升。因为end和pos一个是int类型,一个是size_t类型,两个类型不一样,在进入循环条件的时候,不是直接比较两个值的大小,而是先进行类型提升,即把范围较小的那个的变量类型,提升成范围较大的那个变量的类型,这样的话,end的类型就会从int又变成size_t,陷入和刚刚一样的问题。

所以我们在这里的解决办法就是:对pos进行强制类型转换。

或者可以用这样的写法:

这样的话,当end的下标为1的时候,就已经把下标为0的字符移动过来了,并且end再--,变成0,也不会进入循环。

然后对于insert的字符串的插入,就直接展示代码:

这是对insert函数分别插入一个字符和一个字符串的演示:

insert模拟实现完成后,接下来是erase。

首先erase这个函数需要用到一个静态全局变量npos,表示整数的最大值。我们需要先给npos声明。对于erase函数的定义来说,最简单的情况就是,从第pos个位置开始,后面的数据内容全部删除,这样的话只需要让第pos个位置等于 ' \0 ' 就可以了,后面的内容可以直接不用去管了。

第二种比较复杂的情况就是删除中间部分的字符,那就涉及到一个字符的移动。

在这里我们定义一个 i 变量,使其等于pos,然后让第 i 个位置的字符等于第 i+len个位置的字符,即进行字符交换,直到拷贝到 ' \0 ' 。

1.8 string中的流插入和流提取

因为正常的流插入的使用习惯是:" cout<< ",所以第一个参数类型必须是ostream,这样的话流插入就必须设置成全局函数而不是成语函数,因为如果设置成成员函数,this指针会默认抢占第一个参数位置。

大家可以看到,这个函数声明是放在string.h这个头文件底下的,与string类同再命名空间chen里面,被设置成了全局函数。

大家可以看到,在模拟实现的时候,是用c_str这个函数进行了封装。

但是实际上,这里有一个大坑。

大家来看,当遇到这样的场景时,我向st1这个对象里面+=了三个 ' \0 ',然后再+=一个感叹号,然后调用流插入的运算符重载将st1打印出来,按理说应该还有一个感叹号要打印出来的吧,但是结果却跟我们想的不一样。

因为刚刚的这个是我们自己实现的string类对象,现在来看一下C++库里面的string类对象,进行如上操作之后的结果:

大家就会发现这个感叹号就能打印出来了。说明我们模拟实现的还是有问题。

通过调试我们发现我们自己模拟实现的string类在插入的代码上是没有什么问题的,因为监视窗口上显示,我们插入的三个 ' \0 ' 和一个 ' ! ' 都插入成功了。所以问题就只能出现在最后的流插入的思路上。

因为我们说,c_str是返回的const char*类型,而const char*类型结束的标志就是遇到斜杠零 ( ' \0 ' ),所以在调用c_str时,遇到st1中的abcde后面的第一个 ' \0 ' 就直接停止了,所以在流插入的时候,只打印出来了abcde。所以就不能再用c_str了,它不能满足我们的需求,就要换另一种写法:

这里给出了两种写法,一种是范围for的循环方式,逐次打印出对象的内容。但是要注意因为operator<<的一个参数是const string&类型,所以要将begin和end函数写成const形式:

不然会有参数类型不匹配的报错。当然头文件中的声明也要修改。

第二种就是普通的for循环,逐次将对象中的内容打印出来。

通过这两个方法,就可以规避掉c_str函数遇到 ' \0 ' 就停止的错误。

另外这里还会出现一个问题:

首先,每次的打印我分为两次,一次是修改过的流插入,一次是直接打印st1通过c_str函数之后的值。结果在我往st1的后面继续+=了十个字符y组成的一个字符串,打印出来的结果却有了问题,中间竟然出现了一个 " 屯 " 的字。

出现"屯"这类乱码,通常是因为字符串没有正确以空字符 '\0' 结尾。在 C/C++ 中,字符串是以空字符作为结束标志的字符数组。当我们操作字符串时,如果没有确保字符串末尾有 '\0' ,程序在处理字符串时就会"越界",读取到内存中其他无意义的内容,这些内容在输出时就会表现为乱码,像"屯" 、"烫"(其实常见的是"烫烫烫...",这通常是未初始化的栈内存默认值 0xCCCCCCCC 对应的字符表现,"屯"这类也属于类似的非法内存读取导致的乱码情况)。

因为前面还没有出现问题,直到+=了一个长字符串,那么在这里,应该就是+=运算符重载的问

题。且+=运算符重载是靠append实现的,那就是我们的append模拟实现出现了问题。

首先是append函数中的扩容问题,因为原来的容量是10,而字符串长度应该是abcde \0 \0 ! ,一共8个字符,那么先进行二倍扩容,扩容后容量是20,加入新字符串之后所需容量是18,所以扩容后的容量足够存储新的字符串。

然后调用strcpy函数进行拷贝,结果只把abcde\0拷贝过来了,包含 \0 的话一共拷贝了6个字符过来。这是因为strcpy函数也是遇到 \0 就要停止执行。但是我明明期望的是用strcpy函数拷贝9个字符过来,因为除了abcde \0 \0 ! ,末尾还应该有个 \0 作为结束标志。

而在扩容执行结束之后,目前还没有执行下一个strcpy的代码,我们通过监视窗口观察到,刚刚拷贝过来的abcde\0后面就是"屯屯屯屯屯屯屯",共七个屯,这是因为汉字的空间大小是两个字节,而容量大小是20,除了存储了abcde\0这六个字节外,还剩下14个字节,然后每两个字节存储一个汉字,所以是七个。

然后调用strcpy函数进行拷贝,从第_str+_size个位置开始,_size的值是8,所以就从第8个位置开始存储:

那么第六和第七两个位置,正好存储一个随机值"屯"。

这样的话,我们就找到了问题,是在append函数里的,reserve过程中的,strcpy使问题产生。就需要修改一下:使用memcpy。

memcpy和strcpy的区别就在于,memcpy可以指定拷贝的长度,而不是单单看到 \0 就停止。

这里要拷贝_size+1个是因为还要把 \0 也拷贝过去。

像这样,问题就解决了。

接下来模拟实现一下流提取 operator<< 。

要注意这里的参数不能给const,因为流提取是要插入到这个string类对象当中的,一定会涉及到对内容的修改,所以和流插入不一样。

我们来解释一下这个代码逻辑:假如说此时我在测试时写的代码是:cin>>st1。首先因为st1是我们的自定义类型,所以会调用运算符重载operator>>,进入函数后,此时函数参数中的in就是测试代码中cin的别名,函数参数中的s就是测试代码中st1的别名。然后创建一个临时变量ch,下一步调用C++中的istream标准库里面的流提取 >>,此时弹出调试控制台,我们输入123 ,此时123就会先暂存到输入流的缓冲区,然后C++中的istream标准库里面的 >> 会先读取123中的第一个字符 1 ,存储到临时变量ch当中,此时ch的值就是 1 ,然后进入循环,先判断ch 是否等于换行符或者空格,判别不是,则为true,进入循环。 接着将ch加到s上面去,注意这里的s就是我们测试代码中的st1,然后重复。

最后返回的是 in,这是为了支持能够连续的执行流提取的操作,比如:cin>>st1>>st2,先执行了cin>>st1,执行结束之后返回的又是cin,然后继续执行cin>>st2。

但是其实这里还存在一个问题,:

测试了一下刚刚我们所写的代码,我的预期是让st1等于123,让st2等于456,因为是连续输入,所以我在中间加了一个空格,输入完了之后我按下了enter换行键,本来预期是会弹出来123 456,结果却是光标一直在闪烁,并没有打印。

核心问题主要是因为:使用in >> ch读取字符,这里的>>是C++中的istream标准库里面的流提取 >> ,导致无法按空格/换行符分割字符串,输入123 456时st1会被赋值为 123456 , st2 为空。

那为什么会这样呢?其实是因为:istream的原生 >> 运算符读取char类型时,会自动跳过空格、换行等空白字符,函数中 while 循环判断 ch != ' ' && ch != '\n' 的逻辑完全失效,因为 in >> ch 根本不会读取到空白字符,会直接跳过空格读取后续的456字符,直到输入流结束,最终把所有非空白字符都拼接给了第一个字符串 st1 ,第二个字符串 st2 无字符可读。

标准库>>(针对基础类型如 int ):读取时会按数据类型规则分割,遇到非该类型的字符(如空格、字母)就停止当前数据的读取,保留该字符在输入流中供下一次读取。例如输入 123 456 读取 int 类型时,第一个 >> 读取 123 后停止,空格保留在输入流,第二个 >> 跳过空格读取 456 ,实现正确分割。

标准库 >> (针对 string ):同样以空白字符为分割符,遇到空格/换行就停止读取,本质是底层实现时会检测空白字符并终止当前字符串的读取,而非跳过空白字符继续读取。

而我们自定义重载的 >> :错误使用 in >> ch 跳过了空白字符,破坏了"遇空白分割字符串"的核心逻辑,与标准库 string 的>>行为完全相反,导致连续读取所有非空白字符。

所以要解决这个问题,只能放弃使用标准库中的>>,换用流提取中的另一个函数:get。

接下来简单测试一下:

并且在这里还有一点不够完善,在标准库里面的流提取中,如果说对象原来就有内容,然后再调用流提取,会将原来的内容给覆盖掉,比如说:有一个st1的内容是:hello,现在调用cin>>st1,然后输入值123,再将st1打印出来,结果就是123。

但是对于我们自己实现的这个函数,中间有一行代码:s += ch;是将cin的内容直接插入到原内容的后面,所以这与标准库中的流提取有些差别,我们自己模拟实现的结果就是这样:

所以我们在这里需要做一个改进,模拟实现一个函数:clear。这个函数的作用是清空目前对象中的所有内容,但不释放空间。

说是清空,但是实际上只要令首位的内容等于 ' \0 ' 就可以,并且把_size置为0。

调整之后的代码就是这样:

接下来我们来简单测试一下:

目前代码当中其实还存在一个小坑,核心原因还是因为我们用的是 s+=ch。如果输入的是一个短字符串到没什么问腿,如果输入的是一个非常长的,比如几千个字符呢?那对于s来说,就会频繁扩容。而且因为s是每次+=一个字符,对于加一个字符来说,底层其实调用的是push_back:

而大家可以看到push_back每次扩容都是二倍扩容,如果说输入的是一个长串,以输入了257个x为例,在一个string类对象本身就为空的情况下,那就会扩容7次,并且第7次的时候扩完的容量是256,所以还需要再扩一次,而扩第8次的时候就扩容成了512,会有很多的空间浪费。所以2倍扩容的弊端在于:前几次的扩容量小,需要扩容次数多,越往后每次扩容的数量更多,容易造成浪费。

既然可能会频繁扩容,那就先提前给容量扩的足够大不就好了?那如果我只是输入一个长度很小的短串呢?那提前扩的大容量不就是浪费了吗。但是如果提前扩的不够大,那当遇到一个长串的时候,还是会遇到频繁扩容的问题。所以提前扩容这条路在这里是走不通的。

我们可以尝试用这种思路:

我们先创建一个临时数组buff,然后给buff设置一个空间,不断地让buff存储提取到的ch的值,在这里因为buff的容量是128,所以当 i 等于127的时候,就说明存储满了,那就让数组最后一个位置的值为 ' \0 ' 然后让s一次性+=buff,且buff是一个字符数组,相当于加的是一个字符串,那么我们来看+=的模拟实现:

因为+=的函数对于字符串来说是用append封装的,而在append中。每次扩容的大小是_size+len,就会比二次扩容的误差更小。当ch取到空格或者换行符时,若此时buff还没有存满,那就直接让第i个位置变为 ' \0 ' ,然后再让s+=buff就可以了。 那么对于同样的如果要输入257个字符,在一个string类对象本身就为空的情况下,用这种方法只需要扩容3次,并且不会造成空间浪费。 并且如果把buff这个字符数组的长度修改成254,就可以只需要扩容两次。这样的话就可以规避掉2倍扩容的一些弊端。

这里还有一个问题,刚刚我们使用了get函数,解决的是当连续输入值的时候流提取的正确性。但是如果说只有一个string类对象,我想让它的内容为hello world,按照我们刚刚的逻辑,流提取只会提取hello,因为当读到空格的时候,就会默认停止,就像这样:

这个时候就会想到string库中的一个提取整行内容的函数:getline。

1.9 string中的非成员函数

getline这个函数是读取到delim这个字符之后才会停止读取,我们在这里直接用缺省值,设置成换行符,即如果不指定标志符号,那就默认提取整个字符串。

大家会发现getline的模拟实现和流提取的模拟实现非常像,只是while循环的判定条件不一样。并且要注意的是,getline因为是直接调用的函数,所以不能在string类里面声明,要声明成全局函数。接下来简单测试一下:

1.10 string中的字符串操作

下面我们要模拟实现一下:find函数。

因为find函数作用是查找子字符或子字符串,并不会对要查找的内容进行修改,所以首先在函数声明时需要加上const。

对于查找子字符来说是比较容易的,我们只需要写一个循环,然后依次比对是否和我们要查找的字符相等就可以:

但是查找子字符串的话,就有些复杂,那么在模拟实现的时候,为了更加方便且容易理解,我们可以用strstr这个函数来完成实现,strstr这个函数在我之前的文章中提到过,这是文章链接:

https://blog.csdn.net/2502_91842264/article/details/153521039?fromshare=blogdetail&sharetype=blogdetail&sharerId=153521039&sharerefer=PC&sharesource=2502_91842264&sharefrom=from_link

因为strstr调用之后返回的是子串的起始地址,如果找不到就返回nullptr,所以我们就可以用一个指针来接收。并且因为我们想要的是起始的下标,就可以用返回的指针减去原字符串的起始指针,这样减出来的差值,就是我们想要的下标:

我们简单测试一下:

接下来要模拟实现的是substr函数。因为substr是截取需要的字符串,然后返回这个字符串。

首先先要判断一下传值过来的要截取的长度len是否符合要求,如果len是大于从pos位置开始到_size位置的长度的,那就让len等于_size - pos。然后还需要定义一个新的string类对象,遍历从pos到pos + len的位置,将这块区域内_str的字符依次拷贝到新的string类对象当中去:

要注意,因为我们希望只是截取原字符串中的子串,并不希望对原字符串进行修改,所以需要加上const。

并且在这里还存在一个问题:因为这里是传值返回,我们之前提到过,传值返回,返回的并不是变量本身,而是创建一个新的临时变量,然后将原变量的内容拷贝到新的临时变量当中去。而既然涉及到拷贝,那就需要拷贝构造函数。

如果没有写拷贝构造函数的话,当调用这个函数时,就会使用系统默认生成的拷贝构造函数,而系统生成的拷贝构造函数是浅拷贝,而在进行浅拷贝的时候,编译器只是将对象中的值拷贝过来,即临时对象是把ret中的值拷贝过来。而显式构造函数是深拷贝,是单独开辟一块新的空间用于拷贝内容。

那么在此时ret的_str和临时对象中的_str指向的就是同一块空间,因为临时对象中的_str的地址存的是ret的_str的地址。那么当函数调用结束时,出了这个作用域,就会调用析构函数,把ret的资源清理掉,ret的空间释放掉,此时临时变量还依然指向ret原来的_str指向的空间,就会有野引用的风险。

所以在这里还需要加上一个显示拷贝构造函数:

接下来再模拟实现一个赋值运算符重载:operator = 。

运算符重载operator = 和拷贝构造函数不太一样,比如说写出:st1=st2,是让st1的内容和st2的内容相等,并且st1中的_str指向新的空间,然后释放掉原来的空间。并且要保证不能自己给自己赋值,比如写成:st1=st1是不行的。

首先展示一下常规的写法:先开辟一个和目标对象一样大小的空间,然后再调用memcpy函数把目标对象的内容拷贝过来,再释放对象原来指向的空间,指向新的空间,最后再返回*this。常规的写法的特征就是:要进行赋值,就要老老实实的自己重新开辟一块新的空间。其实还有一种更巧妙的写法:

刚刚我们在开辟空间的角度,是自己去扩容开辟空间,但是这个写法是调用构造函数去开辟空间,然后调用了一个自定义函数swap。

这就是那个构造函数。然后在自定义函数swap里面,再去调用标准命名空间std里面的函数swap,将形参s的_str,_size,_capacity都和我们的目标对象中的_str,_size,_capacity去进行交换。相当于是借助了构造函数进行了一个深拷贝,再把深拷贝后的内容和我的目标对象进行一个交换,说实话有点像"借刀杀人"、"鸠占鹊巢"的感觉。

在这边还需要注意的一个点是,借助构造函数进行深拷贝之后,是有一个交换的操作,既然是交换,那st3也需要有空间大小,这样才能去交换,但是对于string st3 = st1这个代码来说,st3还没有调用构造函数,即没有初始化,所以在这里,我们还要给string类对象的成员变量加上缺省值:

同样的,对于拷贝构造函数也可以这样调用:

但是在这里的string tmp(s._str)不能写成string tmp(s),因为写成这样的话,就是又要调用拷贝构造函数,就会引发无限递归导致程序崩溃。

这个写法还可以更巧妙一些:

刚刚那个写法我们相当于是创建了一个string类的临时对象tmp,然后利用了拷贝构造进行了深拷贝。而这个写法充分的利用了传值传参要调用拷贝构造,且会生成一个临时变量的特点,所以都不需要我们自己再主动去创建临时变量了。在这里st2传值传参给tmp,会生成一个临时对象拷贝st2的值,再把临时对象的值拷贝给tmp,所以tmo就是一块全新的空间,并且tmp的所有值都和st2相等,这时候再进行swap交换就可以了。

当然这两个所谓的更巧妙的写法在效率上没有什么提升,只是写起来比较简洁,思路比较巧妙而已。

这边还需要提及一点,我们再刚刚的简洁写法当中,实现了一个自定义函数swap,里面使用了标准命名空间中的swap,然后在string标准库里面也有一个swap:

那标准命名空间里面的swap和string标准库里面的swap有什么区别呢?我们先来看看标准库里面的swap:

比如对于我们看标准命名空间里面给了一个样例,原来标准命名空间里的swap在交换的时候,会先调用拷贝构造函数创建一个变量c,然后让a=b,b=c。而调用显式拷贝构造函数都是深拷贝,所以在这里其实是三次深拷贝。而深拷贝的代价是需要额外分配独立内存并逐份复制资源,会带来更高的内存开销和时间成本,同时还需承担独立管理资源的维护成本。

而string标准库里面的swap,它的定义逻辑大概就是这样:

是分别调用3次标准命名空间中的swap,分别交换_str,_size_capacity。这样做的好处就是更加迅速且精准。

所以总结一下就是:std命名空间中的swap是通用模板函数,可交换任意类型对象(包括 std::string),对于复杂类型(如string)可能会触发多次拷贝/赋值操作;而std::string专属的swap成员函数是针对字符串内部结构(如指向字符数组的指针、大小、容量)做的专门优化,仅需交换几个内部成员变量的地址,几乎无额外开销,效率远高于通用版std::swap。

2. string中剩下的一些小细节

我们对标准命名空间里的string类还有一些小细节需要补充:

我们来看这样一段代码,我们创建了两个string类对象,然后分别打印出这两个对象的长度,因为sizeof计算的是string类里面成员变量的长度大小,而大家看到红色字体的部分,是成员变量存储的内容。那string类里面成员变量的长度大小的计算应该是:在x86也就是32位环境下,一个char*的指针,占4个字节,两个size_t类型,占8个字节,一共是占12个字节,但是在这里结果却显示是28个字节。

我们通过监视窗口来观察:

大家会发现,在st1里面有一个原始视图,我们点开会发现里面有一个叫_Buf的数组,而这个数组里面竟然存储着12345,这和st1的内容一摸一样。

大家看一下_Buf这个数组的下标一直到15,也就是存储了包含 ' \0 ' 的16个内容。

刚刚观察的是st1,现在来看一下st2:

我们会发现,现在_Buf这个数组里面是乱码一样的东西,而_Ptr这个数组里面存储的是st2的内容。

那结合刚刚我们看到的计算st1和st2的长度大小的结果是28,在这里就可以理解:其实vs编译器在底层做了一个优化:

其实在成员变量当中,除了我们自己创建的成员变量,还有一个字符数组_Buf,里面有16个内容对应16个字节,加上成员变量的12个字节,刚好是28个字节。到这里大家应该就能体会到,如果当string类对象的字符长度小于16的时候,就是存储在_Buf这个数组中,如果字符长度大于或等于16,就存储在_Ptr数组当中。

那为什么vs这个编译器要这么干呢?这是因为,我们在实际工程中,可能会创建很多很多个string类对象,而每个string都有一个char*的指针去指向堆上的空间,就会在堆上开很多小块的内存,而这样的话会导致两个问题:1.效率问题 。2. 内存碎片问题。那vs这个编译器为了规避这个问题,它就在string类对象里面开辟了一个小数组,用来存放一些短值,而这些存储在这个小数组中的内容,其实都是存储在栈上面的,而在栈上面存储的内容,使用的效率就很高,并且也规避了堆上的内存碎片化。而长一点的字符串就存储在_Ptr里面,就存储在堆上。

所以在我们之前讲到string里面reserve扩容,第一次扩容都是扩容到15,就是这个原因。

3. 头文件和函数定义文件的全部展示

接下来展示一下所有的代码,这个是头文件:

cpp 复制代码
//string.h
#pragma once
#include <string.h>
#include <assert.h>
#include <iostream>

using namespace std;

namespace chen
{
	class string
	{
	public:

		static const size_t npos;

		typedef char* iterator;

		iterator begin();

		iterator end();

		//string();
		string(const char* str = " ");

		string(const string& str);
		string operator = (const string& s);
		//string operator = (string tmp);

		~string();
		size_t size() const;
		char& operator[](size_t i);
		const char& operator[](size_t i) const;

		const char* c_str() const;

		void reserve(size_t n);

		void push_back(char ch);

		void append(const char* str);

		string& operator+= (char ch);

		string& operator+= (const char* str);

		void insert(size_t pos, char ch);

		void insert(size_t pos, const char* str);

		void erase(size_t pos, size_t len = npos);

		size_t find(char ch, size_t pos = 0) const;
		size_t find(const char* str, size_t pos = 0) const;

		string substr(size_t pos, size_t len = npos) const;

		void clear();

		void swap(string& str);

	private:
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity;

	};

	istream& getline(istream& in, string& str, char delim = '\n');

	ostream& operator<<(ostream& out, const string& s);

	istream& operator>>(istream& in,string& s);

}

这个是函数定义文件:

cpp 复制代码
//string.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include "string.h"
namespace chen
{
	//string::string()
	//	:_str(new char[1] {'\0'})
	//	, _size(0)
	//	, _capacity(0)
	//{}
	string::string(const char* str)
		:_size(strlen(str))
	{
		_str = new char[_size + 1];
		_capacity = _size;
		strcpy(_str, str);
	}

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

	string::string(const string& s)
	{
		//_str = new char[s._capacity + 1];
		//memcpy(_str, s._str, s._size + 1);
		//_size = s._size;
		//_capacity = s._capacity;
		string tmp(s._str);
		swap(tmp);
	}

	string string::operator = (const string& s)
	{
		if (this != &s)
		{
			//char* tmp = new char[s._capacity + 1];
			//memcpy(_str, s._str, s._size + 1);
			//delete[] _str;
			//_str = tmp;
			//_size = s._size;
			//_capacity = s._capacity;

			string tmp(s);
			swap(tmp);
		}
		return* this;
	}

	////st1 = st2
	//string string::operator = (string tmp)
	//{
	//	swap(tmp);
	//	return*this;
	//}

	string::~string()
	{
		delete[] _str;
		_str = nullptr;
		_size = _capacity = 0;
	}

	size_t string::size() const
	{
		return _size;
	}
	
	char& string::operator[] (size_t i)
	{
		assert(i < _size);

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

		return _str[i];
	}

	const char* string::c_str() const
	{
		return _str;
	}

	string::iterator string::begin()
	{
		return _str;
	}

	string::iterator string::end()
	{
		return _str + _size;
	}

	void string::reserve(size_t n)
	{
		if (n > _capacity)
		{
			char* str = new char[n + 1];
			//strcpy(str, _str);
			memcpy(str, _str, _size + 1);
			delete[] _str;
			_str = str;
			_capacity = n;
		}
	}

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

	void string::append(const char* str)
	{
		size_t len = strlen(str);
		if (_size + len > _capacity)
		{
			size_t newcapacity = 2 * _capacity > _size + len ? 2 * _capacity : _size + len;
			reserve(newcapacity);
		}
		strcpy(_str + _size, str);
		_size += len;
	}

	string& string::operator+=(char ch)
	{
		push_back(ch);
		return *this;
	}

	string& string::operator+=(const char* str)
	{
		append(str);
		return *this;
	}


	void string::clear()
	{
		_str[0] = '\0';
		_size = 0;
	}

	ostream& operator<<(ostream& out, const string& s)
	{
		//out << s.c_str();
		for (int i = 0; i < s.size(); i++)
		{
			out << s[i];
		}
		return out;
	}

	istream& operator>>(istream& in, string& s)
	{
		s.clear();

		char buff[128];
		int i = 0;

		char ch = in.get();
		while (ch != ' ' && ch != '\n')
		{
			buff[i++] = ch;
			if (i == 127)
			{
				buff[i] = '\0';
				s += buff;
				i = 0;
			}
			ch = in.get();
		}
		if (i > 0)
		{
			buff[i] = '\0';
			s += buff;
		}
		return in;
	}

	void string::insert(size_t pos, char ch)
	{
		assert(pos <= _size);

		if (_size >= _capacity)
		{
			size_t newcapacity = 2 * _capacity > _size ? 2 * _capacity : _size;
			reserve(newcapacity);
		}
		size_t end = _size + 1;
		while (end > pos)
		{
			_str[end] = _str[end -1];
			--end;
		}
		_str[pos] = ch;
		++_size;
	}

	void string::insert(size_t pos, const char* str)
	{
		size_t len = strlen(str);
		if (_size + len > _capacity)
		{
			size_t newcapacity = 2 * _capacity > _size + len ? 2 * _capacity : _size + len;
			reserve(newcapacity);
		}
		size_t end = _size + len;

		while (end > pos + len - 1)
		{
			_str[end] = _str[end - len];
			--end;
		}
		
		for (int i = pos; i < pos + len; i++)
		{
			_str[i] = str[i - pos];
		}
		_size += len;
	}

	const size_t string::npos = -1;

	void string::erase(size_t pos, size_t len)
	{
		assert(pos <= _size);

		if (len == npos || len >= (_size - pos))
		{
			_str[pos] = '\0';
			_size = pos;
		}
		else
		{
			size_t i = pos;
			for (i; i + len < _size; i++)
			{
  				_str[i] = _str[i + len];
			}
			_size -= len;
			_str[_size] = '\0';
		}
	}

	size_t string::find(char ch, size_t pos) const
	{
		for (int i = pos; i < _size; i++)
		{
			if (_str[i] == ch)
			{
				return i;
			}
		}
		return npos;
	}

	size_t string::find(const char* str, size_t pos) const
	{
		const char* p1 = strstr(_str + pos, str);
		if (p1 == nullptr)
		{
			return npos;
		}
		else
		{
			return p1 - _str;
		}
	}

	string string::substr(size_t pos, size_t len) const
	{
		if (len == npos || len >= (_size - pos))
		{
			len = _size - pos;
		}

		string ret;
		ret.reserve(len);
		for (int i = 0; i < len; i++)
		{
			ret += _str[pos + i];
		}
		return ret;
	}
	
	istream& getline(istream& in, string& str, char delim)
	{
		str.clear();

		char buff[128];
		int i = 0;

		char ch = in.get();
		while (ch != delim)
		{
			buff[i++] = ch;
			if (i == 127)
			{
				buff[i] = '\0';
				str += buff;
				i = 0;
			}
			ch = in.get();
		}
		if (i > 0)
		{
			buff[i] = '\0';
			str += buff;
		}
		return in;
	}
}

关于string的所有内容就到此结束,感谢大家的阅读,如果有讲解的不到位或错误的地方,欢迎各位读者批评和指正。

相关推荐
ZHang......2 小时前
synchronized(三)
开发语言·笔记·juc
许泽宇的技术分享2 小时前
AgentFramework:错误处理策略
开发语言·c#
阿里嘎多学长2 小时前
2025-12-21 GitHub 热点项目精选
开发语言·程序员·github·代码托管
wanghowie2 小时前
01.04 Java基础篇|泛型、注解与反射实战
java·开发语言·windows
DechinPhy2 小时前
使用Python免费合并PDF文件
开发语言·数据库·python·mysql·pdf
qq_252614412 小时前
python爬虫爬取视频
开发语言·爬虫·python
言之。2 小时前
Claude Code Skills 实用使用手册
java·开发语言
Rinai_R2 小时前
关于 Go 的内存管理这档事
java·开发语言·golang
咸鱼加辣2 小时前
【python面试】你x的启动?
开发语言·python