The unexpected behaviour of the ternary operator (?:)

The in-place if operator (officially known as the ternary operator) has some behaviour that you might not expect. In fact, the compiler may do a lot of things you don’t expect! Let’s go on the magical journey of the tenary operator and the differences between GCC, Clang and Visual Studio.

Consider the following code:

auto my_function(int value, int offset)
{
    return value - offset ? 0 : 1;
}

What the reason would be for writing something like this isn’t important. What is important is to consider the order of operations that happen here. At first glance, you might consider that the offset first gets implicitly casted to a bool, and then depending on that result it will either return value or value - 1.

However this is not the case. The operator - takes precedence over the ternary operator. What is actually happening looks like this:

auto my_function(int value, int offset)
{
    return (value - offset) ? 0 : 1;
}

What this means is that this function can only ever return 0 or 1. Comparing the generated assembly from the compiler also clearly shows this difference.

auto my_function(int value, int offset)
{
    return value - offset ? 0 : 1;
}

auto my_function2(int value, int offset)
{
    return value - (offset ? 0 : 1);
}

The generated assembly looks like this:

my_function(int, int):                      # @my_function(int, int)
        xor     eax, eax
        cmp     edi, esi
        sete    al
        ret

my_function2(int, int):                     # @my_function2(int, int)
        mov     eax, edi
        cmp     esi, 1
        sbb     eax, 0
        ret

Be aware of this when using the ternary operator. Don’t be afraid to put code on multiple lines. It will improve readability.

Compiler differences

This is not the end of the story. From a pure logic point of view, the following methods will do exactly the same:

auto my_function(int value, int offset)
{
    return value - (offset ? 0 : 1);
}

auto my_function2(int value, int offset)
{
    if (offset)
        return value;
    else
        return value - 1;
}

auto my_function3(int value, int offset)
{
    if (!offset)
        return value - 1;
    else
        return value;
}

Both GCC (Trunk) and Clang (Trunk) agree on this, and generate exactly the same assembly for all 3 cases:

GCC (-O2):

my_function(int, int):
        cmp     esi, 1
        mov     eax, edi
        sbb     eax, 0
        ret
my_function2(int, int):
        cmp     esi, 1
        mov     eax, edi
        sbb     eax, 0
        ret
my_function3(int, int):
        cmp     esi, 1
        mov     eax, edi
        sbb     eax, 0
        ret

Clang (-O2):

my_function(int, int):                      # @my_function(int, int)
        mov     eax, edi
        cmp     esi, 1
        sbb     eax, 0
        ret
my_function2(int, int):                     # @my_function2(int, int)
        mov     eax, edi
        cmp     esi, 1
        sbb     eax, 0
        ret
my_function3(int, int):                     # @my_function3(int, int)
        mov     eax, edi
        cmp     esi, 1
        sbb     eax, 0
        ret

However, in Visual Studio 2019 (19.24) it is a different story. Here, the compiler is only able to generate the first function in a branchless fashion; although still less efficient than GCC and Clang.

value$ = 8
offset$ = 16
int my_function(int,int) PROC                          ; my_function, COMDAT
        xor     eax, eax
        test    edx, edx
        sete    al
        sub     ecx, eax
        mov     eax, ecx
        ret     0
int my_function(int,int) ENDP                          ; my_function

value$ = 8
offset$ = 16
int my_function2(int,int) PROC                   ; my_function2, COMDAT
        mov     eax, ecx
        test    edx, edx
        jne     SHORT $LN3@my_functio
        lea     eax, DWORD PTR [rcx-1]
$LN3@my_functio:
        ret     0
int my_function2(int,int) ENDP                   ; my_function2

value$ = 8
offset$ = 16
int my_function3(int,int) PROC                   ; my_function3, COMDAT
        lea     eax, DWORD PTR [rcx-1]
        test    edx, edx
        je      SHORT $LN3@my_functio
        mov     eax, ecx
$LN3@my_functio:
        ret     0
int my_function3(int,int) ENDP                   ; my_function3

But wait, there’s more!

Consider the following code:

auto my_function(int value, int offset)
{
    return value - (offset ? 0 : 1);
}

auto my_function2(int value, int offset)
{
    return value - static_cast<int>(!offset);
}

In this case, Visual Studio is able to determine that this will result in the behaviour, and generates the same assembly for both:

value$ = 8
offset$ = 16
int my_function(int,int) PROC                          ; my_function, COMDAT
        xor     eax, eax
        test    edx, edx
        sete    al
        sub     ecx, eax
        mov     eax, ecx
        ret     0
int my_function(int,int) ENDP                          ; my_function

value$ = 8
offset$ = 16
int my_function2(int,int) PROC                   ; my_function2, COMDAT
        xor     eax, eax
        test    edx, edx
        sete    al
        sub     ecx, eax
        mov     eax, ecx
        ret     0
int my_function2(int,int) ENDP                   ; my_function2

Both GCC and Clang do the same.

GCC:

my_function(int, int):
        cmp     esi, 1
        mov     eax, edi
        sbb     eax, 0
        ret

my_function2(int, int):
        cmp     esi, 1
        mov     eax, edi
        sbb     eax, 0
        ret

Clang:

my_function(int, int):                      # @my_function(int, int)
        mov     eax, edi
        cmp     esi, 1
        sbb     eax, 0
        ret

my_function2(int, int):                     # @my_function2(int, int)
        mov     eax, edi
        cmp     esi, 1
        sbb     eax, 0
        ret

And now for something completely different… ?

It doesn’t end here however. Visual Studio’s behaviour when it comes to inlining vs function code generation is very different.

Consider the following code:

auto my_function(int value, int offset)
{
    if (!offset)
        return value - 1;
    else
        return value;
}

int main(int argc, char *argv[])
{
    return my_function(argc, *argv[2]);
}

Don’t worry about the argc/argv being passed to the function, these only serve as input that the compiler can’t predetermine at compile time.

Now have a look at the generated assembly:

value$ = 8
offset$ = 16
int my_function(int,int) PROC                          ; my_function, COMDAT
        lea     eax, DWORD PTR [rcx-1]
        test    edx, edx
        je      SHORT $LN3@my_functio
        mov     eax, ecx
$LN3@my_functio:
        ret     0
int my_function(int,int) ENDP                          ; my_function

argc$ = 8
argv$ = 16
main    PROC                                            ; COMDAT
        mov     rax, QWORD PTR [rdx+16]
        lea     edx, DWORD PTR [rcx-1]
        cmp     BYTE PTR [rax], 0
        cmovne  edx, ecx
        mov     eax, edx
        ret     0
main    ENDP

The compiler decided to inline my_function and when it does that it is able to generate a conditional move (cmovne) instead of a branch (je). Also note that when inlining, the assembly generated for the function isn’t removed, as during linking it may still be needed by another compilation unit.

Conclusion

While I don’t like to advocate the use of the tenary operator due to its sometimes unexpected behaviour; in some cases it may be the better option. Don’t optimize prematurely though. Go for readability first. If you need more performance, measure!

Thanks to Steven Hoving for additional input for this article.