【C++ 17 新功能 std::visit 】深入解析 C++17 中的 std::visit:从原理到实践

目录标题


1. 引言

1.1 C++17 新特性简介

C++17 是一个标准化的 C++ 语言版本,该版本引入了许多新特性,旨在简化编程任务,提高代码效率,并增强类型安全。从 std::optional(可选值)到 std::filesystem(文件系统操作),这些新特性都体现了现代 C++ 的设计理念:让复杂事物变得简单,而不是简单事物变得复杂。

你可能熟悉 Bjarne Stroustrup(C++ 的创造者)的名言:“C++ 旨在让你有能力做到你想做的事,但你必须更加明确地表达你的意图。” 在 C++17 中,你会发现这一点更为明显。

1.2 std::variantstd::visit 的重要性

1.2.1 为什么需要 std::variant(变体)?

在很多编程场景中,我们经常遇到需要处理多种类型的情况。传统上,这可以通过多种方式来实现,例如使用 unionvoid* 指针,甚至使用一系列的 if-else 语句和类型转换。但这些方法通常都有各种缺点,如类型不安全、容易出错或难以维护。

std::variant 为这一问题提供了一个现代、类型安全的解决方案。它允许你在一个单一的变量中存储多种不同的类型,并能在运行时安全地访问它们。

1.2.2 std::visit(访问器)的作用

当你使用 std::variant 时,一个自然而然的问题是如何处理存储在其中的不同类型的值。手动检查和处理多种可能的类型通常很繁琐,而且容易出错。这就是 std::visit 发挥作用的地方。

std::visit 提供了一种机制,让你能够方便、优雅地处理 std::variant 中存储的多种可能的类型。它基于访问者模式(Visitor Pattern),是一种运行时多态的实现。

1.2.3 从人性出发,为什么这两者如此重要

想象一下,你手头有一把瑞士军刀。这把刀有各种功能,但你却不知道如何正确、安全地使用它。这就像拥有 std::variant 但不知道如何有效地使用它。而 std::visit 就像是一个详细的使用手册,它教你如何安全、高效地使用这把瑞士军刀。

1.3 代码示例

让我们先来看一个简单的例子,这将帮助你更好地理解 std::variantstd::visit 的基本用法。

#include <iostream>
#include <variant>
#include <string>

int main() {
    
    
    std::variant<int, double, std::string> myVariant = "Hello, world!";
    std::visit([](auto&& arg) {
    
    
        std::cout << "The value is: " << arg << std::endl;
    }, myVariant);

    return 0;
}

在这个例子中,myVariant 可以存储 intdoublestd::string 类型的值。我们使用 std::visit 来访问存储在 myVariant 中的值,并输出它。

这里,std::visit 接受了一个 lambda 表达式作为参数,这个 lambda 表达式可以接受任何类型的参数(由 auto&& 指定),然后输出这个参数。

2. 什么是 std::variant

2.1 基础介绍和用法

在 C++17 之前,如果你想在一个变量中存储多种可能的类型,通常会使用 unionvoid* 指针。然而,这些方法都有明显的缺点。使用 union 时,类型信息会丢失,使得代码容易出错。而 void* 指针则需要手动进行类型转换和内存管理,容易导致内存泄漏或未定义的行为。

std::variant(变体)作为一种更安全、更方便的多类型容器,应运而生。你可以把它看作是一个可以存储多种类型中的任一种的类型安全的容器。下面是一个基本用法的例子:

#include <variant>
#include <iostream>

int main() {
    
    
    std::variant<int, double, std::string> v1 = 42;
    std::variant<int, double, std::string> v2 = 3.14;
    std::variant<int, double, std::string> v3 = "hello";
    
    // 访问存储的值(不安全,需确保类型正确)
    std::cout << std::get<int>(v1) << std::endl;
    
    // 安全地访问存储的值
    if (auto pval = std::get_if<int>(&v1)) {
    
    
        std::cout << *pval << std::endl;
    }
    
    return 0;
}

在这个例子中,我们定义了三个 std::variant 变量,分别存储了 intdoublestd::string 类型的值。

2.2 与 unionvoid* 的比较

union void* std::variant
类型安全
自动内存管理
运行时类型信息
性能 ⚖️ ⚖️ ⚖️
代码可读性

相比之下,std::variant 提供了一种类型安全、自动管理内存和运行时获取类型信息的方式,显著提高了代码质量和可维护性。这就像是你拥有了一个瑞士军刀,但每把刀片都刻有明确的标签,你总是知道该使用哪一把。

2.3 std::variant 的局限性

尽管 std::variant 非常强大,但它并不是万能的。它的一个主要限制是,虽然它可以存储多种类型,但在任何给定时间点,它只能存储其中一种。这就像是一个变色的蜥蜴,虽然它可以变成多种颜色,但一次只能是一种。

这就引出了一个问题:当你拿到一个 std::variant 对象时,如何知道它当前存储了哪种类型的值?这是下一章节 std::visit 登场的时候。

当然,你可以使用 std::holds_alternativestd::get_if 进行手动检查,但这样做的代码通常既繁琐又容易出错。正如 Herbert Schildt 在其著作《C++ 完全手册》中所说,简洁性往往是高效代码的关键。

3. std::variant 的局限性

在深入探讨 std::visit 之前,了解 std::variant 的局限性是非常重要的。这并不是因为 std::variant 是一个不好的工具,恰恰相反,它是一个极其有用的构造。然而,正如 Robert C. Martin 在他的著作 “Clean Code” 中提到的,每一个工具都有其适用场景,以及不适用的场景。所以,让我们先了解一下 std::variant 在什么情况下可能让你陷入困境。

3.1 需要运行时类型检查

3.1.1 静态类型与动态类型

在 C++ 这样的静态类型(Static Typing)语言中,类型信息在编译时就已经确定。然而,当你使用 std::variant(变体)时,你实际上是在模拟动态类型(Dynamic Typing)的行为。这意味着你需要在运行时去判断它究竟存储了哪种类型的对象。

这和编程中的“迪米特法则”(Law of Demeter)有点矛盾。这一法则告诉我们,一个对象应该对其他对象有最少的了解。当你不得不去检查一个 std::variant 所存储的具体类型时,你实际上是在违反这一原则。

3.1.2 手动类型检查的风险

C++ 提供了 std::holds_alternativestd::get 等函数,用于检查和提取 std::variant 中存储的类型。这种做法虽然有效,但是很容易出错。

std::variant<int, double, std::string> v = 42;
if (std::holds_alternative<int>(v)) {
    
    
    int value = std::get<int>(v);  // 安全
} else if (std::holds_alternative<double>(v)) {
    
    
    double value = std::get<double>(v);  // 运行时错误!
}

如果你不小心用了错误的类型去访问 std::variant,会抛出一个 std::bad_variant_access 异常。这种情况下,你不得不依赖运行时错误检查,这无疑增加了代码的复杂性。

3.2 如何手动进行类型检查

手动类型检查通常涉及使用 std::holds_alternativestd::get,或者更糟糕的是,使用 std::get_if。这些方法都有其适用场合,但也都有明显的缺点。

方法 优点 缺点
std::holds_alternative 简单、直观 不能提取值
std::get 可以直接提取值 类型错误会抛出异常
std::get_if 可以检查和提取值,不会抛出异常 返回指针,需要额外的空指针检查

考虑到这些局限性,一个更加统一和安全的解决方案就显得非常有用。这也正是 std::visit 的用武之地。

当你面对一个复杂的问题时,心里可能会产生一种想逃避的冲动。这时,最好的方法是将问题拆分成更小、更易管理的部分。这也是 std::visit 的核心思想:它允许你将复杂的类型检查和数据提取问题分解为更简单、更易于管理的部分。

4. std::visit 简介

4.1 基础概念和作用

std::visit(标准访问)是 C++17 引入的一种强大机制,用于安全地访问 std::variant(标准变体)中存储的数据。如果你曾经被 C++ 的多态性或类型系统弄得头疼,那么 std::visit 将会是你的救星。

在日常编程中,我们经常遇到需要存储多种类型数据的场景。传统的做法是使用 union 或者 void*,然而这些方法都有各自的缺点。union 不安全,因为它不跟踪当前存储的数据类型,而 void* 更糟糕,因为它完全丧失了类型信息。

在这种背景下,std::variant 出现了,它可以安全地存储多种类型。但是,存储是一回事,安全地访问和操作这些数据又是另一回事。这就是 std::visit 的用武之地。

编程名著引用: 如 Bjarne Stroustrup 在《The C++ Programming Language》中所言,“类型安全是高质量代码的基石”。

4.2 使用示例

考虑以下代码示例:

#include <iostream>
#include <variant>
#include <string>

int main() {
    
    
    std::variant<int, double, std::string> v = "hello";

    std::visit([](auto&& arg) {
    
    
        using T = std::decay_t<decltype(arg)>;
        if constexpr (std::is_same_v<T, int>) {
    
    
            std::cout << "int: " << arg << std::endl;
        } else if constexpr (std::is_same_v<T, double>) {
    
    
            std::cout << "double: " << arg << std::endl;
        } else {
    
    
            std::cout << "string: " << arg << std::endl;
        }
    }, v);
}

在这个示例中,我们定义了一个 std::variant,它可以存储 intdoublestd::string 类型的数据。然后,我们用 std::visit 来访问它,并基于存储的数据类型进行相应的操作。

4.3 std::visit 的工作原理

4.3.1 类型擦除和 vtable

std::visit 的内部通常会有一个 vtable(虚函数表),用于在运行时查找应该调用的函数地址。这是一种“类型擦除”的技术,即 std::variant 会在内部保存元数据,以跟踪其当前存储的数据类型。

4.3.2 编译器优化

现代编译器通常会对 std::visit 进行优化,使其运行时性能几乎与手动类型检查和转换一样快。这意味着,使用 std::visit 不仅更安全,而且几乎不会带来额外的性能开销。

4.4 如何优雅地使用 std::visit

编程不仅仅是一门技术活,更是一门艺术。优雅的代码不仅易于维护,也更容易被人理解。

4.4.1 使用泛型 lambda 表达式

泛型 lambda 表达式允许我们以一种类型无关的方式处理数据,这大大增加了代码的复用性。

4.4.2 使用 if constexpr 和类型萃取

通过结合使用 if constexpr 和类型萃取,我们不仅可以优雅地处理多种类型,还可以在编译时捕获类型错误,从而增加代码的健壮性。

心理学名言引用: 如 Carl Jung 所言,“知觉和直觉是不同的”。在编程中,直觉可能会让你觉得某种做法是对的,但类型检查和编译时错误是知觉的产物,它们能让你确信你的代码是正确的。

方法 优点 缺点
手动类型检查 (if-else) 简单,易于理解 不安全,容易出错
std::visit 类型安全,易于维护,可读性高 需要 C++17 支持

在编程中,我们常常需要在速度和安全性之间做出选择。std::visit 提供了一种方式,让我们不必牺牲太多速度就能获得更高的安全性和可维护性。

5. std::visit 的工作原理

5.1 类型擦除(Type Erasure)与 vtable

当你遇到一个需要同时处理多种数据类型的问题时,如何优雅地解决它?std::variant 提供了一个答案,但它仅仅是问题的一半。为了完整地解决这个问题,你需要一种在运行时决定如何处理这些不同类型的方法。这就是 std::visit 发挥作用的地方。

在许多情况下,std::visit 的工作原理依赖于编程语言或编译器的内部机制,这些机制通常对程序员透明。其中一种可能的实现方式是使用 “vtable”(Virtual Table,虚函数表)。

简而言之,vtable 是一种在运行时查找函数地址的机制。每当你创建一个 std::variant 实例时,它都会有一个与之关联的 vtable,用于存储该 std::variant 可以持有的每种类型的相关信息。

5.2 编译器优化

我们都知道,时间就是金钱。这也适用于编程世界。编译器在 std::visit 的实现中采用了一些优化技术,以减少运行时开销。这些优化通常包括内联(Inlining)、死代码消除(Dead Code Elimination)等。

5.3 如何优雅地使用 std::visit

5.3.1 使用泛型 lambda 表达式

C++11 引入了 lambda 表达式,这是一种便捷的匿名函数。C++14 进一步引入了泛型 lambda,允许你用一个单一的 lambda 表达式来处理多种类型。

std::visit([](auto&& arg) {
    
    
    // 处理 arg...
}, my_variant);

这里的 auto&& 是一个通用引用(Universal Reference),它可以匹配任何类型。这使得你可以在一个单一、连贯的代码块中处理多种可能的类型。

5.3.2 使用 if constexpr 和类型萃取(Type Traits)

你可能会想,为什么不直接使用 if-else 语句来检查类型呢?毕竟,这是一种更直接、更易于理解的方法。然而,使用 if constexpr 和类型萃取工具(如 std::is_same)可以使代码更加简洁和类型安全。

std::visit([](auto&& arg) {
    
    
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, int>) {
    
    
        // 处理 int 类型...
    } else if constexpr (std::is_same_v<T, double>) {
    
    
        // 处理 double 类型...
    } // ...以此类推
}, my_variant);

这种方式也符合 “Don’t Repeat Yourself”(DRY,不要重复自己)的原则,这是 Robert C. Martin 在他的著作 “Clean Code” 中提到的一个重要概念。

5.4 方法对比表

方法 优点 缺点
手动类型检查 (std::holds_alternativestd::get) 直观、易于理解 代码冗长、容易出错
std::visit + 泛型 lambda 代码简洁、类型安全、更可维护 学习曲线稍微陡峭一些

在人们决策时,简单和熟悉通常会胜出。但在编程中,选择稍微复杂但更强大和灵活的方法通常会带来长远的好处。这也是为什么 std::visit 和泛型 lambda 表达式在现代 C++ 编程中越来越受欢迎的原因。

6. 如何优雅地使用 std::visit

6.1 使用泛型 lambda 表达式

std::visit 允许你传入一个可调用对象(callable object),通常是一个 lambda 表达式。现代 C++ 提供了一种特殊的 lambda 表达式,称为泛型 lambda 表达式(generic lambda)。

6.1.1 什么是泛型 lambda?

泛型 lambda 是一个使用 auto 关键字作为参数类型的 lambda 表达式。这意味着 lambda 可以接受任何类型的参数,并在函数体内进行处理。

auto generic_lambda = [](auto x) {
    
    
    // do something with x
};

这种灵活性在处理 std::variant 时尤为有用,因为你可能需要根据多种可能的类型来编写逻辑。

6.1.2 为什么使用泛型 lambda?

泛型 lambda 为你的代码提供了极大的灵活性,同时还保持了类型安全性。这就像是一把双刃剑:一方面,你不需要编写重复的代码来处理不同的类型;另一方面,你还需要确保代码能够适用于所有可能的类型。

Bjarne Stroustrup 曾说:“C++ 的优点是你可以几乎做任何事情,缺点也是你可以几乎做任何事情。” 泛型 lambda 正是这一观点的体现。

6.2 使用 if constexpr 和类型萃取

编程就像是一场高级的拼图游戏。你需要一种机制来判断哪块拼图适用于当前的情况。在 std::visit 的上下文中,这通常是通过 if constexpr 和类型萃取(type traits)来完成的。

6.2.1 if constexpr 的威力

if constexpr 是 C++17 引入的一种编译时 if 语句,它允许在编译时进行条件判断。这意味着编译器会根据条件来优化生成的代码,这通常会带来更高的性能。

使用 if constexpr,你可以在一个统一的代码块中处理多种类型,而无需使用多个繁琐的 if-else 语句。这不仅让代码看起来更简洁,而且更易于维护。

6.2.2 类型萃取:认识你的类型

类型萃取(Type Traits)是 C++11 引入的一组模板,用于在编译时获取类型的属性。例如,std::is_same_v<T1, T2> 可以告诉你 T1T2 是否是同一种类型。

通过结合 if constexpr 和类型萃取,你可以写出高度灵活且类型安全的代码。这也是 std::visit 能发挥最大威力的地方。

6.3 综合应用:泛型 lambda 与类型判断

现在,让我们把这些元素融合到一起,看看如何优雅地使用 std::visit

std::variant<int, double, std::string> v = "hello";

std::visit([](auto&& arg) {
    
    
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, int>) {
    
    
        std::cout << "int: " << arg << std::endl;
    } else if constexpr (std::is_same_v<T, double>) {
    
    
        std::cout << "double: " << arg << std::endl;
    } else {
    
    
        static_assert(std::is_same_v<T, std::string>);
        std::cout << "string: " << arg << std::endl;
    }
}, v);

这里,我们使用了泛型 lambda 来接受任何类型的 arg,然后用 if constexpr 和类型萃取来确定 arg 的实际类型,并据此执行相应的操作。

这种方法的美妙之处在于,所有这些操作都在编译时完成,这意味着你得到了灵活性,同时几乎没有运行时的性能损失。

编程最终是一种人类活动。当你的工具(在这里是 std::visit 和相关的 C++ 特性)能帮助你更直观、更自然地表达你的意图时,你就赢了。这也是为什么了解这些工具并掌握它们如何工作是如此重要的原因。

7. 使用 std::visit 的优缺点

7.1 优点

7.1.1 代码简洁

使用 std::visit(访问器模式)可以让你的代码变得更加简洁和组织良好。这正是Bruce Eckel在《Thinking in C++》中所强调的,即“代码的可读性和维护性应当是编程中的首要任务”。

考虑一个没有使用 std::visit 的例子,你可能会这样写:

if (std::holds_alternative<int>(v)) {
    
    
    // 处理 int 类型
} else if (std::holds_alternative<double>(v)) {
    
    
    // 处理 double 类型
} else if (std::holds_alternative<std::string>(v)) {
    
    
    // 处理 std::string 类型
}

而使用 std::visit,这些 if-else 语句可以被优雅地替换为一个泛型 lambda 表达式:

std::visit([](auto&& arg) {
    
    
    // 统一处理逻辑
}, v);

这种简洁性对于代码的组织和可读性有着明显的优势。简单来说,简洁的代码更容易被理解和维护。

7.1.2 类型安全

std::visit 还具有类型安全(Type Safety)的优点。这意味着编译器将在编译阶段检查类型错误,减少了运行时错误的风险。这与 C++ 的核心原则一致,即“让错误尽早地暴露出来”。

7.1.3 扩展性

std::visit 的另一个优点是扩展性(Extensibility)。如果 std::variant 添加了新的类型,你只需要更新 std::visit 的访问器函数,而无需改动其他代码。

7.2 缺点

7.2.1 性能影响

尽管 std::visit 提供了许多优势,但它并非没有代价。其中之一就是潜在的性能影响。由于 std::visit 需要进行运行时类型检查,这可能会引入一定的开销。

然而,现代编译器通常会进行优化,使这种开销最小化。实际上,许多情况下,使用 std::visit 造成的性能损失是可以接受的。

7.2.2 模板代码膨胀

std::visit 是模板函数,这意味着每一种类型组合都可能生成新的实例代码,导致所谓的“模板代码膨胀”(Template Bloat)。

方法 代码简洁性 类型安全性 扩展性 性能影响 代码膨胀
手动类型检查 (if-else)
std::visit 可变

7.3 深入底层:std::visit 的实现细节

为了理解这些优缺点,让我们更深入地了解 std::visit 的内部工作原理。它通常是通过虚函数表(vtable)来实现运行时多态性的。这种底层机制虽然强大,但也是性能开销和模板代码膨胀的根源。

关于性能开销,你可能会记得Donald Knuth的名言:“过早优化是万恶之源”。在大多数情况下,std::visit 的性能足够快,不会成为性能瓶颈。

7.4 人与代码:为什么选择 std::visit

人们总是倾向于使用简单、直观的解决方案,这也是为什么 std::visit 受到欢迎的原因之一。它不仅简化了代码,还减少了错误的可能性,让程序员可以更加专注于解决实际问题,而不是纠结于类型检查和转换。

当然,选择使用 std::visit 还取决于具体的项目需求和团队习惯。但一般来说,如果你在寻找一种类型安全、易于维护和扩展的方法来处理 std::variant,那么 std::visit 很可能是最佳选择。

这章节内容希望可以帮助你更全面地理解 std::visit 的优缺点,以及如何在实际项目中权衡这些因素。选择合适的工具总是关键,但更重要的是,合适的工具能让你更专注于解决问题,而不是工具本身。

8. 实战:应用案例

在这一章节,我们将从实际应用的角度出发,探讨如何有效地利用 std::visit(标准访问器)来简化和优化代码。你可能听过这句名言:“实践出真知”。在编程世界中,这同样适用。理解一个概念的最好方式就是将其应用于实际问题。

8.1 用于配置管理

假设你正在开发一个大型软件项目,其中包含多种类型的配置选项,例如整数、浮点数、字符串等。你可能会将这些配置选项存储在一个 std::map<std::string, std::variant<int, double, std::string>> 中。

8.1.1 动态解析配置

在不使用 std::visit 的情况下,你可能需要使用一系列 if-else 语句和 std::holds_alternative(类型检查)来手动解析这些配置。

std::map<std::string, std::variant<int, double, std::string>> config;
// ... 填充配置

for (const auto& [key, value] : config) {
    
    
    if (std::holds_alternative<int>(value)) {
    
    
        // 处理 int 类型
    } else if (std::holds_alternative<double>(value)) {
    
    
        // 处理 double 类型
    } else {
    
    
        // 处理 string 类型
    }
}

这样做虽然可行,但不够优雅。每次添加或删除类型时,你都需要更新这个大型 if-else 语句。

8.1.2 利用 std::visit 简化代码

通过使用 std::visit,你可以将这个复杂的逻辑简化为一个简洁、可维护的代码块。

std::map<std::string, std::variant<int, double, std::string>> config;
// ... 填充配置

for (const auto& [key, value] : config) {
    
    
    std::visit([](auto&& arg) {
    
    
        using T = std::decay_t<decltype(arg)>;
        // 这里进行类型相关的操作
    }, value);
}

这样,每当你需要添加或删除一个类型时,只需更新 lambda 函数体内的内容即可。

8.2 在游戏开发中的应用

假设你正在开发一个角色扮演游戏(RPG),其中的物品(如武器、药水、装备等)可以用一个 std::variant 类型来表示。

8.2.1 角色与物品交互

在游戏中,角色与这些物品的交互方式各不相同。例如,拾取一个武器会增加攻击力,而使用一个药水则会恢复生命值。在不使用 std::visit 的情况下,你可能需要写出大量的代码来处理这些交互。

8.2.2 std::visit 的高效应用

使用 std::visit,你可以将所有的交互逻辑集中在一个地方,使得代码更容易维护。

std::visit([](auto&& item) {
    
    
    using T = std::decay_t<decltype(item)>;
    // 根据 T 的类型进行相应的角色与物品的交互
}, pickedItem);

你可能注意到了,使用 std::visit 和泛型 lambda 表达式(Generic Lambda Expressions)可以极大地简化代码,同时还能保持高度的灵活性和可维护性。

方法 灵活性 可维护性 代码复杂性
手动类型检查
使用 std::visit

8.3 状态机模型

在嵌入式系统、网络协议或复杂的用户界面中,状态机(State Machines)是一种常见的设计模式。这些状态机可能会有多种状态和转换逻辑。

8.3.1 繁琐的状态管理

在传统的设计中,状态通常由枚举(Enums)或整数常量表示,而状态转换则通过一系列复杂的 if-elseswitch-case 语句来管理。

8.3.2 std::visit 的优雅应用

通过使用 std::variant 来表示不同的状态,以及使用 std::visit 来处理状态转换,你可以将整个状态机模型简化为一个结构化、易于维护的系统。

std::variant<IdleState, RunningState, ErrorState> currentState;
// ... 更新状态

std::visit([](auto&& state) {
    
    
    using T = std::decay_t<decltype(state)>;
    // 根据 T 的类型进行相应的状态转换
}, currentState);

通过这种方式,添加或删除状态变得异常简单,只需修改一处代码即可。

结语

在我们的编程学习之旅中,理解是我们迈向更高层次的重要一步。然而,掌握新技能、新理念,始终需要时间和坚持。从心理学的角度看,学习往往伴随着不断的试错和调整,这就像是我们的大脑在逐渐优化其解决问题的“算法”。

这就是为什么当我们遇到错误,我们应该将其视为学习和进步的机会,而不仅仅是困扰。通过理解和解决这些问题,我们不仅可以修复当前的代码,更可以提升我们的编程能力,防止在未来的项目中犯相同的错误。

我鼓励大家积极参与进来,不断提升自己的编程技术。无论你是初学者还是有经验的开发者,我希望我的博客能对你的学习之路有所帮助。如果你觉得这篇文章有用,不妨点击收藏,或者留下你的评论分享你的见解和经验,也欢迎你对我博客的内容提出建议和问题。每一次的点赞、评论、分享和关注都是对我的最大支持,也是对我持续分享和创作的动力。


阅读我的CSDN主页,解锁更多精彩内容:泡沫的CSDN主页
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_21438461/article/details/132659408