为啥对于输出流,'\n' 优于 "\n"?

Posted

技术标签:

【中文标题】为啥对于输出流,\'\\n\' 优于 "\\n"?【英文标题】:Why is '\n' preferred over "\n" for output streams?为什么对于输出流,'\n' 优于 "\n"? 【发布时间】:2019-12-06 00:42:32 【问题描述】:

在this 的回答中,我们可以读到:

我想使用'\n'或使用"\n"之间没有什么区别,但后者是一个(两个)字符的数组,必须逐个字符打印,必须设置循环, 这比输出单个字符更复杂

强调我的

这对我来说很有意义。我认为输出const char* 需要一个循环来测试空终止符,必须引入比简单的putchar 更多的操作(并不意味着std::coutchar 代表调用它 - 只是介绍一个示例的简化)。

这说服了我使用

std::cout << '\n';
std::cout << ' ';

而不是

std::cout << "\n";
std::cout << " ";

值得一提的是,我知道性能差异几乎可以忽略不计。尽管如此,有些人可能会争辩说,前一种方法的意图实际上是传递单个字符,而不是恰好是一个 char 长的字符串文字(如果你数一下'\0')。

最近我为使用后一种方法的人做了一些小的代码审查。我对这个案子做了一个小小的评论,然后继续前进。开发者随后对我表示感谢,并说他甚至没有想过这种差异(主要是关注意图)。它根本没有影响(不足为奇),但改变被采纳了。

然后我开始想到底这个变化到底有多大意义,所以我跑到了神箭那里。令我惊讶的是,在带有 -std=c++17 -O3 标志的 GCC(主干)上进行测试时,它显示了 following results。为以下代码生成的程序集:

#include <iostream>

void str() 
    std::cout << "\n";


void chr() 
    std::cout << '\n';


int main() 
    str();
    chr();

让我感到惊讶,因为看起来 chr() 实际上生成的指令数量是 str() 的两倍:

.LC0:
        .string "\n"
str():
        mov     edx, 1
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:_ZSt4cout
        jmp     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)
chr():
        sub     rsp, 24
        mov     edx, 1
        mov     edi, OFFSET FLAT:_ZSt4cout
        lea     rsi, [rsp+15]
        mov     BYTE PTR [rsp+15], 10
        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)
        add     rsp, 24
        ret

这是为什么呢?为什么他们最终都使用const char* 参数调用相同的std::basic_ostream 函数?这是否意味着char 文字方法不仅没有更好,而且实际上比字符串文字更差

【问题讨论】:

有趣的是,对于这两个版本,都调用了char* 版本的ostream::insert。 (是否存在单字符重载?)生成程序集时使用了什么优化级别? @3Dave 似乎没有char 过载。 GCC 和 Clang 委托给 const char* 过载,但 MSVS(感谢 @PaulSanders)提供了额外的 optimisation。至于优化级别,我在问题中指定了 - 我使用 GCC 8.2.0-O3 考虑到您正在执行 I/O,性能差异不仅可以忽略不计,而且会降低噪音。 @Bohemian 我认为 OP 正在计算终止数组的空字符,正如问题后面提到的那样。 @Bohemian:字符串文字"\n" 的静态存储由两个字节组成:0xa(换行符)和0(终止符)。一个 2 字节的数组是一个很好的描述。 (我假设一个“正常”的 ASCII/UTF-8 C++ 实现,比如 x86-64 的 g++,其中 char = byte。)指向这个隐式长度字符串/数组的指针被传递给 ostream 运算符。 【参考方案1】:

其他答案都没有真正解释为什么编译器会生成它在您的 Godbolt 链接中执行的代码,所以我想我会加入。

如果你查看生成的代码,你可以看到:

std::cout << '\n';

编译成,实际上是:

const char c = '\n';
std::cout.operator<< (&c, 1);

为了完成这项工作,编译器必须为函数chr() 生成一个堆栈帧,这是许多额外指令的来源。

