strcpy函数体现出的编程细节

Posted 庸人冲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了strcpy函数体现出的编程细节相关的知识,希望对你有一定的参考价值。

前言

在C语言中,strcpy库函数是我们经常会使用到的一个字符串操作函数。它的功能是将字符串A的内容拷贝至字符数组B,其中包含了字符串A中的结束字符\\0。我们来看下Cplusplus网站上是如何解释strcpy函数。

在这里插入图片描述

图中解释:

功能:将源字符串(source)的内容拷贝至目标字符数组(destination)中,包含源字符串中结束字符\\0,并且会在\\0的位置停止。为了避免溢出,目标数组必须有足够的空间能装得下源字符串中的内容(包括\\0),同时目标数组在内存中的存储位置不能与source的存储位置有冲突。

从图中的解释大概能知道,该函数的功能就是拷贝字符串用的,因此才叫strcpy(string copy)。

功能实现

好了,既然知道了strcpy的大致功能,如果让我们自己实现应该如何去编写代码呢?

首先,我们很容易想到,既然是将字符串A的内容拷贝到字符串B中,那么首先得有2个字符串吧,所以我们创建两个字符数组arrAarrB 用来存放两个字符串。

int main()
{
    char arrA[] = "abc";
    char arrB[] = "123456789";
    return 0;
}

其次,我们就应该定义一个函数来实现字符串拷贝的功能,功能如何实现暂时不管,我们先考虑下函数三个要素:函数名、参数类型、返回值类型。

  1. 函数名可以随意些,但是一定要能表示出函数的功能,所以我们就叫my_strcpy把。
  2. 参数类型,实参我们肯定是传递两个字符数组的数组名给函数,数组名中保存的是数组首元素的地址,所以形参我们需要用一个指针变量来接收这个地址,既然是字符数组那么我们就用char*作用形参的类型把。
  3. 返回值,我们实现的功能只是将arrA的内存拷贝至arrB,而我们在传参时采用的是传址调用的方法,那么arrA的内容在函数内容是可以被改变了,这样说好像没有必要返回什么内容,所以我们就暂时将返回值类型设置为void的把。
void my_strcpy(char* dest, char* src)
{
    // 功能实现
}

int main()
{
    char arrA[] = "abc";
    char arrB[] = "123456789";
    my_strcpy(arrB,arrA);     // 传址调用
    return 0;
}

既然框架都搭建好了,我们就来最后一步,完成功能的实现把。

功能应该如何实现呢?我们既然用的是两个指针变量接收的地址,那么就可以通过解引用指针变量,将src所指向的内容赋值给dest,并且两个指针变量同时+1就能找到下一块内存空间,通过这样多次的循环直到*src = '\\0'我们就可以结束这个循环了。但是这会出现一个问题,就是当*src = '\\0'时就不再进入循环中,那么\\0这个字符就没有被拷贝到*dest中,所以我们要在循环结束后,在后面再一次将*src的值赋值给*dest,这样就能实现这个函数的功能了。

// 功能实现
void my_strcpy(char* dest, char* src)
{
    while (*src != '\\0')
    {
        *dest = *src;
        src++;
        dest++;
    }
    *dest = *src;
}

通过打印结果的对比确实能完成strcpy函数的功能。
在这里插入图片描述

但是这样的代码完美吗?它是否还有可以优化的地方呢?

优化版本一

我们如果对操作符的优先级有所了解应该直到自增运算符++的优先级是高于解引用操作符*的,并且因为如果使用后置++,那么会先使用操作数再自增,而我们在循环体内部的功能不就是先使用再++吗?所以我们可以有如下写法:

// 优化版本1
void my_strcpy(char* dest, char* src)
{
    while (*src != '\\0')
    {
        *dest++ = *src++;
    }
    *dest = *src;
}

这样看,代码少了两行但是执行逻辑完全相同,这种写法是不是更加简洁一些呢?

不过,赋值操作的表达式在循环内外各写了一些,这不重复了嘛?能不能写一个判断条件可以让这两个执行重复表达式合并,而达到在循环中也能实现把\\0赋值给*dest的功能呢?

答案当然是可以的,我们知道一个字符,它的底层实际上存储的是二进制的ASCII码值,而经常使用ASCII码表的同学应该会注意到'\\0'的对应的ASCII码值实际是上0,而在C语言当中 0代表的是假,那当我们把循环体内的表达式放在while()的判断条件中,当*src = '\\0' 时依然会执行判断条件将'\\0'赋值给*dest ;接着,因为此时*dest = '\\0' 它的ASCII码值为0,那么条件表达式为假,则终止循环。通过这样的操作我们就可以进一步缩减代码量,同时完成功能的实现,代码如下:

