Czy jest to znana pułapka C ++ 11 dla pętli?


89

Wyobraźmy sobie, że mamy strukturę do trzymania 3 podwójnych z niektórymi funkcjami składowymi:

struct Vector {
  double x, y, z;
  // ...
  Vector &negate() {
    x = -x; y = -y; z = -z;
    return *this;
  }
  Vector &normalize() {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  // ...
};

Jest to trochę wymyślone dla uproszczenia, ale jestem pewien, że zgadzasz się, że istnieje podobny kod. Metody te pozwalają wygodnie łączyć, na przykład:

Vector v = ...;
v.normalize().negate();

Lub nawet:

Vector v = Vector{1., 2., 3.}.normalize().negate();

Teraz, gdybyśmy zapewnili funkcje begin () i end (), moglibyśmy użyć naszego Vectora w nowej pętli for, powiedzmy aby wykonać pętlę po trzech współrzędnych x, y i z (bez wątpienia można skonstruować więcej "użytecznych" przykładów zastępując Vector np. String):

Vector v = ...;
for (double x : v) { ... }

Możemy nawet zrobić:

Vector v = ...;
for (double x : v.normalize().negate()) { ... }

i również:

for (double x : Vector{1., 2., 3.}) { ... }

Jednak następujący (wydaje mi się) jest uszkodzony:

for (double x : Vector{1., 2., 3.}.normalize()) { ... }

Chociaż wydaje się to logicznym połączeniem dwóch poprzednich zastosowań, myślę, że to ostatnie użycie tworzy zwisające odniesienie, podczas gdy poprzednie dwa są całkowicie w porządku.

  • Czy jest to poprawne i powszechnie doceniane?
  • Która część powyższego jest „złą” częścią, której należy unikać?
  • Czy język zostałby ulepszony poprzez zmianę definicji pętli for opartej na zakresie, tak aby elementy tymczasowe skonstruowane w wyrażeniu for istniały przez cały czas trwania pętli?

Z jakiegoś powodu przypominam sobie bardzo podobne pytanie zadawane wcześniej, ale zapomniałem, jak się nazywało.
Pubby

Uważam to za wadę językową. Żywotność prowizoriów nie jest przedłużana na całą zawartość pętli for, ale tylko do konfiguracji pętli for. Cierpi nie tylko na składnię zakresu, ale także na składnię klasyczną. Moim zdaniem żywotność elementów tymczasowych w instrukcji init powinna trwać przez cały czas trwania pętli.
edA-qa mort-ora-y

1
@ edA-qamort-ora-y: Zwykle zgadzam się, że czai się tu niewielka wada językowa, ale myślę, że jest to szczególnie fakt, że przedłużenie życia następuje pośrednio za każdym razem, gdy bezpośrednio łączysz tymczasowy z odwołaniem, ale nie w żadnym inna sytuacja - wydaje się, że jest to niedopracowane rozwiązanie podstawowego problemu tymczasowych żywotów, chociaż nie oznacza to, że jest oczywiste, jakie byłoby lepsze rozwiązanie. Być może jawna składnia `` przedłużenia życia '' podczas konstruowania tymczasowego, co sprawia, że ​​trwa do końca bieżącego bloku - co o tym myślisz?
ndkrempel

@ edA-qamort-ora-y: ... to sprowadza się do tego samego, co wiązanie elementu tymczasowego z odwołaniem, ale ma tę zaletę, że czytelnik ma tę zaletę, że jest bardziej wyraźny, że `` rozszerzenie życia '' występuje w tekście (w wyrażeniu , zamiast wymagać osobnej deklaracji) i nie wymagać od Ciebie podania nazwy tymczasowej.
ndkrempel

Odpowiedzi:


64

Czy jest to poprawne i powszechnie doceniane?

Tak, twoje rozumienie rzeczy jest poprawne.

Która część powyższego jest „złą” częścią, której należy unikać?

Zła część to pobranie odwołania do wartości l do tymczasowej wartości zwróconej z funkcji i powiązanie go z odwołaniem do wartości r. Jest tak źle, jak to:

auto &&t = Vector{1., 2., 3.}.normalize();

Okres Vector{1., 2., 3.}istnienia tymczasowego nie może zostać przedłużony, ponieważ kompilator nie ma pojęcia, że ​​wartość zwracana z normalizeniego odwołuje się do niego.

Czy język zostałby ulepszony poprzez zmianę definicji pętli for opartej na zakresie, tak aby elementy tymczasowe skonstruowane w wyrażeniu for istniały przez cały czas trwania pętli?

Byłoby to wysoce niezgodne z tym, jak działa C ++.

Czy zapobiegnie to pewnym pułapkom robionym przez ludzi używających wyrażeń łańcuchowych na obiektach tymczasowych lub różnych metod leniwej oceny wyrażeń? Tak. Ale wymagałoby to również specjalnego kodu kompilatora, a także byłoby mylące, dlaczego nie działa z innymi konstrukcjami wyrażeń.

O wiele bardziej rozsądnym rozwiązaniem byłoby poinformowanie kompilatora, że ​​zwracana wartość funkcji jest zawsze odwołaniem this, a zatem jeśli wartość zwracana jest powiązana z konstrukcją rozszerzającą tymczasowo, wówczas rozszerzyłaby poprawną wartość tymczasową. Jest to jednak rozwiązanie na poziomie języka.

Obecnie (jeśli kompilator to obsługuje), możesz sprawić, że normalize nie będzie można go wywołać tymczasowo:

struct Vector {
  double x, y, z;
  // ...
  Vector &normalize() & {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  Vector &normalize() && = delete;
};

Spowoduje to Vector{1., 2., 3.}.normalize()wyświetlenie błędu kompilacji, podczas gdy v.normalize()będzie działać dobrze. Oczywiście nie będziesz w stanie zrobić poprawnych rzeczy, takich jak:

Vector t = Vector{1., 2., 3.}.normalize();

Ale nie będziesz też w stanie robić niewłaściwych rzeczy.

Alternatywnie, jak sugerowano w komentarzach, możesz sprawić, by wersja referencyjna rvalue zwracała wartość zamiast referencji:

struct Vector {
  double x, y, z;
  // ...
  Vector &normalize() & {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  Vector normalize() && {
     Vector ret = *this;
     ret.normalize();
     return ret;
  }
};

Jeśli Vectorbył to typ z rzeczywistymi zasobami do przeniesienia, możesz użyć Vector ret = std::move(*this);zamiast tego. Nazwana optymalizacja wartości zwracanej sprawia, że ​​jest to racjonalnie optymalne pod względem wydajności.


1
Rzeczą, która może sprawić, że będzie to bardziej „haczyk”, jest to, że nowa pętla for ukrywa składniowo fakt, że powiązanie referencyjne zachodzi pod okładkami - tj. Jest to o wiele mniej rażące niż twoje „równie złe” przykłady powyżej. Dlatego wydaje się prawdopodobne, aby zasugerować regułę przedłużenia dodatkowego czasu życia, tylko dla nowej pętli for.
ndkrempel

1
@ndkrempel: Tak, ale jeśli zamierzasz zaproponować funkcję językową, aby to naprawić (i dlatego musisz poczekać przynajmniej do 2017 r.), Wolałbym, aby była bardziej wszechstronna, coś, co mogłoby rozwiązać problem tymczasowego rozszerzenia wszędzie .
Nicol Bolas

3
+1. W ostatnim podejściu zamiast deletealternatywnej operacji, która zwraca wartość r: Vector normalize() && { normalize(); return std::move(*this); }(Uważam, że wywołanie normalizewewnątrz funkcji spowoduje wysłanie do przeciążenia lvalue, ale ktoś powinien to sprawdzić :)
David Rodríguez - dribeas

3
Nigdy nie widziałem tego &/ &&kwalifikacji metod. Czy pochodzi z C ++ 11, czy jest to jakieś (być może rozpowszechnione) zastrzeżone rozszerzenie kompilatora. Daje ciekawe możliwości.
Christian Rau

1
@ChristianRau: Jest to nowość w C ++ 11 i analogiczna do C ++ 03 "const" i "volatile" kwalifikacji niestatycznych funkcji składowych, ponieważ w pewnym sensie kwalifikuje "this". g ++ 4.7.0 nie obsługuje go jednak.
ndkrempel

25

for (double x: Vector {1., 2., 3.}. normalize ()) {...}

To nie jest ograniczenie języka, ale problem z twoim kodem. Wyrażenie Vector{1., 2., 3.}tworzy tymczasowy, ale normalizefunkcja zwraca odniesienie do l-wartości . Ponieważ wyrażenie jest lwartością , kompilator zakłada, że ​​obiekt będzie żył, ale ponieważ jest to odniesienie do tymczasowego, obiekt umiera po ocenie pełnego wyrażenia, więc pozostaje wiszące odwołanie.

Teraz, jeśli zmienisz projekt, aby zwracał nowy obiekt według wartości, a nie odwołanie do bieżącego obiektu, nie byłoby problemu, a kod działałby zgodnie z oczekiwaniami.


1
Czy constodniesienie wydłużyłoby żywotność obiektu w tym przypadku?
David Stone

5
Co mogłoby złamać wyraźnie pożądaną semantykę normalize()jako funkcji mutującej na istniejącym obiekcie. Stąd pytanie. To, że tymczasowy ma „wydłużoną żywotność”, gdy jest używany w konkretnym celu iteracji, a nie w innym przypadku, jest moim zdaniem mylącym błędem.
Andy Ross

2
@AndyRoss: Dlaczego? Każde tymczasowe powiązanie z odwołaniem do wartości r (lub const&) ma wydłużony czas życia.
Nicol Bolas

2
@ndkrempel: Still, a nie ograniczenie zakresu oparte na pętli, ten sam problem przyjdzie jeśli zwiążesz na odniesienie: Vector & r = Vector{1.,2.,3.}.normalize();. Twój projekt ma to ograniczenie, a to oznacza, że ​​albo chcesz zwrócić wartość (co może mieć sens w wielu okolicznościach, a zwłaszcza w przypadku odwołań do wartości r i przenieść ), albo musisz poradzić sobie z problemem w miejscu call: utwórz odpowiednią zmienną, a następnie użyj jej w pętli for. Zwróć również uwagę, że wyrażenie Vector v = Vector{1., 2., 3.}.normalize().negate();tworzy dwa obiekty ...
David Rodríguez - dribeas

1
@ DavidRodríguez-dribeas: problem z dowiązaniem do const-reference jest następujący: T const& f(T const&);jest całkowicie w porządku. T const& t = f(T());jest całkowicie w porządku. A potem, w innej JT odkrywasz to T const& f(T const& t) { return t; }i płaczesz… Jeśli operator+operuje wartościami, to jest bezpieczniejsze ; wtedy kompilator może zoptymalizować kopiowanie (Want Speed? Pass by Values), ale to bonus. Jedynym powiązaniem tymczasowych, na które pozwoliłbym, jest powiązanie z odwołaniami do wartości r, ale funkcje powinny następnie zwracać wartości dla bezpieczeństwa i polegać na Copy Elision / Move Semantics.
Matthieu M.

4

IMHO, drugi przykład jest już wadliwy. To, że zwracane przez modyfikujące operatory *thissą wygodne w sposób, o którym wspomniałeś: pozwala na tworzenie łańcucha modyfikatorów. To może być wykorzystane do przekazania po prostu na skutek modyfikacji, ale robi to jest podatne na błędy, ponieważ może być łatwo przeoczyć. Jeśli zobaczę coś takiego

Vector v{1., 2., 3.};
auto foo = somefunction1(v, 17);
auto bar = somefunction2(true, v, 2, foo);
auto baz = somefunction3(bar.quun(v), 93.2, v.qwarv(foo));

Nie podejrzewałbym automatycznie, że funkcje modyfikują się vjako efekt uboczny. Oczywiście, że mogliby , ale byłoby to zagmatwane. Więc gdybym miał napisać coś takiego, upewniłbym się, że vpozostanie to niezmienne. Na przykład dodałbym darmowe funkcje

auto normalized(Vector v) -> Vector {return v.normalize();}
auto negated(Vector v) -> Vector {return v.negate();}

a następnie napisz pętle

for( double x : negated(normalized(v)) ) { ... }

i

for( double x : normalized(Vector{1., 2., 3}) ) { ... }

To jest lepiej czytelne IMO i jest bezpieczniejsze. Oczywiście wymaga to dodatkowej kopii, jednak w przypadku danych przydzielonych na stertę można by to prawdopodobnie zrobić tanią operacją przenoszenia w C ++ 11.


Dzięki. Jak zwykle jest wiele możliwości. Jedną z sytuacji, w której twoja sugestia może nie być wykonalna, jest sytuacja, w której Vector jest tablicą (nie przydzieloną sterty) na przykład 1000 podwójnych. Kompromis wydajności, łatwości obsługi i bezpieczeństwa użytkowania.
ndkrempel

2
Tak, ale i tak rzadko warto mieć na stosie struktury o rozmiarze> ≈100.
lewo około
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.