Об'єкти життєвих інваріантів проти семантики переміщення


13

Коли я давно вивчив C ++, мені було наголошено, що частина пункту C ++ полягає в тому, що так само, як петлі мають "петлю-інваріанти", класи також мають інваріанти, пов'язані з життям об'єкта - речі, які повинні бути правдивими доки об’єкт живий. Речі, які повинні бути встановлені конструкторами, а збережені методами. Інкапсуляція / контроль доступу є для того, щоб допомогти вам застосувати інваріанти. RAII - це одне, що ви можете зробити з цією ідеєю.

Оскільки C ++ 11, тепер ми маємо семантику переміщення. Для класу, який підтримує переміщення, переміщення з об'єкта формально не закінчує його життєвий час - цей крок повинен залишити його в якомусь "дійсному" стані.

При проектуванні класу це погана практика, якщо ви проектуєте його так, щоб інваріанти класу зберігалися лише до того моменту, з якого він переміщений? Або це нормально, якщо це дозволить вам змусити його йти швидше.

Щоб зробити це конкретним, припустімо, у мене є тип копіюваного ресурсу, який не можна скопіювати, але так:

class opaque {
  opaque(const opaque &) = delete;

public:
  opaque(opaque &&);

  ...

  void mysterious();
  void mysterious(int);
  void mysterious(std::vector<std::string>);
};

І з будь-якої причини мені потрібно зробити обгортальну копію для цього об’єкта, щоб вона могла бути використана, можливо, в якійсь існуючій системі відправки.

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { o_->mysterious(); }
  void operator()(int i) { o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { o_->mysterious(v); }
};

У цьому copyable_opaqueоб'єкті інваріантом класу, встановленого при будівництві, є те, що член o_завжди вказує на дійсний об'єкт, оскільки немає ctor за замовчуванням, і єдиний ctor, який не є копіюючим ctor, гарантує це. Усі operator()методи припускають, що цей інваріант дотримується, і зберігають його згодом.

Однак, якщо об’єкт переміщено з, то він o_буде вказувати ні на що. І після цього, виклик будь-якого з методів operator()спричинить UB / збій.

Якщо об'єкт ніколи не переміщується, інваріант буде збережений аж до виклику dtor.

Припустимо, що я гіпотетично написав цей клас, і через кілька місяців мій уявний колега випробував UB, оскільки в якійсь складної функції, де багато цих предметів чомусь перетасовувались, він перейшов від однієї з цих речей і пізніше назвав одну з її методи. Зрозуміло, це його вина в кінці дня, але чи цей клас "погано розроблений?"

Думки:

  1. Зазвичай у C ++ погана форма створювати зомбі-об’єкти, які вибухають, якщо торкнутися їх.
    Якщо ви не можете побудувати якийсь об'єкт, не зможете встановити інваріанти, тоді киньте виняток із ctor. Якщо ви не можете зберегти інваріанти в якомусь методі, тоді якось сигналізуйте про помилку і відкатуйтесь. Чи повинно це відрізнятися для об'єктів, що переміщуються?

  2. Чи достатньо лише задокументувати "після переміщення цього об'єкта, заборонено (UB) робити з ним що-небудь, крім знищення" в заголовку?

  3. Чи краще постійно твердити, що він дійсний у кожному виклику методу?

Так:

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { assert(o_); o_->mysterious(); }
  void operator()(int i) { assert(o_); o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { assert(o_); o_->mysterious(v); }
};

Твердження істотно не покращують поведінку, і вони викликають уповільнення. Якщо ваш проект використовує схему "build build / debug build", а не завжди працює з твердженнями, я думаю, що це привабливіше, оскільки ви не платите за чеки в складанні випусків. Якщо ви фактично не маєте налагоджень, це здається зовсім непривабливим.

  1. Чи краще зробити клас копіюваним, але не рухомим?
    Це також здається поганим і спричиняє хіт продуктивності, але це вирішує "інваріантне" питання прямо.

Що б ви вважали відповідними "найкращими практиками" тут?



Відповіді:


20

Зазвичай у C ++ погана форма створювати зомбі-об’єкти, які вибухають, якщо торкнутися їх.

Але це не те, що ти робиш. Ви створюєте "зомбі-об'єкт", який вибухне, якщо доторкнутися до нього неправильно . Що в кінцевому рахунку не відрізняється від будь-якої іншої передумови, заснованої на державі.

Розглянемо наступну функцію:

void func(std::vector<int> &v)
{
  v[0] = 5;
}

Чи безпечна ця функція? Ні; користувач може передати порожній vector . Отже, функція має фактичну передумову, яка vмістить у собі хоча б один елемент. Якщо це не так, ви отримуєте UB, коли ви телефонуєте func.

Тож ця функція не є "безпечною". Але це не означає, що він зламаний. Він порушується лише в тому випадку, якщо код, що використовує його, порушує попередню умову. Можливо func, статична функція використовується як помічник у здійсненні інших функцій. Локалізований таким чином, ніхто не назве це таким чином, що порушує його передумови.

Багато функцій, незалежно від простору імен або членів класу, очікують на стан значення, над яким вони працюють. Якщо ці передумови не виконуються, функції припиняться, як правило, з UB.

Стандартна бібліотека C ++ визначає "правильне, але не визначене" правило. Це говорить про те, що, якщо стандарт не говорить інше, кожен об'єкт, який переміщено з нього, буде дійсним (це юридичний об'єкт цього типу), але конкретний стан цього об'єкта не вказаний. Скільки елементів має переїзд vector? Це не говорить.

Це означає, що ви не можете викликати будь-яку функцію, яка має будь-яку передумову. vector::operator[]має передумову, що vectorмає принаймні один елемент. Оскільки ви не знаєте, в якому стані знаходиться vector, ви не можете його зателефонувати. Було б не краще, ніж зателефонувати, funcне попередньо перевіривши, що значення vectorне порожнє.

Але це також означає, що функції, які не мають передумов, добре. Це абсолютно законний код C ++ 11:

vector<int> v1 = {1, 2, 3, 4, 5};
vector<int> v2{std::move(v1)};
v1.assign({6, 7, 8, 9, 10});

vector::assignне має передумов. Він буде працювати з будь-яким дійсним vectorоб'єктом, навіть з тим, з якого переміщено.

Таким чином, ви не створюєте об'єкт, який порушено. Ви створюєте об'єкт, стан якого невідомий.

Якщо ви не можете побудувати якийсь об'єкт, не зможете встановити інваріанти, тоді киньте виняток із ctor. Якщо ви не можете зберегти інваріанти в якомусь методі, тоді якось сигналізуйте про помилку і відкатуйтесь. Чи повинно це відрізнятися для об'єктів, що переміщуються?

Закидання винятків з конструктора ходу, як правило, вважається ... грубим. Якщо ви переміщуєте об'єкт, що володіє пам'яттю, ви переносите право власності на цю пам'ять. І це зазвичай не передбачає нічого, що може кинути.

На жаль, ми не можемо застосувати це з різних причин . Ми маємо погодитись, що перекидання - це можливість.

Слід також зазначити, що вам не доведеться дотримуватися "дійсної - ще не визначеної" мови. Це просто так, як стандартна бібліотека C ++ каже, що рух за стандартними типами працює за замовчуванням . Деякі типові бібліотечні типи мають більш суворі гарантії. Наприклад, unique_ptrдуже ясно про стан переміщеного з unique_ptrекземпляра: він дорівнює nullptr.

Таким чином, ви можете вирішити надати більш гарантійну гарантію, якщо хочете.

Тільки пам'ятайте: рух є оптимізація продуктивності , один , який, як правило , робиться на об'єктах, які про повинні бути знищені. Розглянемо цей код:

vector<int> func()
{
  vector<int> v;
  //fill up `v`.
  return v;
}

Це переміститься vу повернене значення (якщо при цьому компілятор не змине його). І немає ніякого способу посилання vпісля завершення переїзду. Тому будь-яка робота, яку ви зробили, щоб привести vв корисний стан, безглуздо.

У більшості кодів ймовірність використання екземпляра з переміщеним об'єктом низька.

Чи достатньо лише задокументувати "після переміщення цього об'єкта, заборонено (UB) робити з ним що-небудь, крім знищення" в заголовку?

Чи краще постійно твердити, що він дійсний у кожному виклику методу?

Вся суть наявності передумов - не перевіряти подібні речі. operator[]має передумову vectorмати елемент із заданим індексом. Ви отримаєте UB, якщо спробуєте отримати доступ за межами розміру vector. vector::at не має такої передумови; він явно кидає виняток, якщо vectorзначення не має такого значення.

Передумови існують з міркувань продуктивності. Вони такі, що вам не доведеться перевіряти речі, які абонент міг перевірити для себе. Кожен дзвінок v[0]не повинен перевіряти, чи vпорожній він; тільки перший робить.

Чи краще зробити клас копіюваним, але не рухомим?

Ні. Насправді клас ніколи не повинен бути "копіюваним, але не переміщуваним". Якщо його можна скопіювати, то його слід мати можливість перемістити, викликавши конструктор копій. Це стандартна поведінка C ++ 11, якщо ви оголошуєте визначений користувачем конструктор копій, але не оголошуєте конструктор переміщення. І це поведінка, яку ви повинні прийняти, якщо ви не хочете реалізувати спеціальну семантику руху.

Рух семантики існує для вирішення дуже специфічної проблеми: робота з об'єктами, які мають великі ресурси, де копіювання було б надмірно дорогим або безглуздим (наприклад, ручками файлів). Якщо ваш об’єкт не кваліфікується, копіювання та переміщення для вас однакові.


5
Приємно. +1. Я зауважу, що: "Весь сенс мати передумови - це не перевіряти подібні речі". - Я не думаю, що це стосується тверджень. Твердження - ІМХО - хороший та вагомий інструмент для перевірки передумов (більшість часу, принаймні.)
Мартін,

3
Плутанина копіювання / переміщення може бути уточнена, зрозумівши, що ctor переміщення може залишити вихідний об'єкт у будь-якому стані, включаючи ідентичний новому об'єкту - це означає, що можливі результати є надмножиною результатів копії копіювача.
MSalters
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.