news 2026/7/29 18:39:16

从零实现C++ vector:三指针模型、动态扩容与迭代器失效详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现C++ vector:三指针模型、动态扩容与迭代器失效详解

1. 项目概述:为什么我们要亲手“造轮子”?

在C++的世界里,std::vector大概是每个开发者最早接触、使用最频繁的STL容器,没有之一。它用起来太顺手了:动态扩容、随机访问、连续存储,感觉就像是一个会自动变长的数组。但用久了,特别是当项目规模上来,开始遇到性能瓶颈、内存异常或者面试官那意味深长的“说说vector的底层原理”时,我们才会意识到,对这个“老朋友”的了解可能还停留在表面。

我见过不少开发者,能熟练调用push_backpop_back,也知道要使用reserve来避免频繁扩容,但一旦被问到“扩容时迭代器为什么会失效?”、“emplace_backpush_back在底层实现上到底差在哪?”、“vector<bool>为什么是个特例?”,就有点含糊其辞了。这很正常,因为STL库给我们封装得太好了,好到我们几乎不用关心它内部是怎么运转的。但作为一名有追求的C++程序员,尤其是当你需要写出高性能、高稳定性的代码时,理解这些“轮子”的内部构造,不仅是为了应付面试,更是为了能在关键时刻精准地定位问题、优化性能,甚至自己动手定制更适合特定场景的容器。

这次,我们不满足于仅仅使用vector,而是要拿起“手术刀”,从零开始,模拟实现一个我们自己的MyVector。这个过程,就像拆解一台精密的发动机,你会看清每一个“活塞”(元素)是如何被安置在“气缸”(内存块)里的,“燃油喷射系统”(内存分配器)是如何工作的,以及“涡轮增压”(扩容策略)在什么时机被触发。相信我,当你亲手实现一遍之后,再回来看std::vector的源码,会有一种豁然开朗的感觉,以往那些模糊的概念会变得无比清晰。

2. 核心架构与设计思想拆解

在动手写代码之前,我们必须先把vector这个抽象概念具象化,理解它的核心设计哲学。vector的本质,是一个封装了动态数组的类模板。它的所有魔法,都源于几个最基础的指针和一套精心设计的规则。

2.1 底层内存模型:三指针定天下

几乎所有主流STL实现中,vector的底层都通过三个指针来管理其动态数组。这是我们模拟实现的基石:

  • _start (或begin): 指向动态数组(即数据块)的起始位置,也就是第一个元素所在的地方。
  • _finish (或end): 指向当前已使用的最后一个元素的下一个位置。这意味着[_start, _finish)这个左闭右开的区间,包含了所有有效元素。size()成员函数返回的值,就是_finish - _start
  • _end_of_storage (或end_of_storage): 指向整个动态数组(当前分配的内存块)的末尾的下一个位置。capacity()返回的值,就是_end_of_storage - _start

这三个指针的关系,清晰地划分了内存的三种状态:已使用、未使用但已分配、未分配。所有的成员函数操作,本质上都是在操作这三个指针以及它们所指向的内存。

注意:有些资料或简化实现可能只用两个指针(startfinish)外加一个capacity变量。用三个指针是更经典和直观的模型,它能直接通过指针运算获得大小和容量,逻辑上更清晰。

2.2 核心特性与设计权衡

理解了内存模型,我们就能看透vector那些著名特性背后的代价与权衡:

  1. 动态扩容:这是vector的灵魂,也是性能陷阱的高发区。当size() == capacity()时,再插入新元素就需要扩容。经典的扩容策略是申请一块新的、更大的内存(通常是原容量的1.5倍或2倍),将旧数据逐个拷贝或移动到新内存,然后释放旧内存。这个过程时间复杂度是O(N),并且会导致所有迭代器、指针、引用失效。

    • 为什么是1.5或2?这是一个数学上的权衡。增长因子太小(如1.1),会导致频繁扩容,总体拷贝成本高。增长因子太大,又会浪费内存。1.5(GCC)和2(MSVC)是实践中被证明在时间和空间上取得较好平衡的选择。你可以通过reserve()来干预这个过程,避免多次扩容。
  2. 连续存储:元素在内存中是紧挨着存放的。这带来了巨大的优势:极高的缓存局部性。CPU访问一个元素后,其相邻元素有很大概率已经在缓存中,这使得顺序遍历vector的速度极快。同时,这也支持了随机访问(通过operator[]at()),因为地址可以通过start + index直接计算出来,时间复杂度是O(1)。

  3. 类型无关的泛型:通过类模板实现,vector可以存储任意类型(只要该类型满足可拷贝/可移动等基本要求)。模板会在编译时实例化出特定类型的代码,没有运行时开销。

  4. 异常安全:标准库的vector实现提供了很强的异常安全保证。例如,push_back在发生异常时,会保证vector的状态不变(强异常安全)。我们的模拟实现可以简化这一点,但必须意识到生产级代码对此有严格要求。

我们的MyVector将围绕这些设计思想来构建,目标是实现一个具备基本功能、能清晰反映底层原理的简化版本。

3. 基础框架与资源管理

让我们从搭建类的骨架和最重要的资源管理开始。这是保证我们的MyVector不会内存泄漏的基石。

3.1 类模板声明与成员变量

首先,我们定义一个类模板,并声明那三个核心的指针成员。

template <typename T> class MyVector { public: // 类型别名,增加可读性并与STL风格保持一致 using iterator = T*; using const_iterator = const T*; private: iterator _start = nullptr; // 指向数组起始 iterator _finish = nullptr; // 指向最后一个有效元素的下一个位置 iterator _end_of_storage = nullptr; // 指向分配内存的末尾的下一个位置 public: // 构造函数、析构函数、成员函数等将在后续实现... };

这里我们做了一个重要的简化:将迭代器iterator直接定义为原生指针T*。对于vector来说,这是成立的,因为其迭代器就是随机访问迭代器,原生指针完全满足所有操作要求(如++,--,+ n,- n)。这让我们可以更专注于内存管理本身。

3.2 构造函数与析构函数

资源管理类的“三巨头”:构造、拷贝、析构。我们先实现最基础的。

public: // 默认构造函数 MyVector() : _start(nullptr), _finish(nullptr), _end_of_storage(nullptr) {} // 带初始大小和值的构造函数 MyVector(size_t n, const T& value = T()) { _start = new T[n]; // 申请未初始化的内存 _finish = _start + n; _end_of_storage = _finish; // 需要将内存初始化为 value for (iterator it = _start; it != _finish; ++it) { *it = value; // 这里调用的是 T 的赋值运算符 } } // 析构函数 ~MyVector() { if (_start) { // 首先,需要调用每个元素的析构函数。 // 对于简单类型(如int),这步可能无操作,但对于类类型是必须的。 // 然后,释放内存块。 delete[] _start; _start = _finish = _end_of_storage = nullptr; } }

这里有一个关键点一个坑

  • 关键点new T[n]分配内存并调用每个元素的默认构造函数。对于内置类型(如int),会进行零初始化。这对于我们后续的resize等操作行为有影响。
  • :在带参数的构造函数中,我们使用了*it = value;进行初始化。这假设了类型T支持赋值操作。更严谨的做法是使用“定位 new(placement new)”在已分配的内存上直接构造对象,但这会引入复杂性。我们的简化版本暂时用赋值,但心里要明白,对于某些不可赋值但可构造的类型,这里会出问题。STL的实现会使用std::uninitialized_fill等算法。

3.3 拷贝控制:深拷贝与移动语义

让我们的MyVector能够正确地进行拷贝和赋值,是避免浅拷贝导致双重释放(double free)错误的关键。

public: // 拷贝构造函数(深拷贝) MyVector(const MyVector<T>& v) { size_t cap = v.capacity(); size_t sz = v.size(); _start = new T[cap]; // 分配同样大小的内存 // 拷贝数据 for (size_t i = 0; i < sz; ++i) { _start[i] = v._start[i]; // 同样是赋值,假设T可拷贝赋值 } _finish = _start + sz; _end_of_storage = _start + cap; } // 拷贝赋值运算符(现代写法:copy-and-swap) MyVector<T>& operator=(MyVector<T> v) // 注意,这里参数是值传递,会调用拷贝构造 { swap(v); // 交换当前对象和临时对象v的内容 return *this; // 离开作用域时,临时对象v(现在是旧数据)被析构 } // 交换函数 void swap(MyVector<T>& v) { std::swap(_start, v._start); std::swap(_finish, v._finish); std::swap(_end_of_storage, v._end_of_storage); }

拷贝赋值运算符的实现技巧:这里使用了“copy-and-swap”惯用法。参数MyVector<T> v是传值,它会调用我们刚刚实现的拷贝构造函数,生成一个v的副本。然后我们调用swap交换当前对象和这个副本的内容。函数返回时,副本(现在持有当前对象的旧数据)被自动析构。这种方法异常安全且代码简洁。

实操心得:自己实现拷贝赋值时,最容易忘记处理“自赋值”情况(即v1 = v1;)。使用 copy-and-swap 技法天然避免了这个问题,因为传参时已经产生了一个副本。如果采用传统的“先检查自赋值,再释放旧内存,再分配新内存,再拷贝”的方式,很容易漏掉自赋值检查,导致灾难性后果。

我们暂时不实现移动构造函数和移动赋值运算符,但要知道在C++11以后的标准库vector中,它们对于提升从临时对象转移资源的效率至关重要。

4. 核心功能模拟实现

有了稳固的基础,我们现在来实现vector那些最常用的成员函数。我们将看到,它们几乎都是对三个指针的舞蹈。

4.1 容量相关操作

这些函数通常不修改元素内容,只查询或调整内存布局。

public: size_t size() const { return _finish - _start; } size_t capacity() const { return _end_of_storage - _start; } bool empty() const { return _start == _finish; } // reserve: 确保容量至少为 n void reserve(size_t n) { if (n > capacity()) { size_t old_size = size(); iterator new_start = new T[n]; // 分配新内存 // 拷贝/移动旧数据 if constexpr (std::is_nothrow_move_constructible_v<T> || !std::is_copy_constructible_v<T>) { // 如果T的移动构造函数不抛异常,或不可拷贝但可移动,则使用移动 for (size_t i = 0; i < old_size; ++i) { // 使用移动构造,在C++17后可用 std::uninitialized_move new (&new_start[i]) T(std::move(_start[i])); _start[i].~T(); // 析构原对象 } } else { // 否则使用拷贝构造 for (size_t i = 0; i < old_size; ++i) { new (&new_start[i]) T(_start[i]); // 拷贝构造 _start[i].~T(); } } // 释放旧内存 delete[] reinterpret_cast<char*>(_start); // 注意,不能用 delete[] _start,因为元素已手动析构 // 更新指针 _start = new_start; _finish = _start + old_size; _end_of_storage = _start + n; } // 如果 n <= capacity(),什么都不做 } // resize: 调整有效元素个数为 n void resize(size_t n, const T& value = T()) { if (n > capacity()) { reserve(n); // 需要扩容 } if (n > size()) { // 需要新增元素,用 value 初始化 while (_finish != _start + n) { *_finish = value; // 在已初始化的内存上赋值 ++_finish; } } else { // 需要减少元素,销毁多余的元素 // 注意:对于类对象,我们需要调用析构函数。 // 简化处理:仅调整 _finish 指针。实际上,从 _start+n 到 _finish 的元素应被析构。 // 更正确的做法是: // for (iterator it = _start + n; it != _finish; ++it) { // it->~T(); // } _finish = _start + n; } }

关于reserve的深度讨论

  1. 内存分配new T[n]分配了内存并默认构造了nT对象。这可能会带来不必要的构造开销。更高效的做法是只分配原始内存(如operator newallocator),然后在需要时构造对象。STL使用分配器(Allocator)来分离这两步。我们的简化版本为了易懂,牺牲了这部分效率。
  2. 元素迁移:代码中使用了编译时条件判断来选择使用移动还是拷贝。这是为了在可能的情况下获得更好的性能(移动通常比拷贝快)。std::is_nothrow_move_constructible_v<T>检查移动构造是否保证不抛异常,这是安全移动的前提。实际STL实现会使用std::move_if_noexcept等更精巧的机制。
  3. 手动析构与释放:在移动元素后,我们必须手动调用旧位置上元素的析构函数~T()。最后释放内存时,因为_start的类型是T*,直接delete[] _start会试图再次析构那些已经析构的对象,这是未定义行为。所以我们将其转换为char*再删除,这相当于只释放内存,不调用析构。这是一种 hack,更规范的做法是使用分配器。

4.2 元素访问

public: T& operator[](size_t pos) { // 不进行边界检查,与 std::vector 行为一致(快速但不安全) return _start[pos]; } const T& operator[](size_t pos) const { return _start[pos]; } T& front() { return *_start; } const T& front() const { return *_start; } T& back() { return *(_finish - 1); } const T& back() const { return *(_finish - 1); } T* data() { return _start; } const T* data() const { return _start; } // at 函数,进行边界检查,越界时抛出 std::out_of_range 异常 T& at(size_t pos) { if (pos >= size()) { throw std::out_of_range("MyVector::at - pos out of range"); } return _start[pos]; } const T& at(size_t pos) const { if (pos >= size()) { throw std::out_of_range("MyVector::at - pos out of range"); } return _start[pos]; }

operator[]vsat():这是一个经典的效率与安全的权衡。operator[]不检查边界,访问速度最快,但要求程序员自己保证索引有效。at()进行边界检查,无效时抛异常,更安全但稍有开销。在性能关键的循环中,如果确定索引安全,应使用operator[]

4.3 迭代器

由于我们将迭代器定义为原生指针,相关函数实现起来非常简单。

public: iterator begin() { return _start; } const_iterator begin() const { return _start; } const_iterator cbegin() const { return _start; } iterator end() { return _finish; } const_iterator end() const { return _finish; } const_iterator cend() const { return _finish; }

正是由于迭代器是指针,所以vector的迭代器支持所有随机访问迭代器的操作,如it + 5it1 - it2等。

4.4 修改器:插入与删除

这是vector最核心也最需要小心操作的部分,因为涉及元素的移动和可能的扩容。

public: // push_back: 在末尾添加元素 void push_back(const T& value) { // 检查是否需要扩容 if (_finish == _end_of_storage) { // 计算新容量,简化版:翻倍,如果为0则设为1 size_t new_capacity = capacity() == 0 ? 1 : capacity() * 2; reserve(new_capacity); } // 在 _finish 指向的位置构造新元素 *_finish = value; // 同样是赋值,简化处理 ++_finish; } // pop_back: 删除末尾元素 void pop_back() { if (!empty()) { --_finish; // 应该调用末尾元素的析构函数 // _finish->~T(); // 简化版本,仅移动指针。对于类类型,这会导致资源泄漏。 // 正确做法是调用析构。 } } // insert: 在指定位置前插入元素 (返回指向新插入元素的迭代器) iterator insert(iterator pos, const T& value) { // 检查 pos 是否在有效范围内 [begin(), end()] if (pos < _start || pos > _finish) { // 通常行为是未定义,我们可以选择断言或抛异常 return end(); } // 检查扩容 if (_finish == _end_of_storage) { // 扩容会导致所有迭代器失效,包括 pos。 // 我们需要记录 pos 相对于 _start 的偏移量。 size_t offset = pos - _start; size_t new_capacity = capacity() == 0 ? 1 : capacity() * 2; reserve(new_capacity); // 扩容后,_start 地址可能改变,需要重新计算 pos pos = _start + offset; } // 将 pos 及之后的元素向后移动一位 // 从后往前移动,避免覆盖 for (iterator it = _finish; it != pos; --it) { *it = std::move(*(it - 1)); // 使用移动赋值提高效率 } // 在 pos 位置插入新元素 *pos = value; ++_finish; return pos; } // erase: 删除指定位置的元素 (返回指向被删除元素之后位置的迭代器) iterator erase(iterator pos) { if (pos < _start || pos >= _finish) { return end(); } // 将 pos+1 及之后的元素向前移动一位 // 从前往后移动 for (iterator it = pos; it != _finish - 1; ++it) { *it = std::move(*(it + 1)); } --_finish; // 应该调用现在位于 _finish 位置(原最后一个元素)的析构函数 // _finish->~T(); return pos; // 注意,此时 pos 指向的是原来 pos+1 的元素 }

插入与删除的陷阱

  1. 迭代器失效:这是vector操作中最著名的坑。insertpush_back(当引发扩容时)会导致所有迭代器、指针、引用失效。因为内存地址变了。erase会导致被删除元素及其之后的迭代器、指针、引用失效。我们的insert实现中,在扩容前计算偏移量offset,就是为了在扩容后能重新定位pos,这是处理迭代器失效的常见手法。
  2. 元素移动inserterase都需要移动元素。移动的次数平均是 O(N) 的。这就是为什么在vector中间频繁插入/删除效率很低。如果需要这种操作,std::liststd::deque可能是更好的选择。
  3. 析构的缺失:在我们的简化版pop_backerase中,只是移动了指针,没有调用被删除元素的析构函数。对于intdouble这类平凡类型,这没问题。但如果T是一个管理了资源(如动态内存、文件句柄)的类,不调用析构函数就会导致资源泄漏。生产代码中必须调用~T()

4.5 更高效的 emplace_back

C++11 引入了emplace_back,它允许直接在容器末尾构造元素,省去了临时对象的创建和拷贝/移动。

public: template <typename... Args> void emplace_back(Args&&... args) { if (_finish == _end_of_storage) { size_t new_capacity = capacity() == 0 ? 1 : capacity() * 2; reserve(new_capacity); } // 在 _finish 指向的原始内存上,直接使用参数 args... 构造一个 T 对象 new (_finish) T(std::forward<Args>(args)...); // 定位 new + 完美转发 ++_finish; }

emplace_back的精髓

  • 可变模板参数typename... Args:可以接受任意数量、任意类型的参数。
  • 完美转发std::forward<Args>(args)...:将参数以原有的值类别(左值/右值)传递给T的构造函数。
  • 定位 newnew (_finish) T(...):在指定的内存地址(_finish)上构造对象,不分配新内存。

假设我们有一个类Person,构造函数是Person(string name, int age)。比较以下两种方式:

MyVector<Person> vec; vec.push_back(Person("Alice", 30)); // 先构造一个临时 Person 对象,再移动(或拷贝)到 vector 中。 vec.emplace_back("Bob", 25); // 直接在 vector 的内存里,用 "Bob" 和 25 调用 Person 的构造函数。

emplace_back通常更高效,因为它避免了临时对象的创建。这也是为什么在现代C++中,emplace_back被推荐优先使用。

5. 问题排查、性能调优与扩展思考

自己实现一遍之后,我们再回过头来看std::vector,就能理解很多“为什么”,也能预见到使用中可能遇到的问题。

5.1 常见问题与陷阱

  1. 迭代器失效:如前所述,这是第一大坑。一个经典的错误模式:

    std::vector<int> v = {1,2,3,4,5}; for (auto it = v.begin(); it != v.end(); ++it) { if (*it % 2 == 0) { v.erase(it); // 错误!erase 后 it 失效,后续的 ++it 行为未定义 } }

    正确写法erase会返回下一个有效迭代器。

    for (auto it = v.begin(); it != v.end(); ) { if (*it % 2 == 0) { it = v.erase(it); // 用返回值更新 it } else { ++it; } }
  2. vector<bool>的特化:标准库对vector<bool>进行了特化,每个bool值只占一个 bit 而不是一个字节,以节省空间。但这导致它不能返回真正的bool&(因为无法指向一个 bit),其迭代器也不是随机访问迭代器。因此,vector<bool>在很多方面行为异常,如果需要存储布尔值并希望其行为像普通vector,可以考虑使用std::vector<char>std::bitset

  3. 未初始化的访问:使用reserve()只分配内存,不创建元素。直接通过operator[]访问[size(), capacity())范围内的位置是未定义行为。

    MyVector<int> v; v.reserve(10); v[0] = 5; // 错误!v.size() 为 0,_start[0] 是未构造的内存。
  4. shrink_to_fit()的非强制性std::vector::shrink_to_fit()是一个请求,要求容器减少capacity()以匹配size()。但标准并不强制要求实现遵守这个请求。这是为了给库实现者留出优化空间。如果你非常确定未来不会再有大量插入,且想立刻释放多余内存,可以结合“swap 技法”:

    std::vector<int>(v).swap(v); // 用 v 的内容创建一个临时 vector,再和 v 交换。临时对象离开作用域被释放。

5.2 性能调优要点

  1. 预分配空间:如果事先知道元素的大致数量,使用reserve()一次性分配足够内存,可以避免多次扩容和数据拷贝,这是提升vector性能最有效的手段之一。
  2. 理解扩容成本:扩容的代价是 O(N) 的。在需要频繁追加元素且数量不确定的场景下,选择一个合适的增长因子(虽然我们无法修改标准库的)很重要。通常,1.5倍比2倍在内存利用率上更优(因为之前释放的内存块更容易被复用)。
  3. 选择正确的操作
    • 在末尾添加元素,用push_backemplace_back
    • 在中间或开头插入/删除,尽量避免。如果必须,考虑是否能用std::liststd::deque
    • 批量插入,使用insert的迭代器范围版本,或者先reserve再循环push_back
  4. 利用移动语义:确保你存储的类型(T)实现了移动构造函数和移动赋值运算符,并且标记为noexcept。这样在vector扩容时,会优先使用移动而非拷贝,效率更高。

5.3 扩展思考:我们的 MyVector 与 std::vector 的差距

我们的MyVector是一个高度简化的教学模型,与std::vector相比,缺少了很多工业级的考量和特性:

  • 分配器(Allocator):STL容器都支持自定义分配器,用于控制内存的分配与释放策略。我们的实现硬编码了new[]delete[]
  • 异常安全:我们的代码几乎没有考虑异常安全。STL的实现保证了基本的异常安全,甚至强异常安全。
  • 类型萃取(Type Traits):STL 会利用std::is_trivially_copyablestd::is_nothrow_move_constructible等类型萃取,在可能的情况下使用memcpymemmove等低级函数来优化平凡类型(如int,double)的拷贝/移动,效率极高。
  • 更完善的迭代器体系:提供了reverse_iterator,const_reverse_iterator等。
  • 更多的构造函数和赋值运算符:如初始化列表构造、范围构造、移动构造/赋值等。
  • 更复杂的算法std::vectorinserterase等可能会使用std::movestd::uninitialized_copy等标准算法,实现更优化。

亲手实现这个简化版本的最大价值,不在于造出一个能替代std::vector的轮子,而在于照亮了黑盒内部的构造。当你再使用std::vector时,你看到的将不再是一个简单的“动态数组”,而是一个由三个指针精密控制、在效率与功能间反复权衡、充满细节与陷阱的复杂系统。这份理解,会让你在未来的C++编程中,写出更高效、更健壮的代码。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/29 18:37:55

从理论到实践:Geotorch约束优化的数学原理与代码实现

从理论到实践&#xff1a;Geotorch约束优化的数学原理与代码实现 【免费下载链接】geotorch Constrained optimization toolkit for PyTorch 项目地址: https://gitcode.com/gh_mirrors/ge/geotorch Geotorch是一个专为PyTorch设计的约束优化工具包&#xff0c;它提供了…

作者头像 李华
网站建设 2026/7/29 18:28:40

一张图讲清楚:Planner 如何让 Agent 先想步骤再动手

一句话判断&#xff1a;Planner 的价值不是让 Agent 写一份漂亮计划&#xff0c;而是把复杂目标拆成可执行、可检查、可中断和可重试的任务序列。 这个词到底是什么 Planner&#xff0c;可以理解成 Agent 里的“任务拆解员”。 用户说一句“帮我修复这个仓库里的登录问题”&a…

作者头像 李华
网站建设 2026/7/29 18:28:27

2026年5款OPPO通话转文字对比:日常使用怎么选才能不踩雷?

针对HR用OPPO手机开展面试、OKR面谈、访谈这类场景选通话转文字工具&#xff0c;2026年我亲测了提到的5款工具&#xff0c;结合HR的核心需求来说&#xff1a;只需要基础转写选网易见外&#xff0c;企业要稳定集成服务选腾讯云&#xff0c;多语种访谈选Trint&#xff0c;需要转写…

作者头像 李华
网站建设 2026/7/29 18:24:37

如何在电脑上免费玩Switch游戏:Ryujinx模拟器终极指南

如何在电脑上免费玩Switch游戏&#xff1a;Ryujinx模拟器终极指南 【免费下载链接】Ryujinx 用 C# 编写的实验性 Nintendo Switch 模拟器 项目地址: https://gitcode.com/GitHub_Trending/ry/Ryujinx 想在电脑上体验《塞尔达传说&#xff1a;旷野之息》、《马里奥赛车8》…

作者头像 李华