С++ для начинающих. Специальные функции-члены классов. Правило трёх

В этой статье будут описаны не очевидные на первый взгляд, но очень важные моменты, относящиеся к проектированию классов в С++. Для описания будет использовано написание примера, где класс будет представлять собой описание простенькой строки. Основу, взятую для примера, вы, возможно, сможете написать сами: нужно просто обернуть обычную Си-строку классом.

Для дальнейшего изучения темы вы должны понимать показанный пример. Потому что дальше происходит развитие этого примера и неочевидные на первый взгляд вещи становятся более явными.
Статический объект в классе, представляющем из себя строку, создавать необязательно и даже не нужно. Но этот элемент будет нам полезен для описания темы. Статические объекты класса по сути не являются объектами класса, а являются глобальными объектами, сокрытыми областью видимости класса. На все объекты класса, независимо от их количества, статические объекты приходятся в единственном экземпляре. В отличие от обычных элементов класса, статическую переменную, сокрытую областью видимости класса, надо инициализировать вне класса в глобальной области: где инициализируются глобальные переменные. Как это делается вы можете увидеть по коду. Просто нужно запомнить, что статические элементы класса к элементам класса не относятся, а являются глобальными элементами, внутри класса их можно только объявить, а потом обязательно инициализировать вне класса.
Добавим немного отладочной информации в виде выводов на экран информационных сообщений.

Листинг #1 тот же, что и #0, просто добавились сообщения, выводимые на экран, которые могут стать полезными при дальнейшем разборе. Поскольку мы забегаем немного вперёд и знаем, к чему идём, то заранее подготовили эти сообщения.
При запуске и компиляции программы проблем никаких нет и программа работает обычным образом. Каждый конструктор содержит выражение str_count++;. Это значит, что каждый раз, когда программа создает новый объект, общая переменная str_count увеличивается на единицу, т. е. всегда содержит общее количество объектов MyString. А деструктор содержит выражение str_count. Таким образом, класс MyString отслеживает не только создание, но и удаление объектов, храня в переменной str_count их текущее количество.
Поскольку в качестве непосредственной строки выступает указательная переменная, но указательная переменная не умеет хранить строки, а умеет указывать на адреса памяти, во всех конструкторах следует выделять область памяти для хранения строки и связывать эту область с указательной переменной. Выделяемой области памяти должно хватать для хранния строки. Внутри объекта хранится только указательная переменная, а выделяемые области памяти, с которыми связывается та указательная переменная, хранятся вне класса. Говорят, что символы строки хранятся в куче, а объект только указывает, где их искать.
Новички, которые успели поработать с указателями на строки, знают, что нельзя взять и присвоить одну строку (не являющуюся объектом класса) в другую:


Указательные переменные не хранят строки, поэтому попытка присвоить один указатель в другой только перенаправляет один указатель к другому. В листинге #a1 произошла привязка двух указателей к одному и тому же адресу, при этом в момент перенаправления одного ввыделенный ему участок памяти оказался потерян, это утечка памяти. Но кроме утечки памяти происходит двойное delete. А применять delete к участку памяти, который не выделен персонально, нельзя, может случится разное: поведение программы непрогнозируемо. Так вот, новички, которые успели поработать с си-строками и указателями на строки, сталкивались с этой проблемой и должны знать, что для копирования строк нужно не перенаправлять указательные переменные, а копировать строки путём копирования символов из одной выделенной области памяти в другую. Поэтому в конструкторах используются функции копирования строк.
В деструкторе происходит процесс очистки выделенных участков памяти. Независимо от того, посредством какого конструктора объект создавался, деструктор всегда будет вызываться один и тот же. Деструктор автоматически срабатывает при уничтожении объекта. Очень важно подчищать выделенную, но уже ненужную память. В деструкторе описывается реализация такой чистки.
  • Всякий раз, когда в конструкторе для выделения памяти используется операция new, в соответствующем деструкторе необходима операция delete для освобождения этой памяти. Если использовалась операция new [ ] (с квадратными скобками), то нужно применять операцию delete [ ] (тоже с квадратными скобками).
Результат работы программы:
По первым двум строчкам (см. картинку) можно судить о сработавших конструкторах и созданных строках: "Hello" создана с помощью конструктора с параметром; "C++" создана с помощью конструктора по умолчанию. Следующие две строки просто вывод значений строк на экран. А последние четыре дают нам знать, какая строка была разрушена деструктором и сколько объектов осталось.
Надеюсь, основная часть показанного примера и данные пояснения дают вам понимание происходящего, потому что теперь мы немного разовьём класс. Листинг, который будет сейчас написан, содержит в себе несколько нарочных дефектов, поэтому не нужно паниковать. Это так нужно для дальнейших объяснений. Результат работы программы неопределён и зависит от компиляторов.

Результат работы программы:
Как было оговорено заранее, в вашем случае вывод на экран может быть иным. Я показываю ориентируясь на свой и постарался объяснить на картинке что к чему.
На моей картинке результата работы программы видно, что при срабатывании callme2(const MyString) зачем-то вызывается деструктор, но нет информации о создании объекта. Кроме того, можно наблюдать факт удаления, ибо об этом свидетельствует информационное сообщение. При срабатывании S5 = S1 зачем-то сработал конструктор по умолчанию, что можно понять по выводимому тексту. И заканчивается всё неудачей при выполнении деструкторов. Это может свидетельствовать о том, что деструкторы чистят уже почищенную память, достаточно распространённая ошибка.
Почему программа так работает? Точнее, почему она не работает? Дело в том, что С++ генерирует некоторые элементы класса неявно. Независимо от того, что получилось у вас на экране, а проблем может выползти значительно больше, чем выползло у меня, основные проблемы и способы их решения одинаковые для всех нас.
Количество вызовов конструкторов должно равнятья количеству вызовов деструкторов, но есть вероятность, что общее число объектов у вас ушло в минус.
Проблемы с классом MyString возникают из-за специальных функций-членов, которые определяются автоматически.
C++ автоматически предоставляет следующие функции-члены:

  • конструктор по умолчанию, если не было определено ни одного конструктора;
  • деструктор по умолчанию, если он не был определен;
  • конструктор копирования, если он не был определен; (если программа использует объект так, что это определение будет нужно)
  • операция присваивания, если она не была определена; (если программа использует объект так, что это определение будет нужно)
  • операция взятия адреса, если она не была определена; (если программа использует объект так, что это определение будет нужно)
Например, если где-то выполняется присваивание одного объекта другому, то программа предоставляет определение для операции присваивания.
Причиной проблем с классом MyString являются неявный конструктор копирования и неявная операция присваивания.
Начиная со стандарта С++11 предлагаются еще две специальные функции-члена — конструктор переноса и операция присваивания с переносом.
Если для класса явно не задан вообще никакой конструктор, то компилятор генерирует конструктор по умолчанию: генерируемый компилятором конструктор по умолчанию пустой, не делает ничего.

Если определён хотя бы один конструктор не по умолчанию, то С++ не считает нужным генерировать конструктор по умолчанию.


  • Если требуется создавать объекты, которые инициализируются неявно, то придется явно определить конструктор по умолчанию.
Несмотря на то, что конструктор по умолчанию не принимает аргументов, элементы объекта с помощью конструктора по умолчанию легко инициализировать значениями.


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


Однако в классе может быть только один конструктор по умолчанию. То есть нельзя делать следующее:


Первый важный конструктор, конструктор по умолчанию, мы сейчас бегло просмотрели. Второй важный конструктор — конструктор копирования.

  • Конструктор копирования служит для копирования некоторого объекта в создаваемый объект. Другими словами, он используется во время инициализации — в том числе при передаче функции аргументов по значению — но не во время обычного присваивания. Конструктор копирования для класса обычно имеет следующий прототип: Имя_класса(const Имя_класса &);
Конструктор копирования в качестве параметра принимает константную ссылку на объект класса. Например, конструктор копирования для класса String будет выглядеть так:

О конструкторе копирования нужно знать два момента: когда он используется и что он делает.

Конструктор копирования вызывается всякий раз, когда создается новый объект, и для его инициализации берётся значение существующего объекта того же типа. Это происходит в нескольких ситуациях. Наиболее очевидный случай — когда новый объект явно инициализируется существующим объектом. Например, если motto является объектом String, то следующие четыре объявления вызывают конструктор копирования:

В зависимости от реализации, два объявления в середине (3-я и 4-я строчки листинга #a7) могут использовать конструктор копирования либо непосредственно для создания объектов metoo и also, либо для генерирования временных объектов, содержимое которых затем присваивается объектам metoo и also. Последний показанный вариант из листинга #a7 инициализирует анонимный объект значением motto и присваивает адрес нового объекта указателю pString.

Менее очевидно то, что компилятор использует конструктор копирования при каждом генерировании копии объекта в программе. В частности, он применяется, когда функция передает объект по значению (как это делает функция callme2 () (в листинге #2) или когда функция возвращает объект. Ведь передача по значению подразумевает создание копии исходной переменной. Компилятор также использует конструктор копирования при генерировании временных объектов. Например, компилятор может генерировать временный объект Vector для хранения промежуточного результата при сложении трех объектов Vector. Различные компиляторы могут вести себя по-разному при создании временных объектов, но все они вызывают конструктор копирования при передаче объектов по значению и при их возврате. В частности, следующий вызов функции в однострочном листинге #2 запускает и конструктор копирования:

В программе конструктор копирования применяется для инициализации S — формального параметра типа MyString для функции callme2 () (смотрите функцию callme2() и её параметры). Тот факт, что при передаче объекта по значению вызывается конструктор копирования, является хорошей причиной для передачи по ссылке. Это позволит сэкономить время вызова конструктора и память для хранения нового объекта.

Конструктор копирования по умолчанию выполняет почленное копирование нестатических членов, также иногда называемое поверхностным копированием. Каждый член копируется по значению. Если член сам является объектом класса, для копирования одного объекта-члена в другой используется конструктор копирования этого класса. Но это не влияет на статические члены, подобные str_count, поскольку они для класса вообще, а не к отдельным его объектам. Поверхностное копирование обозначает, например, что при коприовании указателей будут копироваться только адреса, но целые области памяти.
  • Если в классе имеются статические данные-члены, значение которых изменяется при создании новых объектов, должен быть предусмотрен явный конструктор копирования, который принимает это внимание.
В моём случае неточности с подсчётом не возникло, но у вас это вполне может произойти. Конструктор копирования ведь в листинге #2 не написан явно, а значит есть вероятность, что при вызове функции с параметром-значением наш статический счётчик собьётся: сработает неявно сгенерированный компилятором конструктор копирования, создастся новый объект, статический счётчик никак не обновит своё значение. Приблизительно вот такого вполне можно ожидать.
Кроме потенциальной угрозы, связанной с неучитываемостью неявным конструктором копирования, более серьёзная опасность состоит в том, что если посредством конструктора копирования будет создан новый объект, то из-за указательных переменных неявным конструктором копирования указательные переменные будут перенаправляться, но не будут копироваться конечные (нужные нам) области памяти. Это значит, что можно столкнутся с поведением программы как в листинге #a8:


Проявится этот симптом или нет — будет зависеть от того, произойдёт ли срабатывание (инициация) конструктора копирования. И если вы явно не описывали конструктор копирования в классе, которым учили бы классы копироваться должным образом, то можете ждать бомбы замедленного действия.
Можно столкнуться с проблемой двойного delete, которой поспособствует опять же таки конструктор копирования. Чтобы на это посмотреть, достаточно немного изменить листинг #a8:

В листинге #a9 указатели обоих объектов сводятся на один адрес памяти. Для каждого объекта вызывается деструктор. И оба деструктора выполняют delete [ ] к тому адресу, на который направлены оба указателя. Когда срабатывает конструктор копирования, сгенерированный компилятором, такую ситуацию очень легко получить при работе наших программ.

  • Поведение программы при двойном delete неопределено.
Кроме описанных симптомов могут быть и другие, причиной которым может быть неявно сгенерированный конструктор копирования. Лечение — явно описывать конструктор копирования, в котором показывать компилятору как нужно копировать классы.

  • Если класс содержит члены, которые являются указателями, инициализированными операцией new, потребуется определить конструктор копирования, копирующий данные, на которые указывают указатели, а не сами указатели. Это называется глубоким копированием. Альтернативная форма копирования (почленное или поверхностное копирование) просто копирует значения указателей. Поверхностная копия — это только "наружное соскабливание" информации указателя для копирования, а не "глубокая добыча", требующая копироварования конструкций, на которые указывают указатели.
Кроме проблем, возникающих из-за генерации неявным образом описанных конструкторов, проблемной может стать и операция присваивания. Объекты одного и того же класса можно присваивать друг в друга без дополнительного обучения этому класса.

Делается это благодаря неявному генерированию компилятором операции =. Операция присваивания, сгенерированная классом, принимает ссылку на объект, состояние которого предполагается скопировать, и возвращает ссылку. Это выглядит приблизительно так:


  • Перегруженная операция присваивания используется при присваивании одного объекта другому существующему объекту
  • При инициализации объекта операция присваивания не обязательна:

Операция присваивания — это механизм обычного копирования. Проблемы могут возникнуть такие же, какие могут возникнуть из-за неявно генерируемого компилятором конструктора копирования. Т. е. генерируемая компилятором операция присваивания выполняет поверхностное копирование, а не глубокое. В том числе копирование на статические элементы класса операцией неявного присваивания не распространяется. Нужно предотвращать возможную угрозу путём явного описания оперции присваивания, как и в случае с конструктором копирования.
Несмотря на то, что операция присваивания и конструктор копирования выполняют схожую работу, операция присваивания описывается немного иначе, чем конструктор копирования. Подробнее в статье Перегрузка операции присваивания.
Начиная с С++11 есть ещё два генерируемыех компилятором механизма. В настоящей статье не расматриваются, но уже упоминалось: конструктор перемещения и операция присваивания с переносом. Как и в случае с вышеописанными проблемами, неявная генерация и этих двух функций-членов может повлечь за собой возникновение тех же самых дефектов, которые получаются от неявной генерации компилятором упомянутых специальных функций-членов класса. Это значит, что если вы используете С++11 и старше, то должны явно описывать и эти две функции вкупе с тремя первыми.

  • Правило трёх (также известное как "Закон Большой Тройки" или "Большая Тройка") — правило в C++, гласящее, что если класс или структура определяет один из следующих методов, то они должны явным образом определить все три метода:

    1. Деструктор
    2. Конструктор копирования
    3. Операция присваивания копированием
Это обозначает, что в случае явного определения любого из этих трёх методов, вы на уровне автоматизма должны явно прописать два оставшихся. С С++11 действует правило пяти, но суть та же самая. Почему это необходимо, вам должно быть уже понятно: чтобы избегать таких проблем, которые были проявлены в ходе изучения этой статьи, и некоторых других, которые, возможно, пока ещё не дали о себе знать.

  • Эти три метода являются особыми членами-функциями, автоматически создаваемыми компилятором в случае отсутствия их явного объявления программистом. Если один из них должен быть определен программистом, то это означает, что версия, сгенерированная компилятором, не удовлетворяет потребностям класса в одном случае и, вероятно, не удовлетворит в остальных случаях. Смотрите: Правило трёх (С++)
Теперь мы здорово осведомлены, возможно, поняли источник неприятностей и готовы обновить наш код, зная что нам нужно описать явно те функции, которые компилятор неявно загенерировал. Исправляем: деструктор написан, а копирующий конструктор и операция присваивания не написаны, значит последние две нужно написать явным образом:

Благодаря внесённым исправлениям программа заработала как и ожидалось в начале. Всё не так страшно и не так непонятно должно быть теперь. Специальные функции-члены, которые генерируются автоматически, не берут на себя обязательства по управлению памятью и не делают больше чем им позволено. Если какая-то из специальных функций понадобилась в классе, нужно на уровне автоматизма явно описать и остальные (по правилу большой тройки), даже если на первый взгляд кажется, что некоторые из них совсем не нужны.
Не забывайте, что если в одном конструкторе копирования выделяете указателю память, то выделять память указателю в основном правильно будет в каждом конструкторе, иначе, если в одном выделить, а в другом нет, и конструкторы сработают, то можно получить указатель без выделенной памяти, к которому произойдёт попытка доступа как к указателю, которому память выделена. Хотя и можно не выделять, но если в каких-то конструкторах память выделяется, а деструктором высвобождается, то если в каких-то отдельных память указательной переменной не выделять, то нужно направить указатель на нулевой адрес: при разрушении объекта delete, запущенное деструктором, к нулевому адресу безвредно Если память для указательного элемента класса выделяется с помощью new, то высвобождается с помощью delete, а если new [ ], то delete [ ]. При описании деструкторов вы должны внимательно за этим следить. Это обязательное правило.
Основная цель этой статьи дать вам знать о неявной генерации компилятором некоторых функций, специальных функций-членов класса, и дать знать, что генерируемые компиляторам они могут не удовлетворять нашим потребностям в полной мере, из-за чего мы должны в некоторых ситуациях (всегда, когда пишем любую одну из них) описывать все эти функции явным образом.
Использованные материалы:

Язык программирования C++. Лекции и упражнения. 6-е изд (Стивен Прата)

Один комментарий на «“С++ для начинающих. Специальные функции-члены классов. Правило трёх”»

  1. Спасибо.
    Очень полезно и доходчиво.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Поиск

 
     

Случайная книга в электронном формате

https://www.litres.ru/irina-kozlova/programmirovanie/?lfrom=15589587
Яндекс.Метрика