22.3 -- Przenieś konstruktory i przenieś przypisanie

W lekcji 22.1 -- Wprowadzenie do inteligentnych wskaźników i semantyki ruchów, przyjrzeliśmy się std::auto_ptr, omówiliśmy potrzebę semantyki przenoszenia i przyjrzeliśmy się niektórym wadom, które występują, gdy funkcje przeznaczone do semantyki kopiowania (konstruktory kopiowania i operatory przypisania kopiowania) są przedefiniowane w celu implementacji semantyki przenoszenia.

W tej lekcji przyjrzymy się bliżej, jak C++11 rozwiązuje te problemy za pomocą konstruktorów przenoszenia i przenoszenia przypisanie.

Podsumowanie konstruktorów kopiujących i przypisanie kopii

Najpierw poświęćmy chwilę na podsumowanie semantyki kopiowania.

Konstruktory kopiujące służą do inicjowania klasy poprzez utworzenie kopii obiektu tej samej klasy. Przypisanie kopiujące służy do kopiowania jednego obiektu klasy do innego istniejącego obiektu klasy. Domyślnie C++ udostępnia konstruktor kopiujący i operator przypisania kopiowania, jeśli nie został on wyraźnie podany. Te funkcje dostarczane przez kompilator wykonują płytkie kopie, co może powodować problemy dla klas, które alokują pamięć dynamiczną. Zatem klasy zajmujące się pamięcią dynamiczną powinny zastąpić te funkcje, aby móc wykonywać głębokie kopie.

Wracając do naszego przykładu klasy inteligentnego wskaźnika Auto_ptr z pierwszej lekcji w tym rozdziale, spójrzmy na wersję, która implementuje konstruktor kopiujący i operator przypisania kopiowania, które wykonują głębokie kopie, oraz przykładowy program, który je wykonuje:

#include <iostream>

template<typename T>
class Auto_ptr3
{
	T* m_ptr {};
public:
	Auto_ptr3(T* ptr = nullptr)
		: m_ptr { ptr }
	{
	}

	~Auto_ptr3()
	{
		delete m_ptr;
	}

	// Kopiuj konstruktor
	// Wykonaj głęboką kopię a.m_ptr do m_ptr
	Auto_ptr3(const Auto_ptr3& a)
	{
		m_ptr = new T;
		*m_ptr = *a.m_ptr;
	}

	// Kopiuj przypisanie
	// Wykonaj głęboką kopię a.m_ptr do m_ptr
	Auto_ptr3& operator=(const Auto_ptr3& a)
	{
		// Wykrywanie samodzielnego przypisania
		if (&a == this)
			return *this;

		// Zwolnij wszystkie posiadane zasoby
		delete m_ptr;

		// Kopiuj zasób
		m_ptr = new T;
		*m_ptr = *a.m_ptr;

		return *this;
	}

	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
	bool isNull() const { return m_ptr == nullptr; }
};

class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
};

Auto_ptr3<Resource> generateResource()
{
	Auto_ptr3<Resource> res{new Resource};
	return res; // ta wartość zwracana wywoła konstruktor kopiujący
}

int main()
{
	Auto_ptr3<Resource> mainres;
	mainres = generateResource(); // to przypisanie wywoła kopię przypisanie

	return 0;
}

W tym programie używamy funkcji o nazwie generateResource() do tworzenia zasobu zawartego w inteligentnym wskaźniku, który jest następnie przekazywany z powrotem do funkcji main(). Funkcja main() następnie przypisuje to do istniejącego obiektu Auto_ptr3.

Po uruchomieniu tego programu wypisuje:

Resource acquired
Resource acquired
Resource destroyed
Resource acquired
Resource destroyed
Resource destroyed

(Uwaga: możesz otrzymać tylko 4 wyniki, jeśli kompilator pominie wartość zwracaną przez funkcję generateResource())

W przypadku tak prostego programu dzieje się dużo tworzenia i niszczenia zasobów! Co tu się dzieje?

Przyjrzyjmy się bliżej. Program ten składa się z 6 kluczowych kroków (po jednym dla każdego drukowanego komunikatu):

  1. W środku generateResource() tworzona jest zmienna lokalna res, która jest inicjalizowana dynamicznie przydzielanym zasobem, co powoduje pierwszy „Zdobyty zasób”.
  2. Res jest zwracany z powrotem do main() według wartości. Zwracamy tutaj wartość, ponieważ res jest zmienną lokalną — nie można jej zwrócić przez adres ani referencję, ponieważ res zostanie zniszczony po zakończeniu generateResource(). Zatem res to kopia zbudowana w obiekcie tymczasowym. Ponieważ nasz konstruktor kopiujący wykonuje głęboką kopię, przydzielany jest tutaj nowy zasób, co powoduje drugi „Zasób zdobyty”.
  3. Res wykracza poza zakres, niszcząc oryginalnie utworzony Zasób, co powoduje pierwszy „Zasób zniszczony”.
  4. Obiekt tymczasowy jest przypisywany do mainres poprzez przypisanie kopii. Ponieważ nasze przypisanie kopiujące wykonuje również głęboką kopię, przydzielany jest nowy zasób, powodując kolejny „Zasób zdobyty”.
  5. Wyrażenie przypisania kończy się, a obiekt tymczasowy wychodzi poza zakres wyrażenia i zostaje zniszczony, powodując „Zasób zniszczony”.
  6. Pod koniec funkcji main() mainres wychodzi poza zakres i wyświetla się nasz końcowy „Zasób zniszczony”.

A więc w skrócie, ponieważ raz wywołujemy konstruktor kopiujący w celu skopiowania skonstruuj res do tymczasowego i raz skopiuj przypisanie, aby skopiować tymczasowe do mainres, kończy się to alokacją i zniszczeniem w sumie 3 oddzielnych obiektów.

Nieefektywne, ale przynajmniej nie ulega awarii!

Jednak dzięki semantyce przenoszenia możemy działać lepiej.

Przenieś konstruktory i przenieś przypisanie

C++11 definiuje dwie nowe funkcje obsługujące semantykę przenoszenia: konstruktor przenoszenia i operator przypisania przenoszenia. Podczas gdy celem konstruktora kopiującego i przypisania kopiowania jest utworzenie kopii jednego obiektu na drugim, celem konstruktora przenoszenia i przypisania przenoszenia jest przeniesienie własności zasobów z jednego obiektu na inny (co jest zwykle znacznie tańsze niż wykonanie kopii).

Definiowanie konstruktora przenoszenia i przydziału przenoszenia odbywa się analogicznie do ich odpowiedników kopiowania. Jednakże, podczas gdy warianty kopiowania tych funkcji przyjmują parametr referencyjny o stałej wartości l (który będzie wiązał się z niemal wszystkim), warianty przenoszenia tych funkcji wykorzystują parametry referencyjne o wartościach innych niż stałe (które wiążą się tylko z wartościami).

Oto ta sama klasa Auto_ptr3 co powyżej, z dodanym konstruktorem przenoszenia i operatorem przypisania przenoszenia. Dla celów porównawczych pozostawiliśmy konstruktor kopiujący do głębokiego kopiowania i operator przypisania kopiowania.

#include <iostream>

template<typename T>
class Auto_ptr4
{
	T* m_ptr {};
public:
	Auto_ptr4(T* ptr = nullptr)
		: m_ptr { ptr }
	{
	}

	~Auto_ptr4()
	{
		delete m_ptr;
	}

	// Kopiuj konstruktor
	// Wykonaj głęboką kopię a.m_ptr do m_ptr
	Auto_ptr4(const Auto_ptr4& a)
	{
		m_ptr = new T;
		*m_ptr = *a.m_ptr;
	}

	// Move constructor
	// Przenieś własność a.m_ptr na m_ptr
	Auto_ptr4(Auto_ptr4&& a) noexcept
		: m_ptr { a.m_ptr }
	{
		a.m_ptr = nullptr; // więcej o tej linii porozmawiamy poniżej
	}

	// Kopiuj przypisanie
	// Wykonaj głęboką kopię a.m_ptr do m_ptr
	Auto_ptr4& operator=(const Auto_ptr4& a)
	{
		// Wykrywanie samodzielnego przypisania
		if (&a == this)
			return *this;

		// Zwolnij wszystkie posiadane zasoby
		delete m_ptr;

		// Kopiuj zasób
		m_ptr = new T;
		*m_ptr = *a.m_ptr;

		return *this;
	}

