当前位置 : 主页 > 编程语言 > python >

Python 面试题

来源:互联网 收集:自由互联 发布时间:2022-07-19
一、Python 1、数据类型,可变不可变 不可变:tuple、str、int、float、bool 可变:list、dict、set 2、深浅拷贝 浅拷贝通常只复制对象本身,在拷贝中改动原对象不会改变 而深拷贝不仅会复制

一、Python

1、数据类型,可变不可变

  • 不可变:tuple、str、int、float、bool
  • 可变:list、dict、set

2、深浅拷贝

  • 浅拷贝通常只复制对象本身,在拷贝中改动原对象不会改变
  • 而深拷贝不仅会复制对象,还会递归的复制对象所关联的对象,对一个对象的拷贝做出改变时,不会影响原对象,深拷贝会导致两个问题:
    • 对象如果直接或间接的引用了自身,会导致无休止的递归拷贝(可以通过memo字典来保存已经拷贝过的对象,从而避免刚才所说的自引用递归问题)
    • 可能对原本设计为多个对象共享的数据也进行拷贝
>>> test = [1, 2, 3, ['a', 'b']] >>> import copy >>> a = copy.copy(test) >>> b = copy.deepcopy(test) >>> a [1, 2, 3, ['a', 'b']] >>> a[3][1] = 'c' >>> a [1, 2, 3, ['a', 'c']] >>> test [1, 2, 3, ['a', 'c']] >>> b [1, 2, 3, ['a', 'b']] >>> b[3][0] = 'b' >>> b [1, 2, 3, ['b', 'b']] >>> test [1, 2, 3, ['a', 'c']]

数据类型可变不可变拷贝 id 也是有区别的:

# 只有一层数据结构:可变类型时,浅拷贝 id 不一致,不可变类型时,浅拷贝 id 一致 >>> a = [1, 2, 3] >>> b = copy.copy(a) >>> id(a) 1996137345800 >>> id(b) 1996137414600 >>> a = (1, 2, 3) >>> b = copy.copy(a) >>> id(a) 1996136097400 >>> id(b) 1996136097400

3、Python是如何实现内存管理的?

Python 提供自动化的内存管理,内存空间的分配与释放都是由 Python 解释器运行时自动进行。以CPython解释器为例,它的内存管理有三个关键点:引用计数、标记清理、分代收集

引用计数

Python中的每一个对象其实就是PyObject结构体,它的内部有一个名为ob_refcnt 的引用计数器成员变量。程序在运行的过程中ob_refcnt的值会被更新并藉此来反映引用有多少个变量引用到该对象。当对象的引用计数值为0时,它的内存就会被释放掉。

以下情况会导致引用计数加1:

  • 对象被创建
  • 对象被引用
  • 对象作为参数传入到一个函数中
  • 对象作为元素存储到一个容器中

以下情况会导致引用计数减1:

  • 用del语句显示删除对象引用
  • 对象引用被重新赋值其他对象
  • 一个对象离开它所在的作用域
  • 持有该对象的容器自身被销毁
  • 持有该对象的容器删除该对象

可以通过sys模块的getrefcount函数来获得对象的引用计数。引用计数的内存管理方式在遇到循环引用的时候就会出现致命伤,因此需要其他的垃圾回收算法对其进行补充。

标记清理:

CPython使用了“标记-清理”(Mark and Sweep)算法解决容器类型可能产生的循环引用问题。该算法在垃圾回收时分为两个阶段:

  • 标记阶段,遍历所有的对象,如果对象是可达的(被其他对象引用),那么就标记该对象为可达;
  • 清除阶段,再次遍历对象,如果发现某个对象没有标记为可达,则就将其回收

分代回收

在循环引用对象的回收中,整个应用程序会被暂停,为了减少应用程序暂停的时间,Python 通过分代回收(空间换时间)的方法提高垃圾回收效率。分代回收的基本思想是:对象存在的时间越长,是垃圾的可能性就越小,应该尽量不对这样的对象进行垃圾回收

4、大量字符串拼接

转换成列表再用 join,使用 + 号但是会生成新的字符串(字符串不可变)

5、对Python中迭代器和生成器的理解

  • 迭代器:实现了迭代器协议的对象,__next__和__iter__这两个魔法方法就代表了迭代器协议,通过 for-in 循环可以从迭代器对象中取出值,next 函数可以取出下一个值
  • 生成器:在调用生成器运行的过程中,每次遇到 yield 时函数会暂停并保存当前所有的运行信息,返回 yield 的值, 并在下一次执行 next() 方法时从当前位置继续运行
    • 生成器函数:实现了 yield 的函数,自动实现了迭代器协议
    • 生成器表达式:将列表推导式中的中括号替换成圆括号

区别

  • 迭代器是访问容器的一种方式,即容器已出现,只是从已有元素拓印出一份副本,用于此次迭代使用。而生成器是自动生成元素,且只能遍历一次,属于边计算别迭代
  • 生成器是随用随生成,用完即释放,高效且不怎么占用内存,节省资源

一个对象若想可以迭代,内部就需要实现迭代器协议:

class Fib(object): def __init__(self, num): self.num = num self.a, self.b = 0, 1 self.idx = 0 def __iter__(self): return self def __next__(self): if self.idx < self.num: self.a, self.b = self.b, self.a + self.b self.idx += 1 return self.a raise StopIteration()

而生成器内部已自动实现迭代器协议,更为简洁:

def fib(num): a, b = 0, 1 for _ in range(num): a, b = b, a + b yield a

参考:

  • 生成器和迭代器区别

    6、多线程、多进程区别

通常我们运行的程序会包含一个或多个进程,而每个进程中又包含一个或多个线程

  • 多线程:操作系统分配 CPU 的基本单位,适用于 IO密集型任务,如:requests 请求
    • 优点:多个线程之间可以共享进程内存空间,进程间通信比较容易实现,但是受限于 GIL 的限制多线程不能利用 CPU 的多核特性
  • 多进程:是操作系统分配内存的基本单位,适用于CPU密集型,需要大量计算的任务,如: 视频编码解码、数据处理、科学计算等
    • 可以充分利用 CPU 多核特性,但是进程间通信比较麻烦,需要使用 IPC 机制(管道、套接字等)

7、正则表达式的match方法和search方法有什么区别

都是返回 match 对象或 None

  • match:从字符串头部开始匹配
  • search:会扫描整个字符串

8、list 拓容,越往后添加数据性能越差,怎么优化?

List 会自动扩容 但是会自动摊销,倍数扩容,可以使用字典或集合,不使用大列表

9、写一个函数统计列表每个元素出现的次数

方法一:

>>> a = ["a", "b", "a", "c"] >>> info = {} >>> for i in a: ... info[i] = info.get(i, 0) + 1 >>> info {'a': 2, 'b': 1, 'c': 1}

方法二:

>>> from collections import Counter >>> info = Counter(a) >>> info Counter({'a': 2, 'b': 1, 'c': 1})

10、Python 为什么运行慢?

  • Python 是动态强类型语言,边解释边运行, 比较和转换类型的开销很大,每次读取、写入或引用一个变量,都要检查类型
  • C 与C++运行的时候要先进行编译,编译成为可以直接生成运行效率高的机械码,python执行的时候是源码,需要一个源码到机械码的过程变量随时切换,所以运行时需要随时检查类型
  • GIL 限制无法多核 CPU 并发执行, 同步线程的一种机制,它使得任何时刻仅有一个线程在执行。即便在多核心处理器上,使用GIL的解释器只允许单个时刻单个进程只能使用一个线程

11、Python构造方法和析构构造方法

  • 构造方法 __init__:用于类的实例化
  • 析构方法 __del__:作用是在对象调用完毕后将其释放,不再使用
class A: def __init__(self): pass def __del__(self): print("对象被释放") a = A() del a print(a) # 调用会报错,因为 a 已经被清理了

12、函数参数*arg和**kwargs分别代表什么?

Python中,函数的参数分为位置参数、可变参数、关键字参数、命名关键字参数

  • *args代表可变参数,可以接收0个或任意多个参数,当不确定调用者会传入多少个位置参数时,就可以使用可变参数,它会将传入的参数打包成一个元组
  • **kwargs代表关键字参数,可以接收用参数名=参数值的方式传入的参数,传入的参数的会打包成一个字典。
  • 定义函数时如果同时使用*args和**kwargs,那么函数可以接收任意参数。

13、写一个记录函数时间的装饰器

1、用函数实现装饰器:

import time from functools import wraps def record_time(func): @wraps(func) def wrapper(*args, **kwargs): start_time = time.time() result = func(*args, **kwargs) print(f"总耗时:{time.time() - start_time}") return result return wrapper @record_time def ff(): for i in range(22000): print(i) if __name__ == '__main__': ff()

2、用类实现装饰器:

class Record: def __call__(self, func, *args, **kwargs): """该方法的功能类似于在类中重载 () 运算符,使得类实例对象可以像调用普通函数那样,以“对象名()”的形式使用""" @wraps(func) def wrapper(*args, **kwargs): start_time = time.time() result = func(*args, **kwargs) print(f"总耗时:{time.time() - start_time}") return result return wrapper record = Record() # record() 会触发 __call__ @record def ff(): for i in range(22000): print(i)

14、单例模式

装饰器实现、元类实现、import 1)、装饰器形式:

from functools import wraps def singleton(cls): """单例类装饰器""" instances = {} @wraps(cls) def wrapper(*args, **kwargs): if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return wrapper @singletonclass President: pass

2)、元类:

class SingletonMeta(type): """自定义单例元类""" def __init__(cls, *args, **kwargs): cls.__instance = None super().__init__(*args, **kwargs) def __call__(cls, *args, **kwargs): if cls.__instance is None: cls.__instance = super().__call__(*args, **kwargs) return cls.__instance class President(metaclass=SingletonMeta): pass

15、lambda 是什么,使用场景

匿名函数,不会跟其他函数命令冲突,一行代码就可以实现一个函数要实现的功能, 表达式的执行结果就是函数的返回值

主要的用途是把一个函数传入另一个高阶函数(如Python内置的filter、map等)中来为函数做解耦合,增强函数的灵活性和通用性

list(map(lambda x: x*2, [1, 2, 3])) # 对字典按照 value 进行排序 info = {"a": 1, "b": 2} sorted(info.items(), key=lambda x: x[1], reverse=True) # [('b', 2), ('a', 1)]

16、 什么是鸭子类型(duck typing)?

鸭子类型是动态类型语言判断一个对象是不是某种类型时使用的方法,也叫做鸭子判定法。简单的说,鸭子类型是指判断一只鸟是不是鸭子,我们只关心它游泳像不像鸭子、叫起来像不像鸭子、走路像不像鸭子就足够了。换言之,如果对象的行为跟我们的预期是一致的(能够接受某些消息),我们就认定它是某种类型的对象。

在Python语言中,有很多bytes-like对象(如:bytes、bytearray、array.array、memoryview)、file-like对象(如:StringIO、BytesIO、GzipFile、socket)、path-like对象(如:str、bytes),其中file-like对象都能支持read和write操作,可以像文件一样读写,这就是所谓的对象有鸭子的行为就可以判定为鸭子的判定方法。再比如Python中列表的extend方法,它需要的参数并不一定要是列表,只要是可迭代对象就没有问题。

说明:动态语言的鸭子类型使得设计模式的应用被大大简化。

17、谈谈对闭包的理解

由于种种原因,有时需要在函数外部得到函数内部的局部变量,但是由于 Python 作用域的限制,在外部访问会直接报 NameError,是无法直接实现的,如:

def f1(): n=999; print(n)

但如果在函数内部再定义一个内部函数,这个函数是可以访问该函数的局部变量的,若将 内部函数直接返回,那么在外部也可以间接访问函数内部局部变量:

def f1(): n=999 def f2(): print(n) return f2 result = f1() result()

以上 f2() 函数就是一个闭包,闭包的定义就是在函数中可以(嵌套)定义另一个函数时,如果内部的函数引用了外部的函数的变量,则可能产生闭包。

闭包的用途

  • 读取函数内部的变量
  • 让函数内部的局部变量始终保持在内存中:函数内部的局部变量在这个函数运行完以后,就会被Python的垃圾回收机制从内存中清除掉。如果我们希望这个局部变量能够长久的保存在内存中,那么就可以用闭包来实现这个功能,这样就会导致很大的内存开销,因此要避免滥用

18、collections

1、defaultdict

是内置 dict 类的子类。它实现了当 key 不存在是返回默认值的功能,除此之外,与内置 dict 功能完全一样。

>>> from collections import defaultdict >>> default_dict = defaultdict(int) >>> default_dict['x'] = 22 >>> default_dict['x'] 22 >>> default_dict['y'] 0 # 自定义返回默认值 >>> def get_default_info(): ... return {"name": "123"} ... >>> d1 = defaultdict(get_default_info) >>> d1['aa'] {'name': '123'}

2、OrderedDict

有序字典,可以保留元素的插入顺序

>>> from collections import OrderedDict >>> d = OrderedDict() >>> d['name'] = 'rose' >>> d['age'] = 18 >>> d OrderedDict([('name', 'rose'), ('age', 18)]) >>> d.move_to_end("name") # 可以将指定 key 移动到末尾,d.move_to_end("name", last=False) 移动到开头 >>> d OrderedDict([('age', 18), ('name', 'rose')])

3、Counter

统计元素出现的次数,其额外提供的 most_common() 函数通常用于求 Top k 问题

>>> from collections import Counter >>> d = ['a', 'b', 'a', 'c'] >>> d_counter = Counter(d) >>> d_counter Counter({'a': 2, 'b': 1, 'c': 1}) >>> d_counter.most_common(1) [('a', 2)]

4、deque

双端队列

>>> from collections import deque >>> q = deque([1, 2, 3]) >>> q.append(4) >>> q.appendleft(5) >>> q deque([5, 1, 2, 3, 4]) >>> q.pop() 4 >>> q.popleft() 5

5、namedtuple

命名元组,返回一个具有命名字段的元组的新子类,可以用来构建一个只有少数属性,但没有方法的类对象;比直接定义 class 的方式省很多空间,其次其返回值是一个 tuple,支持 tuple 的各种

>>> from collections import namedtuple >>> Point = namedtuple('Point', ['x', 'y']) >>> Point.x <property object at 0x000001D0C31050E8> >>> p = Point(1, 2) # 实例化 >>> p.x 1 >>> p._asdict() OrderedDict([('x', 1), ('y', 2)])

6、ChainMap

将多个字典集合到一个字典中去,对外提供一个统一的视图

from collections import ChainMap def demo_chain(): user1 = {"name": "rose", "age": 18} user2 = {"name": "lila", "age": 19} user = ChainMap(user1, user2) print(user.maps) # [{'name': 'rose', 'age': 18}, {'name': 'lila', 'age': 19}] print(user.keys()) # KeysView(ChainMap({'name': 'rose', 'age': 18}, {'name': 'lila', 'age': 19})) print(user.values()) # ValuesView(ChainMap({'name': 'rose', 'age': 18}, {'name': 'lila', 'age': 19})) for k, v in user.items(): print(k, v) """ name rose age 18 """

如果 ChainMap() 中的多个字典有重复 key,查看的时候可以看到所有的 key,但遍历的时候却只会遍历 key 第一次出现的位置,其余的忽略。

19、线程池的工作原理

线程池是一种可以减少线程本身创建和销毁造成的开销的技术,属于空间换时间的操作。

因为线程的创建和销毁涉及到大量的系统的底层操作,开销较大;线程池的原理就是将创建和释放线程的操作变成预创建,创建一定数量的线程后,放入空闲队列中,最初这些线程都是阻塞的,不会消耗 CPU 资源,但会占用少量内存空间。

当有新任务来了时,从队列中取出一个空闲线程,并将该线程标记为已占用。任务执行完毕后,线程并不会结束,而是继续保持在池中等待下一个任务,当系统比较空闲时,大部分线程长时间处于闲置状态时,线程池可以自动销毁一部分线程,回收系统资源。

基于这种预创建技术,线程池将线程创建和销毁本身所带来的开销分摊到了各个具体的任务上,执行次数越多,每个任务所分担到的线程本身开销则越小。

20、如何读取大文件,比如 8G 的文件

利用yield生成器 + 指定大小读取

def read_file(): with open(path, encoding='utf-8') as f: while True: chunk_data = f.read(chunk_size=2048) # 指定每次读取大小(字节) if not chunk_data: break yield chunk_data

21、Python实例方法、类方法、静态方法区别详解

  • 实例方法:可以通过对象直接调用
  • 类方法 classmethod:不能访问实例变量,只能访问类变量,可以通过类名、对象调用
  • 静态方法 staticmethod:与类无关,只是类中的一个功能而已,不能类变量和实例变量,可以通过类名、对象调用
class Dog: age = 3 # 类变量 def __init__(self): self.name = "XiaoBai" # 实例变量 def run(self): # 实例方法 print("{} years old's {} is running!".format(self.age, self.name)) @classmethod def eat(cls): # print(cls.name) # 类方法,不能访问实例变量(属性) print("XiaoHei is {} years old".format(cls.age)) # 类方法只能访问类变量 @staticmethod def sleep(name): # 静态方法与类无关,只能类中的一个功能而已 # 静态方法不能访问类变量和实例变量 print("{} is sleeping".format(name)) d = Dog() d.run() # 通过实例化对象调用实例方法 Dog.run(d) # 通过类名称调用实例方法,需要在方法中传入实例对象 d.eat() # 通过实例化对象调用类方法 Dog.eat() # 通过类名称调用类方法 d.sleep("XiaoLan") # 通过实例化对象调用静态方法 Dog.sleep("XiaoLan") # 通过类名称调用静态方法

参考:Python实例方法、类方法、静态方法区别详解

二、kafka

1、kafka offset 提交

  • 自动提交:基于时间提交,难以把握提交时机
  • 手动提交:
    • 同步提交:影响吞吐量,会失败重试
    • 异步提交:最常用,无重试机制有可能失败

2、消息漏消费和重复消费的场景

无论同步还是异步提交 offset,都有可能漏消费或重复消费,

  • 漏消费:先提交后消费
  • 重复消费:先消费后提交

3、自定义 offset

kafka 0.9 之前 offset 存储在 zk 中,之后默认存储在一个内置的 topic 中,用户也可以自定义选择存储方式其目的是为了保证消费和提交 offset 同时成功或失败,可以利用数据库的事务功能来实现,因此可以将 offset 存放在 MySQL 中

4、消息积压场景

  • 实时或消费任务挂掉:任务挂掉没监控到没有自动拉起
  • kafka 分区数设置不合理(太少)和消费者消费能力不足
  • kafka 消息的 key不均匀,导致分区间数据不均衡(producer 生产消息时可指定key)

解决:

  • 任务重新启动后直接消费最新的消息,对于"滞后"的历史数据采用离线程序进行"补漏"。
  • 增加监控和自动拉起任务启动从上次提交offset处开始消费处理如果积压的数据量很大,需要增加任务的处理能力,比如增加资源,让任务能尽可能的快速消费处理,并赶上消费最新的消息合理增加分区
  • 如果利用的是Spark流和Kafka direct approach方式,也可以对KafkaRDD进行repartition重分区,增加并行度处理。
  • 给key加随机后缀,使其均衡

参考:https://baijiahao.baidu.com/s?id=1713112192042676423&wfr=spider&for=pc&searchword=kafka%E6%B6%88%E6%81%AF%E7%A7%AF%E5%8E%8B

三、数据库

1、Redis 有事务?

2、MySQL、Redis、MongoDB、HBase的区别

  • MySQL:关系型数据库,一般 web 开发搭配使用,数据存储在磁盘中,读写慢

  • Redis:非关系型数据库,数据类型:字符串、列表、集合、有序集合、哈希,存储在内存中,读写速度快,可用作缓存key-value 存储 结构

    • 缺点: 因为是基于内存查询的,所以限制了可存储的数据量,限制了 Redis 在数据规模很大的应用场景中
    • 场景: 适合对读写性能极高,且数据表结构简单,查询条件简单的应用场景
  • MongoDB:非关系型数据库,存储在磁盘上,读取数据会被加载到内存中,读取速度快表结构灵活可变,字段类型可以随时修改, 插入数据时,不必考虑表结构的限制

    • 缺点: 给多表查询、复杂事务等高级操作带来了阻碍
    • 场景:适合那些表结构经常改变,数据的逻辑结构没又没那么复杂不需要多表查询操作,数据量又比较大的应用场景
  • HBase:非关系型数据库(列式存储), 极强的横向扩展能力,适合存储海量数据, 使用廉价的 PC 机就能够搭建起海量数据处理的大数据集群

    • 缺点: 对数据的读取带来了局限,只有同一列族的数据才能够放在一起,而且所有的查询都必须依赖于 key,这就使得很多复杂的查询难以实现
    • 场景: hbase 是一款很重的产品,依赖很多 hadoop 组件,如果数据规模规模不大,没必要使用 hbase,MongoDB 就完全可以满足需求 由于列式存储的能力带来了海量数据的容纳能力,因此非常适合数据量极大、查询条件简单、列与列之间联系不大的场景

    参考:https://blog.csdn.net/oppo62258801/article/details/76767626https://my.oschina.net/u/4389636/blog/4528148

3、MySQL 分页

获取数据总量 total再配合 SQL limit offset 进行查询

4、Redis key 有没有过期时间

默认没有,可以自定义

5、MySQL 索引分类及索引失效的情况

索引分类

  • 普通索引:加速查询
  • 唯一索引:加速查询 + 列值唯一(可为 null)
  • 主键索引:加速查询+列值唯一+全表只有一个(不允许null)
  • 组合(联合)索引:多个列组成一个索引

索引失效场景

  • 查询条件包含 or,可能导致索引失效

  • 若字段类型是字符串,where 时一定用引号括起来,否则索引失效

  • like 通配符可能导致索引失效

  • 联合索引,查询时的条件列不是联合索引中的第一个列,索引失效

  • 在索引列上使用mysql的内置函数,索引失效

  • 对索引列运算(如,+、-、*、/),索引失效

  • 索引字段上使用(!= 或者 < >,not in)时,可能会导致索引失效

  • 索引字段上使用is null, is not null,可能导致索引失效

  • 左连接查询或者右连接查询查询关联的字段编码格式不一样,可能导致索引失效

  • mysql 估计使用全表扫描要比使用索引快,则不使用索引

6、日常工作是如何优化 SQL

  • 加索引
  • 避免返回不必要的数据
  • 适当分批量进行
  • 优化 sql 结构
  • 分库分表
  • 读写分离

7、书写高质量SQL的30条建议

参考:后端程序员必备:书写高质量SQL的30条建议

8、100道MySQL面试题及答案

100道MySQL面试题及答案

9、redis 是单线程的,为什么那么快

  • 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。

  • 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的

  • 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗

  • 使用多路I/O复用模型,非阻塞IO

  • 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求

四、Spark

1、spark 任务如何解决第三方依赖

比如机器学习的包,需要在本地安装?--py-files 添加 py、zip、egg 文件不需要在各个节点安装

2、spark 数据倾斜怎么解决

spark 中数据倾斜指的是 shuffle 过程中出现的数据倾斜,主要是由于 key 对应的数据不同导致不同 task 所处理的数据量不同。

例如,reduce点一共要处理100万条数据,第一个和第二个task分别被分配到了1万条数据,计算5分钟内完成,第三个task分配到了98万数据,此时第三个task可能需要10个小时完成,这使得整个Spark作业需要10个小时才能运行完成,这就是数据倾斜所带来的后果。

数据倾斜的表现:

  • Spark 作业的大部分 task 都执行迅速,只有有限的几个task执行的非常慢,此时可能出现了数据倾斜,作业可以运行,但是运行得非常慢

  • Spark 作业的大部分task都执行迅速,但是有的task在运行过程中会突然报出OOM,反复执行几次都在某一个task报出OOM错误,此时可能出现了数据倾斜,作业无法正常运行

定位数据倾斜问题:

  • 查阅代码中的shuffle算子,例如reduceByKey、countByKey、groupByKey、join等算子,根据代码逻辑判断此处是否会出现数据倾斜

  • 查看 Spark 作业的 log 文件,log 文件对于错误的记录会精确到代码的某一行,可以根据异常定位到的代码位置来明确错误发生在第几个stage,对应的 shuffle 算子是哪一个
上一篇:Python 迭代器介绍及其作用
下一篇:没有了
网友评论