20.5 – Исключения, классы и наследование

Добавлено 11 сентября 2021 в 12:17

Исключения и функции-члены

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

int& IntArray::operator[](const int index)
{
    return m_data[index];
}

Хотя эта функция будет отлично работать, пока index является допустимым индексом массива, ей очень не хватает проверки на ошибку. Мы могли бы добавить инструкцию assert, чтобы убедиться, что index корректен:

int& IntArray::operator[](const int index)
{
    assert (index >= 0 && index < getLength());
    return m_data[index];
}

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

int& IntArray::operator[](const int index)
{
    if (index < 0 || index >= getLength())
        throw index;

    return m_data[index];
}

Теперь, если пользователь передает недопустимый индекс, operator[] вызовет исключение типа int.

Когда конструкторы дают сбой

Конструкторы – это еще одна область классов, в которой исключения могут быть очень полезны. Если по какой-либо причине конструктор может дать сбой (например, пользователь передал недопустимые входные данные), просто выбросите исключение, чтобы указать, что объект не удалось создать. В таком случае создание объекта прерывается, и все члены класса (которые уже были созданы и инициализированы до выполнения тела конструктора) уничтожаются как обычно.

Однако деструктор класса в этом случае никогда не вызывается (потому что объект так и не завершил построение). Поскольку деструктор никогда не выполняется, вы не можете полагаться на этот деструктор в освобождении любых ресурсов, которые уже были выделены.

Это приводит к вопросу о том, что мы должны делать, если мы выделили ресурсы в нашем конструкторе, а затем возникает исключение до завершения конструктора. Как обеспечить правильное освобождение уже выделенных ресурсов? Один из способов – обернуть любой код, который может дать сбой в блок try, использовать соответствующий блок catch для перехвата исключения и выполнить любую необходимую очистку, а затем повторно выбросить исключение (эту тему мы обсудим в уроке «20.6 – Повторное выбрасывание исключений»). Однако это добавляет много бардака, и здесь легко ошибиться, особенно если ваш класс выделяет несколько ресурсов.

К счастью, есть способ получше. Воспользовавшись тем фактом, что члены класса уничтожаются, даже если конструктор завершается сбоем, если вы выполняете размещение ресурсов внутри членов класса (а не в самом конструкторе), эти члены, когда они уничтожаются, могут выполнять после себя очистку.

Например:

#include <iostream>

class Member
{
public:
    Member()
    {
        std::cerr << "Member allocated some resources\n";
    }

    ~Member()
    {
        std::cerr << "Member cleaned up\n";
    }
};

class A
{
private:
    int m_x {};
    Member m_member;

public:
    A(int x) : m_x{x}
    {
        if (x <= 0)
            throw 1;
    }

    ~A()
    {
        std::cerr << "~A\n"; // не должен вызываться
    }
};


int main()
{
    try
    {
        A a{0};
    }
    catch (int)
    {
        std::cerr << "Oops\n";
    }

    return 0;
}

Этот код печатает:

Member allocated some resources
Member cleaned up
Oops

В приведенной выше программе, когда класс A выдает исключение, все члены A уничтожаются. Вызывается деструктор m_member, который дает возможность освободить все выделенные им ресурсы.

Это одна из причин того, что RAII (метод, описанный в уроке «12.9 – Деструкторы») так широко пропагандируется – даже в исключительных обстоятельствах классы, реализующие RAII, могут выполнять после себя очистку.

Однако создание пользовательского класса, такого как Member, для управления размещением ресурсов неэффективно. К счастью, стандартная библиотека C++ поставляется с RAII-совместимыми классами для управления распространенными типами ресурсов, такими как файлы (std::fstream, рассмотренные в уроке «23.6 – Основы файлового ввода/вывода») и динамическая память (std::unique_ptr и другие умные указатели, описанные в «M.1 – Введение в умные указатели и семантику перемещения»).

Например, вместо этого:

class Foo
private:
    int *ptr; // Foo будет обрабатывать выделение/освобождение

Сделайте так:

class Foo
private:
    std::unique_ptr<int> ptr; // std::unique_ptr будет обрабатывать выделение/освобождение

В первом случае, если конструктор Foo завершится со сбоем после того, как ptr выделил свою динамическую память, Foo будет отвечать за очистку, что может быть сложной задачей. Во втором случае, если конструктор Foo завершится со сбоем после того, как ptr выделил свою динамическую память, деструктор ptr выполнит и вернет эту память в систему. Foo не должен выполнять какую-либо явную очистку, когда обработка ресурсов делегируется членам, совместимым с RAII!

Классы исключений

Одна из основных проблем с использованием базовых типов данных (таких как int) в качестве типов исключений заключается в том, что они по своей сути непонятны. Еще бо́льшая проблема – это разрешение неоднозначности того, что означает исключение, когда в блоке try есть несколько инструкций или вызовов функций.

// Использование перегруженного operator[] класса IntArray
// из примера выше

try
{
    int* value{ new int{ array[index1] + array[index2]} };
}
catch (int value)
{
    // Что мы здесь ловим?
}

В этом примере, если бы мы перехватили исключение типа int, о чем это нам сказало бы? Был ли один из индексов массива вне допустимого диапазона? operator+ вызвал целочисленное переполнение? Сбой оператора new из-за нехватки памяти? К сожалению, в этом случае нет простого способа устранить неоднозначность. Хотя мы можем генерировать исключения const char* для решения проблемы определения, ЧТО пошло не так, это всё же не дает нам возможности обрабатывать исключения из разных источников по-разному.

Один из способов решить эту проблему – использовать классы исключений. Класс исключения – это просто обычный класс, специально созданный для выдачи исключения. Давайте спроектируем простой класс исключения, который будет использоваться с нашим классом IntArray:


#include <string>
#include <string_view>

class ArrayException
{
private:
    std::string m_error;

public:
    ArrayException(std::string error)
        : m_error{ error }
    {
    }

    std::string_view getError() const { return m_error; }
// В C++14 или более ранней версии, используйте вместо этого следующее
//    const char* getError() const { return m_error.c_str(); } 
};

Вот полный код программы, использующей этот класс:

#include <iostream>
#include <string>
#include <string_view>

class ArrayException
{
private:
    std::string m_error;

public:
    ArrayException(std::string error)
        : m_error{ error }
    {
    }

    std::string_view getError() const { return m_error; }
// В C++14 или более ранней версии, используйте вместо этого следующее
//    const char* getError() const { return m_error.c_str(); } 
};

class IntArray
{
private:

    int m_data[3]{}; // для простоты предполагаем, что массив имеет длину 3
public:
    IntArray() {}

    int getLength() const { return 3; }

    int& operator[](const int index)
    {
        if (index < 0 || index >= getLength())
            throw ArrayException{ "Invalid index" };

        return m_data[index];
    }

};

int main()
{
    IntArray array;

    try
    {
        int value{ array[5] }; // индекс вне допустимого диапазона
    }
    catch (const ArrayException& exception)
    {
        std::cerr << "An array exception occurred (" << exception.getError() << ")\n";
    }
}

Используя такой класс, мы можем сделать так, чтобы исключение вернуло описание возникшей проблемы, которое предоставляет контекст для того, что пошло не так. А поскольку ArrayException – это собственный уникальный тип, мы можем специально перехватывать исключения, создаваемые классом массива, и при желании обрабатывать их иначе, чем другие исключения.

Обратите внимание, что обработчики исключений должны перехватывать объекты класса исключения по ссылке, а не по значению. Это предотвращает создание компилятором копии исключения, что может быть дорогостоящим, если исключение является объектом класса, и предотвращает обрезку объекта при работе с производными классами исключений (о которых мы поговорим чуть позже). Как правило, следует избегать перехвата исключений по указателю, если для этого нет особых причин.

Исключения и наследование

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

class Base
{
public:
    Base() {}
};

class Derived: public Base
{
public:
    Derived() {}
};

int main()
{
    try
    {
        throw Derived();
    }
    catch (const Base& base)
    {
        std::cerr << "caught Base";
    }
    catch (const Derived& derived)
    {
        std::cerr << "caught Derived";
    }

    return 0;
}

В приведенном выше примере мы генерируем исключение типа Derived. Однако результат этой программы:

caught Base

Что случилось?

Во-первых, как упоминалось выше, производные классы будут перехвачены обработчиками базового типа. Поскольку Derived является производным от Base, Derived «является» Base (между ними есть связь «является чем-либо»). Во-вторых, когда C++ пытается найти обработчик возникшего исключения, он делает это последовательно. Следовательно, первое, что делает C++, – это проверяет, соответствует ли обработчик исключений для Base исключению Derived. Поскольку Derived «является» Base, ответ – да, и он выполняет блок catch для типа Base! Блок catch для Derived в этом случае даже не проверяется.

