7. Обработка исключительных ситуаций в С++.
Проблемы с динамической памятью при обработке исключительных ситуаций. Перегрузка операторов в С++. Правила перегрузки операторов. Операторы .*, ->*.
Перегрузка унарных и бинарных операторов. Перегрузка оператора = : копирование, перенос. Перегрузка операторов ->, *, []. Перегрузка операторов ++, --. Операторы приведения типов.
Начало
Мы знаем, что можно легко модифицировать программу, подменяя один объект другим, работая с указателем или ссылкой на базовый класс. Благодаря полиморфизму, мы, ставя указатель или ссылку на объект производного класса, можем работать с методами производного класса - будут вызываться подменяющие методы за счет полиморфных свойств.
Базовый класс в данном случае выступает как объединяющий, его задача - сформировать интерфейс, который обязаны будут поддерживать все производные классы. Поэтому, в объектно-ориентированном проектировании базовый класс рассматривается как абстрактное понятие, объекты которого создавать нельзя. Это реализуется за счет чисто виртуальных методов или виртуального деструктора. Таким образом, мы получаем возможность легкой подмены одного объекта на другой. Производные классы не должны ни сужать, ни расширять базовый интерфейс.
Возникают две проблемы:
Создавая объект, мы вызываем его конструктор (создаем конкретный объект конкретного типа) - проблема подмены. Дело в том, что конструктор является не методом объекта, а методом класса, он не может быть виртуальным как любой другой метод.
Возникают задачи, в которых нам надо расширять интерфейс базового класса.
Эти две проблемы мы будем решать в дальнейшем.
Таким образом, объектно-ориентированный подход решает одну из серьезнейших проблем структурного программирования - модификацию без изменения написанного кода.
Обработка исключительных ситуаций
Недостатки обработки ошибок в структурном программировании
Недостатки:
Если где-то возникает ошибка в коде, мы вынуждены "протащить" ее через все уровни абстракции/иерархии до того места, пока мы не сможем обработать эту ошибку.
Весь код насыщен непрерывными проверками. Обработка ошибки совмещена вместе с кодом.
Идея в том, чтобы при возникновении какой-либо ошибки передавать управление непосредственно в то место, где мы можем обработать ее, не "протаскивая" ее через много уровней.
Обработка ошибок в C++
Инструкции обработки ошибок:
Инструкция
try
- заворачиваем в неё блок кода, в котором может произойти ошибка, и берём его под контроль.Если в блоке
try
возникает исключительная ситуация, мы можем перейти на обработчикcatch
. Обработчики идут непосредственно после блокаtry
.Генерируем исключительную ситуацию, используя инструкцию
throw
.
Обработчиков может быть несколько. Обработчик принимает объект какого-либо типа. Соответственно, если объект, который мы передаем в throw
или создаем throw
, может быть принят обработчиком (тип совпадает или приводится к этому типу), вызывается этот обработчик. Если не может, он передается следующим обработчикам до тех пор, пока не выберется блок catch
. Если на этом уровне ни один из catch
не перехватил этот объект, то это передается на более высокий уровень. Ошибка может никем не перехватиться, в этом случае программа "падает".
Мы можем перехватить любые исключительные ситуации, используя catch
с 3 точками. Этот обработчик должен быть в конце списка, иначе он перехватит любую ситуацию, и другие обработчики ниже не будут работать.
Когда мы выходим на обработчик, удаляются все временные объекты, которые создавались в блоке try, то есть вызываются их деструкторы в обратном порядке их создания.
Плюсы:
Мы не "протаскиваем ошибку"
Вся обработка сводится в одно место
Задачи обработчика
Задачи, возлагаемые на обработчик:
Выдать сообщение пользователю или записать его в log-файл. В сообщении нужно писать:
Время ошибки
Где она произошла
Что за ошибка
Возможно, данные, которые привели к этой ошибке
Задача обработчика - по возможности обработать ситуацию, но ошибка может быть критической. В этом случае на обработчик возлагается функция корректного завершения программы (например, нормально закрыть БД, чтобы не потерять данные)
Проблема с динамической памятью при обработке исключительных ситуаций
В C++, перейдя на обработчик, мы не можем вернуться в место возникновения ошибки (все временные объекты будут уничтожены). Это проблема.
Предположим, у класса А
есть метод f()
. Если мы динамически выделили память:
Если при вызове метода f()
возникает исключительная ситуация и мы выходим на какой-то из обработчиков, объект obj
не удаляется. Происходит утечка памяти.
Решение проблемы с помощью noexcept
или throw()
noexcept
или throw()
Можно запретить методу обрабатывать исключительную ситуацию.
Два варианта решения проблемы:
С noexcept
при возникновении исключительной ситуации вызывается функция terminate()
. Функция terminate()
приводит к тому, что будут вызываться все деструкторы только временных объектов в порядке, обратном их созданию.
Со throw()
результат непредсказуем, это старый синтаксис, который лучше не использовать.
Если пишем
noexcept
без параметров аналогиченnoexcept(True)
- это говорит о том, что данный метод не должен обрабатывать исключительную ситуацию.Если пишем
noexcept(False)
илиthrow(...)
, то этот метод может обрабатывать все исключительные ситуации, как и в случае если ничего не пишем.
Определение исключительных ситуаций, которые метод может обрабатывать
Можно указать, какую исключительную ситуацию метод может обрабатывать.
Этот метод может обрабатывать все исключительные ситуации с этим типом и производными от него.
Исключительная ситуация в деструкторе
Вызов исключительной ситуации из деструктора - опасная вещь, может привести к непредсказуемым ситуациям. Если идет генерация исключительной ситуации, то эту ситуацию надо обрабатывать сразу в деструкторе.
Несколько обработчиков одной исключительной ситуации
Ситуацию можно "протащить". Например, обработчик принял объект, но не смог полностью обработать ситуацию. Мы можем "прокинуть" ее до следующего обработчика, который может принять этот объект:
Объект принимается везде по ссылке => объект должен создаваться исключительно при вызове исключительной ситуации. Время жизни этого объекта ограничивается этим блоком.
Исключительная ситуация в разделе инициализации объекта
Если мы говорим о теле конструктора, то тело выполняется после создания объекта, и если возникает исключительная ситуация после создания объекта, то все корректно. Но если она возникает в разделе инициализации, когда объекта еще нет, возникает проблема.
Чтобы было корректно, конструктор можно "обернуть" в try
и для него сделать обработчик:
Пишем это не при объявлении, а при инициализации конструктора.
Использование exception при обработке ошибок
Наша задача - cделать так, чтобы при модификации мы не исправляли написанный код. Хочется, чтобы при работе с ошибками был такой же подход. При модификации могут возникать новые ошибки и мы должных обрабатывать их, не изменяя написанный код. То есть, те обработчики, которые у нас написаны, должны обрабатывать новые ситуации.
Мы можем использовать механизм полиморфности. Дело в том, что обработчик перехватит исключительную ситуацию, если тип объекта является производным от заявленного в обработчике типа или им самим.
Таким образом, мы можем задавать базовый класс как ситуацию, а конкретная ситуация будет производной от этого типа.
Для универсальности и распространения на всю программы нам предоставляется базовый класс std::exception
, от него можно порождать свои классы.
Для использования мы должны подключить заголовочный файл:
Идея exception
У нас есть базовый класс std::exception
. Этот базовый класс нам представляет виртуальный метод what()
, возвращающий строку message
. Стандартные ошибки являются производными от этого класса. Производные классы могут подменять метод what()
.
Например, есть стандартная ошибка
bad_alloc
- ошибка, связанная с выделением памяти.
И да, мы свои классы можем тоже порождать от этого базового класса! Пусть у нас есть класс Array
, мы для него хотим создать объекты, которые отвечают за определенные ситуации. Назовём класс ErrorArray
. От него уже будем порождать конкретные ошибки: некорректный индекс - ErrorIndex, ErrorAlloc
(перехватываем bad_alloc
на себя).
Любую ошибку с нашим классом мы можем перехватить. На уровне класса exp
мы можем перехватить любую ошибку, связанную с нашим массивом.
Для нашего ПО таких уровней перехвата ошибок может быть много. Удобно модифицировать. Модифицируя наш класс, мы добавим новую ошибку, но она будет перехватываться на уровне базового класса, отвечающего за ошибки, связанные с объектом нашего класса.
Преимущества использования обработки исключительных ситуаций
Плюсы использования обработки исключительных ситуаций:
Не нужно прокидывать ошибку через много уровней. Мы сразу переходим в обработчик.
Мы разделяем саму логику (код нашей задачи) от обработки исключительных ситуаций, вынося обработчики отдельно, занося их в методы what().
Можем легко развивать ПО и модифицировать.
Пример с удобной обработкой исключительных ситуаций:
Пример с тем же самым классом Array
, описанным выше.
Перегрузка операторов
Перегрузка операторов - это удобный механизм, но он не относится к объектно-ориентированной части. Эта вещь уже была реализована в необъектных языках.
Идея
Мы создаем свое данное. Почему бы нам для этого данного не определить какие-либо операции? Ведь для стандартных данных они есть.
Мы должны задать знак операции, сказать, какая это операция (указать арность). Она может быть унарной, бинарной, тернарной. Операция может выполняться либо слева направо, либо справа налево. Операции имеют приоритет.
Мы не можем задавать новые операторы, а на основе существующих операторов создавать новые операции, то есть перегружать оператор.
Какие операторы нельзя перегружать
Шесть операторов, которые перегружать нельзя:
Оператор
.
- доступ к члену объекта. Если мы перегрузим этот оператор, мы не сможем вызвать для объекта ни одного метода!Оператор
.*
- указатель на метод.Оператор
::
- оператор доступа к контексту. Он применяется для доступа к членам через имя класса или для доступа к глобальному контексту.Оператор
? :
- тернарный оператор. Разработчики просто не смогли придумать, как перегрузить этот оператор. Страуструп не пришел ни к одному из решений.Оператор
sizeof
- определение размера объекта. Если мы перегрузим, мы такое вытворим в программе!Оператор
typeid
- возвращаетid
типа объекта. Если мы перегрузим, мы не сможем идентифицировать объект и понять, какого он типа.
Операторы .*
и ->*
.*
и ->*
С++ добавляет два интересных оператора. Напомним, что оператор .* перегружать нельзя, а оператор ->* можно.
Посмотрим на очень интересный пример с функциями:
Примечание: оператор
()
- оператор разыменования - вызов функции по адресу. Так же вызвать функциюf()
можно и таким образом:(*pf)()
- синтаксис позволяет.
Что касается методов класса:
Таким образом, мы разделяем вызов функции и вызов метода. Если мы вызываем метод класса через указатель для объекта, используется оператор ., а если работаем с указателем на объект, используется оператор ->.
Рекомендации по перегрузке операторов
Операторы, которые можно перегрузить только как члены классов:
Оператор
=
- оператор присваивания (бинарный)Оператор
()
- функтуатор (бинарный)Оператор
[]
- индексация (бинарный)Оператор
->
- унарныйОператор
->*
- бинарный, так как принимает указатель на метод и объект, метод которого вызываем
Бинарные операторы можно перегружать как члены класса или как внешние функции-операторы. Это зависит от ситуации. Конечно, надо отдавать предпочтение члену класса. Если мы перегружаем бинарный оператор, как член класса, он принимает 1 параметр (второй параметр он принимает неявно -
*this
).Унарные операторы перегружаем как члены класса.
Пример перегрузки бинарных операторов
Бинарный оператор перегружается либо как член, либо как внешняя функция.
Если мы перегружаем бинарный оператор как член класса, он принимает один параметр (неявно) - указатель this
.
Какие бывают проблемы с перегрузкой, как членов?
Рассмотрим пример с типом Complex
.
В данном случае оператор -
перегружен как член, а оператор +
- как друг класса. Это сделано для того, чтобы получить доступ к private-членам без get()
-еров.
Если мы перегружаем оператор, как член класса, то в этом случае как левый операнд, так и правый операнд в операторе + может неявно преобразовываться к типу Comlex
. Ну, например, мы хотим число сложить с комплексным. Для оператора +
, если он реализован, как внешняя функция, можно как число сложить с комплексным числом, так и комплексное число с простым числом. Для бинарной операции - левый операнд всегда должен быть комплексным числом. Мы не можем от числа отнять комплексное число!
Дополнительные примечания к примеру выше:
Операторы
>>
и<<
лучше перегружать только для работы с потоком ввода-вывода.Унарный оператор - не принимает параметров (один параметр передается неявно -
*this
).Когда левый операнд надо неявно приводить к типу класса, удобно определять как внешнюю функцию.
Умные указатели. Перегрузка операторов ->
и *
.
->
и *
.Оператор ->
перегружается как член класса, он унарный, принимающий один параметр - в данном случае this будет принимать, и должен возвращать либо указатель, либо ссылку на объект.
Пример реализации - использование оператора ->
.
->
.Объект класса B по существу является прозрачной оболочкой. Мы через объект B работаем с методами класса A
. Совместно с указателем ->
еще перегружается оператор *
. Он помогает делать примерно то же самое - возвращает ссылку на объект, и по ссылке мы уже вызываем метод. Мы можем перегружать эти операторы как для константных, так и для не константных объектов.
Оператор->.
может возвращать так же ссылку в том случае, если этот класс, на который она возвращает ссылку, содержит перегруженный оператор ->.
Рассмотрим пример ниже:
Перегрузка оператора ->*
. Функтуатор.
->*
. Функтуатор.Оператор ->*
перегружается как член класса и является бинарным (*this
и указатель на метод)
Этот класс Pointer
по существу скрывает связь объекта который выбирает связку и указателя на метод. Создаем объект и вызывая перегруженный оператор, он возвращает нам объект, который отвечает за связь и этот объект имеет перегруженный оператор круглые скобочки. Он уже вызывает указатель.
Перегрузка операторов [], =, ++
и приведения типа. Индексатор
[], =, ++
и приведения типа. ИндексаторПараметром оператора []
может быть объект любого типа. Таким образом можно создавать ассоциативные массивы.
Я привел пример, когда оператор квадратные скобки принимает объект класса Index
и который по индексу получает массив. Второй параметр должен быть целого типа - int
.
Мы так же можем определить оператор приведения типа.
Если мы не хотим чтобы не было возможности неявно вызвать оператор, то точно так же как для конструктора записывается модификатор explicit перед оператором приведения типа.
Что касается оператора присваивания
Если мы динамически выделяем память под члены класса, мы обязаны явно определить оператор присваивания или запретить. Дело в том, что для любого типа неявно определяется оператор присваивания, который побайтно копирует данные. Может выйти так, что два объекта указывают на одну область памяти. В большинстве случаев это не нужно.
Разница оператора присваивания с копированием - создает копию объекта, а оператор присваивания с переносом - захватывает временный объект.
Копирование - выделяем новую память и копируем из одной области в другую, а при переносе захватываем область того объекта, который получаем. Параметр обнуляем, чтобы при деструкторе не произошло удаления области памяти, которой мы захватили.
Очевидное неочевидное: Если мы перегружаем оператор, то этот оператор не наследуется. Если он будет наследоваться, то начнется абсурд. В частности если он будет наследоваться, то производный класс будет вызывает оператор присваивания базового и произойдет неполное копирование объекта.
Операторы инкремент (++
) и декремент (--
)
++
) и декремент (--
)Идея: отделить постфиксную от префиксной записи.
Решение: унарный - префиксный, бинарный - постфиксный операторы.
Замечание: если мы бинарный не перегружаем, то и для постфиксного и префиксного будет вызываться перегруженный унарный оператор. Иначе мы четко разделяем их.
Операторы приведения типов
Перегрузка операторов приведения
Позволяет определять, как объект пользовательского типа может быть приведен к другому типу.
Last updated