C++视图类型:传递const&还是值?

82

最近在代码审查讨论中提出了这个问题,但没有得出令人满意的结论。涉及的类型类似于C++ string_view TS。它们是简单的非拥有者包装器,围绕指针和长度装饰了一些自定义函数:

#include <cstddef>

class foo_view {
public:
    foo_view(const char* data, std::size_t len)
        : _data(data)
        , _len(len) {
    }

    // member functions related to viewing the 'foo' pointed to by '_data'.

private:
    const char* _data;
    std::size_t _len;
};

问题在于是否有理由更倾向于通过值或常量引用传递这些视图类型(包括即将推出的string_view和array_view类型)。

支持按值传递的论据包括“打字更少”、“如果视图具有有意义的变异,则可以突变本地副本”和“可能不会更低效”。

支持按const引用传递的论据包括“通过const&传递对象更符合惯例”,以及“可能不会更低效”。

是否有其他考虑可能最终倾向于以哪种方式更好地通过值或常量引用传递惯用视图类型的论点。

对于此问题,可以安全地假设C++11或C++14语义,以及足够现代的工具链和目标体系结构等。


在调用目标环境中的非内联函数时,为什么不对按值传递和按引用传递进行基准测试呢? - Maxim Egorushkin
12
在计算机科学的历史上,“少打字”这个论点所造成的负面影响比正面影响更大。主要原因是这根本不是一个可行的论据,人们必须停止使用它,即使是开玩笑也不行。 - screwnut
3
对我来说,一个字符串视图就是一个引用。除非需要重新设置引用,否则将引用传递给引用几乎没有任何意义。 - Mooing Duck
8个回答

53

当你不确定时,请按值传递。

现在,你应该很少会不确定。

通常情况下,传递值的成本很高,并且没有太多好处。有时您实际上需要引用存储在其他地方的可变值。在通用代码中,通常不知道复制是否是昂贵的操作,因此您会偏向于不复制。

当你不确定时,之所以应该按值传递,是因为值更容易理解。对外部数据的引用(即使是const引用)可能在算法执行过程中发生变化,例如调用函数回调等,这会将看似简单的函数变成复杂的麻烦。

在这种情况下,您已经有了一个隐式的引用绑定(到您正在查看的容器内容)。添加另一个隐式的引用绑定(到查看容器的视图对象)并不比已经存在的情况更好,因为已经存在复杂性。

最后,编译器可以更好地处理值而不是对值的引用。如果您离开了局部分析范围(通过函数指针回调),编译器必须假设存储在const引用中的值可能已完全更改(如果它不能证明相反)。自动存储中的值,如果没有人获取指向它的指针,则可以假定不修改类似地——没有定义的方式从外部作用域访问和更改它,因此可以认为不会发生这样的修改。

当您有机会将值作为值传递时,请接受这种简单性。这种情况很少发生。


10
我更喜欢有关编译器对值进行推理的论点。编译器需要假设任何回调函数可能已经改变了所引用的对象,这一点对我来说相当有说服力。 - acm
1
不应该通过值传递大对象。指针和引用的创建是为了防止不必要的大结构/对象复制。 - Thomas Matthews
@ThomasMatthews 这个问题明确涉及小对象。 - acm
@LightnessRacesinOrbit 是的,对我来说也是如此。根据上面的评论以及我下面的代码生成实验结果,原始的代码审查讨论似乎已经明确地支持视图类型始终按值传递。 - acm
我会建议:“当你不确定时,通过值传递并返回。” - KeyC0de
显示剩余4条评论

32

编辑:此处可找到代码:https://github.com/acmorrow/stringview_param

我创建了一些示例代码,似乎证明了将类似于string_view的对象按值传递会在至少一个平台上为调用者和函数定义产生更好的代码。

首先,在string_view.h中定义了一个虚假的string_view类(我没有真正的东西方便使用):

#pragma once

#include <string>

class string_view {
public:
    string_view()
        : _data(nullptr)
        , _len(0) {
    }

    string_view(const char* data)
        : _data(data)
        , _len(strlen(data)) {
    }

    string_view(const std::string& data)
        : _data(data.data())
        , _len(data.length()) {
    }

    const char* data() const {
        return _data;
    }

    std::size_t len() const {
        return _len;
    }

private:
    const char* _data;
    size_t _len;
};

