21.12 — Przeciążanie operatora przypisania

Klasa operator przypisania kopiowania (operator=) służy do kopiowania wartości z jednego obiektu do innego już istniejącego obiektu.

Powiązana treść

Począwszy od C++11, C++ obsługuje także opcję „Przenieś przypisanie”. Przypisywanie przenoszenia omawiamy na lekcji 22.3 -- Przenieś konstruktory i przenieś przypisanie.

Przypisanie kopiowania a konstruktor kopiowania

Zadanie konstruktora kopiującego i operatora przypisania kopiowania jest prawie równoważne — oba kopiują jeden obiekt do drugiego. Konstruktor kopiujący inicjuje jednak nowe obiekty, natomiast operator przypisania zastępuje zawartość istniejących obiektów.

Różnica pomiędzy konstruktorem kopiującym a operatorem przypisania kopiującego powoduje sporo zamieszania wśród nowych programistów, ale tak naprawdę nie jest to wcale takie trudne. Podsumowując:

  • Jeśli nowy obiekt musi zostać utworzony, zanim nastąpi kopiowanie, używany jest konstruktor kopiujący (uwaga: obejmuje to przekazywanie lub zwracanie obiektów przez wartość).
  • Jeśli nie ma potrzeby tworzenia nowego obiektu, zanim nastąpi kopiowanie, używany jest operator przypisania.

Przeciążanie operatora przypisania

Przeciążanie operatora przypisania kopiowania (operator=) jest dość proste, z jednym konkretnym zastrzeżeniem, do którego dojdziemy. Operator przypisania kopiowania musi być przeciążony jako funkcja składowa.

#include <cassert>
#include <iostream>

class Fraction
{
private:
	int m_numerator { 0 };
	int m_denominator { 1 };

public:
	// Default constructor
	Fraction(int numerator = 0, int denominator = 1 )
		: m_numerator { numerator }, m_denominator { denominator }
	{
		assert(denominator != 0);
	}

	// Copy constructor
	Fraction(const Fraction& copy)
		: m_numerator { copy.m_numerator }, m_denominator { copy.m_denominator }
	{
		// no need to check for a denominator of 0 here since copy must already be a valid Fraction
		std::cout << "Copy constructor called\n"; // just to prove it works
	}

	// Overloaded assignment
	Fraction& operator= (const Fraction& fraction);

	friend std::ostream& operator<<(std::ostream& out, const Fraction& f1);
        
};

std::ostream& operator<<(std::ostream& out, const Fraction& f1)
{
	out << f1.m_numerator << '/' << f1.m_denominator;
	return out;
}

// A simplistic implementation of operator= (see better implementation below)
Fraction& Fraction::operator= (const Fraction& fraction)
{
    // do the copy
    m_numerator = fraction.m_numerator;
    m_denominator = fraction.m_denominator;

    // return the existing object so we can chain this operator
    return *this;
}

int main()
{
    Fraction fiveThirds { 5, 3 };
    Fraction f;
    f = fiveThirds; // calls overloaded assignment
    std::cout << f;

    return 0;
}

Wypisuje:

5/3

To wszystko powinno być już całkiem proste. Nasz przeciążony operator= zwraca *to, abyśmy mogli połączyć ze sobą wiele przypisań:

int main()
{
    Fraction f1 { 5, 3 };
    Fraction f2 { 7, 2 };
    Fraction f3 { 9, 5 };

    f1 = f2 = f3; // chained assignment

    return 0;
}

Problemy wynikające z samodzielnego przypisania

W tym miejscu sprawy zaczynają się robić nieco bardziej interesujące. C++ umożliwia samodzielne przypisanie:

int main()
{
    Fraction f1 { 5, 3 };
    f1 = f1; // self assignment

    return 0;
}

Wywoła to f1.operator=(f1) i zgodnie z uproszczoną implementacją powyżej wszyscy członkowie zostaną przypisani do siebie. W tym konkretnym przykładzie samoprzypisanie powoduje, że każdy członek zostaje przypisany do siebie, co nie ma żadnego ogólnego wpływu poza stratą czasu. W większości przypadków samodzielne przypisanie nie musi w ogóle nic robić!

Jednak w przypadkach, gdy operator przypisania musi dynamicznie przypisywać pamięć, samodzielne przypisanie może być w rzeczywistości niebezpieczne:

#include <algorithm> // for std::max and std::copy_n
#include <iostream>

class MyString
{
private:
	char* m_data {};
	int m_length {};

public:
	MyString(const char* data = nullptr, int length = 0 )
		: m_length { std::max(length, 0) }
	{
		if (length)
		{
			m_data = new char[static_cast<std::size_t>(length)];
			std::copy_n(data, length, m_data); // copy length elements of data into m_data
		}
	}
	~MyString()
	{
		delete[] m_data;
	}

	MyString(const MyString&) = default; // some compilers (gcc) warn if you have pointer members but no declared copy constructor

	// Overloaded assignment
	MyString& operator= (const MyString& str);

	friend std::ostream& operator<<(std::ostream& out, const MyString& s);
};

std::ostream& operator<<(std::ostream& out, const MyString& s)
{
	out << s.m_data;
	return out;
}

// A simplistic implementation of operator= (do not use)
MyString& MyString::operator= (const MyString& str)
{
	// if data exists in the current string, delete it
	if (m_data) delete[] m_data;

	m_length = str.m_length;
	m_data = nullptr;

	// allocate a new array of the appropriate length
	if (m_length)
		m_data = new char[static_cast<std::size_t>(str.m_length)];

	std::copy_n(str.m_data, m_length, m_data); // copies m_length elements of str.m_data into m_data

	// return the existing object so we can chain this operator
	return *this;
}

int main()
{
	MyString alex("Alex", 5); // Meet Alex
	MyString employee;
	employee = alex; // Alex is our newest employee
	std::cout << employee; // Say your name, employee

	return 0;
}

Najpierw uruchom program w niezmienionej postaci. Zobaczysz, że program wypisuje „Alex” tak, jak powinien.

Teraz uruchom następujący program:

int main()
{
    MyString alex { "Alex", 5 }; // Meet Alex
    alex = alex; // Alex is himself
    std::cout << alex; // Say your name, Alex

    return 0;
}

Prawdopodobnie otrzymasz wynik śmieciowy. Co się stało?

Zastanów się, co dzieje się w przeciążonym operator=, gdy zarówno ukryty obiekt ORAZ przekazany parametr (str) są zmiennymi alex. W tym przypadku m_data jest tym samym, co str.m_data. Pierwszą rzeczą, która się dzieje, jest to, że funkcja sprawdza, czy ukryty obiekt ma już ciąg znaków. Jeśli tak, musi go usunąć, aby nie doszło do wycieku pamięci. W tym przypadku przydzielane jest m_data, więc funkcja usuwa m_data. Ale ponieważ str jest tym samym co *this, ciąg, który chcieliśmy skopiować, został usunięty, a m_data (i str.m_data) pozostają w zawieszeniu.

Później przydzielamy nową pamięć do m_data (i str.m_data). Zatem kiedy później kopiujemy dane z str.m_data do m_data, kopiujemy śmieci, ponieważ str.m_data nigdy nie zostało zainicjowane.

Wykrywanie i obsługa samodzielnego przypisania

Na szczęście możemy wykryć, kiedy następuje samoprzypisanie. Oto zaktualizowana implementacja naszego przeciążonego operatora= dla klasy MyString:

MyString& MyString::operator= (const MyString& str)
{
	// self-assignment check
	if (this == &str)
		return *this;

	// if data exists in the current string, delete it
	if (m_data) delete[] m_data;

	m_length = str.m_length;
	m_data = nullptr;

	// allocate a new array of the appropriate length
	if (m_length)
		m_data = new char[static_cast<std::size_t>(str.m_length)];

	std::copy_n(str.m_data, m_length, m_data); // copies m_length elements of str.m_data into m_data

	// return the existing object so we can chain this operator
	return *this;
}

Dzięki sprawdzeniu, czy adres naszego ukrytego obiektu jest taki sam, jak adres obiektu przekazywanego jako parametr, możemy sprawić, że nasz operator przypisania po prostu zwróci natychmiast, nie wykonując żadnej innej pracy.

Ponieważ jest to tylko porównanie wskaźników, powinno być szybkie i nie wymaga przeciążenia operatora==.

Jeśli nie do obsługi samodzielnego przypisania

Zazwyczaj sprawdzanie samodzielnego przypisania jest pomijane w przypadku konstruktorów kopiujących. Ponieważ kopiowany obiekt jest nowo utworzony, jedynym przypadkiem, w którym nowo utworzony obiekt może być równy kopiowanemu obiektowi, jest próba zainicjowania nowo zdefiniowanego obiektu samym sobą:

someClass c { c };

W takich przypadkach kompilator powinien Cię ostrzec, że c jest niezainicjowaną zmienną.

Po drugie, sprawdzenie samodzielnego przydziału można pominąć w klasach, które w naturalny sposób radzą sobie z samodzielnym przydziałem. Rozważmy ten operator przypisania klasy Ułamek, który ma zabezpieczenie przed samodzielnym przypisaniem:

// A better implementation of operator=
Fraction& Fraction::operator= (const Fraction& fraction)
{
    // self-assignment guard
    if (this == &fraction)
        return *this;

    // do the copy
    m_numerator = fraction.m_numerator; // can handle self-assignment
    m_denominator = fraction.m_denominator; // can handle self-assignment

    // return the existing object so we can chain this operator
    return *this;
}

Gdyby zabezpieczenie przed samoprzypisaniem nie istniało, ta funkcja nadal działałaby poprawnie podczas samodzielnego przydziału (ponieważ wszystkie operacje wykonywane przez tę funkcję mogą poprawnie obsłużyć samodzielne przypisanie).

Ponieważ samodzielne przypisanie jest rzadkim zdarzeniem, niektórzy wybitni guru C++ zalecają pomijanie tego zabezpieczenia nawet w klasach, które odniosłyby z tego korzyści. Nie zalecamy tego, ponieważ uważamy, że lepszą praktyką jest kodowanie defensywne, a później selektywna optymalizacja.

Idiom kopiowania i zamiany

Lepszym sposobem radzenia sobie z problemami związanymi z samodzielnym przypisaniem jest tak zwany idiom kopiowania i zamiany. Istnieje świetny opis działania tego idiomu w przypadku przepełnienia stosu.

niejawny operator przypisania kopiowania

W przeciwieństwie do innych operatorów, kompilator zapewni niejawny publiczny operator przypisania kopii dla twojej klasy, jeśli nie podasz operatora zdefiniowanego przez użytkownika. Ten operator przypisania dokonuje przypisania członkowskiego (co jest zasadniczo takie samo jak inicjalizacja członkowa wykonywana przez domyślne konstruktory kopiujące).

Podobnie jak inne konstruktory i operatory, możesz zapobiec wykonywaniu przypisań, ustawiając operator przypisania kopiowania jako prywatny lub używając słowa kluczowego usuwania:

#include <cassert>
#include <iostream>

class Fraction
{
private:
	int m_numerator { 0 };
	int m_denominator { 1 };

public:
    // Default constructor
    Fraction(int numerator = 0, int denominator = 1)
        : m_numerator { numerator }, m_denominator { denominator }
    {
        assert(denominator != 0);
    }

	// Copy constructor
	Fraction(const Fraction &copy) = delete;

	// Overloaded assignment
	Fraction& operator= (const Fraction& fraction) = delete; // no copies through assignment!

	friend std::ostream& operator<<(std::ostream& out, const Fraction& f1);
        
};

std::ostream& operator<<(std::ostream& out, const Fraction& f1)
{
	out << f1.m_numerator << '/' << f1.m_denominator;
	return out;
}

int main()
{
    Fraction fiveThirds { 5, 3 };
    Fraction f;
    f = fiveThirds; // compile error, operator= has been deleted
    std::cout << f;

    return 0;
}

Zauważ, że jeśli twoja klasa ma stałe elementy członkowskie, kompilator zamiast tego zdefiniuje ukryty operator= jako usunięty. Dzieje się tak, ponieważ nie można przypisać elementów const, więc kompilator przyjmie, że Twojej klasy nie powinno można przypisywać.

Jeśli chcesz, aby klasa ze składnikami const była możliwa do przypisania (dla wszystkich członków, którzy nie są stałymi), będziesz musiał jawnie przeciążyć operator= i ręcznie przypisać każdy element inny niż const.

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