27-5 异常、类和继承

异常和成员函数

到目前为止,本教程中您只看到了非成员函数中使用异常的情况。然而,异常在成员函数中同样有用,在重载运算符中更是如此。考虑以下重载的 [] 运算符,它是一个简单的整数数组类的一部分:

int& IntArray::operator[](const int index)
{
    return m_data[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 代码块捕获异常并进行必要的清理,然后重新抛出异常(我们将在第27.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"; // should not be called
	}
};


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

	return 0;
}

打印出来的内容

img

在上述程序中,当类 A 抛出异常时,A 的所有成员都会被销毁。成员 m_member 的析构函数会被调用,从而有机会清理它分配的所有资源。

这也是 RAII(在第19.3 课——析构函数中介绍)被大力提倡的部分原因——即使在特殊情况下,实现 RAII 的类也能够自行清理。

然而,创建像 Member 这样的自定义类来管理资源分配效率不高。幸运的是,C++ 标准库提供了符合 RAII 标准的类来管理常见的资源类型,例如文件(std::fstream,在第28.6 课——基本文件 I/O中介绍)和动态内存(std::unique_ptr 和其他智能指针,在第 22.1 课——智能指针和移动语义简介中介绍)。

例如,而不是这样:

class Foo
private:
    int* ptr; // Foo will handle allocation/deallocation

这样做:

class Foo
private:
    std::unique_ptr<int> ptr; // std::unique_ptr will handle allocation/deallocation

在前一种情况下,如果 Foo 的构造函数在 ptr 分配了动态内存后失败,Foo 将负责清理工作,这可能很棘手。在后一种情况下,如果 Foo 的构造函数在 ptr 分配了动态内存后失败,ptr 的析构函数将执行并将该内存返回给系统。当资源处理委托给符合 RAII 标准的成员时,Foo 无需执行任何显式的清理工作!

异常类

使用基本数据类型(例如 int)作为异常类型的主要问题之一是它们本身含义模糊。更大的问题是,当 try 代码块内存在多个语句或函数调用时,如何明确异常的含义。

// Using the IntArray overloaded operator[] above

try
{
    int* value{ new int{ array[index1] + array[index2]} };
}
catch (int value)
{
    // What are we catching here?
}

在这个例子中,如果我们捕获到一个 int 类型的异常,它究竟能告诉我们什么?是数组的某个索引越界了吗?是加号运算符导致了整数溢出吗?还是 new 运算符因为内存不足而失败了?遗憾的是,在这种情况下,我们很难区分具体原因。虽然我们可以抛出 const char* 类型的异常来解决“哪里出了问题”的问题,但这仍然无法让我们针对不同来源的异常进行区别处理。

解决这个问题的一种方法是使用异常类。异常类就是一个专门设计用来抛出异常的普通类。让我们设计一个简单的异常类,与我们的 IntArray 类一起使用:

#include <string>
#include <string_view>

class ArrayException
{
private:
	std::string m_error;

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

	const std::string& getError() const { return m_error; }
};

以下是使用此类的完整程序:

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

class ArrayException
{
private:
	std::string m_error;

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

	const std::string& getError() const { return m_error; }
};

class IntArray
{
private:
	int m_data[3]{}; // assume array is length 3 for simplicity

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] }; // out of range subscript
	}
	catch (const ArrayException& exception)
	{
		std::cerr << "An array exception occurred (" << exception.getError() << ")\n";
	}
}

img

使用这样的类,我们可以让异常返回对所发生问题的描述,从而提供出错原因的上下文。而且由于 ArrayException 是一个独特的类型,我们可以专门捕获数组类抛出的异常,并根据需要区别对待它们。

请注意,异常处理程序应该按引用而不是按值捕获类异常对象。这可以防止编译器在捕获异常时复制异常对象(当异常是类对象时,复制异常会造成很大的开销),并且可以防止在处理派生异常类时出现对象切片(我们稍后会讨论)。除非有特殊原因,否则通常应避免按指针捕获异常。

最佳实践:
基本类型的异常可以通过值捕获,因为复制成本很低。
类类型的异常应该通过(常量)引用捕获,以避免代价高昂的复制和切片操作。

异常和继承

由于可以将类作为异常抛出,而类又可以继承自其他类,因此我们需要考虑使用继承的类作为异常时会发生什么。事实证明,异常处理程序不仅会匹配特定类型的类,还会匹配继承自该特定类型的类!请看以下示例:

#include <iostream>

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;
}

在上面的例子中,我们抛出了一个派生类型的异常。然而,该程序的输出结果是:

img
img

发生了什么?

首先,如上所述,派生类会被基类型的异常处理程序捕获。因为 Derived 派生自 Base,所以 Derived 是一个 Base(它们之间存在 is-a 关系)。其次,当 C++ 尝试查找引发异常的处理程序时,它是按顺序进行的。因此,C++ 首先会检查 Base 的异常处理程序是否与 Derived 的异常匹配。因为 Derived 是一个 Base,所以答案是肯定的,因此它会执行 Base 类型的 catch 代码块!在这种情况下,Derived 的 catch 代码块甚至都不会被检查。

为了使这个例子按预期运行,我们需要调换 catch 代码块的顺序:

#include <iostream>

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;
}

img

这样,派生类对象处理程序将优先捕获派生类对象(在基类对象处理程序之前)。基类对象与派生类对象处理程序不匹配(派生类是基类,但基类不是派生类),因此会“向下传递”到基类对象处理程序。

规则:
派生异常类的处理程序应该列在基类的处理程序之前。
使用基类的处理程序来捕获派生类型的异常的能力,被证明是极其有用的。

std::exception

标准库中的许多类和运算符在失败时会抛出异常。例如,如果 new 运算符无法分配足够的内存,则会抛出 std::bad_alloc 异常。dynamic_cast 失败也会抛出 std::bad_cast 异常。诸如此类。截至 C++20,共有 28 种不同的异常类可以被抛出,并且后续的每个语言标准都会增加新的异​​常类。

好消息是,所有这些异常类都派生自一个名为std::exception 的类(定义在 <exception> 头文件中)。std::exception 是一个小型接口类,旨在作为 C++ 标准库抛出的任何异常的基类。

大多数情况下,当标准库抛出异常时,我们并不关心是内存分配错误、类型转换错误还是其他什么问题。我们只关心发生了灾难性的错误,导致程序崩溃。多亏了 std::exception,我们可以设置一个异常处理程序来捕获 std::exception 类型的异常,这样就能在一个地方同时捕获 std::exception 及其所有派生异常。简单吧!

#include <cstddef> // for std::size_t
#include <exception> // for std::exception
#include <iostream>
#include <limits>
#include <string> // for this example

int main()
{
    try
    {
        // Your code using standard library goes here
        // We'll trigger one of these exceptions intentionally for the sake of the example
        std::string s;
        s.resize(std::numeric_limits<std::size_t>::max()); // will trigger a std::length_error or allocation exception
    }
    // This handler will catch std::exception and all the derived exceptions too
    catch (const std::exception& exception)
    {
        std::cerr << "Standard exception: " << exception.what() << '\n';
    }

    return 0;
}

在作者的电脑上,上述程序输出如下:

img

上面的例子应该很容易理解。需要注意的是,std::exception 有一个名为what()的虚成员函数,它返回一个 C 风格的异常描述字符串。大多数派生类都会重写 what() 函数来更改异常消息。请注意,此字符串仅用于描述异常,不要将其用于比较,因为不同编译器生成的字符串可能不同。

有时我们需要对特定类型的异常进行特殊处理。在这种情况下,我们可以为该特定类型添加一个处理程序,而让所有其他异常“回退”到基础处理程序。例如:

try
{
     // code using standard library goes here
}
// This handler will catch std::length_error (and any exceptions derived from it) here
catch (const std::length_error& exception)
{
    std::cerr << "You ran out of memory!" << '\n';
}
// This handler will catch std::exception (and any exception derived from it) that fall
// through here
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 <exception> // for std::exception
#include <iostream>
#include <stdexcept> // for std::runtime_error

int main()
{
	try
	{
		throw std::runtime_error("Bad things happened");
	}
	// This handler will catch std::exception and all the derived exceptions too
	catch (const std::exception& exception)
	{
		std::cerr << "Standard exception: " << exception.what() << '\n';
	}

	return 0;
}

打印出来的内容:

img

从 std::exception 或 std::runtime_error 派生您自己的类

当然,你可以从 std::exception 派生出自己的类,并重写虚函数 what() const 成员函数。以下是与上面相同的程序,其中 ArrayException 派生自 std::exception

#include <exception> // for std::exception
#include <iostream>
#include <string>
#include <string_view>

class ArrayException : public std::exception
{
private:
	std::string m_error{}; // handle our own string

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

	// std::exception::what() returns a const char*, so we must as well
	const char* what() const noexcept override { return m_error.c_str(); }
};

class IntArray
{
private:
	int m_data[3] {}; // assume array is length 3 for simplicity

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) // derived catch blocks go first
	{
		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 可以接受 C 风格的字符串参数,也可以接受其他类型的const std::string&参数。

以下是使用 std::runtime_error 生成的相同示例:

#include <exception> // for std::exception
#include <iostream>
#include <stdexcept> // for std::runtime_error
#include <string>

class ArrayException : public std::runtime_error
{
public:
	// std::runtime_error takes either a null-terminated const char* or a const std::string&.
	// We will follow their lead and take a const std::string&
	ArrayException(const std::string& error)
		: std::runtime_error{ error } // std::runtime_error will handle the string
	{
	}


        // no need to override what() since we can just use std::runtime_error::what()
};

class IntArray
{
private:
	int m_data[3]{}; // assume array is length 3 for simplicity

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) // derived catch blocks go first
	{
		std::cerr << "An array exception occurred (" << exception.what() << ")\n";
	}
	catch (const std::exception& exception)
	{
		std::cerr << "Some other std::exception occurred (" << exception.what() << ")\n";
	}
}

img

你可以选择创建自己的独立异常类,使用标准异常类,或者从 std::exceptionstd::runtime_error 派生自己的异常类。所有这些方法都有效,具体取决于你的目标。

异常的生命周期

当抛出异常时,抛出的对象通常是分配在栈上的临时变量或局部变量。然而,异常处理过程可能会回溯函数,导致函数内部所有局部变量都被销毁。那么,抛出的异常对象是如何在栈回溯后仍然存在的呢?

当抛出异常时,编译器会将异常对象复制到调用栈之外的一块未指定的内存区域,该区域专门用于处理异常。这样,无论调用栈是否被回绕,异常对象都会被持久化。异常对象会一直存在,直到被处理为止。

这意味着被抛出的对象通常需要是可复制的(即使栈实际上并未展开)。智能编译器或许能够执行移动操作,或者在特定情况下完全省略复制步骤。

提示:
异常对象需要是可复制的。

以下示例展示了当我们尝试抛出一个不可复制的派生对象时会发生什么:

#include <iostream>

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

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

    Derived(const Derived&) = delete; // not copyable
};

int main()
{
    Derived d{};

    try
    {
        throw d; // compile error: Derived copy constructor was deleted
    }
    catch (const Derived& derived)
    {
        std::cerr << "caught Derived";
    }
    catch (const Base& base)
    {
        std::cerr << "caught Base";
    }

    return 0;
}

img
img

当编译此程序时,编译器会报错,提示派生复制构造函数不可用,并停止编译。

异常对象不应持有指向栈分配对象的指针或引用。如果抛出的异常导致栈展开(从而销毁栈分配的对象),这些指针或引用可能会成为悬空指针。:w

posted @ 2025-12-03 09:48  游翔  阅读(12)  评论(0)    收藏  举报