Niezmienniki czasu życia obiektu a semantyka ruchu

13

Kiedy nauczyłem się C ++ dawno temu, mocno podkreślono, że część C ++ polega na tym, że podobnie jak pętle mają „niezmienniki pętli”, klasy mają również niezmienniki związane z czasem życia obiektu - rzeczy, które powinny być prawdziwe tak długo, jak obiekt żyje. Rzeczy, które powinny zostać ustalone przez konstruktorów i zachowane metodami. Hermetyzacja / kontrola dostępu pomaga egzekwować niezmienniki. RAII to jedna z rzeczy, które możesz zrobić z tym pomysłem.

Od C ++ 11 mamy teraz semantykę ruchów. W przypadku klasy, która obsługuje przenoszenie, przenoszenie z obiektu nie kończy formalnie jego żywotności - ruch powinien pozostawić go w jakimś „prawidłowym” stanie.

Czy przy projektowaniu klasy jest to zła praktyka, jeśli projektuje się ją tak, aby niezmienniki klasy były zachowane tylko do momentu, w którym zostały przeniesione? A może to w porządku, jeśli pozwoli ci to przyspieszyć.

Aby uczynić to konkretnym, załóżmy, że mam typ zasobu, którego nie można kopiować, ale można go przenosić:

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

public:
  opaque(opaque &&);

  ...

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

I z jakiegokolwiek powodu muszę wykonać kopiowalne opakowanie dla tego obiektu, aby można go było użyć, być może w jakimś istniejącym systemie wysyłkowym.

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

W tym copyable_opaqueobiekcie niezmiennikiem klasy ustanowionej podczas budowy jest to, że element członkowski o_zawsze wskazuje poprawny obiekt, ponieważ nie ma domyślnego ctor, a jedyny ctor, który nie jest ctor kopiującym, gwarantuje to. Wszystkie operator()metody zakładają, że ten niezmiennik utrzymuje i zachowuje go później.

Jeśli jednak obiekt zostanie przeniesiony, wówczas nie o_będzie wskazywać na nic. A po tym punkcie wywołanie dowolnej z metod operator()spowoduje awarię UB /.

Jeśli obiekt nigdy nie zostanie przeniesiony, niezmiennik zostanie zachowany aż do wywołania dtor.

Załóżmy, że hipotetycznie napisałem tę klasę, a kilka miesięcy później mój wymyślony współpracownik doświadczył UB, ponieważ w jakiejś skomplikowanej funkcji, w której z jakiegoś powodu tasowano wiele tych obiektów, odszedł od jednej z tych rzeczy, a później nazwał jedną z jego metody. Najwyraźniej to jego wina pod koniec dnia, ale czy ta klasa jest „źle zaprojektowana?”

Myśli:

  1. W C ++ jest to zwykle zła forma tworzenia obiektów zombie, które eksplodują po ich dotknięciu.
    Jeśli nie możesz skonstruować jakiegoś obiektu, nie możesz ustalić niezmienników, to rzuć wyjątek od ctor. Jeśli nie możesz zachować niezmienników w jakiejś metodzie, to w jakiś sposób zasygnalizuj błąd i wycofaj. Czy powinno być inaczej w przypadku obiektów przeniesionych?

  2. Czy wystarczy tylko udokumentować „po przeniesieniu tego obiektu, nielegalne (UB) jest robić z nim cokolwiek innego niż zniszczenie” w nagłówku?

  3. Czy lepiej jest stale twierdzić, że jest poprawna w każdym wywołaniu metody?

Tak jak:

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

Twierdzenia nie poprawiają zasadniczo zachowania i powodują spowolnienie. Jeśli twój projekt używa schematu „kompilacja wydania / kompilacja debugowania”, a nie tylko zawsze działa z asercjami, myślę, że jest to bardziej atrakcyjne, ponieważ nie płacisz za kontrole w kompilacji wersji. Jeśli tak naprawdę nie masz kompilacji debugowania, wydaje się to dość nieatrakcyjne.

  1. Czy lepiej jest uczynić klasę kopiowalną, ale nie ruchomą?
    Wydaje się to również złe i powoduje pogorszenie wydajności, ale rozwiązuje problem „niezmienności” w prosty sposób.

Jakie według Ciebie są tutaj „najlepsze praktyki”?

Chris Beck
źródło

Odpowiedzi:

20

W C ++ jest to zwykle zła forma tworzenia obiektów zombie, które eksplodują po ich dotknięciu.

Ale nie to robisz. Tworzysz „obiekt zombie”, który wybuchnie, jeśli dotkniesz go niewłaściwie . Który ostatecznie nie różni się niczym od innych warunków wstępnych opartych na stanie.

Rozważ następującą funkcję:

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

Czy ta funkcja jest bezpieczna? Nie; użytkownik może przekazać pusty vector . Tak więc funkcja ma de facto warunek wstępny, który vzawiera co najmniej jeden element. Jeśli tak się nie stanie, otrzymasz UB, gdy zadzwonisz func.

Ta funkcja nie jest więc „bezpieczna”. Ale to nie znaczy, że jest zepsute. Jest on łamany tylko wtedy, gdy kod go wykorzystujący narusza warunek wstępny. Być może funcjest to funkcja statyczna używana jako pomocnik przy implementacji innych funkcji. Tak zlokalizowany, nikt nie nazwałby go w sposób naruszający jego warunki wstępne.

Wiele funkcji, niezależnie od tego, czy dotyczą one przestrzeni nazw, czy członków klasy, będzie miało oczekiwania dotyczące stanu wartości, na której działają. Jeśli te warunki wstępne nie zostaną spełnione, funkcje zawiodą, zwykle z UB.

Standardowa biblioteka C ++ definiuje regułę „ważna, ale nieokreślona”. Oznacza to, że o ile standard nie stanowi inaczej, każdy obiekt, z którego zostanie przeniesiony, będzie ważny (jest to legalny obiekt tego typu), ale nie określono konkretnego stanu tego obiektu. Ile elementów ma przeprowadzka vector? Nie mówi

Oznacza to, że nie można wywołać żadnej funkcji, która ma jakiekolwiek warunki wstępne. vector::operator[]ma warunek, że vectorma co najmniej jeden element. Ponieważ nie znasz stanu vector, nie możesz go nazwać. Nie byłoby nic lepszego niż dzwonienie funcbez uprzedniego sprawdzenia, czy vectornie jest puste.

Ale oznacza to również, że funkcje, które nie mają warunków wstępnych, są w porządku. Jest to całkowicie legalny kod C ++ 11:

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

vector::assignnie ma żadnych warunków wstępnych. Będzie działać z każdym poprawnym vectorobiektem, nawet tym, z którego został przeniesiony.

Więc nie tworzysz uszkodzonego obiektu. Tworzysz obiekt, którego stan jest nieznany.

Jeśli nie możesz skonstruować jakiegoś obiektu, nie możesz ustalić niezmienników, to rzuć wyjątek od ctor. Jeśli nie możesz zachować niezmienników w jakiejś metodzie, to w jakiś sposób zasygnalizuj błąd i wycofaj. Czy powinno być inaczej w przypadku obiektów przeniesionych?

Zgłaszanie wyjątków od konstruktora ruchu jest ogólnie uważane za ... niegrzeczne. Jeśli przenosisz obiekt będący właścicielem pamięci, przenosisz własność tej pamięci. I zwykle nie obejmuje to niczego, co może rzucić.

Niestety nie możemy tego egzekwować z różnych powodów . Musimy zaakceptować fakt, że ruch do rzucania jest możliwy.

Należy również zauważyć, że nie musisz postępować zgodnie z „ważnym, ale jeszcze nieokreślonym” językiem. W ten sposób standardowa biblioteka C ++ mówi, że ruch dla standardowych typów działa domyślnie . Niektóre standardowe typy bibliotek mają bardziej rygorystyczne gwarancje. Na przykład, unique_ptrjest bardzo jasny o stanie przeniesionej unique_ptrinstancji: jest równy nullptr.

Możesz więc zdecydować się na silniejszą gwarancję.

Tylko pamiętaj: ruch jest optymalizacja wydajności , jeden, który jest zwykle wykonywana na obiektach, które są o być zniszczone. Rozważ ten kod:

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

Spowoduje to przejście od vwartości zwracanej (zakładając, że kompilator jej nie pominie). I nie ma żadnego sposobu na odniesienie vpo przeprowadzce została zakończona. Więc każda praca, którą wykonałeś, aby wprowadzić vużyteczny stan, jest bez znaczenia.

W większości kodów prawdopodobieństwo użycia przeniesionej instancji obiektu jest niskie.

Czy wystarczy tylko udokumentować „po przeniesieniu tego obiektu, nielegalne (UB) jest robić z nim cokolwiek innego niż zniszczenie” w nagłówku?

Czy lepiej jest stale twierdzić, że jest poprawna w każdym wywołaniu metody?

Chodzi przede wszystkim o to, by nie sprawdzać takich rzeczy. operator[]ma warunek wstępny, aby vectormieć element o podanym indeksie. Otrzymasz UB, jeśli spróbujesz uzyskać dostęp poza rozmiarem vector. vector::at nie ma takiego warunku wstępnego; jawnie zgłasza wyjątek, jeśli vectornie ma takiej wartości.

Istnieją warunki wstępne ze względu na wydajność. Są tak, że nie musisz sprawdzać rzeczy, które osoba dzwoniąca mogła sama zweryfikować. Każde połączenie z v[0]nie musi sprawdzać, czy vjest puste; robi to tylko pierwszy.

Czy lepiej jest uczynić klasę kopiowalną, ale nie ruchomą?

Nie. W rzeczywistości klasa nigdy nie powinna być „kopiowalna, ale nie ruchoma”. Jeśli można go skopiować, należy go przenieść, wywołując konstruktor kopiowania. Jest to standardowe zachowanie C ++ 11, jeśli deklarujesz konstruktor kopiowania zdefiniowany przez użytkownika, ale nie deklarujesz konstruktora przenoszenia. Jest to zachowanie, które powinieneś przyjąć, jeśli nie chcesz implementować specjalnej semantyki ruchów.

Istnieje semantyka przenoszenia, aby rozwiązać bardzo specyficzny problem: radzenie sobie z obiektami o dużych zasobach, w których kopiowanie byłoby zbyt drogie lub pozbawione sensu (np. Uchwyty plików). Jeśli twój obiekt się nie kwalifikuje, kopiowanie i przenoszenie są dla ciebie takie same.

Nicol Bolas
źródło
5
Ładny. +1. Chciałbym zauważyć, że: „Najważniejszym warunkiem posiadania warunków wstępnych jest nie sprawdzanie takich rzeczy”. - Nie sądzę, że dotyczy to twierdzeń. Asercje są IMHO dobrym i ważnym narzędziem do sprawdzania warunków wstępnych (przynajmniej przez większość czasu)
Martin Ba
3
Zamieszanie kopiowania / przenoszenia można wyjaśnić, uświadamiając sobie, że ctor ruchu może pozostawić obiekt źródłowy w dowolnym stanie, w tym identycznym z nowym obiektem - co oznacza, że ​​możliwe wyniki są nadzbiorem efektu ctor kopiowania.
MSalters