34

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

char *the_function (char *destination, ...)

这些函数的返回值其实和提供的一样destination。你为什么要把返回值浪费在多余的东西上?这样的函数为 void 或返回有用的东西更有意义。

对于为什么会这样,我唯一的猜测是将函数调用嵌套在另一个表达式中更容易、更方便,例如:

printf("%s\n", strcpy(dst, src));

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

4

6 回答 6

26

正如埃文指出的那样,有可能做类似的事情

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

malloc()ed例如,在不使用辅助变量的情况下为内存分配一个值。

(这个例子不是最好的,它会在内存不足的情况下崩溃,但这个想法很明显)

于 2010-08-24T22:16:13.727 回答
14

char *stpcpy(char *dest, const char *src);返回指向字符串末尾的指针,并且是 POSIX.1-2008 的一部分。在此之前,它是 1992 年以来的 GNU libc 扩展。它于 1986 年首次出现在 Lattice C AmigaDOS 中。

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 包本身的任何文档或源代码。)

它们确实以目前的形式出现在1978 年的K&R 第一版中。


函数应该返回它们所做的计算结果,如果它可能对调用者有用,而不是把它扔掉。作为指向字符串末尾的指针,或整数长度。(指针很自然。)

正如@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()必须解析其格式字符串并且速度不快

甚至没有返回差异位置strcmp的ormemcmp的版本。如果这就是您想要的,那么您遇到的问题与为什么 python 中的字符串比较如此之快?: 一个优化的库函数,运行速度比使用编译循环执行的任何操作都快(除非您为您关心的每个目标平台手动优化 asm),您可以使用它来接近不同的字节,然后再回退到一个一旦你接近,定期循环。

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

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


历史猜测

在 PDP-11 的早期 C 中,我怀疑这strcpy并不比while(*dst++ = *src++) {}(并且可能以这种方式实现)更有效。

事实上,K&R 第一版(第 101 页)显示了该实现strcpy并说:

尽管乍一看这似乎很神秘,但符号的便利性是相当可观的,并且应该掌握这个习语,如果没有其他原因,你会在 C 程序中经常看到它。

这意味着他们完全期望程序员在您想要or的最终值的情况下编写自己的循环dstsrc。因此,也许他们认为没有必要重新设计标准库 API,直到为手动优化的 asm 库函数公开更多有用的 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故意溢出/重新加载所有内容以进行一致的调试) . iegcc -O0对于古代编译器来说不是一个好的模型,因为它是为了一致的调试而故意反优化的。


可能的编译器生成的 asm 动机

鉴于 C 字符串库的通用 API 设计缺乏对效率的关注,这可能不太可能。但也许有代码大小的好处。(在早期的计算机上,代码大小比 CPU 时间更受硬限制)。

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

在函数调用之后需要字符串指针是很常见的。在 asm 级别,您(编译器)可能在调用之前将它放在寄存器中。根据调用约定,您可以将其推送到堆栈上,也可以将其复制到调用约定说第一个 arg 所在的正确寄存器。(即在哪里strcpy期待它)。或者,如果您提前计划,您已经在正确的寄存器中为调用约定找到了指针。

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

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

  • 将其存储/重新加载到本地堆栈内存。(或者如果最新的副本仍在内存中,则只需重新加载它)。
  • 在整个函数的开始/结束处保存/恢复调用保留寄存器,并在函数调用之前将指针复制到这些寄存器之一。
  • 该函数为您返回寄存器中的值。(当然,这仅在 C 源代码被编写为使用返回值而不是输入变量时才有效。例如dst = strcpy(dst, src);,如果您不嵌套它)。

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

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

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

PDP-11 工具链通常使用某种堆栈参数调用约定,总是将参数压入堆栈。我不确定有多少调用保留寄存器和调用破坏寄存器是正常的,但只有 5 或 6 个 GP 寄存器可用(R7 是程序计数器,R6 是堆栈指针,R5 经常用作帧指针)。所以它类似于 32 位 x86,但比 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 =,而是将输入 arg 重用于strcat. (参见Godbolt 编译器资源管理器。)

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

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


到 C-string 或不到 C-string

C 隐式长度字符串并不总是天生不好,并且具有有趣的优点(例如,后缀也是有效的字符串,无需复制它)。

但是 C 字符串库的设计方式并没有使高效代码成为可能,因为char一次循环通常不会自动矢量化,并且库函数会丢弃它们必须执行的工作结果。

gcc 和 clang 从不自动矢量化循环,除非在第一次迭代之前知道迭代计数,例如for(int i=0; i<n ;i++). ICC 可以矢量化搜索循环,但它仍然不太可能像手写 asm 一样好。


strncpy等等基本上都是灾难。例如,如果它达到缓冲区大小限制,strncpy则不会复制终止。'\0'它似乎是为写入较大字符串的中间而设计的,而不是为了避免缓冲区溢出。不返回指向末尾的指针意味着您必须在arr[n] = 0;之前或之后,可能会触摸到永远不需要触摸的内存页面。

像这样的一些函数snprintf是可用的,并且总是 nul-terminate。记住哪个做哪个是困难的,如果你记错了会有巨大的风险,所以你必须每次都检查它是否对正确性很重要。

正如布鲁斯道森所说:停止使用 strncpy !. 显然,一些 MSVC 扩展_snprintf更糟糕。

于 2018-07-26T21:15:21.050 回答
5

我相信您的猜测是正确的,它使嵌套调用更容易。

于 2010-08-24T22:10:20.900 回答
1

它也非常容易编码。

返回值通常留在 AX 寄存器中(这不是强制性的,但经常是这种情况)。当函数启动时,目的地被放入 AX 寄存器中。要返回目的地,程序员需要做......什么都不做!只需将值留在原处即可。

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

于 2010-08-24T22:21:02.123 回答
0

与Fluent Interfaces相同的概念。只是让代码更快/更容易阅读。

于 2010-08-24T22:22:00.343 回答
-2

我不认为这真的是为了嵌套目的而设置的,而是更多地用于错误检查。如果内存不服务于任何 c 标准库函数自己做很多错误检查,因此更有意义的是,这将确定在 strcpy 调用期间是否出现问题。

if(strcpy(dest, source) == NULL) {
  // Something went horribly wrong, now we deal with it
}
于 2016-02-29T13:34:02.327 回答