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_opaque
obiekcie 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:
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?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?
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.
- 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”?
źródło
Odpowiedzi:
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ę:
Czy ta funkcja jest bezpieczna? Nie; użytkownik może przekazać pusty
vector
. Tak więc funkcja ma de facto warunek wstępny, któryv
zawiera co najmniej jeden element. Jeśli tak się nie stanie, otrzymasz UB, gdy zadzwoniszfunc
.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
func
jest 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ówiOznacza to, że nie można wywołać żadnej funkcji, która ma jakiekolwiek warunki wstępne.
vector::operator[]
ma warunek, żevector
ma co najmniej jeden element. Ponieważ nie znasz stanuvector
, nie możesz go nazwać. Nie byłoby nic lepszego niż dzwonieniefunc
bez uprzedniego sprawdzenia, czyvector
nie 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::assign
nie ma żadnych warunków wstępnych. Będzie działać z każdym poprawnymvector
obiektem, nawet tym, z którego został przeniesiony.Więc nie tworzysz uszkodzonego obiektu. Tworzysz obiekt, którego stan jest nieznany.
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_ptr
jest bardzo jasny o stanie przeniesionejunique_ptr
instancji: jest równynullptr
.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:
Spowoduje to przejście od
v
wartości zwracanej (zakładając, że kompilator jej nie pominie). I nie ma żadnego sposobu na odniesieniev
po przeprowadzce została zakończona. Więc każda praca, którą wykonałeś, aby wprowadzićv
użyteczny stan, jest bez znaczenia.W większości kodów prawdopodobieństwo użycia przeniesionej instancji obiektu jest niskie.
Chodzi przede wszystkim o to, by nie sprawdzać takich rzeczy.
operator[]
ma warunek wstępny, abyvector
mieć element o podanym indeksie. Otrzymasz UB, jeśli spróbujesz uzyskać dostęp poza rozmiaremvector
.vector::at
nie ma takiego warunku wstępnego; jawnie zgłasza wyjątek, jeślivector
nie 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ć, czyv
jest puste; robi to tylko pierwszy.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.
źródło