【问题标题】:How efficient is std::string compared to null-terminated strings?与以空结尾的字符串相比,std::string 的效率如何?
【发布时间】:2010-10-12 20:50:52
【问题描述】:

我发现std::strings 与老式的以空字符结尾的字符串相比非常慢,非常慢,以至于它们显着使我的整个程序减慢了 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;
}

【问题讨论】:

  • 如果 char* 指针副本对您有用(即,如果不需要深拷贝),那么 std::string* 指针副本也是如此。所以使用它们。没有人说你不允许混合指针和 std::strings。与 char* 一样,您需要确保所指向的对象在您使用它们时保持活动状态。
  • @Tim Cooper,您实际上并没有复制这些 C 字符串。您复制的是句柄(指针),而不是它们指向的数据。它相当于在 std::string 上使用 swap。
  • 蒂姆·库珀。尝试像这样循环测量尺寸。 strlen vs str.size() 我敢打赌你会看到 std::string 至少快 3 倍 :)
  • @TimCooper 这是一种完全错误的思考方式。与任何其他对象一样,使用指向 std::string 或任何其他 C++ 对象的指针或引用或智能指针非常好(只要它们不拥有内存,原始指针就非常好)。您还可以廉价地交换两个字符串的内容(string::swap),这是指针的浅交换。如果您使用任何 C++ 对象,则默认行为是 复制。这不是类的问题,这是因为默认情况下,C++ 对象模型可复制的值类型,而不是引用类型。如果你想要一个引用或指向 ...
  • @TimCooper 也避免一起使用 C++ 并坚持使用 C,否则你可以对任何对象提出相同的论点:向量、列表、字符串、QT 小部件,如果你认为因为此类事物的默认行为是深复制意味着您可以深复制,那么您将永远不会编写非常高效的 C++ 代码。对于 C++ 开发人员而言,编写性能关键代码以了解在何处进行深度复制以及如何避免不必要的复制以及能够区分使用堆栈和堆的内容是至关重要的。

标签: c++ stl performance


【解决方案1】:

您肯定做错了什么,或者至少没有在 STL 和您自己的代码之间进行“公平”比较。当然,不看代码就很难更具体了。

可能是您使用 STL 构建代码的方式导致更多构造函数运行,或者没有以与您自己实现操作时所做的方式相匹配的方式重用分配的对象,等等.

【讨论】:

    【解决方案2】:

    他们没有出错。 STL 实现通常比你的要好。

    我相信你可以为一个非常特殊的情况写一些更好的东西,但是 2 的因素太多了......你真的做错了什么。

    【讨论】:

      【解决方案3】:

      如果使用得当,std::string 与 char* 一样高效,但增加了保护。

      如果您在使用 STL 时遇到性能问题,很可能是您做错了什么。

      此外,STL 实现不是跨编译器的标准。我知道 SGI 的 STL 和 STLPort 表现一般。

      也就是说,我是完全认真的,您可能是 C++ 天才,并且设计了比 STL 复杂得多的代码。这不太可能,但谁知道呢,你可能是 C++ 的 LeBron James。

      【讨论】:

        【解决方案4】:

        如果您知道向量的最终大小,您可以通过在填充之前调用 reserve() 来防止过度调整大小。

        【讨论】:

        • 抱歉,我在发布后对问题进行了重大编辑,以专注于 std::string 的问题。但是,如果 STL 看到您使用 push_back() 创建向量,为什么它不自动保留额外空间?
        • @Tim:vector 的大多数实现都会这样做——当空间用完时,它们会重新分配为原始大小的两倍。这样,添加 n 个元素会导致最多 log(n) 次重新分配,并且您永远不会浪费超过 50% 的内存。
        【解决方案5】:

        很大一部分原因可能是引用计数不再用于 STL 的现代实现。

        故事是这样的(如果我错了,请指正我):一开始,STL 实现使用引用计数,并且速度很快但不是线程安全的 - 实现者希望应用程序程序员在更高级别插入他们自己的锁定机制, 以使它们成为线程安全的,因为如果在 2 个级别上完成锁定,那么这会使事情变慢两倍。

        但是,世界上的程序员太无知或太懒了,无法在任何地方插入锁。例如,如果多线程程序中的工作线程需要读取 std::string 命令行参数,那么即使只是读取字符串也需要锁定,否则可能会发生崩溃。 (2 个线程在不同 CPU 上同时增加引用计数(+1),但分别减少(-2),因此引用计数降至零,并释放内存。)

        因此实现者放弃了引用计数,而是让每个 std::string 始终拥有自己的字符串副本。更多的程序可以工作,但它们都比较慢。

        所以现在,即使是简单地将一个 std::string 分配给另一个,(或等效地,将 std::string 作为参数传递给函数),也需要大约 400 条机器代码指令,而不是 2 条分配一个char*,减速200倍。

        我在一个主要程序上测试了 std::string 效率低下的程度,与以空字符结尾的字符串相比,该程序的总体速度降低了约 100%。我还使用以下代码测试了原始 std::string 分配,这表明 std::string 分配慢了 100-900 倍。 (我无法测量 char* 分配的速度)。我还调试了 std::string operator=() 函数——我最终在堆栈中的膝盖深处,大约 7 层深,然后点击“memcpy()”。

        我不确定是否有任何解决方案。也许如果您需要快速的程序,请使用普通的老式 C++,如果您更关心自己的生产力,则应该使用 Java。

        #define LIMIT 800000000
        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;
        
        TfcMessage("done", 'i', "Empty loop = %1.3f s\n"
                                "char* loop = %1.3f s\n"
                                "std::string loop = %1.3f s\n\n"
                                "slowdown = %f", 
                                emptyLoop, charLoop, stl, 
                                (stl - emptyLoop) / (charLoop - emptyLoop));
        

        【讨论】:

        • 你在比较苹果和橙子。在第二种情况下,您只是更改了一些指针。与第一个代码等效的是调用 strcpy()。另外,我相信你没有复制粘贴所有代码
        • 错了,严重错了。今天仍然需要锁定对象以防止并发访问。但是使用引用计数,您必须锁定可能是您正在使用的字符串的副本的 all 字符串。此外,测得 COW 的性能较慢,详情请参阅 google。
        • MSalters,如果我在某处暗示从 std::string 中删除引用计数消除了锁定任何对象以防止并发访问的需要,我深表歉意。
        • Ionut Anghelcovici:我意识到 char* 只涉及复制指针。我想我的意思是我宁愿这样做而不是复制完整的字符串。
        • 如果您想复制指针,那么只需将指针复制到 std::string。否则,这根本就不是一个有意义的比较。
        【解决方案6】:

        我会说 STL 实现比传统实现要好。您是否也尝试过使用列表而不是向量,因为向量对某些目的有效,而列表对其他目的有效

        【讨论】:

        • 我不相信我在自己的代码中遇到过std::list 实际上是最好的容器的情况。
        • @David:取决于用例。如果您通常将内容插入集合的中间,并且不需要随机访问,那么列表可能会快很多。
        • @cHao 我不相信这通常是正确的,我最近刚刚写了一个更完整的主题处理:stackoverflow.com/questions/8742462/…。执行摘要:在列表中搜索以查找要在中间插入的位置的成本在std::list 中占主导地位,以至于插入std::vector 的中间位置并将所有内容都移过来仍然更便宜。在现代计算机中,内存位置规则。
        【解决方案7】:

        关于字符串和其他容器的性能肯定存在已知问题。其中大多数与临时和不必要的副本有关。

        正确使用它并不难,但做错也很容易。例如,如果您看到您的代码在不需要可修改参数的情况下按值接受字符串,那么您做错了:

        // 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 的界面被设计为对写时复制友好。然而,随着线程变得越来越流行,写入字符串的透明复制在保持其状态一致方面存在问题。最近的实现倾向于避免在写入字符串时复制,而是在适当的情况下应用其他技巧。

        然而,对于下一版本的标准,大部分问题都已解决。例如,代替push_back,您可以使用emplace_back 直接在您的向量中创建Foo

        v.emplace_back(a, b);
        

        而不是在上面的串联中创建副本,std::string 将识别何时串联临时并针对这些情况进行优化。重新分配也将避免复制,但会将元素移动到适当的新位置。

        如需出色阅读,请考虑由 Andrei Alexandrescu 撰写的 Move Constructors

        然而,有时比较也往往是不公平的。标准容器必须支持它们必须支持的特性。例如,如果您的容器在从地图中添加/删除元素时没有保持地图元素引用有效,那么将“更快”的地图与标准地图进行比较可能会变得不公平,因为标准地图必须确保元素保持有效。当然,这只是一个例子,当你说“我的容器比标准容器快!!!”时,你必须记住很多这样的情况。

        【讨论】:

        • 我很抱歉 - 我在发布后对问题进行了重大编辑。我想在一个单独的问题中讨论 std::vector 。无论如何,我根据你的建议改变了我的测试,并且减速了 50 倍。每当你在实现中到达 memcpy() 时,我认为你就有麻烦了。
        • 类 C { std::string foo;公共:无效集(const std::string& _foo){ foo = _foo; } };为了完全避免 memcpy(),我必须将 foo 声明为指针,不是吗?这意味着我需要担心内存分配,就像 char* 一样?
        • 在这种特殊情况下,按值获取字符串,然后使用 foo.swap(_foo);可能更好。如果您只想传递一些参数但不需要复制进行修改,我的意思是一般谈论参数传递。
        • 据我所知,msvc 使用小字符串优化:对于小字符串,它将数据保存在静态分配为成员数组的缓冲区中,而不是使用堆。也尽量使用 .reserve 之类的东西。
        • 您可能会喜欢这里的视频:video.google.com/videoplay?docid=-562129216565760352 这是 alexandrescu 谈论他的字符串类使用 COW/noCOW 和小字符串优化策略。有趣的手表
        【解决方案8】:

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

        • 如果您在没有编译器优化(尤其是函数内联)的情况下进行基准测试,类将会丢失。它们不是内置的,即使是 stl。它们是根据方法调用实现的。

        • 不要创建不必要的对象。

        • 尽可能不要复制对象。

        • 如果可能,将对象作为引用而不是副本传递,

        • 使用更专业的方法和函数以及更高级别的算法。例如:

          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 的 C++ 代码开始变得更加复杂时,您需要实现更高级的数据结构,例如自动扩展数组、字典、高效的优先级队列。突然你意识到它的工作量很大,而且你的课程并没有真正比 stl 更快。只是更多的错误。

        【讨论】:

          【解决方案9】:

          优化的主要规则:

          • 规则 1:不要这样做。
          • 规则 2:(仅限专家)不要这样做。

          您确定您已证明确实是 STL 慢,而不是您的算法

          【讨论】:

            【解决方案10】:

            使用 STL 实现良好的性能并不总是那么容易,但一般来说,它旨在为您提供强大的功能。我发现 Scott Meyers' "Effective STL" 让我大开眼界,让我了解如何有效地处理 STL。阅读!

            正如其他人所说,您可能经常遇到字符串的深层副本,并将其与指针分配/引用计数实现进行比较。

            通常,针对您的特定需求设计的任何类都将击败为一般情况而设计的通用类。但是学会很好地使用泛型类,学会遵守 80:20 规则,你会比自己滚动所有东西的人更有效率。


            std::string 的一个特定缺点是它不提供性能保证,这是有道理的。正如 Tim Cooper 所提到的,STL 并没有说明字符串赋值是否会创建深层副本。这对于泛型类很好,因为引用计数可能成为高并发应用程序中的真正杀手,即使它通常是单线程应用程序的最佳方式。

            【讨论】:

              【解决方案11】:

              您似乎在粘贴的代码中误用了 char*。如果你有

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

              您正在执行字符串复制操作。如果您对 char* 执行相同操作,则您正在执行指针复制操作。

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

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

              【讨论】:

              • 您所说的一切都是正确的,但问题的关键是“要有效地使用 std::string,您通常需要使用对它们的指针/引用而不是值本身,在这种情况下,您'不得不担心值的生命周期,但摆脱这些担忧通常被吹捧为 std::string 的主要优势。”
              【解决方案12】:

              这个测试是测试两个根本不同的东西:浅拷贝和深拷贝。了解差异以及如何避免 C++ 中的深拷贝至关重要,因为默认情况下,C++ 对象为其实例提供值语义(与普通旧数据类型的情况一样),这意味着通常将一个分配给另一个复制。

              我“纠正”了你的测试并得到了这个:

              char* loop = 19.921
              string = 0.375
              slowdown = 0.0188244
              

              显然我们应该停止使用 C 风格的字符串,因为它们太慢了!实际上,我故意让我的测试和你的一样有缺陷,通过在字符串端测试浅拷贝与在 :

              #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 中,您还应该了解移动和复制语义之间的区别。

              我真正的问题是:为什么人们不使用引用计数 实现了,这是否意味着我们都需要 更加小心避免常见的性能陷阱 标准::字符串?

              人们确实使用引用计数实现。下面是一个例子:

              shared_ptr<string> ref_counted = make_shared<string>("test");
              shared_ptr<string> shallow_copy = ref_counted; // no deep copies, just 
                                                             // increase ref count
              

              不同之处在于 string 不会在内部执行它,因为这对于不需要它的人来说效率低下。由于类似的原因(加上它通常会使线程安全成为问题),通常不再对字符串执行诸如写时复制之类的事情。然而,如果我们愿意的话,我们这里有所有的构建块来进行写时复制:我们能够在不进行任何深度复制的情况下交换字符串,我们能够为它们创建指针、引用或智能指针.

              要有效地使用 C++,您必须习惯这种涉及值语义的思维方式。如果您不这样做,您可能会享受额外的安全性和便利性,但这样做会降低代码的效率(不必要的副本肯定是导致编写不佳的 C++ 代码比 C 慢的重要部分)。毕竟,您的原始测试仍在处理指向字符串的指针,而不是char[] 数组。如果你使用的是字符数组而不是指向它们的指针,你同样需要strcpy 来交换它们。使用字符串,您甚至可以使用内置的交换方法来有效地完成您在测试中所做的事情,因此我的建议是花更多时间学习 C++。

              【讨论】:

              • 我的重点是 C 风格的字符串鼓励你使用指针并进行浅拷贝,而对于 C++ 字符串,你需要做一些复杂的事情才能将它们用作引用,如果你正在使用指向 C++ 字符串的指针/引用,那么您需要担心实际字符串的生命周期是多少,在这种情况下,人们想知道 char* 的改进是什么。
              • 另外,当我在问题中提到“引用计数”实现时,我指的是 std::string 的早期实现是引用计数实现:非常高效,但容易出现错误在多线程程序中。如果 shared_ptr 既高效又没有相同的并发问题,那么它将是 std::string 的内置实现,不需要那些“shared_ptr”的东西。
              • @TimCooper 但是,当您已经在 C 风格的字符串中使用指针时,引用和指针有多复杂?如果你有一个 struct Foo 并且你不想深度复制它,你要么引用它,要么指向它。编译器何时进行复制的知识在 C 中同样普遍,我们只是没有那么多工具来构建像 std::string 这样复杂的类型。
              • @TimCooper 引用计数和写时复制字符串仅在用户不了解如何避免深度复制的天真使用时才有效。但与此同时,它使我们这些使用 std::string 的人在实际需要和打算复制的地方变慢。它增加了我们这些不想要这些功能的人的开销。它基本上增加了 str1 = str2 的开销,当一个副本实际上是通过尝试做一些花哨的事情来避免复制时,除非字符串被修改。有了知识渊博的开发人员手中的 C++,他无论如何都会避免这样的副本。
              • @TimCooper 我有一位同事实际上以同样的方式看待事情。他认为像 std::vector 这样的类应该为线程安全提供原子 push_backs。对于不需要并发 push_backs 的情况,这会破坏 std::vector 的效率。现在 C++ 设计者所追求的哲学是让你避免为不需要的东西买单。
              【解决方案13】:

              std::string总是比 C 字符串慢。 C 字符串只是一个线性内存数组。仅作为数据结构,您无法获得比这更有效的方法。您使用的算法(如strcat()strcpy())通常等同于STL 对应物。相对而言,类实例化和方法调用将比 C 字符串操作慢得多(如果实现使用虚拟则更糟)。获得同等性能的唯一方法是编译器进行优化。

              【讨论】:

              • 它会做什么,除非你告诉它不要。优化是 C++ 中的默认设置,因为在幕后进行了很多事情,以至于当你不需要做所有事情时,这会使事情变得难以忍受。但是,使用一个体面的编译器,C++ 可以和 C 一样快,如果不是更快的话。 (考虑到字符串副本可以很容易地在 C++ 中内联,并且由于您已经有了长度(与 C 中不同),实际副本变为 rep movsb(这几乎与您将获得的一样快)。跨度>
              【解决方案14】:
              string const string& char* Java 字符串 -------------------------------------------------- ------------------------------------------------- 高效 否 ** 是 是 是 任务 线程安全 是 是 是 是 内存管理 是 否 否 是 为你做了

              ** std::string 有 2 种实现:引用计数或深拷贝。引用计数在多线程程序中引入了性能问题,即使只是读取字符串,深拷贝显然更慢,如上所示。看: Why VC++ Strings are not reference counted?

              如该表所示,“string”在某些方面优于“char*”,而在其他方面则更差,“const string&”在属性上与“char*”相似。就我个人而言,我将在许多地方继续使用'char*'。大量的 std::string 的复制是默默发生的,带有隐式的复制构造函数和临时变量,这让我对 std::string 有点矛盾。

              【讨论】:

              • 我更喜欢 const& 尽可能,几乎是出于这样的原因。我认为内存管理 /is/ 为您完成,因为您可以拥有 const& 而无需指针。
              • 任何时候你使用'const&'你需要担心字符串的所有者超出范围,此时你有一个悬空引用。所以我坚持认为内存管理不是为你完成的。
              猜你喜欢
              • 2015-07-18
              • 1970-01-01
              • 1970-01-01
              • 2014-07-15
              • 2017-08-28
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 2020-08-25
              相关资源
              最近更新 更多