// 优化版本1
void my_strcpy(char* dest, char* src)
{
    while (*dest++ = *src++)
    {
        ;  // 循环体内不执行,则使用;空语句
    }
}

我们再次输出结果,发现这段代码的执行逻辑也是正确的。

在这里插入图片描述

那么这段代码应该就完美了吧?很可惜,它还有优化的空间。

优化版本二

上面代码还存在什么问题呢?我们假设一种情况,假如我们在编写代码时因为疏忽,将一个空指针传给形参会发生什么情况?

在这里插入图片描述

指针变量是可以接收NULL的,而当指针变量中存储的是NULL时,那么这个指针就是一个空指针,空指针是为了避免出现野指针现象而存在的,访问空指针的结果是不可预料的,一般情况下都是导致程序被终止。那么我们如何能避免把NULL传给形参而导致程序被终止呢?

一旦有了需要解决空指针问题的概念,实现方法很简单,加一个判断条件就可以了,如果是空指针,就让程序不执行while循环,直接退出函数,这样即使传入了空指针也不会影响后面代码的执行。

// 优化版本2
void my_strcpy(char* dest, char* src)
{
    if (dest != NULL && src != NULL)     // 只有两个形参都不是空指针才执行if语句中的循环,存在至少一个空指针则不执行。
    {
        while (*dest++ = *src++)
        {
            ;  // 循环体内不执行,则使用;空语句
        }
    }    
}

在这里插入图片描述

再次将NULL传给形参测试代码,发现程序果然没有奔溃了,但是问题是,咱们的my_strcpy却没有达到预期的功能实现。这就很令人头疼了,如果在代码量非常大的情况下,去查找一个能正常编译但是输出结果异常代码中的问题,比查找一个直接报错的代码中的问题可要麻烦多了,直接报错的代码编译器会告诉我们问题出在哪里,而上面这种代码就意味着需要去一步步的分析和调试代码,这往往会花费非常多的时间。

那么有没有办法能让编译器告诉我们如果一个代码出现问题,它的问题出现在哪里呢?

这时我们就需要用到另一个库函数assert

在这里插入图片描述

assert函数的参数是一个表达式,如果表达式的结果为0也就是假,那么将终止该程序,并输入错误信息到控制台中。如果不为0那么什么也不会发生。

这个库函数是不是非常强大?如果在合适的位置加上这个库函数,可以极大降低我们查找BUG的时间。

既然有了这么强大的库函数我们就把这个功能引进到我们自己的函数中把,当然这里需要注意assert函数在使用前需要引用头文件

assert.h

#include <assert.h>
// 优化版本2
void my_strcpy(char* dest, char* src)
{
		assert((dest != NULL) && (src != NULL));  // 断言,当条件表达式为假时,会报错并指出错误位置
        while (*dest++ = *src++)
        {
            ;  // 循环体内不执行,则使用;空语句
        }  
}

在这里插入图片描述

我们再次测试可以看到,系统终止了程序的运行,并在控制台输出了错误的信息,指出了错误的位置。是不是很棒呢?

那么代码优化到这一步应该就无懈可击了把?既然我都这么问题,那肯定不是啦,哈哈。那么我们再看看这段代码还有哪里可以优化的地方?

优化版本三

经常查阅文档的小伙伴应该会知道,我们在查阅一个库函数的功能是,不仅要了解它的功能,也要了解它的参数类型和返回值类型。这两个项决定了我们能不能正确使用一个库函数来实现它的功能。在文章开头贴出了strcpy函数的介绍,心细的小伙伴肯定注意到我们的my_strcpy在定义之处就有两个地方和strcpy不同,一个是形参2的类型,一个是返回值类型。

在这里插入图片描述

库函数strcpy在实现字符串拷贝功能时,使用const修饰了指向源字符串的指针,并且使用char* 作为函数的返回值,既然大神都这样写,那么其中肯定有一些玄机。

那么我们先来看看为什么要用const修饰指向源字符串的指针。

const关键字

在说明为什么前,我们先了解是什么的问题。

我们知道const关键字修饰的变量一旦定义则无法更改,也就是变量具备了常属性。那修饰指针变量是不是也一样呢?

答案是,对的,但要分情况。

  1. 第一种情况:

    const type* ptr,将const 放在type*的左边(type在这里泛指数据类型),此时const修饰的是*ptr,这就表示*ptr的数据不能被修改,而*ptr指的就是指针ptr指向的内存空间中的数据,因此这种写法的意思就是ptr所指向空间内的数据不能被修改。

  2. 第二种情况:

    type* const ptr,将const放在type*的右边,此时const修饰的是ptr,这表示指针变量ptr中存的数据不能被修改,而ptr存储的是地址,因此这种写法的意思是指针变量ptr的指向地址不能被更改。

  3. 第三种情况:

    const type* const ptr,将const放在type*左右两侧,通过上面两种情况的说明,应该很容易猜测出,此时的两个const分别修饰了ptr*ptr ,这就表示了ptr指向的地址不能更改,同时地址上的数据也不能被更改。

