在 C + + 中,我是否为我没有吃的东西付费?

让我们考虑下面 C 和 C + + 中的 hello world 示例:

main.c

#include <stdio.h>


int main()
{
printf("Hello world\n");
return 0;
}

main.cpp

#include <iostream>


int main()
{
std::cout<<"Hello world"<<std::endl;
return 0;
}

当我把它们在 Godbolt 中编译成汇编语言时,C 代码的大小只有9行(gcc -O3) :

.LC0:
.string "Hello world"
main:
sub     rsp, 8
mov     edi, OFFSET FLAT:.LC0
call    puts
xor     eax, eax
add     rsp, 8
ret

但是 C + + 代码的大小是22行(g++ -O3) :

.LC0:
.string "Hello world"
main:
sub     rsp, 8
mov     edx, 11
mov     esi, OFFSET FLAT:.LC0
mov     edi, OFFSET FLAT:_ZSt4cout
call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)
mov     edi, OFFSET FLAT:_ZSt4cout
call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)
xor     eax, eax
add     rsp, 8
ret
_GLOBAL__sub_I_main:
sub     rsp, 8
mov     edi, OFFSET FLAT:_ZStL8__ioinit
call    std::ios_base::Init::Init() [complete object constructor]
mov     edx, OFFSET FLAT:__dso_handle
mov     esi, OFFSET FLAT:_ZStL8__ioinit
mov     edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
add     rsp, 8
jmp     __cxa_atexit

... 比这个大得多。

众所周知,在 C + + 中,你要为你吃的东西付钱。那么,在这种情况下,我要付出什么?

25897 次浏览

那么,在这种情况下,我要付出什么?

std::coutprintf更强大、更复杂,它支持语言环境、有状态格式标志等等。

如果您不需要这些,使用 std::printfstd::puts-他们可在 <cstdio>


众所周知,在 C + + 中,你要为你吃的东西付钱。

我还想说清楚 c + + abc 0的 C++标准程式库。标准库应该是通用的并且“足够快”,但是它通常会比您所需要的专门实现慢。

另一方面,C + + 语言力求在不支付不必要的额外隐藏成本的情况下编写代码(例如,opt-in virtual,没有垃圾收集)。

C + + 中的 Input/Output 函数编写得非常优雅,而且设计得非常简单易用。在许多方面,它们是 C + + 中面向对象特性的展示。

但是您的确会放弃一些性能,但是与操作系统在较低级别处理函数所花费的时间相比,这些性能是可以忽略不计的。

您总是可以回到 C 样式函数,因为它们是 C + + 标准的一部分,或者完全放弃可移植性,使用对操作系统的直接调用。

众所周知,在 C + + 中,你要为你吃的东西付钱。所以,在这种情况下, 我花钱买的是什么?

很简单。你付 std::cout的钱。“你只为你吃的东西付钱”并不意味着“你总是得到最好的价格”。当然,printf更便宜。有人可能会说,std::cout更安全,功能更多,因此其更大的成本是合理的(它的成本更高,但提供更多的价值) ,但这没有抓住要点。你不使用 printf,你使用 std::cout,所以你为使用 std::cout付费。使用 printf不需要付费。

虚函数就是一个很好的例子。虚函数有一些运行时成本和空间需求——但只有在 事实上使用它们的情况下。如果不使用虚函数,就不需要支付任何费用。

几句话

  1. 即使 C + + 代码的计算结果是更多的汇编指令,它仍然是少量的指令,而且任何性能开销与实际的 I/O 操作相比仍然相形见绌。

  2. 事实上,有时候它甚至比“在 C + + 中你为你吃的东西付钱”还要好。例如,编译器可以推断在某些情况下不需要虚函数调用,并将其转换为非虚函数调用。这意味着您可能会得到 自由的虚函数。是不是很棒?

“ printf 的汇编清单”不是为 printf 准备的,而是为了放入(类似于编译器最佳化?); printf 要比 put 复杂得多... 别忘了!

我在这里看到了一些有效的答案,但我还是要进一步深入细节。

如果你不想看完整面墙的文字,可以跳转到下面的摘要,找到你主要问题的答案。


抽象

那么,在这种情况下,我要付出什么?

您正在为 抽象支付。能够编写更简单、更人性化的代码是有代价的。在 C + + 这种面向对象的语言中,几乎所有的东西都是对象。当你使用任何对象时,三件主要的事情总是会在引擎盖下发生:

  1. 对象创建,基本上是对象本身及其数据的内存分配。
  2. 对象初始化(通常通过某种 init()方法)。内存分配通常作为此步骤的第一步发生在底层。
  3. 对象破坏(并非总是如此)。

在代码中没有看到,但是每次使用一个对象时,以上三种情况都需要以某种方式发生。如果您手动完成所有操作,那么代码显然要长得多。

现在,可以在不增加开销的情况下有效地进行抽象: 编译器和程序员都可以使用方法内联和其他技术来消除抽象开销,但这不是您的情况。

C + + 到底发生了什么?

在这里,分解:

  1. 初始化 std::ios_base类,它是与 I/O 相关的所有内容的基类。
  2. 初始化 std::cout对象。
  3. 加载字符串并将其传递给 std::__ostream_insertstd::__ostream_insertstd::cout(基本上是 <<操作符)的一个方法,它将一个字符串添加到流中。
  4. cout::endl也传递给 std::__ostream_insert
  5. __std_dso_handle被传递给 __cxa_atexit__cxa_atexit是一个全局函数,负责在退出程序之前进行“清理”。此函数调用 __std_dso_handle本身来释放和销毁剩余的全局对象。

那么使用 C = = 不用付任何费用?

在 C 代码中,只有很少的几个步骤:

  1. 字符串被加载并通过 edi寄存器传递给 puts
  2. puts接到电话。

任何地方都没有对象,因此不需要初始化/销毁任何东西。

然而,这并不意味着你没有为 C 中的任何东西“付费”。你仍然需要为抽象、 C 标准库的初始化和动态解析付费,printf函数(或者,实际上是 puts,它是由编译器优化的,因为你不需要任何格式字符串)仍然在底层发生。

如果你用纯汇编语言编写这个程序,它会是这样的:

jmp start


msg db "Hello world\n"


start:
mov rdi, 1
mov rsi, offset msg
mov rdx, 11
mov rax, 1          ; write
syscall
xor rdi, rdi
mov rax, 60         ; exit
syscall

这基本上只会导致调用 write Syscall,然后是 exit系统调用。现在,这个将是完成同样事情的最低限度。


总结一下

C 语言更加基本,只做最基本的需求,让用户完全控制,用户可以完全优化和定制他们想要的任何东西。告诉处理器在寄存器中加载一个字符串,然后调用库函数来使用该字符串。另一方面,C + + 更加复杂和抽象.这在编写复杂代码时有巨大的优势,并且允许更容易编写和更人性化的代码,但是显然这是有代价的。在这种情况下,如果与 C 相比,C + + 的性能总是会有一个缺点,因为它是 C + + 提供了完成这些基本任务所需要的更多内容,因此增加了更多的开销

回答你的主要问题 :

我是在为我没吃的东西付钱吗?

在这个特定的例子中,是的。你没有利用 C + + 提供的比 C 更多的东西,但那只是因为在那段简单的代码中没有 C + + 可以帮助你的东西: 它是如此简单,你根本不需要 C + + 。


还有一件事!

C + + 的优点乍看之下可能并不明显,因为你写了一个非常简单和小的程序,但是看一个稍微复杂一点的例子,就会发现它们的不同之处(两个程序做的是完全相同的事情) :

C :

#include <stdio.h>
#include <stdlib.h>


int cmp(const void *a, const void *b) {
return *(int*)a - *(int*)b;
}


int main(void) {
int i, n, *arr;


printf("How many integers do you want to input? ");
scanf("%d", &n);


arr = malloc(sizeof(int) * n);


for (i = 0; i < n; i++) {
printf("Index %d: ", i);
scanf("%d", &arr[i]);
}


qsort(arr, n, sizeof(int), cmp)


puts("Here are your numbers, ordered:");


for (i = 0; i < n; i++)
printf("%d\n", arr[i]);


free(arr);


return 0;
}

C + + :

#include <iostream>
#include <vector>
#include <algorithm>


using namespace std;


int main(void) {
int n;


cout << "How many integers do you want to input? ";
cin >> n;


vector<int> vec(n);


for (int i = 0; i < vec.size(); i++) {
cout << "Index " << i << ": ";
cin >> vec[i];
}


sort(vec.begin(), vec.end());


cout << "Here are your numbers:" << endl;


for (int item : vec)
cout << item << endl;


return 0;
}

希望你能明白我的意思。还要注意的是,在 C 语言中,你必须使用 mallocfree在较低的级别管理内存,你需要更加注意索引和大小,以及在输入和打印时需要非常具体。

首先有一些误解。首先,C + + 程序 没有产生了22条指令,大约有22,000条(我从帽子里抽出了这个数字,但大约是这个数字)。而且,C 代码 没有会产生9条指令。这些只是你看到的。

C 代码所做的是,在做了很多你没有看到的事情之后,它从 CRT 调用一个函数(通常但不一定以共享库的形式出现) ,然后 没有检查返回值或处理错误,然后退出。根据编译器和优化设置的不同,它甚至不会真正调用 printf而是 puts,或者更原始的调用。
如果用相同的方法调用相同的函数,那么也可以在 C + + 中编写差不多相同的程序(除了一些不可见的 init 函数)。或者,如果您希望超级正确,那么使用 std::作为前缀的同一个函数。

相应的 C + + 代码实际上完全不是一回事。虽然整个 <iostream>是众所周知的肥猪丑猪,为小程序增加了巨大的开销(在一个“真正的”程序中,你并没有真正注意到这么多) ,一个更公平的解释是,它做了很多可怕的东西,你没有看到哪个 很有效。包括但不限于几乎所有随机事件的神奇格式化,包括不同的数字格式和地区,以及缓冲和正确的错误处理。处理错误?是的,猜猜怎么着,输出一个字符串实际上可能会失败,而且与 C 程序不同,C + + 程序会无声地忽略这一点。考虑到 std::ostream在引擎盖下的功能,而且没有人知道,它实际上是相当轻量级的。不像我使用它是因为我非常讨厌流语法。但是,如果你仔细想想它的功能,还是很棒的。

但是可以肯定的是,C + + 总体上是 没有,效率和 C 一样高。它不可能是有效的,因为它不是一样的东西,它不是 做什么一样的东西。如果没有别的事情,C + + 会生成异常(以及生成、处理或失败异常的代码) ,它提供了一些 C 没有提供的保证。所以,当然,一个 C + + 程序需要更大一点。然而,从大局来看,这无关紧要。相反,对于 真的程序,我很少发现 C + + 表现得更好,因为出于这样或那样的原因,它似乎有助于更有利的优化。别问我为什么,我不知道。

如果你写的 C 代码是 正确(也就是说,你实际上检查了错误,并且程序在出现错误的情况下运行正常) ,而不是“放弃希望”,那么差异是微乎其微的,如果存在的话。

你不是在比较 C 和 C + + 。您正在比较 printfstd::cout,它们能够处理不同的事情(语言环境、有状态格式等等)。

尝试使用下面的代码进行比较。Godbolt 为两个文件生成相同的程序集(使用 gcc 8.2,-O3进行测试)。

主要内容 c:

#include <stdio.h>


int main()
{
int arr[6] = {1, 2, 3, 4, 5, 6};
for (int i = 0; i < 6; ++i)
{
printf("%d\n", arr[i]);
}
return 0;
}

Cpp:

#include <array>
#include <cstdio>


int main()
{
std::array<int, 6> arr {1, 2, 3, 4, 5, 6};
for (auto x : arr)
{
std::printf("%d\n", x);
}
}

你的列表确实是在比较苹果和橙子,但不是因为大多数其他答案中暗示的原因。

让我们检查一下代码的实际功能:

答:

  • 打印单个字符串 "Hello world\n"

C + + :

  • 将字符串 "Hello world"传送到 std::cout
  • std::endl操纵器流入 std::cout

显然,你的 C + + 代码要做两倍的工作,为了公平起见,我们应该结合以下几点:

#include <iostream>


int main()
{
std::cout<<"Hello world\n";
return 0;
}

突然之间,你的 main汇编代码看起来和 C 非常相似:

main:
sub     rsp, 8
mov     esi, OFFSET FLAT:.LC0
mov     edi, OFFSET FLAT:_ZSt4cout
call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
xor     eax, eax
add     rsp, 8
ret

实际上,我们可以逐行比较 C 和 C + + 代码,有 几乎没有差别:

sub     rsp, 8                      sub     rsp, 8
mov     edi, OFFSET FLAT:.LC0   |   mov     esi, OFFSET FLAT:.LC0
>   mov     edi, OFFSET FLAT:_ZSt4cout
call    puts                    |   call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
xor     eax, eax                    xor     eax, eax
add     rsp, 8                      add     rsp, 8
ret                                 ret

唯一真正的区别是,在 C + + 中,我们使用两个参数(std::cout和字符串)调用 operator <<。我们甚至可以使用一个更接近的 C 等价物: fprintf,它也有一个指定流的第一个参数,从而消除这个细微的差异。

这就留下了 _GLOBAL__sub_I_main的汇编代码,它是为 C + + 而不是 C 生成的。这是在这个汇编清单中可见的唯一真正的开销(当然,对于 都有语言还有更多不可见的开销)。这段代码在 c + + 程序开始时执行一次性设置一些 C++标准程式库函数。

但是,正如在其他答案中所解释的那样,这两个程序之间的相关差异不会在 main函数的汇编输出中找到,因为所有繁重的工作都发生在幕后。

虽然现有的技术性答案是正确的,但我认为问题最终源于这种误解:

众所周知,在 C + + 中,你要为你吃的东西付钱。

这只是 C + + 社区的营销谈话。(公平地说,每个语言社区都有营销讨论。)这并不意味着你可以真正依赖任何具体的东西。

“你为你使用的东西付费”意味着一个 C + + 特性只有在你使用它的时候才有开销。但是 “特征”的定义不是无限细粒度的。通常你最终会激活具有多个方面的特性,即使你只需要这些方面的一个子集,实现部分引入这些特性通常是不切实际或不可能的。

一般来说,许多(尽管可以说不是所有)语言都力求高效,并取得了不同程度的成功。C + + 已经达到了一定的规模,但它的设计并没有什么特别或神奇的地方可以让它在这个目标上取得完美的成功。

你在为错误付出代价。在80年代,当编译器不能很好地检查格式字符串时,运算符重载被认为是在 IO 期间加强类型安全的一种好方法。然而,它的每一个横幅功能要么执行不当,要么从一开始就在概念上破产:

< iomanip >

C + + 流 ioapi 中最令人讨厌的部分是这个格式化头文件库的存在。除了有状态、丑陋和容易出错之外,它还将格式耦合到流中。

假设您想打印一行,其中8位零填充十六进制无符号整型,后面跟一个空格,后面跟一个小数点后3位的双精度数。使用 <cstdio>,您可以读取简洁的格式字符串。对于 <ostream>,你必须保存旧的状态,设置对齐为右,设置填充字符,设置填充宽度,设置基数为十六进制,输出整数,恢复保存的状态(否则整数格式会污染你的浮点格式) ,输出空格,设置符号为固定,设置精度,输出双精度和换行,然后恢复旧的格式。

// <cstdio>
std::printf( "%08x %.3lf\n", ival, fval );


// <ostream> & <iomanip>
std::ios old_fmt {nullptr};
old_fmt.copyfmt (std::cout);
std::cout << std::right << std::setfill('0') << std::setw(8) << std::hex << ival;
std::cout.copyfmt (old_fmt);
std::cout << " " << std::fixed << std::setprecision(3) << fval << "\n";
std::cout.copyfmt (old_fmt);

运算符重载

ABc0是如何避免使用运算符重载的典范:

std::cout << 2 << 3 && 0 << 5;

表演

std::cout的速度是 printf()的好几倍。猖獗的特征和虚拟调度的确会带来负面影响。

螺纹安全

<cstdio><iostream>都是线程安全的,因为每个函数调用都是原子的。但是,printf()每次通话完成的工作要多得多。如果使用 <cstdio>选项运行以下程序,您将只看到一行 f。如果您在多核机器上使用 <iostream>,您可能会看到其他的东西。

// g++ -Wall -Wextra -Wpedantic -pthread -std=c++17 cout.test.cpp


#define USE_STREAM 1
#define REPS 50
#define THREADS 10


#include <thread>
#include <vector>


#if USE_STREAM
#include <iostream>
#else
#include <cstdio>
#endif


void task()
{
for ( int i = 0; i < REPS; ++i )
#if USE_STREAM
std::cout << std::hex << 15 << std::dec;
#else
std::printf ( "%x", 15);
#endif


}


int main()
{
auto threads = std::vector<std::thread> {};
for ( int i = 0; i < THREADS; ++i )
threads.emplace_back(task);


for ( auto & t : threads )
t.join();


#if USE_STREAM
std::cout << "\n<iostream>\n";
#else
std::printf ( "\n<cstdio>\n" );
#endif
}

对这个例子的反驳是,大多数人都遵守纪律,从不从多个线程写入单个文件描述符。那么,在这种情况下,您将不得不观察到,<iostream>将有助于抓住每个 <<和每个 >>的锁定。而在 <cstdio>中,您不会经常锁定,甚至可以选择不锁定。

<iostream>使用更多的锁以获得不一致的结果。

除了其他答案都说了,
还有一个事实就是 std::endl'\n'是一样的。

不幸的是,这是一个常见的误解。 std::endl并不意味着“新线”,
它的意思是“打印新行 然后把水冲走”。 冲厕所可不便宜!

暂时完全忽略 printfstd::cout之间的区别,为了在功能上等同于 C 示例,C + + 示例应该如下所示:

#include <iostream>


int main()
{
std::cout << "Hello world\n";
return 0;
}

这里有一个例子,说明如果包含冲洗,示例应该是什么样的。

C

#include <stdio.h>


int main()
{
printf("Hello world\n");
fflush(stdout);
return 0;
}

C + +

#include <iostream>


int main()
{
std::cout << "Hello world\n";
std::cout << std::flush;
return 0;
}

当比较代码时,你应该总是小心,你比较喜欢喜欢和你理解你的代码所做的暗示。有时候,即使是最简单的例子也比有些人意识到的要复杂得多。

您所支付的是调用一个笨重的库(不像打印到控制台那样笨重)。初始化 ostream对象。有一些隐藏的储藏室。然后,调用 std::endl,它不是 \n的同义词。iostream库可以帮助您调整许多设置,并将负担放在处理器而不是程序员身上。这就是你付钱的原因。

让我们回顾一下代码:

.LC0:
.string "Hello world"
main:

初始化 ostream 对象 + cout

    sub     rsp, 8
mov     edx, 11
mov     esi, OFFSET FLAT:.LC0
mov     edi, OFFSET FLAT:_ZSt4cout
call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)

再次调用 cout打印新行并刷新

    mov     edi, OFFSET FLAT:_ZSt4cout
call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)
xor     eax, eax
add     rsp, 8
ret

静态存储初始化:

_GLOBAL__sub_I_main:
sub     rsp, 8
mov     edi, OFFSET FLAT:_ZStL8__ioinit
call    std::ios_base::Init::Init() [complete object constructor]
mov     edx, OFFSET FLAT:__dso_handle
mov     esi, OFFSET FLAT:_ZStL8__ioinit
mov     edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
add     rsp, 8
jmp     __cxa_atexit

此外,区分语言和图书馆也很重要。

顺便说一句,这只是故事的一部分。您不知道正在调用的函数中写入了什么。

正如您在其他答案中看到的那样,当您在常规库中链接并调用复杂构造函数时,就需要付费。这里没有什么特别的问题,更像是抱怨。我将指出一些现实世界的方面:

  1. 巴恩有一个核心设计原则,那就是永远不要让效率成为停留在 C 而不是 C + + 的理由。也就是说,要获得这些效率需要非常小心,偶尔也会出现一些始终有效但并非“技术上”属于 C 规范的效率。例如,没有真正指定位字段的布局。

  2. 看看 ostream。哦,我的上帝,它浮肿!如果里面有飞行模拟器我一点也不惊讶。即使是 stdlib 的 printf ()也通常运行50K 左右。这些程序员不是懒惰的程序员: printf 大小的一半与间接精度参数有关,而大多数人从不使用这些参数。几乎每个真正受限的处理器库都会创建自己的输出代码,而不是 printf。

  3. 规模的增加通常提供了一种更具包容性和灵活性的体验。打个比方,自动售货机会以几个硬币的价格出售一杯类似咖啡的物质,而整个交易只需不到一分钟。走进一家好餐馆需要布置餐桌、就座、点菜、等待、拿到一个漂亮的杯子、拿到账单、选择付款方式、加上小费,然后在离开时被祝福一天愉快。这是一种不同的体验,如果你顺便和朋友一起吃一顿丰盛的晚餐,会更方便。

  4. 人们仍然编写 ANSI C,尽管很少有 K & R C。我的经验是,我们总是用 C + + 编译器编译它,使用一些配置调整来限制拖入的内容。对于其他语言也有很好的论据: Go 去除了多态性开销和疯狂的预处理器; 对于更智能的字段打包和内存布局也有一些很好的论据。恕我直言,我认为任何语言设计都应该从目标清单开始,就像 巨蟒之禅一样。

这是一个有趣的讨论,你会问为什么不能拥有小巧、简单、优雅、完整和灵活的库呢?

没有答案,也不会有答案,这就是答案。