别害怕,C++容器的迭代器其实好用又不难

别害怕,C++容器的迭代器其实好用又不难,第1张

如前所述,不同容器,比如向量和链表,其内部数据结构差异很大。不同的内部数据结构导致了不同的容器特性:向量、模板数组、双端队列可以通过下标随机访问元素,而链表、单向链表则只能顺序访问。不同类型容器的接口差异,为代码复用(code reuse) 带来困难。迭代器(iterator) 的设计目的之一,就是消除不同容器间的访问接口差异,从而使得泛型程序设计(generic programming) 成为可能。广义地,迭代器属于设计模式(design patterns) 的范畴。

本文引用自作者编写的下述图书; 本文允许以个人学习、教学等目的引用、讲授或转载,但需要注明原作者"海洋饼干叔
叔";本文不允许以纸质及电子出版为目的进行抄摘或改编。
1.《Python编程基础及应用》,陈波,刘慧君,高等教育出版社。免费授课视频 Python编程基础及应用
2.《Python编程基础及应用实验教程》, 陈波,熊心志,张全和,刘慧君,赵恒军,高等教育出版社Python编程基础及应用实验教程
3. 《简明C及C++语言教程》,陈波,待出版书稿。免费授课视频

19.5.1获取迭代器

大多数容器,都有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.5.2迭代器算术

迭代器本质是对象,但使用方法类似于指针。表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节。
19.5.3折半查找示例

我们使用迭代器重写了第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()函数不支持对链表的折半查找,因为链表的迭代器不支持减法 *** 作符。
19.5.4容器元素的修改

下述程序展示了通过迭代器对容器元素进行修改的一般方法。

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行。

19.5.5容器元素的增加

💥警告往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的描述相符。

🎯要点可以看出,迭代器的使用部分消除了不同类型容器之间的鸿沟。在上述示例中,通过迭代器,我们很容易地把向量中的元素批量复制到链表内。反之亦然。
19.5.6容器元素的删除

💥警告从容器移除元素,可能会使得相关的迭代器、引用、地址等全部失效,因为元素的移除过程可能会导致原有元素的内存地址变生变化。

下述程序中的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编程基础及应用实验教程

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/3002455.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-09-27
下一篇 2022-09-27

发表评论

登录后才能评论

评论列表(0条)

保存