与空字符结尾的字符串相比,std::string有多高效?

13

我发现与老式的空字符结尾字符串相比,std::string非常慢,甚至会使我的整个程序速度减慢2倍。

我本来以为STL会慢一些,但我没有想到会慢这么多。

我正在使用Visual Studio 2008的发布模式。它显示字符串赋值比char*赋值慢100-1000倍(很难测试char*赋值的运行时间)。我知道这不是公平的比较,一个指针赋值和一个字符串复制,但我的程序有很多字符串赋值,我不确定是否可以在所有地方使用“const reference”技巧。如果使用引用计数实现,我的程序就没问题了,但似乎现在不存在这样的实现了。

我的真正问题是:为什么现在没有人再使用引用计数实现,这是否意味着我们都需要更加谨慎地避免std::string常见的性能陷阱?

我的完整代码如下。

#include <string>
#include <iostream>
#include <time.h>

using std::cout;

void stop()
{
}

int main(int argc, char* argv[])
{
    #define LIMIT 100000000
    clock_t start;
    std::string foo1 = "Hello there buddy";
    std::string foo2 = "Hello there buddy, yeah you too";
    std::string f;
    start = clock();
    for (int i=0; i < LIMIT; i++) {
        stop();
        f = foo1;
        foo1 = foo2;
        foo2 = f;
    }
    double stl = double(clock() - start) / CLOCKS\_PER\_SEC;

    start = clock();
    for (int i=0; i < LIMIT; i++) {
        stop();
    }
    double emptyLoop = double(clock() - start) / CLOCKS_PER_SEC;

    char* goo1 = "Hello there buddy";
    char* goo2 = "Hello there buddy, yeah you too";
    char *g;
    start = clock();
    for (int i=0; i < LIMIT; i++) {
        stop();
        g = goo1;
        goo1 = goo2;
        goo2 = g;
    }
    double charLoop = double(clock() - start) / CLOCKS_PER_SEC;
    cout << "Empty loop = " << emptyLoop << "\n";
    cout << "char* loop = " << charLoop << "\n";
    cout << "std::string = " << stl << "\n";
    cout << "slowdown = " << (stl - emptyLoop) / (charLoop - emptyLoop) << "\n";
    std::string wait;
    std::cin >> wait;
    return 0;
}

9
如果 char* 指针的复制适用于您(即不需要深拷贝),那么 std::string* 指针的复制同样适用。所以请使用它们。没有人说您不能混合使用指针和 std::string。就像 char* 一样,您需要确保在使用时指向的对象仍然存在。 - j_random_hacker
2
@Tim Cooper,你实际上并没有复制那些C字符串。你复制的是句柄(指针),而不是它们所指向的数据。这相当于在std::string上使用swap。 - Johannes Schaub - litb
2
Tim Cooper。尝试像这样在循环中测量大小。strlen vs str.size(),我敢打赌你会看到std::string至少快3倍 :) - Johannes Schaub - litb
4
@TimCooper 那种想法是完全错误的。使用指针、引用或智能指针来操作std::string或其他C++对象与处理其他对象一样可以很好地工作(只要它们不拥有内存,裸指针也是可以接受的)。你也可以廉价地交换两个字符串的内容(string::swap),这是一个浅层次的指针交换。如果你使用任何C++对象,默认行为是复制。这不是类的问题,而是因为C++对象默认模型化可复制的值类型,而非引用类型。如果你想要一个指向某个东西的引用或指针... - stinky472
3
建议避免使用C++,而只使用C语言,因为除此之外,您可能会对任何对象做出同样的论点:向量、列表、字符串、QT小部件。如果认为由于这些东西的默认行为是深度复制意味着您只能进行深度复制,那么您将永远无法编写非常高效的C++代码。对于C++开发人员来说,理解何时进行深度复制以及如何避免不必要的深度复制,并能够区分使用堆栈和堆是编写性能关键代码的基本要求。 - stinky472
显示剩余8条评论
14个回答

39

关于字符串和其他容器的性能确实存在已知的问题。其中大部分与临时变量和不必要的拷贝有关。

使用它并不太难,但如果你看到你的代码在不需要可修改参数的情况下接受字符串作为值,那么你就做错了:

// you do it wrong
void setMember(string a) {
    this->a = a; // better: swap(this->a, a);
}

最好通过const引用获取它,或者进行交换操作,而不是又复制了一遍。在这种情况下,对于向量或列表,性能惩罚会增加。不过,你确实是正确的,已经知道有些问题存在。例如,在这种情况下:

// let's add a Foo into the vector
v.push_back(Foo(a, b));
我们正在创建一个临时的Foo,只是为了将一个新的Foo添加到我们的向量中。在手动解决方案中,可能会将Foo直接创建到向量中。如果向量达到其容量限制,则必须重新分配更大的内存缓冲区来存储其元素。它会做什么?它会使用它们的复制构造函数将每个元素单独复制到其新位置。如果手动解决方案事先知道元素的类型,那么它可能会表现得更加智能。
另一个常见问题是引入临时对象。看看这个例子:
string a = b + c + e;

在自定义解决方案中,您可以优化性能并避免创建大量的临时对象。早期,std::string 的接口设计为支持写入时复制(copy-on-write),但随着线程变得越来越普遍,透明的写入时复制字符串存在保持其状态一致性的问题。最近的实现方式倾向于避免使用写入时复制字符串,并在适当的情况下采用其他技巧。

然而,在下一个标准版本中,大多数这些问题已经得到解决。例如,您可以使用 emplace_back 直接将一个 Foo 对象插入到向量中,而不是使用 push_back

v.emplace_back(a, b);

与其在一个拼接操作中创建副本,std::string将识别它何时拼接暂存对象并针对这些情况进行优化。重新分配也将避免制作副本,但会在适当的位置移动元素。

如果你想阅读一篇很棒的文章,请考虑阅读Andrei Alexandrescu的《移动构造函数》

然而,有时比较也容易变得不公平。标准容器必须支持它们必须支持的功能。例如,如果你的容器在添加或删除元素时不保持映射元素引用的有效性,那么将你的“更快”的映射与标准映射进行比较可能会变得不公平,因为标准映射必须确保元素始终保持有效。当声称“我的容器比标准容器更快!”时,你需要牢记这些情况。


非常抱歉 - 在发布后我进行了重大修改。我想单独提出关于std::vector<>的讨论。不管怎样,我根据你的建议更改了我的测试,并获得了50倍的减速。我认为,每当在实现中到达memcpy()时,都会有问题。 - Tim Cooper
类 C { std::string foo; public: void set(const std::string& _foo) { foo = _foo; } };
为了完全避免 memcpy(),我必须将 foo 声明为指针,是这样吗?这意味着我需要像 char* 一样担心内存分配吗?
- Tim Cooper
在这种特定情况下,通过值传递字符串,然后使用foo.swap(_foo)可能更好。我的意思是一般讨论参数传递,如果您只想传递一些参数,但不需要复制进行修改。 - Johannes Schaub - litb
据我所知,MSVC使用了一种小字符串优化:对于小字符串,它将数据保留在静态分配的成员数组缓冲区中,而不是使用堆。此外,尽可能使用.reserve等方法。 - Johannes Schaub - litb
你可能会喜欢这个视频:http://video.google.com/videoplay?docid=-562129216565760352,其中Alexandrescu讲解了他的字符串类,使用策略来实现COW / noCOW和小字符串优化。非常有趣的观看。 - Johannes Schaub - litb

11

看起来你在贴的代码中误用了char*。如果你有

std::string a = "this is a";
std::string b = "this is b"
a = b;

你正在执行一个字符串复制操作。如果你使用char*进行相同的操作,那么你实际上是在执行一个指针复制操作。

std::string的赋值操作会分配足够的内存来容纳b中的内容,然后逐个字符地复制每个字符。而在char*的情况下,它不执行任何内存分配或逐个复制单个字符,只是说“a现在指向与b相同的内存”。

我猜这就是为什么std::string比较慢的原因,因为它实际上是在复制字符串,这似乎是你想要的。要对char*执行复制操作,你需要使用strcpy()函数将其复制到已经适当大小的缓冲区中。然后你将有一个准确的比较。但是对于你的程序目的,你几乎肯定应该使用std::string。


2
你所说的一切都是正确的,但问题的重点在于,“为了有效地使用std::string,通常需要使用指针/引用来处理它们,而不是仅使用值本身。这种情况下,您需要担心值的生命周期,然而,摆脱这些问题通常被誉为std::string的主要优势。” - Tim Cooper

7

当使用任何实用类(无论是STL还是自己的)编写C ++代码时,而不是使用好老的C空终止字符串,您需要记住一些事情。

  • If you benchmark without compiler optimisations on (esp. function inlining), classes will lose. They are not built-ins, even stl. They are implemented in terms of method calls.

  • Do not create unnesessary objects.

  • Do not copy objects if possible.

  • Pass objects as references, not copies, if possible,

  • Use more specialised method and functions and higher level algorithms. Eg.:

    std::string a = "String a"
    std::string b = "String b"
    
    // Use
    a.swap(b);
    
    // Instead of
    std::string tmp = a;
    a = b;
    b = tmp;
    

最后提醒一下,当您的类C++代码变得越来越复杂时,您需要实现更高级的数据结构,如自动扩展数组、字典和高效优先队列。突然间,您会意识到这是很多工作量,并且您的类并没有比STL的更快,只是更容易出错。


5

这个测试涉及到两种根本不同的复制方式:浅拷贝和深拷贝。了解它们的区别以及如何避免在C++中使用深拷贝是非常重要的,因为默认情况下,C++对象为其实例提供值语义(与普通数据类型相同),这意味着将一个对象赋值给另一个对象通常会进行拷贝。

我改正了你的测试,得到了这个结果:

char* loop = 19.921
string = 0.375
slowdown = 0.0188244

显然,我们应该停止使用C风格的字符串,因为它们速度非常慢!实际上,我故意制造了和你一样有缺陷的测试,通过在字符串一侧进行浅层复制测试与使用strcpy:

#include <string>
#include <iostream>
#include <ctime>

using namespace std;

#define LIMIT 100000000

char* make_string(const char* src)
{
    return strcpy((char*)malloc(strlen(src)+1), src);
}

int main(int argc, char* argv[])
{
    clock_t start;
    string foo1 = "Hello there buddy";
    string foo2 = "Hello there buddy, yeah you too";
    start = clock();
    for (int i=0; i < LIMIT; i++)
        foo1.swap(foo2);
    double stl = double(clock() - start) / CLOCKS_PER_SEC;

    char* goo1 = make_string("Hello there buddy");
    char* goo2 = make_string("Hello there buddy, yeah you too");
    char *g;
    start = clock();
    for (int i=0; i < LIMIT; i++) {
        g = make_string(goo1);
        free(goo1);
        goo1 = make_string(goo2);
        free(goo2);
        goo2 = g;
    }
    double charLoop = double(clock() - start) / CLOCKS_PER_SEC;
    cout << "char* loop = " << charLoop << "\n";
    cout << "string = " << stl << "\n";
    cout << "slowdown = " << stl / charLoop << "\n";
    string wait;
    cin >> wait;
}

重点是,这实际上涉及到你最终问题的核心,你必须知道在代码中要做什么。如果使用C ++对象,那么您必须知道将一个对象赋值给另一个对象会复制该对象(除非禁用赋值,否则会出现错误)。您还必须知道何时适用于引用、指针或智能指针指向一个对象,而且对于C++11,您还应该了解移动和复制语义之间的差异。

我的真正问题是: 为什么人们不再使用引用计数实现,并且这是否意味着我们需要更加谨慎地避免std :: string常见的性能陷阱?

人们确实使用引用计数实现。这是一个例子:
shared_ptr<string> ref_counted = make_shared<string>("test");
shared_ptr<string> shallow_copy = ref_counted; // no deep copies, just 
                                               // increase ref count

区别在于字符串不会内部执行这个操作,因为对于那些不需要它的人来说这样做是低效的。类似的东西,比如写时复制也通常不再为字符串做了,原因相同(还有事实上这会使线程安全成为一个问题)。然而,如果我们希望这样做,我们就有所有的构建块:我们有交换字符串的能力而无需进行深层复制,我们有指向它们的指针、引用或智能指针的能力。
要有效地使用C++,你必须习惯这种涉及值语义的思考方式。如果你不这样做,你可能会享受到额外的安全性和便利性,但会以代码效率的巨大代价为代价(不必要的拷贝肯定是导致编写不良C++代码比C代码慢的重要原因之一)。毕竟,你最初的测试仍然处理的是对字符串的指针,而不是char[]数组。如果你正在使用字符数组而不是指向它们的指针,你同样需要执行strcpy来交换它们。对于字符串,你甚至有一个内置的swap方法来高效地执行你在测试中所做的事情,所以我的建议是花更多时间学习C++。

另外,当我在问题中提到"引用计数(reference counting)"的实现时,我指的是早期的std::string实现采用了引用计数的方式:非常高效,但在多线程程序中容易出现错误。如果shared_ptr<>既高效又没有并发问题,那么它将成为std::string的内置实现,而不需要使用"shared_ptr"这种东西。 - Tim Cooper
2
@TimCooper 但是如果你已经在使用指向C风格字符串的指针,那么引用和指针又如何变得复杂呢?如果你有一个名为Foo的结构体,且你不想进行深拷贝,那么你可以创建一个对它的引用或指针。在C语言中,编译器何时会进行拷贝同样普遍,只是我们没有像std::string一样复杂的类型构建工具。 - stinky472
2
@TimCooper 引用计数和写时复制字符串只适用于用户不了解如何避免深拷贝的简单应用,但同时使得我们这些需要并且想要复制的std::string用户变慢。这会给那些不需要这些特性的人增加开销。当实际需要进行复制时,像str1 = str2这样的操作会因为尝试避免拷贝而增加开销。熟练掌握C++的开发者本来就能够避免这样的复制操作。 - stinky472
1
@TimCooper 我有一位同事也是这样看待这个问题的。他认为像 std::vector 这样的类应该提供原子 push_backs 来保证线程安全性。但这会破坏 std::vector 在不需要并发 push_back 的情况下的效率。C++ 设计者现在追求的理念是让你避免为不需要的东西付费。 - stinky472
让我们在聊天中继续这个讨论:http://chat.stackoverflow.com/rooms/8421/discussion-between-tim-cooper-and-stinky472 - Tim Cooper
显示剩余3条评论

5
你肯定做错了什么,或者至少在STL和你自己的代码之间没有进行“公平”的比较。当然,如果没有代码可以查看,很难更具体地说明。
可能是因为你在使用STL时构建代码的方式导致更多的构造函数运行,或者在分配对象时没有以与你自己实现操作相匹配的方式重复利用它们等等。

2

如果您已经知道将来向量的最终大小,可以在填充向量之前调用reserve()函数来防止过度重新分配。


抱歉,我在发布问题后进行了重大编辑,重点放在std::string上。但是,为什么STL不会自动保留额外的空间,如果它看到你使用push_back()创建一个向量呢? - Tim Cooper
3
大多数 vector<T> 的实现在空间不足时会重新分配为原始大小的两倍。这样,添加 n 个元素最多会导致 log(n) 次重新分配,并且您永远不会浪费超过 50% 的内存。 - j_random_hacker

2

优化的主要规则:

  • 规则1:不要进行优化。
  • 规则2:(仅限专家)暂时不要进行优化。

你确定已经证明了是STL很慢,而不是你的算法吗?


2
良好的性能并不总是容易实现,但通常情况下,STL旨在为您提供强大的功能。我发现《Effective STL》这本书非常有启发性,可以帮助您有效地处理STL。建议阅读!
正如其他人所说,您可能会频繁地复制字符串,并将其与指针赋值/引用计数实现进行比较。
一般来说,任何专为满足您具体需求而设计的类,都会胜过为一般情况而设计的通用类。但是学会使用通用类,并遵循80:20规则,您会比那些自己编写所有代码的人更加高效。
std::string 的一个具体缺点是它没有性能保证,这是有道理的。正如 Tim Cooper 所提到的,STL 并不说明字符串赋值是否创建了深拷贝。对于一个通用类来说,这是好的,因为在高并发应用中,引用计数可能会成为一个真正的杀手,即使它通常是单线程应用程序的最佳方法。

0

他们没有做错。STL实现通常比你的更好。

我相信你可以为特定情况写出更好的代码,但2倍的差距太大了... 你一定是做错了什么。


0
如果正确使用,std::string 与 char* 一样高效,但具有额外的保护功能。
如果你在STL方面遇到性能问题,很可能是某些地方出了问题。
此外,不同编译器的STL实现是不标准的。我知道SGI的STL和STLPort通常表现良好。
话虽如此,我完全认真地说,你可能是C++天才,已经设计出比STL更复杂的代码。这不太可能,但谁知道呢?也许你就是C++界的勒布朗·詹姆斯。

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