	// Move assignment
	// Przenieś własność a.m_ptr na m_ptr
	Auto_ptr4& operator=(Auto_ptr4&& a) noexcept
	{
		// Wykrywanie samodzielnego przypisania
		if (&a == this)
			return *this;

		// Zwolnij wszystkie posiadane zasoby
		delete m_ptr;

		// Przenieś własność a.m_ptr na m_ptr
		m_ptr = a.m_ptr;
		a.m_ptr = nullptr; // więcej o tej linii porozmawiamy poniżej

		return *this;
	}

	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
	bool isNull() const { return m_ptr == nullptr; }
};

class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
};

Auto_ptr4<Resource> generateResource()
{
	Auto_ptr4<Resource> res{new Resource};
	return res; // ta wartość zwracana wywoła przeniesienie konstruktor
}

int main()
{
	Auto_ptr4<Resource> mainres;
	mainres = generateResource(); // to przypisanie wywoła przypisanie ruchu

	return 0;
}

Konstruktor przenoszenia i operator przypisania przenoszenia są proste. Zamiast głęboko kopiować obiekt źródłowy (a) do obiektu docelowego (obiekt ukryty), po prostu przenosimy (kradniemy) zasoby obiektu źródłowego. Wiąże się to z płytkim skopiowaniem wskaźnika źródła do ukrytego obiektu, a następnie ustawieniem wskaźnika źródła na wartość null.

Po uruchomieniu ten program wypisuje:

Resource acquired
Resource destroyed

Tak jest znacznie lepiej!

Przebieg programu jest dokładnie taki sam jak poprzednio. Jednak zamiast wywoływać konstruktor kopiujący i operatory przypisania kopiowania, program ten wywołuje konstruktor przenoszenia i operatory przypisania przenoszenia. Patrząc nieco głębiej:

  1. W środku generateResource() tworzona jest zmienna lokalna res, która jest inicjalizowana dynamicznie przydzielanym zasobem, co powoduje pierwszy „Zdobyty zasób”.
  2. Res jest zwracany z powrotem do main() według wartości. Res to przeniesienie skonstruowane do obiektu tymczasowego, przeniesienie dynamicznie utworzonego obiektu przechowywanego w res do obiektu tymczasowego. Poniżej porozmawiamy o tym, dlaczego tak się dzieje.
  3. Res wykracza poza zakres. Ponieważ res nie zarządza już wskaźnikiem (został przeniesiony do tymczasowego), nie dzieje się tu nic ciekawego.
  4. Obiekt tymczasowy jest przenoszony do mainres. Spowoduje to przeniesienie dynamicznie utworzonego obiektu przechowywanego w pliku tymczasowym do pliku mainres.
  5. Wyrażenie przypisania kończy się, a obiekt tymczasowy wychodzi poza zakres wyrażenia i ulega zniszczeniu. Ponieważ jednak element tymczasowy nie zarządza już wskaźnikiem (został przeniesiony do mainres), również tutaj nie dzieje się nic ciekawego.
  6. Pod koniec funkcji main() mainres wychodzi poza zakres i wyświetla się nasz końcowy „Zasób zniszczony”.

Zamiast więc kopiować nasz zasób dwa razy (raz dla konstruktora kopiującego i raz dla przypisania kopii), przesyłamy go dwukrotnie. Jest to bardziej wydajne, ponieważ zasób jest budowany i niszczony tylko raz, a nie trzy razy.

Powiązana treść

Konstruktory przenoszenia i przypisanie przenoszenia powinny być oznaczone jako noexcept. Mówi to kompilatorowi, że te funkcje nie będą zgłaszać wyjątków.

Wprowadzamy noexcept w lekcji 27,9 — specyfikacje wyjątków i noexcept i omawiamy, dlaczego konstruktory przenoszenia i przypisanie przenoszenia są oznaczone jako noexcept w lekcji 27.10 — std::move_if_noexcept.

Kiedy wywoływany jest konstruktor przenoszenia i przypisanie przenoszenia?

Konstruktor przenoszenia i przypisanie przenoszenia są wywoływane, gdy te funkcje są zdefiniowane, a argumentem konstrukcji lub przypisania jest wartość. Najczęściej ta wartość będzie wartością dosłowną lub tymczasową.

Konstruktor kopiujący i przypisanie kopii są używane w inny sposób (kiedy argument jest wartością lub gdy argument jest wartością, a konstruktor przenoszenia lub funkcje przypisania przenoszenia nie są zdefiniowane).

Niejawny konstruktor przenoszenia i operator przypisania przenoszenia

Kompilator utworzy niejawny konstruktor przenoszenia i operator przypisania przenoszenia, jeśli spełnione są wszystkie poniższe warunki prawda:

  • Nie ma zadeklarowanych przez użytkownika konstruktorów kopiujących ani operatorów przypisania kopiowania.
  • Nie ma zadeklarowanych przez użytkownika konstruktorów przenoszenia ani operatorów przypisania przenoszenia.
  • Nie ma destruktora zadeklarowanego przez użytkownika.

Te funkcje wykonują przenoszenie członowe, które zachowuje się w następujący sposób:

  • Jeśli element ma konstruktor przenoszenia lub przypisanie ruchu (w zależności od przypadku), zostanie to wykonane zostanie wywołany.
  • W przeciwnym razie element zostanie skopiowany.

W szczególności oznacza to, że wskaźniki zostaną skopiowane, a nie przeniesione!

Ostrzeżenie

Niejawny konstruktor przenoszenia i przypisanie przenoszenia skopiują wskaźniki, a nie je przeniosą. Jeśli chcesz przenieść element wskaźnikowy, musisz samodzielnie zdefiniować konstruktor przenoszenia i przypisanie przeniesienia.

Kluczowe informacje dotyczące semantyki przenoszenia

Masz teraz wystarczający kontekst, aby zrozumieć kluczowe informacje stojące za semantyką przenoszenia.

Jeśli konstruujemy obiekt lub wykonujemy zadanie, w którym argumentem jest wartość l, jedyne, co możemy rozsądnie zrobić, to skopiować wartość l. Nie możemy założyć, że zmiana wartości l jest bezpieczna, ponieważ może ona zostać ponownie użyta w dalszej części programu. Jeśli mamy wyrażenie „a = b” (gdzie b jest lwartością), nie moglibyśmy rozsądnie oczekiwać, że b zostanie w jakikolwiek sposób zmienione.

Jeśli jednak skonstruujemy obiekt lub wykonamy zadanie, w którym argumentem jest wartość r, to wiemy, że wartość r jest tylko pewnego rodzaju obiektem tymczasowym. Zamiast go kopiować (co może być kosztowne), możemy po prostu przenieść jego zasoby (co jest tanie) na obiekt, który konstruujemy lub przydzielamy. Jest to bezpieczne, ponieważ element tymczasowy i tak zostanie zniszczony na końcu wyrażenia, więc mamy pewność, że nigdy więcej nie zostanie użyty!

C++ 11, poprzez odniesienia do wartości r, daje nam możliwość zapewnienia różnych zachowań, gdy argumentem jest wartość r w porównaniu z wartością l, umożliwiając nam podejmowanie mądrzejszych i skuteczniejszych decyzji dotyczących zachowania naszych obiektów.

Kluczowa informacja

Semantyka ruchu jest optymalizacją okazja.

Funkcje przenoszenia powinny zawsze pozostawiać oba obiekty w prawidłowym stanie

W powyższych przykładach zarówno konstruktor przenoszenia, jak i funkcje przypisania przenoszenia ustawiają a.m_ptr na nullptr. Może się to wydawać niepotrzebne -- w końcu, jeśli a jest tymczasową wartością r, po co zawracać sobie głowę „czyszczeniem”, jeśli parametr a i tak ma zostać zniszczony?

Odpowiedź jest prosta: Kiedy a wyjdzie poza zakres, zostanie wywołany destruktor dla a , a.m_ptr zostanie usunięty. Jeśli w tym momencie a.m_ptr wciąż wskazuje na ten sam obiekt, co m_ptr, wówczas m_ptr zostanie pozostawiony jako wiszący wskaźnik. Kiedy obiekt zawierający m_ptr zostanie w końcu użyty (lub zniszczony), otrzymamy niezdefiniowane zachowanie.

