Warum sollte das Verhalten von std :: memcpy für Objekte, die nicht TriviallyCopyable sind, undefiniert sein?


73

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

Wenn die Objekte nicht TriviallyCopyable sind (z. B. Skalare, Arrays, C-kompatible Strukturen), ist das Verhalten undefiniert.

Bei meiner Arbeit haben wir std::memcpylange Zeit Objekte, die nicht TriviallyCopyable sind, bitweise ausgetauscht, indem wir :

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);
}

und hatte nie irgendwelche Probleme.

Ich verstehe, dass es trivial ist, std::memcpymit nicht TriviallyCopyable-Objekten zu missbrauchen und stromabwärts undefiniertes Verhalten zu verursachen. Meine Frage:

Warum sollte das Verhalten von sich std::memcpyselbst undefiniert sein, wenn es mit nicht TriviallyCopyable-Objekten verwendet wird? Warum hält es der Standard für notwendig, dies anzugeben?

AKTUALISIEREN

Der Inhalt von http://en.cppreference.com/w/cpp/string/byte/memcpy wurde als Antwort auf diesen Beitrag und die Antworten auf den Beitrag geändert. Die aktuelle Beschreibung lautet:

Wenn die Objekte nicht TriviallyCopyable sind (z. B. Skalare, Arrays, C-kompatible Strukturen), ist das Verhalten undefiniert, es sei denn, das Programm hängt nicht von den Auswirkungen des Destruktors des Zielobjekts (das nicht ausgeführt wird memcpy) und der Lebensdauer des Das Zielobjekt (das beendet, aber nicht gestartet wird memcpy) wird auf andere Weise gestartet, z. B. durch Platzierung neu.

PS

Kommentar von @Cubbi:

@RSahu Wenn etwas UB Downstream garantiert, wird das gesamte Programm undefiniert. Ich stimme jedoch zu, dass es in diesem Fall möglich zu sein scheint, UB zu umgehen und die Referenz entsprechend zu ändern.


1
@Columbo, ich wünschte, ich könnte diesen Anspruch für meine Arbeit geltend machen. Wir verwenden immer noch VS2008 :)
R Sahu

3
Es gibt ein interessantes aktuelles Papier .
TC

2
§3.9 / 3 [basic.types] "Für jeden trivial kopierbaren Typ T , wenn zwei Zeiger auf Tunterschiedliche TObjekte verweisen obj1und obj2wenn weder ein Unterobjekt der Basisklasse obj1noch obj2ein Basisobjekt ist, wenn die zugrunde liegenden Bytes, aus denen obj1sich zusammensetzt, kopiert werden obj2, obj2muss anschließend das gleicher Wert wie obj1". (Hervorhebung von mir) Das nachfolgende Beispiel verwendet std::memcpy.
Mooing Duck

1
@dyp "Ich habe gerade erfahren, dass Objekte in C keine Typen haben" - der Standard verwendet ziemlich oft den Begriff "Objekt vom Typ T". Es scheint mir, dass das Objektmodell in beiden Sprachen nicht richtig definiert ist.
MM

1
@dyp Ich sehe nicht, wie diese Aussage eine Definition sein kann, wenn sie keine Äquivalenz angibt. Was genau ist ein Objekt?
MM

Antworten:


43

Warum sollte das Verhalten von sich std::memcpyselbst undefiniert sein, wenn es mit nicht TriviallyCopyable-Objekten verwendet wird?

Es ist nicht! Sobald Sie jedoch die zugrunde liegenden Bytes eines Objekts eines nicht trivial kopierbaren Typs in ein anderes Objekt dieses Typs kopieren, ist das Zielobjekt nicht mehr aktiv . Wir haben es durch Wiederverwendung seines Speichers zerstört und es nicht durch einen Konstruktoraufruf wiederbelebt.

Die Verwendung des Zielobjekts - Aufrufen seiner Elementfunktionen , Zugreifen auf seine Datenelemente - ist eindeutig undefiniert [basic.life] / 6 , ebenso wie ein nachfolgender impliziter Destruktoraufruf [basic.life] / 4 für Zielobjekte mit automatischer Speicherdauer. Beachten Sie, wie undefiniert das Verhalten rückwirkend ist . [intro.execution] / 5:

Wenn eine solche Ausführung jedoch eine undefinierte Operation enthält, stellt diese Internationale Norm keine Anforderung an die Implementierung, die dieses Programm mit dieser Eingabe ausführt ( nicht einmal in Bezug auf Operationen, die der ersten undefinierten Operation vorausgehen ).

Wenn eine Implementierung erkennt, wie ein Objekt tot ist und notwendigerweise weiteren Operationen unterzogen wird, die nicht definiert sind, ... kann sie darauf reagieren, indem sie die Semantik Ihres Programms ändert. Ab dem memcpyAnruf. Und diese Überlegung wird sehr praktisch, wenn wir an Optimierer und bestimmte Annahmen denken, die sie treffen.

Es ist jedoch zu beachten, dass Standardbibliotheken bestimmte Standardbibliotheksalgorithmen für trivial kopierbare Typen optimieren können und dürfen. std::copyBei Zeigern auf trivial kopierbare Typen werden normalerweise memcpydie zugrunde liegenden Bytes aufgerufen. Das tut es auchswap .
Halten Sie sich also einfach an die Verwendung normaler generischer Algorithmen und lassen Sie den Compiler alle geeigneten Optimierungen auf niedriger Ebene durchführen. Dies ist teilweise der Grund, warum die Idee eines trivial kopierbaren Typs erfunden wurde: Feststellung der Rechtmäßigkeit bestimmter Optimierungen. Dies vermeidet auch, Ihr Gehirn zu verletzen, indem Sie sich um widersprüchliche und nicht spezifizierte Teile der Sprache sorgen müssen.


4
@dyp Nun, die Lebensdauer eines Objekts endet in jedem Fall, nachdem sein Speicher "wiederverwendet oder freigegeben" wurde ([basic.life] /1.4). Der Teil über den Destruktor ist ein bisschen optional, aber die Speichersache ist obligatorisch.
Columbo

1
Es scheint mir, dass ein Objekt vom trivial kopierbaren Typ eine nicht triviale Initialisierung haben kann. Wenn also memcpydie Lebensdauer des Zielobjekts mit einem solchen Typ endet, wurde es nicht wiederbelebt. Dies steht im Widerspruch zu Ihrer Argumentation, denke ich (obwohl es eine Inkonsistenz im Standard selbst sein könnte).
Dyp

1
(Ich denke, es ist möglich, dass dies nicht vollständig spezifiziert ist oder dass wichtige Informationen entweder im Standard fehlen oder sehr schwer abzuleiten sind. Was bedeutet beispielsweise "Wiederverwendung des Speichers"?)
dyp

1
@dyp Wiederverwenden des Speichers <=> Direktes Ändern eines oder mehrerer Bytes der Objektdarstellung durch einen Gl-Wert vom Typ char oder unsigned char? Ich weiß nicht. Nirgends angegeben, verdammt noch mal.
Columbo

1
Ok, nach einigen weiteren Überlegungen und dem Stöbern in der Standarddiskussionsliste: Die Lebensdauer eines Objekts wird beendet, wenn sein Speicher wiederverwendet wird (vereinbart, aber meiner Meinung nach ist dies in 3.8p1 klarer). Die Wiederverwendung ist wahrscheinlich nicht spezifiziert , aber ich denke, das Überschreiben über memcpysoll als Wiederverwendung gelten. Die Trivialität von init (oder Leere ) ist eine Eigenschaft des init, nicht des Typs. Es gibt keine Init über ctor des Zielobjekts, wenn memcpy, daher ist die Init immer leer
dyp

23

Es ist einfach genug, eine Klasse zu memcpyerstellen, in der das basiert 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; }
};

memcpyEin solches Objekt bricht diese Invariante.

GNU C ++ 11 std::stringmacht genau das mit kurzen Strings.

Dies ähnelt der Implementierung der Standarddatei- und Zeichenfolgenströme. Die Streams werden schließlich abgeleitet, von std::basic_iosdenen ein Zeiger auf enthält std::basic_streambuf. Die Streams enthalten auch den spezifischen Puffer als Element (oder Basisklassen-Unterobjekt), auf den dieser Zeiger in std::basic_ioszeigt.


3
OTOH, ich würde vermuten, dass es leicht zu spezifizieren ist, dass memcpyin solchen Fällen einfach die Invariante gebrochen wird , aber die Effekte sind streng definiert (rekursiv memcpys die Mitglieder, bis sie trivial kopierbar sind).
Dyp

@dyp: Das gefällt mir nicht, weil es zu einfach erscheint, die Kapselung zu unterbrechen, wenn dies als genau definiert angesehen wird.
Kevin

1
@dyp Das könnte dazu führen, dass Leistungsfreaks "unabsichtlich" nicht kopierbare Objekte kopieren.
Maxim Egorushkin

22

Weil der Standard es sagt.

Compiler können davon ausgehen, dass nicht TriviallyCopyable-Typen nur über ihre Kopier- / Verschiebungskonstruktoren / Zuweisungsoperatoren kopiert werden. Dies kann zu Optimierungszwecken erfolgen (wenn einige Daten privat sind, kann die Einstellung verschoben werden, bis ein Kopieren / Verschieben erfolgt).

Dem Compiler steht es sogar frei, Ihren memcpyAnruf anzunehmen und nichts zu tun oder Ihre Festplatte zu formatieren. Warum? Weil der Standard es sagt. Und nichts zu tun ist definitiv schneller als Teile zu bewegen. Warum also nicht Ihr memcpyProgramm auf ein ebenso gültiges schnelleres Programm optimieren ?

In der Praxis gibt es viele Probleme, die auftreten können, wenn Sie nur Bits in Typen herumblitzen, die dies nicht erwarten. Virtuelle Funktionstabellen sind möglicherweise nicht richtig eingerichtet. Instrumente zur Erkennung von Lecks sind möglicherweise nicht richtig eingerichtet. Objekte, deren Identität ihren Standort enthält, werden durch Ihren Code völlig durcheinander gebracht.

Der wirklich lustige Teil ist, dass using std::swap; swap(*ePtr1, *ePtr2);es möglich sein sollte, memcpyvom Compiler auf ein für trivial kopierbare Typen kompiliertes und für andere Typen definiertes Verhalten zu reduzieren. Wenn der Compiler nachweisen kann, dass es sich bei der Kopie nur um kopierte Bits handelt, kann er diese frei ändern memcpy. Und wenn Sie ein optimaleres schreiben können swap, können Sie dies im Namespace des betreffenden Objekts tun.


2
@TC Wenn Sie memcpyvon einem Objekt des Typs Tzu einem anderen wechseln , das kein Array von chars ist, würde der dtor des Zielobjekts dann nicht UB verursachen?
Dyp

3
@dyp Sicher, es sei denn, Sie platzieren newdort in der Zwischenzeit ein neues Objekt. Ich lese, dass memcpydas Eingreifen in etwas als "Wiederverwendung des Speichers" gilt, sodass die Lebensdauer dessen endet, was zuvor vorhanden war (und da es keinen dtor-Aufruf gibt, haben Sie UB, wenn Sie von der vom dtor verursachten Nebenwirkung abhängen). Die Lebensdauer eines neuen Objekts beginnt jedoch nicht, und Sie erhalten UB später beim impliziten dtor-Aufruf, es sei denn, Tin der Zwischenzeit wird dort ein Ist erstellt .
TC

3
@RSahu Der einfachste Fall ist, wenn der Compiler Identität in Objekte einfügt, was legal ist. Beispiel: Bijektives Verknüpfen von Iteratoren mit den Containern, aus denen sie stammen, stddamit Ihr Code die Verwendung ungültiger Iteratoren frühzeitig abfängt, anstatt Speicher oder ähnliches zu überschreiben (eine Art instrumentierter Iterator).
Yakk - Adam Nevraumont

2
@MooingDuck, das sind sehr gute Gründe, warum die Verwendung memcpyfür dieses Objekt Probleme stromabwärts verursacht. Ist das Grund genug zu sagen, dass das Verhalten von memcpyfür solche Objekte undefiniert ist?
R Sahu

2
@ Cubbi Ich habe es noch einmal umformuliert. Wenn Sie etwas mit dynamischer Speicherdauer überladen memcpyund es anschließend einfach verlieren , sollte das Verhalten genau definiert sein (wenn Sie nicht von den Auswirkungen des dtor abhängig sind), auch wenn Sie dort kein neues Objekt erstellen, da es vorhanden ist Kein impliziter dtor-Aufruf, der UB verursachen würde.
TC

15

C ++ garantiert nicht für alle Typen, dass ihre Objekte zusammenhängende Speicherbytes belegen [intro.object] / 5

Ein Objekt vom trivial kopierbaren oder Standardlayout-Typ (3.9) muss zusammenhängende Speicherbytes belegen.

In der Tat können Sie über virtuelle Basisklassen nicht zusammenhängende Objekte in Hauptimplementierungen erstellen. Ich habe versucht , um ein Beispiel zu bauen , in dem eine Basisklasse Subobjekt eines Objekts xbefindet , bevor x‚s - Startadresse . Betrachten Sie zur Veranschaulichung das folgende Diagramm / die folgende Tabelle, in der die horizontale Achse der Adressraum und die vertikale Achse die Vererbungsstufe ist (Stufe 1 erbt von Stufe 0). Mit gekennzeichnete Felder dmwerden von direkten Datenelementen der Klasse belegt.

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

Dies ist ein übliches Speicherlayout bei Verwendung der Vererbung. Der Speicherort eines Unterobjekts der virtuellen Basisklasse ist jedoch nicht festgelegt, da es von untergeordneten Klassen verschoben werden kann, die auch virtuell von derselben Basisklasse erben. Dies kann dazu führen, dass das Objekt der Ebene 1 (Basisklasse-Unterobjekt) meldet, dass es an Adresse 8 beginnt und 16 Byte groß ist. Wenn wir diese beiden Zahlen naiv addieren, würden wir denken, dass sie den Adressraum belegen [8, 24], obwohl sie tatsächlich [0, 16) belegen.

Wenn wir ein solches Objekt der Ebene 1 erstellen können, können wir es nicht memcpyzum Kopieren verwenden: memcpywürde auf Speicher zugreifen, der nicht zu diesem Objekt gehört (Adressen 16 bis 24). Wird in meiner Demo als Stapel-Puffer-Überlauf vom Adress-Desinfektionsprogramm von clang ++ abgefangen.

Wie konstruiere ich ein solches Objekt? Durch die Verwendung mehrerer virtueller Vererbungen habe ich ein Objekt mit dem folgenden Speicherlayout gefunden (virtuelle Tabellenzeiger sind als gekennzeichnet vp). Es besteht aus vier Vererbungsebenen:

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

Das oben beschriebene Problem tritt für das Unterobjekt der Basisklasse 1 auf. Die Startadresse ist 32 und 24 Byte groß (vptr, eigene Datenelemente und Datenelemente der Ebene 0).

Hier ist der Code für ein solches Speicherlayout unter clang ++ und 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;
};

Wir können einen Stapelpufferüberlauf wie folgt erzeugen:

l3  o;
l1& so = o;

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

Hier ist eine vollständige Demo, die auch einige Informationen zum Speicherlayout druckt:

#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

Beispielausgabe (abgekürzt, um vertikales Scrollen zu vermeiden):

l3 :: Bericht bei Offset 0; Daten sind am Offset 16; naiv zu versetzen 48
l2 :: Bericht bei Offset 0; Daten sind am Offset 8; naiv zu versetzen 40
l1 :: Bericht bei Offset 32; Daten sind am Offset 40; naiv zu versetzen 56
l0 :: Bericht bei Offset 24; Daten sind am Offset 24; naiv zu versetzen 32
das gesamte Objekt belegt [0x9f0, 0xa20]
Kopieren von [0xa10, 0xa28) nach [0xa20, 0xa38)

Beachten Sie die beiden hervorgehobenen Endversätze.


Das ist eine gute Antwort. Vielen Dank für die ausführliche Erklärung und den Demo-Code.
R Sahu

Nur ein Unterobjekt kann nicht kontinuierlich sein. Ein vollständiges Objekt ist kontinuierlich.
Neugieriger

@curiousguy Ist dies durch den Standard garantiert? Was ist mit dem Auffüllen von Bytes? Wäre ein Objekt, das aus drei Seiten besteht, von denen die mittlere nicht zugänglich ist, nicht konform?
Dyp

@dyp Nicht ständig von Bedeutung! Nicht alle Bytes sind wichtig. Bytes, die keine Rolle spielen ... spielen keine Rolle. Man kann also sagen, dass die Darstellung "Löcher" enthält, aber der von der Darstellung belegte Speicher befindet sich innerhalb von sizeof(T)Bytes, beginnend mit der Adresse des gesamten Objekts, was mein Punkt war. Sie können ein Objekt eines nicht abstrakten Klassentyps in einem ausreichend großen und ausgerichteten Speicher haben. Dies ist eine starke Anforderung auf der Ebene der Sprachsemantik und des Speicherzugriffs: Der gesamte zugewiesene Speicher ist gleichwertig. Speicher kann wiederverwendet werden.
Neugieriger

Nur const-Objekte, die global oder statisch sind und ständig const sind (keine veränderlichen Elemente und keine Änderung in c / dtor), werden in der Praxis möglicherweise speziell behandelt, da sie im Nur-Lese-Speicher abgelegt und in " spezielles "Gedächtnis wie in anderen Antworten vorgeschlagen. Andere Objekte sind jedoch im Speicher nicht konstant, und die durch C ++ gewährte Freiheit bedeutet, dass der Speicher nicht typisiert wird : Alle nicht konstanten Speicher, in denen benutzerdefinierte Objekte gespeichert werden, sind generisch.
Neugieriger

5

Viele dieser Antworten erwähnen, dass memcpyInvarianten in der Klasse gebrochen werden könnten, was später zu undefiniertem Verhalten führen würde (und was in den meisten Fällen Grund genug sein sollte, es nicht zu riskieren), aber das scheint nicht das zu sein, was Sie wirklich fragen.

Ein Grund, warum der memcpyAufruf selbst als undefiniertes Verhalten angesehen wird, besteht darin, dem Compiler so viel Raum wie möglich zu geben, um Optimierungen basierend auf der Zielplattform vorzunehmen. Durch das Gespräch mit sich selbst UB sein, wird der Compiler erlaubt seltsam, plattformabhängige Dinge zu tun.

Betrachten Sie dieses (sehr ausgeklügelte und hypothetische) Beispiel: Für eine bestimmte Hardwareplattform gibt es möglicherweise verschiedene Arten von Speicher, von denen einige für verschiedene Vorgänge schneller sind als andere. Es kann beispielsweise eine Art speziellen Speicher geben, der besonders schnelle Speicherkopien ermöglicht. Ein Compiler für diese (imaginäre) Plattform darf daher alle TriviallyCopyableTypen in diesem speziellen Speicher ablegen und implementieren memcpy, um spezielle Hardwareanweisungen zu verwenden, die nur auf diesem Speicher funktionieren.

Wenn Sie diese Option memcpyfür Nicht- TriviallyCopyableObjekte auf dieser Plattform verwenden, kann es im memcpyAufruf selbst zu einem Absturz von UNGÜLTIGEM OPCODE auf niedriger Ebene kommen .

Vielleicht nicht das überzeugendste Argument, aber der Punkt ist, dass der Standard es nicht verbietet , was nur durch den memcpy Aufruf von UB möglich ist .


2
Vielen Dank, dass Sie sich mit der Kernfrage befasst haben. Es ist interessant, dass die hoch bewerteten Antworten über die nachgelagerten Effekte sprechen, aber nicht über die Kernfrage.
R Sahu

" Es kann verschiedene Arten von Speicher geben " Haben Sie eine bestimmte CPU im Sinn?
Neugieriger

" Es kann verschiedene Arten von Speicher geben " In C / C ++? Es gibt nur eine Art von malloc, eine Art von new.
neugieriger Kerl

Ein Compiler kann beispielsweise festlegen, dass globale Objekte von const im Nur-Lese-Speicher abgelegt werden. Dies ist ein Beispiel für eine spezielle Speicheroptimierung, die nicht weit hergeholt ist. Dieses spezielle Beispiel ist hypothetischer und erfundener, aber es ist theoretisch möglich, dass der Compiler auf die gleiche Weise ein globales nicht trivial kopierbares in eine Art nicht memkopierbaren Speicher legt, wenn er möchte.
CAdaker

3

memcpy kopiert alle Bytes oder tauscht in Ihrem Fall alle Bytes aus. Ein übereifriger Compiler könnte das "undefinierte Verhalten" als Entschuldigung für alle Arten von Unfug nehmen, aber die meisten Compiler werden das nicht tun. Trotzdem ist es möglich.

Nachdem diese Bytes kopiert wurden, ist das Objekt, in das Sie sie kopiert haben, möglicherweise kein gültiges Objekt mehr. Ein einfacher Fall ist eine Zeichenfolgenimplementierung, bei der große Zeichenfolgen Speicher zuweisen, kleine Zeichenfolgen jedoch nur einen Teil des Zeichenfolgenobjekts verwenden, um Zeichen zu speichern und einen Zeiger darauf zu behalten. Der Zeiger zeigt offensichtlich auf das andere Objekt, sodass die Dinge falsch sind. Ein anderes Beispiel, das ich gesehen habe, war eine Klasse mit Daten, die nur in sehr wenigen Fällen verwendet wurden, sodass Daten in einer Datenbank mit der Adresse des Objekts als Schlüssel gespeichert wurden.

Wenn Ihre Instanzen beispielsweise einen Mutex enthalten, würde ich denken, dass das Verschieben ein großes Problem sein könnte.


Ja, aber das ist ein Benutzercodeproblem, kein Kernsprachenproblem.
Neugieriger

1

Ein weiterer Grund memcpyfür UB (abgesehen von dem, was in den anderen Antworten erwähnt wurde - es könnte später zu Invarianten führen) ist, dass es für den Standard sehr schwierig ist, genau zu sagen, was passieren würde .

Für nicht triviale Typen sagt der Standard sehr wenig darüber aus, wie das Objekt im Speicher angeordnet ist, in welcher Reihenfolge die Elemente platziert werden, wo sich der vtable-Zeiger befindet, wie das Auffüllen sein soll usw. Der Compiler verfügt über enorme Freiheiten bei der Entscheidung.

Selbst wenn der Standard dies memcpyin diesen "sicheren" Situationen zulassen wollte , wäre es daher unmöglich anzugeben, welche Situationen sicher sind und welche nicht oder wann genau die tatsächliche UB für unsichere Fälle ausgelöst würde.

Ich nehme an, Sie könnten argumentieren, dass die Auswirkungen implementierungsdefiniert oder nicht spezifiziert sein sollten, aber ich persönlich würde der Meinung sein, dass dies sowohl ein wenig zu tief in die Plattformspezifikationen eingreift als auch etwas, das im allgemeinen Fall ein wenig zu legitimiert ist ist eher unsicher.


1
Ich habe kein Problem damit zu sagen, dass die Verwendung von memcpy zum Schreiben in ein solches Objekt UB aufruft, da ein Objekt Felder haben kann, die sich ständig ändern, aber schlimme Dinge verursachen, wenn sie auf eine Weise geändert werden, von der der Compiler nichts weiß . In Anbetracht T * p, gibt es keinen Grund , warum memcpy(buffer, p, sizeof (T)), wo bufferein char[sizeof (T)];sollte erlaubt sein , etwas anderes als schreiben Sie einige Bytes in den Puffer zu tun?
Supercat

Das vptr ist nur ein weiteres verstecktes Mitglied (oder viele solcher Mitglieder für MI). Es spielt keine Rolle, wo sie sich befinden, wenn Sie ein vollständiges Objekt auf ein anderes Objekt desselben Typs kopieren.
Neugieriger

1

Beachten Sie zunächst, dass es unbestreitbar ist, dass der gesamte Speicher für veränderbare C / C ++ - Objekte nicht typisiert, nicht spezialisiert und für jedes veränderbare Objekt verwendbar sein muss. (Ich denke, der Speicher für globale const-Variablen könnte hypothetisch typisiert werden. Es macht einfach keinen Sinn, eine solche Hyperkomplikation für einen so kleinen Eckfall durchzuführen.) Im Gegensatz zu Java hat C ++ keine typisierte Zuordnung eines dynamischen Objekts : new Class(args)In Java handelt es sich um eine typisierte Objekterstellung : Erstellen eines Objekts eines genau definierten Typs, das möglicherweise im typisierten Speicher gespeichert ist. Auf der anderen Seite ist der C ++ - Ausdruck new Class(args)nur ein dünner Typisierungs-Wrapper um die typlose Speicherzuweisung, der entspricht new (operator new(sizeof(Class)) Class(args): Das Objekt wird im "neutralen Speicher" erstellt. Das zu ändern würde bedeuten, einen sehr großen Teil von C ++ zu ändern.

Das Verbot der Bitkopieroperation (unabhängig davon, ob sie von einem memcpyoder einem äquivalenten benutzerdefinierten byteweisen Kopiervorgang ausgeführt wird) für einen Typ bietet der Implementierung für polymorphe Klassen (solche mit virtuellen Funktionen) und andere sogenannte "virtuelle Klassen" (nicht a) viel Freiheit Standardbegriff), das sind die Klassen, die das virtualSchlüsselwort verwenden.

Die Implementierung polymorpher Klassen könnte eine globale assoziative Zuordnungskarte verwenden, die die Adresse eines polymorphen Objekts und seine virtuellen Funktionen verknüpft. Ich glaube, das war eine Option, die beim Entwurf der ersten Iterationen der C ++ - Sprache (oder sogar "C mit Klassen") ernsthaft in Betracht gezogen wurde. Diese Karte polymorpher Objekte verwendet möglicherweise spezielle CPU-Funktionen und speziellen assoziativen Speicher (solche Funktionen sind für den C ++ - Benutzer nicht verfügbar).

Natürlich wissen wir, dass alle praktischen Implementierungen virtueller Funktionen vtables (einen konstanten Datensatz, der alle dynamischen Aspekte einer Klasse beschreibt) verwenden und in jedes polymorphe Basisklassen-Unterobjekt einen vptr (vtable-Zeiger) einfügen, da dieser Ansatz äußerst einfach zu implementieren ist (at am wenigsten für die einfachsten Fälle) und sehr effizient. Es gibt keine globale Registrierung von polymorphen Objekten in einer realen Implementierung, außer möglicherweise im Debug-Modus (ich kenne einen solchen Debug-Modus nicht).

Der C ++ - Standard machte das Fehlen einer globalen Registrierung etwas offiziell, indem er sagte, dass Sie den Destruktoraufruf überspringen können, wenn Sie den Speicher eines Objekts wiederverwenden, solange Sie nicht von den "Nebenwirkungen" dieses Destruktoraufrufs abhängig sind. (Ich glaube, das bedeutet, dass die "Nebenwirkungen" vom Benutzer erstellt wurden, dh der Hauptteil des Destruktors, nicht die erstellte Implementierung, wie dies von der Implementierung automatisch für den Destruktor getan wird.)

In der Praxis verwendet der Compiler in allen Implementierungen nur versteckte vptr-Elemente (Zeiger auf vtables), und diese ausgeblendeten Elemente werden von ordnungsgemäß kopiertmemcpy;; als ob Sie eine einfache, kopierweise Kopie der C-Struktur erstellt hätten, die die polymorphe Klasse darstellt (mit all ihren versteckten Elementen). Bitweise Kopien oder vollständige Kopien von C-Strukturelementen (die vollständige C-Struktur enthält ausgeblendete Elemente) verhalten sich genau wie ein Konstruktoraufruf (wie durch Platzieren von new ausgeführt). Alles, was Sie tun müssen, lässt den Compiler denken, dass Sie dies könnten habe Platzierung neu genannt. Wenn Sie einen stark externen Funktionsaufruf ausführen (einen Aufruf einer Funktion, die nicht eingebunden werden kann und deren Implementierung vom Compiler nicht geprüft werden kann, wie einen Aufruf einer in einer dynamisch geladenen Codeeinheit definierten Funktion oder einen Systemaufruf), dann ist der Der Compiler geht lediglich davon aus, dass solche Konstruktoren von dem Code aufgerufen wurden, den er nicht untersuchen kann. So ist das Verhalten vonmemcpyHier wird nicht durch den Sprachstandard definiert, sondern durch den Compiler ABI (Application Binary Interface). Das Verhalten eines stark externen Funktionsaufrufs wird vom ABI definiert, nicht nur vom Sprachstandard. Ein Aufruf einer potenziell inlinierbaren Funktion wird von der Sprache definiert, da ihre Definition sichtbar ist (entweder während des Compilers oder während der globalen Optimierung der Verbindungszeit).

In der Praxis können Sie also bei geeigneten "Compiler-Zäunen" (z. B. beim Aufruf einer externen Funktion oder nur asm("")) memcpyKlassen verwenden, die nur virtuelle Funktionen verwenden.

Natürlich muss Ihnen die Sprachsemantik erlauben, eine solche Platzierung neu memcpydurchzuführen, wenn Sie Folgendes tun : Sie können den dynamischen Typ eines vorhandenen Objekts nicht ohne weiteres neu definieren und so tun, als hätten Sie das alte Objekt nicht einfach zerstört. Wenn Sie ein nicht konstantes globales, statisches, automatisches Element-Unterobjekt oder Array-Unterobjekt haben, können Sie es überschreiben und ein anderes, nicht verwandtes Objekt dort ablegen. Wenn der dynamische Typ jedoch unterschiedlich ist, können Sie nicht so tun, als wäre es immer noch dasselbe Objekt oder Unterobjekt:

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
}

Die Änderung des polymorphen Typs eines vorhandenen Objekts ist einfach nicht zulässig: Das neue Objekt hat keine Beziehung zu aaußer dem Speicherbereich: den fortlaufenden Bytes ab &a. Sie haben verschiedene Arten.

[Der Standard ist stark gespalten darüber, ob *&a(in typischen Flachspeichermaschinen) oder (A&)(char&)a(auf jeden Fall) verwendet werden kann, um auf das neue Objekt zu verweisen. Compiler-Autoren sind nicht geteilt: Sie sollten es nicht tun. Dies ist ein tiefer Fehler in C ++, vielleicht der tiefste und beunruhigendste.]

In portablem Code können Sie jedoch keine bitweise Kopie von Klassen ausführen, die virtuelle Vererbung verwenden, da einige Implementierungen diese Klassen mit Zeigern auf die virtuellen Basisunterobjekte implementieren: Bei diesen Zeigern, die vom Konstruktor des am meisten abgeleiteten Objekts ordnungsgemäß initialisiert wurden, wird der Wert von kopiert memcpy(wie eine einfache mitgliedsweise Kopie der C-Struktur, die die Klasse mit all ihren versteckten Elementen darstellt) und würde nicht auf das Unterobjekt des abgeleiteten Objekts zeigen!

Andere ABI verwenden Adressversätze, um diese Basisunterobjekte zu lokalisieren. Sie hängen nur vom Typ des am meisten abgeleiteten Objekts ab, wie z. B. endgültige Überschreibungen und typeid, und können daher in der vtable gespeichert werden. Funktioniert bei dieser Implementierung memcpywie vom ABI garantiert (mit der oben genannten Einschränkung beim Ändern des Typs eines vorhandenen Objekts).

In beiden Fällen handelt es sich ausschließlich um ein Problem der Objektdarstellung, dh um ein ABI-Problem.


1
Ich habe Ihre Antwort gelesen, konnte aber nicht herausfinden, was Sie sagen wollen.
R Sahu

tl; dr: Sie können memcpyin der Praxis polymorphe Klassen verwenden, sofern der ABI dies impliziert. Dies hängt also von der Implementierung ab. In jedem Fall müssen Sie Compiler-Barrieren verwenden, um zu verbergen, was Sie tun (plausible Verleugnung) UND Sie müssen die Sprachsemantik weiterhin respektieren (kein Versuch, den Typ eines vorhandenen Objekts zu ändern).
Neugieriger

Dies ist eine Teilmenge der Objekttypen, die nicht TriviallyCopyable sind. Ich möchte nur sicherstellen, dass Ihre Antwort das Verhalten memcpynur für die polymorphen Objekttypen berücksichtigen soll.
R Sahu

Ich diskutiere explizit virtuelle Klassen, eine Supermenge polymorpher Klassen. Ich denke, der historische Grund, memcpyeinige Typen zu verbieten , war die Implementierung virtueller Funktionen. Für nicht virtuelle Typen habe ich keine Ahnung!
Neugieriger

0

Was ich hier wahrnehmen kann, ist, dass der C ++ - Standard für einige praktische Anwendungen möglicherweise zu restriktiv oder eher nicht zulässig genug ist.

Wie in anderen Antworten gezeigt memcpyunten schnell Pausen für „kompliziert“ Typen, aber IMHO, ist es eigentlich sollte für Standard - Layout - Typen arbeiten , solange das memcpynicht das, was nicht bricht die definierten Kopie-Operationen und destructor des Standardlayout Art tun. (Beachten Sie, dass eine gerade TC-Klasse einen nicht trivialen Konstruktor haben darf .) Der Standard ruft nur explizit TC-Typen wrt auf. dies jedoch.

Ein aktueller Zitatentwurf (N3797):

3.9 Typen

...

2 Für jedes Objekt (außer einem Unterobjekt der Basisklasse) vom trivial kopierbaren Typ T können die zugrunde liegenden Bytes (1.7), aus denen das Objekt besteht, in ein Array von char kopiert werden, unabhängig davon, ob das Objekt einen gültigen Wert vom Typ T enthält oder nicht oder nicht signiertes Zeichen. Wenn der Inhalt des Arrays char oder unsigned char zurück in das Objekt kopiert wird, behält das Objekt anschließend seinen ursprünglichen Wert. [Beispiel:

  #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 

- Beispiel beenden]

3 Wenn für jeden trivial kopierbaren Typ T zwei Zeiger auf T auf unterschiedliche T-Objekte obj1 und obj2 zeigen, wobei weder obj1 noch obj2 ein Unterobjekt der Basisklasse sind, wenn die zugrunde liegenden Bytes (1.7), aus denen obj1 besteht, in obj2 kopiert werden, obj2 soll anschließend den gleichen Wert wie obj1 haben. [Beispiel:

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

- Beispiel beenden]

Der Standard spricht hier von trivial kopierbaren Typen, aber wie oben von @dyp beobachtet , gibt es auch Standardlayouttypen , die sich meines Erachtens nicht unbedingt mit trivial kopierbaren Typen überschneiden.

Der Standard sagt:

1.8 Das C ++ - Objektmodell

(...)

5 (...) Ein Objekt vom trivial kopierbaren oder Standardlayout-Typ (3.9) muss zusammenhängende Speicherbytes belegen.

Was ich hier sehe, ist Folgendes:

  • Der Standard sagt nichts über nicht trivial kopierbare Typen aus. memcpy. (wie hier schon mehrfach erwähnt)
  • Der Standard verfügt über ein separates Konzept für Standardlayouttypen, die zusammenhängenden Speicher belegen.
  • Der Standard erlaubt oder verbietet nicht explizit die Verwendung memcpyvon Objekten mit Standardlayout, die nicht trivial kopierbar sind.

Es scheint also nicht explizit UB genannt zu werden, aber es ist sicherlich auch nicht das, was als nicht spezifiziertes Verhalten bezeichnet wird , so dass man schließen könnte, was @underscore_d im Kommentar zur akzeptierten Antwort getan hat:

(...) Man kann nicht einfach sagen "Nun, es wurde nicht explizit als UB bezeichnet, daher ist es definiertes Verhalten!", Was dieser Thread zu sein scheint. N3797 3.9 Punkte 2 ~ 3 definieren nicht, was memcpy für nicht trivial kopierbare Objekte tut, daher ist (...) [t] in meinen Augen funktional ziemlich äquivalent zu UB, da beide für das Schreiben von zuverlässigem, dh tragbarem Code unbrauchbar sind

Ich persönlich würde zu dem Schluss kommen, dass es sich bei der Portabilität um UB handelt (oh, diese Optimierer), aber ich denke, dass man mit etwas Absicherung und Wissen über die konkrete Implementierung damit durchkommen kann. (Stellen Sie nur sicher, dass es die Mühe wert ist.)


Randnotiz: Ich denke auch, dass der Standard die Semantik vom Typ Standardlayout wirklich explizit in das gesamte memcpyDurcheinander einbeziehen sollte , da dies ein gültiger und nützlicher Anwendungsfall ist, um nicht trivial kopierbare Objekte bitweise zu kopieren, aber das ist hier nicht der Punkt.

Link: Kann ich memcpy verwenden, um in mehrere benachbarte Standardlayout-Unterobjekte zu schreiben?


Es ist logisch, dass der TC-Status erforderlich ist, damit ein Typ in der memcpyLage ist, solche Objekte über Standardkonstruktoren zum Kopieren / Verschieben und Zuweisen von Operationen zu verfügen, die als einfache byteweise Kopien definiert sind memcpy. Wenn ich sage, dass mein Typ in der memcpyLage ist, aber eine nicht standardmäßige Kopie hat, widerspreche ich mir selbst und meinem Vertrag mit dem Compiler, der besagt, dass für TC-Typen nur die Bytes von Bedeutung sind. Auch wenn meine benutzerdefinierte Kopie Ctor / assign gerade tut eine byteweise Kopie & fügt eine Diagnosemeldung, ++sa staticZähler oder etwas - dass ich den Compiler meinen Code erwarten impliziert zu analysieren und beweist , dass es nicht mit Zohan an Bytedarstellung.
underscore_d

SL-Typen sind zusammenhängend, können jedoch vom Benutzer bereitgestellte Kopier- / Verschiebungs- / Zuweisungsoperationen haben. Wenn alle Benutzeroperationen byteweise gleichwertig sind, muss memcpyder Compiler für jeden Typ unrealistische / unfaire Mengen statischer Analysen durchführen. Ich habe nicht aufgezeichnet, dass dies die Motivation ist, aber es scheint überzeugend. Aber wenn wir cppreference glauben - Standard layout types are useful for communicating with code written in other programming languages- sind sie viel Gebrauch ohne die Sprachen der Lage, Kopien in definierter Weise zu nehmen? Ich denke, wir können dann nur dann einen Zeiger ausgeben, wenn wir ihn sicher auf C ++ - Seite zugewiesen haben.
underscore_d

@underscore_d - Ich stimme nicht zu, dass es logisch ist, dies zu verlangen . TC ist nur erforderlich, um sicherzustellen, dass ein Memcpy einer logischen Objektkopie semantisch entspricht. Das OP-Beispiel zeigt, dass das bitweise Austauschen von zwei Objekten ein Beispiel ist, bei dem meiner Meinung nach keine logische Kopie ausgeführt wird.
Martin Ba

Der Compiler muss nichts überprüfen. Wenn der memcpy den Objektstatus durcheinander bringt, sollten Sie memcpy nicht verwendet haben! Was der Standard meiner Meinung nach explizit zulassen sollte, wäre genau ein bitweiser Austausch als OP mit SL-Typen, auch wenn es sich nicht um TC handelt. Natürlich würde es Fälle geben, in denen es zusammenbricht (selbstreferenzierende Objekte usw.), aber das ist kaum ein Grund, dies in der Schwebe zu lassen.
Martin Ba

Nun, sicher, vielleicht könnten sie sagen: "Sie können dies kopieren, wenn Sie möchten, und es wird definiert, dass es denselben Status hat, aber ob dies sicher ist - z. B. keine pathologische gemeinsame Nutzung von Ressourcen verursacht - liegt bei Ihnen." Ich bin mir nicht sicher, ob ich mich dafür einsetzen würde. Aber stimmen Sie zu, dass, was auch immer entschieden wird ... eine Entscheidung getroffen werden sollte. Die meisten Fälle wie dieser, in denen der Standard nicht spezifisch ist, lassen Leute, die die Fähigkeit unruhig haben wollen, ob sie sicher sind, ihn zu benutzen, und Leute wie ich, die solche Threads lesen, unruhig über die konzeptuelle Akrobatik, mit der manche Leute Wörter in den Mund stecken der Standard, wo es Lücken lässt ;-)
underscore_d

0

Ok, versuchen wir Ihren Code anhand eines kleinen Beispiels:

#include <iostream>
#include <string>
#include <string.h>

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

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

int main() {
  std::string foo = "foo", bar = "bar";
  std::cout << "foo = " << foo << ", bar = " << bar << std::endl;
  swapMemory(&foo, &bar);
  std::cout << "foo = " << foo << ", bar = " << bar << std::endl;
  return 0;
}

Auf meinem Computer wird vor dem Absturz Folgendes gedruckt:

foo = foo, bar = bar
foo = foo, bar = bar

Seltsam, was? Der Tausch scheint überhaupt nicht durchgeführt zu werden. Nun, der Speicher wurde ausgetauscht, verwendet aber std::stringdie Small-String-Optimierung auf meinem Computer: Er speichert kurze Strings in einem Puffer, der Teil des std::stringObjekts selbst ist, und zeigt nur mit seinem internen Datenzeiger auf diesen Puffer.

Wenn swapMemory()die Bytes ausgetauscht werden, werden sowohl die Zeiger als auch die Puffer ausgetauscht. Der Zeiger im fooObjekt zeigt nun auf den Speicher im barObjekt, der jetzt die Zeichenfolge enthält "foo". Zwei Swap-Ebenen machen keinen Swap.

Wenn std::stringder Destruktor anschließend versucht, aufzuräumen, passiert mehr Böses: Der Datenzeiger zeigt nicht mehr auf den std::stringinternen Puffer des eigenen, sodass der Destruktor daraus schließt, dass dieser Speicher auf dem Heap zugewiesen worden sein muss, und versucht deletees. Das Ergebnis auf meinem Computer ist ein einfacher Absturz des Programms, aber dem C ++ - Standard wäre es egal, ob rosa Elefanten auftauchen würden. Das Verhalten ist völlig undefiniert.


Und das ist der grundlegende Grund, warum Sie nicht für memcpy()nicht trivial kopierbare Objekte verwenden sollten: Sie wissen nicht, ob das Objekt Zeiger / Verweise auf seine eigenen Datenelemente enthält oder auf andere Weise von seinem eigenen Speicherort im Speicher abhängt. Wenn Sie ein memcpy()solches Objekt verwenden, wird die Grundannahme verletzt, dass sich das Objekt nicht im Speicher bewegen kann, und einige Klassen wie std::stringdiese stützen sich auf diese Annahme. Der C ++ - Standard zeichnet die Grenze zwischen (nicht) trivial kopierbaren Objekten, um zu vermeiden, dass mehr auf unnötige Details zu Zeigern und Referenzen eingegangen wird. Es macht nur eine Ausnahme für trivial kopierbare Objekte und sagt: Nun, in diesem Fall sind Sie sicher. Aber beschuldigen Sie mich nicht für die Konsequenzen, wenn Sie versuchen, memcpy()andere Objekte zu verwenden.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.