当前位置: 首页 > ai >正文

C++学习:六个月从基础到就业——异常处理:机制与最佳实践

C++学习:六个月从基础到就业——异常处理:机制与最佳实践

本文是我C++学习之旅系列的第三十八篇技术文章,也是第二阶段"C++进阶特性"的最后一篇,主要介绍C++中的异常处理机制及其最佳实践。查看完整系列目录了解更多内容。

引言

在处理复杂软件系统时,错误处理是一个关键问题。C++提供了异常处理机制,使我们能够分离正常代码流程和错误处理逻辑,从而提高代码的可读性和鲁棒性。然而,异常处理也是C++中最具争议的特性之一,使用不当会导致性能问题、资源泄漏和复杂的控制流。

本文将深入探讨C++异常处理机制的工作原理、最佳实践以及如何在实际项目中有效地使用异常处理。我们将从基础语法开始,逐步深入到高级主题,帮助你掌握这一强大而复杂的语言特性。

异常处理基础

异常处理的基本语法

C++异常处理的基本构造包括三个关键词:trycatchthrow

#include <iostream>
#include <stdexcept>double divide(double numerator, double denominator) {if (denominator == 0) {throw std::runtime_error("Division by zero!");}return numerator / denominator;
}int main() {try {// 可能抛出异常的代码double result = divide(10, 0);std::cout << "Result: " << result << std::endl;} catch (const std::runtime_error& e) {// 处理特定类型的异常std::cerr << "Caught runtime_error: " << e.what() << std::endl;} catch (const std::exception& e) {// 处理其他标准异常std::cerr << "Caught exception: " << e.what() << std::endl;} catch (...) {// 处理所有其他类型的异常std::cerr << "Caught unknown exception" << std::endl;}std::cout << "Program continues execution" << std::endl;return 0;
}

在上面的例子中:

  • throw 语句用于抛出异常
  • try 块包含可能抛出异常的代码
  • catch 块用于捕获和处理特定类型的异常
  • ... 可以用于捕获任何类型的异常

异常处理的工作机制

当异常被抛出时,C++运行时系统开始"栈展开"(stack unwinding)过程:

  1. 程序停止执行当前函数中throw语句后的代码
  2. 运行时系统沿着调用栈向上搜索,寻找处理该异常类型的catch块
  3. 在栈展开过程中,所有局部对象的析构函数被调用
  4. 如果找到匹配的catch块,执行该块中的代码
  5. 执行完catch块后,程序从try-catch结构后的语句继续执行

如果没有找到匹配的catch块,程序将调用std::terminate()函数,默认终止程序执行。

让我们通过一个示例来观察栈展开过程:

#include <iostream>class Resource {
private:std::string name;
public:Resource(const std::string& n) : name(n) {std::cout << "Resource " << name << " acquired" << std::endl;}~Resource() {std::cout << "Resource " << name << " released" << std::endl;}
};void function2() {Resource r("Function2");std::cout << "About to throw from function2" << std::endl;throw std::runtime_error("Error in function2");std::cout << "This line will never be executed" << std::endl;
}void function1() {Resource r("Function1");std::cout << "Calling function2" << std::endl;function2();std::cout << "This line will never be executed" << std::endl;
}int main() {try {Resource r("Main");std::cout << "Calling function1" << std::endl;function1();} catch (const std::exception& e) {std::cout << "Exception caught: " << e.what() << std::endl;}std::cout << "Program continues" << std::endl;return 0;
}

输出结果:

Resource Main acquired
Calling function1
Resource Function1 acquired
Calling function2
Resource Function2 acquired
About to throw from function2
Resource Function2 released
Resource Function1 released
Resource Main released
Exception caught: Error in function2
Program continues

从输出可以看到,当异常被抛出时,栈展开过程会按照与构造相反的顺序调用所有局部对象的析构函数,确保资源被正确释放。

标准异常

C++标准库异常层次结构

C++标准库提供了一套完整的异常层次结构,根类是std::exception。以下是主要的标准异常:

  • std::exception - 所有标准异常的基类
    • std::logic_error - 程序逻辑错误,一般可在程序开发时检测
      • std::invalid_argument - 无效参数
      • std::domain_error - 参数在有效范围外
      • std::length_error - 尝试创建太长的对象
      • std::out_of_range - 访问超出有效范围的元素
    • std::runtime_error - 只能在运行时检测的错误
      • std::range_error - 计算结果超出有效范围
      • std::overflow_error - 计算导致上溢
      • std::underflow_error - 计算导致下溢
    • std::bad_alloc - 内存分配失败
    • std::bad_cast - 动态类型转换失败
    • std::bad_typeid - 使用空指针调用typeid
    • std::bad_exception - 意外的异常类型
    • std::bad_function_call - 调用空函数对象
    • std::bad_weak_ptr - 通过失效的weak_ptr创建shared_ptr

以下示例展示了如何使用一些常见的标准异常:

#include <iostream>
#include <vector>
#include <stdexcept>void processVector(const std::vector<int>& vec, int index) {// 参数验证if (vec.empty()) {throw std::invalid_argument("Vector cannot be empty");}// 范围检查if (index < 0 || index >= static_cast<int>(vec.size())) {throw std::out_of_range("Index out of range");}// 处理元素std::cout << "Value at index " << index << ": " << vec[index] << std::endl;
}int main() {std::vector<int> numbers;try {processVector(numbers, 0);} catch (const std::invalid_argument& e) {std::cerr << "Invalid argument: " << e.what() << std::endl;} catch (const std::out_of_range& e) {std::cerr << "Out of range: " << e.what() << std::endl;}numbers.push_back(10);numbers.push_back(20);try {processVector(numbers, 5);} catch (const std::exception& e) {std::cerr << "Exception: " << e.what() << std::endl;}try {processVector(numbers, 1);} catch (const std::exception& e) {std::cerr << "This should not be printed" << std::endl;}return 0;
}

自定义异常类

在实际项目中,标准异常可能无法满足所有需求,我们经常需要创建自定义异常类。一个好的做法是从std::exception或其派生类继承:

#include <iostream>
#include <stdexcept>
#include <string>// 自定义异常基类
class ApplicationError : public std::runtime_error {
public:explicit ApplicationError(const std::string& message): std::runtime_error(message) {}
};// 特定类型的异常
class DatabaseError : public ApplicationError {
private:int errorCode;
public:DatabaseError(const std::string& message, int code): ApplicationError(message), errorCode(code) {}int getErrorCode() const {return errorCode;}
};class NetworkError : public ApplicationError {
private:std::string serverAddress;
public:NetworkError(const std::string& message, const std::string& address): ApplicationError(message), serverAddress(address) {}const std::string& getServerAddress() const {return serverAddress;}
};// 使用异常
void connectToDatabase(const std::string& connectionString) {if (connectionString.empty()) {throw DatabaseError("Empty connection string", 1001);}if (connectionString == "invalid") {throw DatabaseError("Invalid connection format", 1002);}std::cout << "Connected to database: " << connectionString << std::endl;
}void connectToServer(const std::string& address) {if (address.empty()) {throw NetworkError("Empty server address", "unknown");}if (address == "unreachable.com") {throw NetworkError("Server unreachable", address);}std::cout << "Connected to server: " << address << std::endl;
}int main() {try {try {connectToDatabase("invalid");} catch (const DatabaseError& e) {std::cerr << "Database error: " << e.what() << " (Error code: " << e.getErrorCode() << ")" << std::endl;// 例如,在特定错误码的情况下重新抛出异常if (e.getErrorCode() == 1002) {throw NetworkError("Database connection failed, trying backup server", "backup.com");}}} catch (const NetworkError& e) {std::cerr << "Network error: " << e.what()<< " (Server: " << e.getServerAddress() << ")" << std::endl;} catch (const ApplicationError& e) {std::cerr << "Application error: " << e.what() << std::endl;} catch (const std::exception& e) {std::cerr << "Standard exception: " << e.what() << std::endl;}return 0;
}

异常规范与noexcept

异常规范的历史

在C++的历史中,异常规范经历了以下变化:

  1. C++98/03: 引入了动态异常规范throw(type-list)

    void func() throw(std::runtime_error, std::logic_error);
    
  2. C++11: 将动态异常规范标记为弃用,引入noexcept说明符

    void func() noexcept;  // 保证不抛出异常
    void func() noexcept(expression);  // 条件性保证
    
  3. C++17: 彻底移除动态异常规范,只保留noexcept

noexcept说明符

noexcept说明符用于指定函数不会抛出异常:

void functionA() noexcept;  // 保证不会抛出异常
void functionB() noexcept(sizeof(int) > 4);  // 条件性保证

如果noexcept函数确实抛出了异常,std::terminate将被调用,立即终止程序。

noexcept的主要用途:

  1. 优化:编译器可以对标记为noexcept的函数进行更积极的优化
  2. 保证:提供不会抛出异常的保证,特别是在移动操作中
  3. 文档:作为接口文档的一部分,明确函数的异常行为

noexcept运算符

noexcept也可以作为运算符使用,检查表达式是否声明为不抛出异常:

#include <iostream>
#include <type_traits>
#include <vector>void mayThrow() {throw std::runtime_error("Error");
}void noThrow() noexcept {// 不抛出异常
}template<typename T>
void templateFunc() noexcept(noexcept(T())) {T t;  // 如果T的构造函数不抛异常,这个函数也不抛异常
}int main() {std::cout << std::boolalpha;std::cout << "mayThrow() noexcept? " << noexcept(mayThrow()) << std::endl;std::cout << "noThrow() noexcept? " << noexcept(noThrow()) << std::endl;std::cout << "templateFunc<int>() noexcept? " << noexcept(templateFunc<int>()) << std::endl;std::cout << "templateFunc<std::vector<int>>() noexcept? " << noexcept(templateFunc<std::vector<int>>()) << std::endl;return 0;
}

何时使用noexcept

以下是使用noexcept的一些指南:

  1. 移动构造函数和移动赋值运算符:这些操作应尽可能标记为noexcept,因为标准库容器会利用这一点进行优化

  2. 析构函数:默认情况下,析构函数已经隐式标记为noexcept,除非显式声明可能抛出异常

  3. swap函数:交换函数通常应标记为noexcept,因为它们是许多算法的基础

  4. 内存管理函数:分配/释放内存的函数通常应考虑使用noexcept

  5. 简单的getter函数:不执行复杂操作的访问器函数

例如:

class MyString {
private:char* data;size_t length;public:// 析构函数默认为noexcept~MyString() {delete[] data;}// 移动构造函数声明为noexceptMyString(MyString&& other) noexcept: data(other.data), length(other.length) {other.data = nullptr;other.length = 0;}// 移动赋值运算符声明为noexceptMyString& operator=(MyString&& other) noexcept {if (this != &other) {delete[] data;data = other.data;length = other.length;other.data = nullptr;other.length = 0;}return *this;}// 交换函数声明为noexceptvoid swap(MyString& other) noexcept {std::swap(data, other.data);std::swap(length, other.length);}// getter函数声明为noexceptsize_t getLength() const noexcept {return length;}// 可能抛出异常的方法不标记为noexceptvoid resize(size_t newLength) {// 这可能会抛出std::bad_alloc异常char* newData = new char[newLength];// ...}
};

异常安全性

异常安全保证级别

异常安全性是指程序在面对异常时能够维持的可靠性和正确性。C++中通常定义了以下几个级别的异常安全保证:

  1. 无异常安全保证(No guarantee)

    • 出现异常时,程序可能处于未定义状态
    • 可能有资源泄漏或数据损坏
    • 应避免这种代码
  2. 基本异常安全保证(Basic guarantee)

    • 出现异常时,程序保持在有效状态
    • 没有资源泄漏
    • 但是对象状态可能已经改变
  3. 强异常安全保证(Strong guarantee)

    • 出现异常时,操作要么完全成功,要么状态不变
    • 保证"事务语义"或"原子性"
    • 通常通过"copy-and-swap"实现
  4. 无异常保证(No-throw guarantee)

    • 保证操作不会抛出异常
    • 对于某些操作(如析构函数)至关重要

实现异常安全

RAII (资源获取即初始化)

RAII是实现异常安全的最基本技术,它确保在构造函数中获取资源,在析构函数中释放资源:

#include <iostream>
#include <fstream>
#include <memory>
#include <mutex>// RAII文件处理
class FileRAII {
private:std::fstream file;
public:FileRAII(const std::string& filename, std::ios::openmode mode): file(filename, mode) {if (!file.is_open()) {throw std::runtime_error("Failed to open file: " + filename);}}// 不需要析构函数,std::fstream会自动关闭文件std::fstream& get() { return file; }
};// RAII互斥锁
class LockRAII {
private:std::mutex& mtx;
public:explicit LockRAII(std::mutex& m) : mtx(m) {mtx.lock();}~LockRAII() {mtx.unlock();}// 禁止复制LockRAII(const LockRAII&) = delete;LockRAII& operator=(const LockRAII&) = delete;
};void processFile(const std::string& filename) {FileRAII file(filename, std::ios::in | std::ios::out);// 使用文件...file.get() << "Writing some data" << std::endl;// 即使这里抛出异常,文件也会被正确关闭if (filename == "bad_file.txt") {throw std::runtime_error("Processing error");}// 正常退出时,文件也会被关闭
}std::mutex globalMutex;void criticalSection() {LockRAII lock(globalMutex);// 临界区代码...std::cout << "Executing critical section" << std::endl;// 即使这里抛出异常,互斥锁也会被释放if (rand() % 10 == 0) {throw std::runtime_error("Random failure in critical section");}// 正常退出时,互斥锁也会被释放
}
Copy-and-Swap技术

Copy-and-Swap是实现强异常安全保证的常用技术:

#include <algorithm>
#include <iostream>
#include <vector>class Database {
private:std::vector<int> data;std::string connectionString;bool isConnected;public:Database(const std::string& conn) : connectionString(conn), isConnected(false) {}// 连接到数据库void connect() {// 假设这可能失败if (connectionString.empty()) {throw std::runtime_error("Empty connection string");}isConnected = true;std::cout << "Connected to database" << std::endl;}// 断开连接void disconnect() {if (isConnected) {isConnected = false;std::cout << "Disconnected from database" << std::endl;}}// 提供强异常安全保证的数据更新void updateData(const std::vector<int>& newData) {// 1. 创建副本std::vector<int> tempData = newData;// 2. 在副本上执行可能抛出异常的操作for (auto& value : tempData) {if (value < 0) {throw std::invalid_argument("Negative values not allowed");}value *= 2;  // 一些处理}// 3. 当所有操作都成功时,交换新数据和旧数据data.swap(tempData);// tempData现在包含旧数据,将在函数退出时被销毁}// 使用swap实现强异常安全的赋值运算符Database& operator=(Database other) {// 注意:other是按值传递的,已经创建了副本swap(*this, other);return *this;}// 友元swap函数friend void swap(Database& first, Database& second) noexcept {using std::swap;swap(first.data, second.data);swap(first.connectionString, second.connectionString);swap(first.isConnected, second.isConnected);}// 查看数据void printData() const {std::cout << "Data: ";for (int value : data) {std::cout << value << " ";}std::cout << std::endl;}// 析构函数~Database() {disconnect();}
};
智能指针

智能指针是实现异常安全的另一个关键工具:

#include <memory>
#include <iostream>
#include <vector>class Resource {
public:Resource(int id) : id_(id) {std::cout << "Resource " << id_ << " acquired" << std::endl;}~Resource() {std::cout << "Resource " << id_ << " released" << std::endl;}void use() {std::cout << "Using resource " << id_ << std::endl;}private:int id_;
};void exceptionSafeFunction() {// 使用智能指针自动管理资源auto r1 = std::make_unique<Resource>(1);auto r2 = std::make_shared<Resource>(2);std::vector<std::shared_ptr<Resource>> resources;resources.push_back(std::move(r2));resources.push_back(std::make_shared<Resource>(3));// 即使此处抛出异常,r1和resources中的所有资源都会被正确释放for (const auto& res : resources) {res->use();if (rand() % 3 == 0) {throw std::runtime_error("Random failure");}}r1->use();
}

异常处理的性能考量

异常处理的成本

异常处理机制有一定的性能开销:

  1. 代码大小:异常处理相关的代码会增加程序大小
  2. 运行时开销
    • 正常路径:几乎没有开销
    • 异常路径:栈展开和异常对象构造有显著开销
  3. 编译器优化限制:某些优化可能受到异常处理的限制

何时使用异常

基于性能考虑,对于异常处理的使用建议:

  1. 使用异常处理

    • 真正的异常情况(罕见、非预期的错误)
    • 构造函数失败(无法通过返回值报告错误)
    • 深层次函数调用中的错误传播
  2. 避免使用异常

    • 可预见的错误条件(如用户输入验证)
    • 性能关键的代码路径
    • 实时系统或硬性延迟要求的系统
    • 嵌入式系统(资源受限)

错误处理策略比较

// 基于返回值的错误处理
bool parseData1(const std::string& input, int& result) {if (input.empty()) {return false;  // 表示解析失败}try {result = std::stoi(input);return true;  // 解析成功} catch (...) {return false;  // 解析失败}
}// 基于异常的错误处理
int parseData2(const std::string& input) {if (input.empty()) {throw std::invalid_argument("Empty input");}return std::stoi(input);  // 内部可能抛出异常
}// 使用示例
void errorHandlingDemo() {std::string input = "abc";// 方法1:使用返回值int result1;if (parseData1(input, result1)) {std::cout << "Parsed value: " << result1 << std::endl;} else {std::cout << "Failed to parse input" << std::endl;}// 方法2:使用异常try {int result2 = parseData2(input);std::cout << "Parsed value: " << result2 << std::endl;} catch (const std::exception& e) {std::cout << "Error: " << e.what() << std::endl;}
}

异常处理最佳实践

设计原则

  1. 只对异常情况使用异常处理

    • 异常应用于真正的异常情况,而非常规控制流
    • 常见的错误应通过返回值或其他机制处理
  2. 异常类的设计

    • 保持轻量级,避免复杂的异常类
    • 提供有意义的错误信息
    • 考虑异常类的层次结构
  3. 异常安全性

    • 确保代码符合基本或强异常安全保证
    • 使用RAII、智能指针和copy-and-swap等技术
  4. 处理位置

    • 在能够有意义处理异常的地方捕获它们
    • 避免捕获所有异常然后不处理(空catch块)

具体实践

  1. 构造函数中使用异常
    • 构造函数无法返回错误码
    • 构造失败时应抛出异常
class ConfigManager {
private:std::map<std::string, std::string> settings;public:ConfigManager(const std::string& configFile) {std::ifstream file(configFile);if (!file.is_open()) {throw std::runtime_error("Could not open config file: " + configFile);}// 解析配置文件...std::string line;while (std::getline(file, line)) {// 解析每行,填充settings// 如果格式错误,抛出异常if (!parseLine(line)) {throw std::runtime_error("Invalid config format: " + line);}}}private:bool parseLine(const std::string& line) {// 解析实现...return true;}
};
  1. 标准库和异常
    • 了解标准库函数何时抛出异常
    • 对容器操作、IO操作和类型转换等可能抛异常的操作做好准备
void standardLibraryExceptions() {std::vector<int> vec;try {// 操作可能抛出异常的标准库函数vec.at(10);  // 会抛出std::out_of_range} catch (const std::out_of_range& e) {std::cerr << "Range error: " << e.what() << std::endl;}try {// 类型转换可能抛出异常std::string numberStr = "abc";int number = std::stoi(numberStr);  // 会抛出std::invalid_argument} catch (const std::invalid_argument& e) {std::cerr << "Invalid argument: " << e.what() << std::endl;}
}
  1. 异常与RAII
    • 确保资源管理遵循RAII原则
    • 避免在析构函数中抛出异常
// 不好的做法:析构函数抛出异常
class BadPractice {
public:~BadPractice() {// 糟糕的设计!throw std::runtime_error("Exception in destructor");}
};// 好的做法:析构函数不抛出异常
class GoodPractice {
public:~GoodPractice() noexcept {try {// 可能失败的操作} catch (const std::exception& e) {// 记录错误但不抛出std::cerr << "Error in destructor: " << e.what() << std::endl;}}
};
  1. 异常与多线程
    • 理解线程边界如何影响异常处理
    • 确保每个线程都有适当的异常处理
#include <thread>
#include <future>void threadExceptionHandling() {// 方法1:使用标准线程,需要在线程内处理异常std::thread t1([]() {try {// 可能抛出异常的代码throw std::runtime_error("Error in thread");} catch (const std::exception& e) {std::cerr << "Thread caught exception: " << e.what() << std::endl;}});t1.join();// 方法2:使用async,可以传播异常auto future = std::async(std::launch::async, []() {// 异常将存储在future中throw std::runtime_error("Error in async task");return 42;});try {// get()将重新抛出存储在future中的任何异常int result = future.get();} catch (const std::exception& e) {std::cerr << "Caught async exception: " << e.what() << std::endl;}
}

文档和契约

明确文档化函数的异常规范是良好实践:

/*** 计算两个数的除法结果。* * @param a 被除数* @param b 除数* @return a除以b的结果* @throws std::invalid_argument 如果b为0*/
double safeDivide(double a, double b) {if (b == 0) {throw std::invalid_argument("Division by zero");}return a / b;
}

调试和错误定位

异常可以包含丰富的上下文信息,帮助诊断问题:

#include <sstream>class ContextualException : public std::runtime_error {
private:std::string contextInfo;public:ContextualException(const std::string& message, const std::string& file,int line,const std::string& function): std::runtime_error(message) {std::ostringstream oss;oss << "Error: " << message<< "\nFile: " << file<< "\nLine: " << line<< "\nFunction: " << function;contextInfo = oss.str();}const char* what() const noexcept override {return contextInfo.c_str();}
};// 使用宏简化异常创建
#define THROW_EXCEPTION(message) \throw ContextualException(message, __FILE__, __LINE__, __func__)void functionA() {THROW_EXCEPTION("Something went wrong in functionA");
}void functionB() {try {functionA();} catch (const ContextualException& e) {std::cerr << "Caught exception with context:\n" << e.what() << std::endl;throw;  // 重新抛出}
}

总结

异常处理是C++中处理错误的强大机制,但需要谨慎使用才能充分发挥其优势。本文探讨了以下关键点:

  1. 基础机制:理解try-catch-throw语法和栈展开过程
  2. 标准异常:使用标准库提供的异常层次结构
  3. 自定义异常:创建合理的异常类层次结构
  4. 异常规范:使用noexcept适当标记函数
  5. 异常安全保证:理解不同级别的异常安全保证
  6. 实现技术:使用RAII、智能指针和Copy-and-Swap等技术
  7. 性能考量:了解异常处理的性能影响
  8. 最佳实践:掌握异常处理的设计原则和具体实践

遵循这些最佳实践,你可以编写出更健壮、可维护的C++代码,有效地处理各种错误情况,同时避免异常处理带来的潜在问题。

记住,异常处理不仅仅是语言特性,它是一种设计和架构工具,能够帮助我们构建更可靠的软件系统。无论是选择使用异常还是其他错误处理机制,最重要的是保持一致性,并确保所有资源都得到适当管理。


这是我C++学习之旅系列的第三十八篇技术文章。查看完整系列目录了解更多内容。

http://www.xdnf.cn/news/2963.html

相关文章:

  • Qt5与现代OpenGL学习(三)纹理
  • 极狐GitLab 如何使用文件导出迁移项目和群组?
  • 机器学习day4-Knn+交叉验证api练习(预测facebook签到位置)
  • QT6链接mysql数据库
  • SQL实战:04之SQL中的分组问题求解
  • 深度学习·经典模型·VisionTransformer
  • 串口通信协议
  • (004)Excel 监视窗口
  • 系统分析师-第十三、十四章
  • 算法设计:分支限界法的基础原理与应用
  • Element:Cheack多选勾选效果逻辑判断
  • 区块链最佳框架:Truffle vs Hardhat vs Brownie
  • partition_pdf 和chunk_by_title 的区别
  • package.json文件中的 ^ 和 ~
  • DOM 事件的处理通常分为三个阶段:捕获、目标、冒泡【前端示例】
  • 京东关键词与商品详情信息数据采集接口指南
  • python jupyter notebook
  • 如何搭建一个简单的文件服务器的方法
  • JavaScript学习教程,从入门到精通,jQuery快速入门指南(30)
  • 建立对人工智能(AI)的信任
  • Oracle11g——空表无法导出的问题
  • 软件分析师-第三遍-章节导图-13/14
  • 基础排序方法
  • 【C++11】新的类功能、lambda
  • SICAR 标准功能块 FB3352 (MODE)工作模式功能块
  • 是否想要一个桌面哆啦A梦的宠物
  • 特征工程四-2:使用GridSearchCV 进行超参数网格搜索(Hyperparameter Tuning)的用途
  • 基于开闭原则优化数据库查询语句拼接方法
  • KenticoCMS 文件上传导致xss漏洞复现(CVE-2025-2748)
  • RN 获取视频封面,获取视频第一帧