Wdrażając semantykę przenoszenia, ważne jest, aby upewnić się, że przenoszony obiekt pozostaje w prawidłowym stanie, tak aby uległ prawidłowemu zniszczeniu (bez tworzenia niezdefiniowanego zachowania).

Automatyczne l-wartości zwracane przez wartość mogą być przenoszone zamiast kopiowane

W generateResource() z powyższego przykładu Auto_ptr4, gdy zmienna res jest zwracana przez wartość, jest ona przenoszona zamiast kopiowana, nawet jeśli res jest wartością l. Specyfikacja C++ ma specjalną regułę, która mówi, że automatyczne obiekty zwracane przez funkcję według wartości można przenosić, nawet jeśli są to l-wartości. Ma to sens, ponieważ res i tak miał zostać zniszczony na końcu funkcji! Równie dobrze moglibyśmy ukraść jego zasoby, zamiast tworzyć kosztowną i niepotrzebną kopię.

Chociaż kompilator może przenosić zwracane wartości o wartości l, w niektórych przypadkach może zrobić to jeszcze lepiej, po prostu całkowicie pomijając kopię (co pozwala uniknąć konieczności tworzenia kopii lub wykonywania jakichkolwiek ruchów). W takim przypadku nie zostanie wywołany ani konstruktor kopiujący, ani konstruktor przenoszący.

Wyłączenie kopiowania

W powyższej klasie Auto_ptr4 pozostawiliśmy konstruktor kopiujący i operator przypisania dla celów porównawczych. Jednak w klasach umożliwiających przenoszenie czasami pożądane jest usunięcie konstruktora kopiującego i funkcji przypisania kopii, aby mieć pewność, że kopie nie zostaną utworzone. W przypadku naszej klasy Auto_ptr nie chcemy kopiować naszego szablonowego obiektu T - zarówno dlatego, że jest drogi, jak i każda klasa T może nawet nie obsługiwać kopiowania!

Oto wersja Auto_ptr, która obsługuje semantykę przenoszenia, ale nie semantykę kopiowania:

#include <iostream>

template<typename T>
class Auto_ptr5
{
	T* m_ptr {};
public:
	Auto_ptr5(T* ptr = nullptr)
		: m_ptr { ptr }
	{
	}

	~Auto_ptr5()
	{
		delete m_ptr;
	}

	// Kopiuj konstruktor — kopiowanie nie jest oczyszczane!
	Auto_ptr5(const Auto_ptr5& a) = delete;

	// Move constructor
	// Przenieś własność a.m_ptr na m_ptr
	Auto_ptr5(Auto_ptr5&& a) noexcept
		: m_ptr { a.m_ptr }
	{
		a.m_ptr = nullptr;
	}

	// Kopiuj przypisanie — kopiowanie jest zabronione!
	Auto_ptr5& operator=(const Auto_ptr5& a) = delete;

	// Move assignment
	// Przenieś własność a.m_ptr na m_ptr
	Auto_ptr5& operator=(Auto_ptr5&& a) noexcept
	{
		// Wykrywanie samodzielnego przypisania
		if (&a == this)
			return *this;

		// Zwolnij wszystkie posiadane zasoby
		delete m_ptr;

		// Przenieś własność a.m_ptr na m_ptr
		m_ptr = a.m_ptr;
		a.m_ptr = nullptr;

		return *this;
	}

	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
	bool isNull() const { return m_ptr == nullptr; }
};

Gdybyś próbował przekazać wartość l Auto_ptr5 do funkcji według wartości, kompilator narzekałby, że konstruktor kopiujący wymagany do inicjacji parametru funkcji został usunięty. To dobrze, ponieważ prawdopodobnie i tak powinniśmy przekazywać Auto_ptr5 przez odwołanie do wartości l const!

Auto_ptr5 jest (wreszcie) dobrą klasą inteligentnego wskaźnika. I tak naprawdę standardowa biblioteka zawiera klasę bardzo podobną do tej (której zamiast tego powinieneś używać) o nazwie std::unique_ptr. Porozmawiamy więcej o std::unique_ptr w dalszej części tego rozdziału.

Kolejny przykład

