41

So I have some pretty extensive functional code where the main data type is immutable structs/classes. The way I have been declaring immutability is "practically immutable" by making member variables and any methods const.

struct RockSolid {
   const float x;
   const float y;
   float MakeHarderConcrete() const { return x + y; }
}

Is this actually the way "we should do it" in C++? Or is there a better way?

BlamKiwi
  • 2,093
  • 2
  • 19
  • 30
  • 2
    It depends much on your desired concept of immutable. Consider Java and C# strings. They're immutable but assignable. – Cheers and hth. - Alf Nov 11 '14 at 06:18
  • 4
    `const` data members has an advantage in that you get an error if you forget to initialize such member of basic type. A disadvantage is that you cannot assign to variables of the type. – Cheers and hth. - Alf Nov 11 '14 at 06:23
  • 1
    A major disadvantage is that you cannot move data out of an object if its members are `const` which is why I'd prefer to make the members private and only provide `const` getters and methods instead as oikosdev suggests – Joe Nov 21 '14 at 21:27

3 Answers3

36

The way you proposed is perfectly fine, except if in your code you need to make assignment of RockSolid variables, like this:

RockSolid a(0,1);
RockSolid b(0,1);
a = b;

This would not work as the copy assignment operator would have been deleted by the compiler.

So an alternative is to rewrite your struct as a class with private data members, and only public const functions.

class RockSolid {
  private:
    float x;
    float y;

  public:
    RockSolid(float _x, float _y) : x(_x), y(_y) {
    }
    float MakeHarderConcrete() const { return x + y; }
    float getX() const { return x; }
    float getY() const { return y; }
 }

In this way, your RockSolid objects are (pseudo-)immutables, but you are still able to make assignments.

chrphb
  • 542
  • 5
  • 5
  • 2
    The semantics I particularly want, at least for this project, are the same as in "900 Pound Functional Languages" like Haskell where any new state has to be explicitly (type) constructed. – BlamKiwi Nov 11 '14 at 20:50
  • 2
    You may consider using the [immutable value–mutable companion idiom](http://martin-moene.blogspot.com/2012/08/growing-immutable-value.html). – Martin Moene Nov 21 '14 at 12:35
  • 4
    @MartinMoene : That "idiom" still builds non-copy-assignable and non-move-assignable value objects (or relies on the heap). oikosdev's solution does not suffer for that drawback, and doesn't need a (companion) boilerplate. It just works... Why complicate everything? – paercebal Nov 23 '14 at 23:22
  • Why suggesting the use of `class`? Even `struct` with `private` member variables will do! – CinCout Dec 10 '15 at 04:51
  • @chrphb : Here is an assertion my me - we did this because "For a type to be CopyAssignable, it must have a public copy assignment operator". Am I thinking right ? – jack_1729 Nov 11 '17 at 07:11
  • I read in this post https://dzone.com/articles/how-to-create-an-immutable-class-in-java that immutable classes cannot be inherited by any other class and that's done by final keyword in Java. On searching, I found that final keyword is also now present in C++; to confirm, shouldn't we use that in the C++ implementation of an immutable class too? – Utkarsh Jun 10 '19 at 10:13
  • 2
    `struct` and `class` are the same thing, with but one defaulting to `public:` and one defaulting to `private:`. Suggesting the use of `class` isn't a difference. Other languages, like C# or D, there is a semantic difference. – Eljay Oct 08 '19 at 11:07
12

I assume your goal is true immutability -- each object, when constructed, cannot be modified. You cannot assign one object over another.

The biggest downside to your design is that it is not compatible with move semantics, which can make functions returning such objects more practical.

As an example:

struct RockSolidLayers {
  const std::vector<RockSolid> layers;
};

we can create one of these, but if we have a function to create it:

RockSolidLayers make_layers();

it must (logically) copy its contents out to the return value, or use return {} syntax to directly construct it. Outside, you either have to do:

RockSolidLayers&& layers = make_layers();

or again (logically) copy-construct. The inability to move-construct will get in the way of a number of simple ways to have optimal code.

Now, both of those copy-constructions are elided, but the more general case holds -- you cannot move your data from one named object to another, as C++ does not have a "destroy and move" operation that both takes a variable out of scope and uses it to construct something else.

And the cases where C++ will implicitly move your object (return local_variable; for example) prior to destruction are blocked by your const data members.

In a language designed around immutable data, it would know it can "move" your data despite its (logical) immutability.

One way to go about this problem is to use the heap, and store your data in std::shared_ptr<const Foo>. Now the constness is not in the member data, but rather in the variable. You can also only expose factory functions for each of your types that returns the above shared_ptr<const Foo>, blocking other construction.

Such objects can be composed, with Bar storing std::shared_ptr<const Foo> members.

A function returning a std::shared_ptr<const X> can efficiently move the data, and a local variable can have its state moved into another function once you are done with it without being able to mess with "real" data.

For a related technique, it is idomatic in less constrained C++ to take such shared_ptr<const X> and store them within a wrapper type that pretends they are not immutable. When you do a mutating operation, the shared_ptr<const X> is cloned and modified, then stored. An optimization "knows" that the shared_ptr<const X> is "really" a shared_ptr<X> (note: make sure factory functions return a shared_ptr<X> cast to a shared_ptr<const X> or this is not actually true), and when the use_count() is 1 instead casts away const and modifies it directly. This is an implementation of the technique known as "copy on write".

Now as C++ has developed, there are more opportunities for elision. Even C++23 is going to have more advanced elision. Elision is when the data isn't logically moved or copied, but just has two different names, one inside a function and one outside.

Relying on that remains awkward.

Yakk - Adam Nevraumont
  • 262,606
  • 27
  • 330
  • 524
1

As of c++20, workarounds with getters is no longer needed.

You can now define your own copy-assignment operator for classes that contain const member objects without undefined behavior as of c++20.

This was undefined behavior prior to c++ and remains so for complete const objects but not non-const objects with const members.

https://stackoverflow.com/a/71848927/5282154

doug
  • 3,840
  • 1
  • 14
  • 18