Механизмы раннего и позднего связывания
Полиморфизм ("много форм") – это свойство классов-родственников выполнять однотипные действия по-разному. Разберёмся, как можно реализовать полиморфизм в С++ при организации иерархий классов.
Мы уже говорили, что в классах-потомках можно переопределять реализацию методов родителя. Сделать это можно двумя способами:
1) Переопределение (наверное, более точным будет термин "простое переопределение")
2) Перекрытие
При простом переопределении действительно всё просто – достаточно определить в потомке метод с таким же прототипом, как в родителе. При этом используется так называемое раннее связывание, т.е. компилятор по контексту определяет, метод какого класса необходимо использовать при вызове метода.
В примере 3.4 мы уже использовали такой приём, определив два разных метода show() в классах coord и point. Использовать такой способ, конечно, можно, но никаких интересных возможностей от него ожидать не приходится. Например, при выполнении примера 3.4 фрагмент кода
coord *cor = &p; // p – это переменная типа point
cor-> show();
выведет только координаты x и y – компилятор подключил метод show класса coord.
Чтобы избежать подобной ситуации и получить возможность более гибкого определения, какой именно из переопределённых методов следует вызвать, используется механизм перекрытия метода. Для реализации перекрытия используются виртуальные функции – методы класса, которые могут быть перекрыты в классах-наследниках. Для того, чтобы механизм перекрытия заработал, метод родителя должен быть определён с использованием специального слова virtual. При этом виртуальный метод представляет собой какой-то универсальный (общий) метод класса. Вопрос о том, метод какого класса вызывать при вызове виртуального метода решается на этапе выполнения программы. Такой механизм называется поздним связыванием.
Для того, чтобы почувствовать разницу между простым переопределением и перекрытием, можете выполнить такой эксперимент. В программный код примера 3.4 добавьте слово virtual перед определением метода show в классе coord:
virtual void show() {тело не меняем}
Перекомпилируйте программу и убедитесь, что теперь выводятся все поля класса point – механизм позднего связывания заработал!
Отметим, что при реализации позднего связывания полезно добавить в определение соответствующего метода в классе-потомке служебное слово override (в переводе на русский - перекрытый). В нашем примере для класса coord определим метод show так:
void show() override {тело не меняем}
Такое определение заставит компилятор сделать проверку полной идентичности прототипов перекрываемых методов (без слова override компилятор в случае малейшего несовпадения прототипов будет считать их просто разными методами и не запустит механизм позднего связывания).
Кроме слова override, разрешено к использованию в этой ситуации служебное слово final, которое означает, что в следующих потомках перекрывать этот метод уже нельзя.
Добавим, что реализованы виртуальные функции с помощью скрытых от глаз программиста таблиц виртуальных функций. Поскольку компилятор не решил проблему, какой именно метод вызывать, все они загружены в таблицу виртуальных методов и ждут своего часа. При вызове соответствующего метода программа ищет в таблице адрес нужной функции и вызывает её.
Абстрактные классы
Иногда бывают случаи, когда невозможно реализовать какой-либо метод (группу методов) в классе-родителе, но точно известно, что его можно и нужно реализовать во всех классах-наследниках. Например, нельзя нарисовать геометрическую фигуру вообще, но легко изобразить точку, круг, квадрат. Для решения этой проблемы используются абстрактные методы.
Абстрактные методы – виртуальные методы класса без реализации, иначе они называются чистыми виртуальными функциями и записываются следующим образом:
virtual тип_результата имя_метода(параметры) = 0;
Соответственно, реализацию данного метода в классе писать не надо, но и нельзя создавать объекты такого класса, т.к. в принципе не понятно, что делать, если будет вызван метод без реализации. Классы, содержащие хотя бы один абстрактный метод, называются абстрактными классами. От них нельзя создавать объекты, но их очень удобно использовать при организации иерархии классов как основу для создания целого семейства наследников, имеющих общие свойства. Кстати, отметим, что упоминавшийся уже класс ios из библиотеки iostream является абстрактным классом, который используется для хранения информации, общей для классов istream и ostream.
Для примера реализации абстрактного класса и его потомков снова воспользуемся предметной областью геометрических фигур. Абстрактный класс shape содержит абстрактный метод area (площадь фигуры), который легко реализуется в двух наследниках circle и rectangle. Далее в примере представлены полиморфные функции, которые корректно работают с любой геометрической фигурой (количество потомков класса shape можно существенно расширить).
// Пример 3.5 – иерархия классов геометрических фигур.
#include <iostream>
// абстрактный класс - геометрическая фигура
class shape
{protected:
int x,y;
public:
shape(int _x,int _y);
shape();
virtual double area()=0; // чистая виртуальная функция
// нельзя вычислить площадь абстрактной фигуры
};
// первый наследник - круг
class circle:public shape
{private:
int r;
public:
circle();
circle(int _x, int _y, int _r);
double area() override;
};
// второй наследник - прямоугольник
class rectangle:public shape
{private:
int w,h;
public:
rectangle();
rectangle(int _x, int _y, int _w, int _h);
double area() override;
};
// реализация конструкторов предка
shape::shape(int _x, int _y)
{x=_x;y=_y;}
shape::shape()
{x=1;y=1;}
// методы circle
circle::circle():shape(),r(100){}
circle::circle(int _x, int _y, int _r):shape(_x,_y),r(_r){}
double circle::area(){return 3.1415926*r*r;}
// методы rectangle
double rectangle::area(){return w*h;}
rectangle::rectangle():shape(){w=100;h=100;}
rectangle::rectangle(int _x, int _y, int _w, int _h):shape(_x,_y),w(_w),h(_h){}
// Теперь мы можем писать универсальные функции для разных видов фигур
// например, сравниваем площади двух фигур
double compare(shape& x, shape& y){return x.area()-y.area();}
// находим максимальную площадь в массиве из разных фигур
double maxarea(shape **x, int n)
{double m=x[0]->area();
for(int i=1;i<n;i++)
if (x[i]->area()>m) m=x[i]->area();
return m;
}
int main(){
shape *p[2];
p[0]=new circle(100,100,50);
p[1]=new rectangle(100,100,20,30);
for (int i=0;i<2; i++)
std::cout<<p[i]->area()<<std::endl;
std::cout<<maxarea(p,2)<<std::endl;
std::cout<<compare(*p[0],*p[1])<<std::endl;
}
В данной главе мы рассмотрели самые основные понятия объектно-ориентированного программирования на C++. В следующих главах эта тема будет существенно расширена.
ОБРАБОТКА ИСКЛЮЧЕНИЙ
Основные понятия
Представим себе, что нам для каких-то целей потребовалось написать функцию, возвращающую первую строку файла с заданным именем. В простейшем варианте реализация функции может выглядеть так:
// Пример 4.1 - функция для получения первой строки файла
#include <fstream>
std::string firstLine(const std::string &fileName) {
std::ifstream inp(fileName);
std::string res;
std::getline(inp, res);
return res;
}
Однако, что делать, если в процессе работы функции что-то пошло не так, и она не может корректно выполнить свою работу? Например, файл с таким именем не существует, у пользователя недостаточно прав доступа к этому файлу, и т.п. Рассмотрим несколько способов, которыми функция может сигнализировать об этом:
1). Отсутствие какой-либо проверки возникновения ошибочных ситуаций. В промышленном программировании такой способ нежелателен, поскольку он может привести к неопределенному поведению ("undefined behavior"), при этом дальнейший результат работы программы непредсказуем. Однако, всё же к данному способу иногда прибегают в целях повышения производительности (даже некоторые функции стандартной библиотеки могут вызывать неопределённое поведение при передаче некорректных параметров).
2). Запись сообщения об ошибке в стандартный поток ошибок или в специальный файл журнала (так называемый "лог-файл") и аварийное завершение работы программы. В таком варианте функция из предыдущего примера будет выглядеть так:
// Пример 4.2 - функция для получения первой строки файла
// с аварийным завершением программы при ошибке
#include <fstream>
#include <string>
#include <cstdlib>
std::string firstLine(const std::string &fileName) {
std::ifstream inp(fileName);
if (!inp.is_open()) {
// Запись сообщения и аварийное завершение
std::cerr << "Не удалось открыть файл "
<< fileName << std::endl;
exit(1);
}
std::string res;
std::getline(inp, res);
return res;
}
Данный подход встречается, например, в программах, работающих в неинтерактивном режиме (системных службах и т.п.). Однако, во многих случаях этот вариант недостаточно гибок (особенно для интерактивных приложений). Представим, что имя файла, которое передаётся в нашу функцию, вводится пользователем. Если пользователь случайно ошибся при вводе, то было бы логично не завершать программу совсем, а сообщить пользователю об ошибке и позволить ввести имя файла ещё раз.
3). Возврат определённого значения по умолчанию. Например, самый первый вариант нашей функции firstLine именно это и делает: если файл открыть не удалось, будет возвращена пустая строка.
В некоторых случаях такой подход оправдан. Однако, его стоит использовать с осторожностью, так как он может привести к сложно обнаруживаемым ошибкам. Например, если пользователь случайно ошибётся при вводе имени файла, то из результата функции будет невозможно определить, что произошло − то ли не удалось открыть файл, то ли первая строка в файле оказалась пустой.
4). Функция может вернуть код ошибки (иногда используется термин "код возврата"). Это довольно типичный способ для языка C (заметим, что механизм исключительных ситуаций появился только в языке C++ − см. следующий пункт). Приведём вариант нашей функции, возвращающей код ошибки:
// Пример 4.3 - функция для получения первой строки файла
// с возвратом кода ошибки
std::string firstLine(const std::string &fileName, int *pErrCode) {
std::ifstream inp(fileName);
std::string res;
if (!inp.is_open()) {
*pErrCode = 1;
} else {
std::getline(inp, res);
*pErrCode = 0;
}
return res;
}
В функцию был добавлен дополнительный параметр pErrCode − указатель на переменную, куда функция поместит код ошибки. Если ошибки нет, то код будет равен нулю. Пример вызова этой функции тогда может выглядеть так:
int errCode;
std::string s = firstLine("aaa.txt", &errCode);
if (errCode == 0) {
std::cout << s;
} else {
std::cerr << "Не удалось открыть файл";
exit(1);
}
У данного подхода также есть существенные недостатки. При вызове функции можно забыть (или полениться) проверить код ошибки − как итог, получим неопределённое поведение. С другой стороны, если тщательно проверять все коды возврата, то в программе добавится большое число операторов if, её размер значительно вырастет, а читаемость ухудшится.
Дополнительно заметим, что язык C++ унаследовал от языка C большое количество стандартных библиотечных функций. Многие из них возвращают код ошибки не с помощью дополнительного параметра, а записывают его в специальную глобальную переменную errno. Следующий пример демонстрирует вычисление квадратного корня с помощью стандартной функции sqrt. Если вводимое число меньше нуля, то корень не может быть корректно вычислен − выведем сообщение об ошибке. Функция strerror позволяет получить краткое описание ошибки по его коду.
// Пример 4.4 - использование errno и функции strerror
#include <cstdio>
#include <cmath>
#include <cstring>
#include <cerrno>
int main() {
double x;
std::cin >> x;
double r = sqrt(x);
if (errno != 0) {
// выводим сообщение об ошибке
fprintf(stderr, strerror(errno));
} else {
printf("%0.3f", sqrt(x));
}
}
Примечание. Можно предположить, что использование глобальной переменной errno должно вызывать проблемы в многопоточном режиме. В некоторых старых компиляторах это так. Однако, согласно стандарту C++11, errno является уже не глобальной переменной, а специальным макросом, который гарантирует корректную работу в условиях многопоточности, и в современных компиляторах таких проблем быть не должно.
5). Генерация (возбуждение, выбрасывание) исключения. Данный способ появился в языке C++. Именно этот способ рекомендуется использовать при написании своего кода, и о нём пойдёт речь в оставшейся части главы.
Если в функции произошла аномальная ситуация, то та часть программы, которая её обнаружила, может сгенерировать исключение. Исключение может представлять собой значение почти любого типа. Чаще всего исключение является объектом какого-то класса (или структуры), хотя может быть и значением простого типа (например, int). Для возбуждения исключения используется ключевое слово throw. Перепишем нашу функцию firstLine в таком варианте:
// Пример 4.5 - функция для получения первой строки файла
// с возбуждением исключения
class FileNotOpenedException{};
std::string firstLine(const std::string &fileName) {
std::ifstream inp(fileName);
if (!inp.is_open())
throw FileNotOpenedException();
std::string res = "123";
std::getline(inp, res);
return res;
}
В случае, если файл по какой-то причине открыть не удалось, функция возбудит исключение − объект класса FileNotOpenedException. Заметим, что в нашем примере этот класс даже не содержит ни одного члена − сам тип уже даёт информацию о том, какое именно исключение произошло.
Дальнейшая работа программы зависит от того, существует ли подходящий оператор (блок) try-catch, способный обработать данное исключение. Если подходящего блока try-catch найти не удалось, то происходит аварийное завершение работы программы, перед этим этом в поток ошибок будет выдана информация о типе возникшего исключения.
Примечание. Заметим, что деструкторы объектов-локальных переменных при этом вызваны не будут. Корректное уничтожение локальных объектов в процессе раскрутки стека (см. следующий пункт) происходят лишь при наличии подходящего блока try-catch.
Перехват исключений
Для перехвата исключений используется блок try-catch, синтаксис которого (чуть упрощенно) выглядит следующим образом:
try { тело блока }
catch (Тип [декларатор]) {тело обработчика исключений 1}
[catch (Тип [декларатор]) { тело обработчика исключений 2}]
…
[catch (Тип [декларатор]) { тело обработчика исключений N-1}]
[catch (…) { тело обработчика исключений N}]
Здесь под словом "декларатор" понимается описание формального параметра − аналогично описанию параметров функций.
Для примера решим следующую задачу. Предложим пользователю ввести имя файла, после чего выведем первую строку этого файла, используя написанную выше функцию firstLine. Если в функции возникло исключение, то сообщим, что файл открыть не удалось, и предложим ввести имя файла ещё раз (будем повторять до тех пор, пока операция не завершится успешно).
// Пример 4.6 - перехват исключения
int main() {
std::string filename, result;
for(;;) {
std::cout << "Введите имя файла: ";
std::cin >> filename;
try{
result = firstLine(filename);
break;
} catch(FileNotOpenedException) {
std::cout << "Не удалось открыть файл, проверьте путь и попробуйте ещё раз\n";
} catch(...) { // пишется именно так - три точки
std::cout << "Неизвестная ошибка, попробуйте ещё раз\n";
}
}
std::cout << result;
}
Если функция firstLine завершилась успешно, то результат будет записан в переменную result, затем с помощью оператора break произойдёт выход из цикла, и результат будет выведен на консоль.
Если в функции возникло исключение типа FileNotOpenedException, то выполнение тела try будет прервано (оператор break уже не будет выполняться), и управление передастся в первый блок catch.
Заметим, что у нас может возникнуть исключение и другого типа − ведь в функции мы пользуемся классами std::ifstream и std::string, в методах которых могут возбуждаться свои исключения. Для отлова таких исключений предназначен второй блок catch с многоточием, где многоточие говорит о том, что будет поймано исключение любого типа. Например, если размер первой строки файла будет превышать размер доступной оперативной памяти, то будет поймано исключение типа std::bad_alloc.
В рассмотренном примере мы не использовали возможность объявления параметра в catch − нам было достаточно знать лишь тип исключения. Иногда может быть удобно при возбуждении исключения передавать в объекте исключения какую-то дополнительную информацию. Расширим наш пример, чтобы при возникновении исключения можно было узнать, какой именно файл не удалось открыть:
class FileNotOpenedException{
std::string msg;
public:
FileNotOpenedException(const std::string &fileName)
: msg ("Файл не найден " + fileName){}
std::string what() { return msg; }
};
Теперь оператор throw в нашей функции нужно записывать следующим образом: throw FileNotOpenedException(fileName);
Первый блок catch в функции main теперь будет выглядеть так:
catch(FileNotOpenedException &e) {std::cout << e.what() << std::endl;}
Параметр e передаётся по ссылке, чтобы не тратить лишнее время и память на создание копии объекта (аналогично передаче параметров функций).
Примечание. Заметим, что можно было бы не писать наш класс FileNotOpenedException с нуля, а унаследовать его от одного из стандартных классов исключений − при этом размер кода был бы чуть меньше. Об этом смотрите далее в этой главе.