现在,让我们定义一些函数,这些函数可以通过值或引用来使用string_view。以下是example.hpp中的签名:

#pragma once

class string_view;

void __attribute__((visibility("default"))) use_as_value(string_view view);
void __attribute__((visibility("default"))) use_as_const_ref(const string_view& view);

这些函数的主体在example.cpp中定义如下:

#include "example.hpp"

#include <cstdio>

#include "do_something_else.hpp"
#include "string_view.hpp"

void use_as_value(string_view view) {
    printf("%ld %ld %zu\n", strchr(view.data(), 'a') - view.data(), view.len(), strlen(view.data()));
    do_something_else();
    printf("%ld %ld %zu\n", strchr(view.data(), 'a') - view.data(), view.len(), strlen(view.data()));
}

void use_as_const_ref(const string_view& view) {
    printf("%ld %ld %zu\n", strchr(view.data(), 'a') - view.data(), view.len(), strlen(view.data()));
    do_something_else();
    printf("%ld %ld %zu\n", strchr(view.data(), 'a') - view.data(), view.len(), strlen(view.data()));
}

do_something_else函数在此处做为编译器无法洞察的任意函数调用的替身(例如来自其他动态对象的函数等)。声明位于do_something_else.hpp中:

#pragma once

void __attribute__((visibility("default"))) do_something_else();

而琐碎的定义在do_something_else.cpp中:

#include "do_something_else.hpp"

#include <cstdio>

void do_something_else() {
    std::printf("Doing something\n");
}
现在,我们将do_something_else.cpp和example.cpp编译为单独的动态库。编译器是XCode 6 clang,操作系统为OS X Yosemite 10.10.1: clang++ -mmacosx-version-min=10.10 --stdlib=libc++ -O3 -flto -march=native -fvisibility-inlines-hidden -fvisibility=hidden --std=c++11 ./do_something_else.cpp -fPIC -shared -o libdo_something_else.dylib clang++ -mmacosx-version-min=10.10 --stdlib=libc++ -O3 -flto -march=native -fvisibility-inlines-hidden -fvisibility=hidden --std=c++11 ./example.cpp -fPIC -shared -o libexample.dylib -L. -ldo_something_else 现在,我们对libexample.dylib进行反汇编:
> otool -tVq ./libexample.dylib
./libexample.dylib:
(__TEXT,__text) section
__Z12use_as_value11string_view:
0000000000000d80    pushq   %rbp
0000000000000d81    movq    %rsp, %rbp
0000000000000d84    pushq   %r15
0000000000000d86    pushq   %r14
0000000000000d88    pushq   %r12
0000000000000d8a    pushq   %rbx
0000000000000d8b    movq    %rsi, %r14
0000000000000d8e    movq    %rdi, %rbx
0000000000000d91    movl    $0x61, %esi
0000000000000d96    callq   0xf42                   ## symbol stub for: _strchr
0000000000000d9b    movq    %rax, %r15
0000000000000d9e    subq    %rbx, %r15
0000000000000da1    movq    %rbx, %rdi
0000000000000da4    callq   0xf48                   ## symbol stub for: _strlen
0000000000000da9    movq    %rax, %rcx
0000000000000dac    leaq    0x1d5(%rip), %r12       ## literal pool for: "%ld %ld %zu\n"
0000000000000db3    xorl    %eax, %eax
0000000000000db5    movq    %r12, %rdi
0000000000000db8    movq    %r15, %rsi
0000000000000dbb    movq    %r14, %rdx
0000000000000dbe    callq   0xf3c                   ## symbol stub for: _printf
0000000000000dc3    callq   0xf36                   ## symbol stub for: __Z17do_something_elsev
0000000000000dc8    movl    $0x61, %esi
0000000000000dcd    movq    %rbx, %rdi
0000000000000dd0    callq   0xf42                   ## symbol stub for: _strchr
0000000000000dd5    movq    %rax, %r15
0000000000000dd8    subq    %rbx, %r15
0000000000000ddb    movq    %rbx, %rdi
0000000000000dde    callq   0xf48                   ## symbol stub for: _strlen
0000000000000de3    movq    %rax, %rcx
0000000000000de6    xorl    %eax, %eax
0000000000000de8    movq    %r12, %rdi
0000000000000deb    movq    %r15, %rsi
0000000000000dee    movq    %r14, %rdx
0000000000000df1    popq    %rbx
0000000000000df2    popq    %r12
0000000000000df4    popq    %r14
0000000000000df6    popq    %r15
0000000000000df8    popq    %rbp
0000000000000df9    jmp 0xf3c                   ## symbol stub for: _printf
0000000000000dfe    nop
__Z16use_as_const_refRK11string_view:
0000000000000e00    pushq   %rbp
0000000000000e01    movq    %rsp, %rbp
0000000000000e04    pushq   %r15
0000000000000e06    pushq   %r14
0000000000000e08    pushq   %r13
0000000000000e0a    pushq   %r12
0000000000000e0c    pushq   %rbx
0000000000000e0d    pushq   %rax
0000000000000e0e    movq    %rdi, %r14
0000000000000e11    movq    (%r14), %rbx
0000000000000e14    movl    $0x61, %esi
0000000000000e19    movq    %rbx, %rdi
0000000000000e1c    callq   0xf42                   ## symbol stub for: _strchr
0000000000000e21    movq    %rax, %r15
0000000000000e24    subq    %rbx, %r15
0000000000000e27    movq    0x8(%r14), %r12
0000000000000e2b    movq    %rbx, %rdi
0000000000000e2e    callq   0xf48                   ## symbol stub for: _strlen
0000000000000e33    movq    %rax, %rcx
0000000000000e36    leaq    0x14b(%rip), %r13       ## literal pool for: "%ld %ld %zu\n"
0000000000000e3d    xorl    %eax, %eax
0000000000000e3f    movq    %r13, %rdi
0000000000000e42    movq    %r15, %rsi
0000000000000e45    movq    %r12, %rdx
0000000000000e48    callq   0xf3c                   ## symbol stub for: _printf
0000000000000e4d    callq   0xf36                   ## symbol stub for: __Z17do_something_elsev
0000000000000e52    movq    (%r14), %rbx
0000000000000e55    movl    $0x61, %esi
0000000000000e5a    movq    %rbx, %rdi
0000000000000e5d    callq   0xf42                   ## symbol stub for: _strchr
0000000000000e62    movq    %rax, %r15
0000000000000e65    subq    %rbx, %r15
0000000000000e68    movq    0x8(%r14), %r14
0000000000000e6c    movq    %rbx, %rdi
0000000000000e6f    callq   0xf48                   ## symbol stub for: _strlen
0000000000000e74    movq    %rax, %rcx
0000000000000e77    xorl    %eax, %eax
0000000000000e79    movq    %r13, %rdi
0000000000000e7c    movq    %r15, %rsi
0000000000000e7f    movq    %r14, %rdx
0000000000000e82    addq    $0x8, %rsp
0000000000000e86    popq    %rbx
0000000000000e87    popq    %r12
0000000000000e89    popq    %r13
0000000000000e8b    popq    %r14
0000000000000e8d    popq    %r15
0000000000000e8f    popq    %rbp
0000000000000e90    jmp 0xf3c                   ## symbol stub for: _printf
0000000000000e95    nopw    %cs:(%rax,%rax)

有趣的是,按值传递的版本要短几条指令。但那只是函数体。那么调用方呢?

我们将在example_users.hpp中定义一些函数,调用这两个重载函数,同时转发一个const std::string&

#pragma once

#include <string>

void __attribute__((visibility("default"))) forward_to_use_as_value(const std::string& str);
void __attribute__((visibility("default"))) forward_to_use_as_const_ref(const std::string& str);

并在 example_users.cpp 中定义它们:

#include "example_users.hpp"

#include "example.hpp"
#include "string_view.hpp"

void forward_to_use_as_value(const std::string& str) {
    use_as_value(str);
}

void forward_to_use_as_const_ref(const std::string& str) {
    use_as_const_ref(str);
}

我们再次编译example_users.cpp成为一个共享库:

clang++ -mmacosx-version-min=10.10 --stdlib=libc++ -O3 -flto -march=native -fvisibility-inlines-hidden -fvisibility=hidden --std=c++11 ./example_users.cpp -fPIC -shared -o libexample_users.dylib -L. -lexample

再次查看生成的代码:

> otool -tVq ./libexample_users.dylib
./libexample_users.dylib:
(__TEXT,__text) section
__Z23forward_to_use_as_valueRKNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE:
0000000000000e70    pushq   %rbp
0000000000000e71    movq    %rsp, %rbp
0000000000000e74    movzbl  (%rdi), %esi
0000000000000e77    testb   $0x1, %sil
0000000000000e7b    je  0xe8b
0000000000000e7d    movq    0x8(%rdi), %rsi
0000000000000e81    movq    0x10(%rdi), %rdi
0000000000000e85    popq    %rbp
0000000000000e86    jmp 0xf60                   ## symbol stub for: __Z12use_as_value11string_view
0000000000000e8b    incq    %rdi
0000000000000e8e    shrq    %rsi
0000000000000e91    popq    %rbp
0000000000000e92    jmp 0xf60                   ## symbol stub for: __Z12use_as_value11string_view
0000000000000e97    nopw    (%rax,%rax)
__Z27forward_to_use_as_const_refRKNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE:
0000000000000ea0    pushq   %rbp
0000000000000ea1    movq    %rsp, %rbp
0000000000000ea4    subq    $0x10, %rsp
0000000000000ea8    movzbl  (%rdi), %eax
0000000000000eab    testb   $0x1, %al
0000000000000ead    je  0xebd
0000000000000eaf    movq    0x10(%rdi), %rax
0000000000000eb3    movq    %rax, -0x10(%rbp)
0000000000000eb7    movq    0x8(%rdi), %rax
0000000000000ebb    jmp 0xec7
0000000000000ebd    incq    %rdi
0000000000000ec0    movq    %rdi, -0x10(%rbp)
0000000000000ec4    shrq    %rax
0000000000000ec7    movq    %rax, -0x8(%rbp)
0000000000000ecb    leaq    -0x10(%rbp), %rdi
0000000000000ecf    callq   0xf66                   ## symbol stub for: __Z16use_as_const_refRK11string_view
0000000000000ed4    addq    $0x10, %rsp
0000000000000ed8    popq    %rbp
0000000000000ed9    retq
0000000000000eda    nopw    (%rax,%rax)

再次说明,按值传递版本的指令数量要少几个。

在至少通过指令计数这种粗略度量标准来看,按值传递版本对调用者和生成的函数体都能产生更好的代码。

当然,我乐于听取如何改进此测试的建议。显然,下一步是将其重构为可以有意义地进行基准测试的内容。我会尽快尝试实现这一点。

我将在github上发布示例代码,并附有某种构建脚本,以便其他人可以在其系统上进行测试。

但基于上述讨论和检查生成的代码的结果,我的结论是:按值传递是视图类型的最佳选择。


我也注意到,按值传递版本生成的代码比按引用传递版本短。然而令我惊讶的是,较长的代码比按值传递的那个稍微快一些。所以正如有人已经指出的(不确定是否在这个线程中),指令的数量并不总是意味着它会运行得更快。 - Radek Strugalski

16

暂且不考虑const&方式和值方式作为函数参数时的信号价值哲学问题,我们可以看看在不同架构下的一些ABI影响。

http://www.macieira.org/blog/2012/02/the-value-of-passing-by-value/介绍了一些QT人员在x86-64, ARMv7 hard-float, MIPS hard-float (o32)和IA-64上的决策和测试。主要是检查是否可以通过寄存器传递各种结构体。毫不奇怪的是,似乎每个平台都可以通过寄存器管理2个指针。而且由于sizeof(size_t)通常等于sizeof(void*),所以没有理由认为我们会在这里溢出到内存。

我们可以找到更多相关信息,参考http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3538.html等建议。请注意,const引用具有一些缺点,即别名风险,这可能会阻止重要的优化并需要程序员进行额外的思考。在缺乏对C99中restrict的C ++支持的情况下,按值传递可以提高性能并降低认知负担。

因此,我认为我在合成支持传递值的两个参数:

  1. 32位平台通常无法通过寄存器传递两个字结构。这似乎不再是问题。
  2. const引用在定量和定性上都比值差,因为它们可能会别名。

以上所有内容将使我支持对于整数类型的小型结构体(<16 bytes)使用按值传递。显然,您的情况可能有所不同,并且在涉及性能的情况下应始终进行测试,但对于非常小的类型,值似乎更好一些。


1
根据您提到的第一点,您是否认为在64位平台上,小于等于16字节的结构体应该通过值传递而不是引用传递?这往往是我的经验法则:2个字,无论在相关平台上这些字有多长。这是基于一个假设,即2个字可以放入扩展寄存器中,或者复制起来足够便宜,以至于间接引用反而更糟糕。 - underscore_d

12
除了已经在这里支持传值的内容之外,现代C++优化器对于引用参数也有困难。
当被调用函数的主体不可用于翻译单元(函数驻留在共享库或另一个翻译单元中,并且链接时优化不可用)时,会发生以下情况:
  1. 优化器假设按引用或常量引用传递的参数可以被更改(由于const_cast,const不重要),或者可以被全局指针引用或另一个线程更改。基本上,按引用传递的参数在调用点变成了被污染的值,优化器不能再对其应用许多优化。
  2. 如果被调用函数中有几个相同基类型的引用/指针参数,则优化器会假设它们与其他内容别名,这也会阻止许多优化。
  3. 此外,所有char类型数组都可以与任何其他类型的值别名,因此修改任何std::string对象都意味着修改任何和所有其他对象,导致以下机器代码必须重新加载所有对象。 restrict关键字添加到C中,以解决这种低效率。不同地址仍然可能别名,因为可以将一个页面帧多次映射到一个虚拟地址空间中(这是0复制接收环缓冲区的常用技巧),因此编译器不能假设不同地址没有别名,除非使用restrict关键字。

从优化器的角度来看,按值传递和返回是最好的,因为这消除了别名分析的需要:调用者和被调用者独占其值的副本,因此这些值不能从任何其他地方修改。

关于这个主题的详细处理,我强烈推荐Chandler Carruth: Optimizing the Emergent Structures of C++。演讲的要点是“人们需要改变他们关于按值传递的想法...寄存器模型的参数传递已经过时了。”


9

以下是我在IT技术方面传递变量给函数的经验法则:

  1. 如果变量可以放入处理器的寄存器中且不会被修改,则传递值。
  2. 如果变量将被修改,则传递引用。
  3. 如果变量大于处理器的寄存器并且不会被修改,则通过常量引用传递。
  4. 如果需要使用指针,则通过智能指针传递。

希望这有所帮助。


你可能需要修正第二点(函数内的修改,修改后的输出参数)。 - user2249683
3
#4 的逻辑是什么?就我所知,你几乎从不希望将智能指针作为参数传递。 - James Kanze
@JamesKanze 如果所有权是共享/可变的,并且内存是堆分配的,那么您将使用4。 - Félix Cantournet
我个人不喜欢#2,因为从代码中无法看出参数是否被修改。如果参数被修改,我总是通过指针传递。 (因此,我不喜欢将参数作为非const引用。) - Paul J. Lucas
@FélixCantournet 可能是,也可能不是。如果被调用的函数应该共享所有权,那么或许可以这样做。但是共享所有权本来就很少见。 - James Kanze
1
如果没有涉及所有权语义,那么“愚蠢指针”是非常好的选择。如果函数将保留指针,以便在返回后某个时间可以访问它,则应该使用智能指针。否则,传递“愚蠢指针”是不错的选择。 - MaHuJa

3
一个值就是一个值,而一个const引用就是一个const引用。
如果对象不是不可变的,则这两个概念不等价
是的...即使通过const引用接收到的对象可能会发生变化(甚至在您手中仍有一个const引用时可能会被销毁)。 const与引用一起只说明可以使用该引用进行什么操作,它并不表示所引用的对象不会发生变化或不会因其他方式停止存在。
要查看一个非常简单的情况,其中别名可以咬伤表面上合法的代码,请参见此答案
当逻辑要求引用时(即对象标识很重要),应使用引用。当逻辑仅需要值时(即对象标识无关紧要)应传递值。对于不可变对象,通常标识是无关紧要的。
当使用引用时,应特别注意别名和生命周期问题。另一方面,当传递值时,应考虑到可能涉及复制,因此,如果类很大,并且这对于您的程序来说是一个严重的瓶颈,则可以考虑传递const引用(并仔细检查别名和生命周期问题)。
在我看来,在这种特定情况下(只有几个本地类型),需要const引用传递效率的借口将很难证明。最有可能的是,一切都将被内联,并且引用只会使优化变得更加困难。
在调用方不关心标识(即未来*状态更改)时指定const T&参数是设计错误。故意犯这个错误的唯一理由是对象很重,并且复制会严重影响性能。
对于小对象,从性能的角度来看,实际上通常更好,因为少了一个间接操作,并且优化器保守面不需要考虑别名问题。例如,如果您有F(const X& a, Y& b)并且X包含类型为Y的成员,则优化器将被迫考虑非const引用实际上是否绑定到X的那个子对象。
(*)通过“未来”,我包括从方法返回后(即调用方存储对象的地址并记住它)和在调用方代码执行期间(即别名)。

我理解这个区别,不需要对函数参数语义进行澄清。 - acm
1
F(T t)和F(const T& t)的语义非常相似,尽管当然不完全相同。作为一个编写F的人,对于预期实现而言,这两个声明都是适当的,我必须在这些声明之间做出选择。我想知道,在这两种情况下都适用时,是否有理由优先选择其中一种,针对一类狭窄的类型(指针+大小)。 - acm
1
@acm:语义确实非常不同;在F(const T&)中,您正在传递对象的地址,而F可以访问当前和未来的状态,而在F(T)中,您只是传递当前状态。如果您检查链接答案中的rect示例,您应该注意到如何使用-=(const P2d&)在概念上是错误的(并且会反噬回来)。对于使用const引用进行值传递的唯一借口是效率,但在您的情况下不成立,使用值传递编译器将做得更好。 - 6502
1
@acm:就像我之前所写的,当被调用者对身份(即未来状态更改)不感兴趣时,指定const T&是一种设计错误。唯一的理由是当对象很重且复制会导致严重的性能问题时。对于小对象而言,从性能角度来看,实际上复制通常更好,因为少了一个间接引用,优化器的偏执面不需要考虑别名问题(例如,如果您有F(const X&a,X&b),优化器将考虑两个引用都指向同一对象的可能性)。 - 6502
"F(const T&) 你在传递对象的地址,而且 F 函数可以访问当前和未来的状态。而 F(T) 只能访问当前状态。" 不幸的是,C++ 没有区分这种语义差异和仅仅为了避免复制大对象的优化。这是许多错误的根源,也是我使用该语言时面临的最大问题。 - Slava
显示剩余2条评论

0

在这种情况下使用哪个并没有丝毫的区别,这似乎只是一个关于自我认同的争论。这不应该成为代码审查的阻碍。除非有人测量性能并发现此代码是时间关键的,但我非常怀疑。


我并没有说它阻碍了代码审查。那只是一个旁边的讨论。我的问题不在于正确性,而在于习语。 - acm
2
既然在这种情况下使用哪个都没有任何区别,那你为什么这么说呢? - newacct

0

我的观点是两者都使用。更喜欢使用const&。这也成为了文档。如果你将其声明为const&,那么编译器会在你试图修改实例时(当你没有打算这样做时)发出警告。如果你确实想要修改它,那么就按值传递。但这样你明确地向未来的开发人员传达了你打算修改实例的意图。而且const&“可能不比按值传递差”,并且潜在地更好(如果构造一个实例很昂贵,并且你还没有一个实例)。


1
是的,但您可以始终声明它以取一个const值。 - acm
1
@acm 确实。然后回到建设成本的考虑。如果建设成本昂贵,那么const&可能是一个更好的选择。(暂且不考虑多线程问题。这会增加许多其他方面的考虑。) - Andre Kostur
好的。总的来说,我同意你的观点。特别是对于模板中的任意类型T,在这种情况下你无法知道按值传递T的成本,使用T const& 很可能总是更好的选择。然而,我对我们都知道确切类型并且知道该类型的表示方式正好是一个指针和一个size_t的特定情况很感兴趣。 - acm
“按值传递。但这样你明确地告诉未来的开发人员,你打算修改实例。”...什么?不是的。你只是在明确告诉他们,你会拿到他们传递的副本。你对它做什么都不关他们的事,因为它是他们的副本。如果你不想修改它,那就在实现中声明参数为const。除非你真的需要修改副本,否则应该养成这种习惯。但无论如何,你都有一个不同的实例,调用者不需要关心。 - underscore_d

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