30 kwietnia 2013

Constructor() = default/delete;

Podstawy

W poprzedniej wersji C++, jeżeli klasa potrzebowała explicit konstruktorów np. domyślnego, który nic nie robił, trzeba było napisać coś w tym stylu:
class MyClass 
{ 
public: 
    MyClass() {}
};
Zalecane jest jednak by interfejs zawierał deklaracje publicznych metod bez ich implementacji. Rozwiązaniem jest przeniesienie definicji do pliku cpp, jednak to tylko niepotrzebnie zwiększa ilość kodu.
MyClass::MyClass() {}
W nowym standardzie, kompilator wygeneruje za nas domyślny konstruktor bez potrzeby pisania implementacji, jeśli tylko użyjemy słowa default. Podobnie możemy korzystać z delete, gdy nie chcemy jakiegoś konstruktora dla naszej klasy, oraz nie chcemy by kompilator wygenerował konstruktor domyślny. Musimy więc "wykasować" konstruktor domyślny.
#include <iostream>

class MyClass {
public:
    MyClass() = default;
    MyClass(const MyClass& r) = delete;
    MyClass(int v) : value(v) {}

    int value;
};

int main()
{
    MyClass m1;         // default constructor
    MyClass m2(44);
//  MyClass m3 = m2;    // copy constructor - Error z powodu "delete"

    std::cout << "m1 = " << m1.value << std::endl;
    std::cout << "m2 = " << m2.value << std::endl;
//  std::cout << "m3 = " << m3.value << std::endl;

    return 0;
}
Wyniki:
m1 = 134515449
m2 = 44
Możemy też w ten sposób stworzyć operatora przypisania, który domyślnie zrobi płytką kapię zawartości obiektu.
#include <iostream>
#include <vector>

using namespace std;

class MyClass {
public:
    MyClass() = default;
    MyClass(const MyClass& r) = delete;
    MyClass& operator=(const MyClass& r) = default;

    void put(int value) {
        vec.push_back(value);
    }

    void show() const {
        for (const auto& v : vec)
            cout << v << " ";
        cout << endl;
    }

private:
    vector<int> vec;
};

int main()
{
    MyClass m1;
    m1.put(1);
    m1.put(2);

    MyClass m2;
    m2 = m1;
    m2.show();

    return 0;
}
Wynik:
1 2 
Operacje typu default traktowane są jako user-declared, więc np. klasa bez deklaracji destruktora nie jest taka sama jak klasa z destruktorem default. Przykład poniżej.

Można także usunąć funkcję, wtedy zabraniamy niejawnej konwersji argumentów w momencie jej zawołania.
#include <iostream>

bool convert(int val) {
    return true;
}

bool convert(double vale) = delete;

int main()
{
    convert(44);
    convert(12.3); // Error
    return 0;
}

Kiedy kompilator generuje za nas kod

Zasady automatycznego generowania konstruktorów domyślnych/kopiujących, operatorów przypisani/przesunięcia i destruktorów przez kompilator:
  • Jeżeli konstruktor domyślny, konstruktor kopiujący, operator przypisania albo destruktor w klasie bazowej jest skasowany (delete) lub niedostępny (np. przez private), wtedy odpowiadający mechanizm w klasie pochodnej zostanie zdefiniowany jako skasowany (delete).
  • Jeżeli klasa bazowa ma niedostępny albo skasowany (delete) destruktor wtedy zsyntetyzowany konstruktor domyślny i konstruktor kopiujący w klasie pochodnej są zdefiniowane jako delete.
  • Jeżeli klasa bazowa ma niedostępny albo skasowany (delete) destruktor wtedy move konstruktor zostanie zdefiniowany jako delete.
  • Jeżeli operacje move, w klasie bazowej są skasowana albo niedostępna, to pomimo użycia default w klasie pochodnej, odpowiednie operacje nadal będą skasowane (ponieważ składowych klasy bazowej nadal nie można przenieść).

Wirtualny destruktor = default

Typowym sposobem tworzenia interfejsów w C++ jest wykorzystanie klas czysto wirtualnych (Base). Specyfika języka wymaga, aby w klasie takiej znalazł się również zdefiniowany wirtualny destruktor. Teraz zamiast tworzyć jego definicję można posłużyć się default.
struct Base {
    virtual ~Base() = default;

    virtual void fun() = 0;
};

struct Child : public Base {
    void fun() override { cout << "Child::fun" << endl; }
    Data data;
};
Nasuwa się pytanie, na które wciąż nie mam wystarczająco dobrej odpowiedzi. Czy user-declared (default) destruktor sprawi, że nie będzie można skorzystać z move semantic? Stworzyłem kilka eksperymentów i spróbowałem sobie zracjonalizować, dlaczego uzyskałem takie, a nie inne wyniki. W pierwszej kolejności potrzebna jest gadatliwa klasa:
#include <iostream>

using namespace std;

struct Data {
    Data() = default;
    Data(const Data&) {
        cout << "Copy constructor" << endl;
    }
    Data(Data&&) noexcept {
        cout << "Move constructor" << endl;
    }
    Data& operator=(const Data&) {
        cout << "Copy assign" << endl;
        return *this;
    }
    Data& operator=(Data&&) noexcept {
        cout << "Move assign" << endl;
        return *this;
    }
    ~Data() = default;
};
Dziedziczenie "nie uszkadza" (nie kasuje) mechanizmów move semantic w klasie pochodnej. No prawie. Ponieważ klasa Base1 jest czysto wirtualna i nie posiada żadnych danych, więc nie ma też sensu, by narzucać jakieś ograniczenia klasie z niej dziedziczącej w kwestii kopiowania lub przesuwania.
struct Base1 {
    virtual ~Base1() = default;

    virtual void fun() = 0;
};

struct Child1 : public Base1 {
    void fun() override { cout << "Child1::fun" << endl; }
    Data data;
};

int main() {
    Child1 c1;
    auto m1 = std::move(c1);
}
Bez problemu zadziała konstruktor move. Wynik:
Move constructor
Co się jednak stanie, gdy dane zostaną przeniesione do klasy Base (Base2)?
struct Base2 {
    virtual ~Base2() = default;

    virtual void fun() = 0;
    Data data;
};

struct Child2 : public Base2 {
    void fun() override { cout << "Child2::fun" << endl; }
};

int main() {
    Child2 c2;
    auto m2 = std::move(c2);
}
Wynik:
Copy constructor
Co ciekawe teraz move semantic już nie działa, za to działa kopiowanie. W pierwszej kolejności myślałem, że user-declared destruktor kasuje konstruktor move, gdy tylko w klasie znajdują się jakieś dane, ale to dalej nie jest do końca prawda.

Jeżeli user-declared destruktor kasowałby konstruktor move, to kod w którym konstruktor move jest explicit wykasowany (delete) powinien zachowywać się w identyczny sposób. W przykładzie poniżej Base3 się jednak nie skompiluje. Kompilator poinformuje o braku konstruktora move, ale także o braku konstruktora kopiującego. Musiałem więc stworzyć znacznie bardziej rozbudowaną wersją (Base4) z wykasowanym (delete) operatorem move i default-owym konstruktorem kopiującym.
//struct Base3 {
//    Base3(Base3&&) = delete;        // Error
//    virtual ~Base3() = default;
//
//    virtual void fun() = 0;
//    Data data;
//};

//struct Child3 : public Base3 {
//    void fun() override { cout << "Child3::fun" << endl; }
//};

struct Base4 {
    Base4() = default;

    Base4(Base4&&) = delete;
    Base4(const Base4&) = default;
    virtual ~Base4() = default;

    virtual void fun() = 0;
    Data data;
};

struct Child4 : public Base4 {
    void fun() override { cout << "Child4::fun" << endl; }
};

int main() {
//  Child3 c3;
//  auto m3 = std::move(c3);       // Error

    Child4 c4;
    auto m4 = std::move(c4);
}
Wynik:
Copy constructor
W takim razie skoro user-declared destruktor kasuje move konstruktor, to dlaczego nie kasuje także konstruktora kupującego, w końcu w Base4 wykasowany move konstruktor właśnie to uczynił? Najlepsza odpowiedź jaką uzyskałem, to taka, że default destruktor nie tyle kasuje move konstruktor, ale zapobiega jego deklaracji. Mało to intuicyjne jak dla mnie.

Jak widać reguły jakimi rządzi się ten mechanizm są bardzo skomplikowane. Jak więc powinna wyglądać prawidłowo taka klasa. Zalecaną metodą jest deklaracja wszystkich pięciu elementów: The rule of three/five/zero. Jeżeli potrzebujesz choćby jednego zadeklaruj wszystkie.
struct Base0 {
    Base0() = default;

    Base0(Base0&&) = default;
    Base0(const Base0&) = default;
    Base0& operator=(const Base0&) = default;
    Base0& operator=(Base0&&) = default;
    virtual ~Base0() = default;

    virtual void fun() = 0;
    Data data;
};

struct Child0 : public Base0 {
    void fun() override { cout << "Child0::fun" << endl; }
};

int main() {
    Child0 c0;
    auto m0 = std::move(c0);
}
Wynik:
Move constructor

Na koniec jeszcze jeden przypadek, którego już zupełnie nie rozumiem. Jest to zmodyfikowana wersja klasy Base4. Nie jest ona jednak już czysto wirtualna (funkcja fun ma definicję), można więc tworzyć obiekty tej klasy.
struct BaseX {
    BaseX() = default;

    BaseX(BaseX&&) = delete;
    BaseX(const BaseX&) = default;
    virtual ~BaseX() = default;

    virtual void fun() { cout << "BaseX::fun" << endl; }
    Data data;
};

struct ChildX : public BaseX {
    void fun() override { cout << "ChildX::fun" << endl; }
};

int main() {
    BaseX bx;
    auto mb = std::move(bx);    // Error

    ChildX cx;                  // To samo co Child4
    auto mc = std::move(cx);    
}
ChildX niczym nie różnic się od Child4 i tak jak poprzednio std::move zwoła konstruktor kopiujący. Kompilator jednak zaprotestuje, gdy będziemy chcieli zawołać std::move na obiekcie BaseX. Co prawda nie ma konstruktora move, ale dlaczego w zamian nie zostanie zawołany konstruktor kopiujący?
main.cpp: In function ‘int main()’:
main.cpp: error: use of deleted function ‘BaseX::BaseX(BaseX&&)’
        auto mb = std::move(bx);
                              ^
main.cpp: note: declared here
        BaseX(BaseX&&) = delete;
        ^~~~~~

Brak komentarzy:

Prześlij komentarz