14.12 — Konstruktory delegujące

Gdy to możliwe, chcemy zredukować nadmiarowy kod (zgodnie z zasadą DRY – Don’t Repeat Yourself).

Rozważ następujące funkcje:

void A()
{
    // statements that do task A
}

void B()
{
    // statements that do task A
    // statements that do task B
}

Obie funkcje mają zbiór instrukcji, które robią dokładnie to samo (zadanie A). W takim przypadku możemy przeprowadzić refaktoryzację w następujący sposób:

void A()
{
    // statements that do task A
}

void B()
{
    A();
    // statements that do task B
}

W ten sposób usunęliśmy zbędny kod, który istniał w funkcjach A() i B(). Dzięki temu nasz kod jest łatwiejszy w utrzymaniu, ponieważ zmiany wystarczy wprowadzić tylko w jednym miejscu.

Gdy klasa zawiera wiele konstruktorów, niezwykle często kod w każdym konstruktorze jest podobny, jeśli nie identyczny, z dużą ilością powtórzeń. Podobnie chcielibyśmy usunąć, tam gdzie to możliwe, nadmiarowość konstruktorów.

Rozważ następujący przykład:

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };
    bool m_isManager { false };

public:
    Employee(std::string_view name, int id) // Employees must have a name and an id
        : m_name{ name }, m_id { id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }

    Employee(std::string_view name, int id, bool isManager) // They can optionally be a manager
        : m_name{ name }, m_id{ id }, m_isManager { isManager }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1{ "James", 7 };
    Employee e2{ "Dave", 42, true };
}

Treść każdego konstruktora ma dokładnie tę samą instrukcję print.

Nota autora

Generalnie nie jest dobrym pomysłem, aby konstruktor coś wypisywał (z wyjątkiem celów debugowania), ponieważ oznacza to, że nie można utworzyć obiektu przy użyciu tego konstruktora w przypadku, gdy nie chcesz czegoś wydrukować. Robimy to w tym przykładzie, aby pomóc zilustrować, co się dzieje.

Konstruktory mogą wywoływać inne funkcje, w tym inne funkcje składowe klasy. Moglibyśmy więc przeprowadzić refaktoryzację w następujący sposób:

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name { "???" };
    int m_id{ 0 };
    bool m_isManager { false };

    void printCreated() const // our new helper function
    {
        std::cout << "Employee " << m_name << " created\n";
    }

public:
    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id }
    {
        printCreated(); // we call it here
    }

    Employee(std::string_view name, int id, bool isManager)
        : m_name{ name }, m_id{ id }, m_isManager { isManager }
    {
        printCreated(); // and here
    }
};

int main()
{
    Employee e1{ "James", 7 };
    Employee e2{ "Dave", 42, true };
}

Chociaż jest to lepsze niż poprzednia wersja (ponieważ zbędna instrukcja została zastąpiona zbędnym wywołaniem funkcji), wymaga wprowadzenia nowej funkcji. Obaj nasi dwaj konstruktorzy również inicjują m_name i m_id. Idealnie byłoby, gdybyśmy usunęli także tę nadmiarowość.

Czy możemy zrobić lepiej? Możemy. Ale w tym miejscu wielu nowych programistów wpada w kłopoty.

Wywołanie konstruktora w treści funkcji tworzy obiekt tymczasowy

Analogicznie do tego, jak mieliśmy funkcję B() wywołanie funkcji A() w powyższym przykładzie oczywistym rozwiązaniem wydaje się wywołanie konstruktora Employee(std::string_view, int) z treści z Employee(std::string_view, int, bool) w celu zainicjowania m_name, m_id i wydrukowania wyciągu. Oto jak to wygląda:

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };
    bool m_isManager { false };

public:
    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id } // this constructor initializes name and id
    {
        std::cout << "Employee " << m_name << " created\n"; // our print statement is back here
    }

    Employee(std::string_view name, int id, bool isManager)
        : m_isManager { isManager } // this constructor initializes m_isManager
    {
        // Call Employee(std::string_view, int) to initialize m_name and m_id
        Employee(name, id); // this doesn't work as expected!
    }

    const std::string& getName() const { return m_name; }
};

int main()
{
    Employee e2{ "Dave", 42, true };
    std::cout << "e2 has name: " << e2.getName() << "\n"; // print e2.m_name
}

Ale to nie działa poprawnie, ponieważ program wypisuje następujący komunikat:

Employee Dave created
e2 has name: ???

Mimo że Employee Dave created wydrukowany, po e2 zakończeniu budowy, e2.m_name wydaje się, że nadal jest ustawiony na wartość początkową "???". Jak to możliwe?

Spodziewaliśmy się Employee(name, id) wywołania konstruktora w celu kontynuacji inicjalizacji bieżącego ukrytego obiektu (e2). Ale inicjalizacja obiektu klasy zostaje zakończona po zakończeniu wykonywania listy inicjatorów składowych. Zanim zaczniemy wykonywać treść konstruktora, jest już za późno na dalszą inicjalizację.

Gdy zostanie wywołane z treści funkcji, coś, co wygląda na wywołanie funkcji do konstruktora, zwykle tworzy i bezpośrednio inicjuje obiekt tymczasowy (w innym przypadku zamiast tego pojawi się błąd kompilacji). W powyższym przykładzie Employee(name, id); tworzy tymczasowy (nienazwany) obiekt Pracownik. Tym obiektem tymczasowym jest ten, którego m_name ustawiono na Dave i to ten, który drukuje Employee Dave created. Następnie element tymczasowy zostaje zniszczony. e2 nigdy nie m_name lub m_id zmieniono <<<M35>>>wartości domyślnych.

Najlepsza praktyka

Konstruktorów nie należy wywoływać bezpośrednio z treści innej funkcji. Spowoduje to albo błąd kompilacji, albo spowoduje bezpośrednią inicjalizację obiektu tymczasowego.
Jeśli chcesz obiektu tymczasowego, preferuj inicjalizację listy (która jasno pokazuje, że masz zamiar utworzyć obiekt).

Jeśli więc nie możemy wywołać konstruktora z treści innego konstruktora, to jak rozwiązać ten problem?

Delegowanie konstruktorów

Konstruktorzy mogą delegować (przenosić odpowiedzialność za) inicjalizacja do innego konstruktora z tego samego typu klasy. Proces ten jest czasami nazywany łączeniem konstruktorów , a takie konstruktory nazywane są konstruktorami delegującymi.

Aby jeden konstruktor delegował inicjalizację innemu konstruktorowi, po prostu wywołaj konstruktor na liście inicjatorów składowych:

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };

public:
    Employee(std::string_view name)
        : Employee{ name, 0 } // delegate initialization to Employee(std::string_view, int) constructor
    {
    }

    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id } // actually initializes the members
    {
        std::cout << "Employee " << m_name << " created\n";
    }

};

int main()
{
    Employee e1{ "James" };
    Employee e2{ "Dave", 42 };
}

Po wywołaniu e1 { "James" } jest inicjalizowany, pasujący konstruktor Employee(std::string_view) jest wywoływany z parametrem name ustawionym na "James". Lista inicjatorów składowych tego konstruktora deleguje inicjalizację innemu konstruktorowi, więc zostaje wywołana funkcja Employee(std::string_view, int) . Jako pierwszy argument przekazywana jest wartość name ("James", a jako drugi argument literał 0 . Lista inicjatorów elementów członkowskich delegowanego konstruktora następnie inicjuje elementy członkowskie. Następnie zostaje uruchomiona treść delegowanego konstruktora. Następnie kontrola wraca do początkowego konstruktora, którego (pusta) treść jest uruchamiana. Na koniec kontrola wraca do wywołującego.

Wadą tej metody jest to, że czasami wymaga ona powielenia wartości inicjujących. W delegacji do konstruktora Employee(std::string_view, int) potrzebujemy wartości inicjującej dla parametru int . Musieliśmy zakodować na stałe literał 0, ponieważ nie ma możliwości odniesienia się do domyślnego inicjatora elementu członkowskiego.

Kilka dodatkowych uwag na temat delegowania konstruktorów. Po pierwsze, konstruktor delegujący do innego konstruktora nie może samodzielnie inicjować żadnego elementu członkowskiego. Zatem twoi konstruktorzy mogą delegować lub inicjować, ale nie jedno i drugie.

Na marginesie…

Zauważ, że Employee(std::string_view) (konstruktor z mniejszą liczbą parametrów) delegował do Employee(std::string_view name, int id) (konstruktor z większą liczbą parametrów). Często zdarza się, że konstruktor z mniejszą liczbą parametrów jest delegowany do konstruktora z większą liczbą parametrów.

Gdybyśmy zamiast tego wybrali Employee(std::string_view name, int id) delegację do Employee(std::string_view), wówczas nie moglibyśmy zainicjować m_id za pomocą id, ponieważ konstruktor może tylko delegować lub inicjować, a nie jedno i drugie.

Po drugie, jeden konstruktor może delegować do inny konstruktor, który deleguje z powrotem do pierwszego konstruktora. Tworzy to nieskończoną pętlę i powoduje, że w programie zabraknie miejsca na stosie i nastąpi awaria. Można tego uniknąć, upewniając się, że wszystkie konstruktory są rozpoznawane jako konstruktor niedelegujący.

Najlepsza praktyka

Jeśli masz wiele konstruktorów, zastanów się, czy możesz użyć konstruktorów delegujących, aby zredukować duplikat kodu.

Redukcja konstruktorów przy użyciu argumentów domyślnych

Czasami można również użyć wartości domyślnych, aby zredukować wiele konstruktorów do mniejszej liczby konstruktorów. Na przykład, umieszczając wartość domyślną naszego parametru id , możemy utworzyć pojedynczy Employee konstruktor, który wymaga argumentu name, ale opcjonalnie akceptuje argument id:

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name{};
    int m_id{ 0 }; // default member initializer

public:

    Employee(std::string_view name, int id = 0) // default argument for id
        : m_name{ name }, m_id{ id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1{ "James" };
    Employee e2{ "Dave", 42 };
}

Ponieważ wartości domyślne muszą być dołączone do parametrów znajdujących się najbardziej na prawo w wywołaniu funkcji, dobrą praktyką podczas definiowania klas jest zdefiniowanie elementów, dla których użytkownik muszą poda najpierw wartości inicjujące (a następnie skrajne lewe parametry konstruktora). Elementy, dla których użytkownik może opcjonalnie podać (ponieważ dopuszczalne są wartości domyślne), należy zdefiniować jako drugie (a następnie ustawić je jako parametry konstruktora znajdujące się najbardziej na prawo).

Najlepsza praktyka

Elementy, dla których użytkownik musi podać wartości inicjujące, należy zdefiniować jako pierwsze (i jako parametry konstruktora znajdujące się najbardziej na lewo). Elementy, dla których użytkownik może opcjonalnie podać wartości inicjujące (ponieważ wartości domyślne są dopuszczalne) powinny być zdefiniowane jako drugie (i jako parametry konstruktora znajdujące się najbardziej na prawo).

Zauważ, że ta metoda wymaga również zduplikowania domyślnej wartości inicjalizacyjnej dla m_id (‚0”): raz jako domyślny inicjator elementu i raz jako domyślny argument.

Zagadka: nadmiarowe konstruktory vs nadmiarowe wartości domyślne wartości

W powyższych przykładach użyliśmy konstruktorów delegujących, a następnie argumentów domyślnych, aby zmniejszyć redundancję konstruktorów. Ale obie te metody wymagały od nas zduplikowania wartości inicjalizacyjnych dla naszych członków w różnych miejscach. Niestety, obecnie nie ma sposobu, aby określić, że konstruktor delegujący lub argument domyślny powinien używać domyślnej wartości inicjatora elementu członkowskiego.

Istnieją różne opinie na temat tego, czy lepiej jest mieć mniej konstruktorów (z powielaniem wartości inicjujących), czy więcej konstruktorów (bez powielania wartości inicjujących). Naszym zdaniem zazwyczaj prościej jest mieć mniej konstruktorów, nawet jeśli skutkuje to zduplikowaniem wartości inicjujących.

Dla zaawansowanych czytelników

Gdy mamy wartość inicjującą, która jest używana w wielu miejscach (np. jako domyślny inicjator elementu członkowskiego i domyślny argument parametru konstruktora), możemy zdefiniować nazwaną stałą i użyć jej wszędzie tam, gdzie jest potrzebna nasza wartość inicjująca. Pozwala to na zdefiniowanie wartości inicjalizacyjnej w jednym miejscu.

Chociaż można do tego użyć zmiennej globalnej constexpr, lepszą opcją jest użycie static constexpr elementu wewnątrz klasy:

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    static constexpr int default_id { 0 }; // define a named constant with our desired initialization value
    
    std::string m_name {};
    int m_id { default_id }; // we can use it here

public:

    Employee(std::string_view name, int id = default_id) // and we can use it here
        : m_name { name }, m_id { id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1 { "James" };
    Employee e2 { "Dave", 42 };
}

Użycie słowa kluczowego static w tym kontekście pozwala nam mieć pojedynczy default_id element współdzielony przez wszystkie Employee obiekty. Bez static każdy Employee obiekt miałby swój własny, niezależny default_id element (co działałoby, ale byłoby stratą pamięci).

Wadą tego podejścia jest to, że każda dodatkowa nazwana stała dodaje inną nazwę, którą należy zrozumieć, co czyni klasę nieco bardziej zagraconą i złożoną. To, czy jest to tego warte, zależy od tego, ile takich stałych jest wymaganych i w ilu miejscach potrzebne są wartości inicjujące.

Statyczne składowe danych omówimy w lekcji 15.6 -- Statyczne zmienne składowe.

Czas quizu

Pytanie nr 1

Napisz klasę o nazwie Ball. Piłka powinna mieć dwie prywatne zmienne członkowskie, jedną przechowującą kolor (wartość domyślna: black) i drugą przechowującą promień (wartość domyślna: 10.0). Dodaj 4 konstruktory, po jednym do obsługi każdego poniższego przypadku:

int main()
{
    Ball def{};
    Ball blue{ "blue" };
    Ball twenty{ 20.0 };
    Ball blueTwenty{ "blue", 20.0 };

    return 0;
}

Program powinien dać następujący wynik:

Ball(black, 10)
Ball(blue, 10)
Ball(black, 20)
Ball(blue, 20)

Pokaż rozwiązanie

Pytanie nr 2

Zmniejsz liczbę konstruktorów w powyższym programie, używając domyślnych argumentów i delegując konstruktory.

Pokaż rozwiązanie

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
344 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze