【问题标题】:Examples of when a bitwise swap() is a bad idea?什么时候按位交换()是一个坏主意的例子?
【发布时间】:2012-07-23 04:59:50
【问题描述】:

在 OOP 语言(包括 C++)中,您不应该将对象指针视为指向原始二进制数据的指针。对象“不仅仅是”它们的表现形式。

因此,例如,swap通过交换两个对象的字节是不正确的:

template<class T>
void bad_swap(T &a, T &b)  // Assuming T is the most-derived type of the object
{
    char temp[sizeof(T)];
    memcpy(temp, &a, sizeof(a));
    memcpy(&a, &b, sizeof(b));
    memcpy(&b, temp, sizeof(temp));
}

然而,我可以想象这个快捷方式导致问题的唯一情况是当一个对象包含一个指向自身的指针时,我在实践中很少(从来没有?)看到过;不过,可能还有其他情况。

如果执行按位交换,正确的 swap 何时会中断,有哪些实际(现实世界)示例?
我可以很容易地想出带有自指针的人为示例,但我想不出任何真实世界的示例。

【问题讨论】:

  • 这对于可简单复制的类型是完全合法的。 C++ 不是严格意义上的“OOP 语言”,可平凡复制的类型“不仅仅是它们的表示”。
  • 什么时候是一个的主意?我敢肯定,只要二进制副本或交换正常,编译器就会生成它。
  • 使用指向自身的指针的常用对象是 std::string,为了优化,一些实现包含一个短字符串缓冲区,它是主“结构”的一部分。如果长度超出此缓冲区的大小,则使用动态分配的缓冲区。有一个指针,要么指向内部短字符串,要么指向动态分配的缓冲区。
  • 我不知道您使用的是什么编译器,但我看到的编译器足够聪明,可以内联memcpy,并为结构分配(或循环)生成相同的代码复制一个数组)。
  • 请注意,交换自指针的问题不仅影响具有自指针的对象,还影响包含此类对象的任何其他对象。

标签: c++ bit-manipulation swap


【解决方案1】:

这不是专门针对 swap 的,而是一个表明低级优化可能不值得麻烦的示例。无论如何,编译器通常会计算出来。

当然,这是我最喜欢的例子,编译器非常幸运,但无论如何我们不应该认为编译器很愚蠢,我们可以通过一些简单的技巧轻松改进生成的代码。

我的测试代码是 - 构造一个 std::string 并复制它。

std::string whatever = "abcdefgh";
std::string whatever2 = whatever;

第一个构造函数是这样的

  basic_string(const value_type* _String,
               const allocator_type& _Allocator = allocator_type() ) : _Parent(_Allocator)
  {
     const size_type _StringSize = traits_type::length(_String);

     if (_MySmallStringCapacity < _StringSize)
     {
        _AllocateAndCopy(_String, _StringSize);
     }
     else
     {
        traits_type::copy(_MySmallString._Buffer, _String, _StringSize);

        _SetSmallStringCapacity();
        _SetSize(_StringSize);
     }
  }

生成的代码是

   std::string whatever = "abcdefgh";
000000013FCC30C3  mov         rdx,qword ptr [string "abcdefgh" (13FD07498h)]  
000000013FCC30CA  mov         qword ptr [whatever],rdx  
000000013FCC30D2  mov         byte ptr [rsp+347h],0  
000000013FCC30DA  mov         qword ptr [rsp+348h],8  
000000013FCC30E6  mov         byte ptr [rsp+338h],0  

这里traits_type::copy包含对memcpy的调用,它被优化为整个字符串的单个寄存器副本(仔细选择以适应)。编译器还将对strlen 的调用转换为编译时8

然后我们将它复制到一个新的字符串中。复制构造函数是这样的

  basic_string(const basic_string& _String)
     : _Parent(std::allocator_traits<allocator_type>::select_on_container_copy_construction(_String._MyAllocator))
  {
     if (_MySmallStringCapacity < _String.size())
     {
        _AllocateAndCopy(_String);
     }
     else
     {
        traits_type::copy(_MySmallString._Buffer, _String.data(), _String.size());

        _SetSmallStringCapacity();
        _SetSize(_String.size());
     }
  }

结果只有 4 条机器指令:

   std::string whatever2 = whatever;
000000013FCC30EE  mov         qword ptr [whatever2],rdx  
000000013FCC30F6  mov         byte ptr [rsp+6CFh],0  
000000013FCC30FE  mov         qword ptr [rsp+6D0h],8  
000000013FCC310A  mov         byte ptr [rsp+6C0h],0  

请注意,优化器会记住 char 仍在寄存器 rdx 中,并且字符串长度必须相同,8

在看到这样的事情之后,我才喜欢相信我的编译器,并避免尝试用一些小技巧来改进代码。它没有帮助,除非分析发现意外的瓶颈。

(以 MSVC 10 和我的 std::string 实现为特色)

【讨论】:

  • 我已经看到你多次提到你的 stdlib 实现了——它在某个地方可供阅读吗?
  • 不,这只是我在业余时间玩的部分实现。之所以在此提及,是因为代码与编译器自带的不符。
  • 提供更多信息 - 我不会为需要重新分配版权的开源项目贡献代码,因为我已经正式将我所有的版权重新分配给我现在的雇主(一家银行,如果是律师,还有很多) .为了安全起见,我也避免发布大量代码,以免因某些法律技术问题而陷入麻烦。
【解决方案2】:

我要争辩说,这几乎总是一个的想法,除非在特定情况下进行了分析并且swap 的更明显和更清晰的实现存在性能问题。即使在那种情况下,我也只会对直接的无继承结构使用这种方法,而不是对任何类型的类。您永远不知道何时添加继承可能会破坏整个事物(也可能以真正阴险的方式)。

如果您想要快速交换实现,也许更好的选择(在适当的情况下)是 pimpl 类,然后只换出实现(再次,这假设没有指向所有者的反向指针,但这很容易包含到类和 impl 而不是外部因素)。

编辑:这种方法可能存在的问题:

  • 指向自身的指针(直接或间接)
  • 如果类包含任何直接字节复制没有意义的对象(有效地递归此定义)或复制通常被禁用
  • 如果类需要任何类型的锁定来复制
  • 很容易在这里意外传入两种不同的类型(只需要一个中间函数来隐式地使派生类看起来像父类)然后交换 vptrs(哎哟!)李>

【讨论】:

  • 这 4 个项目符号可以减少到 2 个:(1) 自指针(我已经提到过似乎很少见,直到 William 指出它很常见的一类),以及 (2)锁(你能扩展一下吗?我没有假设线程,因为在 C++11 之前,C++ 甚至没有线程模型。)。 #3 基本上就是 #2,而 #4 只是完全没有我在代码中的注释。
  • @Mehrdad:#3 与 #2 不同。 #2 是关于可能具有某些固有魔法使它们不可复制的类型,而 #3 是关于设计为从多个线程使用的类,其中更改对象而不锁定至少可以说是相当有问题的。
  • @Grizzly:嗯?互斥锁怎么不是某种“锁”? (另外我不是在谈论复制,我是在谈论交换。)
  • @Mehrdad:这不是重点。一个项目符号是关于不易复制的对象(并且 memcopy 是一个非常副本,甚至用于交换目的),而另一个项目符号是关于在复制过程中需要进行的额外操作。在类中包含互斥锁并不一定意味着它需要被锁定以进行复制,也不需要锁定意味着类包含互斥锁(例如,锁可能在多个对象之间共享)
  • 我不确定我是否理解您的意思。您基本上是在说“如果对象很神奇并且不允许您memcpy 它,那么您就不能memcpy 它”,这对我来说似乎有点重言式。能不能举个具体的例子?
【解决方案3】:

为什么要设计“自指针”?

class RingBuffer
{
    // ...
private:
    char buffer[1024];
    char* curr;
};

这种类型保存一个缓冲区和缓冲区的当前位置。

或者您可能听说过 iostream:

class streambuf
{
  char buffer[64];
  char* put_ptr;
  char* get_ptr;
  // ...
};

正如其他人提到的,小字符串优化:

// untested, probably buggy!
class String {
  union {
    char buf[8];
    char* ptr;
  } data;
  unsigned len;
  unsigned capacity;
  char* str;
public:
  String(const char* s, unsigned n)
  {
    if (n > sizeof(data.buf)-1) {
      str = new char[n+1];
      len = capacity = n;
    }
    else
    {
      str = data.buf;
      len = n;
      capacity = sizeof(data.buf) - 1;
    } 
    memcpy(str, s, n);
    str[n] = '\0';
  }
  ~String()
  {
    if (str != data.buf)
      delete[] str;
  }
  const char* c_str() const { return str; }
  // ...
};

这也有一个自指针。如果你构造两个小字符串然后交换它们,析构函数都会判断字符串是“非本地的”并尝试删除内存:

{
  String s1("foo", 3);
  String s2("bar", 3);
  bad_swap(s1, s2);
}  // BOOM! destructors delete stack memory

Valgrind 说:

==30214== Memcheck, a memory error detector
==30214== Copyright (C) 2002-2010, and GNU GPL'd, by Julian Seward et al.
==30214== Using Valgrind-3.6.1 and LibVEX; rerun with -h for copyright info
==30214== Command: ./a.out
==30214== 
==30214== Invalid free() / delete / delete[]
==30214==    at 0x4A05E9C: operator delete[](void*) (vg_replace_malloc.c:409)
==30214==    by 0x40083F: String::~String() (in /dev/shm/a.out)
==30214==    by 0x400737: main (in /dev/shm/a.out)
==30214==  Address 0x7fefffd00 is on thread 1's stack
==30214== 
==30214== Invalid free() / delete / delete[]
==30214==    at 0x4A05E9C: operator delete[](void*) (vg_replace_malloc.c:409)
==30214==    by 0x40083F: String::~String() (in /dev/shm/a.out)
==30214==    by 0x400743: main (in /dev/shm/a.out)
==30214==  Address 0x7fefffce0 is on thread 1's stack

这表明它会影响像 std::streambufstd::string 这样的类型,几乎没有人为或深奥的例子。

基本上,bad_swap永远 一个好主意,如果类型是可简单复制的,那么默认的 std::swap 将是最佳的(你的编译器不会将其优化为 memcpy 然后得到更好的编译器),如果它们不是可轻易复制的,那么这是认识未定义行为先生和他的朋友严重错误先生的好方法。

【讨论】:

  • +1 我永远不会那样做,但我可以看到人们会这样做(它更快)。
【解决方案4】:

除了其他答案中提到的示例(特别是包含指向自身部分的指针和需要锁定的对象的对象)之外,还可能存在指向对象的指针由外部数据结构管理的情况,需要相应地更新(请注意,该示例有些人为,以免过度(并且可能由于未经过测试而出现错误):

class foo
{
private:
   static std::map<foo*, int> foo_data;
public:
   foo() { foo_data.emplace(this, 0); }
   foo(const foo& f) { foo_data.emplace(this, foo_data[&f]); }
   foo& operator=(const foo& f) { foo_data[this] = foo_data[&f]; return *this}
   ~foo() { foo_data.erase(this); }
   ...
};

如果对象被memcpy 交换,显然这样的事情会很糟糕。当然,现实世界的例子通常会稍微复杂一些,但重点应该很清楚。

除了示例之外,我认为复制(或交换)像这样的非平凡可复制对象是标准未定义的行为(稍后可能会检查)。在这种情况下,根本无法保证该代码可以处理更复杂的对象。

【讨论】:

  • 无论交换是否使用memcpy,都存在更新对象外部指针的问题。
  • @MarkRansom:我指的是问题中给出的实现。当然,任何不使用正确的复制/移动构造函数/赋值运算符的场景都存在问题
【解决方案5】:

一些尚未提及的:

  • 交换可能会产生副作用,例如您可能必须更新外部元素的指针以指向新位置,或者通知侦听对象该对象的内容已更改。
  • 交换两个使用相对地址的元素会导致问题

【讨论】:

  • +1 用于相对寻址(尽管值得一提的是,它基本上只是伪装的外部指针)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2011-11-23
  • 2013-10-02
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多