那么通过上面的解释,应该可以明白,库函数strcpy中使用const char* source的写法实际上是为了防止指针source指向地址中的数据被修改。而在拷贝字符串的功能中,源字符串(source)也确实不需要被修改,因此这种写法可以有效避免,程序员在不经意的情况下,把目标字符数组和原字符串的位置写反,导致把目标的内容拷贝至源中。代码如下:

#include <assert.h>
// 优化版本3
void my_strcpy(char* dest, const char* src)
{
    assert((dest != NULL) && (src != NULL));
        while (*dest++ = *src++)
        {
            ;  // 循环体内不执行,则使用;空语句
        }
}

这样写,即使把*dest*src位置写反,编译也跑不过去。

在这里插入图片描述

现在,我们了解了const在这个代码中的作用,再来看看返回值类型为什么是char*。

返回值 char* 原因

我第一次看到char* 返回值时也不是很明白,既然dest接收到的是arrA数组首元素的地址,并且也没有const修饰,那么就可以很好的实现字符串拷贝的功能,为什么还需要再返回一个char*它的返回的是什么呢?

我们看文档可以知道,他返回的其实也是目标数组的地址。

在这里插入图片描述

那这不就多此一举了嘛?既然arrA数组已经在函数内被修改,那又把它的地址返回来做什么呢?

通过查找资料其中一种解释是,利用该函数的返回值可以实现链式表达的效果。比如,我们想将字符串A的内容拷贝至字符数组B中,并且需要获取拷贝后的字符数组B的长度,那么如果字符串拷贝函数不具有char*类型的返回值,则需要先调用该函数,再把字符数组B放入到strlen()函数中。

my_strcpy(arrB,arrA); 
int len = strlen(arrB);

但是当my_strcpy具有char*类型的返回值,我们就可以直接将my_strcpy()函数写在strlen(),直接将my_strcpy的返回值作为实参传递给strlen()函数的形参。

int len = my_strcpy(my_strcpy(arrB,arrA));

这样的写法就相对来说更加简练一些,虽然我自己觉得这一点的优化效果可能不如上面几点优化效果明显,不过既然大神们都这样设计,那肯定还是有他的道理把。

那么我们我们在思考下,如何实现将dest的地址作为返回值返回,我们之前的代码 dest 所指向的地址一直再向后移动,因此如果直接返回dest的地址,那么这个地址值指向的是\\0后一个位置的地址。也就是数组被未被覆盖的位置,这个地址肯定不是我们想要获取到的,因此我们需要在操作dest前,先将它的地址值报错到一个char*变量中,在赋值操作完毕后在返回这个char* 变量。因此最终代码如下:

#include <stdio.h>
#include <string.h>
#include <assert.h>
// 优化版本3
char* my_strcpy(char* dest, const char* src)
{
    // 当dest 和 src 任意一个为空指针时报错
    assert((dest != NULL) && (src != NULL));
    char* ret = dest;  // 先存入dest的地址,作为函数的返回值
    while (*dest++ = *src++)       // 把*src的字符赋值给*dest,包括'\\0'
    {
        ;  // 循环体内不执行,则使用;空语句
    }
    return ret;
}

int main()
{
    const int n;
    char arrA[] = "abc";
    char arrB[] = "123456789";
    int len = strlen(my_strcpy(arrB, arrA));     // 传址调用
    printf("arrB的内容是:%s\\narrB的长度为:%d\\n", arrB,len);
    int len1 = strlen(strcpy(arrB, arrA));
    printf("arrB的内容是:%s\\narrB的长度为:%d\\n", arrB, len1);

    return 0;
}

此时我们在输出结果,可以看到咱们自己编写的my_strcpy和库函数strcpy在功能上基本一样了。
在这里插入图片描述

总结

通过上面的4个版本的代码,我们其实可以感受到,一个看似功能十分简单的函数里面蕴含了诸多的细节,而这些细节才是区分代码质量的根本,好的代码一定是兼顾了各种意外情况可能的,而这些意外情况作为初学者的我们可能从来没有遇到过,不过我们可以学习前人的经验,并将这些宝贵的经验和优秀的代码内化于自身,从而提高我们自己在编写代码时的质量。

以上是关于strcpy函数体现出的编程细节的主要内容,如果未能解决你的问题,请参考以下文章

c语言strcpy将一个结构体的数据复制到另一个后,出问题了

通过模拟strcpy函数学习编程思想

strcpy和memcpy的区别

Kotlin语法总结:Java代码文件转Kotlin代码文件改造注意细节

理解js异步编程

结构体赋值给数组