Идиоматический способ создания неизменного и эффективного класса в С++
Я хочу сделать что-то вроде этого (С#).
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
Вы действительно хотите неизменные объекты некоторого типа плюс семантика значения (поскольку вы заботитесь о производительности во время выполнения и хотите избежать кучи). Просто определите 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
). Дополнительные функции могут быть предоставлены с помощью бесплатных функций, нет смысла добавлять функции-члены в агрегирование данных только для чтения.
Вы хотите 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
Вас не волнует семантика значений и/или приращения атомного счетчика ссылок в вашем сценарии. Используйте решение, описанное в ответе @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
.