内存函数
导读
大家好,很高兴又和大家见面啦!!!
在C语言标准库中,有一些直接对内存进行操作的函数,我们将其称之为内存函数,这些函数位于头文件<string.h>
,在网站https://cplusplus.com/reference/cstring/中我们可以看到这些函数:
从函数的介绍中,我们可以看到这些函数主要是用于进行拷贝、比较、查找以及设置的功能。
在今天的内容中我们将会介绍如何使用这些内存函数,以及这些内存函数我们应该如何实现。接下来就让我们进入今天的内容吧!!!
一、内存拷贝函数—memcpy
首先我们要介绍的第一个函数是内存拷贝函数——memcpy
,我们先来看一下这个函数的介绍:
1.1 函数介绍
从函数介绍中我们可以获取一下信息:
memcpy
这个函数是用来进行内存拷贝的函数;- 函数有三个参数:第一个参数为拷贝的目的地地址,第二个参数为拷贝的源对象的地址,第三个参数为拷贝的空间大小,单位为字节。
- 在拷贝的过程中,函数不会受终止符的影响,只会根据字节数量
num
来进行精确的拷贝; - 在拷贝前需要注意,目的地的空间大小和源对象的空间大小都应该至少是
num
个字节,并且拷贝的目标空间与源空间不能有重叠。
通过函数的介绍,我们现在对memcpy
有了一个初步的了解,那么接下来,我们就来探讨一下该函数的使用方式;
1.2 函数的使用
从函数的原型我们不难看出,函数在使用时只需要将拷贝的目标地址、源地址以及拷贝的字节数这三个信息依次传入函数即可,由于函数的返回类型是void*
,那我们是不需要通过变量来对函数的返回值进行接收的,因此函数的使用比较简单,只需要准备好3个参数即可,如下所示:
memcpy(dist,src,num);
函数的初步使用方式我们已经知道了,接下来我们就需要来对函数进行更加深入的探讨了。
1.2.1 memcpy
与strncpy
从函数的原型与函数的功能可以看到memcpy
这个函数和strncpy
有点像,但是它要比strncpy
更加完善,如下所示:
从测试中我们可以看到,在拷贝的过程中,当strncpy
在进行复制时,如果遇到了\0
,则后面的内容都将被\0
给替代,而memcpy
则不受\0
的影响,会严格的按照num
的大小进行拷贝。
1.2.2 拷贝其他类型
对于memcpy
来说,它不仅仅能够实现字符串的拷贝,还能够实现其他数据类型的拷贝,如下所示:
这里需要注意的是,我们在拷贝其他类型时,传入的num
的值应该是字节大小,拷贝n个整型元素对应的num
就是n * sizeof(int)
。
1.3 memcpy
的模拟实现
现在我们已经知道了memcpy
的使用方式,接下来我们尝试着根据memcpy
的使用方式来模拟实现一个memcpy
。
1.3.1 函数三要素
从memcpy
的函数原型可知,其返回类型为无返回指针型,三个形参分别是无类型指针型、静态无类型指针型以及无符号整型,因此我们不妨直接按照函数的原型来实现memcpy
,为了避免重命名的问题,这里我们将自己实现的memcpy
命名为my_memcpy
,如下所示:
//memcpy的模拟实现
void* my_memcpy(void* dest, const void* src, size_t num) {
}
1.3.2 函数主体
在函数的主体部分,我主要实现的功能是从src
中拷贝num
个字节的内容到dest
中,因为我们在拷贝时是根据字节拷贝以此来实现所有类型的拷贝,因此,我们在实现的过程中需要将指针类型强制转换成char*
,之后再对其进行拷贝,如下所示:
while (num--) {
*(char*)dest = *(char*)src;//拷贝
//移动指针
dest = (char*)dest + 1;
src = (char*)src + 1;
}
这里有朋友可能会奇怪,为什么我们在移动指针的时候不能够直接对dest
和src
进行移动,而是需要将其强制转换后再进行移动呢?
有这个疑问的朋友说明你忽略了指针中一个重要的知识点——void*类型的指针不能进行+-整数的运算。因此我们如果要一个字节一个字节的移动地址,那此时就需要将void*
的指针强转成char*
的指针之后再对其进行+-整数的操作。
那是不是说这样就完成了这个函数呢?我们测试一下:
从测试结果中我们可以看到,函数此时已经可以正常运行了,但是系统报了一个警告——函数需要有一个返回值,也就是对于void类型的函数而言,它和void类型还是有区别的,void函数不需要返回值,但是void类型的函数是需要返回值的。那返回值是什么呢?
从memcpy
函数介绍中我们不难发现,在memcpy
中,函数的返回值是目标空间地址,因此在我们模拟实现的my_memcpy
函数中同样可以将目标空间地址返回。为了确保返回的是目标空间的其实地址,我们可以在开始拷贝前,先将目标空间的起始地址记录下来,最后在拷贝结束后将起始地址返回给函数,如下所示:
//memcpy的模拟实现
void* my_memcpy(void* dest, const void* src, size_t num) {
assert(dest && src);
void* ret = dest;
while (num--) {
*(char*)dest = *(char*)src;//拷贝
//移动指针
dest = (char*)dest + 1;
src = (char*)src + 1;
}
return ret;
}
这样我们就完成了memcpy
函数的模拟实现了。
1.3.3 函数测试
接下来我们就来从3个方面对函数的使用进行测试:
- 正常拷贝
- 源空间中有结束标志
- 目标空间与源空间有重叠
测试结果如下所示:
从测试结果中可以看到,函数在使用上没有任何问题。但是此时我们实现的函数与memcpy
还是有一点区别。接下来我们就继续来探讨一下memcpy
这个函数;
1.4 memcpy
与my_memcpy
在memcpy
函数中,C语言规定它是无法对函数重叠部分进行拷贝的,在我们实现的my_memcpy
中可以很好的印证这一点,如上图的第三个测试结果中可以看到,当有空间重叠的情况存在时,my_memcpy
在拷贝时输出的结果会出错,但是当我们在VS中测试memcpy
对空间重叠的拷贝时,却能正常拷贝,如下所示:
从测试结果中可以看到,在空间有重叠的情况下,memcpy
是可以进行正确的拷贝操作的,但是我们根据memcpy
的用法来实现的话会发现my_memcpy
在空间有重叠的情况下拷贝的结果是错误的。
因此建议大家在理解memcpy
这个函数时,以模拟实现的my_memcpy
来理解函数的用法——memcpy
只负责拷贝空间无重叠的情况。
当空间出现重叠时的拷贝则需要调用我们接下来要介绍的函数——内存移动函数memmove
;
二、内存移动—memmove
memmove
这个函数与memcpy
一样,也是来实现拷贝操作的函数,但是,它们之间的区别就是memmove
能够实现空间有重叠的拷贝。下面我们先来看一下这个函数的介绍;
2.1 函数介绍
从memmove
函数的介绍中我们不难发现,它与memcpy
的功能是一样的,只不过相较于memcpy
,memmove
可以实现重叠部分的拷贝。函数的使用我就不再过多赘述,接下来我们就来重点介绍一下如何实现memmove
这个函数。
2.2 memmove
的模拟实现——中间数组
从函数介绍中我们可以看到,memmove
在实现时就像使用了一个中间缓冲区一样,从而来实现重叠空间的拷贝。从这个介绍我们不难想到第一种实现方式,通过一个中间数组来完成,因为我们是一个字节一个字节的完成拷贝,因此中间数组我们可以通过字符型的数组来实现,对应代码如下所示:
//my_memmove的模拟实现——中间数组
void* my_memmove(void* dest, const void* src, size_t num) {
assert(dest && src);
void* ret = dest;
char* tmp = (char*)calloc(num, sizeof(char));
if (!tmp) {
perror("calloc fail");
return NULL;
}
//将内容拷贝到中间数组中
for (size_t i = 0; i < num; i++) {
tmp[i] = *((char*)src + i);
}
//将内容拷贝到目标空间中
for (size_t i = 0; i < num; i++)
*((char*)dest + i) = tmp[i];
free(tmp);
return dest;
}
下面我们就来测试一下函数:
可以看到,在my_memcpy
中无法实现的空间重叠的拷贝,在my_memmove
中很好的实现了。这种实现的思路比较简单,相信以大家目前的编程水平是能够轻松实现的。下面我们就来介绍一下如何不借助中间数组来实现memmove
。
2.3 memmove
的模拟实现——指针实现
当我们需要通过指针来实现函数的话,我们必须要解决的问题是——如何处理重叠空间的元素拷贝?
要解决这个问题,我们首先需要思考清楚是哪一部分有重叠,如下所示:
不难发现,当源空间在前,目标空间在后时,重叠空间为源空间的后侧与目标空间的前侧;当目标空间在后,源空间在前时,重叠空间则为源空间的前侧与目标空间的后侧。
因此我们如果想要在拷贝时不会改变重叠空间的内容,那我们只能先处理重叠空间的内容,再来处理不重叠空间的内容,因此拷贝的方式就有两种情况:
- 当重叠部分在源空间的后侧,则从后进行拷贝;
- 当重叠部分在源空间的前侧,则从前进行拷贝;
从这两种情况中我们可以得到结论:
- 当拷贝的空间有重叠时,需要从源空间重叠部分的一端开始进行拷贝。
因此我们就能很容易的编写出对应的代码,如下所示:
//my_memmove的模拟实现——指针
void* my_memmove2(void* dest, const void* src, size_t num) {
assert(dest && src);
void* ret = dest;
if (dest > src) {
//当源空间在前,重叠空间在后,从后往前拷贝
while (num--) {
*((char*)dest + num) = *((char*)src + num);
}
}
else {
//当源空间在后,重叠空间在前,从前往后拷贝
for (int i = 0; i < num; i++) {
*((char*)dest + i) = *((char*)src + i);
}
}
return ret;
}
下面我们就来测试一下函数,如下所示:
从测试结果中可以看到,此时很好的实现了memmove
这个函数。
2.4 小结
对于memcpy
和memmove
这两个函数而言,在使用上我们需要按照模拟实现的my_memcpy
与my_memmove
这两个函数来进行理解:
memcpy
可以对空间不重叠的情况进行任意类型的元素拷贝;memmove
可以对空间重叠的情况进行任意类型的元素拷贝;
这种理解方式是复合C语言规定的理解,但是在VS2019中不管是memmove
还是memcpy
都是能够实现对重叠空间的数据拷贝。
三、内存查找字符—memchr
memchr
这个函数是用来在内存块中查找字符的。我们先来看一下函数的介绍;
3.1 函数介绍
memchr
函数有三个参数——指向查找空间的指针ptr
、要查找的元素对应的整型值value
、以及要查找的字节数num
。
在函数介绍中对于该函数的描述是在指针ptr
指向的内存块的第一个num
字节中搜索第一个value
(解释为无符号字符类型),并返回指向他的指针。
我们应该怎么来理解这句话呢?
首先是函数的一个基本用法——用来在内存块的第一个num
字节中搜索第一个value
。这里需要注意的是两个第一——第一个num
字节与第一个value
;
其次是函数的底层逻辑——将value
解释为无符号字符类型。这里要注意的是什么是无符号字符类型。
最后是函数的返回值——返回指向value的指针。这个我们并不陌生了,在前面实现memcpy
的时候我们就有介绍过对于void*
类型的函数在函数结束时,需要给函数返回一个地址,这里就不再继续展开。
接下来我们将从函数的基本用法和函数的底层逻辑两个方面来进一步认识和使用memchr
这个函数并
3.2 函数的使用
3.2.1 基本用法
在函数的基本用法中提到了两个第一,这两个第一我们应该如何理解呢?接下来我们通过几个具体的例子来理解进一步理解这两个第一。
我们先来看一下什么是第一个num
字节,如下所示:
在这次测试中我们通过memchr
函数对空间大小为10的字符数组s
进行了查找字符'g'
的操作。
由于该数组为字符数组,因此每个元素的数据类型都为字符类型所占的空间大小为1个字节,从测试用例中我们可以看到在字符数组s中存放的是8个元素——字符a~g
的七个字符以及'\0'
。
我们在查找的过程中依次给函数传入了1~8
个字节数,可以看到函数在整个执行的过程中并不是说当查找一个字节没有找到时继续查找下一个字节,而是说当查找的第一个字节数中没有找到时,就没有找到。随着我们传入的字节数增加,函数的查找范围也在增加,因此我们可以看到当查找的字节范围超过6个字节时,此时函数就准确的找到了元素'g'
的所在位置。
从这个例子我们就可以知道所谓的第一个num
字节,指的就是从查找空间的起始地址开始往后数num
个字节。
也就是说,当我的函数参数中给num
传入了一个值后,即使该内存空间中的字节数超过了我们传入的字节数,当在寻找的过程中,函数也只会从空间的起始位置开始往后的num
字节数中进行查找,能找到就返回该元素的地址,找不到就返回空指针。
相信大家对什么是第一个num
字节有了一个初步的理解,下面我们再来看一下如何来理解什么是第一个value
,如下所示:
在这个例子中,字符数组s中存储的元素变为了由七个字符a
和一个'\0'
。这里我们分别测试了在1个字节中查找和在多个字节中查找的测试,从测试结果不难看出,不管我们查找的范围是多大以及查找的元素在空间中存在多少个,函数在实际的查找过程中只会查找num
字节中的第一个value
。也就是说只要在查找的过程中找到了value
就不会继续往后查找,而是直接返回该value
的地址。
现在大家应该对这个函数的基本用法比较熟悉了,下面我们继续来看一下函数的底层逻辑;
3.2.2 底层逻辑
函数的介绍中说的是在查找的过程中会将值解释成无符号字符类型。
那什么是无符号字符类型呢?接下来我们就来简单的回顾一下有符号与无符号的相关内容;
整型数据类型
在我们所熟知的数据类型中,char、short、int、long、long long
这些数据类型,实际上我们都可以将其称为是整型,并且是有符号整型,这时有朋友可能就会奇怪了,char
不是字符类型吗,为什么也可以称为整型呢?
这个是因为计算机在进行数据存储时,它并不认识什么数字、字符、字符串……这些数据,计算机能够识别的只有电信号,如果用计算机语言来描述的话,那就是二进制语言,也就是说不管是什么数据,在计算机中存储时都是以01的二进制形式进行存储的。
也就是说,不管是字符还是数字,都是可以通过进制转换将其二进制序列以十进制的形式来进行表示的,而我们所熟知的ASCII编码就是定义了不同字符的二进制形式与其所对应的十进制整数,因此我们可以将字符类型称整型,只不过其数据类型所包含的字节数只有1个字节。
有符号整型
在有符号整型中,数据在进行存储时,其二进制位是有符号位和数值位的区别。有符号整型中,二进制的最高位表示的是符号位,其余位表示的是数值位,最高位为0则表示正数,最高位为1则表示负数,因此,对于有符号整型而言,它所包含的数据范围是从负数的最小值到正数的最大值。并且其最小值与最大值与该数据类型表示的字节数有关:
- char——1个字节,最小值为,最大值;
- short——2个字节,最小值为,最大值;
- int——4个字节,最小值为,最大值;
- long——8个字节,最小值为,最大值;
在【C语言必学知识点四】操作符篇章中我们在介绍表达式的类型转换时有以
char
类型为例说明了不同数据类型所能存储的数值范围,感兴趣的朋友可以点击链接来回顾一下相关的内容。
知识点拓展——<limits.h>
对于int型的变量来说,它能够存储的值的范围是这个范围内的数值,具体的值我们可以通过头文件<limits.h>
中的常量值INT_MIN
与INT_MAX
进行获取,如下所示:
因此当一个值超过了这个范围后,会通过周期函数的转换来获取一个在该范围内所对应的值,如最大值+1获取到的就是最小值,最小值-1获取到的就是最大值,如下所示:
从系统的警告中可以看到,当一个超过整型范围内的值要存入整型中时,会出现算术溢出的问题,当溢出后,就会发生截断,截断会保留低位的整型值而舍弃高位的溢出值,因此我们就会得到最大值+1变为最小值,最小值-1变为最大值的结果。
无符号整型
与有符号整型相对应的就是无符号整型,该类的数据类型的值在存储时所有的二进制位都是数值位,没有符号位,因此其值的范围也就变成了0~最大值:
- char——
- short——
- int——
- long——
在ASCII码表中,每一个字符都有其对应的整数,并且,现在的ASCII码表中,0~255这些整数都有一个与之对应的字符。
在计算机中,字符在进行存储时会按照无符号字符类型的形式进行存储,因此我们可以认为无符号字符类型就表示的就是一个字符,并且该字符位于ASCII码表中。
从这些介绍中我们可以总结一下有符号数据类型与无符号数据类型的区别——数据类型的取值范围不同:
- 有符号数据类型——负数的最小值~正数的最大值
- 无符号数据类型——0~最大值
从函数的数据类型我们可以知道函数能够接收参数的数据范围是有符号整型的取值范围,但是函数在获取到该值后会将其解释成无符号字符类型,也就是代表着函数在实际识别的过程中,会将其识别成一个位于ASCII码表中的字符。
那现在问题就来了,一个整型占4个字节,一个字符才占1个字节,如果函数在查找的过程中会将4个字节的整型数值识别成一个字节的字符,那它能够找到占4个字节的整型数值吗?下面我们就来测试一下:
在这次测试中,我们通过内存窗口可以看到元素5555555在内存中进行存储时,在四个字节中存储的内分别是 63 c5 54 00
,在arr中的字节所在位置分别是17~20。
我们在进行查找时分别对40个字节、20个字节、16个字节、17个字节进行了查找,从输出结果中可以看到的是在查找到第17个字节时,函数就已经找到了该元素的位置,也就是说后面的18~20个字节上的内容并未进行查找。
那现在问题来了,函数究竟是逐字节的进行查找呢?还是逐元素的进行查找呢?
在进行验证前,我们先理解一下什么是逐字节,什么是逐元素:
- 逐字节——按照一个字节一个字节的进行查找,通过对查找指定内存空间中的每一个字节,来确定要查找的内容。
- 逐元素——按照一个元素一个元素的进行查找,通过将元素解释为单字节的元素,每次只查找该元素的首字节中的内容。
这时会出现的情况我们可以简单预测一下:
- 逐字节——如果通过逐字节查找,那么就容易出现某个元素的起始字节中存储的内容与前一个元素中的某个内容相同,而导致查找出现错误;
- 逐元素——如果通过逐元素查找,那么在实际查找的过程中只需要查找该元素的起始地址中存储的内容是否相同。
从前面的测试中由于要查找的元素的首字节中存储的内容与前面的元素中每个字节存储的内容都不相同,因此我们也就无法对该函数的查找方式进行判断,接下来我们就来取一些特殊值来进行判断,如下所示:
在这次测试结果中我们可以看到,但我们在该数组中查找54时,函数在查找到第4个字节时就找到了54,因此返回的下标为0,当我们在函数中查找40时,40对应的十六进制为0x00000028
,由于首元素的第二个字节中存储的内容为28,因此函数找到了该元素。
从这次测试结果中我们不难发现,memchr
在实际运行的过程中实际上是通过逐字节的方式进行的查找,因此我们在使用该函数时,如果查找的对象为整型数组,那么函数在查找时就容易出现上述所示的错误。
现在我们就可以通过函数的底层逻辑得到一个结论——memchr
更适合在字符数组中查找字符。
下面我们继续来看下一个函数——内存设置函数。
四、内存设置—memset
内存设置函数我们可以理解为是一个用来修改内存块的函数,具体如何修改呢?下面我们来看一下该函数的介绍;
4.1 函数介绍
如下所示:
从函数的介绍中我们可以看到,该函数有3个参数——指向需要填充的内存块的指针,需要填充的值,以及需要填充的字节数。
该函数与memchr
十分相似同样的三个参数,通过前面的介绍,我们对这三个参数已经不陌生了,由memchr
的使用方式,我们也可以大胆的推测memset
的底层逻辑与memchr
一样,都是逐字节进行设置,那具体是不是这样呢?接下来我们就来探讨一下函数的使用;
4.2 函数的使用
从函数的介绍中我们不难推测函数的用法——将指定的内存块中的指定的字节数设定成指定的值,接下来我们需要弄清楚的是函数究竟是将指定的字节数设定成指定的值,还是将指定的字节数中的每一个字节设定成指定的值,读起来有点绕口,不过没关系,接下来我们就来测试一下该函数的使用,如下所示:
从这次的测试结果中我们可以看到,函数在实际运行的过程中与memchr
一样是逐字节进行的修改,我们传入的字节数就是函数在执行的过程中需要修改的字节数,因此,memset
也更加适合对字符数组进行修改。
我们知道对于指向常量字符串的指针是无法对其指向的元素进行修改的,那我们能不能通过memset来实现修改呢?如下所示:
从测试结果中我们可以看到,memset
在进行修改时同样只能修改可以被修改的空间中的元素。
可以看到,memchr
与memset
这两个函数一个是逐字节进行查找一个是逐字节进行修改,因此虽然两个函数都是可以对整型数组进行操作,但是都更加适合对字符数组进行操作。
这里给大家介绍一下memchr与memset这两个函数的一种使用情景——查找并修改某一个元素,代码如下所示:
char s[100] = "abcdabcdabcd";
//通过memchr进行查找
for (char* ptr = memchr(s, 'a', strlen(s)); ptr; ptr = memchr(ptr + 1, 'a', strlen(s))) {
printf("%d\n%s\n\n", ptr - s, ptr);
//找到元素的地址后通过memset进行修改
printf("修改前:%s\n", s);
memset(ptr, 'e', 1);
printf("修改后:%s\n\n", s);
}
感兴趣的朋友可以复制代码在自己的IDE中测试一下,这里我就不给大家演示了,对于这两个函数我们只需要在使用时知道它们都是进行的逐字节操作即可,多的我也就不再赘述,接下来我们来看内存函数中的最后一个函数——内存比较函数;
五、内存比较—memcmp
内存比较函数与strcmp很相似都是通过逐字节来进行内存比较的,下面我们来看一下该函数的介绍;
5.1 函数介绍
如下所示:
可以从介绍中可以看到,函数有3个参数——指向内存块的两个指针,与进行比较的字节数。从函数参数来看,memcmp
与strncmp
更加相似,都需要给函数传入两个进行比较的对象以及需要比较的字符数,而且函数的返回值也是一样,如下所示:
虽然memcmp
与strncmp
从函数参数与返回值上来看好像一样,但是在具体的使用过程中它们还是会有些区别,接下来我们就来看一下memcmp
的使用方式;
5.2 函数的使用
memcmp
的使用方式与strncmp
是一致的,都是给函数传入三个参数——指向需要进行比较的两个内存空间的指针以及需要比较的字节数,但是两个函数的底层实现上是有区别的,strncmp
在进行比较时会以'\0'
作为结束标志,但是memcmp
在进行比较时,则不会受到'\0'的影响,如下所示:
可以看到,在给两个函数传入同样的参数时,函数的返回值上是有区别的,strncmp
在比较到第3个字节时,因为遇到了'\0'
就结束了后续的比较,但是memcmp
在遇到了'\0'后继续往后进行了比较,并且第四个字节中ptr2
的ASCII码值是大于ptr1
的ASCII码值,所以返回值小于0 。
memcmp
比strncmp
的功能更加的强大,strncmp
只能够执行字符串之间的比较,但是memcmp
能够比较除字符类型以外的其他类型,如下所示:
可以看到,不管是结构体还是整型,memcmp
都是能够完成两者之间的比较的。
结语
在今天的内容中我们介绍了一系列的内存函数:
- 内存复制函数——
memcpy
- 内存移动函数——
memmove
- 内存查找字符——
memchr
- 内存设置函数——
memset
- 内存比较函数——
memcmp
这些函数中我们详细介绍了memcpy
与memmove
这两个函数的模拟实现以及memchr
函数的底层逻辑,从字符函数与字符串函数以及今天的内存函数来看,我们不难发现对于C语言中的库函数在使用上实际上都是大同小异的,我们真正需要掌握的是不同库函数所能执行的功能以及其运行的底层逻辑。
就比如对于memchr
与memset
这两个函数,虽然它们能够在内存块中进行查找和修改,但是我们需要知道的是它们都是逐字节的进行查找与修改,因此在具体的使用过程中如果使用不当可能就会造成一些错误。
今天的内容到这里就全部结束了如果大家喜欢博主的内容,可以点赞、收藏加评论支持一下博主,当然也可以将博主的内容转发给你身边需要的朋友。最后感谢各位朋友的支持,咱们下一篇再见!!!