Чтобы этот пример работал, как задумывалось, нам нужно изменить порядок блоков catch:

class Base
{
public:
    Base() {}
};

class Derived: public Base
{
public:
    Derived() {}
};

int main()
{
    try
    {
        throw Derived();
    }
    catch (const Derived& derived)
    {
        std::cerr << "caught Derived";
    }
    catch (const Base& base)
    {
        std::cerr << "caught Base";
    }

    return 0;
}

Таким образом, обработчик Derived получит первым шанс перехватить объекты типа Derived (до того, как это сделает обработчик для Base). Объекты типа Base не будут соответствовать обработчику Derived (Derived «является» Base, но Base не является Derived) и, таким образом, «провалятся вниз» до обработчика Base.

Правило


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

Возможность использовать обработчик для перехвата исключений производных типов с помощью обработчика для базового класса может быть чрезвычайно полезной.

std::exception

Многие классы и операторы в стандартной библиотеке в случае сбоя выдают исключения с объектами классов. Например, оператор new может выбросить std::bad_alloc, если не может выделить достаточно памяти. Неудачный dynamic_cast вызовет std::bad_cast. И так далее. Начиная с C++20, существует 28 различных классов исключений, которые могут быть сгенерированы, и в каждом последующем стандарте языка добавляется еще больше.

Хорошая новость заключается в том, что все эти классы исключений являются производными от одного класса std::exception. std::exception – это небольшой интерфейсный класс, предназначенный для использования в качестве базового класса для любого исключения, создаваемого стандартной библиотекой C++.

В большинстве случаев, когда стандартная библиотека генерирует исключение, нас не волнует, неудачное ли это выделение памяти, неправильное приведение или что-то еще. Нас просто волнует, что что-то катастрофически пошло не так, и теперь наша программа дает сбой. Благодаря std::exception мы можем настроить обработчик исключений для перехвата исключений типа std::exception, и в итоге мы перехватим и std::exception, и все производные исключения в одном месте. Всё просто!

#include <cstddef>   // для std::size_t
#include <iostream>
#include <exception> // для std::exception
#include <limits>
#include <string>    // для this example

int main()
{
    try
    {
        // Здесь идет ваш код, использующий стандартную библиотеку.
        // Для примера мы намеренно вызываем одно из ее исключений.
        std::string s;
        // вызовет исключение std::length_error или исключение выделения памяти
        s.resize(std::numeric_limits<std::size_t>::max());
    }
    // Этот обработчик перехватит std::exception и все производные от него исключения
    catch (const std::exception& exception)
    {
        std::cerr << "Standard exception: " << exception.what() << '\n';
    }

    return 0;
}

Приведенная выше программа печатает:

Standard exception: string too long

Приведенный выше пример довольно простой. В нем стоит отметить одну вещь: std::exception имеет виртуальную функцию-член what(), которая возвращает строку в стиле C с описанием исключения. Большинство производных классов переопределяют функцию what() для изменения этого сообщения. Обратите внимание, что эта строка предназначена для использования только для описательного текста – не используйте ее для сравнений, поскольку не гарантируется, что она будет одинаковой для разных компиляторов.

Иногда нам нужно обрабатывать определенный тип исключения по-другому. В этом случае мы можем добавить обработчик для этого конкретного типа и позволить всем остальным «проваливаться вниз» до обработчика базового типа. Рассмотрим следующий код:

try
{
     // здесь идет код, использующий стандартную библиотеку
}
// Этот обработчик здесь перехватит std::length_error
// (и любые производные от него исключения)
catch (const std::length_error& exception)
{
    std::cerr << "You ran out of memory!" << '\n';
}
// Этот обработчик перехватит std::exception (и любое исключение,
// производное от него), которое "провалится" сюда
catch (const std::exception& exception)
{
    std::cerr << "Standard exception: " << exception.what() << '\n';
}

В этом примере исключения типа std::length_error будут перехвачены и обработаны первым обработчиком. Исключения типа std::exception и всех других производных классов будут перехвачены вторым обработчиком.

Такие иерархии наследования позволяют нам использовать определенные обработчики для нацеливания на определенные производные классы исключений или использовать обработчики базовых классов для перехвата всей иерархии исключений. Это позволяет нам точно контролировать, какие исключения мы хотим обрабатывать, и при этом нам не нужно делать слишком много работы, чтобы отловить «всё остальное» в иерархии.

Использование стандартных исключений напрямую

Ничто напрямую не вызывает std::exception, и вы тоже. Однако вы можете свободно использовать другие стандартные классы исключений из стандартной библиотеки, если они адекватно соответствуют вашим потребностям. Список всех стандартных исключений вы можете найти на cppreference.

std::runtime_error (включен как часть заголовка stdexcept) выбирается часто потому, что он имеет обобщенное название, а его конструктор принимает настраиваемое сообщение:

#include <iostream>
#include <stdexcept> // для std::runtime_error

int main()
{
    try
    {
        throw std::runtime_error("Bad things happened");
    }
    // Этот обработчик перехватит std::exception
    // и все производные от него исключения
    catch (const std::exception& exception)
    {
        std::cerr << "Standard exception: " << exception.what() << '\n';
    }

    return 0;
}

Этот код печатает:

Standard exception: Bad things happened

Создание собственных классов, производных от std::exception или std::runtime_error

Конечно, вы можете наследовать свои классы от std::exception и переопределять виртуальную константную функцию-член what(). Вот та же программа, что и выше, но с исключением ArrayException, производным от std::exception:

#include <exception> // для std::exception
#include <iostream>
#include <string>
#include <string_view>

class ArrayException : public std::exception
{
private:
    std::string m_error{}; // обрабатываем нашу строку

public:
    ArrayException(std::string_view error)
        : m_error{error}
    {
    }

    // std::exception::what() возвращает const char*,
    // поэтому мы должны делать так же, как она
    const char* what() const noexcept override { return m_error.c_str(); }
};

class IntArray
{
private:

    int m_data[3] {}; // для простоты предполагаем, что массив имеет длину 3
public:
    IntArray() {}

    int getLength() const { return 3; }

    int& operator[](const int index)
    {
        if (index < 0 || index >= getLength())
            throw ArrayException("Invalid index");

        return m_data[index];
    }

};

int main()
{
    IntArray array;

    try
    {
        int value{ array[5] };
    }
    catch (const ArrayException& exception) // блоки catch с производными классами идут первыми
    {
        std::cerr << "An array exception occurred (" << exception.what() << ")\n";
    }
    catch (const std::exception& exception)
    {
        std::cerr << "Some other std::exception occurred (" << exception.what() << ")\n";
    }
}

Обратите внимание, что виртуальная функция what() имеет спецификатор noexcept (что означает, что эта функция обещает не генерировать исключения). Следовательно, у нашего переопределения также должен быть спецификатор noexcept.

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

#include <stdexcept> // для std::runtime_error
#include <iostream>
#include <string>

class ArrayException : public std::runtime_error
{
private:

public:
    // std::runtime_error принимает строку const char* с завершающим нулем.
    // std::string_view не может оканчиваться нулем, поэтому здесь это не лучший выбор.
    // Наше исключение ArrayException примет вместо него const std::string&,
    // которая гарантированно оканчивается нулем и может быть преобразована
    // в const char*.
    ArrayException(const std::string &error)
        : std::runtime_error{ error.c_str() } // std::runtime_error обработает эту строку
    {
    }

        // не нужно переопределять what(),
        // так как мы можем просто использовать std::runtime_error::what()
};

class IntArray
{
private:

    int m_data[3]{}; // для простоты предполагаем, что массив имеет длину 3
public:
    IntArray() {}

    int getLength() const { return 3; }

    int& operator[](const int index)
    {
        if (index < 0 || index >= getLength())
            throw ArrayException("Invalid index");

        return m_data[index];
    }

};

int main()
{
    IntArray array;

    try
    {
        int value{ array[5] };
    }
    catch (const ArrayException& exception) // блоки catch с производными классами идут первыми
    {
        std::cerr << "An array exception occurred (" << exception.what() << ")\n";
    }
    catch (const std::exception& exception)
    {
        std::cerr << "Some other std::exception occurred (" << exception.what() << ")\n";
    }
}

Вам решать, хотите ли вы создать свои собственные автономные классы исключений, использовать стандартные классы исключений или наследовать свои классы исключений от std::exception или std::runtime_error. Все эти подходы допустимы и зависят от ваших целей.

Теги

C++ / CppException / ИсключениеLearnCppRAII / Resource Acquisition Is Initialization / Получение ресурса есть инициализацияstd::exceptionstd::runtime_errorSTL / Standard Template Library / Стандартная библиотека шаблоновДля начинающихКласс (программирование)НаследованиеОбработка ошибокОбучениеПрограммирование

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

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