Wie finde ich falsche C ++ - Kopiervorgänge?

11

Vor kurzem hatte ich Folgendes

struct data {
  std::vector<int> V;
};

data get_vector(int n)
{
  std::vector<int> V(n,0);
  return {V};
}

Das Problem mit diesem Code ist, dass beim Erstellen der Struktur eine Kopie auftritt und die Lösung stattdessen darin besteht, return {std :: move (V)} zu schreiben.

Gibt es einen Linter oder Code-Analysator, der solche falschen Kopiervorgänge erkennen würde? Weder Cppcheck, Cpplint noch Clang-Tidy können das.

EDIT: Mehrere Punkte, um meine Frage klarer zu machen:

  1. Ich weiß, dass ein Kopiervorgang aufgetreten ist, weil ich den Compiler-Explorer verwendet habe und ein Aufruf von memcpy angezeigt wird .
  2. Ich konnte feststellen, dass ein Kopiervorgang stattgefunden hat, indem ich mir das Standard-Ja ansah. Meine anfängliche falsche Idee war jedoch, dass der Compiler diese Kopie optimieren würde. Ich lag falsch.
  3. Es ist (wahrscheinlich) kein Compilerproblem, da sowohl clang als auch gcc Code erzeugen, der einen memcpy erzeugt .
  4. Das Memcpy mag billig sein, aber ich kann mir keine Umstände vorstellen, in denen das Kopieren von Speicher und das Löschen des Originals billiger ist als das Übergeben eines Zeigers durch eine std :: move .
  5. Das Hinzufügen von std :: move ist eine elementare Operation. Ich würde mir vorstellen, dass ein Code-Analysator diese Korrektur vorschlagen kann.
Mathieu Dutour Sikiric
quelle
2
Ich kann nicht beantworten, ob es eine Methode / ein Werkzeug zum Erkennen von "falschen" Kopiervorgängen gibt oder nicht. Meiner ehrlichen Meinung nach bin ich jedoch nicht der Meinung, dass das Kopieren von std::vectorauf keinen Fall das ist , was es zu sein vorgibt . Ihr Beispiel zeigt eine explizite Kopie, und es ist nur natürlich und der richtige Ansatz (wieder imho), die std::moveFunktion so anzuwenden , wie Sie sich selbst vorschlagen, wenn eine Kopie nicht Ihren Wünschen entspricht . Beachten Sie, dass einige Compiler das Kopieren möglicherweise unterlassen, wenn Optimierungsflags aktiviert sind und der Vektor unverändert bleibt.
Magnus
Ich befürchte, dass es zu viele unnötige Kopien gibt (die möglicherweise keine Auswirkungen haben), um diese Linter-Regel nutzbar zu machen: - / ( Rost verwendet standardmäßig Bewegung, erfordert also eine explizite Kopie :))
Jarod42
Meine Vorschläge zur Optimierung des Codes bestehen im Wesentlichen darin, die zu optimierende Funktion zu zerlegen, und Sie werden die zusätzlichen Kopiervorgänge entdecken
camp0
Wenn ich Ihr Problem richtig verstehe, möchten Sie Fälle erkennen, in denen eine Kopieroperation (Konstruktor oder Zuweisungsoperator) für ein Objekt aufgerufen wird, gefolgt von dessen Zerstörung. Für benutzerdefinierte Klassen kann ich mir vorstellen, ein Debug-Flag hinzuzufügen, das gesetzt wird, wenn eine Kopie ausgeführt wird, in allen anderen Vorgängen zurückgesetzt zu werden und den Destruktor einzuchecken. Sie wissen jedoch nicht, wie Sie dasselbe für nicht benutzerdefinierte Klassen tun sollen, es sei denn, Sie können deren Quellcode ändern.
Daniel Langr
2
Die Technik, mit der ich falsche Kopien finde, besteht darin, den Kopierkonstruktor vorübergehend privat zu machen und dann zu untersuchen, wo sich der Compiler aufgrund von Zugriffsbeschränkungen verbietet. (Das gleiche Ziel kann erreicht werden, indem der Kopierkonstruktor für Compiler, die ein solches Taggen unterstützen, als veraltet markiert wird.)
Eljay

Antworten:

2

Ich glaube, Sie haben die richtige Beobachtung, aber die falsche Interpretation!

Die Kopie erfolgt nicht durch Rückgabe des Werts, da in diesem Fall jeder normale clevere Compiler (N) RVO verwendet . Ab C ++ 17 ist dies obligatorisch, sodass Sie keine Kopie sehen können, indem Sie einen lokal generierten Vektor von der Funktion zurückgeben.

OK, lass std::vectoruns ein bisschen damit spielen und was während der Konstruktion passieren wird oder indem du es Schritt für Schritt füllst.

Lassen Sie uns zunächst einen Datentyp generieren, der jede Kopie oder Bewegung wie folgt sichtbar macht:

template <typename DATA >
struct VisibleCopy
{
    private:
        DATA data;

    public:
        VisibleCopy( const DATA& data_ ): data{ data_ }
        {
            std::cout << "Construct " << data << std::endl;
        }

        VisibleCopy( const VisibleCopy& other ): data{ other.data }
        {
            std::cout << "Copy " << data << std::endl;
        }

        VisibleCopy( VisibleCopy&& other ) noexcept : data{ std::move(other.data) }
        {
            std::cout << "Move " << data << std::endl;
        }

        VisibleCopy& operator=( const VisibleCopy& other )
        {
            data = other.data;
            std::cout << "copy assign " << data << std::endl;
        }

        VisibleCopy& operator=( VisibleCopy&& other ) noexcept
        {
            data = std::move( other.data );
            std::cout << "move assign " << data << std::endl;
        }

        DATA Get() const { return data; }

};

Und jetzt beginnen wir einige Experimente:

using T = std::vector< VisibleCopy<int> >;

T Get1() 
{   
    std::cout << "Start init" << std::endl;
    std::vector< VisibleCopy<int> > vec{ 1,2,3,4 };
    std::cout << "End init" << std::endl;
    return vec;
}   

T Get2()
{   
    std::cout << "Start init" << std::endl;
    std::vector< VisibleCopy<int> > vec(4,0);
    std::cout << "End init" << std::endl;
    return vec;
}

T Get3()
{
    std::cout << "Start init" << std::endl;
    std::vector< VisibleCopy<int> > vec;
    vec.emplace_back(1);
    vec.emplace_back(2);
    vec.emplace_back(3);
    vec.emplace_back(4);
    std::cout << "End init" << std::endl;

    return vec;
}

T Get4()
{
    std::cout << "Start init" << std::endl;
    std::vector< VisibleCopy<int> > vec;
    vec.reserve(4);
    vec.emplace_back(1);
    vec.emplace_back(2);
    vec.emplace_back(3);
    vec.emplace_back(4);
    std::cout << "End init" << std::endl;

    return vec;
}

int main()
{
    auto vec1 = Get1();
    auto vec2 = Get2();
    auto vec3 = Get3();
    auto vec4 = Get4();

    // All data as expected? Lets check:
    for ( auto& el: vec1 ) { std::cout << el.Get() << std::endl; }
    for ( auto& el: vec2 ) { std::cout << el.Get() << std::endl; }
    for ( auto& el: vec3 ) { std::cout << el.Get() << std::endl; }
    for ( auto& el: vec4 ) { std::cout << el.Get() << std::endl; }
}

Was können wir beobachten:

Beispiel 1) Wir erstellen einen Vektor aus einer Initialisierungsliste und erwarten möglicherweise, dass wir viermal Konstrukte und vier Züge sehen. Aber wir bekommen 4 Exemplare! Das klingt etwas mysteriös, aber der Grund ist die Implementierung der Initialisierungsliste! Es ist einfach nicht erlaubt, aus der Liste zu verschieben, da der Iterator aus der Liste ein const T*Element ist , das es unmöglich macht, Elemente aus der Liste zu verschieben. Eine ausführliche Antwort zu diesem Thema finden Sie hier: initializer_list und move semantics

Beispiel 2) In diesem Fall erhalten wir eine erste Konstruktion und 4 Kopien des Werts. Das ist nichts Besonderes und das können wir erwarten.

Beispiel 3) Auch hier haben wir die Konstruktion und einige Bewegungen wie erwartet. Bei meiner stl-Implementierung wächst der Vektor jedes Mal um den Faktor 2. Wir sehen also ein erstes Konstrukt, ein anderes, und da die Größe des Vektors von 1 auf 2 geändert wird, sehen wir die Bewegung des ersten Elements. Beim Hinzufügen der 3 sehen wir eine Größenänderung von 2 auf 4, bei der die ersten beiden Elemente verschoben werden müssen. Alles wie erwartet!

Beispiel 4) Jetzt reservieren wir Platz und füllen später. Jetzt haben wir keine Kopie und keine Bewegung mehr!

In allen Fällen sehen wir keine Bewegung oder Kopie, wenn der Vektor überhaupt an den Anrufer zurückgegeben wird! (N) RVO findet statt und in diesem Schritt sind keine weiteren Maßnahmen erforderlich!

Zurück zu Ihrer Frage:

"So finden Sie falsche C ++ - Kopiervorgänge"

Wie oben gezeigt, können Sie zum Debuggen zwischendurch eine Proxy-Klasse einführen.

In vielen Fällen funktioniert es möglicherweise nicht, den Kopierer privat zu machen, da Sie möglicherweise einige gewünschte und einige versteckte Kopien haben. Wie oben funktioniert nur der Code zum Beispiel 4 mit einem privaten Kopierer! Und ich kann die Frage nicht beantworten, ob das Beispiel 4 das schnellste ist, da wir Frieden durch Frieden füllen.

Leider kann ich hier keine allgemeine Lösung für das Auffinden "unerwünschter" Kopien anbieten. Selbst wenn Sie Ihren Code für Aufrufe von graben memcpy, werden Sie nicht alle finden, da diese auch memcpyoptimiert werden, und Sie sehen direkt einige Assembler-Anweisungen, die die Arbeit ausführen , ohne Ihre Bibliotheksfunktion aufzurufen memcpy.

Mein Hinweis ist, sich nicht auf ein so kleines Problem zu konzentrieren. Wenn Sie echte Leistungsprobleme haben, nehmen Sie einen Profiler und messen Sie. Es gibt so viele potenzielle Leistungskiller, dass es memcpykeine so lohnende Idee ist , viel Zeit in falsche Nutzung zu investieren .

Klaus
quelle
Meine Frage ist irgendwie akademisch. Ja, es gibt viele Möglichkeiten, langsamen Code zu haben, und dies ist für mich kein unmittelbares Problem. Wir können die memcpy- Operationen jedoch mithilfe des Compiler-Explorers finden. Es gibt also definitiv einen Weg. Dies ist jedoch nur für kleine Programme möglich. Mein Punkt ist, dass es ein Interesse an Code gibt, das Vorschläge zur Verbesserung von Code finden würde. Es gibt Code-Analysatoren, die Fehler und Speicherlecks finden. Warum nicht solche Probleme?
Mathieu Dutour Sikiric
"Code, der Vorschläge zur Verbesserung des Codes finden würde." Dies ist bereits in den Compilern selbst geschehen und implementiert. (N) Die RVO-Optimierung ist nur ein einziges Beispiel und funktioniert wie oben gezeigt perfekt. Das Abfangen von memcpy hat nicht geholfen, da Sie nach "unerwünschtem memcpy" suchen. "Es gibt Code-Analysatoren, die Fehler und Speicherlecks finden. Warum nicht solche Probleme?" Vielleicht ist es kein (allgemeines) Problem. Ein viel allgemeineres Tool zum Auffinden von "Geschwindigkeitsproblemen" ist bereits vorhanden: Profiler! Mein persönliches Gefühl ist, dass Sie nach einer akademischen Sache suchen, die heutzutage in echter Software kein Problem darstellt.
Klaus
1

Ich weiß, dass ein Kopiervorgang aufgetreten ist, weil ich den Compiler-Explorer verwendet habe und ein Aufruf von memcpy angezeigt wird.

Haben Sie Ihre vollständige Anwendung in den Compiler-Explorer gestellt und Optimierungen aktiviert? Wenn nicht, kann es sein, dass das, was Sie im Compiler-Explorer gesehen haben, mit Ihrer Anwendung geschieht oder nicht.

Ein Problem mit dem von Ihnen veröffentlichten Code besteht darin, dass Sie zuerst einen Code erstellen std::vectorund ihn dann in eine Instanz von kopieren data. Es wäre besser, mit dem Vektor zu initialisieren data :

data get_vector(int n)
{
  return {std::vector<int> V(n,0)};
}

Wenn Sie dem Compiler-Explorer nur die Definition von dataund get_vector()und sonst nichts geben, muss er mit einer Verschlechterung rechnen. Wenn Sie ihm tatsächlich einen Quellcode geben, der verwendet wird get_vector() , schauen Sie sich an, welche Assembly für diesen Quellcode generiert wird. In diesem Beispiel erfahren Sie, was die obige Änderung plus tatsächliche Nutzung plus Compileroptimierungen dazu führen kann, dass der Compiler erzeugt.

G. Sliepen
quelle
Ich habe im Computer Explorer nur den obigen Code eingegeben (der den memcpy hat ), sonst würde die Frage keinen Sinn ergeben. Abgesehen davon ist Ihre Antwort hervorragend darin, verschiedene Wege aufzuzeigen, um besseren Code zu erzeugen. Sie bieten zwei Möglichkeiten: Verwendung von statisch und Platzieren des Konstruktors direkt in der Ausgabe. Diese Möglichkeiten könnten also von einem Code-Analysator vorgeschlagen werden.
Mathieu Dutour Sikiric