简体   繁体   中英

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

I am looking to do something like this (C#).

public final class ImmutableClass {
    public readonly int i;
    public readonly OtherImmutableClass o;
    public readonly ReadOnlyCollection<OtherImmutableClass> r;

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

The potential solutions and their associated problems I've encountered are:

1. Using const for the class members , but this means the default copy assignment operator is deleted.

Solution 1:

struct OtherImmutableObject {
    const int i1;
    const int i2;

    OtherImmutableObject(int i1, int i2) : i1(i1), i2(i2) {}
}

Problem 1:

OtherImmutableObject o1(1,2);
OtherImmutableObject o2(2,3);
o1 = o2; // error: use of deleted function 'OtherImmutableObject& OtherImmutableObject::operator=(const OtherImmutableObject&)`

EDIT: This is important as I would like to store immutable objects in a std::vector but receive error: use of deleted function 'OtherImmutableObject& OtherImmutableObject::operator=(OtherImmutableObject&&)

2. Using get methods and returning values , but this means that large objects would have to be copied which is an inefficiency I'd like to know how to avoid. This thread suggests the get solution, but it doesn't address how to handle passing non-primitive objects without copying the original object.

Solution 2:

class OtherImmutableObject {
    int i1;
    int i2;
public:
    OtherImmutableObject(int i1, int i2) : i1(i1), i2(i2) {}
    int GetI1() { return i1; }
    int GetI2() { return i2; }
}

class ImmutableObject {
    int i1;
    OtherImmutableObject o;
    std::vector<OtherImmutableObject> v;
public:
    ImmutableObject(int i1, OtherImmutableObject o,
        std::vector<OtherImmutableObject> v) : i1(i1), o(o), v(v) {}
    int GetI1() { return i1; }
    OtherImmutableObject GetO() { return o; } // Copies a value that should be immutable and therefore able to be safely used elsewhere.
    std::vector<OtherImmutableObject> GetV() { return v; } // Copies the vector.
}

Problem 2: The unnecessary copies are inefficient.

3. Using get methods and returning const references or const pointers but this could leave hanging references or pointers. This thread talks about the dangers of references going out of scope from function returns.

Solution 3:

class OtherImmutableObject {
    int i1;
    int i2;
public:
    OtherImmutableObject(int i1, int i2) : i1(i1), i2(i2) {}
    int GetI1() { return i1; }
    int GetI2() { return i2; }
}

class ImmutableObject {
    int i1;
    OtherImmutableObject o;
    std::vector<OtherImmutableObject> v;
public:
    ImmutableObject(int i1, OtherImmutableObject o,
        std::vector<OtherImmutableObject> v) : i1(i1), o(o), v(v) {}
    int GetI1() { return i1; }
    const OtherImmutableObject& GetO() { return o; }
    const std::vector<OtherImmutableObject>& GetV() { return v; }
}

Problem 3:

ImmutableObject immutable_object(1,o,v);
// elsewhere in code...
OtherImmutableObject& other_immutable_object = immutable_object.GetO();
// Somewhere else immutable_object goes out of scope, but not other_immutable_object
// ...and then...
other_immutable_object.GetI1();
// The previous line is undefined behaviour as immutable_object.o will have been deleted with immutable_object going out of scope

Undefined behaviour can occur due to returning a reference from any of the Get methods.

  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.

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.)

To answer your question, you don't create immutable data structures in C++ because const ing references to the whole object does the trick. Violations of the rule are made visible by the presence of const_cast s.

If I may refer to Kevlin Henney's "Thinking outside the synchronization quadrant", there are two questions to ask about data:

  • Is a structure immutable or mutable?
  • Is it shared or unshared?

These questions can be arranged into a nice 2x2 table with 4 quadrants. In a concurrent context, only one quadrant needs synchronization: shared mutable data.

Indeed, immutable data need not be synchronized because you cannot write to it, and concurrent reads are fine. Unshared data needs not be synchronized, because only the owner of the data can write to it or read from it.

So it is fine for a data structure to be mutable in an unshared context, and the benefits of immutability occur only in a shared context.

IMO, the solution that gives you most freedom is to define your class for both mutability and immutability, using constness only where it makes sense (data that is initalized then never changed):

/* const-correct */ class C {
   int f1_;
   int f2_;

   const int f3_; // Semantic constness : initialized and never changed.
};

You can then use instances of your class C either as mutable or immutable, benefitting of constness-where-it-makes-sense in either case.

If now you want to share your object, you can pack it in a smart pointer to const :

shared_ptr<const C> ptr = make_shared<const C>(f1, f2, f3);

Using this strategy, your freedom spans the whole 3 unsynchronized quandrants while staying safely out of the synchronization quadrant. (therefore, limiting the need of making your structure immutable)

I'd say the most idiomatic way would be that:

struct OtherImmutable {
    int i1;
    int i2;

    OtherImmutable(int i1, int i2) : i1(i1), i2(i2) {}
};

But... that not immutable??

Indeed but you can pass it around as a value:

void frob1() {
    OtherImmutable oi;
    oi = frob2(oi);
}

auto frob2(OtherImmutable oi) -> OtherImmutable {
    // cannot affect frob1 oi, since it's a copy
}

Even better, places that don't need to mutate locally can define its local variables as const:

auto frob2(OtherImmutable const oi) -> OtherImmutable {
    return OtherImmutable{oi.i1 + 1, oi.i2};
}

C++ doesn't quite have the ability to predefine a class as immutable or const.

And at some point you'll probably come to the conclusion that you shouldn't use const for class members in C++. It's just not worth the annoyances, and honestly you can do without it.

As a practical solution, I would try:

typedef class _some_SUPER_obtuse_CLASS_NAME_PLEASE_DONT_USE_THIS { } const Immutable;

to discourage anyone from using anything but Immutable in their code.

Immutable objects work much better with pointer semantics. So write a smart immutable pointer:

struct immu_tag_t {};
template<class T>
struct immu:std::shared_ptr<T const>
{
  using base = std::shared_ptr<T const>;

  immu():base( std::make_shared<T const>() ) {}

  template<class A0, class...Args,
    std::enable_if_t< !std::is_base_of< immu_tag_t, std::decay_t<A0> >{}, bool > = true,
    std::enable_if_t< std::is_construtible< T const, A0&&, Args&&... >{}, bool > = true
  >
  immu(A0&& a0, Args&&...args):
    base(
      std::make_shared<T const>(
        std::forward<A0>(a0), std::forward<Args>(args)...
      )
    )
  {}
  template<class A0, class...Args,
    std::enable_if_t< std::is_construtible< T const, std::initializer_list<A0>, Args&&... >{}, bool > = true
  >
  immu(std::initializer_list<A0> a0, Args&&...args):
    base(
      std::make_shared<T const>(
        a0, std::forward<Args>(args)...
      )
    )
  {}

  immu( immu_tag_t, std::shared_ptr<T const> ptr ):base(std::move(ptr)) {}
  immu(immu&&)=default;
  immu(immu const&)=default;
  immu& operator=(immu&&)=default;
  immu& operator=(immu const&)=default;

  template<class F>
  immu modify( F&& f ) const {
    std::shared_ptr<T> ptr;
    if (!*this) {
      ptr = std::make_shared<T>();
    } else {
      ptr = std::make_shared<T>(**this);
    }
    std::forward<F>(f)(*ptr);
    return {immu_tag_t{}, std::move(ptr)};
  }
};

This leverages shared_ptr for most of its implementation; most of the disadvantages of shared_ptr are not a problem with immutable objects.

Unlike shared ptr, it permits you to create the object directly, and by default creates a non-null state. It can still reach a null state by being moved-from. You can create one in a null state by doing:

immu<int> immu_null_int{ immu_tag_t{}, {} };

and a non-null int via:

immu<int> immu_int;

or

immu<int> immu_int = 7;

I added a useful utility method called modify . Modify gives you a mutable instance of the T to pass to a lambda to modify before it is returned packaged up in an immu<T> .

Concrete use looks like:

struct data;
using immu_data = immu<data>;
struct data {
  int i;
  other_immutable_class o;
  std::vector<other_immutable_class> r;
  data( int i_in, other_immutable_class o_in, std::vector<other_immutable_class> r_in ):
    i(i_in), o(std::move(o_in)), r( std::move(r_in))
  {}
};

Then use immu_data .

Accessing members requires -> not . , and you should check for null immu_data s if you are passed them.

Here is how you use .modify :

immu_data a( 7, other_immutable_class{}, {} );
immu_data b = a.modify([&](auto& b){ ++b.i; b.r.emplace_back() });

This creates a b whose value is equal to a , except i is incremented by 1, and there is an extra other_immutable_class in br (default constructed). Note that a is unmodified by creating b .

There are probably typos above, but I've used the design.

If you want to get fancy, you can make immu support copy-on-write, or modify-in-place if unique. It is harder than it sounds though.

The issue at hand is a mistranslation from C# to C++. In C++ there is simply no* need to do this:

class ImmutableObject {
    ImmutableObject(int i1, int i2) : i1(i1), i2(i2) {}
    const int i1;
    const int i2;
}
ImmutableObject o1(1,2):
ImmutableObject o2(2,3);
o1 = o2; // Doesn't compile, because immutable objects are by definition not mutable.

In your C# example you are using a class. And a variable that holds an instance of a class in C# is really just a reference to a garbage collected object. The closest equivalent in C++ is a reference counted smart pointer. So your c# example is translated to C++ as:

class ImmutableObject {
    ImmutableObject(int i1, int i2) : i1(i1), i2(i2) {}
    const int i1;
    const int i2;
}
std::shared_ptr<ImmutableObject> o1 = std::make_shared<ImmutableObject>(1,2);
std::shared_ptr<ImmutableObject> o2 = std::make_shared<ImmutableObject>(2,3);
o1 = o2; // Does compile because shared_ptr is mutable.

There are several options if you want a mutable reference to an immutable/const object, specifically you can use a pointer, a smart pointer , or a reference_wrapper . Unless you actually want to have a class whose content can be changed by anyone at any time, which is the opposite of an immutable class.


*Of course, C++ is a language where "no" doesn't exist. In those precious few truly exceptional circumstances you can use const_cast .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM