Примери за това, когато побитово swap() е лоша идея?

Не трябва да третирате обектните указатели като указатели към необработени двоични данни в ООП езици, включително C++. Обектите са "повече от" тяхното представяне.

Така, например, swaping на два обекта чрез размяна на техните байтове е неправилно:

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 би се счупило, ако извършите побитова размяна?
Лесно мога да измисля измислени примери със самопоказатели, но не мога да се сетя за никакви реални- световните.


person user541686    schedule 24.07.2012    source източник
comment
Това е напълно законно за тривиално копируеми типове. C++ не е строго ООП език и тривиално копируемите типове са не повече от тяхното представяне.   -  person ildjarn    schedule 24.07.2012
comment
Кога е добра идея? Сигурен съм, че всеки път, когато двоично копие или суап е наред, компилаторът ще го генерира.   -  person Bo Persson    schedule 25.07.2012
comment
@BoPersson: Радвам се, че си сигурен, защото аз не съм...   -  person user541686    schedule 25.07.2012
comment
често използван обект, който използва указател към себе си, е std::string, за оптимизация няколко реализации съдържат кратък низов буфер, който е част от основната структура. ако дължината надхвърли размера на този буфер, се използва динамично разпределен буфер. има един указател, който или сочи към вътрешния кратък низ, или към динамично разпределения буфер.   -  person Willem Hengeveld    schedule 25.07.2012
comment
Не знам какви компилатори използвате, но тези, които съм виждал, са достатъчно умни, за да вградят memcpy и да генерират същия код за присвояване на структура (или за цикъл, копиращ масив).   -  person Bo Persson    schedule 25.07.2012
comment
@BoPersson: Честно казано, никога не съм проверявал. Но не съм съвсем сигурен, че и компилаторът винаги може да го разбере.   -  person user541686    schedule 25.07.2012
comment
друга причина, поради която не трябва просто да разменяте (или копирате) обекти, е, че може да се окажете с обект, който не следва правилата за подравняване на процесора.   -  person Willem Hengeveld    schedule 25.07.2012
comment
друга забележка, не размяната, а копирането може да има нежелани ефекти.   -  person Willem Hengeveld    schedule 25.07.2012
comment
@WillemHengeveld: Не разбирам коментара за правилото за подравняване (защо би причинил проблеми с подравняването?), но за коментара за низ определено си прав... не се колебай да го публикуваш като отговор!   -  person user541686    schedule 25.07.2012
comment
@BoPersson: Да, това ме подтикна да поискам примери. :) Аз също пуснах коментар, изглежда, че sbi отговори преди няколко минути... Просто го уведомих за този въпрос.   -  person user541686    schedule 25.07.2012
comment
По подразбиране някои от итераторите за отстраняване на грешки на MSVC съдържат указатели към обекта, който итерират.   -  person Mooing Duck    schedule 25.07.2012
comment
Имайте предвид, че проблемът с размяната на самопоказател засяга не само обекта със самопоказателя, но и всеки друг обект, който съдържа такъв обект.   -  person Mark Ransom    schedule 25.07.2012


Отговори (5)


Ще твърдя, че това почти винаги е лоша идея, освен в конкретния случай, когато е направено профилиране и по-очевидно и ясно изпълнение на swap има проблеми с производителността. Дори в този случай бих използвал този вид подход само за директни структури без наследяване, никога за какъвто и да е клас. Никога не знаете кога ще бъде добавено наследяване, което потенциално ще разруши всичко (вероятно и по наистина коварни начини).

Ако искате бърза реализация на суап, може би по-добрият избор (където е подходящо) е да размените класа и след това просто да размените имплементацията (отново, това предполага, че няма обратни указатели към собственика, но това лесно се съдържа в класа & impl, а не външни фактори).

РЕДАКТИРАНЕ: Възможни проблеми с този подход:

  • Указатели обратно към себе си (пряко или косвено)
  • Ако класът съдържа обект, за който директното копиране на байтове е безсмислено (ефективно преобразувайки тази дефиниция) или за което копирането обикновено е забранено
  • Ако класът се нуждае от някакво заключване за копиране
  • Лесно е случайно да подадете два различни типа тук (необходима е само една междинна функция, за да накарате производния клас да изглежда като родителя) и след това да размените vptrs (ОУ!)
