27.10 — std::move_if_noexcept

(h/t do czytelnika Koe za udostępnienie pierwszej wersji tej lekcji!)

W lekcji 22.4 — std::move, omówiliśmy std::move, który rzutuje swój argument lvalue na rvalue, abyśmy mogli wywołać semantykę przenoszenia. W lekcji 27,9 — specyfikacje wyjątków i noexcept omówiliśmy noexcept specyfikator i operator wyjątku. Ta lekcja opiera się na obu koncepcjach.

Omówiliśmy także strong exception guarantee, który gwarantuje, że jeśli funkcja zostanie przerwana przez wyjątek, nie nastąpi wyciek pamięci i stan programu nie ulegnie zmianie. W szczególności wszyscy konstruktorzy powinni przestrzegać silnej gwarancji wyjątków, aby reszta programu nie pozostała w zmienionym stanie, jeśli konstrukcja obiektu się nie powiedzie.

Problem wyjątku konstruktorów przenoszenia

Rozważmy przypadek, gdy kopiujemy jakiś obiekt, a kopia z jakiegoś powodu nie powiedzie się (np. w maszynie zabrakło pamięci). W takim przypadku kopiowany obiekt nie zostaje w żaden sposób uszkodzony, ponieważ obiekt źródłowy nie wymaga modyfikacji, aby utworzyć kopię. Możemy odrzucić nieudaną kopię i przejść dalej. strong exception guarantee jest podtrzymane.

Rozważmy teraz przypadek, w którym zamiast tego przesuwamy obiekt. Operacja przenoszenia przenosi własność danego zasobu ze źródła na obiekt docelowy. Jeśli operacja przenoszenia zostanie przerwana przez wyjątek po tym jak nastąpi przeniesienie własności, to nasz obiekt źródłowy pozostanie w zmodyfikowanym stanie. Nie stanowi to problemu, jeśli obiekt źródłowy jest obiektem tymczasowym i tak czy inaczej zostanie odrzucony po przeniesieniu – ale w przypadku obiektów nietymczasowych uszkodziliśmy teraz obiekt źródłowy. Aby zachować zgodność z strong exception guarantee, musielibyśmy przenieść zasób z powrotem do obiektu źródłowego, ale jeśli przeniesienie nie powiedzie się za pierwszym razem, nie ma gwarancji, że przeniesienie również się powiedzie.

Jak możemy dać konstruktorom ruchu strong exception guarantee? Uniknięcie zgłaszania wyjątków w treści konstruktora przenoszenia jest wystarczająco proste, ale konstruktor przenoszenia może wywołać inne konstruktory, które są potentially throwing. Weźmy na przykład konstruktor przenoszenia dla std::pair, który musi próbować przenieść każdy podobiekt z pary źródłowej do obiektu nowej pary.

// Example move constructor definition for std::pair
// Take in an 'old' pair, and then move construct the new pair's 'first' and 'second' subobjects from the 'old' ones
template <typename T1, typename T2>
pair<T1,T2>::pair(pair&& old)
  : first(std::move(old.first)),
    second(std::move(old.second))
{}

Użyjmy teraz dwóch klas, MoveClass i CopyClass, które pair razem zademonstrujemy strong exception guarantee problem z konstruktorami przenoszenia:

#include <iostream>
#include <utility> // For std::pair, std::make_pair, std::move, std::move_if_noexcept
#include <stdexcept> // std::runtime_error

class MoveClass
{
private:
  int* m_resource{};

public:
  MoveClass() = default;

  MoveClass(int resource)
    : m_resource{ new int{ resource } }
  {}

  // Copy constructor
  MoveClass(const MoveClass& that)
  {
    // deep copy
    if (that.m_resource != nullptr)
    {
      m_resource = new int{ *that.m_resource };
    }
  }

  // Move constructor
  MoveClass(MoveClass&& that) noexcept
    : m_resource{ that.m_resource }
  {
    that.m_resource = nullptr;
  }

  ~MoveClass()
  {
    std::cout << "destroying " << *this << '\n';

    delete m_resource;
  }

  friend std::ostream& operator<<(std::ostream& out, const MoveClass& moveClass)
  {
    out << "MoveClass(";

    if (moveClass.m_resource == nullptr)
    {
      out << "empty";
    }
    else
    {
      out << *moveClass.m_resource;
    }

    out << ')';
    
    return out;
  }
};


class CopyClass
{
public:
  bool m_throw{};

  CopyClass() = default;

  // Copy constructor throws an exception when copying from
  // a CopyClass object where its m_throw is 'true'
  CopyClass(const CopyClass& that)
    : m_throw{ that.m_throw }
  {
    if (m_throw)
    {
      throw std::runtime_error{ "abort!" };
    }
  }
};

int main()
{
  // We can make a std::pair without any problems:
  std::pair my_pair{ MoveClass{ 13 }, CopyClass{} };

  std::cout << "my_pair.first: " << my_pair.first << '\n';

  // But the problem arises when we try to move that pair into another pair.
  try
  {
    my_pair.second.m_throw = true; // To trigger copy constructor exception

    // The following line will throw an exception
    std::pair moved_pair{ std::move(my_pair) }; // We'll comment out this line later
    // std::pair moved_pair{ std::move_if_noexcept(my_pair) }; // We'll uncomment this later

    std::cout << "moved pair exists\n"; // Never prints
  }
  catch (const std::exception& ex)
  {
      std::cerr << "Error found: " << ex.what() << '\n';
  }

  std::cout << "my_pair.first: " << my_pair.first << '\n';

  return 0;
}

Powyższy program wypisuje:

destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(empty)
destroying MoveClass(empty)

