【问题标题】:C++ map<std::string> vs map<char *> performance (I know, "again?")C++ map<std::string> vs map<char *> 性能(我知道,“再次?”)
【发布时间】:2012-08-21 14:12:50
【问题描述】:

我使用的是带有std::string 键的地图,虽然一切正常,但我没有获得预期的性能。我搜索了一些可以优化和改进的地方,然后一位同事说,“那个字符串键会很慢。”

我读了几十个问题,他们一直说:

“不要使用char * 作为键”
std::string 密钥永远不是你的瓶颈”
char *std::string 是一个神话。”

我很不情愿地尝试了char * 键,但还是有区别,很大的区别。

我把问题归结为一个简单的例子:

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

#ifdef USE_STRING

#include <string>
typedef std::map<std::string, int> Map;

#else

#include <string.h>
struct char_cmp { 
    bool operator () (const char *a,const char *b) const 
    {
        return strcmp(a,b)<0;
    } 
};
typedef std::map<const char *, int, char_cmp> Map;

#endif

Map m;

bool test(const char *s)
{
    Map::iterator it = m.find(s);
    return it != m.end();
}

int main(int argc, char *argv[])
{
    m.insert( Map::value_type("hello", 42) );

    const int lcount = atoi(argv[1]);
    for (int i=0 ; i<lcount ; i++) test("hello");
}

首先是 std::string 版本:

$ g++ -O3 -o test test.cpp -DUSE_STRING
$ time ./test 20000000
real    0m1.893s

接下来是 'char *' 版本:

g++ -O3 -o test test.cpp             
$ time ./test 20000000
real    0m0.465s

这是一个相当大的性能差异,与我在大型程序中看到的差异大致相同。

使用char * 键在释放键时很痛苦,而且感觉不对。 C ++专家我错过了什么?有什么想法或建议吗?

【问题讨论】:

  • 正如您刚才所展示的,始终对所有笼统的陈述持保留态度。
  • 您的测试可能不是 std::string 和 char* 之间的公平比较。在您的“char *”版本的 Map 中,您没有将内存分配给您的键,而您的“std::string”版本的 Map,每次都会为键分配一个新的字符串。
  • 我刚刚意识到我的第一条评论是一揽子声明。
  • 您的自定义比较器功能版本更优雅iff您已经拥有 C 字符串。如果您不打算使用 std::string 获得的所有“类型安全/无需手动分配”的时髦,那么它并不是很有趣。 C++ 让你只为你使用的东西付费。如果你问我,这是对 std::map 灵活性的致敬
  • @LokiAstari:不总是,不。对于一些非常特别受限的情况,您必须靠近金属。

标签: c++ performance dictionary stdmap


【解决方案1】:

对此的一种解决方案是使用自定义键类,它充当const char *std::string 之间的交叉,但在运行时有一个布尔值来判断它是“拥有”还是“非拥有” .这样,您可以将密钥插入拥有其数据的地图中(并在销毁时释放它),然后与不拥有其数据的密钥进行比较。 (这与rustCow&lt;'a, str&gt;类型的概念类似)。

以下示例还继承自 boost 的 string_ref,以避免重新实现哈希函数等。

注意这有一个危险的影响,如果您不小心将非拥有版本插入到地图中,并且您指向的字符串超出范围,则键将指向已释放的内存。非拥有版本只能用于查找。

#include <iostream>
#include <map>
#include <cstring>

#include <boost/utility/string_ref.hpp>

class MaybeOwned: public boost::string_ref {
public:
  // owning constructor, takes a std::string and copies the data
  // deletes it's copy on destruction
  MaybeOwned(const std::string& string):
    boost::string_ref(
      (char *)malloc(string.size() * sizeof(char)),
      string.size()
    ),
    owned(true)
  {
    memcpy((void *)data(), (void *)string.data(), string.size());
  }

  // non-owning constructor, takes a string ref and points to the same data
  // does not delete it's data on destruction
  MaybeOwned(boost::string_ref string):
    boost::string_ref(string),
    owned(false)
  {
  }

  // non-owning constructor, takes a c string and points to the same data
  // does not delete it's data on destruction
  MaybeOwned(const char * string):
    boost::string_ref(string),
    owned(false)
  {
  }

  // move constructor, tells source that it no longer owns the data if it did
  // to avoid double free
  MaybeOwned(MaybeOwned&& other):
    boost::string_ref(other),
    owned(other.owned)
  {
    other.owned = false;
  }

  // I was to lazy to write a proper copy constructor
  // (it would need to malloc and memcpy again if it owned the data)
  MaybeOwned(const MaybeOwned& other) = delete;

  // free owned data if it has any
  ~MaybeOwned() {
    if (owned) {
      free((void *)data());
    }
  }

private:
  bool owned;
};

int main()
{
  std::map<MaybeOwned, std::string> map;
  map.emplace(std::string("key"), "value");
  map["key"] += " here";
  std::cout << map["key"] << "\n";
}

【讨论】:

    【解决方案2】:

    编译后,2 个“Hello”字符串文字将具有相同的内存地址。在char * 的情况下,您将此内存地址用作键。

    string 的情况下,每个“Hello”都会被转换为不同的对象。这只是性能差异的一小部分(非常非常小)。

    更大的部分可能是因为您使用的所有“Hello”都具有相同的内存地址strcmp 将始终获得 2 个等效的 char 指针,我很确定它会及早检查这种情况 :) 所以它永远不会真正迭代所有字符,但 std::string 比较会。

    【讨论】:

    • 这确实是 st​​d::string 键的一个大且容易被忽视的问题:operator&lt; 不是恒定时间。如果您有带有公共前缀的长键(在某些应用程序中并非不可能),则可能会导致糟糕的性能。
    【解决方案3】:

    如果您在 C++ 11 中,则不会调用复制构造函数 unless the string is changed。因为 std::string 是 C++ 构造,所以至少需要 1 次取消引用才能获取字符串数据。

    我的猜测是额外的取消引用会占用时间(如果执行 10000 次会很昂贵),并且 std::string 可能会进行适当的空指针检查,这又会消耗周期。

    【讨论】:

      【解决方案4】:

      正如 sth 所指出的,问题是关联容器(集合和映射)的规范之一,因为它们的成员搜索方法总是强制转换为 key_type,即使存在可以接受的 operator&lt;将您的键与地图中的键进行比较,尽管它们的类型不同。

      另一方面,&lt;algorithm&gt; 中的函数则不受此影响,例如 lower_bound 定义为:

      template< class ForwardIt, class T >
      ForwardIt lower_bound( ForwardIt first, ForwardIt last, const T& value );
      
      template< class ForwardIt, class T, class Compare >
      ForwardIt lower_bound( ForwardIt first, ForwardIt last, const T& value, Compare comp );
      

      所以,一个替代方案可能是:

      std::vector< std::pair< std::string, int > >
      

      然后你可以这样做:

      std::lower_bound(vec.begin(), vec.end(), std::make_pair("hello", 0), CompareFirst{})
      

      其中CompareFirst定义为:

      struct CompareFirst {
           template <typename T, typename U>
           bool operator()(T const& t, U const& u) const { return t.first < u.first; }
      };
      

      或者甚至构建一个完全自定义的比较器(但它有点难)。

      一对vector 通常在读取繁重的负载时效率更高,因此它实际上是为了存储配置。

      我确实建议提供包装访问的方法。 lower_bound 相当低级。

      【讨论】:

        【解决方案5】:

        您正在使用const char * 作为find() 的查找键。对于包含const char* 的地图,这是find 期望的正确类型,可以直接进行查找。

        包含std::string 的映射期望find() 的参数是std::string,所以在这种情况下const char* 首先必须转换为std::string。这可能就是您看到的差异。

        【讨论】:

        • 对,我认为这正是性能差异所在。处罚这么大,我有点震惊。
        • @uroc:代码复制了 20000000 个字符串(每次迭代一个),这需要一些时间。尤其是与只有一个元素的地图中的查找相比,这应该相当快。
        • 我实际程序中的地图要大得多,但它与问题无关,因此我忽略了将其添加到示例代码中。我认为改善这一点的唯一方法是切换到 char 指针。
        • @uroc:所以你真正的查找键已经是char* 了?因为如果它们是std:string,则不需要在查找时进行转换,并且示例程序中的性能损失不应该适用。
        • @sth:这通常是关联容器的问题。有一个 operator&lt; 比较 std::stringchar const* 而不分配字符串,但关联容器强制转换。这是一种痛苦......
        【解决方案6】:

        将 std::string 存储为指针,然后您就失去了复制构造函数的开销。

        但是在你必须记住处理删除之后。

        std::string 慢的原因是它自己构造。调用复制构造函数,然后在最后调用 delete。如果在堆上创建字符串,则会丢失复制构造。

        【讨论】:

        • 我认为速度差异是在调用 find 时创建临时 std::string ,正如@sth 指出的那样。我认为没有办法优化它。我的下一个问题是如何正确处理删除。在我的析构函数中,我只是遍历所有对并释放 'char *' 指针,然后在地图上调用 clear。
        • 不要猜测 - 分析代码 - 我 100% 正确地猜测你认为瓶颈所在的地方是 100% 错误的 ;-) - 程序员很难猜测这是哪里 - 22 年后,我总是感到惊讶。
        • 给我-1的窥视者能补充一下你为什么这么认为吗?
        • 将 std::string 作为指针或不在 map 中存储无疑不是问题。来吧,他在地图中只有一个字符串实例,而他正在做百万次搜索。请不要猜测,而是证明这是问题所在。更重要的是,拥有 std::map<:string int> 非常难看,除非您知道需要这样做,否则应该避免使用。
        • 现在使用std::move 可以避免额外的副本。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2016-08-28
        • 2012-02-16
        • 2019-10-06
        • 1970-01-01
        相关资源
        最近更新 更多