1

I would like to be able to implement immutable data in C++. In short, given a C++ object in which I would like to modify a member variable, instead of modifying that member in place I would like to get a new copy the object with the member being modified.

To address this, I wrote a small template class to represent the immutable field:

template <typename ParentStruct, typename T>
class ImmutableField {
public:

  ParentStruct set(const T& newNalue) const {
    ParentStruct dst = *(getParentPointer());
    reinterpret_cast<ImmutableField<ParentStruct, T>*>((
        reinterpret_cast<uint8_t*>(&dst) + _offsetInParent))->_value = newNalue;
    return dst;
  }

  const T& get() const {
    return _value;
  }

private:
  friend ParentStruct;

  ImmutableField(const ParentStruct* parent, const T& init = T()) : 
    _offsetInParent(reinterpret_cast<const uint8_t*>(this) - reinterpret_cast<const uint8_t*>(parent)),
    _value(init) {}

  const ParentStruct* getParentPointer() const {
    return reinterpret_cast<const ParentStruct*>(
      reinterpret_cast<const uint8_t*>(this) - _offsetInParent);
  }

  T _value;
  int64_t _offsetInParent = 0;
};

As template parameter, it takes the type of the class it would belong to, and the type of the value that it stores. It has two public methods, get and set. Note that set is const and returns a ParentStruct representing the updated version of ParentStruct.

Now I can, for instance, use it to implement a complex number like this:

double sqr(double x) {return x*x;}

struct ComplexNumber {
  ComplexNumber() : real(this), imag(this) {}

  ImmutableField<ComplexNumber, double> real;
  ImmutableField<ComplexNumber, double> imag;

  double abs() const {
    return sqrt(sqr(real.get()) + sqr(imag.get()));
  }
};

And some basic testing suggests that it works:

#define CHECK(X) if (!(X)) {std::cerr << "Check " #X " failed." << std::endl; abort();}

int main() {

  ComplexNumber x = ComplexNumber().real.set(3.0).imag.set(4.0);
  CHECK(x.abs() == 5.0);

  std::cout << "So far, so good!" << std::endl;

  ComplexNumber y = x.real.set(12).imag.set(5);

  CHECK(y.abs() == 13.0);

  std::cout << "It still works!" << std::endl;

  return 0;
}

My question is: Is there a better way to achieve what I am trying to do and is there a library for doing this in a cleaner way? The ImmutableField class seems extremely hacky with lots of reinterpret_cast.

1
  • Why do you need the real.set(3.0) syntax? A using ComplexNumber = const std::complex<double>; is likely better
    – Caleth
    Nov 2, 2018 at 11:28

1 Answer 1

8

While the goal of using immutable types is usually laudable, your specific implementation is excessively clever, involves loads of UB, and will fail for any ParentStruct that is not trivially copyable. Furthermore, your field instances are bound to a specific instance. As these fields must not be copied around, you should at least delete copy constructors and assignment operators. Also note that your fields each have a comparatively huge int64_t sized overhead – they are not a zero-cost abstraction! (Also, you should have probably used ptrdiff_t types rather than explicit integer types.)

Part of your conceptual problem is that C++ is a value-oriented language, not a reference-oriented language like Java. In C++, a variable is the object. A variable cannot be re-assigned to a different object, but we can overwrite the contents of the object with a new value. This has direct impact on how we think about immutability. In particular, having mutable values is not immediately problematic. Where we want to disallow changes to a value through a specific reference (such as a variable name), we can mark that reference const.

C++ already has an acceptable way to create a new object with only one field changed: make a copy, then change that field.

#include <iostream>
#include <cmath>

struct ComplexNumber {
  ComplexNumber() : real(), imag() {}
  ComplexNumber(double real, double imag) : real(real), imag(imag) {}

  double real;
  double imag;

  double abs() const {
    return std::sqrt(real*real + imag*imag);
  }
};

#define CHECK(X) if (!(X)) {std::cerr << "Check " #X << " failed." << std::endl; abort();}

int main() {
  const ComplexNumber x = ComplexNumber(3.0, 4.0);
  CHECK(x.abs() == 5.0);

  std::cout << "So far, so good!" << std::endl;

  ComplexNumber y = x;
  y.real = 12;
  y.imag = 5;

  CHECK(y.abs() == 13.0);

  std::cout << "It still works!" << std::endl;

  return 0;
}

If y is supposed to be const, then we can use an immediately-invoked lambda:

const ComplexNumber y = [&]() {
  ComplexNumber y = x;
  y.real = 12;
  y.imag = 5;
  return y;
}();

(which, thanks to copy elision, does not require a copy of inner-y to the outer variable.)

This pattern is also nicely generalizable so that you could write a macro to define setter-methods:

#define SETTER(type, name) \
  auto with_##name(type value) const { \
    auto copy = *this; \
    copy.name = value; \
    return copy; \
  }

Then: const ComplexNumber y = x.with_real(12).with_imag(5) at the cost of an extra copy.

8
  • Using those type-transformations from <type_traits> is severe overkill for the macro. The verbosity obscures the purpose. Otherwise nice one. Nov 2, 2018 at 11:42
  • @Deduplicator good point, fixed. That overly general approach also led to a silly double const& type. I've changed the macro signature to be invoked as SETTER(double, real).
    – amon
    Nov 2, 2018 at 11:45
  • 1
    @Rulle This is not Stack Overflow, I will not flip through the C++ standard to figure out why exactly your reinterpret-casts are UB (probably in the fine-print of [basic.stc.dynamic.safety]). If the fields are copied or moved, dereferencing the parent pointer may segfault because this is different. If the parent is not trivially copyable then there might be constructors that perform validation. By directly setting the field you are bypassing any encapsulation, and could bring the parent object copy into an invalid state. Arguably the fault of the parent, though, for exposing the field.
    – amon
    Nov 2, 2018 at 12:24
  • 1
    Btw. this expression is UB because of how pointer subtraction is defined: reinterpret_cast<const uint8_t*>(this) - reinterpret_cast<const uint8_t*>(parent) – “if P and Q point to, respectively, elements x[i] and x[j] of the same array object x, the expression P - Q has the value i−j. Otherwise, the behavior is undefined.” ([expr.add]). Here, the pointers do not point to elements of the same array object.
    – amon
    Nov 2, 2018 at 12:37
  • 2
    I think the conclusion is that the my approach to immutability has too many issues, notably regarding UB (e.g. pointer differences being UB) as pointed out in the comment, so I will accept this answer. Thank you!
    – Rulle
    Nov 2, 2018 at 19:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.