strcpy()函数的返回值是什么?

40

许多来自标准C库的函数,特别是用于字符串操作的函数,尤其是strcpy(),共享以下原型:

char *the_function (char *destination, ...)
这些函数的返回值实际上与提供的目标变量destination相同。为什么要浪费返回值去做某些多余的事情呢?这样的函数更应该是void类型或者返回有用的信息。
我唯一的猜测是,将函数调用嵌套在另一个表达式中更容易、更方便。例如:
printf("%s\n", strcpy(dst, src));

还有其他合理的理由来证明这个习语吗?


21
你的猜测是正确的,但是我们当然希望这些函数返回指向终止空字节的指针(这将把很多O(n)操作降为O(1))。 - R.. GitHub STOP HELPING ICE
4
一项非常正确的观察。许多人只是没有意识到使用strlen()函数的成本。 - Blagovest Buyukliev
POSIX提供了stpcpy(3),它与strcpy(3)相同,但返回指向空字符终止符的指针。 - alx - recommends codidact
请确保包含头文件 #include <string.h>,否则您可能会像我一样遇到读取错误地址的问题。 - Leon Chang
6个回答

29

正如 Evan 指出的,可以像这样做:

char* s = strcpy(malloc(10), "test");

例如,给使用malloc()分配的内存赋值,而不使用辅助变量。

(这个例子不是最好的,如果内存不足会导致崩溃,但思路很明显)


9
如果使用在失败时执行longjmpxmallocchar *s = strcpy(xmalloc(10, my_jmpbuf), "test"); 这个用法就会变得合理。 - R.. GitHub STOP HELPING ICE
谢谢你,Yossarian,这样就很有意义了。一般来说,如果目标参数是一个表达式,那么返回值可能会很有用,因为它将是该表达式的计算结果。 - Blagovest Buyukliev
可能,是的,非常愚蠢,当然。避免使用辅助变量的愿望远远被你的程序严重崩溃的事实所压倒。最好使用(甚至是编写,如果你没有)strdup:https://dev59.com/sHVC5IYBdhLWcg3wjx_u#252802。 - paxdiablo

21

char *stpcpy(char *dest, const char *src); 返回指向字符串结尾的指针,是POSIX.1-2008的一部分。在此之前,它是自1992年以来的GNU libc扩展。它最初出现在Lattice C AmigaDOS中,时间为1986年。

gcc -O3 在某些情况下会优化 strcpy + strcat 以使用 stpcpystrlen + 内联复制,请参见下文。


C的标准库设计非常早期,很容易争论str*函数并不是最优秀的设计。I/O函数在1972年C甚至没有预处理器之前就被设计出来了,这就是为什么fopen(3)采用模式字符串而不是像Unix的open(2)一样采用标志位图

我找不到Mike Lesk的“可移植I/O包”中包含的函数列表,因此我不知道strcpy是否以其当前形式一直存在于那里,或者这些函数是后来添加的。(我找到的唯一真正的来源是Dennis Ritchie广为人知的C历史文章,它非常好,但不是那么深入。我没有找到有关实际I/O包本身的文档或源代码。)

它们以其当前形式出现在K&R第一版,1978年。


函数应该返回它们所做的计算结果,如果对调用者有潜在用处,而不是将其丢弃。可以返回指向字符串末尾的指针或整数长度(指针更自然)。

正如@R所说:

我们都希望这些函数返回指向终止空字节的指针(这将把许多O(n)操作减少为O(1))

例如,在循环中调用strcat(bigstr, newstr[i])从许多短(O(1)长度)字符串构建长字符串的复杂度约为O(n^2),但strlen/memcpy只会查看每个字符两次(一次在strlen中,一次在memcpy中)。

只使用 ANSI C 标准库,没有一种有效的方式只查看每个字符一次。你可以手动编写一个以字节为单位的循环,但对于长度大于几个字节的字符串来说,在现代硬件上,这比使用当前编译器(它不会自动向量化搜索循环)两次查看每个字符还要糟糕,因为有效的 libc 提供了 SIMD strlen 和 memcpy。你可以使用 length = sprintf(bigstr, "%s", newstr[i]); bigstr+=length;,但 sprintf() 必须解析其格式字符串,并且速度不快

甚至没有返回差异位置strcmpmemcmp 版本。如果这是你想要的,你面临着和 Why is string comparison so fast in python? 一样的问题:优化的库函数运行速度比你用编译循环做的任何事情都要快(除非你为每个目标平台都有经过手动优化的汇编代码),你可以使用它接近差异字节,然后在接近后退回到正常循环。

似乎 C 的字符串库在设计时没有考虑到任何操作的 O(n) 成本,不仅是查找隐式长度字符串的结尾,而且 strcpy 的行为绝对不是唯一的例子。

它们基本上将隐式长度字符串视为整个不透明对象,始终返回指向开头的指针,从未返回指向结尾或搜索或附加后的位置。


历史猜测

在 PDP-11 上的早期 C 语言中,我怀疑 strcpy 的效率不会比 while(*dst++ = *src++) {} 更高(并且可能是这样实现的)。

事实上,K&R 第一版(第101页) 展示了 strcpy 的实现,并表示:

虽然这乍一看似乎有点神秘,但它确实非常方便,而且这种用法应该掌握,即使出于其他原因,你也会经常在 C 程序中看到它。

这意味着他们完全希望程序员在需要 dstsrc 的最终值的情况下编写自己的循环。因此,也许他们没有看到需要重新设计标准库 API 的必要性,直到为手动优化的汇编库函数暴露更有用的 API 已经太晚了。


但是返回dst的原始值有意义吗?

strcpy(dst, src)返回dst类似于x=y评估为x。这使得strcpy工作像一个字符串赋值运算符。

正如其他答案所指出的那样,这允许嵌套,例如foo(strcpy(buf,input));。早期计算机非常受内存限制。保持源代码紧凑是常见的做法。打孔卡和缓慢的终端可能是其中的因素。我不知道历史上的编码标准或风格指南或认为在一行上放太多的东西是什么。

陈旧的编译器也可能是一个因素。使用现代优化编译器,char *tmp = foo(); / bar(tmp); 的速度不比bar(foo()); 慢,但是在gcc -O0时会变慢。我不知道很早期的编译器是否能够完全优化掉变量(不为其保留堆栈空间),但希望他们至少可以在简单情况下将它们保留在寄存器中(不像现代的gcc -O0 会故意溢出/重新加载所有内容以进行一致的调试)。即gcc -O0不是古老编译器的好模型,因为它故意进行反优化以便进行一致的调试。


可能是编译器生成汇编代码的动机

考虑到 C 字符串库的通用 API 设计缺乏效率方面的关注,这种可能性不大。但也许有一些代码大小上的好处。(在早期计算机上,代码大小更多地是一个硬性限制,而不是 CPU 时间)。

我对早期 C 编译器的质量了解不多,但可以肯定的是,它们在优化方面并不出色,即使是像 PDP-11 这样简单 / 正交的架构。

通常希望在函数调用后获得字符串指针。在汇编级别上,编译器可能已经将其存储在寄存器中。根据调用约定,您可以将其推送到堆栈上或将其复制到正确的寄存器中,该寄存器是调用约定规定的第一个参数所在的位置(即 strcpy 所期望的位置)。或者,如果您提前计划,您已经将指针存储在符合调用约定的正确寄存器中。

但是函数调用会破坏一些寄存器,包括所有传递参数的寄存器。(因此,当函数在寄存器中获取参数时,它可以在那里递增,而不是复制到临时寄存器中。)

因此,作为调用者,在函数调用中保留某些内容的代码生成选项包括:

  • 将其存储/重新加载到本地堆栈内存中(如果仍在内存中,则只需重新加载)。
  • 在整个函数的开头/结尾保存/恢复调用保留的寄存器,并在函数调用之前将指针复制到这些寄存器中的一个。
  • 该函数会为您在一个寄存器中返回值。 (当然,这仅适用于C源编写成使用返回值而不是输入变量的情况。例如,如果您没有将其嵌套,则 dst = strcpy(dst,src);)。

我所知道的所有体系结构上的所有调用约定都会在寄存器中返回指针大小的返回值,因此库函数中可能有一个额外的指令可以节省所有想要使用该返回值的调用者的代码大小。

通过使用 strcpy 的返回值(已经在寄存器中)可能会从早期原始C编译器获得更好的汇编代码,而不是让编译器将指针保存在调用保留的寄存器中或将其溢出到堆栈中。 这种情况可能仍然存在。

顺便说一句,在许多 ISA 中,返回值寄存器不是第一个参数传递寄存器。除非您使用基址+索引寻址模式,否则执行strcpy操作时需要额外一个指令(并占用另一个寄存器)来复制指针增量循环的寄存器。

PDP-11工具链通常使用某种堆栈参数调用约定,始终将参数推入堆栈。我不确定正常情况下保留调用与破坏调用寄存器的数量有多少,但只有5或6个GP寄存器可用({{link2:R7 是程序计数器,R6 是堆栈指针,R5 经常用作帧指针}})。因此,它类似于但比32位x86更为拥挤。

char *bar(char *dst, const char *str1, const char *str2)
{
    //return strcat(strcat(strcpy(dst, str1), "separator"), str2);

    // more readable to modern eyes:
    dst = strcpy(dst, str1);
    dst = strcat(dst, "separator");
//    dst = strcat(dst, str2);
    
    return dst;  // simulates further use of dst
}

  # x86 32-bit gcc output, optimized for size (not speed)
  # gcc8.1 -Os  -fverbose-asm -m32
  # input args are on the stack, above the return address

    push    ebp     #
    mov     ebp, esp  #,      Create a stack frame.

    sub     esp, 16   #,      This looks like a missed optimization, wasted insn
    push    DWORD PTR [ebp+12]      # str1
    push    DWORD PTR [ebp+8]       # dst
    call    strcpy  #
    add     esp, 16   #,

    mov     DWORD PTR [ebp+12], OFFSET FLAT:.LC0      # store new args over our incoming args
    mov     DWORD PTR [ebp+8], eax    #  EAX = dst.
    leave   
    jmp     strcat                  # optimized tailcall of the last strcat

这种方法比不使用 dst =,而是重复使用输入参数进行 strcat 的版本要紧凑得多。 (请参见 Godbolt 编译器资源管理器上的两个版本。)

-O3 输出非常不同:对于不使用返回值的版本,gcc 使用 stpcpy(返回指向尾部的指针),然后使用 mov-immediate 直接将字面字符串数据存储到正确的位置。

但不幸的是,dst = strcpy(dst, src) 的 -O3 版本仍然使用常规的 strcpy,然后将 strcat 内联为 strlen + mov-immediate。


使用C字符串还是不使用C字符串

C语言隐式长度字符串并非总是本质上不好,而且具有一些有趣的优点(例如,后缀也是一个有效的字符串,无需复制它)。

但是,C字符串库的设计方式并不利于编写高效的代码,因为逐个字符循环通常无法自动向量化,并且库函数会丢弃它们必须执行的工作结果。

除非在第一次迭代之前已知迭代次数(例如,for(int i=0; i<n ;i++)),否则gcc和clang永远不会自动向量化循环。ICC可以向量化搜索循环,但仍然不太可能像手写汇编代码那样做得好。


strncpy等函数基本上是灾难性的。例如,如果达到缓冲区大小限制,strncpy不会复制终止符'\0',因此您需要在之前或之后手动添加arr[n] = 0;。但是,如果源字符串较短,则填充0字节以达到指定长度,可能会触及从未被触及的内存页。(这也使得将短字符串复制到仍有大量空间的大缓冲区中非常低效。)

它似乎是为了写入更大字符串的中间而设计的,而不是为了避免缓冲区溢出。

一些函数如snprintf可用并始终以空字符结尾。记住哪个函数做什么很困难,如果记错了,风险很大,因此在涉及正确性的情况下,您必须每次都进行检查。

正如Bruce Dawson所说:别再使用strncpy了!。显然,一些MSVC扩展如_snprintf甚至更糟。

strncat也存在于POSIX.2001中,与strcpy无关;它实现了你所期望的功能,即一个带边界检查的strcpy,总是以0结尾。但像strcat一样,它仍然返回原始指针,因此不适用于将字符串有效地追加到缓冲区中;如果你在同一缓冲区上重复调用它,它必须每次重新扫描前面的部分以找到当前的结尾。手册中提到了“Shlemiel the painter”。


6

我相信你的猜测是正确的,这会使嵌套调用更加容易。


2

它也非常容易编码。

返回值通常放在AX寄存器中(这不是强制性的,但经常是这样)。当函数开始时,目标会被放入AX寄存器。 要返回目标,程序员什么都不需要做……只需将值留在原地即可。

程序员可以将函数声明为void。但是,返回值已经在正确的位置,只等待返回,而且甚至不需要额外的指令来返回它!无论改进有多小,在某些情况下都很方便。


3
有趣的是,我在 ISO C 标准文档中找不到关于 AX 寄存器的提及 :-) - paxdiablo
2
因为这个细节属于编译器实现的范畴,而ISO标准并未涵盖。正如这里所指出的那样,它是x86函数调用约定的一部分:“整数值和内存地址将在EAX寄存器中返回。” - Fernando
我认为这是原因之一;通过使用strcpy的返回值(已在寄存器中), 你可能从早期的C编译器中获得了更好的汇编,而不是让编译器在调用中保存指针到调用保留寄存器或将其溢出到堆栈。 这可能仍然是事实。顺便说一下,在许多ISA上,返回值寄存器不是第一个参数传递寄存器。并且除非您使用基址+索引寻址模式,否则对于strcpy来说,复制指针增量循环的寄存器会多花费额外的指令(并占用另一个寄存器)。 - Peter Cordes

0

流畅接口相同的概念。只是让代码更快、更易读。


-2

我认为这种设置并不是为了嵌套的目的,而更多地是为了错误检查。如果我的记忆没有错,c标准库函数中没有多少自行进行错误检查,因此这更有意义,以确定在strcpy调用期间是否出现问题。

if(strcpy(dest, source) == NULL) {
  // Something went horribly wrong, now we deal with it
}

1
strcpy 没有任何检查错误的方法。此外,它需要始终返回 dest,因此只有在已经尝试写入 NULL 指针时才能返回 NULL,因此一个假设的系统,在 strcpy 中捕获 SIGSEGV 并返回 NULL 将违反该协议。虽然 UB 已经发生了,所以对于 ISO C 没有关于 strcpy 做坏事情的程序的规定,这种扩展还有空间。 - Peter Cordes
如果传递了NULL,strcpy函数只会返回NULL,而这本身就是不确定的行为。 - user16217248

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接