Kategorien
C++Programmierung

Unions sind fast immer eine schlechte Idee.

Dieser Post wurde [wpstatistics stat=pagevisits time=total id=751] mal angesehen.

Eine klassische union von C in C++ zu verwenden ist fast immer eine schlechte Idee und man sollte stattdessen std::variant verwenden. Ich zeige wieso. Und ich werde auch zeigen in welche große(!) Falle ich kürzlich selbst mit einer union gerannt bin.

Wir starten mit einer einfachen union zum veranschaulichen:

union Num {    // this union can either be an int or a float 
    int    i;
    float  f;
};

constexpr auto s1 = sizeof( Num );  // size of Num is 4 (bytes).

Das Gute an dieser union ist, sie braucht nur 32 bits (4 bytes) im Speicher und sie kann ein int oder ein float sein.
Also, warum sollte ich jetzt für den Overhead bezahlen, der mit std::variant kommt?
Und… was ist überhaupt der Overhead?
Ok, wenn man eine nackte C-style union verwendet, gibt es sehr wichtige Regeln zu beachten, welche nicht verletzt werden dürfen oder man landet andernfalls im Land des UB (undefined behavior – undefiniertes Verhalten) und das Programm ist „fehlerhaft“ (laut C++ Standard: (engl.) ill-formed).
Eine wichtige Regel für unions ist, dass nur einer (und wirklich nur einer) der alternativen Member zu einem Zeitpunkt aktiv sein kann.
Die union Num oben kann entweder ein int oder ein float sein, aber niemals beides!

Num num;      // no member active yet.
num.i = 123;  // now only the int i is active and can be used.

int x = num.i + 7;               // OK, using active i
std::cout << num.i << std::endl; // OK as well.

// Cannot use f because i is active!
float fi = num.f; // DON’T DO THIS!!! This is undefined behavior (UB)!

// switch the active member.
num.f = 2.1;  // now the float f is the active member and i not anymore.

// now can do this:
float fi = num.f;    // OK, f is the active member.

//BUT:
int i2 = num.i;  // DON’T DO THIS!!! This is now undefined behavior (UB)! 

Man wechselt den aktiven Member einer union indem man an ihn zuweist. Danach kann nur dieser Member genutzt werden um davon zu lesen. Das Nutzen jedes anderen Members führt zu undefined behavior (undefiniertes Verhalten)!
Deswegen benötigt man fast immer eine zweite Variabel um sich zu merken, welcher Typ der union aktiv ist. Meistens verwendet man ein enum dafür.

enum eNum   // enum to memory if the union is int or float
{
    Int,
    Float
};

Num num;
num.i = 123; // i is active.

eNum e{ Int }; // enum must be set separately to memorize that the int is active.

// dispatch the active member based on the enum
switch( e ) {
case Int: // the int is active.
    std::cout << num.i;
    break;
case Float: // the float is active.
    std::cout << num.f;
    break;
default:
    std::terminate(); // should not happen.
}

Wenn man dies macht, gibt es viele mögliche Fallen.
Die Gefährlichste ist, dass der Wert des enums von Hand gepflegt werden muss.
Es kann sehr einfach vergessen werden den Wert zu aktualisieren oder es kann auf einen falschen Wert geändert werden.
Außerdem kann die Verwendung der union zusammen mit dem enum immer noch fehlerhaft sein (Tippfehler, etc.).
Es gibt keinen Automatismus, welcher dazu führt, dass das enum immer berücksichtigt wird, wenn man von der union liest oder hinein schreibt.
Deshalb kann man immer noch sehr schnell in der Welt des undefined behavior landen.

Es gibt eine kleine Verbesserung, wenn man die union und das enum in einem struct zusammen bündelt (als eine sogenannte tagged union).

struct Number {  // this is a tagged union
    eNum tag;
    Num  val;
};

Number  num;
num.tag   = Int;
num.val.i = 123;

switch( num.tag ) {
case Int:
    std::cout << num.val.i;
    break;
/* ...  */
};

Dennoch sind dieselben Probleme immer noch vorhanden.
Es gibt keinen Automatismus den „enum tag“ zu verwenden oder korrekt zu verwenden.
Er muss an allen Stellen von Hand gepflegt werden.

Wenn man stattdessen ein std::variant nutzt, wird es all diese Dinge automatisch erledigen!
Interessanterweise, wenn man die Größe eines std::variant mit einer entsprechenden tagged union vergleicht, wird man sehen, dass sie identisch sind. Ein std::variant ist genau genommen eine smart tagged union, welche die ganze Arbeit erledigt.

// The tagged union Number from above as std::variant.
std::variant< int, float > varnum{ 123 };

constexpr auto s1 = sizeof( Num );     // bare metal union: 4 bytes
constexpr auto s2 = sizeof( Number );  // tagged union    : 8 bytes
constexpr auto s3 = sizeof( varnum );  // std::variant    : 8 bytes

// is true, std::variant and tagged union have same size.
static_assert( sizeof( Number ) == sizeof( varnum ) );

Deswegen weil man fast immer die Information darüber, welcher Member aktiv ist, in einer zusätzlich eingeführten Variabel pflegen muss, kann man auch gleich ein std::variant verwenden.
Dadurch wird das Pflegen des „enum tags“ durch das std::variant forciert und sicher gestellt.
Darüber hinaus kann nicht aus Versehen auf den falschen (inaktiven) Member zugegriffen werden.
Das std::variant stellt dies alles sicher.

// The tagged union Number from above as std::variant.
std::variant< int, float > num{ 123 };

switch( num.index() ) {
case 0: // the int is active.
    std::cout << std::get<0>( num ); // will throw if int is not the active member.
    break;
case 1: // the float is active.
    std::cout << std::get<1>( num ); // will throw if float is not the active member.
    break;
case std::variant_npos:
default:
    std::terminate(); // should not happen.
}

// there are better alternatives as to switch over the index.
// but I just wanted to modify the origin example via a minimal change.
// see std::visit and search for the "Overload Pattern" for a better way.
// (or read here: https://www.cppstories.com/2019/02/2lines3featuresoverload.html/ )

num = 2.1; // now float is active.
num.index();  // returns 1 now.
std::cout << std::get<float>( num );  // works, prints 2.1
std::cout << std::get<int>( num );    // will throw std::bad_variant_access

Wie man in dem Beispiel sehen kann, ist die Nutzung des std::variants sicherer und auch einfacher verglichen mit einer union + einem enum.
Man bezahlt nicht mehr als für eine tagged union. Es ist meistens immer erforderlich zu wissen welcher Member aktiv ist.
Tatsächlich, wenn man eine std::variant verwendet, zahlt man sogar weniger, da man sich eine Menge zu schreibenden Code spart.
Abhängig von der Art der Benutzung zahlt man evtl. die Kosten für einen Extra-Check innerhalb des std::variant um zu prüfen, ob auf den aktiven Member zugegriffen wird. Aber hey, die Alternative wäre das Land des undefined behavior zu betreten! Deshalb kann dieser mögliche Extra-Check vernachlässigt werden.

So, ist dies bereits die ganze Geschichte?
Nein!

Da ist eine sehr wichtige weitere Sache bei der Verwendung eines nackten C-style union. In diese große(!) Falle bin ich kürzlich selbst reingelaufen.

Dies werde ich mit dem nächsten Beispiel demonstrieren.

Also, angenommen man hat Objekte einer Klasse Foo, wobei einige der Objekte statisch allokiert sind und andere dynamisch. Die statisch allokierten werden automatisch bei Programmende aufgeräumt. Die dynamisch allokierten werden via Smart-Pointer verwaltet.  

class Foo {
    std::string const mName;
public:
    explicit Foo( std::string const &rName )
        : mName( rName )
    {}
};

// some statically Foos, will be cleaned up automatically at program end.
static Foo const foo_abc( "abc" );
static Foo const foo_xyz( "xyz" );

Dann hatte ich eine weitere Klasse Bar, die eine Instanz von Foo benötigte. Sie wird entweder einen Zeiger auf ein statisch allokiertes Foo bekommen, oder eine neue Instanz, welche dynamisch allokiert wurde. Meine Idee war dann eine union zu schaffen, welche beide Arten von Foo-Instanzen aufnehmen kann. Deswegen weil ich den aktiven Member der union nicht nochmal ändere und weil ich die Information, welcher Member aktiv ist, bereits anderswo hatte, dachte ich, ich könnte die Verwendung eines std::variants in diesem Fall sparen.

Dann hatte ich ähnlichen Code wie im folgenden vereinfachten Beispiel:

class Bar
{
    union FooPtr       // union can either be a raw pointer to Foo or a std::unique_ptr
    {
        Foo const *ptr;
        std::unique_ptr<Foo const> uptr;
    };
    FooPtr  mFooPtr;
public:
    Bar( Foo const *raw_foo )    // this constructor uses a raw pointer without automatic deletion (b/c it is statically allocated we must not delete it here!)
        : mFooPtr( raw_foo )
    { }

    Bar( std::unique_ptr<Foo const> smart_foo ) // this constructor uses a smart pointer.
        : mFooPtr( nullptr )
    {
        mFooPtr.uptr = std::move( smart_foo );
    }
};

// with that you can use it like this:
Bar bar1( &foo_abc );                        // use statically allocated Foo.
Bar bar2( std::make_unique<Foo>( "uvw" ) );  // use dynamically allocated Foo.

Wenn man versucht diesen Code zu kompilieren, dann wird man feststellen, dass es nicht kompiliert!
Mit Visual Studio bekommt man diese Message: warning C4624: 'Bar::FooPtr': destructor was implicitly defined as deleted
Und dann:
error C2280: 'Bar::FooPtr::~FooPtr(void)': attempting to reference a deleted function

Was zum Teufel?

Warum ist der Destruktor deleted? Was geht da vor sich?

Wenn man dann naiver Weise einfach einen Destruktor in die union FooPtr so wie hier ~FooPtr(){}  einfügt, dann kompiliert alles wunderbar. War es das jetzt? Ist dies alles?
Nope!

Es ist jetzt ein riesengroßes Problem vorhanden!

Jeder kann dies einfach selbst entdecken, indem man einen Destruktor in Foo hinzufügt, der eine Message nach std::cout schreibt:

~Foo()
{
    std::cout << "Destructor Foo: " << mName << std::endl;
}


// with this given Foos
static Foo const foo_abc( "abc" );
static Foo const foo_xyz( "xyz" );

// ... and this given Bars
Bar bar1( &foo_abc );
Bar bar2( std::make_unique<Foo>( "uvw" ) );

Welche Messages des Foo Destruktors wird man wohl sehen?
Oder besser: Welche Message wird man nicht sehen?

Der Code kann auch auf Godbold ausprobiert werden: https://godbolt.org/z/3Gs3MfPMP

Richtig, der Destruktor des „uvw“ Foos wird niemals aufgerufen! In dem Code ist ein großes Speicher-/Ressourcen-Loch obwohl wir Smart-Pointer verwenden!

Dies war die große Falle in welche ich gerannt bin: Obwohl ich einen Smart-Pointer benutzte, war der Smart-Pointer überhaupt nicht smart! Der Destruktor wird innerhalb einer union nicht aufgerufen!
Das ist tatsächlich für alle Klassen in union Membern so.
Man muss den Destruktor manuell aufrufen, so wie hier mFooPtr.uptr.~unique_ptr();

Aber ein Smart-Pointer bei dem man den Destruktor manuell aufrufen muss, ist nicht smart und man kann auch gleich den delete operator aufrufen. Der Smart-Pointer ist dann komplett nutzlos.

Mit dieser Regel ist es ganz und gar nicht empfohlen eine union mit Membern zu verwenden, die nicht aus primitiven Datentypen besteht (genauer: Typen, die nicht trivial zerstörbar sind, siehe https://en.cppreference.com/w/cpp/language/destructor#Trivial_destructor ).

Also, einfach std::variant verwenden und der Destruktor des aktiven Members wird so wie erwartet automatisch aufgerufen.

(Aber als kleinen Hinweis: Leider kann man eine std::unique_ptr auch nicht in einem std::variant verwenden, da es ein move-only Typ ist. Man muss einen std::shared_ptr verwenden. )

Ich hoffe dieser Artikel war wertvoll und hat vielleicht etwas Neues und Nützliches gezeigt. Über Kommentare und Feedback freue ich mich sehr. Fröhliches Programmieren! 😊

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert