Poniższy kod wygląda na pierwszy rzut oka raczej nieszkodliwie. Użytkownik używa tej funkcji bar()
do interakcji z niektórymi funkcjami biblioteki. (Mogło to nawet działać przez długi czas, odkąd bar()
zwróciło odwołanie do wartości nietrwałej lub podobnej.) Teraz jednak zwraca po prostu nową instancję B
. B
ponownie ma funkcję, a()
która zwraca odwołanie do obiektu typu iterowalnego A
. Użytkownik chce wysłać zapytanie do tego obiektu, co prowadzi do uszkodzenia pliku, ponieważ B
zwrócony obiekt tymczasowy bar()
jest niszczony przed rozpoczęciem iteracji.
Jestem niezdecydowany, kto (biblioteka lub użytkownik) jest winien za to. Wszystkie klasy dostarczone mi z biblioteki wyglądają dla mnie na czyste i na pewno nie robią nic innego (zwracanie referencji do członków, zwracanie instancji stosu, ...) niż wiele innych kodów. Wydaje się, że użytkownik nie robi nic złego, po prostu iteruje jakiś obiekt, nie robiąc nic w odniesieniu do jego żywotności.
(Powiązane pytanie może brzmieć: jeśli należy ustanowić ogólną zasadę, że kod nie powinien „opierać się na zakresie iteracji” nad czymś, co jest pobierane przez więcej niż jedno połączenie łańcuchowe w nagłówku pętli, ponieważ każde z tych wywołań może zwrócić wartość?)
#include <algorithm>
#include <iostream>
// "Library code"
struct A
{
A():
v{0,1,2}
{
std::cout << "A()" << std::endl;
}
~A()
{
std::cout << "~A()" << std::endl;
}
int * begin()
{
return &v[0];
}
int * end()
{
return &v[3];
}
int v[3];
};
struct B
{
A m_a;
A & a()
{
return m_a;
}
};
B bar()
{
return B();
}
// User code
int main()
{
for( auto i : bar().a() )
{
std::cout << i << std::endl;
}
}
Odpowiedzi:
Myślę, że podstawowym problemem jest połączenie cech językowych (lub ich brak) w C ++. Zarówno kod biblioteki, jak i kod klienta są rozsądne (o czym świadczy fakt, że problem nie jest oczywisty). Gdyby żywotność tymczasowa
B
była odpowiednio przedłużona (do końca pętli), nie byłoby problemu.Sprawienie, by życie tymczasowe było wystarczająco długie i już nie jest niezwykle trudne. Nawet ad-hoc „wszystkie tymczasowe działania związane z tworzeniem zasięgu na podstawie zasięgu na żywo do końca pętli” nie byłyby pozbawione skutków ubocznych. Rozważ przypadek
B::a()
zwrócenia zakresu niezależnego odB
obiektu według wartości. Następnie tymczasoweB
można natychmiast odrzucić. Nawet gdyby można było precyzyjnie zidentyfikować przypadki, w których konieczne jest przedłużenie życia, ponieważ przypadki te nie są oczywiste dla programistów, efekt (niszczyciele nazywane znacznie później) byłby zaskakujący i być może równie subtelnym źródłem błędów.Bardziej pożądane byłoby po prostu wykrycie i zakazanie takich bzdur, zmuszając programistę do wyraźnego podniesienia poziomu
bar()
do zmiennej lokalnej. Nie jest to możliwe w C ++ 11 i prawdopodobnie nigdy nie będzie możliwe, ponieważ wymaga adnotacji. Rdza robi to, gdzie podpisem.a()
byłoby:Tutaj
'x
zmienna lub region na całe życie, która jest symboliczną nazwą okresu, przez który zasób jest dostępny. Szczerze mówiąc, wcielenia są trudne do wyjaśnienia - lub jeszcze nie wymyśliliśmy najlepszego wyjaśnienia - więc ograniczę się do minimum niezbędnego dla tego przykładu i skieruję skłonnego czytelnika do oficjalnej dokumentacji .Narzędzie sprawdzania pożyczek zauważyłoby, że wynik
bar().a()
potrzeby musi istnieć tak długo, jak długo działa pętla. Sformułowane jako ograniczenie na całe życie'x
, możemy napisać:'loop <= 'x
. Zauważyłby również, że odbiorca wywołania metodybar()
, jest tymczasowy. Te dwa wskaźniki są powiązane z tym samym czasem życia, dlatego'x <= 'temp
jest kolejnym ograniczeniem.Te dwa ograniczenia są ze sobą sprzeczne! Potrzebujemy,
'loop <= 'x <= 'temp
ale'temp <= 'loop
, co dość dokładnie ujmuje problem. Ze względu na sprzeczne wymagania błędny kod jest odrzucany. Zauważ, że jest to kontrola czasu kompilacji, a kod Rust zwykle daje ten sam kod maszynowy co równoważny kod C ++, więc nie musisz ponosić za to kosztów wykonawczych.Niemniej jednak jest to duża funkcja, którą można dodać do języka i działa tylko wtedy, gdy korzysta z niej cały kod. wpływa to również na projektowanie interfejsów API (niektóre projekty, które byłyby zbyt niebezpieczne w C ++ stają się praktyczne, inne nie mogą sprawić, aby grały się przyjemnie przez całe życie). Niestety, oznacza to, że nie jest praktyczne dodawanie do C ++ (lub jakiegokolwiek innego języka naprawdę) z mocą wsteczną. Podsumowując, wina leży w tym, że języki, które odnosiły sukcesy w zakresie bezwładności, oraz fakt, że Bjarne w 1983 r. Nie miał kryształowej kuli i nie przewidywałby włączenia lekcji ostatnich 30 lat badań i doświadczenia w C ++ ;-)
Oczywiście nie jest to wcale pomocne w uniknięciu problemu w przyszłości (chyba że przejdziesz na Rust i nigdy więcej nie użyjesz C ++). Można uniknąć dłuższych wyrażeń za pomocą wielu łańcuchowych wywołań metod (co jest dość ograniczające, a nawet nie naprawia wszystkich problemów przez całe życie). Lub można spróbować zastosować bardziej zdyscyplinowaną politykę własności bez pomocy kompilatora: Dokument wyraźnie
bar
zwraca wartość i że wynikB::a()
nie może przeżyć tego,B
na którya()
się powołuje. Zmieniając funkcję, aby zwracała wartość zamiast odwołania o dłuższym okresie życia, należy pamiętać, że jest to zmiana kontraktu . Nadal jest podatny na błędy, ale może przyspieszyć proces identyfikacji przyczyny, kiedy to nastąpi.źródło
Czy możemy rozwiązać ten problem za pomocą funkcji C ++?
W C ++ 11 dodano kwalifikatory ref funkcji członka, które pozwalają ograniczyć kategorię wartości instancji klasy (wyrażenia), do której można wywołać funkcję członka. Na przykład:
Kiedy wywołujemy funkcję
begin
członka, wiemy, że najprawdopodobniej będziemy musieli również wywołać funkcjęend
członka (lub coś w tym rodzajusize
, aby uzyskać rozmiar zakresu). Wymaga to działania na podstawie wartości, ponieważ musimy rozwiązać ją dwukrotnie. W związku z tym można argumentować, że te funkcje składowe powinny być kwalifikowane do wartości referencyjnej.Może to jednak nie rozwiązać podstawowego problemu: aliasing. Funkcja
begin
iend
członek alias obiektu lub zasobów zarządzanych przez obiekt. Jeśli zastąpimybegin
aend
przez jedną funkcjęrange
, powinniśmy zapewnić taki, który można nazwać na rvalues:Może to być poprawny przypadek użycia, ale powyższa definicja
range
go nie zezwala. Ponieważ nie możemy rozwiązać problemu tymczasowego po wywołaniu funkcji składowej, bardziej uzasadnione może być zwrócenie kontenera, tzn. Zakresu własności:Zastosowanie tego do przypadku PO i drobna weryfikacja kodu
Ta funkcja członka zmienia kategorię wartości wyrażenia:
B()
jest wartością, aleB().a()
jest wartością. Z drugiej stronyB().m_a
jest warta uwagi. Zacznijmy od uczynienia tego spójnym. Można to zrobić na dwa sposoby:Druga wersja, jak wspomniano powyżej, naprawi problem w PO.
Dodatkowo możemy ograniczyć
B
funkcje członka:Nie będzie to miało żadnego wpływu na kod OP, ponieważ wynik wyrażenia po
:
pętli for opartej na zakresie jest powiązany ze zmienną referencyjną. Ta zmienna (jako wyrażenie używane w celu uzyskania dostępu do jej funkcjibegin
i elementówend
członkowskich) jest wartością.Oczywiście pytanie brzmi, czy domyślną regułą powinno być „aliasing funkcji składowych na wartościach rv powinien zwrócić obiekt, który jest właścicielem wszystkich jego zasobów, chyba że istnieje dobry powód, aby tego nie robić” . Zwracany alias może być legalnie używany, ale jest niebezpieczny w sposobie, w jaki go doświadczasz: nie można go użyć do przedłużenia żywotności jego „rodzica” tymczasowego:
W C ++ 2a myślę, że powinieneś obejść ten (lub podobny) problem w następujący sposób:
zamiast PO
Obejście to ręcznie określa, że czas życia
b
to cały blok pętli for.Propozycja, która wprowadziła to oświadczenie init
Demo na żywo
źródło