Zbadajmy, co się stało. Pierwsza wydrukowana linia pokazuje, że tymczasowy MoveClass obiekt użyty do inicjalizacji my_pair zostanie zniszczony zaraz po wykonaniu my_pair instrukcji instancji. Dzieje się tak empty ponieważ MoveClass podobiekt w my_pair został z niego zbudowany, czego dowodem jest następna linia, która pokazuje my_pair.first zawiera MoveClass obiekt o wartości 13.

Interesująco robi się w trzeciej linii. Stworzyliśmy moved_pair przez kopiowanie, konstruując jego podobiekt CopyClass (nie ma konstruktora przenoszenia), ale ta konstrukcja kopiowania zgłosiła wyjątek, ponieważ zmieniliśmy flagę Boolean. Budowa moved_pair została przerwana w drodze wyjątku, a jej już zbudowane elementy zostały zniszczone. W tym przypadku element MoveClass został zniszczony, drukowanie destroying MoveClass(13) variable. Następnie widzimy Error found: abort! komunikat wydrukowany przez main().

Kiedy próbujemy wydrukować my_pair.first ponownie, pokazuje się, że MoveClass element jest pusty. Ponieważ moved_pair został zainicjowany elementem std::move, instrukcja MoveClass (który ma konstruktor ruchu), skonstruowano ruch i my_pair.first został wyzerowany.

Na koniec przeprowadzana jest ocena my_pair został zniszczony na końcu funkcji main().

Podsumowując powyższe wyniki: konstruktor ruchu std::pair użył rzucającego konstruktora kopiującego z CopyClass. Ten konstruktor kopiujący zgłosił wyjątek, powodując przerwanie tworzenia moved_pair i trwałe uszkodzenie my_pair.first . strong exception guarantee nie został zachowany.

std::move_if_noexcept na ratunek

Zauważ, że powyższego problemu można było uniknąć, std::pair próbując wykonać kopię zamiast przenieść. W takim przypadku moved_pair nie udałoby się skonstruować, ale my_pair nie zostałby zmieniony.

Ale kopiowanie zamiast przenoszenia wiąże się z kosztem wydajności, którego nie chcemy płacić za wszystkie obiekty — w idealnym przypadku chcemy wykonać przeniesienie, jeśli możemy to zrobić bezpiecznie, a kopię w przeciwnym razie.

Na szczęście w C++ istnieją dwa mechanizmy, które użyte w połączeniu pozwalają nam zrobić dokładnie to. Po pierwsze, ponieważ noexcept funkcje są typu „no-throw/no-fail”, domyślnie spełniają kryteria dla strong exception guarantee. Zatem noexcept konstruktor przenoszenia ma gwarancję powodzenia.

Po drugie, możemy użyć standardowej funkcji bibliotecznej std::move_if_noexcept() w celu ustalenia, czy należy wykonać przeniesienie, czy kopię. std::move_if_noexcept jest odpowiednikiem std::move i jest używany w ten sam sposób.

Jeśli kompilator może stwierdzić, że obiekt przekazany jako argument to std::move_if_noexcept nie zgłosi wyjątku podczas konstrukcji przenoszenia (lub jeśli obiekt jest przeznaczony tylko do przenoszenia i nie ma konstruktora kopiującego), wówczas std::move_if_noexcept zastosuje się identycznie jak std::move() (i zwróci obiekt przekonwertowany na wartość r). W przeciwnym razie std::move_if_noexcept zwróci normalne odniesienie do wartości l do obiektu.

Kluczowa informacja

std::move_if_noexcept zwróci ruchomą wartość r, jeśli obiekt ma konstruktor ruchu noexcept, w przeciwnym razie zwróci wartość l, którą można kopiować. Możemy użyć specyfikatora noexcept w połączeniu z std::move_if_noexcept aby użyć semantyki przenoszenia tylko wtedy, gdy istnieje silna gwarancja wyjątku (w przeciwnym razie użyć semantyki kopiowania).

Zaktualizujmy kod z poprzedniego przykładu w następujący sposób:

//std::pair moved_pair{std::move(my_pair)}; // comment out this line now
std::pair moved_pair{std::move_if_noexcept(my_pair)}; // and uncomment this line

Ponowne uruchomienie programu powoduje wyświetlenie:

destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(13)
destroying MoveClass(13)

Jak widać, po zgłoszeniu wyjątku, podobiekt my_pair.first nadal wskazuje wartość 13.

Konstruktor przenoszenia std::pair nie jest noexcept (od C++20), więc std::move_if_noexcept zwraca my_pair jako odniesienie do l-wartości. Powoduje to utworzenie moved_pair za pomocą konstruktora kopiującego (a nie konstruktora przenoszenia). Konstruktor kopiujący może bezpiecznie rzucać, ponieważ nie modyfikuje obiektu źródłowego.

Biblioteka standardowa std::move_if_noexcept często używa <<<M27>>>do optymalizacji pod kątem funkcji, które noexcept. Na przykład std::vector::resize będą używać semantyki przenoszenia, jeśli typ elementu ma noexcept konstruktor przenoszenia, a w przeciwnym razie semantykę kopiowania. Oznacza to, że std::vector będzie ogólnie działać szybciej z obiektami, które mają noexcept konstruktor przenoszenia.

Ostrzeżenie

Jeśli typ ma zarówno potencjalnie rzucającą semantykę przenoszenia, jak i semantykę usuniętego kopiowania (konstruktor kopiujący i operator przypisania kopiowania są niedostępne), wówczas std::move_if_noexcept zrezygnuje z silnej gwarancji i wywoła semantykę przenoszenia. To warunkowe zrzeczenie się silnej gwarancji jest wszechobecne w standardowych klasach kontenerów bibliotek, ponieważ często używają std::move_if_noexcept.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
84 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze