Punkt 4 - Unikaj niepotrzebnych konstruktorów domyślnych
Można powiedzieć, że konstruktor domyślny (ang. default constructor), czyli konstruktor, który może być wywoływany bez argumentów, jest w języku C++ takim narzędziem, które pozwala Ci spodziewać się, że możesz uzyskać coś za nic. Konstruktory inicjują obiekty, więc konstruktory domyślne inicjują obiekty bez pobierania informacji z miejsca,, w którym dany obiekt będzie tworzony. Czasami jest to całkiem uzasadnione. Na przykład obiekty, które są traktowane tak jak liczby, można inicjować liczbą zero lub pozostawić niezdefiniowane. Obiekty traktowane tak jak wskaźniki można inicjować obiektem pustym lub niezdefiniowanymi wartościami.. Struktury danych takie jak listy,, tablice rozproszone, tablice odwzorowań itp. Można inicjować jako obszary puste, nie zawierające żadnego elementu. Ale nie dotyczy to wszystkich obiektów. Wielu obiektów nie można w rozsądny sposób w pełni zainicjować, jeżeli brakuje informacji podawanych z zewnątrz. Na przykład każdy pracownik (zatrudniony legalnie) w Stanach Zjednoczonych musi mieć numer ubezpieczenia społecznego (ang. Social Security number), więc nie miałoby raczej sensu utworzenie takiego modelu pracowników amerykańskich, w którym nie wymaga się podawania numeru tego ubezpieczenia. Obiekt reprezentujący pozycję w książce adresowej nie ma sensu bez określenia nazwiska przypisanego danej pozycji. W niektórych przedsiębiorstwa każdy przedmiot należący do wyposażenia (jak aparatura, meble, narzędzia itp.) musi być oznaczony numerem inwentarza, więc tworzenie obiektu modelującego element wyposażenie w takim przedsiębiorstwie jest bezsensowne dopóty, dopóki nie poda się właściwego numeru inwentarza. W doskonałym świecie powinno być tak, że klasy, których obiekty mogą w sposób rozsądny być tworzone z niczego, muszą mieć konstruktory domyślne, klasy zaś, w których do tworzenia obiektów są potrzebne informacje, nie mają takich konstruktorów. Niestety, nie żyjemy w najlepszym z możliwych światów, musimy więc zająć się dodatkowymi rozważaniami na temat korzystania z konstruktorów domyślnych. A szczególnie trzeba przeanalizować sytuację, w której klasa nie ma konstruktora domyślnego, wówczas bowiem można jej używać tylko przy pewnych ograniczeniach. Rozważmy klasę utworzoną dla elementów wyposażenia przedsiębiorstwa, w którym numer inwentarza elementu wyposażenia jest obowiązkowym argumentem konstruktora:
class ElementWyposażenia { public: ElementWyposażenia(int NumerInw); ... };
Ponieważ klasa ElementWyposażenia nie ma konstruktora domyślnego, więc napotkamy kłopoty w trzech sytuacjach. Pierwszą z nich jest tworzenie tablic. W zasadzie nie ma sposobu określania argumentów konstruktorów dla obiektów będących elementami tablic, więc zazwyczaj nie ma możliwości tworzenia tablic złożonych z obiektów klasy ElementWyposażenia:
ElementWyposażenia szafy[10]; // bład, nie ma sposobu wywołania konstruktora // klasy ElementWyposażenia ElementWyposażenia *szafy = new ElementWyposażenia[10]; //błąd z tego samego powodu
Istnieją trzy sposoby obejścia tego ograniczenia. Rozwiązanie dla tablic nie znajdujących się w stercie polega na dostarczeniu niezbędnych argumentów do miejsca, w którym zdefiniowano tablicę:
Niestety, nie można rozszerzyć tej strategii na tablice umieszczane w stercie. Ogólniejsze podejście polega na korzystaniu ze wskaźników zamiast tablicy obiektów:
Typedef ElementyWyposażenia* WEW; // WEW jest wskaźnikiem do obiektu // klasy ElementWyposażenia WEW szafy[10]; // dobrze, nie wywołuje się konstruktorów WEW *szafy = new WEW[10]; // też dobrze
Następnie można sprawić, by każdy wskaźnik w tablicy wskazywał na inny obiekt klasy ElementWyposażenia:
for (int i = 0; i<10; i++) szafy[ i ] = new ElementWyposażenia( Numer inwentarza );
Podejście to ma dwie wady. Po pierwsze, musisz pamiętać o usunięciu wszystkich obiektów wskazywanych przez tę tablicę. Jeśli o tym zapomnisz, to będziesz mieć lukę w zasobach. Po drugie, zwiększa się zapotrzebowanie na pamięć, ponieważ potrzebne jest miejsce zarówno na wskaźniki, jak i na obiekty klasy ElementWyposażenia. Możesz uniknąć tej drugiej niedogodności, jeżeli przydzielisz ciąg bajtów w pamięci dla tablicy, a następnie, aby skonstruować obiekty klasy ElementWyposażenia w pamięci, zastosujesz specjalną składnię operatora new, zwaną umieszczającym operatorem new (ang. placement new) (zob. punkt 8)
// przydziel dostateczny obszar w pamięci dla tablicy zawierającej 10 obiektów klasy // ElementWyposażenia; void pamSurowa = operator new[] (10*sizeof(ElementyWyposażenia)); // spraw, by szafy wskazywały na tę pamięć, tak aby można było // traktować ją jak tablicę obiektów klasy ElementWyposażenia ElementWyposażenia *szafy = static_cast< ElementWyposażenia>(pamSurowa); // stosując umieszczający operator new skonstruuj obiekty klasy // ElementWyposażenia w pamięci for (int i = 0; i<10; i++) new (&szafy[i]) ElementWyposażenia (Numer inwentarza);
Zauważ, że nadal musisz dostarczać argumenty konstruktorów dla wszystkich obiektów klasy ElementWyposażenia. Ta metoda (podobnie jak pomysł zastosowania tablicy wskaźników) pozwala tworzyć tablice obiektów wtedy, kiedy klasa nie ma konstruktora domyślnego. Nie pokazuje ona, w jaki sposób można obejść brak potrzebnych argumentów konstruktora. Nie ma takiej możliwości. Gdyby bowiem istniała, wówczas udaremniłoby to przeznaczenie konstruktorów, które polega na zagwarantowaniu zainicjowania obiektów. Stosowanie umieszczającego operatora new nie jest zbyt popularne. Wynika to po części z faktu, że większość programistów nie jest dostatecznie obeznana z tą wersją operatora new (a to utrudnia utrzymywanie programów), po części zaś stąd, że kiedy zechcemy, by obiekty umieszczone w tablicy przestały istnieć, wtedy musimy sami zaprogramować wywoływanie ich destruktorów, a następnie w sposób jawny uwalniać przydzieloną pamięć, wywołując operator delete[] (zob. punkt 8).
// usuń elementy tablicy w kolejności odwrotnej do tej, w której je utworzono for (int i = 9; i >= 0; --i) szafy[i].~ElementWyposażenia(); // uwolnij przydzieloną pamięć surową operator delete[ ] (pamSurowa);
Jeżeli zapomnisz o tym zastrzeżeniu i skorzystasz ze zwykłego sposobu usuwania tablicy, to nie można przewidzieć, jak zachowa się Twój program. Stanie się tak dlatego, że nie jest zdefiniowany wynik skasowania wskaźnika, którego nie utworzył operator new:
delete [] szafy; // niezdefiniowane, gdyż szafy /// nie były utworzone przez operator new
Więcej informacji o operatorze new oraz umieszczającym operatorze new, a także o ich współdziałaniu z konstruktorami i destruktorami można znaleźć w punkcie 8. Drugi kłopot związany z klasami, w których nie ma konstruktorów domyślnych, polega na tym, że obiekty tych klas nie mogą być elementami wzorcowych klas zbiorczych. Wynika to stąd, że zazwyczaj żąda się, aby dla typu, który posłuży do konkretyzowania takich wzorców, był zdefiniowany konstruktor domyślny. To żądanie prawie zawsze wynika z faktu, że wewnątrz wzorca tworzy się tablicę obiektów typu parametru wzorca. Na przykład wzorzec dla klasy Tablica mógłby wyglądać tak jak poniżej.
template<class T> class Tablica { public: Tablica(int rozmiar); private: T *dane ; };
template<class T> Tablica<T>::Tablica(int rozmiar) { dane = new T[rozmiar]; // wywołuje konstruktor T::T() ... // dla każdego elementu tablicy
}
Konstruktory domyślne mogą okazać się w większości wypadków niepotrzebne, jeśli będziemy starannie projektować wzorce. Na przykład w standardowym wzorcu vector (generującym klasy, którymi można posługiwać się tak jak rozszerzalnymi tablicami) nie ma wymagania, by jego parametr typu miał konstruktor domyślny. Niestety wiele wzorców zaprojektowano nieostrożnie. W takiej sytuacji klasy bez konstruktorów domyślnych będą niezgodne z wieloma wzorcami. Problem ten będzie tracił na znaczeniu w miarę, jak programiści lepiej opanują projektowanie wzorców. Nie można jednak przewidzieć, jak szybko to nastąpi. Trzeci wreszcie problem, który trzeba poruszyć w związku z rozważaniami na temat dostarczania (lub niedostarczania) konstruktorów domyślnych, dotyczy wirtualnych klas podstawowych. Korzystanie z wirtualnych klas podstawowych bez konstruktorów domyślnych jest kłopotliwe. Wynika to stąd, że argumenty konstruktorów wirtualnych klas podstawowych muszą być dostarczone przez większość klas pochodnych, których obiekty mają być utworzone. Tak więc wszystkie klasy pochodne względem klasy nie mającej konstruktora domyślnego (niezależnie od tego, jak od niej oddalone w hierarchii dziedziczenia) muszą o tym wiedzieć i dostarczać argumenty konstruktorów wirtualnej klasy podstawowej. Autorzy klas pochodnych ani nie spodziewają się tego wymagania, ani nie są z tego powodu zachwyceni. W wyniku tych ograniczeń nakładanych na klasy bez konstruktorów domyślnych niektórzy ludzie sądzą, że wszystkie klasy powinny mieć zdefiniowane konstruktory domyślne, nawet wówczas, gdy konstruktor taki nie dostarcza dostatecznej informacji potrzebnej do pełnego zainicjowania obiektów danej klasy. Na przykład zgodnie z tą koncepcją można by tak zmienić definicję klasy ElementWyposażenia:
class ElementWyposażenia { public: ElementWyposażenia(int NumerInw = NIEZNANY); ... private: static const int NIEZNANY; // magiczna wartość numeru
// inwentarza oznaczająca,
}; // że numeru tego nie określono
Dzięki temu obiekty klasy ElementWyposażenia można tworzyć tak jak poniżej.
ElementWyposażenia e; // teraz dobrze
Wprowadzenie takiej zmiany prawie zawsze komplikuje inne metody klasy, ponieważ nie można mieć pewności, że pola obiektów klasy ElementWyposażenia zainicjowano w sposób w pełni znaczący. Skoro przyjęto, że klasa ElementWyposażenia nie ma sensu bez pola NumerInw, zatem większość metod klasy musi sprawdzać, czy to pole istnieje. Jeżeli go nie ma, to metody te muszą jakoś sobie z tym poradzić. Często nie wiadomo, co właściwie powinno się zrobić, więc w wielu implementacjach wybrano oportunistyczne rozwiązanie: zgłoszenie sytuacji wyjątkowej lub wywołanie funkcji kończącej wykonywanie programu. W takim wypadku trudno twierdzić, że polepszono ogólną jakość oprogramowania poprzez umieszczenie konstruktora domyślnego w klasie bez żadnych gwarancji poprawnego działania. Tworzenie takich konstruktorów domyślnych, które nie mają znaczenia, wpływa również na wydajność klas. Jeżeli metody klas muszą sprawdzać, czy pola zostały naprawdę zainicjowane, to korzystanie z tych metod stanie się bardziej kosztowne z kilku powodów. W wyniku tego sprawdzania wydłuży się czas wykonywania programu. Ponadto dołączenie kodu sprawdzania spowoduje, że procedury biblioteczne i programy będą dłuższe. A trzeba jeszcze dołączyć kod obsługi sytuacji, w której wynik sprawdzenia będzie negatywny. Można tych wszystkich dodatkowych kosztów uniknąć, jeśli konstruktory będą zapewniać, że wszystkie pola danego obiektu są poprawnie zainicjowane. Ponieważ często konstruktory domyślne nie mogą dać tego rodzaju zapewnienia, więc najlepiej unikać umieszczania ich w klasach, w których nie mają znaczenia. Powoduje to nakładanie pewnych ograniczeń na sposób korzystania z takich klas, ale daje również pewność, że używając takich klas możesz oczekiwać, iż generowane przez nie obiekty są w pełni zainicjowane i wydajnie zaimplementowane.