Czy jednolita inicjalizacja C ++ 11 zastępuje starą składnię?

172

Rozumiem, że jednolita inicjalizacja C ++ 11 rozwiązuje pewną dwuznaczność składniową w języku, ale w wielu prezentacjach Bjarne Stroustrupa (szczególnie podczas rozmów GoingNative 2012) jego przykłady używają tej składni przede wszystkim teraz, gdy buduje obiekty.

Czy zaleca się teraz stosowanie jednolitej inicjalizacji we wszystkich przypadkach? Jakie powinno być ogólne podejście do tej nowej funkcji, jeśli chodzi o styl kodowania i ogólne zastosowanie? Jakie są powody, aby go nie używać?

Zauważ, że myślę przede wszystkim o konstrukcji obiektu jako moim przypadku użycia, ale jeśli są inne scenariusze do rozważenia, daj mi znać.

void.pointer
źródło
To może być temat lepiej omawiany na Programmers.se. Wydaje się, że pochyla się w stronę dobrej subiektywnej.
Nicol Bolas,
6
@NicolBolas: Z drugiej strony, twoja doskonała odpowiedź może być bardzo dobrym kandydatem na znacznik c ++ - faq. Nie sądzę, żebyśmy wcześniej mieli wyjaśnienie tego postu.
Matthieu M.

Odpowiedzi:

233

Styl kodowania jest ostatecznie subiektywny i jest bardzo mało prawdopodobne, aby przyniosło to znaczne korzyści w zakresie wydajności. Ale oto, co powiedziałbym, że zyskujesz dzięki swobodnemu stosowaniu jednolitej inicjalizacji:

Minimalizuje zbędne nazwy typów

Rozważ następujące:

vec3 GetValue()
{
  return vec3(x, y, z);
}

Dlaczego muszę pisać vec3dwa razy? Czy ma to sens? Kompilator dobrze i dobrze wie, co zwraca funkcja. Dlaczego nie mogę po prostu powiedzieć „wezwać konstruktora tego, co zwracam, z tymi wartościami i zwrócić?” Dzięki jednolitej inicjalizacji mogę:

vec3 GetValue()
{
  return {x, y, z};
}

Wszystko działa poprawnie.

Jeszcze lepsze są argumenty funkcji. Rozważ to:

void DoSomething(const std::string &str);

DoSomething("A string.");

Działa to bez konieczności wpisywania nazwy typu, ponieważ std::stringwie, jak zbudować się const char*niejawnie. To wspaniale. Ale co jeśli ten ciąg pochodzi, powiedz RapidXML. Lub sznur Lua. To znaczy, powiedzmy, że faktycznie znam długość sznurka z przodu. std::stringKonstruktor, że trwa const char*będą musiały podjąć długość napisu jeśli po prostu przejść const char*.

Istnieje przeciążenie, które wyraźnie trwa długo. Ale go używać, będę musiał to zrobić: DoSomething(std::string(strValue, strLen)). Dlaczego jest tam dodatkowa nazwa typu? Kompilator wie, jaki jest typ. Podobnie jak w przypadku auto, możemy uniknąć dodatkowych nazw typów:

DoSomething({strValue, strLen});

To po prostu działa. Bez nazwisk, bez zamieszania, nic. Kompilator wykonuje swoją pracę, kod jest krótszy i wszyscy są zadowoleni.

To prawda, że ​​należy argumentować, że pierwsza wersja ( DoSomething(std::string(strValue, strLen))) jest bardziej czytelna. To jest oczywiste, co się dzieje i kto co robi. W pewnym stopniu jest to prawdą; zrozumienie jednolitego kodu opartego na inicjalizacji wymaga spojrzenia na prototyp funkcji. Jest to ten sam powód, dla którego niektórzy twierdzą, że nigdy nie należy przekazywać parametrów przez odwołanie non-const: abyś mógł zobaczyć w witrynie wywoławczej, jeśli wartość jest modyfikowana.

Ale to samo można powiedzieć auto; wiedza o tym, co otrzymujesz, auto v = GetSomething();wymaga spojrzenia na definicję GetSomething. Ale to nie przestało autobyć używane z niemal lekkomyślnym porzuceniem, gdy masz do niego dostęp. Osobiście uważam, że będzie dobrze, gdy się do tego przyzwyczaisz. Zwłaszcza z dobrym IDE.

Nigdy nie otrzymuj najbardziej wekslowej analizy

Oto kod.

class Bar;

void Func()
{
  int foo(Bar());
}

Pop quiz: co to jest foo? Jeśli odpowiedziałeś „zmienna”, jesteś w błędzie. W rzeczywistości jest to prototyp funkcji, która przyjmuje jako parametr funkcję, która zwraca a Bar, a foozwracaną wartością funkcji jest int.

Nazywa się to „Most Vexing Parse” C ++, ponieważ nie ma absolutnie żadnego sensu dla istoty ludzkiej. Niestety zasady C ++ tego wymagają: jeśli można je interpretować jako prototyp funkcji, to tak właśnie będzie . Problemem jest Bar(); to może być jedna z dwóch rzeczy. Może to być typ o nazwie Bar, co oznacza, że ​​tworzy tymczasowy. Lub może to być funkcja, która nie przyjmuje parametrów i zwraca a Bar.

Jednolita inicjalizacja nie może być interpretowana jako prototyp funkcji:

class Bar;

void Func()
{
  int foo{Bar{}};
}

Bar{}zawsze tworzy tymczasowe. int foo{...}zawsze tworzy zmienną.

Istnieje wiele przypadków, w których chcesz użyć, Typename()ale po prostu nie można tego zrobić z powodu reguł analizy składniowej C ++. Dzięki Typename{}nie ma dwuznaczności.

Powody, dla których nie

Jedyną prawdziwą mocą, którą oddajesz, jest zwężenie. Nie można zainicjować mniejszej wartości większą wartością o jednolitej inicjalizacji.

int val{5.2};

To się nie skompiluje. Możesz to zrobić za pomocą staromodnej inicjalizacji, ale nie jednolitej inicjalizacji.

Zostało to częściowo wykonane, aby listy inicjalizujące rzeczywiście działały. W przeciwnym razie byłoby wiele niejednoznacznych przypadków dotyczących typów list inicjalizacyjnych.

Oczywiście niektórzy mogą argumentować, że taki kod zasługuje na to, by się nie kompilować. Ja osobiście się zgadzam; zwężenie jest bardzo niebezpieczne i może prowadzić do nieprzyjemnego zachowania. Prawdopodobnie najlepiej uchwycić te problemy na wczesnym etapie kompilacji. Przynajmniej zawężenie sugeruje, że ktoś nie myśli zbyt mocno o kodzie.

Zauważ, że kompilatory zazwyczaj ostrzegają cię przed tego rodzaju rzeczami, jeśli poziom ostrzeżenia jest wysoki. Tak naprawdę to wszystko powoduje, że ostrzeżenie staje się wymuszonym błędem. Niektórzy mogą powiedzieć, że i tak powinieneś to robić;)

Jest jeszcze jeden powód, aby nie:

std::vector<int> v{100};

Co to robi? Może stworzyć vector<int>sto domyślnie skonstruowanych przedmiotów. Lub może stworzyć vector<int>z 1 przedmiotem, który ma wartość 100. Oba są teoretycznie możliwe.

W rzeczywistości robi to drugie.

Dlaczego? Listy inicjalizujące używają tej samej składni co jednolita inicjalizacja. Dlatego muszą istnieć pewne zasady wyjaśniające, co zrobić w przypadku niejasności. Zasada jest dość prosta: jeśli kompilator może użyć konstruktora listy inicjalizującej z listą inicjowaną nawiasami klamrowymi, zrobi to . Ponieważ vector<int>ma konstruktor listy inicjalizującej, który pobiera initializer_list<int>, a {100} może być poprawny initializer_list<int>, dlatego musi być .

Aby uzyskać konstruktora zmiany rozmiaru, musisz użyć ()zamiast {}.

Zauważ, że gdyby to było vectorcoś, czego nie można zamienić na liczbę całkowitą, to by się nie zdarzyło. Lista_inicjalizatora nie pasuje do konstruktora listy inicjalizatora tego vectortypu, a zatem kompilator będzie mógł wybierać spośród innych konstruktorów.

Nicol Bolas
źródło
11
+1 przybity. Usuwam moją odpowiedź, ponieważ pańska odnosi się do tych samych punktów bardziej szczegółowo.
R. Martinho Fernandes,
21
Ostatnią kwestią jest to, dlaczego naprawdę chciałbym std::vector<int> v{100, std::reserve_tag};. Podobnie z std::resize_tag. Obecnie zajmuje dwa kroki, aby zarezerwować przestrzeń wektorową.
Xeo
6
@NicolBolas - Dwa punkty: Myślałem, że problemem z dokuczliwą analizą jest foo (), a nie Bar (). Innymi słowy, gdyby tak było, czy nie int foo(10)spotkałbyś tego samego problemu? Po drugie, kolejnym powodem, dla którego nie należy go używać, wydaje się być kwestia nadmiernej inżynierii, ale co, jeśli skonstruujemy wszystkie nasze obiekty przy użyciu {}, ale dzień później w drodze dodam konstruktora do list inicjalizacji? Teraz wszystkie moje instrukcje konstrukcyjne zmieniają się w instrukcje listy inicjalizacyjnej. Wydaje się bardzo kruche pod względem refaktoryzacji. Jakieś komentarze na ten temat?
void.pointer
7
@RobertDailey: „jeśli tak int foo(10), czy nie napotkalibyście tego samego problemu?” Nr 10 to literał całkowity, a literał całkowity nigdy nie może być nazwą typu. Irytująca analiza wynika z faktu, że Bar()może to być nazwa typu lub wartość tymczasowa. To powoduje niejednoznaczność kompilatora.
Nicol Bolas,
8
unpleasant behavior- jest nowy standardowy termin do zapamiętania:>
patrz
64

Nie zgadzam się z sekcją odpowiedzi Nicola Bolasa Minimalizuje zbędne nazwy typowe . Ponieważ kod jest zapisywany jeden raz i czytany wiele razy, powinniśmy starać się zminimalizować czas potrzebny na odczyt i zrozumienie kodu, a nie czas potrzebny na napisanie kodu. Próba jedynie zminimalizowania pisania polega na próbie optymalizacji niewłaściwej rzeczy.

Zobacz następujący kod:

vec3 GetValue()
{
  <lots and lots of code here>
  ...
  return {x, y, z};
}

Ktoś czytający powyższy kod po raz pierwszy prawdopodobnie nie zrozumie instrukcji return, ponieważ zanim osiągnie ten wiersz, zapomni o typie zwrotu. Teraz będzie musiał przewinąć z powrotem do podpisu funkcji lub użyć funkcji IDE, aby zobaczyć typ zwracany i w pełni zrozumieć instrukcję return.

I tutaj znowu nie jest łatwo komuś czytając kod po raz pierwszy zrozumieć, co faktycznie jest konstruowane:

void DoSomething(const std::string &str);
...
const char* strValue = ...;
size_t strLen = ...;

DoSomething({strValue, strLen});

Powyższy kod się zepsuje, gdy ktoś zdecyduje, że DoSomething powinien również obsługiwać inny typ łańcucha i doda to przeciążenie:

void DoSomething(const CoolStringType& str);

Jeśli zdarzy się, że CoolStringType ma konstruktor, który przyjmuje const char * i size_t (podobnie jak std :: string), wówczas wywołanie DoSomething ({strValue, strLen}) spowoduje błąd niejednoznaczności.

Moja odpowiedź na aktualne pytanie:
Nie, nie należy traktować Uniform Inicjalizacji jako zamiennika składni starego konstruktora.

Moje rozumowanie jest następujące:
jeśli dwa stwierdzenia nie mają tego samego rodzaju intencji, nie powinny wyglądać tak samo. Istnieją dwa rodzaje inicjalizacji obiektu:
1) Weź wszystkie te przedmioty i wlej je do tego obiektu, który inicjuję.
2) Skonstruuj ten obiekt, korzystając z tych argumentów, które podałem jako przewodnik.

Przykłady zastosowania pojęcia nr 1:

struct Collection
{
    int first;
    char second;
    double third;
};

Collection c {1, '2', 3.0};
std::array<int, 3> a {{ 1, 2, 3 }};
std::map<int, char> m { {1, '1'}, {2, '2'}, {3, '3'} };

Przykład zastosowania pojęcia nr 2:

class Stairs
{
    std::vector<float> stepHeights;

public:
    Stairs(float initHeight, int numSteps, float stepHeight)
    {
        float height = initHeight;

        for (int i = 0; i < numSteps; ++i)
        {
            stepHeights.push_back(height);
            height += stepHeight;
        }
    }
};

Stairs s (2.5, 10, 0.5);

Myślę, że to źle, że nowy standard pozwala ludziom inicjować schody w następujący sposób:

Stairs s {2, 4, 6};

... ponieważ to zaciemnia znaczenie konstruktora. Taka inicjalizacja wygląda jak pojęcie nr 1, ale tak nie jest. Nie wlewa trzech różnych wartości wysokości kroku do obiektu, nawet jeśli tak wygląda. A także, co ważniejsze, jeśli opublikowano implementację biblioteki Stairs podobną do powyższej, a programiści jej używali, a następnie jeśli implementator biblioteki doda później konstruktora initializer_list do Stairs, wówczas cały kod, który używał Stairs z jednolitą inicjalizacją Składnia się zepsuje.

Uważam, że społeczność C ++ powinna zgodzić się na wspólną konwencję dotyczącą sposobu ujednoliconej inicjalizacji, tj. Równomiernie na wszystkich inicjalizacjach lub, jak sugeruję, oddzielenie tych dwóch pojęć inicjalizacji, a tym samym wyjaśnienie intencji programisty czytelnikowi kod.


AFTERTHOUGHT:
Oto jeszcze jeden powód, dla którego nie powinieneś myśleć o jednolitej inicjalizacji jako zamienniku starej składni i dlaczego nie możesz używać notacji nawiasów klamrowych dla wszystkich inicjalizacji:

Załóżmy, że preferowaną składnią do tworzenia kopii jest:

T var1;
T var2 (var1);

Teraz uważasz, że powinieneś zastąpić wszystkie inicjalizacje nową składnią nawiasów klamrowych, abyś mógł (i kod będzie wyglądać) bardziej spójny. Ale składnia wykorzystująca nawiasy klamrowe nie działa, jeśli typ T jest agregacją:

T var2 {var1}; // fails if T is std::array for example
TommiT
źródło
48
Jeśli masz „<dużo i dużo kodu tutaj>”, Twój kod będzie trudny do zrozumienia bez względu na składnię.
kevin cline
8
Oprócz IMO obowiązkiem twojego IDE jest poinformowanie cię, jaki typ zwraca (np. Poprzez najechanie kursorem). Oczywiście, jeśli nie używasz IDE, wziąłeś na siebie ciężar :)
abergmeier 22.12.12
4
@TommiT Zgadzam się z niektórymi częściami tego, co mówisz. Jednak w tym samym duchu, co autow porównaniu z deklaracji wyraźny typu debaty, będę argumentować dla równowagi: jednolite inicjalizatory kołysać dość duży czas w szablonie meta-programowania sytuacjach, w których typ jest zazwyczaj dość oczywiste, w każdym razie. Pozwoli to uniknąć powtarzania twojego cholerstwa skomplikowanego -> decltype(....)do inkantacji, np. Dla prostych szablonów funkcji oneline (doprowadziło mnie do płaczu).
patrz
5
Ale składnia wykorzystująca nawiasy klamrowe nie działa, jeśli typ T jest agregatem: ” Należy pamiętać, że jest to zgłaszana wada standardowego, a nie zamierzonego, oczekiwanego zachowania.
Nicol Bolas,
5
„Teraz będzie musiał przewinąć z powrotem do podpisu funkcji”, jeśli musisz przewinąć, twoja funkcja jest za duża.
Miles Rout
-3

Jeśli konstruktory merely copy their parametersw odpowiednich klasach zmiennych, in exactly the same orderw których są zadeklarowane wewnątrz klasy, to użycie jednolitej inicjalizacji może ostatecznie być szybsze (ale może być również całkowicie identyczne) niż wywołanie konstruktora.

Oczywiście nie zmienia to faktu, że zawsze musisz zadeklarować konstruktora.


źródło
2
Dlaczego mówisz, że może być szybciej?
jbcoe
To jest niepoprawne. Nie ma wymogu, aby zadeklarować konstruktor: struct X { int i; }; int main() { X x{42}; }. Niepoprawne jest również to, że jednolita inicjalizacja może być szybsza niż inicjalizacja wartości.
Tim