Value Struct - wrapping values for strong typing

One of my favorite tricks of C++ is this one simple compile-time device: value structs. This trick consists of a struct that wraps its only field, which type is a primitive one (int, float, ...).

Everything seems the same...

Let's start with a very simple example: converting between radians and degrees.

#include <iostream>

float to_rad(float deg) {
    return deg * 3.14 / 180;
}

float to_deg(float rad) {
    return rad * 180 / 3.14;
}

You may wonder: what's wrong with this code? Nothing, the code is fine. The problem is... nothing can stop you from using a value representing a "degree" to be passed to a function that takes a "radiant".

int main() {

    float half_pi = 1.57;

    // `half_pi` is 90 degrees in radians
    float ninety_deg = to_deg(half_pi);
    std::cout << ninety_deg << std::endl;

    // 90... degrees or radians? the compiler doesn't care!
    float what = to_deg(ninety_deg);
    std::cout << what << std::endl;

    return 0;
}

Unfortunately, this issue is more widespread than you think, and I've seen it happen several times.

A variant of this problem shows up when a function fun(int caller_id, int message_id) has two parameters with the same type, and someone swaps them (fun(int message_id, int caller_id)). If the client code is not updated to reflect this change, the program may not work correctly anymore. The compiler, however, will not warn about this change!

Let's go back to the original problem: what if to_deg and to_rad accept and return a special type, instead of a raw float?

Wrapping values

Let's create two ad-hoc structures for our Degrees and Radians. Both structures will wrap a single float, that can be accessed directly.

struct Degrees {
    float value;
    Degrees(float v): value(v){}
};

struct Radians {
    float value;
    Radians(float v): value(v){}
};

These two structs look exactly the same, but they are different types, and the compiler will treat them as such. No mixing allowed, this time!

Radians to_rad(Degrees d){
    return d.value * 180 / 3.14;
}

Degrees to_deg(Radians r) {
    return r.value * 3.14 / 180;
}

int main() {
    Radians half_pi = Radians(1.57);

    Degrees ninety_degrees = to_deg(half_pi);
    std::cout << ninety_degrees.value << std::endl;

    // Degrees what = to_deg(ninety_degrees);       // COMPILE ERROR!
    // std::cout << what.value << std::endl;

    return 0;
}

Under the hood, nothing has changed

You may now think: "wait, are we going to create a lot of temporary objects? It's going to be slow!". Well, gcc and clang recognize this pattern, and will replace Degrees and Radians with the float value - as if those structures were never defined.

Let's compare the generated assembly code, generated by gcc 9.1.0 (compile flags: -O2). In this listing, we are going to examine the difference between the first, float-based version (left side) and the struct-based version (right side).

Note: section .LFE1544 and section .LFE1551 define to_deg(float) and to_deg(Radians). section .LFB1545 and section .LF1552 contain the code that is executed - the "real code"

.LFE1544:                                       |       .LFE1551:
        .size   _Z6to_radf, .-_Z6to_radf        |               .size   _Z6to_rad7Degrees, .-_Z6to_rad7Degrees
        .p2align 4                                              .p2align 4
        .globl  _Z6to_degf                      |               .globl  _Z6to_deg7Radians
        .type   _Z6to_degf, @function           |               .type   _Z6to_deg7Radians, @function
_Z6to_degf:                                     |       _Z6to_deg7Radians:
.LFB1545:                                       |       .LFB1552:
        .cfi_startproc                                          .cfi_startproc
        mulss   .LC2(%rip), %xmm0                               mulss   .LC2(%rip), %xmm0
        cvtss2sd        %xmm0, %xmm0                            cvtss2sd        %xmm0, %xmm0
        divsd   .LC0(%rip), %xmm0                               divsd   .LC0(%rip), %xmm0
        cvtsd2ss        %xmm0, %xmm0                            cvtsd2ss        %xmm0, %xmm0
        ret                                                     ret
        .cfi_endproc                                            .cfi_endproc

As we can see, the difference is just in the description of the function: the real code is the same. Both versions perform the same multiplications and divisions, and read the same registers.

Conclusion

In this blog post, we looked at value structs, that are structures that wrap a single field, and how we can use them to type-check our code and avoid mixing raw values. We also looked the assembly code, and noticed that the wrappers we introduced do not affect the performance of our code: they are compiled as if we just used the raw values.

If you feel that this article helped you, feel free to share it! If you have questions, ask on Twitter, or offer me a coffee to let me keep writing these notes!

References: