在 C++ 中创建不可变且高效的 class 的惯用方法

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

我想做这样的事情 (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) {}
}

我遇到的潜在解决方案及其相关问题是:

1.使用 const 作为 class 成员 ,但这意味着默认的复制赋值运算符被删除。

解决方案 1:

struct OtherImmutableObject {
    const int i1;
    const int i2;

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

问题 1:

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

编辑: 这很重要,因为我想在 std::vector 中存储不可变对象,但收到 error: use of deleted function 'OtherImmutableObject& OtherImmutableObject::operator=(OtherImmutableObject&&)

2。使用 get 方法并返回值 ,但这意味着必须复制大对象,这是我想知道如何避免的低效率。 This thread 建议使用 get 解决方案,但它没有解决如何在不复制原始对象的情况下处理传递的非原始对象。

解决方案 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.
}

问题2:不必要的副本效率低下。

3。使用 get 方法并返回 const 引用或 const 指针 但这可能会留下挂起的引用或指针。 This thread 讨论了引用超出函数范围的危险 returns。

解决方案 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; }
}

问题 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

由于从任何 Get 方法返回引用,可能会出现未定义的行为。

你基本上可以通过 std::unique_ptrstd::shared_ptr 得到你想要的。如果您只想要这些对象之一,但允许它四处移动,那么您可以使用 std::unique_ptr。如果要允许多个对象 ("copies") 都具有相同的值,则可以使用 std::shared_Ptr。使用别名来缩短名称并提供工厂功能,它变得非常轻松。这将使您的代码看起来像:

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
}

如果代码更改为使用 shared_ptr,那么您可以执行

second = first;

然后两个对象都指向同一个对象,但都不能修改它。

  1. 您确实需要某种类型加上值语义的不可变对象(因为您关心运行时性能并希望避免堆)。只需定义一个 struct 和所有数据成员 public.

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

    您可以实例化和复制它们,读取数据成员,仅此而已。从另一个实例的右值引用移动构造一个实例仍然复制。

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

    通过这种方式,您确实可以确保 class 的每次使用都尊重不变性(假设没有人做坏事 const_cast)。可以通过自由函数提供附加功能,将成员函数添加到数据成员的只读聚合中没有意义。

  2. 你想要 1.,仍然具有值语义,但稍微放松(这样对象不再是真正不可变的)并且你还担心你需要移动构造运行时性能。无法解决 private 数据成员和 getter 成员函数:

    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;
    };
    

    用法是一样的,但是移动构造确实移动了。

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

    是否允许分配现在由您控制。如果您不需要,只需 = delete 赋值运算符,否则使用编译器生成的运算符或实现您自己的运算符。

    obj3 = obj2; // Ok if not manually disabled
    
  3. 您不关心值语义 and/or 在您的场景中原子引用计数增量是可以的。使用 .

  4. 中描述的解决方案

我想说最惯用的方式是:

struct OtherImmutable {
    int i1;
    int i2;

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

But... that not immutable??

确实如此,但您可以将其作为值传递:

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

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

更妙的是,不需要局部变异的地方可以将其局部变量定义为const:

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

由于 C++ 的通用值语义,无法将 C++ 中的不变性直接与大多数其他流行语言中的不变性进行比较。你必须弄清楚你想要 "immutable" 的意思。

您希望能够为 OtherImmutableObject 类型的变量分配新值。这是有道理的,因为您可以在 C# 中使用 ImmutableObject 类型的变量来做到这一点。

在那种情况下,获得所需语义的最简单方法是

struct OtherImmutableObject {
    int i1;
    int i2;
};

看起来这是可变的。毕竟,你可以写

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

但是第二行的效果(忽略并发...)与

的效果完全一样
x = OtherImmutableObject{3, x.i2};

所以如果你想允许对 OtherImmutableObject 类型的变量赋值,那么禁止直接赋值给成员是没有意义的,因为它不提供任何额外的语义保证;它所做的只是让相同抽象操作的代码变慢。 (在这种情况下,大多数优化编译器可能会为两个表达式生成相同的代码,但如果其中一个成员是 std::string 他们可能不够聪明,无法做到这一点。)

请注意,这基本上是 C++ 中每个标准类型的行为,包括 intstd::complexstd::string 等。从某种意义上说,它们都是可变的,您可以为它们分配新值,并且所有不可变的东西都是不可变的,因为您唯一可以(抽象地)更改它们的事情就是为它们分配新值,就像 C# 中的不可变引用类型一样。

如果您不想要这种语义,您唯一的选择就是禁止赋值。我建议通过将变量声明为 const,而不是将类型的所有成员声明为 const,因为它为您提供了更多使用 [=49= 的选项].例如,您可以创建一个 class 的初始可变实例,在其中构建一个值,然后 "freeze" 通过仅使用 const 对它的引用 - 就像转换 StringBuilderstring,但没有复制它的开销。

(将所有成员声明为 const 的一个可能原因可能是它在某些情况下允许更好的优化。例如,如果函数获得 OtherImmutableObject const&,编译器可以看不到调用站点,在对其他未知代码的调用中缓存成员的值是不安全的,因为底层对象可能没有 const 限定符。但是如果实际成员被声明为 const,那么我认为缓存这些值是安全的。)

C++ 相当 没有能力将 class 预定义为不可变或常量。

并且在某些时候您可能会得出这样的结论:您不应该在 C++ 中将 const 用于 class 成员。这只是不值得的烦恼,老实说,你可以没有它。

作为一个实用的解决方案,我会尝试:

typedef class _some_SUPER_obtuse_CLASS_NAME_PLEASE_DONT_USE_THIS { } const Immutable;

阻止任何人在他们的代码中使用 Immutable 以外的任何东西。

为了回答您的问题,您不会在 C++ 中创建 immutable 数据结构,因为 consting 对整个对象的引用可以解决问题。通过 const_casts.

的存在使违反规则变得可见

如果可以参考Kevlin Henney的"Thinking outside the synchronization quadrant",关于数据有两个问题想请教:

  • 结构是immutable还是mutable?
  • 是共享还是非共享?

这些问题可以安排成一个漂亮的 2x2 table,有 4 个象限。在并发上下文中,只有一个象限需要同步:shared mutable data.

的确,immutable数据不需要同步,因为你不能写入它,并发读取就可以了。非共享数据不需要同步,因为只有数据的所有者才能写入或读取数据。

因此,数据结构在非共享上下文中是 mutable 很好,不变性的好处仅在共享上下文中出现。

IMO,给你最大自由的解决方案是为可变性和不变性定义你的class,只在有意义的地方使用常量(数据被初始化然后永远不会改变):

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

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

然后您可以将 class C 的实例用作 mutable 或 immutable,受益于 constness-where-it-makes-sense无论哪种情况。

如果现在你想分享你的对象,你可以把它打包成一个指向const的智能指针:

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

使用此策略,您的自由跨越整个 3 个非同步象限,同时安全地保持在同步象限之外。 (因此,限制使您的结构 immutable)

的需要

不可变对象在指针语义下工作得更好。所以写一个智能不可变指针:

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)};
  }
};

这利用了 shared_ptr 的大部分实施; shared_ptr 的大部分缺点都不是不可变对象的问题。

与共享指针不同,它允许您直接创建对象,并且默认情况下创建非空状态。它仍然可以通过被移出而达到空状态。您可以通过执行以下操作在空状态下创建一个:

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

和一个非空整数通过:

immu<int> immu_int;

immu<int> immu_int = 7;

我添加了一个有用的实用方法,叫做 modify。 Modify 为您提供 Tmutable 实例以传递给 lambda 进行修改,然后将其打包返回 immu<T>.

具体使用如下:

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))
  {}
};

然后使用immu_data.

访问成员需要 -> 而不是 .,如果你通过它们,你应该检查空 immu_datas。

这里是你如何使用.modify:

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

这就创建了一个b,它的值等于a,只是i加了1,在[=30=中多了一个other_immutable_class ](默认构造)。请注意,a 未通过创建 b.

进行修改

上面可能有错别字,不过我用过这个设计

如果你想花哨一些,你可以 immu 支持写时复制,或者如果唯一则就地修改。这比听起来要难。

手头的问题是从 C# 到 C++ 的错误翻译。在 C++ 中,根本不需要*这样做:

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.

在您的 C# 示例中,您使用的是 class。在 C# 中保存 class 实例的变量实际上只是对垃圾收集对象的引用。 C++ 中最接近的等价物是引用计数智能指针。因此,您的 C# 示例被翻译成 C++ 为:

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.

如果您想要对 immutable/const 对象的可变引用,有多种选择,特别是您可以使用指针 smart pointer, or a reference_wrapper。除非你真的想要一个 class 其内容可以被任何人随时更改,这与不可变的 class 相反。


*当然,C++是一门“不”不存在的语言。在极少数真正特殊的情况下,您可以使用 const_cast.