Idiomatic way to create an immutable and efficient class in C++

Immutability in C++ can't be directly compared to immutability in most other popular languages because of C++'s universal value semantics. You have to figure out what you want "immutable" to mean.

You want to be able to assign new values to variables of type OtherImmutableObject. That makes sense, since you can do that with variables of type ImmutableObject in C#.

In that case, the simplest way to get the semantics you want is

struct OtherImmutableObject {
    int i1;
    int i2;
};

It may look like this is mutable. After all, you can write

OtherImmutableObject x{1, 2};
x.i1 = 3;

But the effect of that second line is (ignoring concurrency...) exactly the same as the effect of

x = OtherImmutableObject{3, x.i2};

so if you want to allow assignment to variables of type OtherImmutableObject then it makes no sense to disallow direct assignment to members, since it doesn't provide any additional semantic guarantee; all it does is make the code for the same abstract operation slower. (In this case, most optimizing compilers will probably generate the same code for both expressions, but if one of the members was a std::string they might not be smart enough to do that.)

Note that this is the behavior of basically every standard type in C++, including int, std::complex, std::string, etc. They are all mutable in the sense that you can assign new values to them, and all immutable in the sense that the only thing you can do (abstractly) to change them is assign new values to them, much like immutable reference types in C#.

If you don't want that semantics, your only other option is to forbid assignment. I would advise doing that by declaring your variables to be const, not by declaring all the members of the type to be const, because it gives you more options for how you can use the class. For example, you can create an initially mutable instance of the class, build a value in it, then "freeze" it by using only const references to it thereafter – like converting a StringBuilder to a string, but without the overhead of copying it.

(One possible reason to declare all members to be const might be that it allows for better optimization in some cases. For example, if a function gets an OtherImmutableObject const&, and the compiler can't see the call site, it isn't safe to cache the values of members across calls to other unknown code, since the underlying object may not have the const qualifier. But if the actual members are declared const, then I think it would be safe to cache the values.)


  1. You truly want immutable objects of some type plus value semantics (as you care about runtime performance and want to avoid the heap). Just define a struct with all data members public.

    struct Immutable {
        const std::string str;
        const int i;
    };
    

    You can instantiate and copy them, read data members, but that's about it. Move-constructing an instance from an rvalue reference of another one still copies.

    Immutable obj1{"...", 42};
    Immutable obj2 = obj1;
    Immutable obj3 = std::move(obj1); // Copies, too
    
    obj3 = obj2; // Error, cannot assign
    

    This way, you really make sure every usage of your class respects the immutability (assuming no one does bad const_cast things). Additional functionality can be provided through free functions, there is no point in adding member functions to a read-only aggregation of data members.

  2. You want 1., still with value semantics, but slightly relaxed (such that the objects aren't really immutable anymore) and you're also concerned that you need move-construction for the sake of runtime performance. There is no way around private data members and getter member functions:

    class Immutable {
       public:
          Immutable(std::string str, int i) : str{std::move(str)}, i{i} {}
    
          const std::string& getStr() const { return str; }
          int getI() const { return i; }
    
       private:
          std::string str;
          int i;
    };
    

    Usage is the same, but the move construction really does move.

    Immutable obj1{"...", 42};
    Immutable obj2 = obj1;
    Immutable obj3 = std::move(obj1); // Ok, does move-construct members
    

    Whether you want assignment to be allowed or not is under your control now. Just = delete the assignment operators if you don't want it, otherwise go with the compiler-generated one or implement your own.

    obj3 = obj2; // Ok if not manually disabled
    
  3. You don't care about value semantics and/or atomic reference count increments are ok in your scenario. Use the solution depicted in @NathanOliver's answer.


You can basically get what you want by leveraging a std::unique_ptr or std::shared_ptr. If you only want one of these objects, but allow for it to be moved around, then you can use a std::unique_ptr. If you want to allow for multiple objects ("copies") that all have the same value, then you can use a std::shared_Ptr. Use an alias to shorten the name and provide a factory function and it becomes pretty painless. That would make your code look like:

class ImmutableClassImpl {
public: 
    const int i;
    const OtherImmutableClass o;
    const ReadOnlyCollection<OtherImmutableClass> r;

    public ImmutableClassImpl(int i, OtherImmutableClass o, 
        ReadOnlyCollection<OtherImmutableClass> r) : i(i), o(o), r(r) {}
}

using Immutable = std::unique_ptr<ImmutableClassImpl>;

template<typename... Args>
Immutable make_immutable(Args&&... args)
{
    return std::make_unique<ImmutableClassImpl>(std::forward<Args>(args)...);
}

int main()
{
    auto first = make_immutable(...);
    // first points to a unique object now
    // can be accessed like
    std::cout << first->i;
    auto second = make_immutable(...);
    // now we have another object that is separate from first
    // we can't do
    // second = first;
    // but we can transfer like
    second = std::move(first);
    // which leaves first in an empty state where you can give it a new object to point to
}

If the code is changes to use a shared_ptr instead then you could do

second = first;

and then both objects point to the same object, but neither can modify it.