Почему поведение std:: memcpy будет неопределенным для объектов, которые не являются TriviallyCopyable?


от http://en.cppreference.com/w/cpp/string/byte/memcpy:

если объекты не TriviallyCopyable (например, скаляры, массивы, c-совместимые структуры), поведение не определено.

на моей работе, мы использовали std::memcpy в течение длительного времени для побитовой замены объектов, которые не являются TriviallyCopyable с помощью:

void swapMemory(Entity* ePtr1, Entity* ePtr2)
{
   static const int size = sizeof(Entity); 
   char swapBuffer[size];

   memcpy(swapBuffer, ePtr1, size);
   memcpy(ePtr1, ePtr2, size);
   memcpy(ePtr2, swapBuffer, size);
}

и никогда не было никаких проблем.

Я понимаю, что это тривиально злоупотреблять std::memcpy С нетривиально копируемыми объектами и вызывают неопределенное поведение вниз по течению. Однако мой вопрос:

почему поведение std::memcpy сам быть неопределенным при использовании с нетривиально копируемыми объектами? Почему стандарт считает необходимым это уточнять?

обновление

содержание http://en.cppreference.com/w/cpp/string/byte/memcpy были изменены в ответ на этот пост и ответы на пост. Текущее описание говорит:

если объекты не TriviallyCopyable (например, скаляры, массивы, c-совместимая структуры), поведение не определено, если программа не зависит от воздействия деструктора объекта (который не memcpy) и время жизни целевого объекта (которое заканчивается, но не начинается с memcpy) запускается каким-то другим способом, например размещение-новое.

PS

комментарий @Cubbi:

@RSahu если что-то гарантирует UB вниз по течению, это делает всю программу неопределенной. Но я согласен, что в этом случае можно обойти UB и соответственно изменить cppreference.

9 63

9 ответов:

почему поведение std::memcpy сам быть неопределенным при использовании с нетривиально копируемыми объектами?

это не так! Однако, как только вы копируете базовые байты одного объекта нетривиально копируемого типа в другой объект этого типа,целевой объект не жив. Мы уничтожили его, повторно используя его хранилище, и не оживили его вызовом конструктора.

использование целевого объекта-вызов его функций-членов, доступ к его членам данных-явно не определен[basic.жизнь]/6, а также последующий неявный вызов деструктора[basic.жизнь]/4 для целевых объектов, имеющих автоматическая длительность хранения. Обратите внимание, как неопределенное поведение-это ретроспектива. [вступление.выполнение]/5:

однако, если любое такое выполнение содержит неопределенную операцию, это Международный стандарт не устанавливает никаких требований к внедрению выполнение этой программы с этим входом (даже в отношении операции, предшествующие первой неопределенной операции).

если реализация определяет, как объект мертв и обязательно подлежит дальнейшим операциям, которые не определены,... он может реагировать, изменяя семантику ваших программ. Из memcpy вызовы далее. И это соображение становится очень практичным, как только мы думаем об оптимизаторах и определенных предположениях, которые они делают.

следует отметить, что однако стандартные библиотеки могут и позволяют оптимизировать некоторые стандартные библиотечные алгоритмы для тривиально копируемых типов. std::copy по указателям на тривиально копируемые типы обычно вызывает memcpy на базовых байт. Так же как и swap.
Поэтому просто придерживайтесь использования обычных универсальных алгоритмов и позвольте компилятору делать любые соответствующие низкоуровневые оптимизации-это отчасти то, для чего была изобретена идея тривиально копируемого типа: определение законность некоторых оптимизаций. Кроме того, это позволяет избежать повреждения вашего мозга, беспокоясь о противоречивых и недостаточно специфичных частях языка.

потому что стандарт говорит так.

компиляторы могут предположить, что нетривиально копируемые типы копируются только через их конструкторы копирования/перемещения/операторы присваивания. Это может быть в целях оптимизации (если какие-то данные, он может отложить установку до копирования / перемещения).

компилятор даже может свободно взять ваш memcpy позвоните и получите его ничего, или отформатировать жесткий диск. Зачем? Потому что так сказано в стандарте. И ничего не делать это определенно быстрее, чем перемещение битов вокруг, так почему бы не оптимизировать ваш memcpy к одинаково действительной более быстрой программе?

теперь, на практике, есть много проблем, которые могут возникнуть, когда вы просто Блит вокруг битов в типах, которые этого не ожидают. Таблицы виртуальных функций могут быть настроены неправильно. Приборы, используемые для обнаружения утечек, могут быть установлены неправильно. Объекты, идентификация которых включает их местоположение, полностью запутываются вашим кодом.

действительно забавно это using std::swap; swap(*ePtr1, *ePtr2); должен быть составлен до memcpy для тривиально копируемых типов компилятором, а для других типов определяется поведение. Если компилятор может доказать, что copy-это просто скопированные биты, он может изменить его на memcpy. И если вы можете написать более оптимальный swap, вы можете сделать это в пространстве имен объекта в вопрос.

достаточно легко построить класс, где это memcpy - based swap разрывы:

struct X {
    int x;
    int* px; // invariant: always points to x
    X() : x(), px(&x) {}
    X(X const& b) : x(b.x), px(&x) {}
    X& operator=(X const& b) { x = b.x; return *this; }
};

memcpying такой объект нарушает этот инвариант.

GNU C++11 std::string делает именно это с короткими строками.

это похоже на то, как реализуются стандартные файловые и строковые потоки. Потоки в конечном итоге являются производными от std::basic_ios, который содержит указатель на std::basic_streambuf. Потоки также содержат определенный буфер в качестве члена (или базового класса подобъект), на который этот указатель в std::basic_ios указывает.

C++ не гарантирует для всех типов, что их объекты занимают смежные байты хранения [intro.объектом]/5

объект тривиально копируемого или стандартного типа (3.9) должен быть занимают смежные байты памяти.

и действительно, с помощью виртуальных базовых классов можно создавать несмежные объекты в основных реализациях. Я попытался построить пример, где базовый класс подобъект объекта это до xстартовый адрес. Чтобы визуализировать это, рассмотрим следующий график / таблицу, где горизонтальная ось-адресное пространство, а вертикальная ось-уровень наследования (уровень 1 наследует от уровня 0). Поля, отмеченные dm занимают прямые данные-члены класса.

L | 00 08 16
--+---------
1 |    dm
0 | dm

это обычный макет памяти при использовании наследования. Однако расположение виртуального базового класса подобъекта не является фиксированным, так как оно может быть перемещается дочерними классами, которые также наследуются от того же базового класса практически. Это может привести к тому, что объект level 1 (base class sub)сообщает, что он начинается с адреса 8 и имеет размер 16 байт. Если мы наивно добавим эти два числа, мы подумаем, что он занимает адресное пространство [8, 24), хотя на самом деле он занимает [0, 16).

если мы можем создать такой объект уровня 1, то мы не можем использовать memcpy скопировать это: memcpy доступ к памяти, которая не принадлежит к этому объект (адреса с 16 по 24). В моей демо-версии пойман как переполнение буфера стека с помощью дезинфицирующего средства адреса clang++.

как построить такой объект? Используя множественное виртуальное наследование, я придумал объект, который имеет следующий макет памяти (указатели виртуальной таблицы помечены как vp). Он состоит из четырех слоев наследования:

L  00 08 16 24 32 40 48
3        dm         
2  vp dm
1              vp dm
0           dm

проблема, описанная выше, возникнет для объекта базового класса уровня 1. Его начальный адрес-32, и это 24 байта большой (vptr, его собственные члены данных и члены данных уровня 0).

вот код для такого макета памяти под clang++ и g++ @ coliru:

struct l0 {
    std::int64_t dummy;
};

struct l1 : virtual l0 {
    std::int64_t dummy;
};

struct l2 : virtual l0, virtual l1 {
    std::int64_t dummy;
};

struct l3 : l2, virtual l1 {
    std::int64_t dummy;
};

мы можем произвести stack-buffer-overflow следующим образом:

l3  o;
l1& so = o;

l1 t;
std::memcpy(&t, &so, sizeof(t));

вот полная демонстрация, которая также печатает некоторую информацию о макете памяти:

#include <cstdint>
#include <cstring>
#include <iomanip>
#include <iostream>

#define PRINT_LOCATION() \
    std::cout << std::setw(22) << __PRETTY_FUNCTION__                   \
      << " at offset " << std::setw(2)                                  \
        << (reinterpret_cast<char const*>(this) - addr)                 \
      << " ; data is at offset " << std::setw(2)                        \
        << (reinterpret_cast<char const*>(&dummy) - addr)               \
      << " ; naively to offset "                                        \
        << (reinterpret_cast<char const*>(this) - addr + sizeof(*this)) \
      << "\n"

struct l0 {
    std::int64_t dummy;

    void report(char const* addr) { PRINT_LOCATION(); }
};

struct l1 : virtual l0 {
    std::int64_t dummy;

    void report(char const* addr) { PRINT_LOCATION(); l0::report(addr); }
};

struct l2 : virtual l0, virtual l1 {
    std::int64_t dummy;

    void report(char const* addr) { PRINT_LOCATION(); l1::report(addr); }
};

struct l3 : l2, virtual l1 {
    std::int64_t dummy;

    void report(char const* addr) { PRINT_LOCATION(); l2::report(addr); }
};

void print_range(void const* b, std::size_t sz)
{
    std::cout << "[" << (void const*)b << ", "
              << (void*)(reinterpret_cast<char const*>(b) + sz) << ")";
}

void my_memcpy(void* dst, void const* src, std::size_t sz)
{
    std::cout << "copying from ";
    print_range(src, sz);
    std::cout << " to ";
    print_range(dst, sz);
    std::cout << "\n";
}

int main()
{
    l3 o{};
    o.report(reinterpret_cast<char const*>(&o));

    std::cout << "the complete object occupies ";
    print_range(&o, sizeof(o));
    std::cout << "\n";

    l1& so = o;
    l1 t;
    my_memcpy(&t, &so, sizeof(t));
}

Live demo

выход образца (сокращенный для избежания вертикали прокрутка):

l3::report at offset  0 ; data is at offset 16 ; naively to offset 48
l2::report at offset  0 ; data is at offset  8 ; naively to offset 40
l1::report at offset 32 ; data is at offset 40 ; naively to offset 56
l0::report at offset 24 ; data is at offset 24 ; naively to offset 32
the complete object occupies [0x9f0, 0xa20)
copying from [0xa10, 0xa28) to [0xa20, 0xa38)

обратите внимание на два подчеркнутых конечных смещения.

многие из этих ответов упоминают, что memcpy может нарушить инварианты в классе, что приведет к неопределенному поведению позже (и что в большинстве случаев должно быть достаточной причиной, чтобы не рисковать), но это, похоже, не то, что вы действительно спрашиваете.

одна из причин, почему memcpy сам вызов считается неопределенным поведением, чтобы дать как можно больше места для компилятора, чтобы сделать оптимизацию на основе целевой платформы. Имея сам вызов быть UB, то компилятор - это разрешено делать странные, зависящие от платформы вещи.

рассмотрим этот (очень надуманный и гипотетический) пример: для конкретной аппаратной платформы может быть несколько различных видов памяти, причем некоторые из них быстрее, чем другие для разных операций. Там может, например, быть своего рода специальная память, которая позволяет дополнительные быстрые копии памяти. Поэтому компилятору для этой (воображаемой) платформы разрешено размещать все TriviallyCopyable типы в этом специальном память, и реализовать memcpy использовать специальные аппаратные инструкции, которые работают только с этой памятью.

если вы используете memcpy наTriviallyCopyable объекты на этой платформе могут быть некоторые низкоуровневые недопустимые сбои кода операции на вызов.

не самый убедительный из аргументов, возможно, но дело в том, что стандарт не запрещает это, что возможно только путем внесения memcpyвызов УБ.

memcpy скопирует все байты, или в вашем случае поменять местами все байты, просто отлично. Чрезмерно усердный компилятор может принять "неопределенное поведение" в качестве оправдания для всех видов озорства, но большинство компиляторов этого не сделают. Тем не менее, это возможно.

однако после копирования этих байтов объект, в который вы их скопировали, может больше не быть допустимым объектом. Простой случай-это реализация строки, где большие строки выделяют память, но маленькие строки просто используют часть строковый объект для хранения символов, и сохранить указатель на это. Указатель, очевидно, будет указывать на другой объект, поэтому все будет неправильно. Другим примером, который я видел, был класс с данными, который использовался только в очень немногих случаях, так что данные хранились в базе данных с адресом объекта в качестве ключа.

теперь, если ваши экземпляры содержат мьютекс, например, я думаю, что перемещение этого может быть серьезной проблемой.

другая причина memcpy является ли UB (помимо того, что было упомянуто в других ответах - это может нарушить инварианты позже), что стандарт очень трудно сказать точно что будет.

для нетривиальных типов стандарт очень мало говорит о том, как объект выкладывается в памяти, в каком порядке размещаются члены, где находится указатель vtable, каким должно быть заполнение и т. д. Компилятор имеет огромное количество свободы в принятии решений этот.

в результате, даже если стандарт хотел разрешить memcpy в этих "безопасных" ситуациях было бы невозможно указать, какие ситуации безопасны, а какие нет, или когда именно реальный UB будет вызван для небезопасных случаев.

Я полагаю, что вы можете утверждать, что эффекты должны быть определены реализацией или не определены, но я лично считаю, что это будет слишком глубоко копаться в специфике платформы и давать немного слишком много легитимности к чему-то, что в общем случае весьма небезопасно.

во-первых, обратите внимание, что бесспорно, что вся память для изменяемых объектов C/C++ должна быть не типизированной, не Специализированной, пригодной для любого изменяемого объекта. (Я предполагаю, что память для глобальных переменных const может гипотетически быть типизирована, просто нет смысла с таким гипер-усложнением для такого крошечного углового случая.)в отличие от Java, в C++ не имеет типизированного выделения динамического объекта:new Class(args) в Java-это типизированный объект creation: создание объекта четко определенного типа, который может жить в типизированной памяти. С другой стороны, выражение C++new Class(args) - это просто тонкая оболочка ввода вокруг выделения памяти без типа, эквивалентная с new (operator new(sizeof(Class)) Class(args): объект создается в "нейтральной памяти". Изменение этого означало бы изменение очень большой части C++.

запрещение операции копирования битов (выполняется ли memcpy или эквивалентная пользовательская копия байт за байтом) на некотором типе дает большую свободу реализации для полиморфных классов (с виртуальными функции), и другие так называемые "виртуальные классы" (не стандартный термин), то есть классы, которые используют virtual ключевое слово.

реализация полиморфных классов может использовать глобальную ассоциативную карту адресов, которая связывает адрес полиморфного объекта и его виртуальные функции. Я считаю, что это был вариант, серьезно рассмотренный при разработке первых итераций языка C++ (или даже "C с классами"). Эта карта полиморфных объектов может использовать специальный процессор функции и специальная ассоциативная память (такие функции не доступны пользователю C++).

конечно, мы знаем, что все практические реализации виртуальных функций используют vtables (постоянная запись, описывающая все динамические аспекты класса) и помещают vptr (указатель vtable) в каждый полиморфный субобъект базового класса, поскольку этот подход чрезвычайно прост в реализации (по крайней мере, для простейших случаев) и очень эффективен. Нет глобального реестра полиморфных объектов ни в одном реальном реализации, за исключением, возможно, в режиме отладки (я не знаю такого режима отладки).

стандарт C++ сделал отсутствие глобального реестра несколько официальных сказать, что вы можете пропустить вызов деструктора при повторном использовании памяти объект, пока вы не зависите от "побочных эффектов" этого вызова деструктора. (Я считаю, что это означает, что" побочные эффекты " создаются пользователем, то есть тело деструктора, а не реализация, созданная автоматически сделано к деструктору реализацией.)

потому что на практике во всех реализациях компилятор просто использует скрытые члены vptr (указатель на vtables), и эти скрытые члены будут правильно скопированыmemcpy; как если бы вы сделали простую копию структуры C, представляющей полиморфный класс (со всеми его скрытыми членами). Битовые копии или полные копии элементов структуры C (полная структура C включает скрытые элементы) будут вести себя точно так же, как вызов конструктора (как это делается с помощью placement new), поэтому все, что вам нужно сделать, пусть компилятор думает, что вы могли бы вызвать placement new. Если вы выполняете строго внешний вызов функции (вызов функции, которая не может быть встроена и реализация которой не может быть проверена компилятором, например вызов функции, определенной в динамически загруженном блоке кода, или системный вызов), то компилятор просто предположит, что такие конструкторы могли быть вызваны кодом, который он не может проверить. в поведение memcpy здесь определяется не стандартом языка, а компилятором ABI (Application Binary Interface). поведение сильно внешнего вызова функции определяется ABI, а не только стандартом языка. Вызов потенциально инлинируемой функции определяется языком, поскольку его определение можно увидеть (либо во время компилятора, либо во время глобальной оптимизации времени связи).

так что на практике, учитывая соответствующие "компилятор заборы" (например вызов внешней функции, или просто asm("")), вы можете memcpy классы, которые используют только виртуальные функции.

конечно, вы должны быть разрешены семантикой языка, чтобы сделать такое размещение новым, когда вы делаете memcpy: вы не можете волей-неволей переопределить динамический тип существующего объекта и сделать вид, что вы не просто разрушили старый объект. Если у вас есть неконстантный глобальный, статический, автоматический, членский подобъект, массивный подобъект, вы можете перезаписать его и поместить другой, несвязанный объект там; но если динамический тип отличается, вы не можете притворяться, что это все тот же объект или подобъект:

struct A { virtual void f(); };
struct B : A { };

void test() {
  A a;
  if (sizeof(A) != sizeof(B)) return;
  new (&a) B; // OK (assuming alignement is OK)
  a.f(); // undefined
}

изменения полиморфного типа существующего объекта просто не допускается: новый объект не имеет никакого отношения к a за исключением области памяти: непрерывные байты, начинающиеся с &a. У них есть разные типы.

[стандарт сильно разделен на Ли *&a смогите быть использовано (в типичной плоской памяти машин) или (A&)(char&)a (в любом случае) для ссылки на новый объект. Составители компиляторов не разделяются: вы не должны этого делать. Это глубокий дефект в C++, возможно, самый глубокий и самый тревожный.]

но вы не можете в переносимом коде выполнять побитовое копирование классов, использующих виртуальное наследование, так как некоторые реализации реализуют эти классы с указателями на виртуальные базовые подобъекты: эти указатели, которые были правильно инициализированы конструктором самого производного объекта, будут иметь их значение копируется memcpy (как простая копия структуры C, представляющая класс со всеми его скрытыми членами) и не будет указывать на подобъект производного объекта!

другие ABI используют смещения адресов для поиска этих базовых подобъектов; они зависят только от типа наиболее производного объекта, например final overriders и typeid, и таким образом может храниться в таблице vtable. На эти реализации, memcpy будет работать как гарантировано ABI (с вышеуказанным ограничением на изменение типа существующего объекта).

в любом случае, это полностью проблема представления объекта, то есть проблема ABI.

что я могу понять здесь, так это то, что-для некоторых практических приложений-стандарт C++мая быть ограничительным, или, скорее, не достаточно разрешительным.

как показано в другие ответы memcpy быстро ломается для "сложных" типов, но, ИМХО, это на самом деле должны работа для стандартных типов компоновки до тех пор, как memcpy не нарушает то, что делают определенные операции копирования и деструктор стандартного типа макета. (Обратите внимание, что четный класс TC разрешено имеет нетривиальный конструктор.) Стандарт только явно вызывает типы TC wrt. это, однако.

недавний проект цитаты (N3797):

3.9 типы

...

2 для любого объекта (кроме подобъекта базового класса) тривиально копируемый тип T, независимо от того, содержит ли объект допустимое значение типа T, базовые байты (1.7), составляющие объект, могут быть скопированы в один массив char или unsigned char может. Если содержимое массива char или unsigned char копируется обратно в объект, объект должен впоследствии сохраните его первоначальное значение. [ Пример:

  #define N sizeof(T)
  char buf[N];        T obj; // obj initialized to its original value
  std::memcpy(buf, &obj, N); // between these two calls to std::memcpy,       
                             // obj might be modified         
  std::memcpy(&obj, buf, N); // at this point, each subobject of obj of scalar type
                             // holds its original value 

-конец примера ]

3 для любого тривиально копируемого типа T, если два указателя на T указывают на различные объекты Т obj1 и obj2, где ни obj1 obj2 не является субобъект базового класса, если базовые байты (1.7), составляющие obj1, являются скопировано в obj2, obj2 впоследствии будет иметь то же значение, что и obj1. [ Пример:

T* t1p;
T* t2p;       
     // provided that t2p points to an initialized object ...         
std::memcpy(t1p, t2p, sizeof(T));  
     // at this point, every subobject of trivially copyable type in *t1p contains        
     // the same value as the corresponding subobject in *t2p

-конец примера ]

стандарт здесь говорит о тривиально копируемым типы, А как был замечен по @dyp выше, есть также стандартные типы верстки которые, насколько я вижу, не обязательно перекрываются с тривиально копируемыми типами.

стандартный говорит:

1.8 объектная модель C++

(...)

5 (...) Объект тривиально копируемого или стандартного типа компоновки (3.9) должен занимать смежные байты памяти.

Итак, я вижу вот что:

  • стандарт ничего не говорит о нетривиально копируемых типах wrt. memcpy. (как уже упоминалось несколько раз здесь)
  • стандарт имеет отдельную концепцию для стандартных типов макетов, которые занимают непрерывное хранилище.
  • стандартный не явно разрешить или запретить использование memcpy на объектах стандартной компоновки, которые являются не Тривиально Копируемым.

так это, кажется, не быть явно вызвал UB, но это, конечно, также не то, что называется неуказанному поведению, так что можно сделать вывод, что @underscore_d сделал в комментарий к принятому ответу:

(...) Вы не можете просто сказать: "Ну, это не был явно вызван как UB, поэтому он определен поведение!", и это то, что эта нить, кажется, составляет. N3797 3.9 точки 2~3 не определяют, что memcpy делает для нетривиально копируемого объекты, так что (...) [t]шляпа в значительной степени функционально эквивалент UB в моих глазах, поскольку оба бесполезны для написания надежного, т. е. переносимого кода

I лично пришел бы к выводу, что это равносильно UB, насколько переносится (О, эти оптимизаторы), но я думаю, что с некоторым хеджированием и знанием конкретной реализации можно уйти от этого. (Просто убедитесь, что это стоит.)


боковое Примечание: я также думаю, что стандарт действительно должен явно включать семантику типа стандартной компоновки в целое memcpy беспорядок, потому что это действительный и полезный usecase, чтобы сделать побитовую копию нетривиально Копируемые объекты, но это к делу не относится.

ссылки: могу ли я использовать memcpy для записи в несколько смежных стандартных объектов макета?