Przyjrzyjmy się innej klasie korzystającej z pamięci dynamicznej: prostej dynamicznej tablicy z szablonami. Ta klasa zawiera konstruktor kopiujący głęboko kopiujący i operator przypisania kopiowania.

#include <cstddef> // dla std::size_t

template <typename T>
class DynamicArray
{
private:
	T* m_array {};
	int m_length {};

	void alloc(int length)
	{
		m_array = new T[static_cast<std::size_t>(length)];
        	m_length = length;
	}
public:
	DynamicArray(int length)
	{
		alloc(length);
	}

	~DynamicArray()
	{
		delete[] m_array;
	}

	// Kopiuj konstruktor
	DynamicArray(const DynamicArray &arr)
	{
		alloc(arr.m_length);
		std::copy_n(arr.m_array, m_length, m_array); // skopiuj elementy m_length z arr do m_array
	}

	// Kopiuj przypisanie
	DynamicArray& operator=(const DynamicArray &arr)
	{
		if (&arr == this)
			return *this;

		delete[] m_array;

		alloc(arr.m_length);

		std::copy_n(arr.m_array, m_length, m_array); // skopiuj elementy m_length z arr do m_array

		return *this;
	}

	int getLength() const { return m_length; }
	T& operator[](int index) { return m_array[index]; }
	const T& operator[](int index) const { return m_array[index]; }
};

Teraz użyjmy tej klasy w programie. Aby pokazać, jak ta klasa radzi sobie, gdy alokujemy milion liczb całkowitych na stercie, wykorzystamy klasę Timer, którą opracowaliśmy w lekcji 18.4 — Synchronizacja kodu. Użyjemy klasy Timer, aby zmierzyć szybkość działania naszego kodu i pokazać różnicę w wydajności pomiędzy kopiowaniem a przenoszeniem.

#include <algorithm> // dla std::copy_n
#include <chrono> // dla funkcji std::chrono
#include <iostream>

// Używa powyższej klasy DynamicArray

class Timer
{
private:
	// Aliasy typów, aby ułatwić dostęp do typu zagnieżdżonego
	using Clock = std::chrono::high_resolution_clock;
	using Second = std::chrono::duration<double, std::ratio<1> >;
	
	std::chrono::time_point<Clock> m_beg { Clock::now() };

public:
	void reset()
	{
		m_beg = Clock::now();
	}
	
	double elapsed() const
	{
		return std::chrono::duration_cast<Second>(Clock::now() - m_beg).count();
	}
};

// Zwróć kopię arr z podwojonymi wszystkimi wartościami
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
	DynamicArray<int> dbl(arr.getLength());
	for (int i = 0; i < arr.getLength(); ++i)
		dbl[i] = arr[i] * 2;

	return dbl;
}

int main()
{
	Timer t;

	DynamicArray<int> arr(1000000);

	for (int i = 0; i < arr.getLength(); i++)
		arr[i] = i;

	arr = cloneArrayAndDouble(arr);

	std::cout << t.elapsed();
}

Na jednej z maszyn autora, w trybie wydania, program ten wykonał się w 0,00825559 sekundy.

Teraz zaktualizujemy DynamicArray zastępując konstruktor kopiujący i przypisanie kopiowania konstruktorem przenoszenia i przypisaniem przenoszenia, a następnie uruchom program ponownie:

#include <cstddef> // dla std::size_t

template <typename T>
class DynamicArray
{
private:
	T* m_array {};
	int m_length {};

	void alloc(int length)
	{
		m_array = new T[static_cast<std::size_t>(length)];
		m_length = length;
	}
public:
	DynamicArray(int length)
	{
		alloc(length);
	}

	~DynamicArray()
	{
		delete[] m_array;
	}

	// Kopiuj konstruktor
	DynamicArray(const DynamicArray &arr) = delete;

	// Kopiuj przypisanie
	DynamicArray& operator=(const DynamicArray &arr) = delete;

	// Move constructor
	DynamicArray(DynamicArray &&arr) noexcept
		:  m_array { arr.m_array }, m_length { arr.m_length }
	{
		arr.m_length = 0;
		arr.m_array = nullptr;
	}

	// Move assignment
	DynamicArray& operator=(DynamicArray &&arr) noexcept
	{
		if (&arr == this)
			return *this;

		delete[] m_array;

		m_length = arr.m_length;
		m_array = arr.m_array;
		arr.m_length = 0;
		arr.m_array = nullptr;

		return *this;
	}

	int getLength() const { return m_length; }
	T& operator[](int index) { return m_array[index]; }
	const T& operator[](int index) const { return m_array[index]; }
};

#include <iostream>
#include <chrono> // dla funkcji std::chrono

class Timer
{
private:
	// Aliasy typów, aby ułatwić dostęp do typu zagnieżdżonego
	using Clock = std::chrono::high_resolution_clock;
	using Second = std::chrono::duration<double, std::ratio<1> >;
	
	std::chrono::time_point<Clock> m_beg { Clock::now() };

public:
	void reset()
	{
		m_beg = Clock::now();
	}
	
	double elapsed() const
	{
		return std::chrono::duration_cast<Second>(Clock::now() - m_beg).count();
	}
};

// Zwróć kopię arr z podwojonymi wszystkimi wartościami
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
	DynamicArray<int> dbl(arr.getLength());
	for (int i = 0; i < arr.getLength(); ++i)
		dbl[i] = arr[i] * 2;

	return dbl;
}

int main()
{
	Timer t;

	DynamicArray<int> arr(1000000);

	for (int i = 0; i < arr.getLength(); i++)
		arr[i] = i;

	arr = cloneArrayAndDouble(arr);

	std::cout << t.elapsed();
}

Na tej samej maszynie program ten został wykonany w 0,0056 sekundy.

Porównanie czasu wykonania dwóch programów, (0,00825559 - 0,0056) / 0,00825559 * 100 = 32,1% szybciej!

Usunięcie konstruktora ruchu i ruchu przypisanie

Możesz usunąć konstruktor przenoszenia i przenieść przypisanie, używając = delete składni dokładnie w ten sam sposób, w jaki możesz usunąć konstruktor kopiujący i przypisanie.

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

class Name
{
private:
    std::string m_name {};

public:
    Name(std::string_view name) : m_name{ name }
    {
    }

    Name(const Name& name) = delete;
    Name& operator=(const Name& name) = delete;
    Name(Name&& name) = delete;
    Name& operator=(Name&& name) = delete;

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

int main()
{
    Name n1{ "Alex" };
    n1 = Name{ "Joe" }; // błąd: usunięto przypisanie przeniesienia

    std::cout << n1.get() << '\n';

    return 0;
}

Jeśli usuniesz konstruktor kopiujący, kompilator nie wygeneruje ukrytego konstruktora przenoszenia (co sprawi, że obiektów nie będzie można kopiować ani przenosić). Dlatego też podczas usuwania konstruktora kopiującego warto jasno określić, jakiego zachowania oczekujesz od konstruktorów przenoszenia. Usuń je jawnie (jasno, że jest to pożądane zachowanie) lub domyślnie je (uczyń klasę tylko przenoszeniem).

Kluczowa informacja

Klasa reguły pięciu mówi, że jeśli konstruktor kopiujący, przypisanie kopiujące, konstruktor przenoszenia, przypisanie przenoszenia lub destruktor są zdefiniowane lub usunięte, wówczas każda z tych funkcji powinna zostać zdefiniowana lub usunięta.

Usunięcie tylko konstruktora przenoszenia i przypisania przenoszenia może wydawać się dobrym pomysłem, jeśli chcesz mieć obiekt, który można kopiować, ale nie można przenosić, ma to tę niefortunną konsekwencję, że klasa nie może zostać zwrócona według wartości w przypadkach, gdy nie ma zastosowania obowiązkowe usuwanie kopii. Dzieje się tak, ponieważ usunięty konstruktor przenoszenia jest nadal zadeklarowaną funkcją i dlatego kwalifikuje się do rozpoznawania przeciążenia. Zwrócenie według wartości będzie faworyzowało usunięty konstruktor przenoszenia w stosunku do nieusuniętego konstruktora kopiującego. Ilustruje to następujący program:

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

class Name
{
private:
    std::string m_name {};

public:
    Name(std::string_view name) : m_name{ name }
    {
    }

    Name(const Name& name) = default;
    Name& operator=(const Name& name) = default;

    Name(Name&& name) = delete;
    Name& operator=(Name&& name) = delete;

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

Name getJoe()
{
    Name joe{ "Joe" };
    return joe; // błąd: Konstruktor przenoszenia został usunięty
}

int main()
{
    Name n{ getJoe() };

    std::cout << n.get() << '\n';

    return 0;
}

Problemy z semantyką przenoszenia oraz std::swap Zaawansowane

W lekcji 21.12 — Przeciążanie operatora przypisania, wspomnieliśmy o idiomie kopiowania i zamiany. Kopiowanie i zamiana działają również w przypadku semantyki przenoszenia, co oznacza, że ​​możemy zaimplementować nasz konstruktor przenoszenia i przypisanie przenoszenia, zamieniając zasoby z obiektem, który zostanie zniszczony.

Ma to dwie zalety:

  • Trwały obiekt kontroluje teraz zasoby, które wcześniej były własnością umierającego obiektu (co było naszym głównym celem).
  • Umierający obiekt kontroluje teraz zasoby, które wcześniej były własnością trwałego obiektu. Kiedy umierający obiekt faktycznie umrze, może dokonać dowolnego rodzaju oczyszczenia tych zasobów.

Kiedy myślisz o wymianie, pierwszą rzeczą, która przychodzi na myśl jest zwykle std::swap(). Jednak implementacja konstruktora przenoszenia i przypisania przenoszenia za pomocą std::swap() jest problematyczna, ponieważ std::swap() wywołuje zarówno konstruktor przenoszenia, jak i przypisanie przenoszenia obiektów obsługujących przesuwanie. Spowoduje to nieskończony problem rekurencji.

Możesz to zobaczyć w następującym przykładzie:

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

class Name
{
private:
    std::string m_name {}; // std::string może przenosić

public:
    Name(std::string_view name) : m_name{ name }
    {
    }

    Name(const Name& name) = delete;
    Name& operator=(const Name& name) = delete;

    Name(Name&& name) noexcept
    {
        std::cout << "Move ctor\n";

        std::swap(*this, name); // bad!
    }

    Name& operator=(Name&& name) noexcept
    {
        std::cout << "Move assign\n";

        std::swap(*this, name); // bad!

        return *this;
    }

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

int main()
{
    Name n1{ "Alex" };   
    n1 = Name{"Joe"}; // wywołuje przypisanie ruchu

    std::cout << n1.get() << '\n';
    
    return 0;
}

Wypisuje:

Move assign
Move ctor
Move ctor
Move ctor
Move ctor

I tak dalej… aż do przepełnienia stosu.

Możesz zaimplementować konstruktor przenoszenia i przypisanie przenoszenia za pomocą własnej funkcji wymiany, o ile funkcja elementu członkowskiego wymiany nie wywołuje konstruktora przenoszenia ani przypisania przenoszenia. Oto przykład, jak można to zrobić:

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

class Name
{
private:
    std::string m_name {};

public:
    Name(std::string_view name) : m_name{ name }
    {
    }

    Name(const Name& name) = delete;
    Name& operator=(const Name& name) = delete;
    
    // Utwórz własną funkcję wymiany znajomych, aby zamienić człony Imię
    friend void swap(Name& a, Name& b) noexcept
    {
        // Unikamy wywołań rekurencyjnych poprzez wywoływanie std::swap na elemencie std::string,
        // nie w Imię
        std::swap(a.m_name, b.m_name);
    }

    Name(Name&& name) noexcept
    {
        std::cout << "Move ctor\n";

        swap(*this, name); // Teraz wywołujemy naszą zamianę, a nie std::swap
    }

    Name& operator=(Name&& name) noexcept
    {
        std::cout << "Move assign\n";

        swap(*this, name); // Teraz wywołujemy naszą zamianę, a nie std::swap

        return *this;
    }

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

int main()
{
    Name n1{ "Alex" };   
    n1 = Name{"Joe"}; // wywołuje przypisanie ruchu

    std::cout << n1.get() << '\n';

    return 0;
}

Działa to zgodnie z oczekiwaniami i wypisuje:

Move assign
Joe
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:  
426 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze