如前所述,不同容器,比如向量和链表,其内部数据结构差异很大。不同的内部数据结构导致了不同的容器特性:向量、模板数组、双端队列可以通过下标随机访问元素,而链表、单向链表则只能顺序访问。不同类型容器的接口差异,为代码复用(code reuse) 带来困难。迭代器(iterator) 的设计目的之一,就是消除不同容器间的访问接口差异,从而使得泛型程序设计(generic programming) 成为可能。广义地,迭代器属于设计模式(design patterns) 的范畴。
19.5.1获取迭代器本文引用自作者编写的下述图书; 本文允许以个人学习、教学等目的引用、讲授或转载,但需要注明原作者"海洋饼干叔
叔";本文不允许以纸质及电子出版为目的进行抄摘或改编。
1.《Python编程基础及应用》,陈波,刘慧君,高等教育出版社。免费授课视频 Python编程基础及应用
2.《Python编程基础及应用实验教程》, 陈波,熊心志,张全和,刘慧君,赵恒军,高等教育出版社Python编程基础及应用实验教程
3. 《简明C及C++语言教程》,陈波,待出版书稿。免费授课视频
大多数容器,都有begin( )和end( )两个成员函数,其中begin( )函数返回指向首元素的迭代器。与读者的预期不同,end( )函数所返回的迭代器并非指向末尾元素(容器的最后一个元素),而是指向末尾元素的后面一个“元素”,它被称为尾后迭代器(off-the-end iterator)。当然,末尾元素的后面不会再有其它元素,end( )函数所返回的迭代器用于表示:所有元素都已遍历完成,程序已到达容器尾部的“出口”。我们结合下述程序及图19-4来解释。
1 //Project - StringIterator
2 #include <iostream>
3 using namespace std;
4
5 int main() {
6 string s = "Run, Forrest";
7
8 auto it = s.begin();
9 while (it!=s.end()){
10 *it = toupper(*it);
11 it++;
12 }
13
14 cout << s << endl;
15 return 0;
16 }
上述程序的执行结果为:
1 RUN, FORREST
可以将string类型的对象视为类似于向量的容器,其元素类型为字符。图19-4展示了s对象内部的元素排列状况。
🚩第8行:通过begin()成员函数获取s的首元素迭代器并赋值给变量it。如图19-4所示,此时的it应指向s的首元素’R’。根据类型推断,本例中对象it的类型为string::iterator。
🎯要点 | 迭代器对象可以视为指向容器内元素的智能指针,借助于其重载的 *** 作符函数,可以使用迭代器遍历容器,并插入、删除元素。 |
---|
🚩第9 ~ 12行:借助于循环及迭代器的移动,将s字符串的所有字符改为大写。
- s.end( )返回尾后迭代器,它指向尾元素的后面一个“元素”,如图19-4所示。
- !=是迭代器对象的一个重载 *** 作符函数,两个迭代器如果不相等,就意味着它们指向不同的容器元素。本例中,it迭代器会在循环过程中逐渐后移,it与尾后迭代器相等即意味着容器元素已遍历完毕。
- 间接 *** 作符*也被迭代器对象重载了,*it返回it所指向的元素的引用。
- toupper( )函数将字符转换成大写形式,该函数位于std名字空间之下。
- it++对应it.operator++(int),其执行使得it迭代器后移一个元素。
从执行结果可见,上述循环成功地达成了目标,s内的小写字母全部变成了大写字母。
📌建议 | 上述while循环条件中的it!=s.end()也可以写成it < s.end()。对于两个迭代器a和b而言,如果a < b,说明a所指向的元素在b所指向的元素的前面。但是,作者更建议使用it!=s.end(),这是因为部分容器的迭代器不支持<操作符,使用!=使得上述程序兼容性更佳。 |
---|
如前所述,迭代器的用途之一是消除不同容器的接口差异。借助于迭代器,遍历链表的方法与遍历向量或者字符串基本相同,见下述示例。
1 //Project - ListIterator
2 #include <iostream>
3 #include <list>
4 using namespace std;
5
6 int main() {
7 list<double> a {1,10,100,1000};
8 auto it = a.rbegin();
9 while (it!=a.rend()){
10 *it = (*it)*1.2;
11 cout << *it << ",";
12 it++;
13 }
14 return 0;
15 }
上述程序的执行结果为:
1 1200,120,12,1.2,
在上述程序中,我们使用了反向迭代器(reverse iterator),链表容器的遍历顺序为由后往前,即尾元素1000最先被迭代。
🚩第8行:a.rbegin( )返回的迭代器是个反向迭代器,本例中,它指向尾元素1000。其中,迭代器it的类型被推断为list::reverse_iterator。
🚩第9 ~ 13行:借助于循环及迭代器的移动,将a数组内的元素全部乘以1.2,并输出其值。
- s.rend( )返回反向首前迭代器,它指向首元素的前面一个“元素”,如图19-5所示。
- !=是迭代器对象的一个重载 *** 作符函数,两个迭代器如果不相等,就意味着它们指向不同的容器元素。本例中,it迭代器会在循环过程中逐渐前移,it与首前迭代器相等即意味着容器元素已遍历完毕。
- 间接 *** 作符*也被迭代器对象重载了,*it返回it所指向的元素的引用。
- it++对应it.operator++(int),由于it是反向迭代器,所以it++事实上导致it移至前一个元素。相应地,it–则意味着,it移至后一个元素。请读者注意图19-5中it++及it–所对应的箭头方向。
从执行结果可见,上述循环成功地达成了目标,a内的全部元素由后向前被遍历并修改。表19-2列出了标准的迭代器获取函数的清单。
📍 注意 | 当容器c为空时,c.begin( )与c.end( )相等,它们都返回尾后迭代器。同理,当容器c为空时,c.rbegin( )也等于c.rend( )。 |
---|
迭代器本质是对象,但使用方法类似于指针。表19-3总结了标准的迭代器 *** 作符。
📍 注 意 | 表19-3所述的“前”、“后”与迭代器的方向有关,当迭代器为反向迭代器时,所谓的“后”,事实上是靠近首元素的方向。 |
---|
💥 警告 | 使用迭代器时,程序员需要小心避免“不合逻辑”的访问行为。比如尾后迭代器事实上不指向任何元素,对其使用间接 *** 作符*会导致程序异常;同样地,对于个迭代器加上整数n,程序员也应小心确保其结果迭代器要么指向一个容器元素,要么是“尾后”或“首前”迭代器。 |
---|
把同一个容器的两个迭代器配合使用,可以表示容器元素的一个连续子集。在数学上,迭代器it1和it2所表达的元素范围可以表示成 [it1, it2) ,这是一个左闭右开的区间,它表示子集中的元素从it1所指向的元素(包含,左闭) 开始,到it2所指向的元素结束,且不包含(右开)it2所指向的元素。对于图19-6所示的容器c而言:
- [it1, it2) 表示从A到L的全部元素,请注意it2是尾后迭代器,它指向L的后面一个“元素”。
- [it3, it4) 表示从D到H的元素,即D、E、F、G、H共5个元素。请注意,it4所指向的元素I并未包含。
- [it5, it6) 则表示从L到A的全部元素,请注意it5和it6都是反向迭代器,且it6指向“首前元素”。
当使用两个迭代器,如it1和it2表示元素范围时,必须保证it1在迭代方向上较it2靠前,即it2可以通过递增it1得到,否则,结果子集合为空。对于图19-6 ,[it2, it1)的结果为一个空集合。此外,两个相等的迭代器所表示的元素子集也为空。
📍注意 | [it1, it2) 只是一种数学表达,C++并不支持直接在代码中使用这种格式来获取或表达容器的元素子集。结合两个迭代器来表达容器的元素子集的方法,请见19.6节。 |
---|
我们使用迭代器重写了第5章中讨论过的折半查找算法,来帮助读者理解迭代器的算术运算。其核心代码如下:
1 //Project - BinarySearch
2 ...
3 template <typename T, typename V>
4 T binarySearch(T begin, T end, const V v){
5 auto endOriginal = end;
6 while (begin != end) {
7 auto mid = begin + (end-begin)/2;
8 if (*mid==v)
9 return mid;
10 else if (v < *mid)
11 end = mid;
12 else
13 begin = mid+1;
14 }
15 return endOriginal;
16 }
17 ...
binarySearch()是一个模板函数,其接口描述如下。
- 输入:迭代器[begin, end)代表了搜索的元素范围,如上一小节所述,end所指向的元素不在搜索范围内;v为搜索值。注意,无论是迭代器类型还是搜索值类型都为模板参数,这意味着该函数具备泛型编程的特征,它不对输入的容器/迭代器类型、元素类型作出限定。理论上该折半查找函数适用于向量、模板数组、双端队列等不同容器。
- 输出:如果在指定的元素范围内找到了搜索值,返回指向该元素的迭代器;如未找到,返回end迭代器。
🚩第5行:将end迭代器存入endOriginal备用,未找到搜索值时,返回endOriginal,即原始的end迭代器。
🚩第6行:在整个折半查找的过程中,[begin, end)代表了当前搜索的元素范围。只要begin不等于end,即意味着在该范围内至少存在一个元素,搜索应继续进行,否则应结束循环。
🚩第7行:通过迭代器算术求指向当前搜索范围[begin, end)的中位元素的迭代器。其中,end - begin返回待搜索的元素个数,将该值除以2,再加上begin,即得指向中位元素的迭代器。
🚩第8 ~ 9行:将中位元素与搜索值v进行比较,如相同,说明找到搜索值,直接返回中位元素的迭代器mid。
🚩第10 ~ 11行:如果搜索值小于中位元素,说明搜索值位于中位元素的左边,将中位元素迭代器赋值给end。赋值之后的[begin, end)相较于之前的[begin,end),其范围大概缩小了一半,且不包含之前的中位元素。
🚩第12 ~ 13行:如果上述两种情况都不成立,说明搜索值大于中位元素,其应位于中位元素的右方,将mid + 1赋值给begin。赋值之后的[begin, end)相较于之前的[begin, end),其范围大概缩小了一半。
🚩第15行:在前述循环过程中,如果一直没有找到搜索值,每经过一轮循环,[begin, end)的范围就缩小一半,最终会导致begin与end相等,满足循环中止条件,循环结束并返回endOriginal。
下述代码则展示了使用binarySearch()函数进行折半查找的过程:
1 //Project - BinarySearch
2 #include <iostream>
3 #include <vector>
4 using namespace std;
5 ...
6 int main() {
7 vector<int> a {1,3,5,7,9,11};
8 auto r = binarySearch(a.cbegin(),a.cend(), 7);
9 if (r==a.cend())
10 cout << "Not found." << endl;
11 else
12 cout << "Found: " << *r << endl;
13
14 return 0;
15 }
上述程序的执行结果为:
1 Found: 7
🚩第5行:… 表示被省略的binarySearch()函数的定义。
🚩第8行:a.cbegin(),a.cend()返回向量a的首元素只读迭代器以及只读尾后迭代器,这说明折半查找的搜索范围包括a的全部元素。binarySearch()模板参数T的类型被确定为vector::const_iterator。
🚩第9行:如果返回迭代器等于a.cend(),说明在向量a内未找到搜索值。
🎯要点 | 第5章中所描述的折半查找代码,仅适用于指定类型的数组,不具备通用性。借助于迭代器及模板参数,本节中的binarySearch()理论上可以支持不同的容器类型及元素类型,具备泛型编程的特征。事实上,本节的binarySearch()函数不支持对链表的折半查找,因为链表的迭代器不支持减法 *** 作符。 |
---|
下述程序展示了通过迭代器对容器元素进行修改的一般方法。
1 //Project - ModifyElement
2 #include <iostream>
3 #include <list>
4 #include <vector>
5 using namespace std;
6
7 template <typename T>
8 void output(T begin, T end, const string& sTitle){
9 cout << "----------" << sTitle << "------------\n";
10 while (begin!=end)
11 cout << *begin++ << ",";
12 cout << endl;
13 }
14
15 int main() {
16 vector<int> a;
17 a.assign({0,1,2,3,4,5,6,7,8,9});
18 *(a.begin()+3) = 99;
19 output(a.cbegin(),a.cend(),"vector a" );
20
21 list<int> b;
22 b.assign(a.crbegin()+2,a.crend()-3);
23 output(b.cbegin(),b.cend(), "list b" );
24
25 return 0;
26 }
上述程序的执行结果为:
1 ----------vector<int> a------------
2 0,1,2,99,4,5,6,7,8,9,
3 ----------list<int> b------------
4 7,6,5,4,99,
🚩第7 ~ 13行:定义了一个“通用”的容器元素输出函数output(),因为模板参数及迭代器的使用,该函数理论上可以输出任意序列容器(sequential container)的元素。
🚩第17行:用初始化列表中的元素替换向量a中的全部元素。
🚩第18行:将向量a的第3个元素(从0开始计数)修改为99。这里使用到了迭代器算术。
🚩第19行:使用output()函数输出向量a的全部元素。由于输出过程并不期望改变容器元素的值,所以我们使用了常量型迭代器。其输出对应执行结果的第1 ~ 2行。
🚩第22行:用向量a中的部分元素替换链表b中的全部元素。源自向量a的元素集由两个迭代器来表示,形式上可表示为[a.crbegin()+2, a.crend()-3)。
- a.crbegin() + 2: 反向只读迭代器,指向尾元素之“后”的第2个元素,即向量a倒数第3个元素,其值为7。
- a.crend() - 3: 反向只读迭代器,指向“首前元素”之“前”的第3个元素,即向量a正数第3个元素,其值为2。
- 由于两个迭代器都是反向迭代器,且按照左闭右开的规则,a.crend()-3所指向的元素(即2)不包含在范围内,所以,[a.crbegin()+2, a.crend()-3)所表示的元素序列为7、6、5、4、99。
第22行的assign()与第17行的assign()参数不同,显然,这些容器通过函数名重载定义了多个名为assign()的成员函数。请读者对照图19-7理解代码第22行所描述的元素范围。
🚩第23行:打印链表b的全部元素,其输出对应执行结果的第3 ~ 4行。
💥警告 | 往vector、string、deque等容器添加元素,可能会使得相关的迭代器、引用、地址等全部失效,因为元素的添加过程可能会导致原有元素的内存地址变生变化。这也是表19-4中,c.insert(it,begin,end)中的begin,end不可以源自容器c的原因。 |
---|
下述程序主要讨论表19-4里几个insert( )函数的使用方法。
1 //Project - AddElements
2 #include <iostream>
3 #include <list>
4 #include <vector>
5 using namespace std;
6
7 void output(T begin, T end, const string& sTitle){ ... } //代码有省略
8
9 int main() {
10 vector<int> a {0,1,2,3,4,5};
11 a.insert(a.cbegin()+4,10);
12 a.insert(a.cbegin()+2,3,100);
13 a.insert(a.cbegin()+3,{97,98,99});
14 a.emplace(a.cbegin()+5, 999);
15 output(a.cbegin(),a.cend(),"vector a" );
16
17 list<int> b {0,0,0,0,0};
18 auto it = b.cbegin();
19 it++;
20 b.insert(it, a.cbegin()+2, a.cbegin()+7);
21 output(b.cbegin(),b.cend(),"list b" );
22 return 0;
23 }
上述程序的执行结果为:
1 ----------vector<int> a------------
2 0,1,100,97,98,999,99,100,100,2,3,10,4,5,
3 ----------list<int> b------------
4 0,100,97,98,999,99,0,0,0,0,
第7行代码有省略,该output()函数与前一小节中的output()函数完全相同,用于输出容器内容。
🚩第11 ~ 14行:图19-8展示了相关执行过程。
- 第11行在第4个元素(从0开始计数,即元素4)之前插入元素10。
- 第12行在第2个元素(从0开始计数,即元素2)之前插入3个值为100的元素。
- 第13行在第3个元素(从0开始计数,即如图所示的值为100的元素)之前插入三个元素,其值依次为97、98和99。
- 第14行在第5个元素(从0开始计数,即元素99)之前新增一个元素,其值为999。理论上,emplace( )函数会通过从参数构造而不是复制来初始化新元素,但对于原生数据类型int来讲,两者并无区别。
🚩第15行:输出向量a的全部元素,从执行结果的第2行可见,a内的元素内容与图19-8完全一致。
🚩第18 ~ 20行:图19-9展示了相关执行过程。
- 第18、19行先取得容器b的首元素只读迭代器it,经过++ *** 作符,it指向第1个元素(从0开始计数),如图19-9所示。
- 第20行则把两个迭代器所表示的范围内的元素,对应图19-9中底色为灰的单元格,复制并插入到it迭代器所指向的元素之前。如前所述,迭代器[it1, it2)所指的元素范围是不包括it2所指向的元素的。
🚩第21行:输出容器b的全部内容,从执行结果的第4行可见,相关输出与图19-9的描述相符。
🎯要点 | 可以看出,迭代器的使用部分消除了不同类型容器之间的鸿沟。在上述示例中,通过迭代器,我们很容易地把向量中的元素批量复制到链表内。反之亦然。 |
---|
💥警告 | 从容器移除元素,可能会使得相关的迭代器、引用、地址等全部失效,因为元素的移除过程可能会导致原有元素的内存地址变生变化。 |
---|
下述程序中的evenRemover( )函数,通过迭代器来移除不同类型容器中的偶数。
1 //Project - EvenRemover
2 #include <iostream>
3 #include <list>
4 #include <deque>
5 using namespace std;
6
7 void output(T begin, T end, const string& sTitle){ ... } //代码有省略
8
9 template <typename T>
10 void evenRemover(T& c){
11 auto it = c.begin();
12 while (it!=c.end()){ //检查it是否等于尾后迭代器
13 if (*it % 2 == 0)
14 it = c.erase(it); //发现偶数,删除并移至下一个元素
15 else
16 ++it; //不是偶然,移至下一个元素
17 }
18 }
19
20 int main() {
21 list<int> a {0,1,2,3,4,5,6,7,8,9};
22 evenRemover(a);
23 output(a.cbegin(),a.cend(),"list a" );
24
25 deque<int> b {0,1,2,3,4,5,6,7,8,9};
26 evenRemover(b);
27 output(b.cbegin(),b.cend(),"deque b" );
28 return 0;
29 }
程序的执行结果为:
1 ----------list<int> a------------
2 1,3,5,7,9,
3 ----------deque<int> b------------
4 1,3,5,7,9,
第7行代码有省略,该output( )函数与前一小节中的output( )函数完全相同,用于输出容器内容。
🚩第9 ~ 18行:evenRemover( )函数接受一个容器的引用作为参数,删除该容器内的全部偶数。由于类型T为模板参数,理论上该函数适用于向量、链表、双端队表等各种容器类型。
🚩第11行:获取容器c的首元素迭代器并赋值给it。
🚩第12行:while循环以it!=c.end( )作为循环条件,当it等于c.end( )/尾后迭代器时,即意味着容器c的全部元素已遍历完毕。
🚩第13 ~ 14行:如果迭代器it所指向的元素为偶数,通过c.erase( )函数将其删除。根据erase( )函数的定义,其将返回指向被删除元素的后一个元素的迭代器。该返回迭代器被赋值给it。理论上,从容器中删除元素可能使得该容器的全部迭代器失效,但在第12行的while循环条件中,每次都是重新调用c.end( )函数来获取“最新”的尾后迭代器,这使得程序不会受到迭代器失效的影响。
🚩第15 ~ 16行:不是偶数,通过++it将迭代器移至下一个元素。
🚩第21 ~ 23行:将一个整数链表传递给evenRemover( )函数,执行结果的第1 ~ 2行证实,容器内的全部偶数被删除。
🚩第25 ~ 27行:将一个元素类型为整数的双端队列传递给evenRemover( )函数,执行结果的第3 ~ 4行证实,容器内的全部偶数被删除。
这个示例程序进一步证实,通过模板参数以及迭代器,evenRemover( )函数和output( )函数均具备泛型程序设计的特征,它可以在不同的容器上完成相同的工作。
19.5.7迭代器失效容器中元素的存储空间是由容器自身来管理的。当对容器增/减元素时,容器可能会扩充/缩减存储空间,与增/减 *** 作无关的原有元素的存储位置也可能会发生迁移。当这种迁移发生时,位于新存储位置的“新”元素通过拷贝构造从“旧元素”复制,而“旧元素”则会在稍后被析构释放。
🎯 要点 | 当对容器进行元素的增/减 *** 作时,可能伴随着原有元素的位置迁移。在相关 *** 作进行之前所获得的元素的引用、指针、迭代器很可能失效,继续使用这些引用、指针或迭代器会导致无法预料的后果。 |
---|
具体到不同类型的容器,其内存管理行为有所不同。对于向量,如果在某个中部位置删除一个元素,后续的元素将发生迁移(依次前移一个位置),而被删元素之前的元素位置,则通常不会发生变化。对于链表,通常认为插入/移除一个元素,不会导致原有其他元素的位置变化。容器的内存管理行为,既跟容器类型有关系,也与容器的不同实现版本有关。较为审慎可靠的做法是:不要在容器的元素增/减 *** 作之后使用之前获取的元素引用、指针和迭代器。
下述C++程序遍历一个向量容器,删除其中的负元素并复制全部非负元素。我们通过该示例来说明如何安全地在循环遍历一个容器的过程中增/减元素。
1 //Project - LoopEditor
2 #include <iostream>
3 #include <vector>
4 using namespace std;
5
6 void output(T begin, T end, const string& sTitle){ ... } //代码有省略
7
8 int main() {
9 vector<int> vi {1,-9,3,-2,0,7,-5};
10 auto it = vi.begin();
11 while (it != vi.end()){ //总是执行end()函数重新获取尾后迭代器
12 if (*it < 0)
13 it = vi.erase(it); //erase()返回指向被删元素后一个元素的迭代器
14 else{
15 it = vi.insert(it,*it); //insert()返回指向新增元素的迭代器
16 it += 2; //后移两个位置以指向下一个待处理元素
17 }
18 }
19
20 output(vi.begin(),vi.end(),"vector vi" );
21 return 0;
22 }
程序的执行结果为:
1 ----------vector<int> vi------------
2 1,1,3,3,0,0,7,7,
第6行代码有省略,该output()函数与前一小节中的output()函数完全相同,用于输出容器内容。
🚩第10行:获取容器vi的首元素迭代器并赋值给it。
🚩第11行:在循环遍历过程中,迭代器it从前往后移动。it等于vi.end()所返回的尾后迭代器,意味着所有元素都已遍历并处置完毕,循环应结束。请注意:循环过程中总是调用end()函数重新获取尾后迭代器,这是因为,循环过程中元素的增/减 *** 作(13、15行)会导致之前获取的迭代器,包括尾后迭代器失效。
🚩第12 ~ 13行:如果it指向的当前元素小于0,则删除该元素。注意,erase()函数返回指向被删元素后一个元素的迭代器。该迭代器是在增/减 *** 作之后从容器获取的,继续使用该迭代器是安全的。
🚩第14 ~ 17行:如果it指向的元素不小于0,则对其进行复制插入。insert(it, *it)中的it指明了插入位置,*it则代表了被插入的值。insert()函数返回指向新增元素的迭代器,同样,该迭代器也是在增/减 *** 作之后从容器获取的,可以继续使用。
对于序列[A, B, C, D],如果当前迭代器指向B,在当前迭代器位置插入B的复制品B’后,序列将变成[A,B’,B,C,D]。同时,insert()函数返回的迭代器将指向新增元素,也就是B’。所以第16行将it后移两个位置以指向下一个待处理的元素C。
下述伪代码错误地在容器的增/减 *** 作后使用之前获取的迭代器,其实际执行行为不可预测。
1 vector<int> vi {1,-9,3,-2,0,7,-5};
2 auto it = vi.begin();
3 auto end = vi.end();
4 while (it != end){
5 if (*it < 0){
6 vi.erase(it);
7 ++it;
8 }
9 }
🚩第4行:程序员可能期望将vi的尾后迭代器提前保存至变量end,以避免vi.end()函数的多次执行。但实际情况是,只要发生vi的元素增/减行为,end变量所保存的迭代器便会失效。
🚩第7行:由于第6行删除了一个容器元素,it迭代器已失效,不可以继续使用。
📍注意 | 不要在循环中使用缓存的由end()所返回的尾后迭代器,除非循环过程中不发生对容器的元素增/减 *** 作。 |
---|
练习巩固 👣 | |
---|---|
19-5(rangeMax模板函数)请设计下述rangeMax()模板函数,该函数在给定的迭代器搜索范围[begin,end)里查找值最大的元素,返回指向该元素的迭代器。假设迭代器所指向的元素类型支持> *** 作符。请编写代码对该函数加以验证。 | |
T rangeMax(T begin, T end); |
为了帮助更多的年轻朋友们学好编程,作者在B站上开了两门免费的网课,一门零基础讲Python,一门零基础C和C++一起学,拿走不谢!
简洁的C及C++
Python编程基础及应用
如果你觉得纸质书看起来更顺手,目前Python有两本,C和C++在出版过程中。
Python编程基础及应用
Python编程基础及应用实验教程
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)