欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

python基础Ⅴ(正则表达式、进程、线程、协程、GIL)

程序员文章站 2022-03-15 15:02:26
一、写在前面关于转义的问题正则表达式中用“\”表示转义,而python中也用“\”表示转义,当遇到特殊字符需要转义时,你要花费心思到底需要几个“\”,所以为了避免这个情况,推荐使用原生字符串类型(raw string)来书写正则表达式。方法很简单,只需要在表达式前面加个“r”即可关于贪婪贪婪匹配:匹配尽可能多的字符; 非贪婪匹配:匹配尽可能少的字符。python的正则匹配默认是贪婪匹配例如:import reprint(re.match(r'^(\w+)(\d*)$', 'abc123')....

一、写在前面

关于转义的问题

正则表达式中用 “\”表示转义,而python中也用 “\”表示转义,当遇到特殊字符需要转义时,你要花费心思到底需要几个“\”,所以为了避免这个情况,推荐使用原生字符串类型(raw string)来书写正则表达式。方法很简单,只需要在表达式前面加个“r”即可

关于贪婪

贪婪匹配匹配尽可能多的字符
非贪婪匹配匹配尽可能少的字符
python的正则匹配默认是贪婪匹配
例如:

import re
print(re.match(r'^(\w+)(\d*)$', 'abc123').groups())
print(re.match(r'^(\w+?)(\d*)$', 'abc123').groups())
# 输出结果
('abc123', '')
('abc', '123')

表达式1:
\w+表示匹配字母或数字或下划线或汉字并重复1次或更多次;\d*表示匹配数字并重复0次或更多次。
分组1(\w)是贪婪匹配,它会在满足分组2(\d*)的情况下匹配尽可能多的字符(有点拗口),
因为分组2(\d*)匹配0个数字也满足,所以分组1就把所有字符全部匹配掉了,分组2只能匹配空了。

表达式2:在表达式后加个?即可进行非贪婪匹配,如上面的(\w+?),
因为分组1进行非贪婪匹配,也就是满足分组2匹配的情况下,分组1尽可能少的匹配,
这样的话,上面分组2(\d*)会把所有数字(123)都匹配,所以分组1匹配到(abc)

不要在正则表达式里面胡乱出现空格

二、正则表达式

match方法

re.match字符串的起始位置开始匹配,如果起始位置匹配失败,match()就返回None;如果匹配成功的话,match()就返回一个匹配的对象。类似于自动加了^符号

re.match(pattern, string, flags=0)

  • pattern 匹配的正则表达式
  • string 要匹配的字符串。
  • flags 标志位,用于控制正则表达式的匹配方式,如:是否区分大小写,多行匹配等
  • 当匹配成功时,使用group和groups方法提取到被匹配的内容
  • 当匹配成功时,使用span方法可获得被匹配到的位置
import re

str1 = 'hello world! Hi, there.'
res1 = re.match('there', str1)
print(res1)
res2 = re.match('hello', str1)
print(res2.span())
print(res2.group())
res3 = re.match('e', str1)
print(res3)
# 输出结果
None
(0, 5)
hello
None

search 方法

扫描整个字符串并返回第一个成功的匹配匹配成功re.search方法返回一个匹配的对象否则返回None。

re.search(pattern, string, flags=0)

  • pattern 匹配的正则表达式
  • string 要匹配的字符串。
  • flags 标志位,用于控制正则表达式的匹配方式,如:是否区分大小写,多行匹配等等。
  • 当匹配成功时,使用group和groups方法可提取到被匹配的内容
  • 当匹配成功时,使用span方法可获得被匹配到的位置
import re

str1 = 'hello world! Hi, there.'
res1 = re.search('there', str1)
print(res1.span())
print(res1.group())
res2 = re.search('hello', str1)
print(res2.span())
print(res2.group())
res3 = re.search('e', str1)
print(res3.span())
print(res3.group())
# 输出结果
(17, 22)			# (被匹配到的位置,共匹配的长度)
there
(0, 5)
hello
(1, 2)
e

re.match与re.search的区别

re.match只匹配字符串的开始,如果字符串开始不符合正则表达式,则匹配失败,函数返回None;而re.search匹配整个字符串,直到找到一个匹配。

findall方法

它是search方法的加强版,findall方法会找到所有符合条件的结果并返回一个列表,而search方法只返回一个符合条件的结果

re.findall(pattern, string, flags=0)

  • pattern 匹配的正则表达式
  • string 要匹配的字符串。
  • flags 标志位,用于控制正则表达式的匹配方式,如:是否区分大小写,多行匹配等等。
  • 注意这里没有group、groups方法和span方法
import re

str1 = 'hello world! Hi, there.'
res1 = re.findall('there', str1)
print(res1)
res2 = re.findall('hello', str1)
print(res2)
res3 = re.findall('e', str1)
print(res3)
# 输出结果
['there']
['hello']
['e', 'e', 'e']

sub方法

re.sub用于替换字符串中的匹配项并返回一个新的字符串,它比字符串的replace方法更灵活

re.sub(pattern, repl, string, count=0, flags=0)

  • pattern : 正则中的模式字符串。
  • repl : 替换的字符串,也可为一个函数。
  • string : 要被查找替换的原始字符串。
  • count : 模式匹配后替换的最大次数,默认 0 表示替换所有的匹配。
  • flags 标志位,用于控制正则表达式的匹配方式,如:是否区分大小写,多行匹配等等。
import re

res = re.sub(r'\d+', '100', '数学:78,语文:56')
print(res)
# 输出结果
数学:100,语文:100

split方法

split 方法按照能够匹配的子串字符串分割后返回列表

这里是引用re.split(pattern, string[, maxsplit=0, flags=0])

  • pattern 匹配的正则表达式
  • string 要匹配的字符串。
  • maxsplit 分隔次数,maxsplit=1 分隔一次,默认为 0,不限制次数。
  • flags 标志位,用于控制正则表达式的匹配方式,如:是否区分大小写,多行匹配等
import re

res = re.split(r'[:,]', '数学:78, 语文:56')
print(res)
# 输出结果
['数学', '78', '语文', '56']

特殊字符

符号 说明
. 用于匹配除换行符(\n)之外的所有字符
^ 用于匹配字符串的开始,即行首。
$ 用于匹配字符串的末尾(末尾如果有换行符\n,就匹配\n前面的那个字符),即行尾。
* 用于将前面的模式匹配O次或多次 ,贪婪模式
+ 用于将前面的模式匹配1次或多次,贪婪模式
? 用于将前面的模式匹配0次或1次,贪婪模式
*?、+?、?? 上面的三种特殊字符的非贪婪模式
{} 用于将前面的模式重复匹配
{}? 上面的非贪婪模式
[] 在括号里面选择一个。如果^是第一个字符,则标示的是一个补集。比如[0-9]表示所有的数字,[^0-9]表示除了数字外的字符。
| 或。比如A I B用于匹配A或B。
() 分组,用于匹配括号中的模式,可以在字符串中检素或匹配我们所需要的内容。
\d 匹配任意一个数字,等价于[0-9]
\D 匹配任意一个非数字字符,等价于[^\d]
\s 匹配任意一个空白字符,等价于[\t\n\r\f]。
\S 匹配任意一个非空白字符,等价于[^\s].
\w 匹配任意一个字母数字及下划线,等价于[a-zA-Z0-9_].
\W 匹配任意一个非字母数字及下划线,等价于[^\w]

正则表达式引用分组

使用数字

使用数字加正斜杠来使用正则表达式的引用分组

import re

str1 = '<html><h1>aaaaa</h1></html>'
res1 = re.match(r'<([\w]+)>(.+?)</([\w]+)>', str1)		# 非贪婪模式
print(res1)
print(res1.group())
print(res1.groups())
res2 = re.match(r'<([\w]+)>(.+)</([\w]+)>', str1)		# 贪婪模式
print(res2)
print(res2.group())
print(res2.groups())
res3 = re.match(r'<([\w]+)>(.+?)</(\1)>', str1)			# 非贪婪模式 + 引用分组
print(res3)
print(res3.group())
print(res3.groups())
# 输出结果
<re.Match object; span=(0, 20), match='<html><h1>aaaaa</h1>'>
<html><h1>aaaaa</h1>
('html', '<h1>aaaaa', 'h1')
<re.Match object; span=(0, 27), match='<html><h1>aaaaa</h1></html>'>
<html><h1>aaaaa</h1></html>
('html', '<h1>aaaaa</h1>', 'html')
<re.Match object; span=(0, 27), match='<html><h1>aaaaa</h1></html>'>
<html><h1>aaaaa</h1></html>
('html', '<h1>aaaaa</h1>', 'html')

使用起名的方式

使用**?P**来使用正则表达式的 引用分组起名(?P<名字>正则),引用(?P=名字)。

import re

str1 = '<html><h1>aaaaa</h1></html>'
res1 = re.match(r'<(?P<name1>\w+)><(?P<name2>\w+)>(.+?)</(?P=name2)></(?P=name1)>', str1)
print(res1)
print(res1.group())
print(res1.groups())
res2 = re.match(r'<(?P<name1>\w+)><(?P<name2>\w+)>(.+)</(?P=name2)></(?P=name1)>', str1)
print(res2)
print(res2.group())
print(res2.groups())
# 输出结果
<re.Match object; span=(0, 27), match='<html><h1>aaaaa</h1></html>'>
<html><h1>aaaaa</h1></html>
('html', 'h1', 'aaaaa')

<re.Match object; span=(0, 27), match='<html><h1>aaaaa</h1></html>'>
<html><h1>aaaaa</h1></html>
('html', 'h1', 'aaaaa')

二、进程

下面是python在Windows下面对进程的操作

进程的创建

from multiprocessing import Process
from time import sleep
import os

def task1(s):					# 任务1
    for value in range(5):
        sleep(s)
        print('任务1---子进程pid{}---父进程pid{}'.format(os.getpid(), os.getppid()))

def task2(s):					# 任务2
    for value in range(5):
        sleep(s)
        print('任务2---子进程pid{}---父进程pid{}'.format(os.getpid(), os.getppid()))


if __name__ == '__main__':
    p1 = Process(target=task1, name='任务1', args=(1,))	# 定义要执行的内容,子进程的名字,传入参数
    p1.start()			# 开启子进程
    print(p1.name)		# 打印子进程名字
    p2 = Process(target=task2, name='任务2', args=(2,))
    p2.start()
    print(p2.name)

进程的结束

from multiprocessing import Process
from time import sleep

def task1(n):
    while True:
        sleep(n)
        print('task11111')

def task2(n):
    while True:
        sleep(n)
        print('task22222')

if __name__ == '__main__':
    p1 = Process(target=task1, name='你', args=(0.8,))
    p1.start()				# 开启进程
    print(p1.name)			# 进程名
    p2 = Process(target=task2, name='我', args=(0.5,))
    p2.start()
    print(p2.name)
    n = 1
    while True:
        n += 1
        sleep(0.2)
        if n == 100:
            p1.terminate()			# 结束进程
            p2.terminate()
            break					# break退出循环
    print('进程结束')
  • 多进程相当于程序多开
  • 要注意全局变量多个进程中不能共享,在子进程修改全局变量,对其他进程没有影响

自定义进程

自定义进程可使子进程更加的灵活

from multiprocessing import Process

class MyProcess(Process):
    def __init__(self, name):
        super(MyProcess, self).__init__()		# 调用父类的init方法
        self.name = name

    def run(self):
        num = 1
        while num < 1000:
            print('进程名{}---num为{}'.format(self.name, num))
            num += 1


if __name__ == '__main__':
    p1 = MyProcess('lihua')
    p1.start()				# 当使用start方法时,会自动的调用进程里的run方法
    p2 = MyProcess('liming')
    p2.start()

子类中的**__init__方法会覆盖掉父类的__init__方法**,而可以通过在子类的__init__方法中使用 super().__init__() , 以避免覆盖父类的 __init__构造方法

进程池

需要创建的子进程数量不多时,可以直接利用multiprocessing中的Process动态成生多个进程就可以。但如果是上百甚至上千个目标,手动的去创建进程的工作量巨大,此时就可以用到multiprocessing模块提供的Pool方法。因为进程的创建很浪费资源,所以初始化Pool时,可以指定一个最大进程数,之后Pool里面的进程号就固定了(任务与进程号轮询),这样就可以减少资源的浪费。且进程池分为非阻塞式和阻塞式

非阻塞式

创建指定个数的新进程来执行请求,并将创建好的进程一次性提交到Pool内,如果池中的进程数已经达到指定的最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程,提交到池内并执行进程号与请求轮询

from multiprocessing import Pool
import time
from random import random

def task(task_name):					# 任务
    print('任务名', task_name)
    start = time.time()
    time.sleep(random())
    end = time.time()
    return '任务名:{},执行时间:{}'.format(task_name, end - start)

finish_tasklist = []					# 完成的任务列表
def callbcak_task(param):				# 每次任务完成后就调用
    finish_tasklist.append(param)

if __name__ == '__main__':
    pool = Pool(5)				# 开启进程池,且里面的进程个数为5

    tasks = ('喝水', '吃饭', '做饭', '上厕所', '骑车', '上学', '出游', '写作业')
    for value in tasks:
    	# 将任务提交到Pool内并传递参数与回调函数
        pool.apply_async(task, args=(value,), callback=callbcak_task)

    pool.close()		# 表示进程提交结束
    pool.join()			
    # 因为Pool的生命周期依赖于主进程,而主进程执行的时间相对于子进程来说要短得多
    # 就导致了当主进程完成了后,主进程与Pool就结束了,而Pool里面的进程还没有执行完成
    # 而要避免上述的内容发生,就要使用join方法来确保当Pool里面的进程完成后再继续主进程,也就是说先搁置主进程而执行子进程
    # join()就相当于插队,先执行完他再之心其他的内容
    print(finish_tasklist)
    print('OVER')

阻塞式

创建一个新进程来执行请求,并将创建好的进程提交到Pool内,只有当池中有进程结束,才会创建新的进程,然后提交到池内并执行。将进程的执行,他没有体现进程池的优势,所以说阻塞式的意义不大。进程号与请求轮询

from multiprocessing import Pool
import time
from random import random

def task(task_name):
    print('任务名', task_name)
    start = time.time()
    time.sleep(random())
    end = time.time()
    print('任务名:{},执行时间:{}'.format(task_name, end - start))


if __name__ == '__main__':
    pool = Pool(5)

    tasks = ('喝水', '吃饭', '做饭', '上厕所', '骑车', '上学', '出游', '写作业')
    for value in tasks:
        pool.apply(task, args=(value,))			# 阻塞型

    pool.close()
    pool.join()
    print('OVER')

进程之间的通信(queue)

示例一:

from multiprocessing import Queue

my_queue = Queue(3)			# 利用queue实现进程之间的通信
message = ['a', 'b', 'c', 'd', 'e']
for value in message:
    if not my_queue.full():	# 判断queue是否为满
        my_queue.put(value, timeout=2)	# 放进值到queue里
    else:
        print('满了')

print(my_queue.qsize())			# 打印queue的当前存放了多少个值
while not my_queue.empty():		# 判断queue是否为空
    print(my_queue.get(timeout=2))	# 从queue里取值

示例二:

from multiprocessing import Queue, Process
from time import sleep

def task1(queue):			# 任务1
    message = ['a', 'b', 'c', 'd', 'e']
    for value in message:
        queue.put(value, timeout=2)		# 放值
        sleep(0.5)

def task2(queue):			# 任务2
    while True:			
        try:
            print(queue.get(timeout=5))	# 取值
        except:
            break


if __name__ == '__main__':
    q = Queue(3)
    p1 = Process(target=task1, args=(q,))		# 创建进程
    p1.start()			# 开始进程
    p2 = Process(target=task2, args=(q,))
    p2.start()

三、线程

  • 线程有时被称为轻量进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程有线程ID;当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态就绪状态是指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个事件(如某个信号量),逻辑上不可执行。每一个进程都至少有一个线程,若进程只有一个线程,那就是进程本身。
  • 线程系统独立调度和分派CPU的基本单位指令运行时的程序的调度单位。
  • 多线程(multithreading)是指从软件或者硬件上实现多个线程并发执行的技术。具有多线程能力的计算机因有硬件支持而能够在同一时间执行多于一个线程,进而提升整体处理性能。具有这种能力的系统包括对称多处理机、多核心处理器以及芯片级多处理(Chip-level multithreading)或同时多线程(Simultaneous multithreading)处理器。具有多线程能力的计算机因有硬件支持而能够在同一时间执行多于一个线程,进而提升整体处理性能

多线程

from threading import Thread
from time import sleep

def task1(num):			# 任务一
    message = ['a', 'b', 'c', 'd', 'e']
    for value in message:
        sleep(num)
        print('下载', value)

def task2(num):			# 任务二
    message = ['1', '2', '3', '4', '5']
    for value in message:
        sleep(num)
        print('输出', value)

if __name__ == '__main__':
    t1 = Thread(target=task1, name='任务1', args=(1,))	# 创建线程
    t2 = Thread(target=task2, name='任务2', args=(1,))
    t1.start()			# 开启线程
    t2.start()
    print(t1.name)		# 获得线程名
    print(t2.name)
  • 线程与进程的使用方法基本类似线程可以共享全局变量,而进程里面只能通过Queue来实现共享变量
  • 共享变量的值不够大时,python会自动给线程加上全局解释器锁(GIL)就形成了进程同步;而当共享变量足够大时,python的全局解释器自动释放无法进程同步,而导致数据不安全,不过可用通过手动加锁
  • 但是一加上全局解释器锁python的运行速度就会变慢,也就失去了创建线程的目的,不过同时数据是安全的
  • 所以进程一般用于计算密集型的操作,而线程用于耗时操作(爬虫、文件读写)

手动加锁与解锁

  • 使用Thread模块Lock和Rlock模块可以实现简单的线程同步,这两个对象都有acquire方法release方法,对于那些需要每次只允许一个线程操作的数据,可以将其操作放到acquire和release方法之间。线程的优势在于可以同时运行多个任务(至少感觉起来是这样)。但是当线程需要共享数据时,可能存在数据不同步的问题。为了避免这种情况,引入了锁的概念。
  • 可创建多个锁,但对同一全局变量应使用同一把锁
import threading
from time import sleep

list_example = [0] * 10			# 可变的全局变量
lock = threading.Lock()			# 创建一个锁

def task1():
    lock.acquire()				# 请求锁,这里有阻塞效果,若海没有请求到锁则一直阻塞
    for value in range(len(list_example)):
        sleep(0.5)
        list_example[value] = 1	# 给全局变量赋值
    lock.release()				# 释放锁


def task2():
    lock.acquire()				# 请求锁,这里有阻塞效果,若海没有请求到锁则一直阻塞
    for value in range(len(list_example)):
        sleep(0.5)
        print(list_example[value])
    lock.release()				# 释放锁


if __name__ == '__main__':
    t1 = threading.Thread(target=task1, name='任务1')	# 创建线程
    t2 = threading.Thread(target=task2, name='任务2')
    t1.start()			# 开启线程
    t2.start()
    print(t1.name)
    print(t2.name)
    # 这里是先执行任务1,等任务1执行完后再执行任务2.
    # 虽然两个函数里面都有sleep方法,但是由于GIL的存在,当一个线程里访问全局变量时,另一个线程不会抢占资源

死锁的避免与自定义线程

GIL有可能会导致死锁的发生,而可使用timeout来避免死锁的发生

from threading import Thread, Lock
from time import sleep

lock_a = Lock()			# 锁1
lock_b = Lock()			# 锁2
class MyThread1(Thread):		# 自定义线程1
    def run(self):			# start方法会自动调用run方法
        if lock_a.acquire():	# 返回值为Bool类型,若请求到锁则为True
            print(self.name + '获得了A锁')
            sleep(0.1)		# 
            if lock_b.acquire(timeout=5):	# 设置tumeout防止死锁
                print(self.name + '获得了B锁')
                sleep(0.1)
            	lock_b.release()		# 锁的释放
        	lock_a.release()

class MyThread2(Thread):		# 自定义线程2
    def run(self):
        if lock_b.acquire():
            print(self.name + '获得了B锁')
            sleep(0.1)
            if lock_a.acquire(timeout=5):
                print(self.name + '获得了A锁')
                sleep(0.1)
            	lock_a.release()
        	lock_b.release()

if __name__ == '__main__':
    p1 = MyThread1(name='任务1')		# 创建线程
    p2 = MyThread2(name='任务2')
    p1.start()				# 开启线程
    p2.start()

进程的生成者与消费者

进程的生成者与消费者问题也就是两个进程之间的通信。Python的queue模块中提供了 FIFO(先入先出)队列Queue,LIFO(后入先出)队列LifoQueue,优先级队列PriorityQueue。 这些队列都实现了锁原理(可以理解为原子操作,即要么不做,要么就做完)

import threading
import queue
import random
import time

def producer(queue):			# 生产者
    i = 0
    while i < 10:
        num = random.randint(1, 100)
        queue.put(num)			# 放入数据
        print('生产者生产了{}'.format(num))
        i += 1
    queue.put(None)


def consumer(queue):			# 消费者
    while True:
        item = queue.get()		# 取出数据
        if item is None:
            break
        print('消费者消费了{}'.format(item))

if __name__ == '__main__':
    queue = queue.Queue(3)		# 创建队列
    t1 = threading.Thread(target=producer, name='生产者', args=(queue,))	# 创建线程
    t2 = threading.Thread(target=consumer, name='消费者', args=(queue,))
    t1.start()		# 开启线程
    t2.start()
    print(t1.name)	# 打印线程名
    print(t2.name)

四、协程

协程是一种用户态的轻量级线程,且协程可使用生成器来实现。有耗时操作(爬虫、网络请求、文件读写等)时才考虑使用协程

生成器

import threading
import time

def task1():			# 任务1
    for i in range(3):
        print('A{}'.format(i))
        yield
        time.sleep(0.2)


def task2():			# 任务2
    for i in range(3):
        print('B{}'.format(i))
        yield
        time.sleep(0.2)

if __name__ == '__main__':
    g1 = task1()		# 生成器
    g2 = task2()

    while True:
        try:
            g1.__next__()		# 使用生成器
            g2.__next__()
        except:
            break			# 当有错误时退出循环

greenlet

greenlet实现了协程,它可以使你在任意函数之间随意切换

pip install greenlet

from greenlet import greenlet

def test1():
    print("111")
    g2.switch()		# 切换函数
    print("333")
    g1.switch()		# 切换函数

def test2():
    print("222")
    g1.switch()		# 切换函数

g1 = greenlet(test1)
g2 = greenlet(test2)
g1.switch()			# 切换函数

gevent

  • gevent是一个第三方库,gevent实现了协程,而在gevent中用到的主要模式是greenlet。其原理是当一个greentlet遇到IO(指的是input output输入输出,比如网络、文件操作等)操作时,就自动切换到其他的greenlet等到IO完成,再适当的时候切换回来继续执行

pip install gevent

  • 由于IO操作非常耗时,经常使程序处于等待状态,有了gevent我们自动切换协程,就保证总有greenlet在运行,而不是等待IO
  • gevent猴子补丁一起出现
from gevent import monkey
import time

gevent.monkey.patch_all()		# 猴子补丁,把所有的耗时操作在底层自动进行了替换,如下面的time()

def task1():			# 任务1
    for i in range(3):
        print('A' + str(i))
        time.sleep(0.1)			# 这里的time()不是真正的time()而是经过替换的time()


def task2():			# 任务2
    for i in range(3):
        print('B' + str(i))
        time.sleep(0.1)


if __name__ == '__main__':
    g1 = gevent.spawn(task1)		# 使用gevent
    g2 = gevent.spawn(task2)

    g1.join()		# 注意这里与Pool有点类似,他的生命周期依赖于主进程,如果没有join()的话gevent将*结束
    g1.join()		# gevent内部有joinall()可以代替join()

生成器 > greenlet > gevent

还不了解的可以点击这里

五、杂

全局解释器锁

具体查看这里传送门

本文只用于个人学习与记录

本文地址:https://blog.csdn.net/weixin_45969777/article/details/110310335

相关标签: python