43

While looking at the code for Dear Imgui, I found the following code (edited for relevance):

struct ImVec2
{
    float x, y;
    float& operator[] (size_t idx) { return (&x)[idx]; }
};

It's pretty clear that this works in practice, but from the perspective of the C++ standard, is this code legal? And if not, do any of the major compilers (G++, MSVC, Clang) offer any explicit or implicit guarantees that this code will work as intended?

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
user20126994
  • 459
  • 2
  • 8

4 Answers4

39

is this code legal?

No, it has undefined behavior. The expression &x is a float* that points to a float object and not to the first element of a float array. So, in case idx is 1 or 2 or some other value, the expression (&x)[idx] is (&x)[1] or (&x)[2] respectively which means you're trying to access memory that is not meant to be accessed by you.

do any of the major compilers (G++, MSVC, Clang) offer any explicit or implicit guarantees that this code will work as intended?

Undefined behavior means anything1 can happen including but not limited to the program giving your expected output. But never rely(or make conclusions based) on the output of a program that has undefined behavior. The program may just crash.

So the output that you're seeing(maybe seeing) is a result of undefined behavior. And as i said don't rely on the output of a program that has UB. The program may just crash.

So the first step to make the program correct would be to remove UB. Then and only then you can start reasoning about the output of the program.


1For a more technically accurate definition of undefined behavior see this, where it is mentioned that: there are no restrictions on the behavior of the program.

Jason
  • 36,170
  • 5
  • 26
  • 60
  • 2
    Comments are not for extended discussion; this conversation has been [moved to chat](https://chat.stackoverflow.com/rooms/248484/discussion-on-answer-by-jason-liam-in-c-is-it-valid-to-treat-scalar-members-o). – Machavity Sep 30 '22 at 19:42
  • 3
    @PeterCordes Your comment here is incorrect; the OP's code is indeed undefined behavior. There is *similar* but *not the same* code that isn't undefined behavior in your answer. (so similar it compiles down to the same assembly) – Yakk - Adam Nevraumont Oct 02 '22 at 04:33
  • @Yakk-AdamNevraumont: replacing obsolete comments: this answer is correct, but it's only just barely UB. The intent of the standard seems to be that it's well-defined if you do the pointer-math with `char*`. And the standard *requires* this to work for `std::complex` even with `float*` math, so implementations either define it in general or use something special to define `std::complex`. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p1912r1.pdf (libstdc++ has two separate `_M_real` and `_M_imag` members, not a union with an array.) – Peter Cordes Oct 02 '22 at 19:20
  • 3
    @PeterCordes The standard is quite clear it is *not* defined if you *don't* use `char*` (or similar). The standard wants to be able to guarantee, that if you have a pointer to a single variable `x` (or within `x`), no pointer arithmetic on that pointer can cause it to point to something that isn't `x` (unless you convert to `char*`). This is intended to simplify compilers tasks at proving values of variables from what I understand. `char*` pointer arithmetic is an intended hole; so code without it the compiler can make certain optimization assumptions without proving them – Yakk - Adam Nevraumont Oct 03 '22 at 02:26
  • 2
    Now, this might not end up being *practical*, maybe the standard is wrong here, but it is definitely intentional. – Yakk - Adam Nevraumont Oct 03 '22 at 02:27
30

This is almost safe in ISO C++, and also ISO C, and compilers appear to define the behaviour even with float* To be fully safe, you should cast to char* for the pointer math before casting to float*; the ISO standards only allow pointer math on pointers to arrays, but you're supposed to be able to treat any object as an array of char or std::byte, which is what makes offsetof usable to make a pointer you can deref. But in practice on real implementations like GCC, it seems well-defined even with just float*.

Assuming no padding: you can static_assert that offsetof(ImVec2, y) == sizeof(float).

  • For a standard-layout type, a pointer to the first member is convertible to/from a pointer to the whole struct/class object.

  • Given a standard-layout type, it's well-defined to index into it using offsetof(T,y) as an offset. See Using offsetof to access struct member (C, but I assume the intent in C++ is for offsetof to be usable the same way.) There is some debate over whether the wording of the ISO standards truly supports this, but that's the intent, and compiler devs agree it should be well-defined.

  • Unlike pointers where it matters how you obtained it (not just its numeric value because pointers don't have to be flat integers), a size_t is fungible. Since a value of 4 from offsetof works, a run-time variable 4 from idx*sizeof(float) also works.

  • The char* math using offsetof might be safely done using float* instead of actually casting to char* and back. But this isn't well supported by the wording of the standard and relies on some assumptions about things being equivalent. For maximum safety, use char* so you're only relying on the same behaviour that using offsetof to access a member would, which the standard I think intends to be well-defined.

    See Is adding to a "char *" pointer UB, when it doesn't actually point to a char array? where zwol's answer points out the conflicting goals of making it UB to access outside the bounds of a member array of a struct, but also to allow member access via an offset from offsetof.

The behaviour is undefined if idx isn't 0 or 1, of course, since you don't do bounds-checking. (Using idx & 1 would cost an AND instruction, but fairly cheaply give you unsigned mod 2. But an out-of-bounds index is very likely to be a bug, so silently working in that case is not great. If you want anything for bounds checking, probably a branch that's never taken in the non-buggy case, like an assert, or throwing an exception, or returning NaN.)

It might even be legal to access past the end of the struct starting with this pointer, if it was part of an array of such structs. We'd have to justify it as converting to an array member, and then accessing into another array member similar to offsetof. (Accessing one array member relative to another is guaranteed).


Interconvertibility between first member and whole struct

In C, Is pointer to struct a pointer to its first member? - yes, and vice versa, citing N1570 6.7.2.1p15.

In C++, the same guarantee is restricted to "standard layout" types, which rules out there being a vtable. Padding before the first member is disallowed, and pointer conversion between the first member and whole struct is allowed. See 11.4.1 Class members - General in the current draft:

  1. If a standard-layout class object has any non-static data members, its address is the same as the address of its first non-static data member if that member is not a bit-field. Its address is also the same as the address of each of its base class subobjects.

[Note 11: There can therefore be unnamed padding within a standard-layout struct object inserted by an implementation, but not at its beginning, as necessary to achieve appropriate alignment. — end note]

[Note 12: The object and its first subobject are pointer-interconvertible ([basic.compound], [expr.static.cast]). — end note]


Maximum safety way

Another way to write this is to start with the struct object yourself, without relying on &x implicitly working as this. And do the math using char*.

You could reinterpret_cast<const char*>(this) + 4*idx to get a pointer to the member, then cast that to float* and deref. (Or actually sizeof(float), and assuming offsetof(ImVec2, y) == sizeof(float).) Since you have a 2-member struct, idx * offsetof(ImVec2,y) using char* math would also work, and hopefully let the compiler still make x86 asm like lea rax, [rdi + rsi*4] to return a pointer aka C++ reference.

This is equivalent to casting this to float*, except the actual pointer math happens on a char*, which is intended to be allowed within any object.

#include <cstdlib>
#include <cstddef>
#include <type_traits>

struct ImVec2
{
    float x, y;
    float& operator[] (size_t idx) {
        static_assert(std::is_standard_layout<ImVec2>::value, "can't index in a struct that isn't standard layout");
        // offset(x) == 0 is guaranteed by ISO C++ for standard-layout types
        static_assert(offsetof(ImVec2, x) == 0,             "struct of float x,y isn't 2 contiguous members");
        // A hypothetical compiler could put padding before y
        static_assert(offsetof(ImVec2, y) == sizeof(float), "struct of float x,y isn't 2 contiguous members");

        // assert(idx <= sizeof(*this) / sizeof(x) && "out of bounds access to xy vector");
        char *obj = reinterpret_cast<char*>(this);
        obj += sizeof(float) * idx;      // or idx * offsetof(T,y) for a 2-member struct
        return *reinterpret_cast<float*>(obj);
       // memcpy into  float tmp  could avoid ever dereferencing a float* if you only want to return by value
       // It's safe to derive a pointer to a member from a pointer to the whole object
    }

    float & index_from_member (size_t idx){
        return (&x)[idx];    // Less safe; (ImVec2*)(&x) is allowed, but the pointer math is on float* not char*
    }
};

This will of course compile to the same asm for mainstream CPUs where struct layout is normal and the simple version in the question works.


Real compilers warn on idx>=2, but not for 0 or 1

For the version in the question, or the one starting with this, GCC only warns for a compile-time-constant index of 2 or greater. That's a good sign that it knows there might be a problem, but doesn't think there is when the access is still within the whole struct that the member was part of.

Lack of a compiler warning or runtime detection by UBSAN does not prove it's safe in ISO C++ or C in general, or even that it's fully safe with that compiler.

But presence of a warning in one case and lack of it in another does confirm that the compiler cares about a difference, and that's where the threshold is. Unless there's some other UB it's not warning about. Or it's always possible that the warning and some other part of GCC's internals are out of sync, and some value-range proving part of GCC might infer __builtin_assume(idx==0) despite not not warning. That's probably not what GCC does, but the lack of warning at idx=1 doesn't definitively prove it's safe even though it warns at idx=2. We have other supporting evidence, though, such as code like this existing in real-world source code and apparently working.

So it appears that GCC does define the behaviour. With return iv.index_from_member(1), there's no warning even though we're accessing outside the bounds of x.

Godbolt - GCC and clang with -O3 -Wall -fsanitize=undefined - with a constant 1 arg, both versions just compile to a load, vs. with 2 they also make code that will print an error if executed. In that link, I showed one of each: the new version with iv[1], the old version with iv.index_from_member(2);. Reversing those, the warning comes only from the new version.

## GCC12.2 -O3 -Wall
<source>: In function 'float test_orig()':
<source>:38:32: warning: array subscript 2 is outside array bounds of 'ImVec2 [1]' [-Warray-bounds]
   38 |     return iv.index_from_member(2);
      |            ~~~~~~~~~~~~~~~~~~~~^~~
<source>:37:12: note: at offset 8 into object 'iv' of size 8
   37 |     ImVec2 iv = {2.0, 2.0};
      |            ^~
ASM generation compiler returned: 0

Note that GCC's warning describes it as an object of size 8.

Clang doesn't warn even with -O3 -Wall -Wextra, but with -fsanitize=undefined it makes asm that will unconditionally call __ubsan_handle_type_mismatch_v1 with a compile-time constant idx of 2 (after inlining). (After checking for pointer overflow of the stack pointer first, i.e. that RSP on function entry wasn't 0.)

/app/example.cpp:22:16: runtime error: reference binding to address 0x7ffce4f8eba0 with insufficient space for an object of type 'float'
0x7ffce4f8eba0: note: pointer points here
 00 00 00 40  b0 b5 34 d6 08 56 00 00  83 e0 48 0e 86 7f 00 00  00 00 00 00 00 00 00 00  98 ec f8 e4
              ^ 
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.cpp:22:16 in 
/app/example.cpp:38:15: runtime error: load of address 0x7ffce4f8eba0 with insufficient space for an object of type 'float'
0x7ffce4f8eba0: note: pointer points here
 00 00 00 40  b0 b5 34 d6 08 56 00 00  83 e0 48 0e 86 7f 00 00  00 00 00 00 00 00 00 00  98 ec f8 e4

Possible failure mode if it weren't safe

In a different case where the standard didn't define the behaviour (e.g. in a non standard-layout class, or maybe if there was some other member before x), the most likely way for it to break would be that after inlining, the compiler would conclude that the only possible idx value is 0, and not actually do runtime-variable indexing at all. And optimize away earlier and later computations that led to or use idx. (But UB can cause arbitrary breakage if a compiler doesn't define the behaviour, at least de-facto for the current compiler version.)

It's not strict-aliasing UB. You're not accessing an int object through a float* or something like that. Both objects are float, the only potential problem is deriving a float* to y from a separate object x which merely happens to be located next to it. It's legal to create a one-past-the-end pointer to any object, including a scalar float, but it's not in general legal to deref it. We have to look to other rules to justify this. gcc -fno-strict-aliasing wouldn't make things legal if there had been a problem.

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
  • Is it legal to derive a pointer to a structure given a pointer to a known member and the known offset of that member? – Neil Oct 01 '22 at 09:15
  • @Neil: I don't know. I'm guessing not, but I should probably have checked for existing Q&As about that before writing this answer. :P GCC does apparently allow it, but that might not be what ISO C++ says. If I have time and curiosity later, I'll check, if nobody else gets around to it first. – Peter Cordes Oct 01 '22 at 09:18
  • @Neil No, but it works in practice. – Passer By Oct 01 '22 at 11:53
  • 1
    @PasserBy Well yes, but then so does the OP's original code... – Neil Oct 01 '22 at 12:04
  • 1
    @Neil: I checked, it's safe in C++. Rewrote my answer and moved some stuff around. – Peter Cordes Oct 01 '22 at 22:35
  • @PasserBy: According to the ISO C++ standard, it *is* safe. – Peter Cordes Oct 01 '22 at 22:35
  • 1
    First member, yes. Not _any_ member, which is the entire point of `offsetof`. Doing pointer arithmetic on anything other than arrays are very explicitly UB. The case for `char*` is somewhat questionable, but for `float*` there is no doubt. – Passer By Oct 01 '22 at 23:16
  • @PasserBy: Oh right, I had forgotten the exact phrasing on Neil's comment when I got around to checking. Yes, the first member is special in at least some way. (I haven't tried to check if `(char*)&y - offsetof(T,y)` would be a valid way to get a pointer to the start of the object. I suspect it might be.) Do you have a reference handy for `(float*)this + 1` not being equivalent to `(float*)((char*)this + 4)`, given `sizeof(float)==4`, if you're asserting that my current answer is wrong about it being safe to skip that step? – Peter Cordes Oct 01 '22 at 23:38
  • 1
    Fortunately in this case it only needs to work for the first member! – Neil Oct 01 '22 at 23:43
  • 3
    [This](https://timsong-cpp.github.io/cppwp/n4868/expr.add#4) and [this](https://stackoverflow.com/questions/47498585/is-adding-to-a-char-pointer-ub-when-it-doesnt-actually-point-to-a-char-arr). There's an absurd amount of arguments made on the second point if you go looking. – Passer By Oct 01 '22 at 23:55
  • @PeterCordes Wow -- I really appreciate how you continued to update this answer even after it was accepted! Thanks for the detailed explanation. – user20126994 Oct 02 '22 at 01:57
  • @PasserBy: Thanks, updated to be less cavalier about using `float*` math. My answer now shows a maximum safety way using only `char*` math. Agreed that the wording in the standard itself certainly looks like it disallows `float*` math in a case like this. – Peter Cordes Oct 02 '22 at 02:40
  • 6
    This is an amazing answer, but I don't entirely agree with this bit: "presence of a warning in one case and lack of it in another does confirm that the compiler cares about a difference, and that's where the threshold is." GCC has had hundreds of contributors over several decades, and I think it's very possible for *one* person to write logic that detects and warns about one case but not the other while *another* person writes logic that treats both cases as UB and subjects them to an optimization that can break everything without warning. – ruakh Oct 02 '22 at 03:57
  • @ruakh: Yeah, that's true. At least in this case, *some* parts of GCC's internals involved in this are treating it as well-defined, and as an object of size 8. But yes there could be something else somewhere that uses different logic on the program state. I'll add a caveat – Peter Cordes Oct 02 '22 at 04:00
  • 3
    i think this is somehow relevant: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p1912r1.pdf – Carsten S Oct 02 '22 at 12:48
  • @CarstenS: Yeah, thanks. That seems to indicate that ISO C++'s current wording doesn't actually allow some things the committee wants it to, such as iterating over the bytes of an object directly instead of with `memcmp` into a byte array, or for using values from `offsetof`. But that compilers *do* support this stuff in practice, and are even required to for `std::complex`. – Peter Cordes Oct 02 '22 at 16:54
  • @PeterCordes: The authors of the original C and C++ Standards expected that in cases where a construct would be defined by some parts of the Standard but categorized as UB by others, implementations intended for various purposes would seek to process it in whatever manner would best suit those purposes; the only time it would matter whether the Standard characterized an otherwise-defined action as UB would be if deviating from the otherwise-defined behavior could make an implementation more useful. – supercat Oct 02 '22 at 20:52
22

The reality is that the type punning solution has been used successfully in C for ages. The problem is that it is fragile, and that C++ is not C — additional problems arise that you may not account for.

For a solution you might find palatable, I suggest reference accessors:

#include <iostream>
#include <stdexcept>

struct point
{
  double xy[2];

  double & x() { return xy[0]; }
  double & y() { return xy[1]; }

  double const & x() const { return xy[0]; }
  double const & y() const { return xy[1]; }

  double       & operator [] ( std::size_t n )       { return xy[n]; }
  double const & operator [] ( std::size_t n ) const { return xy[n]; }
};

int main()
{
  point p{ 2, 3 };
  
  std::cout << p[0] << ", " << p.x() << "\n";
  std::cout << p[1] << ", " << p.y() << "\n";
  
  p[0]  = 5;
  p.y() = 7;
  std::cout << p[0] << ", " << p.x() << "\n";
  std::cout << p[1] << ", " << p.y() << "\n";
  
  auto f = []( const point & p )
  {
#if 0
    p[0]  = 11;  // won't compile
    p.y() = 13;  // won't compile
#endif
    std::cout << p[0] << ", " << p.x() << "\n";
    std::cout << p[1] << ", " << p.y() << "\n";
  };
  f( p );
}

That compiles very cleanly.


You might be tempted to just use references directly:

struct point
{
  double xy[2];
  double & x;  // DON’T DO THIS
  double & y;  // DON’T DO THIS

  point() : x{xy[0]}, y{xy[0]} { }
  point( double x, double y ) : x{xy[0]=x}, y{xy[1]=y} { }
};

The problem with this last approach is that it breaks const guarantees. That is, even if you have a const point somewhere, you could still modify it through the references.

void f( const point & p )
{
  p[0] = 97;          // compiler complains properly
  p.y  = 3.14159265;  // compiler blithely accepts this
}

Beyond that, it also breaks a lot of other things. See Ben Voight’s comment below.

Hence, DON’T DO THAT. Use reference accessor methods as I suggest above.

Dúthomhas
  • 8,200
  • 2
  • 17
  • 39
  • 7
    the bigger problem with the latter approach is that it changes the size of the struct – Sopel Sep 30 '22 at 17:35
  • 2
    This isn't type-punning; at no point is there a C array object, just scalar floats and a pointer to one of them. And using that pointer to access one-past-the-end of a single float, where there happens to be another float. I don't think it's a strict-aliasing violation, I think it's a different kind of UB, out-of-bounds access and deriving a pointer to one object from another object in a not fully safe way. Deriving the pointer from the struct object *would* be safe, like you can do with a pointer and `offsetof`. – Peter Cordes Sep 30 '22 at 20:36
  • 1
    The first member has the same address as the whole struct, so it's possible to write the same code in a different way that is safe. – Peter Cordes Sep 30 '22 at 20:38
  • 1
    @PeterCordes: C++ at least makes a scalar object fully act like an array of length 1. Forming a pointer one-past-the-end is completely legal. Then one would get into strict vs relaxed pointer safety... but IIRC that's been removed from the language specification. – Ben Voigt Sep 30 '22 at 21:27
  • 4
    An even more serious problem with the non-static data members of reference type (which in fairness, you do recommend against) is that they occupy storage just like a pointer would, changing the size and alignment of the structure. And your copy assignment, move assignment, copy constructor, move constructor will all default to disabled. No more standard-layout. No more trivial or POD. And the compiler likely won't be able to prove that `x` and `y` point inside the object in all cases, so de-optimization everywhere. – Ben Voigt Sep 30 '22 at 21:29
  • 7
    @Sopel: And makes it non-trivially-copyable; if you memcpy it, the references in the copy will be to the members of the original struct! – Peter Cordes Sep 30 '22 at 21:31
  • 1
    @PeterCordes I was inspired by your comment to whip something up using C++ member pointers: https://godbolt.org/z/T4Efrzr3o. It's a lot closer to the assembly the hacky solution emits, but it's still worse. I feel like at some level this is a failure of the compilers: they should really recognize that the assembly can be optimized more. – user20126994 Sep 30 '22 at 21:40
  • 2
    @user20126994: I *think* it's legal to do `reinterpret_cast(this) + 4*idx` to get a pointer to the member, then cast that to `float*` and deref. An `int` doesn't have an identity, it's not "derived from" anywhere, so you don't need to make an array of `offsetof` values, just `static_assert(offsetof(V2,y) == 4)` to check that `0` and `4` are the correct offsets from the start of the struct. That's the "write the same logic in a way ISO C and C++ do allow" I was talking about before, compiling to identical asm. – Peter Cordes Sep 30 '22 at 21:46
  • @PeterCordes For some reason I was under the impression that such a cast was only legal in C -- if that's not the case, then this is a wonderful solution! Thanks! – user20126994 Sep 30 '22 at 21:55
  • 4
    @BenVoigt I debated even mentioning the reference members version just because of how much of a BAD IDEA it was... but ultimately considered it worth mentioning just because it pops so easily to mind. I’m kind of glad to see all the comments about how horrible it is, lol. I’ll update the answer with reference to your commentary. :O) – Dúthomhas Sep 30 '22 at 23:43
  • @user20126994: Updated my answer; turns out is *is* well-defined to cast a pointer to the first member to a pointer to the whole struct. That means the code is the question is as safe as what I was suggesting starting with `this`. The answers stating it's unsafe without supporting that assertion are wrong, unless there's something I'm mising. – Peter Cordes Oct 01 '22 at 22:37
  • @PeterCordes You aren’t missing anything. – Dúthomhas Oct 02 '22 at 04:52
9

The Most Important Thing

This code does C-style memory access—with no bounds checking. It accepts any size_t as input. This is a buffer overflow bug waiting to happen. It’s wrapped in a class operator where it’s not obvious that it’s unsafe to expose.

Always, always, always bounds-check your array accesses in C and C++.

Now, on to Your Question

No, it’s not guaranteed to work, portably. Others have quoted the Standard. Some things that might break it are: inserting padding between members of the aggregate, putting members in a different order than you expect (especially in a complex class), or violating the assumptions the optimizer makes about whether pointers are allowed to be aliases.

That having been said, some compilers do specify the exact layout of their structures (such as IBM’s z/OS compiler saying that the members are by default naturally-aligned), or provide a directive such as #pragma pack that allows the programmer to specify the exact offset of each member of the struct.

It’s very unlikely that any real-world compiler is going to break code like that, however—especially if there is a standard ABI for that platform which another layout would break. You are not type-punning at all, but accessing a float through a float*. Generally, adding 0 or 1 to an address is legal in C, because &x can be treated as a pointer to a singleton array and (&x)+1 as an end pointer of that array, but dereferencing (&x)+1 might break. Some implementation might represent this pointer in a way you didn’t expect (for instance, as a fat pointer), or an optimizer might assume that the pointer will never be dereferenced and generate code that breaks if it is.

What You Might Do Instead

Seriously consider replacing your individually-named data members with an array, especially once you’re getting up to x, y, z and w.

If you cannot change the representation of the singleton variables, but you need code that conforms to the language standard, that is possible.

A switch block whose default: block throws an exception, or a bounds check followed by nested ternary expressions, would still work on some oddball implementation that inserted padding between x and y for some reason, and also reject any invalid overflows. A modern compiler should be able to turn this into efficient code. For example, Clang 15.0.0 with any optimization flags can do a decent job with code like this:

return (idx == 0) ? x :
       (idx == 1) ? y :
                    z; 

With only a few options, it can generate a conditional move or even calculate the address with simple pointer arithmetic, similar to array indexing. With wider options, it generates a lookup table.

This is more verbose and maybe overcomplicated, and obviously gets even more so the more members you add, but the code it compiles to isn’t terrible and there’s no undefined or unspecified behavior.

Davislor
  • 14,674
  • 2
  • 34
  • 49
  • 5
    I don't think this answer is entirely correct. Clang 15.0.0 doesn't seem to be able to optimize that: https://godbolt.org/z/KnaEYoM7E. I agree that bounds checking is a good idea (certainly in debug mode), but I disagree that "It’s wrapped in a class operator where it’s not obvious that it’s unsafe to expose." C array indexing is unsafe, and std::array's `operator[]` is explicitly specified *not* to do bounds checking, so I'd reckon that unsafe bounds checks are the status quo. (Also, xyzw are traditionally the first four elements in a graphics-focused vector.) – user20126994 Oct 01 '22 at 01:30
  • 1
    @user20126994 The `[]` operator returns its argument by reference. Change the return type to `float&` or `const float&`, and Clang 15.0.0 does indeed make the optimization. – Davislor Oct 01 '22 at 02:19
  • 1
    It still doesn't seem to work: https://godbolt.org/z/Esz66eKbY – user20126994 Oct 01 '22 at 03:48
  • 2
    "rearranging the members of the aggregate" - that's not allowed by the standard. "It’s very unlikely that any real-world compiler is going to break code like that" - if you mean OP's code, I think it could easily malfunction badly even if there is no padding. The compiler can see that `&x` points to a single `float`, and therefore the only legal value for `idx` is `0`. At every occurrence of `v[i]` in the code where `v` is an `ImVec2`, there is an implicit `__builtin_assume(i==0)`. Even if clang doesn't make use of it now, a later version could. – benrg Oct 01 '22 at 03:52
  • @user20126994 In your latest sample, it definitely does work. It finds the offset to return with `lea rax, [rdi + 4*rax + 4]` and generates branchless code. – Davislor Oct 01 '22 at 05:57
  • 1
    I'm not seeing that. The `lea rax, [rdi + 4*rax + 4]` is for `V3B::operator[]`, which is the one using the `(&x)[idx]` trick. – user20126994 Oct 01 '22 at 07:52
  • 1
    @user20126994 Take another look. Line 18 of the assembly. – Davislor Oct 01 '22 at 08:13
  • 1
    The `lea rax, [rdi + 4*rax + 4]` in the `V3A` function runs with `RAX = (idx!=1)`, not the original `idx`, so it has extra latency to booleanize a compare result as well as extra latency on Intel CPUs for a 3-component LEA to select the right pointer offset, either `rdi + 0 | 4 | 8`. But that's because you're making the selection complicated instead of just using `idx & 1` to select between `x` or `y`. Anyway, this isn't clang using indexing with a single `cmov` to pick `+8` when `idx>2`, it's part of a setne / cmove selection process. (@user20126994). – Peter Cordes Oct 01 '22 at 11:06
  • 1
    @PeterCordes Okay, [here is a more realistic code sample.](https://godbolt.org/z/v4PrzeYhM) clang 15.0.0 with `-O1` actually compiles similar code to bounds-check, then looks up the index in a lookup table. With five possible values, it no longer recognizes that it could optimize away the table, so there is an unnecessary memory load. – Davislor Oct 01 '22 at 20:37
  • @PeterCordes Selecting between [only two possibilities,](https://godbolt.org/z/xxP9srqqE) clang generates conditional branches. – Davislor Oct 01 '22 at 20:43
  • 2
    I disagree with the first paragraph. Indeed, most of the time I would expect `operator[]` to have no bounds checks, the standard library has `at` for that. – Carsten S Oct 02 '22 at 12:50
  • @CarstenS: You could read that sentence as "Always provide a bounds-checked `at` accessor." Whether the bounds checking is named `at()` or in the operator overload is just a fine detail and doesn't make Davislor's observation incorrect. – Ben Voigt Oct 03 '22 at 15:19
  • 2
    @Davislor: "doing pointer arithmetic with offsets within the same struct is legal" It's not actually. Wish it were. Pointer comparison (relational operators such as `<`, `>`, `<=`) within a single complete object is allowed, but arithmetic isn't. I've forgotten that the relational comparison and arithmetic requirements aren't the same myself quite a few times. – Ben Voigt Oct 03 '22 at 15:20
  • @BenVoigt Oh, you’re right. Adding `(&x)+1` would be legal in C, although dereferencing it might not be. I’ll correct. – Davislor Oct 03 '22 at 15:44
  • @BenVoigt That having been said, the Standard does provide `offsetof()`, and any program that uses it assumes that some form of pointer arithmetic on a `struct` is safe. – Davislor Oct 03 '22 at 15:54
  • @Davislor: Ahh, you can do pointer arithmetic throughout the byte representation of a trivially-copyable object, but for that permission to apply you have to be using a narrow character pointer (that is `char*` or `signed char*` or `unsigned char*` or `std::byte*` or a cv-qualified variation on those) – Ben Voigt Oct 03 '22 at 16:06
  • 1
    @BenVoigt And it’s not guaranteed that `offsetof(ImVec2, y) == offsetof(ImVec2, x) + sizeof(float)`, either. We’re starting to see implementations use fat pointers, which might break some old assumptions. – Davislor Oct 03 '22 at 18:22
  • @BenVoigt I do not think that `signed char ` is allowed. – Sebastian Oct 03 '22 at 21:44
  • @Sebastian: What makes you think that? [The object representation is defined in terms of `unsigned char`](https://eel.is/c++draft/basic.types#general-4) but the strict aliasing rule makes it perfectly valid to use any narrow character type to perform the memory access. – Ben Voigt Oct 03 '22 at 21:52
  • Well, "ordinary character type" is the phrase in the newest version, since "narrow character type" has been expanded to include `char8_t`. – Ben Voigt Oct 03 '22 at 22:00
  • @BenVoigt Under Type Aliasing https://en.cppreference.com/w/cpp/language/reinterpret_cast there are exceptions for `std::byte`, `char` and `unsigned char`. – Sebastian Oct 04 '22 at 05:55
  • @Sebastian: And the immediately preceding bullet allows using signed and unsigned variations interchangeably. But cppreference.com is not authoritative anyway. – Ben Voigt Oct 04 '22 at 15:39
  • @BenVoigt The preceding bullet is only about the same DynamicType (of the parameter). I am not quite sure whether it originally is from https://eel.is/c++draft/basic#types.general-2 or https://eel.is/c++draft/basic#life-6.4 in the standard (probably the second one). Both paragraphs do not allow `signed char`, only `unsigned char`, `std::byte` and `char`, which unlike `int` is not automatically the same as `signed char` (`int` is `signed int`). – Sebastian Oct 04 '22 at 18:34