1.5.
Перегрузка префиксной и постфиксной операций
инкремента
При перегрузке операций инкремента и декремента нужно учитывать, что они имеют две формы префиксную и постфиксную.
Пример 5. Реализовать класс – Time («Время»). Для него перегру- зить операцию инкремента в двух формах (увеличение времени на одну се- кунду). Требования к классу представлены на UML диаграмме (рис.1.2).
Рис. 1.2. UML диаграмма класса Time
//classTime.h
#ifndef CLASSTIME_H
#define CLASSTIME_H class Time { private: int sec,min,hour; void increment(); // Функция-утилита public:
Time(int _hour=0,int _min=0,int _sec=0);
// Конструктор с параметрами по умолчанию int getHour() const;
Time operator++();
//Префиксная форма инкремента
Time operator++(int);
//Постфиксная форма инкремента
};
#endif
Реализация функций класса Time в файле classTime.cpp:
37
//classTime.cpp
#include "classTime.h"
Time::Time(int _hour, int _min, int _sec) :hour(_hour), min(_min), sec(_sec){} int Time::getHour() const{ return hour;
}
Time Time::operator++() { increment(); return *this;
}
// фиктивный целый параметр не имеет имени
Time Time::operator++(int) {
Time temp(*this); increment(); return temp;
}
// Функция-утилита увеличения времени на 1 секунду void Time::increment() { sec++; if (sec == 60) { sec = 0; min++;
} if (min == 60) { min = 0; hour++;
}
}
В данном классе используется конструктор с параметрами по умолчанию.
Нельзя в классе объявлять одновременно конструктор по умолчанию и конструктор со всеми параметрами по умолчанию. Это приведет к ошиб- ке. Компилятор не сможет определить, какой из конструкторов нужно вызвать.
Реализация конструктора использует инициализаторы элементов.
В связи с этим тело конструктора пусто.
Time::Time(int _hour,int _min,int _sec): hour(_hour),min(_min),sec(_sec){}
38
В общем случае такой способ не является обязательным, но в некото- рых ситуациях без него не обойтись. Например, константные элементы класса должны получать начальные значения с помощью инициализаторов элементов. Присваивания в теле конструктора в этом случае недопустимы.
Член-функцию класса можно объявить константной, если она не должна изменять значение полей объекта. Например, int Time::getHour() const{ return hour;
}
Рекомендуется все функции для доступа к полям на чтение (getXX- функции) объявлять константными.
Это становится важным, если мы хотим объявить объект-константу для данного класса. Такой объект может использовать только константные член-функции.
Например, можно объявить объект-константу для некоторого эталона времени const Time X(12,0,0);
Для объекта X можно использовать только функцию getHour().
Уберите квалификатор const из функции getHour()
и попробуйте использовать его для константного объекта.
При перегрузке операции инкремента (декремента) для получения возможности использования и префиксной, и постфиксной форм, каждая из этих двух перегруженных функций-операций должна иметь разную сигна- туру. Это даст возможность компилятору определить, какая версия инкре- мента имеется в виду в каждом конкретном случае.
Допустим, мы объявили объект t типа Time:
Time T;
39
По соглашению, принятому в С++, когда компилятор встречает вы- ражение с префиксным инкрементом ++t, генерируется вызов функции- элемента t.operator++(), объявление которой должно иметь вид:
Time operator++();
Когда компилятор встречает выражение постфиксной формы инкре- мента t++, он генерирует вызов функции t.operator++(0), объявлени- ем которой является:
Time operator++(int);
Нуль (0) в генерируемом вызове функции является чисто формаль- ным значением, введенным для того, чтобы сделать список аргументов функции operator++, используемой для постфиксной формы инкремен- та, отличным от списка аргументов функции operator++, используемой для префиксной формы инкремента. Заметьте, что формальный параметр является фиктивным, и поэтому не имеет имени.
Обе формы перегрузки операции ++ используют private функцию increment. Это связано с нетривиальным алгоритмом увеличения време- ни на одну секунду.
// Функция-утилита увеличения времени на 1 секунду void Time:: increment() { sec++; if (sec==60) { sec=0; min++;
} if (min==60) { min=0; hour++;
}
}
Перегруженная операция префиксного инкремента возвращает копию текущего объекта с измененным временем. Это происходит потому, что те- кущий объект *this возвращается как объект класса Time, что активизи- рует конструктор копии.
40
Time Time::operator++() { increment(); return *this;
}
Чтобы эмулировать действие постфиксного инкремента, необходимо вернуть неизмененную копию объекта Time. При входе в operator++ текущий объект *this сохраняется во временном объекте temp. Затем вызывается increment(), чтобы инкрементировать объект. В итоге, воз- вращается неизмененная копия объекта temp.
// фиктивный целый параметр не имеет имени
Time Time::operator++(int) {
Time temp(*this); increment(); return temp;
}
Отметим, что эта функция не может вернуть ссылку на объект класса
Time
, потому что значение, которое надо вернуть, сохраняется в локальной переменной в определении функции. Локальные переменные уничтожают- ся, когда функция, в которой они объявлены, завершена. Таким образом, объявление типа, возвращаемого функцией, как Time&, привело бы к ссылке на объект, который после возвращения больше не существует.
Возвращение ссылки на локальную переменную является типичной ошибкой, которую трудно найти.
Пример функции main для класса Time:
#include
#include "classTime.h" using namespace std; void main () {
Time t1; Time t2(11,59,59); cout<}
41
1.6. Реализация преобразования типов В языке С++ определены операции преобразования между встроен- ными типами данных. А преобразования
между встроенными типами и ти- пами, определенными пользователями, или только между типами, опреде- ленными пользователями, требуют определения. Для этого используются или конструкторы преобразований, или операции преобразований. Выбор механизма реализации преобразования зависит от типов данных, между ко- торыми производится преобразование.
Конструктор преобразования (конструктор с единственным аргу- ментом) может быть использован для преобразования объектов разных ти- пов (включая встроенные типы) в объекты данного класса.
Операция преобразования (называемая также
операцией приведения) может быть использована для преобразования объекта одного класса в объ- ект другого класса или в объект встроенного типа. Такая операция преобра- зования должна быть нестатической функцией-членом. Операция преобра- зования этого вида не может быть дружественной функцией.
Пример 6. Реализовать класс «Строка». Предусмотреть конструктор для преобразования строк в стиле С char* в объекты класса «Строка» и операцию приведения для преобразования класса «Строка» в строку в стиле С char*. Требования к классу представлены на UML диаграмме
(рис. 1.3).
//STR.h
#ifndef STR_H
#define STR_H
#include
using namespace std; class Str { friend ostream & operator << (ostream &, const Str &); friend istream & operator >> (istream &, Str &); public:
Str(const char * = "");
//конструктор преобразования
42
Str(const Str &);
//конструктор копии
Str();
//деструктор
Str & operator= (const Str &); //присваивание
Str & operator+= (const Str &); //сцепление (конкатенация) bool operator!() const;
//проверка строки на пустоту bool operator== (const Str &) const;//проверка на равенство char & operator[] (int);
//получение ссылки на символ char operator[] (int) const; //получение символа по номеру
Str operator() (int, int); //получение подстроки int getLength() const;
//определение длины строки operator char*() const;
//операция приведения к char* private: char * sPtr;
//указатель на начало строки int length;
//длина строки
};
#endif
Рис.1.3. UML диаграмма класса Str
//STR.cpp
// Определение некоторых функций элементов класса Str
#include
#include
#include
#include
#include "str.h"
43
// конструктор преобразования: преобразовывает char* в Str
Str::Str(const char* s) { length=(int)strlen(s); sPtr=new char[length + 1]; assert(sPtr != 0);
//завершение, если память не выделена strcpy(sPtr,s);
}
// конструктор копии
Str::Str(const Str & copy) { length=copy.length; sPtr=new char[length + 1]; assert(sPtr != 0);
//завершение, если память не выделена strcpy(sPtr,copy.sPtr);
}
// деструктор
Str::Str() { delete [] sPtr; }
//операция присваивания
Str & Str::operator =(const Str & right) { if (&right != this) { delete [] sPtr; length = right.length; sPtr = new char[length + 1]; assert(sPtr != 0); strcpy(sPtr, right.sPtr);
} return *this;
}
//сцепление (конкатенация)
Str & Str::operator +=(const Str & right) { char * tempPtr = sPtr; length += right.length; sPtr = new char [length+1]; assert(sPtr!=0); strcpy(sPtr, tempPtr); strcat(sPtr, right.sPtr); delete [] tempPtr; return *this;
}
//проверка строки на пустоту bool Str::operator !() const { return length ==0;
}
44
//проверка двух строк на равенство bool Str::operator == (const Str & right) const { if (length!=right.length) return 0; return strcmp(sPtr, right.sPtr) == 0;
}
//получение ссылки на символ char & Str::operator[] (int ind) {
//проверка, не находится ли индекс вне диапазона assert(ind >= 0 && ind < length); return sPtr[ind];
//создание L-величины
}
//получение символа char Str::operator[] (int ind) const{
//проверка, не находится ли индекс вне диапазона assert(ind >= 0 && ind < length); return sPtr[ind];
//создание R-величины
}
//получение подстроки заданной длины, начинающейся с заданного
//индекса
Str Str::operator() (int ind, int sublength) {
//проверка, что индекс в диапазоне и длина подстроки >=0 assert(ind >= 0 && ind < length && sublength>=0);
Str sub;
//определение длины подстроки if ((sublength==0) || (ind+sublength>length)) sub.length=length-ind+1; else sub.length=sublength+1;
//выделение памяти для подстроки sub.sPtr=new char[sub.length]; assert(sub.sPtr!=0);
//копирование подстроки в новый Str strncpy(sub.sPtr,&sPtr[ind],sub.length); sub.sPtr[sub.length]='\0'; //завершение новой строки sPtr return sub;
}
//определение длины строки int Str::getLength() const { return length;
}
//операция приведения к char*
Str::operator char*() const { char *c=new char[length];
45 assert(sPtr != 0); strcpy(c,sPtr); return c;
}
//перегруженная операция вывода данных ostream &operator << (ostream & output, const Str & s) { output< //возврат ссылки на поток для возможности многократного
//последовательного использования операции << return output;
}
//перегруженная операция ввода данных istream &operator >> (istream & input, Str & s) { char temp[100]; //буфер для хранения входных данных; input>>setw(100)>>temp; s=temp; return input;
}
// String.cpp
#include "Str.h" int main() { char *s=new char[100]; cin>>s;
Str s1("Hello"),s2(s), s3; cin>>s3; cout<>s; return 0;
}
Объявление перегруженной функции-операции приведения типа из определенного пользователем типа Str в тип char*: operator char*() const;
Перегруженная функция-операция приведения не указывает тип воз- вращаемой величины, потому что им является тип, к которому преобразо- ван объект.
46
Если s – объект класса Str, то когда компилятор встречает выраже- ние (char*)s, он порождает вызов s.operator char*(). Для этого вызова операнд s – это объект класса Str, для которого была активизиро- вана функция-элемент operator char*.
Конструктор преобразования получает аргумент char* и создает объект Str
Str(const char * = "");
Конструктор с одним параметром char* преобразует соответствую- щую строку в объект Str, который затем присваивается создаваемому объ- екту Str.
Любой конструктор с единственным аргументом можно рассматривать как конструктор преобразования.
Одной из особенностей операций приведения и конструкторов пре- образований является то, что при необходимости компилятор может вызы- вать эти функции автоматически для создания временных объектов.
Если в программе в том месте, где ожидается char*, появился объ- ект s определенного пользователем типа Str, то в этом случае компилятор для преобразования объекта в char* вызывает перегруженную функцию- операцию приведения operator char* и использует в выражении ре- зультирующий char*. Например: cout << s;
Поэтому операция приведения для класса Str позволяет не перегру- жать операцию << (поместить в поток), предназначенную для вывода Str с использованием cout.
Уберите из примера 5 перегрузку операции вывода в поток и про- верьте работоспособность программы.
47
Наличие конструктора преобразования означает, что нет необходи- мости применять перегруженную операцию специально для присваивания строк символов объектам Str. Компилятор автоматически активизирует конструктор преобразования для создания временного объекта Str, содер- жащего строку символов. Затем активизируется перегруженная операция присваивания, чтобы присвоить временный объект другому объекту Str.
Некоторые преобразования типов могут быть источниками ошибок.
Например, при преобразовании от вещественного типа к целому может быть получен некорректный результат, хотя преобразование из целого типа в вещественный всегда гарантирует правильный результат. Поэтому преоб- разование из целого типа в вещественный является безопасным, а обратное
— небезопасным. Для выполнения небезопасных преобразований типов используется операция явного преобразования [2, раздел 4.3].
Поскольку любой конструктор с одним параметром будет использо- ваться всегда по умолчанию для неявного преобразования типа, то для за- прета неявного преобразования необходимо дополнить такой конструктор модификатором explicit.
В классе Str конструктор с параметром char * использовался спе- циально для демонстрации возможности неявного преобразования. Поэто- му он был описан без модификатора explicit.
Чтобы пояснить ситуацию, когда необходимо использовать модифи- катор explicit, рассмотрим следующий пример. Допустим, необходимо к строке, представляемой классом Str,
дописать константную строку, со- держащую число, например, номер курса. Сделаем это, используя перегру- женную операцию +=.
Str s1("Hello"); s1+="2"; // s1 содержит Hello2
48
Предположим, при наборе кода программы сделана ошибка — оказа- лись пропущены кавычки. s1+=2; // сообщение об ошибке компиляции
В нашем примере произойдёт ошибка компиляции, т.к. компилятор не найдет перегруженной операции += с правым операндом целого типа и не определено приведение целого типа к типу Str.
Если бы в классе Str был определён конструктор преобразования из типа int, такая операция была бы разрешена и не ошибка компиляции не возникла бы. Предположим, что конструктор с одним параметром типа int существует и предназначен для формирования строки с заданным ко- личеством пробелов.
Str(int n);
//конструктор неявного преобразования
Str::Str(int n) { length = n; sPtr = new char[length + 1]; assert(sPtr != 0);
//завершение, если память не выделена for (int i = 0; i < length; ++i) sPtr[i] = ' '; sPtr[length] = 0;
}
По правилам языка С++ он является конструктором преобразования типа, но его назначением является формирование пробельной строки за- данной длины. В этом случае результат операции с пропущенными кавыч- ками окажется неожиданным s1+=2; // s1 содержит Hello и еще два пробела
Именно поэтому следовало указать компилятору, что мы не хотим рассматривать такой конструктор как конструктор неявного преобразова- ния. В этом случае конструктор нужно объявить с модификатором explicit. explicit Str(int n);
//конструктор преобразования, вызываемого явно
Str::Str(int n) { length = n;
49 sPtr = new char[length + 1]; assert(sPtr != 0);
//завершение, если память не выделена for (int i = 0; i < length; ++i) sPtr[i] = ' '; sPtr[length] = 0;
}
При использовании explicit конструктора будут получены сле- дующие результаты: s1+=2; // сообщение об ошибке компиляции s1+=Str(2); //s1 содержит Hello и еще два пробела
В C++11 ключевое слово explicit применимо и к операторам преоб- разования. По аналогии с конструкторами, оно защищает от непредвиден- ных неявных преобразований.
1.7.
Обработка исключений
Из-за большого количества недиагностируемых ошибок времени вы- полнения при работе со строками char* в примере 6 используется макрос assert. void assert(int выражение)
Он позволяет включить в программу диагностические сообщения о таких ошибках. Например, assert(sPtr != 0); //завершение, если память не выделена или assert(ind >= 0 && ind < length && sublength>=0);
//проверка, что индекс находится в диапазоне и длина подстроки >=0
Если значение выражения есть нуль, то assert(выражение) напечатает сообщение следующего вида:
Assertion failed: выражение, file имя файла, line nnn
После чего будет вызвана функция abort, которая завершит вычисления.
50
Основное применение макроса assert — диагностика ошибок на этапе отладки приложения. В частности, такой механизм широко применя- ется при создании тестов, в том числе и unit-тестов.
Но в некоторых случаях ошибки времени выполнения могут возни- кать и в отлаженной программе. Например, ошибки при выделении дина- мической памяти или несоответствия типов входных данных. Для обработ- ки таких ситуаций используется механизм исключений.
Когда во время выполнения программы происходит ошибка, генери- руется так называемое исключение (исключительная ситуация), которое можно перехватить и обработать. Если исключение не обработать, то программа завершится с ошибкой.
Если в программе возникла исключительная ситуация и в текущем контексте не хватает информации для принятия решения о том, как дейст- вовать дальше, информацию об ошибке можно передать во внешний, более общий контекст. Для этого в программе создается объект с информацией об исключении, который затем «запускается» из текущего контекста (гово- рят, что в программе запускается исключение).
Для работы с исключениями можно использовать как встроенные ти- пы [2, раздел 1.15], так и создаваемые пользователями классы исключений.
В простейшем случае класс исключения может быть пустым. Этого достаточно, если нам нужно только просигнализировать о возникновении ситуации и никакие данные передавать обработчику не нужно.
Например, создадим класс, описывающий ошибку, возникающую при невозможности выделить динамическую память. class OutOfMemoryException {};
Чтобы воспользоваться классом исключения нужно assert заме- нить запуском исключения.
51
//assert(sPtr!=0); if (sPtr==0) throw OutOfMemoryException();
В этом случае определение перегруженной операции конкатенации будет выглядеть следующим образом:
//сцепление (конкатенация)
Str & Str::operator +=(const Str & right) { char * tempPtr = sPtr; length += right.length; sPtr = new char [length+1]; if (sPtr==0) throw OutOfMemoryException(); strcpy(sPtr, tempPtr); strcat(sPtr, right.sPtr); delete [] tempPtr; return *this;
}
Оператор генерации исключения throw производит целый ряд дей- ствий. Сначала создаётся копия запускаемого объекта, которая возвращает- ся из функции, содержащей throw, даже если тип объекта исключения не соответствует типу, который положено возвращать этой функции. При этом управление передается в специальную часть программы, называемую
обработчиком исключения; она
может находиться далеко от того места, где было запущено исключение. Также уничтожаются все локальные объекты, созданные к моменту запуска исключения. Автоматическое уничтожение локальных объектов называется «раскруткой стека».
Создайте класс исключения BadIndexException для контроля вы- хода индекса за границы. Замените все использования assert генерацией исключений соответствующих типов.
Поскольку ситуация невозможности выделить память встречается крайне редко, рассмотрим ошибку, связанную с индексацией. После прове- дённых изменений в программе неправильное значение индекса и в случае использования макроса assert, и в случае оператора throw приводит к
52 аварийному завершению программы. Различаются только сообщения, опи- сывающие причину завершения программы. Но в отличие от использова- ния assert сгенерированные исключения можно перехватывать и обраба- тывать в программе без обязательного аварийного завершения.
В том случае, когда команда throw не должна приводить к аварий- ному завершению, следует создать специальный блок, который должен реагировать на исключение. Этот блок, называемый блоком try , пред- ставляет область видимости, перед которой ставится ключевое слово try: try {
// Программный код, который может генерировать исключения
}
При использовании обработки исключений выполняемый код поме- щается в блок try, а обработка исключений производится после блока try
. Это существенно упрощает написание и чтение программы, посколь- ку основной код не смешивается с кодом обработки ошибок. Часто не сам код, который может генерировать исключения, а вызов функции, содержа- щей этот код, помещают в блок try.
Программа должна где-то среагировать на запущенное исключение.
Это место называется обработчиком исключения. В программу необходимо включить обработчик исключения для каждого типа перехватываемого ис- ключения. Обработчик может перехватывать как определенный тип ис- ключения, так и исключения классов, производных от этого типа.
Обработчики исключений следуют сразу же за блоком try и обозна- чаются ключевым словом catch: try {
// Программный код, который может генерировать исключения
} catch (type1 id1){
// Обработка исключений типа type1
}
53 catch (typeN idN){
// Обработка исключений типа typeN
}
//Здесь продолжается нормальное выполнение программы...
Если id1, ..., idN не нужны, их можно опускать.
Если в программе запускается исключение, механизм обработки ис- ключений начинает искать первый обработчик с аргументом, соответст- вующим типу исключения. Управление передается в найденную секцию catch
, и исключение считается обработанным (т. е. дальнейший поиск об- работчиков прекращается). Выполняется только нужная секция catch, а работа программы продолжается, начиная с позиции, следующей за по- следним обработчиком для данного блока try.
Иногда требуется написать обработчик для перехвата любых типов исключений. Для этой цели используется специальный список аргументов в виде многоточия (...): catch (...){ cout<<"an exception was thrown"<
}
Поскольку такой обработчик перехватывает все исключения, он раз- мещается в конце списка обработчиков.
Рассмотрим использование в операции индексирования исключения
BadIndexException для контроля выхода индекса за границы строки.
//получение ссылки на символ char & Str::operator[] (int ind) {
//проверка, не находится ли индекс вне диапазона if (ind < 0 || ind >= length) throw BadIndexException(); return sPtr[ind];
//создание L-величины
}
Тип исключения обычно предоставляет достаточно информации для определения характера ошибки, например:
Str s="New string for tests"; for (int i=0; i<10; ++i){ cout<<"Input number of symbol"<
54 cin>> i; try { cout< } catch (BadIndexException){
//обработка исключения
.. cout<<"illegal index"< }
}
Рассмотренный цикл при любых входных данных будет выполнен 10 раз, но в случаях выхода за границы вместо символа строки будет выведено сообщение: illegal index.
Иногда кроме типа исключения обработчику желательно передавать дополнительную информацию, например, значение индекса, вызвавшего исключение. Тогда для исключений нужно использовать классы, имеющие конструктор с параметрами, поля и методы.
Расширим класс BadIndexException, чтобы можно было переда- вать значение индекса в обработчик. class BadIndexException { private: int ind; public:
BadIndexException(int i): ind(i) {} int getInd() const { return ind;
}
};
Теперь при выбрасывании исключения нужно указать значение не- корректного индекса. throw BadIndexException(ind);
Тогда обработчик будет выглядеть следующим образом: catch (BadIndexException e){
//обработка исключения
.. cout<<"illegal index"< }
Механизм исключений в первую очередь предназначен для разделе- ния места, где возникает ошибка, и места, где она обрабатывается. Поэтому
55 чаще всего исключения, выбрасываемые в функциях, обрабатываются там, где эти функции вызываются. При этом такие функции могут входить в со- став библиотек, поставляемых сторонними разработчиками. При этом мо- гут быть известны только заголовки функций. Чтобы сообщить, какие ис- ключения может выбрасывать функция, в её объявление можно добавить список возможных исключений. char & operator[] (int ind) throw (BadIndexException);
В объявлении функции после списка аргументов указана необяза- тельная спецификация исключений. Спецификация исключений состоит из ключевого слова throw, за которым в круглых скобках перечисляются ти- пы всех потенциальных исключений, которые могут запускаться данной функцией.
Вообще говоря, вы не обязаны сообщать пользователям вашей функции, какие исключения она может запускать. Однако такое поведение считается нецивилизованным – оно означает, что пользователи не будут знать, как написать код перехвата потенциальных исключений.
1.8.
Статические члены класса
Классы могут содержать статические поля и статические функции.
Если данные-члены объявлены с квалификатором static, то для всех объектов класса поддерживается только одна копия таких данных. Стати- ческий член используется совместно всеми объектами данного класса. Для того чтобы существовал статический член, не обязательно, чтобы сущест- вовали объекты такого класса.
Пример 7. Реализовать класс, позволяющий подсчитывать количест- во существующих объектов данного класса. class Counter { private:
56 static int count; public: static int getCount() { return count;
}
Counter() {
++count;
}
Counter() {
--count;
}
}; int Counter::count = 0; int main() { cout << "before " << Counter::getCount() << endl;
Counter first; cout << "after first " << first.getCount() << endl;
Counter * second = new Counter(); cout << "after second " << second->getCount() << endl; delete second; cout << "after delete second " << Counter::getCount() << endl; return 0;
}
Внутри класса возможно только объявление статического члена, но не его определение. private: static int count;
Статические данные можно использовать только после определения.
Это делается путем нового объявления статической переменной, причем используется оператор разрешения области видимости для того, чтобы идентифицировать тот класс, к которому принадлежит переменная. int Counter::count = 0;
Статические члены-данные подчиняются правилам доступа к членам класса. Однако определение статических членов-данных выполняется вне зависимости от спецификатора доступа.
57
Определение статических членов-данных класса рекомендуется распо- лагать вместе с определением самого класса в заголовочном файле.
К статическим членам класса можно обращать как через класс, так и через объект или указатель/ссылку на объект. cout << "before " << Counter::getCount() << endl;
Counter first; cout << "after first " << first.getCount() << endl;
Counter * second = new Counter(); cout << "after second " << second->getCount() << endl;
Статические функции-члены не могут обращаться к нестатическим данным или вызывать нестатические функции этого же класса. Это связано с тем, что в статические функции не передается указатель this на объект, для которого вызвана функция.
Реализация статической функции записывается так же, как и реализа- ция любой другой функции-члена класса. При этом если реализация вне класса, то ключевое слово static не указывается.
Например, определение статической функции getCount вне класса могло бы выглядеть следующим образом int Counter::getCount() { return count;
}
1.9.
Члены класса создаваемые автоматически
Поскольку некоторые члены класса создаются компилятором автома- тически, следует понимать принципы их создания и знать ситуации, в ко- торых они создаются. Это позволит в одних случаях избежать ошибок, а в других – эффективно использовать действия по умолчанию.
Рассмотрим следующий «не очень полезный» класс. class Empty {};
На самом деле, такое описание класса эквивалентно следующему:
58 class Empty{ public:
Empty() {}
Empty(Empty const &) {}
Empty & operator = (Empty const &) {}
Empty() {}
};
Здесь присутствуют 4 функции:
Empty() ― конструктор без параметров,
Empty(Empty const &) ― конструктор копий,
Empty & operator =(Empty const &) ― операция копирующего присваивания,
Empty() ― деструктор.
Если класс не имеет членов-данных, то эти функции не выполняют никаких действий.
Напомним, что класс для описания исключения часто можно оста- вить пустым, используя принцип определения по умолчанию. И тогда его описание будет выглядеть именно таким образом.
Добавим член класса value в определение «не очень полезного» пустого класса. class NotQuiteEmpty{ public: int value;
};
У классов, имеющих члены-данные, конструктор без параметров и деструктор, создаваемые по умолчанию, остаются пустыми. Конструктор копии, создаваемый по умолчанию, выполняет побитовое копирование членов-данных. Операция присваивания, создаваемая по умолчанию, вы- полняет побитовое присваивание.
Принцип создания функций-членов класса по умолчанию состоит в следующем: если любая из перечисленных функций, кроме конструктора без параметров, явно не задана, она создаётся неявно по умолчанию. Прин-
59 цип создания функций-членов класса по умолчанию для конструктора без параметров имеет особенность. Такой конструктор создается неявно, толь- ко если не задан явно ни один конструктор. Если в классе будет явно опи- сан хотя бы один конструктор, неявного создания конструктора без пара- метров не произойдёт. А это в дальнейшем при использовании класса мо- жет привести к ошибкам.
На практике, иногда, создаваемые неявно функции могут привнести нежелательную функциональность, от которой нужно избавиться. Напри- мер,
нужно создать класс, для которого должен существовать только один экземпляр. Такие классы называются синглетонами. Для них рекомендует- ся запретить операцию присваивания и конструктор копирования. С другой стороны, рекомендуется наряду с другими конструкторами всегда иметь конструктор без параметров, даже если его тело является пустым.
В C++ предусмотрен механизм управления генерацией стандартных конструкторов и функций. Рассмотрим его на примере класса A: class A { private: int x; public:
A(int i) {…}
// Данная запись указывает на необходимость сгенерировать
// конструктор по умолчанию
A() = default;
// Запретить генерацию конструктора копии по умолчанию
A(const A&) = delete;
// А так можно запретить генерацию operator=
A& operator = (const A&) = delete;
}
60
1.10.
Семантика перемещения: move-конструктор и
move-operator=
Семантика перемещения появилась в стандарте С++11. Она нацелена на уменьшение количества создаваемых копий объектов при выполнении конструктора копии и операции присваивания, которые вызываются для rvalue выражений. Любое выражение в C++ является или левосторонним
(lvalue), или правосторонним (rvalue). Выражение lvalue — это объект, ко- торый имеет имя. Все переменные являются lvalue. А выражение rvalue — это временный безымянный объект, не существующий за пределами того выражения, которое его создало. Более подробно rvalue и lvalue выражения в С++ рассматривались в [2, раздел 1.13].
Для иллюстрации проблемы лишних копий рассмотрим упрощённую реализацию класса для динамического массива.
Пример 8. Реализовать move-семантику на примере упрощённого класса для динамического массива.
Для этого достаточно описать конструктор с параметрами по умолча- нию и конструктор копии, деструктор и перегрузить операцию сложения двух массивов одинакового размера и операцию присваивания. Чтобы от- слеживать процесс создания и удаления объектов, в классе добавлены два поля:
поле name — имя, отражающее способ создания объекта;
статическое поле f для подсчёта существующих в текущий момент объектов. Значение поля f увеличивается каждый раз при выполнении операции new и уменьшается при выполнении операции delete.
#include
#include using namespace std; class myvector { private:
61 static int f; string name; int size; int * vect; public: myvector(int s = 1, string nm ="noname"): size(s), name(nm){ f++; cout << "constructor > " << name<<" "< } myvector(const myvector & v): size(v.size){ f++; name = "Copy ( "+v.name+" )"; cout << "copy > " << name << " " << f << endl; vect = new int[v.size]; for (int i = 0; i }
myvector(){ f--; cout << "destructor > " << name << " " << f << endl; delete[] vect;
} myvector& operator= (const myvector &v){ cout << name <<" operator= > " << v.name <<" "<< f << endl; if (&v != this) { delete[] vect; vect = new int[v.size]; for (int i = 0; i } return *this;
} myvector operator+(const myvector& v){ if (size == v.size) { myvector v1(size, name + " + "+v.name); for (int i = 0; i < size; ++i) v1.vect[i] = vect[i] + v.vect[i]; return v1;
} return *this;//лучше использовать исключение
}
};
В следующей программе выражениями rvalue являются a + b внут- ри вызова конструктора копии для объекта сс и a + D в операторе при-
62 сваивания. Именно для них будут создаваться дополнительные временные объекты, которые после использования в конструкторе копии и операции присваивания тут же будут удалены.
#include "vect.h" int myvector::f = 0; int main() { myvector a(3,"A"), b(3,"B"),D(a); myvector cc (a + b); a = b; b = a + D; return 0;
}
В окне вывода можно проследить последовательность вызовов кон- структоров и деструкторов для данной программы (рис. 1.4).
Рис.1.4. Последовательность вызовов конструкторов и деструкторов
Каждая строка содержит информацию о вызываемой функции (кон- структор, деструктор, операция присваивания), имени объекта и количестве объектов, существующих в текущий момент. При этом можно заметить создание и уничтожение временных объектов, а также накладные расходы на копирование этих объектов.
63
Например, при создании объекта myvector cc (a + b) сначала создаётся временный объект при вычислении значения a + b. Затем вы- зывается конструктор копии myvector(const myvector & v), в ко- торый в качестве значения передаётся, созданный ранее, временный объект с именем A + B. После копирования временный объект удаляется. В ре- зультате происходит лишнее выделение памяти и копирование данных од- ного и того же объекта.
Во многих современных компиляторах встроен механизм Return Value
Optimization (RVO), решающий, в том числе, и эту проблему. Однако авто- матическая оптимизация не всегда бывает эффективной, поэтому в стан- дарте C++11 Бьярн Страуструп предложил вынести решение на уровень языка. Для этого были введены move-конструктор и move-operator=
Идея move-семантики состоит в том, чтобы не удалять временный объект и не выделять память для полей в новом объекте, а инициализиро- вать поля в создаваемом объекте ссылками на поля временного объекта.
Деструкторы для временных переменных вызываются в тот момент, когда эти переменные уже не используются для вычислений. Однако при использовании move-семантики деструктор не
должен освобождать память временного объекта, поскольку ссылкой на нее инициализируется поле в другом объекте. Для этого в move-конструкторе и move-операции присваи- вания поле указатель временного объекта меняет значение на nullptr, а в деструкторе выделенная память освобождается только, если поле указатель не равен nullptr.
Конструктор копирования выделяет новую область памяти для хранения данных, вызывая
оператор new, а перемещающий конструктор — забирает данные у переданного ему временного объекта.
64
Добавим в класс move-конструктор, move-операцию присваивания и внесём изменения в деструктор.
//move-конструктор myvector(myvector&& v){ name = "Move_Copy ( " + v.name+" )"; cout << "move > " << name << " " << f << endl; size = v.size; vect= v.vect;
// Не позволит сразу удалить временный объект v.vect = nullptr;
}
//измененный деструктор
myvector(){ if (vect != nullptr) { f--; cout << "destructor > " << name << " " << f << endl; delete[] vect;
}
}
//move-операция присваивания myvector& operator=(myvector&& v){ cout << name <<" operator-move= > "<< v.name <<" "<< f <}
Чтобы отличать функции с перемещающей семантикой в стандарте
С++11 введены rvalue ссылки — myvector && v. При этом компилятор будет использовать функции с перемещающей семантикой только в случае, если параметром является rvalue выражение (временный объект).
Теперь, при выполнении той же самой программы, для строки myvector cc(a+b); компилятор выберет move-конструктор вместо конструктора копии. А для строки b = a + D; компилятор выберет move- операцию присваивания. Результат выполнения приведен на рисунке 1.5.
65
Рис.1.5. Последовательность вызовов для move-семантики
Вследствие наличия большого количества стандартных классов исполь- зовать move-конструкторы и move-операции присваивания приходится редко, однако знание такого механизма необходимо.