person Mark B    schedule 24.07.2012
comment
Тези 4 куршума могат да бъдат намалени до 2: (1) Самонасочване (което вече споменах изглежда рядко, докато Уилям не посочи един клас, в който е често срещано) и (2) Брави (можете ли да разширите това? Не предполагах нишки, тъй като до C++11 C++ дори няма модел на нишки.). #3 е просто #2, а #4 просто напълно липсва коментара ми в кода. - person user541686; 25.07.2012
comment
@Mehrdad: #3 не е същото като #2. #2 е за типове, които може да имат някаква присъща магия, което ги прави невъзможни за копиране, докато #3 е за класове, предназначени да бъдат използвани от множество нишки, където промяната на обекта без заключване може да бъде меко казано доста проблематична. - person Grizzly; 25.07.2012
comment
@Grizzly: А? Как мютексът не е някакъв вид заключване? (Също така не говоря за копиране, говоря за размяна.) - person user541686; 25.07.2012
comment
@Mehrdad: не е това въпросът. Единият куршум е за обекти, които не могат лесно да се копират (а memcopy е много копие, дори се използва за целите на размяната), докато другият е за допълнителни операции, необходими по време на копирането. Наличието на мютекс, съдържащ се в класа, не означава непременно, че трябва да бъде заключен за копиране, нито необходимостта от заключване предполага, че класът съдържа мютекс (напр. ключалката може да бъде споделена между няколко обекта) - person Grizzly; 25.07.2012
comment
Не съм сигурен, че разбирам какво се опитваш да кажеш. Вие всъщност казвате, че ако обектът е магически и не ви позволява да го memcpy тогава не можете да го memcpy, което ми изглежда малко тавтология. Бихте ли всъщност дали конкретен пример? - person user541686; 25.07.2012
comment
@Mehrdad Толкова е лесно да нарушите предположението си, че и двамата са най-производните типове, че се почувствах длъжен да го спомена така или иначе. - person Mark B; 25.07.2012

Това не е конкретно за 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)

person Bo Persson    schedule 24.07.2012
comment
Виждал съм да споменавате внедряването на stdlib вече няколко пъти -- налично ли е някъде за четене? - person ildjarn; 25.07.2012
comment
Не, това е само частична реализация, с която играя в свободното си време. Спомена го тук, защото кодът не съвпада с това, което идва с компилатора. - person Bo Persson; 25.07.2012
comment
Предлагам малко повече информация - не допринасям с код за проекти с отворен код, които изискват превъзлагане на авторски права, защото вече официално прехвърлих всичките си авторски права на настоящия ми работодател (банка, с много адвокати). Само за по-сигурно избягвам да публикувам по-големи обеми код, за да не си навлечем проблеми поради правни технически подробности. - person Bo Persson; 25.07.2012

Защо са измислени "самопоказатели"?

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

Този тип съдържа буфер и текуща позиция в буфера.

Или може би сте чували за iostreams:

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::streambuf и std::string, едва ли измислени или езотерични примери.

По принцип bad_swap никога не е добра идея, ако типовете могат да се копират тривиално, тогава std::swap по подразбиране ще бъде оптимален (на вашия компилатор не го оптимизира към memcpy, вземете по-добър компилатор) и ако те не подлежат на тривиално копиране, това е чудесен начин да се срещнете с г-н Недефинирано поведение и неговия приятел г-н Сериозен бъг.

person Jonathan Wakely    schedule 24.07.2012
comment
+1 Никога не бих го направил по този начин, но виждам, че хората биха го направили (по-бързо е). - person user541686; 25.07.2012

Освен примерите, споменати в други отговори (особено обекти, съдържащи указатели към части от себе си и обекти, нуждаещи се от заключване), може да има и случай на указатели към обекта, управляван от външна структура от данни, която трябва да бъде актуализирана съответно (моля, имайте предвид, че примерът е донякъде измислен, за да не бъде прекомерен (и може би бъги поради това, че не е тестван)):

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. Разбира се, примерите от реалния свят за това обикновено са малко по-сложни, но смисълът трябва да е ясен.

Освен примерите мисля, че копирането (или размяната) на нетривиално копируеми обекти като това е недефинирано поведение от стандарта (може да проверите това по-късно). В този случай няма да има никаква гаранция този код да работи с по-сложни обекти.

person Grizzly    schedule 24.07.2012
comment
Проблемът с актуализирането на указатели извън обекта съществува независимо дали суапът използва memcpy или не. - person Mark Ransom; 25.07.2012
comment
@MarkRansom: Имах предвид изпълнението, дадено във въпроса. Разбира се, проблемът съществува за всеки сценарий, който не използва правилни конструктори за копиране/преместване/оператори за присвояване - person Grizzly; 25.07.2012

Някои вече неспоменати:

  • Размяната може да има странични ефекти, например може да се наложи да актуализирате указателите на външни елементи, за да сочат към новото местоположение, или да информирате слушащите обекти, че съдържанието на обекта е променено.
  • Размяната на два елемента, които използват относителни адреси, би причинила проблеми
person josefx    schedule 24.07.2012
comment
+1 за относително адресиране (въпреки че си струва да се спомене, че това е просто външен указател под прикритие) - person user541686; 25.07.2012