另一方面,在编译时:

std::cout << "\n";

编译器可以将str() 优化为简单的“尾调用”operator&lt;&lt; (const char *),这意味着不需要堆栈帧。

因此,您将调用 operator&lt;&lt; 放在单独的函数中这一事实使您的结果有些偏差。使这些调用内联更具启发性,请参阅:https://godbolt.org/z/OO-8dS

现在您可以看到,虽然输出 '\n' 仍然有点贵(因为 ofstream::operator&lt;&lt; (char) 没有特定的重载),但差异没有您的示例那么明显。

【讨论】:

好答案。令我惊讶的是,默认情况下,输出chars 真的代表输出const char*。 C++ 似乎以性能为中心,而这些东西虽然通常可以忽略不计,但仍然会溜走...... @Fureeish 是的,我也很惊讶。我在 Godbolt 中简单地检查了一下,Clang 和 gcc 做同样的事情。另一方面,MSVC 似乎有特定的过载operator&lt;&lt; (char),请参阅:godbolt.org/z/AQiyMw @PaulSanders:相同;我假设普通的 C++ 库将包含一个等效于 fputc 的 ostream,它按值获取一个字符。但显然只有 MSVC 可以,在 x86 的 3 个主要版本(MSVCRT、libstdc++ 和 libc++)中。我在 Godbolt (clang -stdlib=libc++godbolt.org/z/sDDgsC) 上检查了 libc++,它总是对字符串和字符使用 char* + 长度函数。 (对于未知的字符串长度,它首先运行strlen)。所以在内部我猜它的 iostream 库只需要使用显式长度的缓冲区,这样它就可以 memcpy 而不是 strcpy。 编译器有一个错过的优化,他们不只是push 0xa / mov rsi,rsp 来存储 + 为字符保留空间;相反,他们sub rsp, ?? 并单独进行字节存储,然后需要 LEA 来复制地址。愚蠢的编译器。不过,在一个更大的函数内部是有意义的;通常希望 RSP 对齐 16,因此推送会使其不对齐。这成为一般错过优化的特殊情况,即不使用push 来存储在函数进入时立即在内存中溢出/初始化的变量的初始值。 人们经常忘记&lt;&lt;格式化输出——它需要根据流的宽度/填充/标志来填充你的字符。这是可以在charconst char* 之间重用的相当大的代码块,所以我对它们共享一个共同的实现并不感到惊讶。如果您只想输出单个字符,则可以使用未格式化的put【参考方案2】:

请记住,您在程序集中看到的只是调用堆栈的创建,而不是实际函数的执行。

std::cout &lt;&lt; '\n'; 仍然比std::cout &lt;&lt; "\n";很多

我创建了这个小程序来测量性能,它在我的机器上使用 g++ -O3 的速度大约 20 倍。自己试试吧!

编辑:抱歉注意到我的程序中有错字,它并没有那么快!几乎无法衡量任何差异。有时一个更快。其他时间其他。

#include <chrono>
#include <iostream>

class timer 
    private:
        decltype(std::chrono::high_resolution_clock::now()) begin, end;

    public:
        void
        start() 
            begin = std::chrono::high_resolution_clock::now();
        

        void
        stop() 
            end = std::chrono::high_resolution_clock::now();
        

        template<typename T>
        auto
        duration() const 
            return std::chrono::duration_cast<T>(end - begin).count();
        

        auto
        nanoseconds() const 
            return duration<std::chrono::nanoseconds>();
        

        void
        printNS() const 
            std::cout << "Nanoseconds: " << nanoseconds() << std::endl;
        
;

int
main(int argc, char** argv) 
    timer t1;
    t1.start();
    for (int i0; 10000 > i; ++i) 
        std::cout << '\n';
    
    t1.stop();

    timer t2;
    t2.start();
    for (int i0; 10000 > i; ++i) 
        std::cout << "\n";
    
    t2.stop();
    t1.printNS();
    t2.printNS();

编辑:正如 geza 建议的那样,我尝试了 100000000 次迭代并将其发送到 /dev/null 并运行了四次。 '\n' 曾经慢过 3 倍,但从来没有很多,但在其他机器上可能会有所不同:

Nanoseconds: 8668263707
Nanoseconds: 7236055911

Nanoseconds: 10704225268
Nanoseconds: 10735594417

Nanoseconds: 10670389416
Nanoseconds: 10658991348

Nanoseconds: 7199981327
Nanoseconds: 6753044774

我想总的来说我不会太在意。

【讨论】:

没错,他们委托给同一个调用,但std::cout &lt;&lt; "Hello World"; 也会委托给同一个调用。但是大小发生了变化:对于 '\n' 是 1,对于 "\n" 是 2,对于 "Hello World" 是 12。函数的执行仍然可以更快。抱歉,起初我的程序中也有一个类型,其中我有 ' ' 而不是 '\n'。现在我已经增加到 1000000 次迭代,并且 '\n' 仍然始终更快,尽管只有一点点。 您应该使用更大的迭代次数,并将标准输出重定向到某个文件或/dev/null。那么差异应该会小得多(因为我们不想对噪声、CPU 的动态频率缩放、控制台输出速度等进行基准测试)。 已经使用 MSVC 构建和重定向到 NUL 运行了 100000000 次迭代。现在形势发生了逆转。在 3 次运行期间,char 的输出总是更快,但幅度不大,平均而言,char 的输出快了 1.7%。 另外我认为"\n" 更容易维护,当需要在换行符之前插入字符时。如果我写'xyz\n',MSVC 不会抱怨,这很容易被匆忙忽略。 你的性能结果几乎肯定是由刷新控制的,因为你还没有关闭sync_with_stdio【参考方案3】:

是的,对于这个特定的实现,例如,char 版本比字符串版本慢一点。

两个版本都调用write(buffer, bufferSize) 样式函数。对于字符串版本,bufferSize 在编译时是已知的(1 字节),因此无需在运行时查找零终止符。对于char 版本,编译器在堆栈上创建一个1 字节的小缓冲区,将字符放入其中,然后传递此缓冲区以写出。所以,char 版本要慢一些。

【讨论】:

Clang 相同。 MSVS 将两者编译为同一个程序集。这看起来真的很奇怪,尤其是因为char 版本似乎被普遍推荐。 @Fureeish:没关系。差异非常微不足道。与创建 1 字节的小缓冲区相比,整个写入应该花费更多的时间(即使它是缓冲的)。 我知道这不应该太重要,这可能就是为什么没有对char 方法进行优化的原因。如果没有人出乎意料的解释,我会很乐意在不久的将来接受你的回答。 不能像字符串一样将 char 存储在 const 内存中,然后只传递它的地址吗?事实上,它甚至可能检测到'\n'"\n" 的第一个字符并同时使用.LC0 @Barmar:如果编译器可以证明它不会导致任何问题,那么是的,可以进行这种转换。但证明这一点并非易事。例如,operator&lt;&lt; 可以调用(理论上)chr()。应该创建另一个缓冲区,而不是使用前一个缓冲区(因为理论上,operator&lt;&lt; 可以存储缓冲区的地址,并且如果 chr() 再次调用它,它会改变)。

以上是关于为啥对于输出流,'\n' 优于 "\n"?的主要内容,如果未能解决你的问题,请参考以下文章

swift 字符串中为啥'\n'不换行

为啥错误控制运算符@不起作用,仍会输出错误,我使用的PHP版本为8.2.0?

为啥“实现可运行”优于“扩展线程”? [复制]

对于时间序列汇总/聚合,流处理是不是优于批处理?

C语言编程问题,下面的这个程序为啥在添加了输出答对、答错题目个数,就会出现主函数未定义的问题?

在 Elixir 中,为啥在导入模块时“别名”优于“导入”?