3
$\begingroup$

Some languages accept untagged union types (C++, C, etc):

union Something
{
    char ch;
    int  i;
};

Some languages accept tagged union types (like Haskell).

But some languages do not have union types at all (like Go).

So, what are pros and cons of these three ways?

$\endgroup$
14
  • 4
    $\begingroup$ Tagged or untagged unions? $\endgroup$
    – mousetail
    Jul 12, 2023 at 9:24
  • 7
    $\begingroup$ I don't think your question is that clear. Do you mean tagged unions vs untagged unions or having union/sum types at all? Your example in C is an untagged union. Go initially aimed to be simple and omitted many things including generics and unions. It now has generics and unions are under discussion $\endgroup$ Jul 12, 2023 at 10:20
  • 1
    $\begingroup$ Where is the "tag" in your union? $\endgroup$
    – dan04
    Jul 12, 2023 at 15:55
  • 2
    $\begingroup$ You also list Go as a third option (not having unions at all), but then ask about "these two ways". Is not having unions at all another thing you want the pros and cons of? Also, don't you mean "tagged union" for Haskell? $\endgroup$ Jul 13, 2023 at 16:14
  • 1
    $\begingroup$ @nchistov You didn't fix the Haskell part, and now the title conflicts with the body. $\endgroup$ Jul 14, 2023 at 14:10

4 Answers 4

5
$\begingroup$

The pro is that you can save some memory (cache) when using them. If there are 2 fields that are never used together then you can put them into a union and only pay the size cost for the larger of the two.

For tagged unions (where the language adds a enum to signal which alternative is active) the downside is that you are locked into the layout of whatever the language defined. So you cannot use them as an aid for parsing a file format or use them for in a foreign function interface for example.

For untagged unions the major downside is type safety is very difficult to guarantee. There is nothing inherent to the union to signal which alternative is active which means a integer could be interpreted as a pointer.

$\endgroup$
3
  • $\begingroup$ In my opinion the clarity of what data is present in what states in a tagged union is even more important than the space savings. Say I have a string in state A and an int in state B. If all I have in my language is structs, then I'll have the int and the string fields all the time. It's some wasted space, yes, but more importantly my code might accidentally read those values in the states where they're not set. This is even trickier if instead of a string or an int I want to conditionally manage something like a file or a socket, with no convenient default/missing value. $\endgroup$ Apr 17 at 21:00
  • $\begingroup$ if you don't care about space savings why use a union at all, just make a uberstruct that has all fields, if you have sensible default init you don't even need to check whether the field is active either (depending on the usecase) $\endgroup$ Apr 18 at 9:33
  • $\begingroup$ Having compiler errors or other tooling that can tell you "this field isn't present in this state" is very nice. $\endgroup$ Apr 19 at 16:58
5
$\begingroup$

Using untagged unions needs a lot of boilerplate to use safely and it's easy to mess up and cause undefined behavior

A pure untagged union does no type checking so you can interpret it's contents as type A when it is type B. In most languages this is undefined behavior.

To fix this issue, developers need to tag the union manually, usually you'd use something like this:

union X {
   int a,
   float b
}

enum XType {
    Float = 1,
    Int = 2,
}

struct TaggedX {
    XType type,
    X value
}

Then every time you want to access it you need:

switch x.type {
    case XType.Float:
        float value = x.value.b;
        // do something with float
    case XType.Int:
        int value = x.value.a;
        // do something with int
}

You can get a bit creative, maybe you use the type variable for something else too and multiple enum values have the same type. However, mostly you need this boilerplate every time you want to safely use the union. The more you depart the more likely you make a mistake and cause undefined behavior.

Just offering a tagged union (like Rust's enum) means all this work is done for you. You need to write less code and get more safety in return. You may need slightly more memory in some specialized situations but usually the memory benefits are considered worth it.

There is no real disadvantage of offering a untagged union for FFI if you cover it with enough warnings about it's unsafety and offer good alternatives for normal use. Even rust supports them. However, discourage their use outside of specialized situations.

$\endgroup$
3
$\begingroup$

It's a trade-off between safety and flexibility.

By attaching a tag to every union, a language can prevent access as the wrong type. However, that prevents use for type-punning as is commonly necessary in low-level languages such as C.

Also, a tag creates overhead that may be undesirable - both in the extra instructions to verify access and in the space that's used even if the program logic is able to ensure correct access in other ways such as the flow of control.

There's a fourth alternative, as in C++, which is to provide both low-level untagged unions (union) and one or more high-level tagged types (std::variant, std::any, arguably std::optional) for easy safe use.

$\endgroup$
2
$\begingroup$

Some tasks require "polymorphic records". For instance, some simulation involving transport vehicles may need additional fields that differ for a light car, a truck, a motorbike, a light tank and a taxiing aircraft. You may still need to keep those records say as members of the same array, or have some functions that take or return any of them.

It would be possible to have a single structure containing all fields that could ever occur, and leave irrelevant fields unpopulated. One may argue that it would be more clear to have each case of such a structure as a member of the union, where each variant only defines relevant fields. The union must obviously have a way to tell which variant is in use. The compiler will allocate enough memory per union so that any variant would fit.

Polymorphism can also be implemented by storing abstract pointers and allocating different structures dynamically, but very extreme usage of the heap may not scale for very many threads.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .