Ta lekcja stanowi kontynuację naszego wprowadzenia do konstruktorów z lekcji 14.9 -- Wprowadzenie do konstruktorów.
Inicjalizacja składowej poprzez inicjalizację składową lista
Aby konstruktor inicjował elementy członkowskie, robimy to za pomocą listy inicjatorów elementów (często nazywanej „listą inicjowania elementów”). Nie należy tego mylić z podobnie nazwaną „listą inicjatorów”, która jest używana do inicjowania agregatów za pomocą listy wartości.
Listy inicjalizacyjne elementów składowych najlepiej poznać na przykładzie. W poniższym przykładzie nasz Foo(int, int) konstruktor został zaktualizowany tak, aby używał listy inicjatorów składowych do inicjalizacji m_x, I m_y:
#include <iostream>
class Foo
{
private:
int m_x {};
int m_y {};
public:
Foo(int x, int y)
: m_x { x }, m_y { y } // here's our member initialization list
{
std::cout << "Foo(" << x << ", " << y << ") constructed\n";
}
void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
}
};
int main()
{
Foo foo{ 6, 7 };
foo.print();
return 0;
}Lista inicjatorów składowych jest zdefiniowana po parametrach konstruktora. Rozpoczyna się dwukropkiem (:), a następnie wyświetla listę każdego elementu członkowskiego do zainicjowania wraz z wartością inicjującą dla tej zmiennej, oddzieloną przecinkiem. Musisz tutaj użyć bezpośredniej formy inicjalizacji (najlepiej przy użyciu nawiasów klamrowych, ale nawiasy też działają) - użycie inicjalizacji kopiowania (z równymi) nie działa tutaj. Należy również pamiętać, że lista inicjatorów elementów nie kończy się średnikiem.
Ten program generuje następujące dane wyjściowe:
Foo(6, 7) constructed Foo(6, 7)
Po wywołaniu foo utworzona jest instancja, elementy listy inicjalizacyjnej są inicjowane określonymi wartościami inicjalizacyjnymi. W tym przypadku lista inicjatorów składowych inicjuje m_x do wartości x (czyli 6) i m_y do wartości y (czyli 7). Następnie zostaje uruchomiona treść konstruktora.
Gdy print() Wywoływana jest funkcja składowa, widać, że m_x nadal ma wartość 6 i m_y nadal ma wartość 7.
Formatowanie listy inicjatorów składowych
C++ zapewnia dużą swobodę w formatowaniu list inicjatorów składowych według własnych upodobań, ponieważ nie ma znaczenia, gdzie umieścisz dwukropek, przecinek lub białe znaki.
Wszystkie poniższe style są poprawne (i prawdopodobnie zobaczysz wszystkie trzy w praktyce):
Foo(int x, int y) : m_x { x }, m_y { y }
{
} Foo(int x, int y) :
m_x { x },
m_y { y }
{
} Foo(int x, int y)
: m_x { x }
, m_y { y }
{
}Naszym zaleceniem jest użycie trzeciego stylu powyżej:
- Umieść dwukropek w wierszu po nazwie konstruktora, ponieważ w ten sposób wyraźnie oddziela się listę inicjatorów składowych od prototypu funkcji.
- Wstaw listę inicjatorów składowych, aby ułatwić zobaczenie funkcji nazwy.
Jeśli lista inicjalizacyjna elementu jest krótka/trywialna, wszystkie inicjatory mogą znajdować się w jednym wierszu:
Foo(int x, int y)
: m_x { x }, m_y { y }
{
}W przeciwnym razie (lub jeśli wolisz) każdy element i para inicjatorów można umieścić w osobnej linii (zaczynając od przecinka, aby zachować wyrównanie):
Foo(int x, int y)
: m_x { x }
, m_y { y }
{
}Kolejność inicjowania elementów
Ponieważ tak mówi standard C++, elementy na liście inicjatorów elementów są zawsze inicjowane w kolejności, w jakiej są zdefiniowane w klasie (a nie w kolejności, w jakiej są zdefiniowane na liście inicjatorów składowych).
W powyższym przykładzie, ponieważ m_x jest zdefiniowane przed m_y w definicji klasy, m_x zostanie zainicjowane jako pierwsze (nawet jeśli nie jest wymienione jako pierwsze na liście inicjatorów składowych).
Ponieważ intuicyjnie oczekujemy, że zmienne będą inicjowane od lewej do prawej, może to powodować subtelne błędy. Rozważmy następujący przykład:
#include <algorithm> // for std::max
#include <iostream>
class Foo
{
private:
int m_x{};
int m_y{};
public:
Foo(int x, int y)
: m_y { std::max(x, y) }, m_x { m_y } // issue on this line
{
}
void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
}
};
int main()
{
Foo foo { 6, 7 };
foo.print();
return 0;
}W powyższym przykładzie naszym zamiarem jest obliczenie większej z wartości inicjujących przekazanych (przez std::max(x, y) a następnie użycie tej wartości do zainicjowania obu m_x i m_y. Jednakże na maszynie autora drukowany jest następujący wynik:
Foo(-858993460, 7)
Co się stało? Mimo że m_y jest wymieniony jako pierwszy na liście inicjalizacji pręta, ponieważ m_x jest zdefiniowany jako pierwszy w klasie, m_x jest inicjowany jako pierwszy i m_x jest inicjowany do wartości m_y, która nie została jeszcze zainicjowana.Na koniec m_y jest inicjowany do większej z wartości inicjalizacyjnych.
Aby zapobiec takim błędom, elementy listy inicjatorów składowych powinny być wymienione w kolejności, w jakiej są zdefiniowane w klasie. Niektóre kompilatory wygenerują ostrzeżenie, jeśli elementy zostaną zainicjowane w niewłaściwej kolejności.
Najlepsza praktyka
Zmienne składowe na liście inicjatorów elementów powinny być wymienione w takiej kolejności, w jakiej są zdefiniowane w klasie.
Dobrym pomysłem jest również unikanie inicjowania elementów przy użyciu wartości innych elementów (jeśli to możliwe). W ten sposób, nawet jeśli popełnisz błąd w kolejności inicjalizacji, nie powinno to mieć znaczenia, ponieważ nie ma zależności między wartościami inicjującymi.
Lista inicjatorów elementów a domyślne inicjatory elementów
Elementy można inicjować na kilka różnych sposobów:
- Jeśli element jest wymieniony na liście inicjatorów elementu, używana jest ta wartość inicjująca
- W przeciwnym razie, jeśli element ma element domyślny inicjator, używana jest ta wartość inicjująca
- W przeciwnym razie element jest inicjowany domyślnie.
Oznacza to, że jeśli element ma zarówno domyślny inicjator elementu członkowskiego, jak i jest wymieniony na liście inicjatorów elementu członkowskiego dla konstruktora, wartość listy inicjatorów elementu ma pierwszeństwo.
Oto przykład pokazujący wszystkie trzy metody inicjalizacji:
#include <iostream>
class Foo
{
private:
int m_x {}; // default member initializer (will be ignored)
int m_y { 2 }; // default member initializer (will be used)
int m_z; // no initializer
public:
Foo(int x)
: m_x { x } // member initializer list
{
std::cout << "Foo constructed\n";
}
void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ", " << m_z << ")\n";
}
};
int main()
{
Foo foo { 6 };
foo.print();
return 0;
}Na komputerze autora jest to wynik:
Foo constructed Foo(6, 2, -858993460)
Oto co się dzieje. Kiedy foo jest konstruowany, na liście inicjatorów składowych pojawia się tylko m_x , więc m_x jest najpierw inicjowany do 6. m_y nie ma go na liście inicjalizacji składowej, ale ma domyślny inicjator składowej, więc jest inicjowany do 2. m_z nie znajduje się na liście inicjalizacji składowej ani nie ma domyślnego inicjatora składowej, więc jest default-initialized (co w przypadku typów podstawowych oznacza, że pozostaje niezainicjowany). Zatem gdy wypiszemy wartość m_z, otrzymamy niezdefiniowane zachowanie.
Ciała funkcji konstruktorów
Ciała funkcji konstruktorów najczęściej pozostają puste. Dzieje się tak dlatego, że do inicjalizacji używamy przede wszystkim konstruktora, co odbywa się za pośrednictwem listy inicjatorów składowych. Jeśli to wszystko, co musimy zrobić, nie potrzebujemy żadnych instrukcji w treści konstruktora.
Jednakże, ponieważ instrukcje w treści konstruktora są wykonywane po wykonaniu listy inicjatorów składowych, możemy dodać instrukcje, aby wykonać inne wymagane zadania konfiguracyjne. W powyższych przykładach wypisujemy coś na konsoli, aby pokazać, że konstruktor wykonał, ale moglibyśmy zrobić inne rzeczy, np. otworzyć plik lub bazę danych, przydzielić pamięć itp.
Nowi programiści czasami używają treści konstruktora do przypisywania wartości członom:
#include <iostream>
class Foo
{
private:
int m_x { 0 };
int m_y { 1 };
public:
Foo(int x, int y)
{
m_x = x; // incorrect: this is an assignment, not an initialization
m_y = y; // incorrect: this is an assignment, not an initialization
}
void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
}
};
int main()
{
Foo foo { 6, 7 };
foo.print();
return 0;
}Chociaż w tym prostym przypadku da to oczekiwany wynik, w przypadku gdy wymagane jest zainicjowanie elementów (np. w przypadku elementów danych będących stałymi lub referencjami) przypisanie nie będzie praca.
Kluczowa informacja
Po zakończeniu wykonywania listy inicjalizującej element, obiekt uważa się za zainicjowany. Po zakończeniu wykonywania treści funkcji obiekt uważa się za skonstruowany.
Najlepsza praktyka
Wolę używać listy inicjatorów składowych do inicjowania elementów zamiast przypisywać wartości w treści konstruktora.
Wykrywanie i obsługa nieprawidłowych argumentów konstruktorów
Rozważ następującą klasę ułamków:
class Fraction
{
private:
int m_numerator {};
int m_denominator {};
public:
Fraction(int numerator, int denominator):
m_numerator { numerator }, m_denominator { denominator }
{
}
};Ponieważ ułamek jest licznikiem podzielonym przez mianownik, mianownik ułamka nie może wynosić zero (w przeciwnym razie otrzymamy dzielenie przez zero, co jest matematycznie nieokreślone). Innymi słowy, ta klasa ma niezmiennik, który m_denominator nie może być 0.
Powiązana treść
Niezmienniki klasy omawialiśmy na lekcji >14.2 -- Wprowadzenie do klas.
Co więc robimy, gdy użytkownik próbuje utworzyć ułamek o mianowniku zerowym (np. Fraction f { 1, 0 };)?
Na liście inicjatorów składowych nasze narzędzia do wykrywania i obsługi błędów są dość ograniczone. Możemy użyć operatora warunkowego, aby wykryć błąd, ale co wtedy?
class Fraction
{
private:
int m_numerator {};
int m_denominator {};
public:
Fraction(int numerator, int denominator):
m_numerator { numerator }, m_denominator { denominator != 0.0 ? denominator : ??? } // what do we do here?
{
}
};Moglibyśmy zmienić mianownik na prawidłową wartość, ale wtedy użytkownik otrzyma Fraction który nie zawiera wartości, o które prosił, i nie mamy możliwości powiadomienia go, że zrobiliśmy coś nieoczekiwanego. Dlatego zazwyczaj nie będziemy próbować przeprowadzać żadnego rodzaju sprawdzania poprawności na liście inicjatorów składowych — po prostu zainicjujemy elementy przekazanymi wartościami, a następnie spróbujemy poradzić sobie z sytuacją.
W treści konstruktora możemy używać instrukcji, dzięki czemu mamy więcej opcji wykrywania i obsługi błędów. To dobre miejsce, aby assert lub static_assert przekazać argumenty, które są semantycznie poprawne, ale w rzeczywistości nie obsługuje to błędów wykonania w kompilacji produkcyjnej.
Gdy konstruktor nie może skonstruować semantycznie poprawnego obiektu, mówimy, że się nie powiódł.
Gdy konstruktory zawiodą (wstęp)
W lekcji 9.4 — Wykrywanie i obsługa błędów, wprowadziliśmy temat obsługi błędów i omówiliśmy niektóre opcje obsługi przypadków, w których funkcja nie można kontynuować z powodu wystąpienia błędu. Ponieważ konstruktory są funkcjami, są podatne na te same problemy.
W tej lekcji zaproponowaliśmy 4 strategie radzenia sobie z takimi błędami:
- Napraw błąd wewnątrz funkcji.
- Przekaż błąd z powrotem do wywołującego, aby się nim zajął.
- Zatrzymaj program.
- Zgłoś wyjątek.
W większości przypadkach nie mamy wystarczających informacji, aby rozwiązać takie problemy całkowicie w konstruktorze. Zatem naprawienie problemu na ogół nie wchodzi w grę.
W przypadku funkcji niebędących składowymi i niespecjalnymi możemy przekazać błąd z powrotem do osoby wywołującej, aby się nim zajął. Ale konstruktory nie zwracają wartości, więc nie mamy na to dobrego sposobu. W niektórych przypadkach możemy dodać isValid() funkcję składową (lub przeciążoną konwersję do bool), która zwraca, czy obiekt jest obecnie w prawidłowym stanie, czy nie. Na przykład funkcja isValid() dla Fraction zwróci true gdy m_denominator != 0.0. Oznacza to jednak, że osoba wywołująca musi pamiętać o wywołaniu funkcji za każdym razem, gdy tworzona jest nowa frakcja. Posiadanie dostępnych semantycznie nieprawidłowych obiektów prawdopodobnie doprowadzi do błędów. Więc chociaż jest to lepsze niż nic, nie jest to zbyt dobra opcja.
W niektórych typach programów możemy po prostu zatrzymać cały program i pozwolić użytkownikowi na ponowne uruchomienie programu z odpowiednimi danymi wejściowymi... ale w większości przypadków jest to po prostu niedopuszczalne. Więc prawdopodobnie nie.
A to pozostawia wyjątek. Wyjątki całkowicie przerywają proces konstrukcji, co oznacza, że użytkownik nigdy nie uzyska dostępu do obiektu semantycznie nieprawidłowego. Zatem w większości przypadków najlepszym rozwiązaniem w takich sytuacjach jest zgłoszenie wyjątku.
Kluczowa informacja
Zgłoszenie wyjątku jest zwykle najlepszym rozwiązaniem, gdy konstruktor zawiedzie (i nie może go odzyskać). Omawiamy to szerzej na lekcjach 27.5 — Wyjątki, klasy i dziedziczenie i 27.7 — Bloki próbne funkcji.
Nota autora
Na razie ogólnie zakładamy, że konstrukcja naszego obiektu klasy zakończyła się pomyślnie utworzeniem obiektu poprawnego semantycznie.
Dla zaawansowanych czytelników
Jeśli wyjątki nie są możliwe lub pożądane (albo dlatego, że zdecydowałeś się ich nie używać, albo dlatego, że jeszcze się o nich nie dowiedziałem), istnieje jeszcze jedna rozsądna opcja. Zamiast pozwalać użytkownikowi na bezpośrednie utworzenie klasy, udostępnij funkcję, która albo zwróci instancję klasy, albo coś, co wskazuje na błąd.
W poniższym przykładzie nasza createFraction() funkcja zwraca std::optional<Fraction> , która opcjonalnie zawiera prawidłowy Fraction. Jeśli tak, możemy użyć tego ułamka. Jeśli nie, dzwoniący może to wykryć i sobie z tym poradzić. Omówimy std::optional w lekcji 12.15 -- std::opcjonalne funkcje znajomych w lekcji 15.8 -- Funkcje niebędące członkami znajomych.
#include <iostream>
#include <optional>
class Fraction
{
private:
int m_numerator { 0 };
int m_denominator { 1 };
// private constructor can't be called by public
Fraction(int numerator, int denominator):
m_numerator { numerator }, m_denominator { denominator }
{
}
public:
// Allow this function to access private members
friend std::optional<Fraction> createFraction(int numerator, int denominator);
};
std::optional<Fraction> createFraction(int numerator, int denominator)
{
if (denominator == 0)
return {};
return Fraction{numerator, denominator};
}
int main()
{
auto f1 { createFraction(0, 1) };
if (f1)
{
std::cout << "Fraction created\n";
}
auto f2 { createFraction(0, 0) };
if (!f2)
{
std::cout << "Bad fraction\n";
}
}Czas quizu
Pytanie nr 1
Napisz klasę o nazwie Ball. Piłka powinna mieć dwie prywatne zmienne składowe, jedną przechowującą kolor i drugą promień. Napisz także funkcję, która wydrukuje kolor i promień kuli.
Następujący przykładowy program powinien się skompilować:
int main()
{
Ball blue { "blue", 10.0 };
print(blue);
Ball red { "red", 12.0 };
print(red);
return 0;
}i wygeneruj wynik:
Ball(blue, 10) Ball(red, 12)
Pytanie nr 2
Dlaczego stworzyliśmy print() funkcję niebędącą składową zamiast funkcji składowej?
Pytanie nr 3
Dlaczego stworzyliśmy m_color a std::string zamiast std::string_view?

