Kodewick
第 26 课,共 30 课目录

异常

第 26 课,共 30 课 · 第 6 部分 · 继承和模板 · 约 20 分钟

函数出了错,怎么告诉调用它的人?老办法是返回一个特殊的值,比如「算不出来就返回 0」。可要是 0 本身也是一个正常的答案呢?

main.cpp
1#include <iostream>2#include <vector>3 4double average(const std::vector<double>& temps) {5    if (temps.empty()) {6        return 0;7    }8    double total = 0;9    for (double t : temps) {10        total += t;11    }12    return total / temps.size();13}14 15int main() {16    std::vector<double> winter{-3, 1, 2};17    std::vector<double> nothing;18    std::cout << "冬天平均:" << average(winter) << '\n';19    std::cout << "没有数据:" << average(nothing) << '\n';20    return 0;21}
输出
冬天平均:0
没有数据:0

两个 0,一个是真的平均气温,一个是「根本没有数据」,调用的人分不出来。而且就算分得出来,也得记着每次调用都检查。C++ 的另一个办法是异常(exception):出了错,函数不返回,而是「抛出」一个异常,交给知道怎么处理的人。

throw 和 try / catch

main.cpp
1#include <iostream>2#include <stdexcept>3#include <vector>4 5double average(const std::vector<double>& temps) {6    if (temps.empty()) {7        throw std::runtime_error("没有数据");8    }9    double total = 0;10    for (double t : temps) {11        total += t;12    }13    return total / temps.size();14}15 16int main() {17    std::vector<double> nothing;18    try {19        double avg = average(nothing);20        std::cout << "平均:" << avg << '\n';21    } catch (const std::runtime_error& e) {22        std::cout << "出错了:" << e.what() << '\n';23    }24    std::cout << "程序继续\n";25    return 0;26}
输出
出错了:没有数据
程序继续

三样新东西:

  • throw std::runtime_error("没有数据"); 建一个异常对象,把它抛出。average 到这里就不往下走了,也不返回值。std::runtime_error 在 <stdexcept> 里,括号里是说明出了什么错的文字。
  • try { … } 围住可能出错的代码。
  • catch (const std::runtime_error& e) { … } 跟在 try 后面,接住这种异常。e.what() 取回当初写的那段文字。

抛出以后,try 里剩下的语句全跳过,直接去 catch;catch 做完,程序从整个 try / catch 后面接着走。所以「平均:」那一行没有显示,「程序继续」显示了。

没接住会怎样

要是没有人接,异常一路往外传,传出了 main,程序就结束了:

main.cpp
1#include <iostream>2#include <stdexcept>3#include <vector>4 5double average(const std::vector<double>& temps) {6    if (temps.empty()) {7        throw std::runtime_error("没有数据");8    }9    double total = 0;10    for (double t : temps) {11        total += t;12    }13    return total / temps.size();14}15 16int main() {17    std::vector<double> nothing;18    double avg = average(nothing);19    std::cout << "平均:" << avg << '\n';20    std::cout << "结束\n";21    return 0;22}
输出
terminate called after throwing an instance of 'std::runtime_error'
  what():  没有数据
Aborted (core dumped)

发生了什么: 抛出了一个 std::runtime_error,没有 catch 接住它,程序就结束了(std::terminate)。

这三行和你在自己电脑上用 g++ 编出来的程序一样:terminate called after throwing an instance of 'std::runtime_error' 说抛出了一个 std::runtime_error 没人接,于是调用了 std::terminate;what(): 后面是那段文字;Aborted (core dumped) 是程序被强行结束了。「结束」没有显示。

异常不能当作没看见:要么接住,要么程序结束。这比返回一个谁都可能忘了检查的 0 好。

标准库自己会抛

第 11 课说过:v.at(5) 越界会抛出 std::out_of_range,没人处理就结束程序。第 5 课的 std::stoi 碰上不是数字的文字,也会抛出 std::invalid_argument。现在你能接住它们了:

main.cpp
1#include <iostream>2#include <stdexcept>3#include <string>4#include <vector>5 6int main() {7    std::vector<int> prices{12, 30, 8};8    try {9        std::cout << prices.at(5) << '\n';10    } catch (const std::exception& e) {11        std::cout << "下标不对:" << e.what() << '\n';12    }13    try {14        int n = std::stoi("十二");15        std::cout << n << '\n';16    } catch (const std::exception& e) {17        std::cout << "不是数字:" << e.what() << '\n';18    }19    std::cout << "两个错都接住了\n";20    return 0;21}
输出
下标不对:vector::_M_range_check: __n (which is 5) >= this->size() (which is 3)
不是数字:stoi
两个错都接住了

这里接的是 const std::exception&。std::exception 是标准库所有异常的基类(第 23 课的继承):std::runtime_error、std::out_of_range、std::invalid_argument 都是它的派生类,所以一个 catch (const std::exception& e) 全都接得住,e.what() 调到的是各自的版本(第 24 课的虚函数)。stoi 的 what() 很短,只有一个 stoi。

按引用接(&):不复制,也不会把派生类对象切成基类对象;加 const,因为只是看看。这是接异常的老规矩。

几个 catch

一个 try 后面可以跟好几个 catch,按不同的错做不同的事。抛出时从上往下比,第一个对得上的接住,后面的不看:

main.cpp
1#include <iostream>2#include <stdexcept>3#include <string>4#include <vector>5 6int seat_number(const std::string& text) {7    int n = std::stoi(text);8    if (n < 1 || n > 30) {9        throw std::runtime_error("没有这个座位");10    }11    return n;12}13 14int main() {15    std::vector<std::string> tickets{"12", "abc", "45"};16    for (const auto& t : tickets) {17        try {18            int seat = seat_number(t);19            std::cout << "座位 " << seat << '\n';20        } catch (const std::invalid_argument& e) {21            std::cout << t << " 不是数字\n";22        } catch (const std::runtime_error& e) {23            std::cout << t << ":" << e.what() << '\n';24        }25    }26    return 0;27}
输出
座位 12
abc 不是数字
45:没有这个座位

"abc" 让 stoi 抛出 invalid_argument,第一个 catch 接住;"45" 让 seat_number 自己抛出 runtime_error(「没有这个座位」是这个程序自己的规矩,用 runtime_error),第一个对不上,第二个接住。try 在循环体里,所以一张票出错,下一张照样处理。

正因为第一个对上的就接住,具体的 catch 要写在前面,std::exception 这种什么都接的写在最后。还有一个 catch (...),三个点,什么类型都接,但拿不到 what(),只能放在最后一个。

先猜一猜

这段程序显示什么?

1#include <iostream>2#include <stdexcept>3 4void check(int n) {5    std::cout << "a";6    if (n > 2) {7        throw std::runtime_error("太大");8    }9    std::cout << "b";10}11 12int main() {13    try {14        check(1);15        check(5);16        check(2);17        std::cout << "c";18    } catch (const std::exception& e) {19        std::cout << "d";20    }21    std::cout << "e\n";22    return 0;23}

路上的对象会析构

异常从 throw 跳到 catch,中间可能隔着好几层函数。这几层里的局部对象怎么办?C++ 保证:一层一层退出时,每层的局部对象都会析构,和函数正常结束时一样。这叫栈展开。

main.cpp
1#include <iostream>2#include <stdexcept>3#include <string>4 5class Cup {6public:7    Cup(const std::string& name) : name_(name) {8        std::cout << "拿出" << name_ << '\n';9    }10    ~Cup() {11        std::cout << "收起" << name_ << '\n';12    }13 14private:15    std::string name_;16};17 18void make_tea() {19    Cup cup{"茶杯"};20    throw std::runtime_error("水不够");21    std::cout << "倒茶\n";22}23 24int main() {25    try {26        Cup tray{"托盘"};27        make_tea();28    } catch (const std::exception& e) {29        std::cout << "接住:" << e.what() << '\n';30    }31    return 0;32}
输出
拿出托盘
拿出茶杯
收起茶杯
收起托盘
接住:水不够

点单步,走到第 20 行的 throw 以后一步一步看:下一步不是 catch,而是 make_tea 里 cup 的析构函数;再下一步,main 的 try 里 tray 的析构函数;然后才到 catch。先收起茶杯、再收起托盘,和它们建出来的顺序相反。

所以第 21 课的智能指针、第 11 课的 vector 碰上异常也不会泄漏:它们都是局部对象,析构函数照样运行。

自己的异常类

标准的异常类够用,但有时想让异常多带点东西,或者让 catch 能分清「这是银行的错」。自己写一个类,继承 std::runtime_error 就行:

main.cpp
1#include <iostream>2#include <stdexcept>3#include <string>4 5class BankError : public std::runtime_error {6public:7    BankError(const std::string& message, int short_by)8        : std::runtime_error(message), short_by_(short_by) {}9    int short_by() const {10        return short_by_;11    }12 13private:14    int short_by_;15};16 17class Account {18public:19    Account(int balance) : balance_(balance) {}20    void withdraw(int amount) {21        if (amount > balance_) {22            throw BankError("余额不足", amount - balance_);23        }24        balance_ -= amount;25        std::cout << "取出 " << amount << ",剩 " << balance_ << '\n';26    }27 28private:29    int balance_;30};31 32int main() {33    Account account{100};34    try {35        account.withdraw(30);36        account.withdraw(100);37    } catch (const BankError& e) {38        std::cout << e.what() << ",还差 " << e.short_by() << '\n';39    } catch (const std::exception& e) {40        std::cout << "别的错:" << e.what() << '\n';41    }42    return 0;43}
输出
取出 30,剩 70
余额不足,还差 30

BankError 的构造函数在初始化列表里调用基类的构造函数 std::runtime_error(message)(第 23 课),what() 就有了;自己再加一个成员 short_by_,记着差多少。因为它是 std::runtime_error 的派生类,别处只写了 catch (const std::exception& e) 的代码也接得住它。

注意: throw 后面其实可以是任何值,throw 42;、throw std::string("错了"); 都行。可是那样就没有 what(),catch (const std::exception&) 也接不住。习惯上只抛标准异常或它们的派生类。

什么时候用异常

异常适合「这里处理不了、也不该发生」的错:函数做不成它答应的事,又不知道该怎么办,就交给上面。构造函数是最典型的地方,它没有返回值,参数不对时只能抛出,这样就不会有一个半坏的对象:

main.cpp
1#include <iostream>2#include <stdexcept>3 4class Discount {5public:6    Discount(int percent) : percent_(percent) {7        if (percent < 0 || percent > 100) {8            throw std::invalid_argument("折扣要在 0 到 100 之间");9        }10    }11    double apply(double price) const {12        return price * (100 - percent_) / 100;13    }14 15private:16    int percent_;17};18 19int main() {20    try {21        Discount sale{20};22        std::cout << sale.apply(50) << '\n';23        Discount wrong{120};24        std::cout << wrong.apply(50) << '\n';25    } catch (const std::invalid_argument& e) {26        std::cout << e.what() << '\n';27    }28    return 0;29}
输出
40
折扣要在 0 到 100 之间

wrong 根本没建成,后面也就没机会用到一个错的折扣。

情况用什么
很常见、调用的人马上就要处理(找不到、输入不对)返回值,比如 find 返回 end()
少见、这里处理不了(参数不对、资源没有了)throw 异常
程序自己写错了(下标算错)修代码,别靠 catch 掩盖

练一练

写好代码,点检查。提示随便看,实在想不出来再看答案。检查只比较输出,所以这几道题要在 catch 里显示一行,证明真的接住了。

练习 1

接住越界的下标

第二次点单时下标是 7,menu.at(7) 会抛出异常,程序结束。在循环体里加上 try / catch,接住 std::out_of_range,显示 没有下标 7。程序要显示下面两行。

要显示
红茶
没有下标 7
exercise1.cpp
1#include <iostream>2#include <stdexcept>3#include <string>4#include <vector>5 6int main() {7    std::vector<std::string> menu{"咖啡", "红茶", "果汁"};8    std::vector<int> picks{1, 7};9    for (int i : picks) {10        std::cout << menu.at(i) << '\n';11    }12    return 0;13}
输出
点「运行」或按 Ctrl+Enter
练习 2

除数不能是 0

main 已经准备好接异常了,可 divide 碰到 0 不会抛出。让 divide 在 b 是 0 时抛出 std::runtime_error,文字是 除数不能是 0。程序要显示下面两行。

要显示
5
出错了:除数不能是 0
exercise2.cpp
1#include <iostream>2#include <stdexcept>3#include <vector>4 5int divide(int a, int b) {6    return a / b;7}8 9int main() {10    std::vector<int> divisors{2, 0};11    for (int b : divisors) {12        try {13            std::cout << divide(10, b) << '\n';14        } catch (const std::runtime_error& e) {15            std::cout << "出错了:" << e.what() << '\n';16        }17    }18    return 0;19}
输出
点「运行」或按 Ctrl+Enter
练习 3

修好它:具体的 catch 写在前面

「八」不是数字,本该显示 不是数字:八,可现在显示的是 出错了,编译器还给了一个警告。只调整代码的顺序,让程序显示下面两行。

要显示
16
不是数字:八
exercise3.cpp
1#include <iostream>2#include <stdexcept>3#include <string>4#include <vector>5 6int main() {7    std::vector<std::string> inputs{"8", "八"};8    for (const auto& text : inputs) {9        try {10            int n = std::stoi(text);11            std::cout << n * 2 << '\n';12        } catch (const std::exception& e) {13            std::cout << "出错了\n";14        } catch (const std::invalid_argument& e) {15            std::cout << "不是数字:" << text << '\n';16        }17    }18    return 0;19}
输出
点「运行」或按 Ctrl+Enter

这一课学会了

  • 出了错,函数可以 throw 一个异常,比如 throw std::runtime_error("没有数据");(<stdexcept>),不用返回一个容易弄混的特殊值。
  • try { … } 围住可能出错的代码,catch (const std::runtime_error& e) 接住,e.what() 是那段文字;接住以后从 try / catch 后面接着走。
  • 没人接住,程序结束:terminate called after throwing an instance of …、what():、Aborted (core dumped)。
  • v.at() 和 std::stoi 自己会抛(out_of_range、invalid_argument),现在能接住了。
  • catch (const std::exception& e) 接住所有标准异常;几个 catch 时第一个对上的接住,具体的写在前面,catch (...) 放最后。
  • 抛出以后,从 throw 到 catch 路上每一层的局部对象都会析构(栈展开)。
  • 自己的异常类继承 std::runtime_error,构造函数写 : std::runtime_error(message)。
  • 少见、这里处理不了的错用异常;常见的情况用返回值;程序写错了就修代码。