Идиоматический способ создания неизменного и эффективного класса в С++

Я хочу сделать что-то вроде этого (С#).

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 для членов класса, но это означает, что оператор назначения копирования по умолчанию удален.

Решение 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 и возвращая значения, но это означает, что большие объекты придется копировать, что неэффективно, и я хотел бы знать, как этого избежать. Этот поток предлагает решение 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, но это может привести к зависанию ссылок или указателей. В этой теме рассказывается об опасностях ссылок, выходящих за рамки видимости функций.

Решение 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.

Ответы

Ответ 1

  1. Вы действительно хотите неизменные объекты некоторого типа плюс семантика значения (поскольку вы заботитесь о производительности во время выполнения и хотите избежать кучи). Просто определите struct со всеми элементами данных public.

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

    Вы можете создавать и копировать их, читать данные членов, но это об этом. Создание экземпляра Move из ссылки на rvalue другой копии все еще копируется.

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

    Таким образом, вы действительно убедитесь, что каждое использование вашего класса учитывает неизменность (при условии, что никто не делает плохие вещи const_cast). Дополнительные функции могут быть предоставлены с помощью бесплатных функций, нет смысла добавлять функции-члены в агрегирование данных только для чтения.

  2. Вы хотите 1., все еще с семантикой значений, но слегка смягченную (так что объекты больше не являются неизменными), и вы также обеспокоены тем, что вам нужна конструкция move для производительности во время выполнения. Нет способа обойти private члены-данные и функции-члены-получатели:

    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. Вас не волнует семантика значений и/или приращения атомного счетчика ссылок в вашем сценарии. Используйте решение, описанное в ответе @NathanOliver.

Ответ 2

Вы можете получить то, что хотите, используя std::unique_ptr или std::shared_ptr. Если вам нужен только один из этих объектов, но вы можете перемещать его, тогда вы можете использовать std::unique_ptr. Если вы хотите разрешить несколько объектов ("копий"), которые имеют одинаковое значение, вы можете использовать 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;

и затем оба объекта указывают на один и тот же объект, но ни один не может изменить его.

Ответ 3

Неизменяемость в C++ нельзя напрямую сравнить с неизменяемостью в большинстве других популярных языков из-за универсальной семантики C++ значений. Вы должны выяснить, что вы хотите, чтобы слово "неизменный" означало.

Вы хотите иметь возможность назначать новые значения переменным типа OtherImmutableObject. Это имеет смысл, поскольку вы можете сделать это с переменными типа ImmutableObject в С#.

В этом случае самый простой способ получить необходимую семантику - это

struct OtherImmutableObject {
    int i1;
    int i2;
};

Это может выглядеть так, как будто оно изменчиво. В конце концов, вы можете написать

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

Но эффект этой второй строки (без учета параллелизма...) точно такой же, как и эффект

x = OtherImmutableObject{3, x.i2};

так что если вы хотите разрешить присваивание переменным типа OtherImmutableObject, то нет смысла запрещать прямое присваивание членам, так как это не дает никакой дополнительной семантической гарантии; все, что он делает - это замедляет код для той же абстрактной операции. (В этом случае большинство оптимизирующих компиляторов, вероятно, сгенерируют один и тот же код для обоих выражений, но если один из членов был std::string, они могут быть недостаточно умны, чтобы сделать это.)

Обратите внимание, что это поведение в основном каждого стандартного типа в C++, включая int, std::complex, std::string и т.д. Все они изменчивы в том смысле, что вы можете назначать им новые значения, и все неизменяемые в том смысле, что единственное, что вы можете сделать (абстрактно), чтобы изменить их, - это присвоить им новые значения, во многом как неизменные ссылочные типы в С#.

Если вы не хотите использовать эту семантику, вы можете запретить присваивание. Я бы посоветовал сделать это, объявив ваши переменные как const, а не объявив все члены типа как const, потому что это дает вам больше возможностей для использования класса. Например, вы можете создать изначально изменяемый экземпляр класса, создать в нем значение, а затем "заморозить" его, используя только ссылки на него const - как при преобразовании StringBuilder в string, но без накладные расходы на копирование.

(Одна из возможных причин объявить все члены const может заключаться в том, что в некоторых случаях это позволяет улучшить оптимизацию. Например, если функция получает OtherImmutableObject const&, а компилятор не может видеть сайт вызова, он не кешировать значения членов через вызовы другого неизвестного кода небезопасно, поскольку базовый объект может не иметь спецификатора const. Но если фактические члены объявлены как const, то я думаю, что было бы безопасно кешировать ценности.)

Ответ 4

Чтобы ответить на ваш вопрос, вы не создаете неизменные структуры данных в C++, потому что const делает ссылки на весь объект. Нарушения правила становятся видимыми благодаря наличию const_cast s.

Если я могу сослаться на Кевлина Хенни "Мышление вне сектора синхронизации", есть два вопроса о данных:

  • Является ли структура неизменной или изменчивой?
  • Это общедоступный или нет?

Эти вопросы могут быть организованы в хороший стол 2х2 с 4 квадрантами. В параллельном контексте только один квадрант нуждается в синхронизации: совместно изменяемые данные.

Действительно, неизменяемые данные не нужно синхронизировать, потому что вы не можете записать в них, и параллельные чтения в порядке. Неразделенные данные не нужно синхронизировать, потому что только владелец данных может записывать в него или читать из него.

Поэтому вполне допустимо, чтобы структура данных была изменяемой в неразделенном контексте, а преимущества неизменности имеют место только в общем контексте.

IMO, решение, которое дает вам большую свободу, состоит в том, чтобы определить свой класс как для изменчивости, так и для неизменяемости, используя константу только там, где это имеет смысл (данные, которые инициализируются, а затем никогда не изменяются):

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

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

Затем вы можете использовать экземпляры вашего класса C как изменяемые или неизменяемые, что в любом случае выгодно для константности "где это имеет смысл".

Если теперь вы хотите поделиться своим объектом, вы можете упаковать его в умный указатель на const:

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

Используя эту стратегию, ваша свобода охватывает все 3 несинхронизированных сектора, оставаясь в безопасности вне сектора синхронизации. (следовательно, ограничение необходимости сделать вашу структуру неизменной)

Ответ 5

Я бы сказал, что самый идиотский способ таков:

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 a copy
}

Более того, места, в которых нет необходимости мутировать локально, могут определять свои локальные переменные как const:

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

Ответ 6

C++ не совсем в состоянии предопределить класс как неизменный или постоянный.

И в какой-то момент вы, вероятно, придете к выводу, что вам не следует использовать const для учеников в C++. Это просто не стоит раздражений, и, честно говоря, вы можете обойтись без него.

В качестве практического решения я бы попробовал:

typedef class _some_SUPER_obtuse_CLASS_NAME_PLEASE_DONT_USE_THIS { } const Immutable;

чтобы никто не использовал что-либо, кроме Immutable в своем коде.

Ответ 7

Неизменяемые объекты работают намного лучше с семантикой указателя. Поэтому напишите умный неизменный указатель:

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 не связаны с неизменяемыми объектами.

В отличие от общего ptr, он позволяет вам создавать объект напрямую и по умолчанию создает ненулевое состояние. Он все еще может достичь нулевого состояния, будучи перемещенным из. Вы можете создать его в нулевом состоянии, выполнив:

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

и ненулевой int через:

immu<int> immu_int;

или

immu<int> immu_int = 7;

Я добавил полезный служебный метод под названием modify. Модификация дает вам изменяемый экземпляр T для передачи лямбда-функции для модификации перед возвратом в упакованном виде в 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_data, если вы их прошли.

Вот как вы используете .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, и есть дополнительный other_immutable_class в b.r (создан по умолчанию). Обратите внимание, что a не изменяется при создании b.

Вероятно, есть опечатки выше, но я использовал дизайн.

Если вы хотите получить фантазию, вы можете заставить immu поддерживать копирование при записи или модифицировать на месте, если оно уникально. Это сложнее, чем кажется.

Ответ 8

В C++ просто нет * необходимости делать это:

class ImmutableObject {
    const int i1;
    const int i2;
}
ImmutableObject o1:
ImmutableObject o2;
o1 = o2; // Doesn't compile because immutable objects are not mutable.

Если вам нужна изменяемая ссылка на неизменяемый /const объект, вы используете указатель, умный указатель или reference_wrapper. Если вы на самом деле не хотите иметь класс, содержимое которого может быть изменено кем угодно в любое время, что является противоположностью неизменяемого класса.

* Конечно, C++ - это язык, где "нет" не существует. В тех немногих поистине исключительных обстоятельствах вы можете использовать const_cast.