Zero-cost Property in C++

I’ll tell you about one solution that makes more sense as an exercise rather than practical use. The problem statement sounds like this: I want to get property semantics in C++ like in C# and without overhead.

At the beginning there will be the result that I came to, then explanations and conclusions.

By the way, Microsoft compilers have a way to describe property but this is not part of the C++ standard.

I’ll note right away that property turned out with significant restrictions and is more suitable for imitation Swizzling from GLSL. Based on this I will reproduce a small piece vec2 namely property yx which should return the original vector with the swapped fields. Next vec2 I will sometimes call it a container, as a more general case. When I mention property, I mean the field inside the container, that is yx in a specific example.

I use the C++11 standard

Desired behavior:

int main()
{
  vec2 a(1, 2);
  std::cout << "a = " << a.x << " " << a.y << std::endl; // a = 1 2
 
  vec2 b = a.yx;
  std::cout << "b = " << b.x << " " << b.y << std::endl; // b = 2 1

  vec2 c;
  c.yx = a;
  std::cout << "c = " << c.x << " " << c.y << std::endl; // c = 2 1

  vec2 d(3, 4);
  d.yx = d;
  std::cout << "d = " << d.x << " " << d.y << std::endl; // d = 4 3
  return 0;
}

The main trick is to create an empty property class that can retrieve a pointer to the container of which it is a field. The most laconic way turned out to be to make the address yx matched the address vec2. Otherwise, you would have to pass the offset of the property field relative to the container.

The result is a template that knows about its container. He considers his address to be the address of the container. And he also knows methods to get or put value.

template <typename OWNER, 
          typename VALUE,
          VALUE (OWNER::*GETTER)() const,
          OWNER &(OWNER::*SETTER)(const VALUE &)>
class Property final
{
  friend OWNER;

 private:
  Property() = default;            // Можно создать только в OWNER
  Property(Property &&) = delete;  // Нельзя перемещать из OWNER
  Property &operator=(Property &&) = delete;

 public:
  operator VALUE() const
  {
    auto owner = reinterpret_cast<const OWNER *>(this); // <- Ключевой элемент
    return (owner->*GETTER)();
  }

  const OWNER &operator=(const VALUE &value)
  {
    auto owner = reinterpret_cast<OWNER *>(this); // <- Ключевой элемент
    return (owner->*SETTER)(value);
  }
};

About all the problems after the code vec2

struct vec2 final
{
  vec2() = default;
  inline vec2(float both) : x(both), y(both) {}
  inline vec2(float x, float y) : x(x), y(y) {}
  inline vec2(const vec2 &other) : x(other.x), y(other.y) {}
  inline vec2(vec2 &&other) : x(other.x), y(other.y) {}

  vec2 &operator=(const vec2 &other);
  vec2 &operator=(vec2 &&other);

 private:
  vec2 get_yx() const;
  vec2 &set_yx(const vec2 &);

 public:
  union  // <- Ключевой элемент
  {
    // Анонимная структура содержит реальные поля vec2
    struct
    {
      float x;
      float y;
    };
    // Property лежит в начале памяти vec2 благодаря union
    Property<vec2, vec2, &vec2::get_yx, &vec2::set_yx> yx;
  };
};

static_assert(std::is_standard_layout<vec2>::value,
              "The property semantics require standard layout");
static_assert(offsetof(vec2, yx) == 0,
              "The property must have zero offset");
static_assert(std::is_trivially_constructible<vec2>::value,
              "Modify the class to take into account the union");
static_assert(std::is_trivially_destructible<vec2>::value,
              "Modify the class to take into account the union");

inline vec2 &vec2::operator=(const vec2 &other)
{
  x = other.x;
  y = other.y;
  return *this;
}

inline vec2 &vec2::operator=(vec2 &&other)
{
  x = std::move(other.x);
  y = std::move(other.y);
  return *this;
}

inline vec2 vec2::get_yx() const { return vec2(y, x); }

inline vec2 &vec2::set_yx(const vec2 &other)
{
  if (this == &other)
  {
    std::swap(x, y);
    return *this;
  }
  x = other.y;
  y = other.x;
  return *this;
}

What is union used for?

Almost all compilers force the size of an empty structure or class to be 1 byte. Although this is not in the C++ standard, it can, for example, be found in the GCC standard 6.18 Structures with No Members

There is another mechanism for controlling memory allocation for empty structures using the attribute [[no_unique_address]] but when checked with the msvc compiler, empty structures still allocated an additional byte. Without union, this would lead to UB since it would be difficult to predict the property offset. Let's say we have only one property. Its address could depend on the compiler, the bit size of the target platform, and the size of other container fields. All due to memory alignment. There is an option to pass the offset relative to the container to property through a function, but more on that later.

Safety

So. For this to work more or less safely, several conditions must be met. What already not safe.

  1. Property should always be located as a field at the very beginning of the container's memory.

  2. Property cannot be copied or moved. The this pointer must always point to the container.

The protection we managed to install:

  • You can check that peorpety's offset relative to the container is zero, but only after declaring the field.

  • You can reliably check the offset only if the container has a standard layout.

  • Due to the private default Property constructor, it can only be created inside the container.

  • Property does not have a move constructor. So it is bound to the container to keep the container's this and property consistent.

Protection that could not be installed:

  • The Property class could not be limited so that it can only be used as a field. That is, no one will prohibit creating an instance inside any container method, which will lead to UB.

  • Using union, it was possible to achieve consistency between the address of the container and Property on different compilers. But, there is no way to force the class to be designed in this way.

What about zero-cost?

With the O2 optimization parameter, compilers will perfectly inline all calls to Property and get/set methods. Union avoids the allocation of additional memory.

I think we can assume that there are no overheads on the release, but for debugging these are several method calls, which even in a debug assembly is not very pleasant for the basic mathematical structure.

I looked at the disassembled code using the example of a function:

vec2 disassembly_target(vec2 value)
{
  return value.yx;
}

Some disassembled code for several compilers:

MinGW clang 16.0.2 -std=c++11 -O2

disassembly_target(vec2):            # @disassembly_target(vec2)
        mov     rax, rcx
        movsd   xmm0, qword ptr [rdx]           # xmm0 = mem[0],zero
        shufps  xmm0, xmm0, 225                 # xmm0 = xmm0[1,0,2,3]
        movlps  qword ptr [rcx], xmm0
        ret

Everything will be online. Not only the calculation of the pointer to vec2 has been optimized, but also the permutation of values ​​itself. Very good.

x86–64 gcc 14.2 -std=c++11 -O2

disassembly_target(vec2):
        movq    xmm0, QWORD PTR [rsi]
        mov     rax, rdi
        shufps  xmm0, xmm0, 0xe1
        movlps  QWORD PTR [rdi], xmm0
        ret

Also a very good result

x64 msvc v19.40 VS17.10 /std:c++17 /GR- /O2

; Function compile flags: /Ogtpy
;       COMDAT vec2 disassembly_target(vec2)
__$ReturnUdt$ = 8
value$ = 16
vec2 disassembly_target(vec2) PROC          ; disassembly_target, COMDAT
; File C:\Windows\TEMP\compiler-explorer-compiler2024829-3304-1p0myd7.2grh\example.cpp
; Line 34
        mov     eax, DWORD PTR [rdx+4]
        mov     DWORD PTR [rcx], eax
        mov     eax, DWORD PTR [rdx]
        mov     DWORD PTR [rcx+4], eax
; Line 92
        mov     rax, rcx
; Line 93
        ret     0
vec2 disassembly_target(vec2) ENDP          ; disassembly_target

msvc did not want to work with C++11, installed C++17. The compiler worked more straightforwardly, but also well. All important optimizations are in place.

I believe that the conditional zero-cost on release has been achieved.

Is it possible to do without union?

Yes you can. Here is an earlier but working way to describe Property that I was considering

class vec3
{
 public:
  vec3() = default;

  inline const vec2 get_a() const { return vec2(x, y); }

  inline const vec3 &set_a(const vec2 &v)
  {
    y = v.x;
    x = v.y;
    return *this;
  }

 public:
  struct
  {
    inline operator vec2() const
    {
      auto self = reinterpret_cast<const vec3 *>(this - offsetof(vec3, yx));
      return self->get_a();
    }

    inline const vec3 &operator=(const vec2 &v)
    {
      auto self = reinterpret_cast<vec3 *>(this - offsetof(vec3, yx));
      return self->set_a(v);
    }
  } yx;

  float x{};
  float y{};
  float z{};
};

static_assert(std::is_standard_layout<vec3>::value,
              "The property semantics require standard layout");
static_assert(offsetof(vec3, yx) == 0, 
              "The property must have zero offset");

In this example, the pointer to the container is calculated through the offset of the property field inside the container, which imposes the same restrictions on the container's layout. Union is also not used, but this does not mean that an extra byte will be allocated. It all depends on how the compiler deals with memory alignment. But it is not so important where exactly the property is located in memory.

Field yx is not protected, although it needs to be done, and the recording will become even more cumbersome. At the same time, the option with an offset cannot be included in the template in its presented form, since it is impossible to pass the offset of an undefined field to the template parameter. The output can be a function that can be passed as a parameter, and it will return the offset calculated by the same offsetof.

What if you pass the offset to the template as a function?

The following code is shortened. It only has a getter and no protection.

template <typename OWNER,
          typename VALUE,
          VALUE (OWNER::*GETTER)() const,
          ptrdiff_t (*OFFSET)()>
class Getter final
{
 public:
  operator VALUE() const
  {
    auto owner = reinterpret_cast<const OWNER *>(this - OFFSET());
    return (owner->*GETTER)();
  }
};

struct vec4
{
  inline static constexpr ptrdiff_t offsetof_yx() { return offsetof(vec4, yx); }

  vec2 get_yx() const { return vec2(y, x); }

 public:
  float x{};
  float y{};
  float z{};
  float w{};

  Getter<vec4, vec2, &vec4::get_yx, &offsetof_yx> yx;
};

The only way I've found to pass an offset into a template is to pass it as a function. Thanks to linking, it is possible to solve the chicken and egg problem when you need to pass the offset of the same field in a container that has not yet been fully defined into a field type.

Conclusions

The solution is working, but requires paying a lot of attention to security. Not all protections can be encapsulated, so cannot be applied freely in client code. This approach can be used inside mathematical libraries that simulate mathematics from other languages. But it requires test coverage. There may also be edge cases that I couldn't see.

To summarize

Are zero-cost properties possible in C++?

Yes, but with restrictions. Only in the release build and not in the client code, but in the library covered with tests.

Is it worth using this technique?

Not worth it. Use classic getters and setters. The increase in convenience is insignificant relative to the risk of making a mistake and reduces the readability of your code.

Why does this article exist?

In order to share an interesting solution and consider aspects related to it, which in themselves may be useful.


PS It was so funny to write all this on the topic “how to remove two empty parentheses”

a.yx() -> a.yx

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *