13.15 – Перегрузка оператора присваивания

Добавлено 21 июля 2021 в 18:04

Оператор присваивания (operator=) используется для копирования значений из одного объекта в другой, уже существующий объект.

Присваивание и конструктор копирования

Назначение конструктора копирования и оператора присваивания почти эквивалентны – оба копируют один объект в другой. Однако конструктор копирования инициализирует новые объекты, тогда как оператор присваивания заменяет содержимое существующих объектов.

Разница между конструктором копирования и оператором присваивания вызывает много путаницы у начинающих программистов, но на самом деле это не так уж и сложно. Обобщим:

  • Если новый объект должен быть создан до того, как может произойти копирование, используется конструктор копирования (примечание: это включает в себя передачу или возврат объектов по значению).
  • Если новый объект не нужно создавать до того, как может произойти копирование, используется оператор присваивания.

Перегрузка оператора присваивания

Перегрузка оператора присваивания (operator=) довольно проста, с одной конкретной оговоркой, которую мы рассмотрим. Оператор присваивания должен быть перегружен как функция-член.

#include <cassert>
#include <iostream>
 
class Fraction
{
private:
    int m_numerator;
    int m_denominator;
 
public:
    // Конструктор по умолчанию
    Fraction(int numerator=0, int denominator=1) :
        m_numerator(numerator), m_denominator(denominator)
    {
        assert(denominator != 0);
    }
 
    // Конструктор копирования
    Fraction(const Fraction &copy) :
        m_numerator(copy.m_numerator), m_denominator(copy.m_denominator)
    {
        // здесь нет необходимости проверять знаменатель на 0,
        // поскольку copy уже должна быть корректным объектом Fraction
        // только, чтобы показать что это работает
        std::cout << "Copy constructor called\n"; 
    }
 
        // Перегруженное присваивание
        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;
}
 
// Упрощенная реализация operator= (улучшенную реализацию смотрите ниже)
Fraction& Fraction::operator= (const Fraction &fraction)
{
    // делаем копию
    m_numerator = fraction.m_numerator;
    m_denominator = fraction.m_denominator;
 
    // возвращаем существующий объект, чтобы
    // можно было включить этот оператор в цепочку
    return *this;
}
 
int main()
{
    Fraction fiveThirds(5, 3);
    Fraction f;
    f = fiveThirds; // вызывает перегруженное присваивание
    std::cout << f;
 
    return 0;
}

Эта программа печатает:

5/3

Теперь всё должно быть довольно просто. Наш перегруженный operator= возвращает *this, чтобы мы могли объединить несколько присваиваний в цепочку:

int main()
{
    Fraction f1(5,3);
    Fraction f2(7,2);
    Fraction f3(9,5);
 
    f1 = f2 = f3; // цепочка присваиваний
 
    return 0;
}

Проблемы из-за самоприсваивания

Здесь всё становится немного интереснее. C++ допускает самоприсваивание:

int main()
{
    Fraction f1(5,3);
    f1 = f1; // самоприсваивание
 
    return 0;
}

Этот код вызовет f1.operator=(f1), и в упрощенной реализации, приведенной выше, все члены будут присвоены сами себе. В этом конкретном примере самоприсваивание приводит к тому, что каждый член присваивается самому себе, что ни на что не влияет, кроме потери времени. В большинстве случаев самоприсваиванию вообще ничего не нужно делать!

Однако в случаях, когда оператору присваивания необходимо динамически присваивать память, самоприсваивание может быть опасным:

#include <iostream>
 
class MyString
{
private:
    char* m_data{};
    int m_length{};
 
public:
    MyString(const char* data = nullptr, int length = 0) :
        m_length(length)
    {
        if (length)
        {
            m_data = new char[length];
 
            for (int i = 0; i < length; ++i)
                m_data[i] = data[i];
        }
    }
    ~MyString()
    {
        delete[] m_data;
    }
 
    // Перегруженное присваивание
    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;
}
 
// Упрощенная реализация operator= (не использовать)
MyString& MyString::operator= (const MyString& str)
{
    // если данные существуют в текущей строке, удалить их
    if (m_data) delete[] m_data;
 
    m_length = str.m_length;
 
    // копируем данные из str в неявный объект
    m_data = new char[str.m_length];
 
    for (int i = 0; i < str.m_length; ++i)
        m_data[i] = str.m_data[i];
 
    // возвращаем существующий объект, чтобы
    // можно было включить этот оператор в цепочку
    return *this;
}
 
int main()
{
    MyString alex("Alex", 5); // Встречайте, это Алекс
    MyString employee;
    employee = alex;          // Алекс - наш новый сотрудник
    std::cout << employee;    // Сотрудник, назовите свое имя
 
    return 0;
}

Сначала запустите программу как есть. Вы увидите, что программа печатает "Alex", как и должна.

Теперь запустите следующую программу:

int main()
{
    MyString alex("Alex", 5); // Встречайте, это Алекс
    alex = alex;              // Алекс сам по себе
    std::cout << alex;        // Алекс, назовите свое имя
 
    return 0;
}

Вероятно, вы получите мусор. Что случилось?

Рассмотрим, что происходит в перегруженном operator=, когда неявный объект и переданный параметр (str) являются переменной alex. В этом случае m_data совпадает с str.m_data. Первое, что происходит, это то, что функция проверяет, есть ли уже у неявного объекта строка. Если это так, ее необходимо удалить, чтобы не произошло утечки памяти. В этом случае размещается m_data, поэтому функция удаляет m_data. Но поскольку str совпадает с *this, строка, которую мы хотели скопировать, была удалена, а m_datastr.m_data) стали висячими указателями.

Затем мы выделяем новую память для m_datastr.m_data). Поэтому, когда мы впоследствии копируем данные из str.m_data в m_data, мы копируем мусор, потому что str.m_data никогда не инициализировалась.

Обнаружение и обработка самоприсваивания

К счастью, мы можем определить, когда происходит самоприсваивание. Вот обновленная реализация нашего перегруженного operator= для класса MyString:

MyString& MyString::operator= (const MyString& str)
{
    // проверка на самоприсваивание
    if (this == &str)
        return *this;
 
    // если данные существуют в текущей строке, удалить их
    if (m_data) delete[] m_data;
 
    m_length = str.m_length;
 
    // копируем данные из str в неявный объект
    m_data = new char[str.m_length];
 
    for (int i = 0; i < str.m_length; ++i)
        m_data[i] = str.m_data[i];
 
    // возвращаем существующий объект,
    // можно было включить этот оператор в цепочку
    return *this;
}

Проверяя, совпадает ли адрес нашего неявного объекта с адресом объекта, переданного в качестве параметра, мы можем заставить наш оператор присваивания немедленно возвращаться без выполнения какой-либо другой работы.

Поскольку это просто сравнение указателей, оно должно быть быстрым и не требует перегрузки operator==.

Когда не обрабатывать самоприсваивание

Обычно проверка на самоприсваивание опускается в конструкторах копирования. Поскольку создаваемый объект для копирования создается заново, единственный случай, когда вновь созданный объект может быть равен копируемому, – это когда вы пытаетесь инициализировать новый определяемый объект самим собой:

someClass c { c };

В таких случаях ваш компилятор должен предупредить вас, что c – неинициализированная переменная.

Во-вторых, проверка на самоприсваивание может быть опущена в классах, которые могут обрабатывать самоприсваивание естественным образом. Рассмотрим следующий оператор присваивания класса Fraction, который имеет защиту от самоприсваивания:

// Улучшенная реализация operator=
Fraction& Fraction::operator= (const Fraction &fraction)
{
    // защита от самоприсваивания
    if (this == &fraction)
        return *this;
 
    // делаем копию
    m_numerator = fraction.m_numerator;     // может обрабатывать самоприсваивание
    m_denominator = fraction.m_denominator; // может обрабатывать самоприсваивание
 
    // возвращаем существующий объект, чтобы
    // можно было добавить этот оператор в цепочку
    return *this;
}

Если бы защиты от самоприсваивания не было, эта функция всё равно работала бы правильно во время самоприсваивания (потому что все операции, выполняемые функцией, могут правильно обрабатывать самоприсваивание).

Поскольку самоприсваивание – редкое событие, некоторые видные гуру C++ рекомендуют опускать защиту самоприсваивания даже в тех классах, которым это было бы полезно. Мы не рекомендуем этого делать, поскольку считаем, что лучше использовать защитный код, а затем, при необходимости, выборочно оптимизировать его.

Идиома копирования и обмена

Лучший способ справиться с проблемами самоприсваивания – использовать так называемую идиому копирования и обмена. Статья о том, как эта идиома работает, появится чуть позже.

Оператор присваивания по умолчанию

В отличие от других операторов, компилятор предоставит для вашего класса открытый оператор присваивания по умолчанию, если вы не предоставите его сами. Этот оператор присваивания выполняет поэлементное присваивание (что по сути то же самое, что и поэлементная инициализация, которую выполняют конструкторы копирования по умолчанию).

Как и другие конструкторы и операторы, вы можете предотвратить выполнение присваивания, сделав свой оператор присваивания закрытым или используя ключевое слово delete:

#include <cassert>
#include <iostream>
 
class Fraction
{
private:
    int m_numerator;
    int m_denominator;
 
public:
    // Конструктор по умолчанию
    Fraction(int numerator=0, int denominator=1) :
        m_numerator(numerator), m_denominator(denominator)
    {
        assert(denominator != 0);
    }
 
    // Конструктор копирования
    Fraction(const Fraction &copy) = delete;
 
    // Перегруженное присваивание
    // никаких копий через присваивание!
    Fraction& operator= (const Fraction &fraction) = delete; 
 
    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; // ошибка компиляции, operator= был удален
    std::cout << f;
 
    return 0;
}

Теги

C++ / CppLearnCppИдиома копирования и обмена / copy-and-swapОбучениеОператор (программирование)Оператор присваиванияПерегрузка (программирование)Перегрузка операторовПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.