Was ist der moderne, korrekte Weg, um in C++ Wortspiele zu machen?

Lesezeit: 7 Minuten

Benutzer-Avatar
janekb04

Es scheint, als gäbe es zwei Arten von C++. Das praktische C++ und der Sprachjurist C++. In bestimmten Situationen kann es hilfreich sein, ein Bitmuster eines Typs so interpretieren zu können, als wäre es ein anderer Typ. Fließkommatricks sind ein bemerkenswertes Beispiel. Nehmen wir die berühmte schnelle inverse Quadratwurzel (entnommen aus Wikipediadie wiederum entnommen wurde hier):

float Q_rsqrt( float number )
{
    long i;
    float x2, y;
    const float threehalfs = 1.5F;

    x2 = number * 0.5F;
    y  = number;
    i  = * ( long * ) &y;                       // evil floating point bit level hacking
    i  = 0x5f3759df - ( i >> 1 );               // what the
    y  = * ( float * ) &i;
    y  = y * ( threehalfs - ( x2 * y * y ) );   // 1st iteration
//  y  = y * ( threehalfs - ( x2 * y * y ) );   // 2nd iteration, this can be removed

    return y;
}

Abgesehen von Details verwendet es bestimmte Eigenschaften der IEEE-754-Fließkommabitdarstellung. Der interessante Teil hier ist die *(long*) gegossen aus float* zu long*. Es gibt Unterschiede zwischen C und C++ darüber, welche Typen solcher Umwandlungen zur Neuinterpretation definiertes Verhalten sind, aber in der Praxis werden solche Techniken oft in beiden Sprachen verwendet.

Die Sache ist die, dass es bei einem so einfachen Problem viele Fallstricke gibt, die mit dem oben vorgestellten Ansatz und verschiedenen anderen auftreten können. Um einige zu nennen:

Gleichzeitig gibt es viele Möglichkeiten, Typ-Wortspiele durchzuführen, und viele Mechanismen, die damit zusammenhängen. Das sind alles, was ich finden konnte:

  • reinterpret_cast und Besetzung im C-Stil

    [[nodiscard]] float int_to_float1(int x) noexcept
    {
        return *reinterpret_cast<float*>(&x);
    }
    [[nodiscard]] float int_to_float2(int x) noexcept
    {
        return *(float*)(&x);
    }
    
  • static_cast und void*

    [[nodiscard]] float int_to_float3(int x) noexcept
    {
        return *static_cast<float*>(static_cast<void*>(&x));
    }
    
  • std::bit_cast

    [[nodiscard]] constexpr float int_to_float4(int x) noexcept
    {
        return std::bit_cast<float>(x);
    }
    
  • memcpy

    [[nodiscard]] float int_to_float5(int x) noexcept
    {
        float destination;
        memcpy(&destination, &x, sizeof(x));
        return destination;
    }
    
  • union

    [[nodiscard]] float int_to_float6(int x) noexcept
    {
        union {
            int as_int;
            float as_float;
        } destination{x};
        return destination.as_float;
    }
    
  • Platzierung new und std::launder

    [[nodiscard]] float int_to_float7(int x) noexcept
    {
        new(&x) float;
        return *std::launder(reinterpret_cast<float*>(&x));
    }
    
  • std::byte

    [[nodiscard]] float int_to_float8(int x) noexcept
    {
        return *reinterpret_cast<float*>(reinterpret_cast<std::byte*>(&x));
    }
    

Die Frage ist, welche dieser Wege sicher, welche unsicher und welche für immer verdammt sind. Welche sollte verwendet werden und warum? Gibt es eine kanonische, die von der C++-Community akzeptiert wird? Warum führen neue Versionen von C++ noch mehr Mechanismen ein? std::launder in C++17 bzw std::byte, std::bit_cast in C++20?

Um ein konkretes Problem zu nennen: Was wäre der sicherste, leistungsfähigste und beste Weg, um die schnelle inverse Quadratwurzelfunktion neu zu schreiben? (Ja, ich weiß, dass es auf Wikipedia einen Vorschlag für einen Weg gibt).

Bearbeiten: Um zur Verwirrung beizutragen, scheint es so zu sein ein Vorschlag das schlägt vor, einen weiteren Wortspielmechanismus hinzuzufügen: std::start_lifetime_aswas auch in einer anderen Frage diskutiert wird.

(Gottriegel)

  • Was Sie als praktischen vs. Sprachanwalt bezeichnen, kümmert sich tatsächlich um die Portabilität oder nicht. Sie können untersuchen, was der Compiler tut, wenn der Standard behauptet, es sei UB, aber dann sind Sie an diesen Compiler, einen bestimmten Satz von Compileroptionen und eine Zielplattform gebunden. Ich bin kein Anwalt für Sprachen, aber ich muss Code schreiben, der, wenn er hier kompiliert wird, auch dort kompiliert wird. Und das ist eine sehr praktische Ansicht

    – 463035818_ist_keine_Nummer

    21. Mai 2021 um 11:53 Uhr


  • Ich denke nur std::bit_cast und memcpy sind nicht UB.

    – Jarod42

    21. Mai 2021 um 11:54 Uhr

  • std::bit_cast ist nur C++20 und höher… Ist aber sicher das modern Weg.

    – Serge Ballesta

    21. Mai 2021 um 12:01 Uhr

  • btw “Allerdings gilt Typ-Wortspiel durch eine Union als schlechte Praxis in C++.” aus Wikipedia ist nicht ganz richtig. Type punning through a union ist in C++ undefiniert, obwohl viele Compiler es als Erweiterung anbieten

    – 463035818_ist_keine_Nummer

    21. Mai 2021 um 12:30 Uhr

  • Auch wenn die Motivation, dies zu Optimierungszwecken (z. B. schnelle inverse Quadratwurzeln) zu tun, im Laufe der Zeit verflogen ist, heißt das nicht, dass ein moderner C++-Programmierer niemals Typ-Wortspiele machen muss. Es ist in der eingebetteten Entwicklung ziemlich üblich.

    – Cody Grey

    21. Mai 2021 um 20:41 Uhr

Benutzer-Avatar
Jérôme Richard

Das vermutest du erstmal sizeof(long) == sizeof(int) == sizeof(float). Dies ist nicht immer wahr und völlig unspezifiziert (plattformabhängig). Tatsächlich ist dies auf meinem Windows mit clang-cl wahr und auf meinem Linux mit demselben 64-Bit-Computer falsch. Verschiedene Compiler auf demselben Betriebssystem/Computer können unterschiedliche Ergebnisse liefern. Ein statisches Assertion ist mindestens erforderlich, um hinterhältige Fehler zu vermeiden.

Einfache C-Umwandlungen, Reinterpret-Umwandlungen und statische Umwandlungen sind hier aufgrund der strengen Aliasing-Regel ungültig (um es vorsichtig auszudrücken, das Programm ist in diesem Fall in Bezug auf den C++-Standard schlecht geformt). Die Union-Lösung ist auch nicht gültig (sie gilt nur in C, nicht in C++). Nur der std::bit_cast und die std::memcpy Lösung sind “sicher” (vorausgesetzt, die Größe der Typen stimmt mit der Zielplattform überein). Verwenden std::memcpy ist oft schnell, da es von den meisten Mainstream-Compilern optimiert wird (wenn Optimierungen aktiviert sind, wie mit -O3 für GCC/Clang): die std::memcpy Aufruf kann inline eingefügt und durch schnellere Anweisungen ersetzt werden. std::bit_cast ist die neue Art, dies zu tun (erst seit C++20). Die letzte Lösung ist sauberer für einen C++-Code als std::memcpy unsicher verwenden void* -Typen und umgehen so das Typsystem.

  • Ich glaube, das OP weiß das alles. Die Wiederholung von Q ist keine Antwort.

    – Red.Wave

    8. Juni 2021 um 15:19 Uhr

Benutzer-Avatar
463035818_ist_keine_Nummer

Dies ist, was ich von gcc 11.1 bekomme -O3:

int_to_float4(int):
        movd    xmm0, edi
        ret
int_to_float1(int):
        movd    xmm0, edi
        ret
int_to_float2(int):
        movd    xmm0, edi
        ret
int_to_float3(int):
        movd    xmm0, edi
        ret
int_to_float5(int):
        movd    xmm0, edi
        ret
int_to_float6(int):
        movd    xmm0, edi
        ret
int_to_float7(int):
        mov     DWORD PTR [rsp-4], edi
        movss   xmm0, DWORD PTR [rsp-4]
        ret
int_to_float8(int):
        movd    xmm0, edi
        ret

Ich musste ein hinzufügen auto x = &int_to_float4; um gcc zu zwingen, irgendetwas für etwas auszugeben int_to_float4ich denke, das ist der Grund, warum es zuerst erscheint.

Live-Beispiel

kenne mich da nicht so aus std::launder also ich kann nicht sagen warum es anders ist. Ansonsten sind sie identisch. Das hat gcc dazu zu sagen (in diesem Zusammenhang mit diesen Flags). Was der Standard sagt, ist eine andere Geschichte. Obwohl, memcpy(&destination, &x, sizeof(x)); ist gut definiert und die meisten Compiler wissen, wie man es optimiert. std::bit_cast wurde in C++20 eingeführt, um solche Umwandlungen deutlicher zu machen. Beachten Sie, dass sie in der möglichen Implementierung auf cpreference verwenden std::memcpy ;).


TL;DR

Was wäre der sicherste, leistungsfähigste und beste Weg, um die schnelle inverse Quadratwurzelfunktion neu zu schreiben?

std::memcpy und in C++20 und darüber hinaus std::bit_cast.

  • GCC generiert keinen Code für int_to_float4 weil die Funktion nichts macht und du sie markiert hast constexpr. Es wird immer eingebettet und nicht verwendet, sodass kein Code für die Funktion ausgegeben werden muss. Als Alternative zur Übernahme der Adresse der Funktion können Sie einfach die entfernen constexpr. (Notiere dass der [[noinline]] Attribut wird für ignoriert constexpr Funktionen.) Abgesehen davon sehe ich nicht, wie eine Assembly-Auflistung dessen, was ein bestimmter Compiler generiert, auch nur annähernd eine Antwort auf diese Frage sein kann.

    – Cody Grey

    21. Mai 2021 um 20:39 Uhr


  • In der Tat ist es oft irreführend, die Compilerausgabe nach trivialen Beispielen zu durchsuchen. Einige dieser Ansätze mögen isoliert gut aussehen, aber wenn sie in komplexeren Code eingefügt werden, können sie aufgrund von Dingen wie strikten Aliasing-Verletzungen schlecht brechen.

    – Nate Eldredge

    21. Mai 2021 um 21:39 Uhr

  • @CodyGray Ist es nicht. Aus Neugier habe ich mir die Ausgabe von gcc nach OPs-Code angesehen (keine Änderungen, das ist der Grund für constexpr und [[noinline]]). Anstatt einen Godbolt-Link in einem Kommentar zu teilen, ist das passiert. In der Tat ist es schlecht, den Eindruck zu erwecken, dass dieses Beispiel der gcc-Ausgabe helfen würde, alle OP-Fragen zu beantworten, und dann gibt es nichts weiter als “memcpy verwenden”. Mein Fehler war, eine qualitativ hochwertige Frage nicht zu erkennen und dann zu versuchen, etwas zu formulieren, das keine Antwort ist, als ob es eine wäre. Dies muss ernsthaft umgeschrieben werden, aber es wird einige Zeit dauern

    – 463035818_ist_keine_Nummer

    21. Mai 2021 um 23:35 Uhr


1015520cookie-checkWas ist der moderne, korrekte Weg, um in C++ Wortspiele zu machen?

This website is using cookies to improve the user-friendliness. You agree by using the website further.

Privacy policy