Python进阶语法记录-OOP、IO、网络
面向对象
类和实例
以Student类为例,在Python中,定义类是通过class
关键字:
1 | class Student(object): |
class
后面紧接着是类名,即Student
,类名通常是大写开头的单词,紧接着是(object)
,表示该类是从哪个类继承下来的,可以省略。通常,如果没有合适的继承类,就使用object
类,这是所有类最终都会继承的类。
定义好了Student
类,就可以根据Student
类创建出Student
的实例,创建实例是通过类名+()实现的:
1 | bart = Student() |
可以看到,变量bart
指向的就是一个Student
的实例,后面的0x10a67a590
是内存地址,每个object的地址都不一样,而Student
本身则是一个类。
可以使用点号 . 来访问对象的属性,同时可以自由地给一个实例变量绑定属性,比如,给实例bart
绑定一个name
属性:
1 | 'Bart Simpson' bart.name = |
由于类可以起到模板的作用,因此,可以在创建实例的时候,把一些我们认为必须绑定的属性强制填写进去。通过定义一个特殊的__init__
方法,在创建实例的时候,就把name
,score
等属性绑上去:
1 | class Student(object): |
注意到__init__
方法的第一个参数永远是self
,表示创建的实例本身,因此,在__init__
方法内部,就可以把各种属性绑定到self
,因为self
就指向创建的实例本身。
有了__init__
方法,在创建实例的时候,就不能传入空的参数了,必须传入与__init__
方法匹配的参数,但self
不需要传,Python解释器自己会把实例变量传进去:
1 | 'Bart Simpson', 59) bart = Student( |
和普通的函数相比,在类中定义的函数只有一点不同,就是第一个参数永远是实例变量self
,并且,调用时,不用传递该参数。除此之外,类的方法和普通函数没有什么区别,所以,你仍然可以用默认参数、可变参数、关键字参数和命名关键字参数。
self代表类的实例,而非类
类的方法与普通的函数只有一个特别的区别——它们必须有一个额外的第一个参数名称, 按照惯例它的名称是 self。
从执行结果可以很明显的看出,self 代表的是类的实例,代表当前对象的地址,而 self.__class__
则指向类。
self 不是 python 关键字,我们把他换成 runoop 也是可以正常执行的。
类属性与方法
类的私有属性
__private_attrs
:两个下划线开头,声明该属性为私有,不能在类的外部被使用或直接访问。在类内部的方法中使用时 self.__private_attrs。
类的私有方法
__private_method
:两个下划线开头,声明该方法为私有方法,不能在类的外部调用。在类的内部调用 self.__private_methods
单下划线、双下划线、头尾双下划线说明:
__foo__
: 定义的是特殊方法,一般是系统定义名字 ,类似 init() 之类的。_foo
: 以单下划线开头的表示的是 protected 类型的变量,即保护类型只能允许其本身与子类进行访问,不能用于 from module import *__foo
: 双下划线的表示的是私有类型(private)的变量, 只能是允许这个类本身进行访问了。
数据封装
既然Student
实例本身就拥有数据,要访问这些数据,就没有必要从外面的函数去访问,可以直接在Student
类的内部定义访问数据的函数,这样,就把“数据”给封装起来了。这些封装数据的函数是和Student
类本身是关联起来的,我们称之为类的方法:
1 | class Student(object): |
要定义一个方法,除了第一个参数是self
外,其他和普通函数一样。要调用一个方法,只需要在实例变量上直接调用,除了self
不用传递,其他参数正常传入:
1 | bart.print_score() |
这样一来,我们从外部看Student
类,就只需要知道,创建实例需要给出name
和score
,而如何打印,都是在Student
类的内部定义的,这些数据和逻辑被“封装”起来了,调用很容易,但却不用知道内部实现的细节。
python对象销毁(垃圾回收)
Python 使用了引用计数这一简单技术来跟踪和回收垃圾。一个内部跟踪变量,称为一个引用计数器。在 Python 内部记录着所有使用中的对象各有多少引用。
当对象被创建时, 就创建了一个引用计数, 当这个对象不再需要时, 也就是说, 这个对象的引用计数变为0 时, 它被垃圾回收。但是回收不是"立即"的, 由解释器在适当的时机,将垃圾对象占用的内存空间回收。
垃圾回收机制不仅针对引用计数为0的对象,同样也可以处理循环引用的情况。循环引用指的是,两个对象相互引用,但是没有其他变量引用他们。这种情况下,仅使用引用计数是不够的。Python 的垃圾收集器实际上是一个引用计数器和一个循环垃圾收集器。作为引用计数的补充, 垃圾收集器也会留心被分配的总量很大(即未通过引用计数销毁的那些)的对象。 在这种情况下, 解释器会暂停下来, 试图清理所有未引用的循环。
析构函数 __del__
,__del__
在对象销毁的时候被调用,当对象不再被使用时,__del__
方法运行
注意:通常你需要在单独的文件中定义一个类。
继承和多态
在OOP程序设计中,当我们定义一个class的时候,可以从某个现有的class继承,新的class称为子类(Subclass),而被继承的class称为基类、父类或超类(Base class、Super class)。
比如,我们已经编写了一个名为Animal
的class,有一个run()
方法可以直接打印:
1 | class Animal(object): |
当我们需要编写Dog
和Cat
类时,就可以直接从Animal
类继承:
1 | class Dog(Animal): |
对于Dog
来说,Animal
就是它的父类,对于Animal
来说,Dog
就是它的子类。Cat
和Dog
类似。
继承有什么好处?最大的好处是子类获得了父类的全部功能。由于Animial
实现了run()
方法,因此,Dog
和Cat
作为它的子类,什么事也没干,就自动拥有了run()
方法:
1 | dog = Dog() |
运行结果如下:
1 | Animal is running... |
当然,也可以对子类增加一些方法,比如Dog类:
1 | class Dog(Animal): |
继承的第二个好处需要我们对代码做一点改进。你看到了,无论是Dog
还是Cat
,它们run()
的时候,显示的都是Animal is running...
,符合逻辑的做法是分别显示Dog is running...
和Cat is running...
,因此,对Dog
和Cat
类改进如下:
1 | class Dog(Animal): |
再次运行,结果如下:
1 | Dog is running... |
当子类和父类都存在相同的run()
方法时,我们说,子类的run()
覆盖了父类的run()
,在代码运行的时候,总是会调用子类的run()
。这样,我们就获得了继承的另一个好处:多态。
要理解什么是多态,我们首先要对数据类型再作一点说明。当我们定义一个class的时候,我们实际上就定义了一种数据类型。我们定义的数据类型和Python自带的数据类型,比如str、list、dict没什么两样:
1 | a = list() # a是list类型 |
判断一个变量是否是某个类型可以用isinstance()
判断:
1 | isinstance(a, list) |
看来a
、b
、c
确实对应着list
、Animal
、Dog
这3种类型。
但是等等,试试:
1 | isinstance(c, Animal) |
看来c
不仅仅是Dog
,c
还是Animal
!
不过仔细想想,这是有道理的,因为Dog
是从Animal
继承下来的,当我们创建了一个Dog
的实例c
时,我们认为c
的数据类型是Dog
没错,但c
同时也是Animal
也没错,Dog
本来就是Animal
的一种!
所以,在继承关系中,如果一个实例的数据类型是某个子类,那它的数据类型也可以被看做是父类。但是,反过来就不行:
1 | b = Animal() |
Dog
可以看成Animal
,但Animal
不可以看成Dog
。
要理解多态的好处,我们还需要再编写一个函数,这个函数接受一个Animal
类型的变量:
1 | def run_twice(animal): |
当我们传入Animal
的实例时,run_twice()
就打印出:
1 | run_twice(Animal()) |
当我们传入Dog
的实例时,run_twice()
就打印出:
1 | >>> run_twice(Dog()) |
当我们传入Cat
的实例时,run_twice()
就打印出:
1 | >>> run_twice(Cat()) |
看上去没啥意思,但是仔细想想,现在,如果我们再定义一个Tortoise
类型,也从Animal
派生:
1 | class Tortoise(Animal): |
当我们调用run_twice()
时,传入Tortoise
的实例:
1 | >>> run_twice(Tortoise()) |
你会发现,新增一个Animal
的子类,不必对run_twice()
做任何修改,实际上,任何依赖Animal
作为参数的函数或者方法都可以不加修改地正常运行,原因就在于多态。
多态的好处就是,当我们需要传入Dog
、Cat
、Tortoise
……时,我们只需要接收Animal
类型就可以了,因为Dog
、Cat
、Tortoise
……都是Animal
类型,然后,按照Animal
类型进行操作即可。由于Animal
类型有run()
方法,因此,传入的任意类型,只要是Animal
类或者子类,就会自动调用实际类型的run()
方法,这就是多态的意思:
对于一个变量,我们只需要知道它是Animal
类型,无需确切地知道它的子类型,就可以放心地调用run()
方法,而具体调用的run()
方法是作用在Animal
、Dog
、Cat
还是Tortoise
对象上,由运行时该对象的确切类型决定,这就是多态真正的威力:调用方只管调用,不管细节,而当我们新增一种Animal
的子类时,只要确保run()
方法编写正确,不用管原来的代码是如何调用的。这就是著名的“开闭”原则:
对扩展开放:允许新增Animal
子类;
对修改封闭:不需要修改依赖Animal
类型的run_twice()
等函数。
继承还可以一级一级地继承下来,就好比从爷爷到爸爸、再到儿子这样的关系。而任何类,最终都可以追溯到根类object,这些继承关系看上去就像一颗倒着的树。比如如下的继承树:
1 | ┌───────────────┐ |
静态语言 vs 动态语言
对于静态语言(例如Java)来说,如果需要传入Animal
类型,则传入的对象必须是Animal
类型或者它的子类,否则,将无法调用run()
方法。
对于Python这样的动态语言来说,则不一定需要传入Animal
类型。我们只需要保证传入的对象有一个run()
方法就可以了:
1 | class Timer(object): |
这就是动态语言的“鸭子类型”,它并不要求严格的继承体系,一个对象只要“看起来像鸭子,走起路来像鸭子”,那它就可以被看做是鸭子。
Python的“file-like object“就是一种鸭子类型。对真正的文件对象,它有一个read()
方法,返回其内容。但是,许多对象,只要有read()
方法,都被视为“file-like object“。许多函数接收的参数就是“file-like object“,你不一定要传入真正的文件对象,完全可以传入任何实现了read()
方法的对象。
实例属性和类属性
由于Python是动态语言,根据类创建的实例可以任意绑定属性。动态绑定允许我们在程序运行的过程中动态给class加上功能,这在静态语言中很难实现。
给实例绑定属性的方法是通过实例变量,或者通过self
变量:
1 | class Student(object): |
但是,如果Student
类本身需要绑定一个属性呢?可以直接在class中定义属性,这种属性是类属性,归Student
类所有:
1 | class Student(object): |
当我们定义了一个类属性后,这个属性虽然归类所有,但类的所有实例都可以访问到。
实例属性属于各个实例所有,互不干扰;
类属性属于类所有,所有实例共享一个属性;
不要对实例属性和类属性使用相同的名字,否则将产生难以发现的错误。
使用__slots__
但是,如果我们想要限制实例的属性怎么办?比如,只允许对Student实例添加name
和age
属性。
为了达到限制的目的,Python允许在定义class的时候,定义一个特殊的__slots__
变量,来限制该class实例能添加的属性:
1 | class Student(object): |
然后,我们试试:
1 | # 创建新的实例 s = Student() |
由于'score'
没有被放到__slots__
中,所以不能绑定score
属性,试图绑定score
将得到AttributeError
的错误。
使用__slots__
要注意,__slots__
定义的属性仅对当前类实例起作用,对继承的子类是不起作用的:
1 | class GraduateStudent(Student): |
除非在子类中也定义__slots__
,这样,子类实例允许定义的属性就是自身的__slots__
加上父类的__slots__
。
使用@property
在绑定属性时,如果我们直接把属性暴露出去,虽然写起来很简单,但是,没办法检查参数,导致可以把成绩随便改:
1 | s = Student() |
这显然不合逻辑。为了限制score的范围,可以通过一个set_score()
方法来设置成绩,再通过一个get_score()
来获取成绩,这样,在set_score()
方法里,就可以检查参数:
1 | class Student(object): |
现在,对任意的Student实例进行操作,就不能随心所欲地设置score了:
1 | s = Student() |
但是,上面的调用方法又略显复杂,没有直接用属性这么直接简单。
有没有既能检查参数,又可以用类似属性这样简单的方式来访问类的变量呢?对于追求完美的Python程序员来说,这是必须要做到的!
还记得装饰器(decorator)可以给函数动态加上功能吗?对于类的方法,装饰器一样起作用。Python内置的@property
装饰器就是负责把一个方法变成属性调用的:
1 | class Student(object): |
@property
的实现比较复杂,我们先考察如何使用。把一个getter方法变成属性,只需要加上@property
就可以了,此时,@property
本身又创建了另一个装饰器@score.setter
,负责把一个setter方法变成属性赋值,于是,我们就拥有一个可控的属性操作:
1 | s = Student() |
注意到这个神奇的@property
,我们在对实例属性操作的时候,就知道该属性很可能不是直接暴露的,而是通过getter和setter方法来实现的。
还可以定义只读属性,只定义getter方法,不定义setter方法就是一个只读属性:
1 | class Student(object): |
上面的birth
是可读写属性,而age
就是一个只读属性,因为age
可以根据birth
和当前时间计算出来。
要特别注意:属性的方法名不要和实例变量重名。例如,以下的代码是错误的:
1 | class Student(object): |
这是因为调用s.birth
时,首先转换为方法调用,在执行return self.birth
时,又视为访问self
的属性,于是又转换为方法调用,造成无限递归,最终导致栈溢出报错RecursionError
。
枚举类
当我们需要定义常量时,一个办法是用大写变量通过整数来定义,例如月份:
1 | JAN = 1 |
好处是简单,缺点是类型是int
,并且仍然是变量。
更好的方法是为这样的枚举类型定义一个class类型,然后,每个常量都是class的一个唯一实例。Python提供了Enum
类来实现这个功能:
1 | from enum import Enum |
这样我们就获得了Month
类型的枚举类,可以直接使用Month.Jan
来引用一个常量,或者枚举它的所有成员:
1 | for name, member in Month.__members__.items(): |
value
属性则是自动赋给成员的int
常量,默认从1
开始计数。
如果需要更精确地控制枚举类型,可以从Enum
派生出自定义类:
1 | from enum import Enum, unique |
@unique
装饰器可以帮助我们检查保证没有重复值。
访问这些枚举类型可以有若干种方法:
1 | day1 = Weekday.Mon |
可见,既可以用成员名称引用枚举常量,又可以直接根据value的值获得枚举常量。
IO
IO编程中,Stream(流)是一个很重要的概念,可以把流想象成一个水管,数据就是水管里的水,但是只能单向流动。Input Stream就是数据从外面(磁盘、网络)流进内存,Output Stream就是数据从内存流到外面去。
由于CPU和内存的速度远远高于外设的速度,所以,在IO编程中,就存在速度严重不匹配的问题。举个例子来说,比如要把100M的数据写入磁盘,CPU输出100M的数据只需要0.01秒,可是磁盘要接收这100M数据可能需要10秒,怎么办呢?有两种办法:
第一种是CPU等着,也就是程序暂停执行后续代码,等100M的数据在10秒后写入磁盘,再接着往下执行,这种模式称为同步IO;
另一种方法是CPU不等待,只是告诉磁盘,“您老慢慢写,不着急,我接着干别的事去了”,于是,后续代码可以立刻接着执行,这种模式称为异步IO。
同步和异步的区别就在于是否等待IO执行的结果。好比你去麦当劳点餐,你说“来个汉堡”,服务员告诉你,对不起,汉堡要现做,需要等5分钟,于是你站在收银台前面等了5分钟,拿到汉堡再去逛商场,这是同步IO。
你说“来个汉堡”,服务员告诉你,汉堡需要等5分钟,你可以先去逛商场,等做好了,我们再通知你,这样你可以立刻去干别的事情(逛商场),这是异步IO。
很明显,使用异步IO来编写程序性能会远远高于同步IO,但是异步IO的缺点是编程模型复杂。想想看,你得知道什么时候通知你“汉堡做好了”,而通知你的方法也各不相同。如果是服务员跑过来找到你,这是回调模式,如果服务员发短信通知你,你就得不停地检查手机,这是轮询模式。总之,异步IO的复杂度远远高于同步IO。
操作IO的能力都是由操作系统提供的,每一种编程语言都会把操作系统提供的低级C接口封装起来方便使用,Python也不例外。我们后面会详细讨论Python的IO编程接口。
注意,本章的IO编程都是同步模式。
文件读写
读写文件是最常见的IO操作。Python内置了读写文件的函数,用法和C是兼容的。
读写文件前,我们先必须了解一下,在磁盘上读写文件的功能都是由操作系统提供的,现代操作系统不允许普通的程序直接操作磁盘,所以,读写文件就是请求操作系统打开一个文件对象(通常称为文件描述符),然后,通过操作系统提供的接口从这个文件对象中读取数据(读文件),或者把数据写入这个文件对象(写文件)。
打开和关闭文件
Python 提供了必要的函数和方法进行默认情况下的文件基本操作。你可以用 file 对象做大部分的文件操作。
open 函数
你必须先用Python内置的open()函数打开一个文件,创建一个file对象,相关的方法才可以调用它进行读写。
1 | file = open(file_name [, access_mode][, buffering]) |
各个参数的细节如下:
file_name
:file_name变量是一个包含了你要访问的文件名称的字符串值。access_mode
:access_mode
决定了打开文件的模式:只读,写入,追加等。所有可取值见如下的完全列表。这个参数是非强制的,默认文件访问模式为只读®。- ``buffering
:**如果
buffering`的值被设为0,就不会有寄存。如果buffering的值取1,访问文件时会寄存行。如果将buffering的值设为大于1的整数,表明了这就是的寄存区的缓冲大小。如果取负值,寄存区的缓冲大小则为系统默认。**
实际上参数还有很多,包括:
1 | (file: Union[str, bytes, PathLike[str], PathLike[bytes], int], mode: str, buffering: int, encoding: Optional[str], errors: Optional[str], newline: Optional[str], closefd: bool, opener: Optional[(str, int) -> int]) -> IO |
不同模式打开文件的完全列表:
模式 | 描述 |
---|---|
t | 文本模式 (默认)。 |
x | 写模式,新建一个文件,如果该文件已存在则会报错。 |
b | 二进制模式。 |
+ | 打开一个文件进行更新(可读可写)。 |
U | 通用换行模式(不推荐)。 |
r | 以只读方式打开文件。文件的指针将会放在文件的开头。这是默认模式。 |
rb | 以二进制格式打开一个文件用于只读。文件指针将会放在文件的开头。这是默认模式。一般用于非文本文件如图片等。 |
r+ | 打开一个文件用于读写。文件指针将会放在文件的开头。 |
rb+ | 以二进制格式打开一个文件用于读写。文件指针将会放在文件的开头。一般用于非文本文件如图片等。 |
w | 打开一个文件只用于写入。如果该文件已存在则打开文件,并从开头开始编辑,即原有内容会被删除。如果该文件不存在,创建新文件。 |
wb | 以二进制格式打开一个文件只用于写入。如果该文件已存在则打开文件,并从开头开始编辑,即原有内容会被删除。如果该文件不存在,创建新文件。一般用于非文本文件如图片等。 |
w+ | 打开一个文件用于读写。如果该文件已存在则打开文件,并从开头开始编辑,即原有内容会被删除。如果该文件不存在,创建新文件。 |
wb+ | 以二进制格式打开一个文件用于读写。如果该文件已存在则打开文件,并从开头开始编辑,即原有内容会被删除。如果该文件不存在,创建新文件。一般用于非文本文件如图片等。 |
a | 打开一个文件用于追加。如果该文件已存在,文件指针将会放在文件的结尾。也就是说,新的内容将会被写入到已有内容之后。如果该文件不存在,创建新文件进行写入。 |
ab | 以二进制格式打开一个文件用于追加。如果该文件已存在,文件指针将会放在文件的结尾。也就是说,新的内容将会被写入到已有内容之后。如果该文件不存在,创建新文件进行写入。 |
a+ | 打开一个文件用于读写。如果该文件已存在,文件指针将会放在文件的结尾。文件打开时会是追加模式。如果该文件不存在,创建新文件用于读写。 |
ab+ | 以二进制格式打开一个文件用于追加。如果该文件已存在,文件指针将会放在文件的结尾。如果该文件不存在,创建新文件用于读写。 |
下图很好的总结了这几种模式:
模式 | r | r+ | w | w+ | a | a+ |
---|---|---|---|---|---|---|
读 | + | + | + | + | ||
写 | + | + | + | + | + | |
创建 | + | + | + | + | ||
覆盖 | + | + | ||||
指针在开始 | + | + | + | + | ||
指针在结尾 | + | + |
File对象的属性
一个文件被打开后,你有一个file对象,你可以得到有关该文件的各种信息。
以下是和file对象相关的所有属性的列表:
属性 | 描述 |
---|---|
file.closed | 返回true如果文件已被关闭,否则返回false。 |
file.mode | 返回被打开文件的访问模式。 |
file.name | 返回文件的名称。 |
file.softspace | 如果用print输出后,必须跟一个空格符,则返回false。否则返回true。 |
如下实例:
1 | fo = open("foo.txt", "w") |
以上实例输出结果:
1 | 文件名: foo.txt |
write()方法
write()方法可将任何字符串写入一个打开的文件。需要重点注意的是,Python字符串可以是二进制数据,而不是仅仅是文字。write()方法不会在字符串的结尾添加换行符(‘\n’):
1 | fileObject.write(string) |
在这里,被传递的参数是要写入到已打开文件的内容。
例子:
1 | fo = open("foo.txt", "w") |
上述方法会创建foo.txt文件,并将收到的内容写入该文件,并最终关闭文件。如果你打开这个文件,将看到以下内容:
1 | $ cat foo.txt |
read()方法
read()
方法从一个打开的文件中读取一个字符串。需要重点注意的是,Python字符串可以是二进制数据,而不是仅仅是文字。
1 | fileObject.read([count]) |
在这里,被传递的参数是要从已打开文件中读取的字节计数。该方法从文件的开头开始读入,如果没有传入count,它会尝试尽可能多地读取更多的内容,很可能是直到文件的末尾。
1 | fo = open("foo.txt", "r+") |
以上实例输出结果:
1 | 读取的字符串是 : www.runoob |
由于文件读写时都有可能产生IOError
,一旦出错,后面的f.close()
就不会调用。所以,为了保证无论是否出错都能正确地关闭文件,我们可以使用try ... finally
来实现:
1 | try: |
但是每次都这么写实在太繁琐,所以,Python引入了with
语句来自动帮我们调用close()
方法:
1 | with open('/path/to/file', 'r') as f: |
这和前面的try ... finally
是一样的,但是代码更佳简洁,并且不必调用f.close()
方法。
调用read()
会一次性读取文件的全部内容,如果文件有10G,内存就爆了,所以,要保险起见,可以反复调用read(size)
方法,每次最多读取size个字节的内容。另外,调用readline()
可以每次读取一行内容,调用readlines()
一次读取所有内容并按行返回list
。因此,要根据需要决定怎么调用。
如果文件很小,read()
一次性读取最方便;如果不能确定文件大小,反复调用read(size)
比较保险;如果是配置文件,调用readlines()
最方便:
1 | for line in f.readlines(): |
close()方法
File 对象的close()
方法刷新缓冲区里任何还没写入的信息,并关闭该文件,这之后便不能再进行写入。
当一个文件对象的引用被重新指定给另一个文件时,Python 会关闭之前的文件。用 close()方法关闭文件是一个很好的习惯。
1 | fileObject.close() |
另:
字符编码
要读取非UTF-8编码的文本文件,需要给open()
函数传入encoding
参数,例如,读取GBK编码的文件:
1 | open('/Users/michael/gbk.txt', 'r', encoding='gbk') f = |
遇到有些编码不规范的文件,你可能会遇到UnicodeDecodeError
,因为在文本文件中可能夹杂了一些非法编码的字符。遇到这种情况,open()
函数还接收一个errors
参数,表示如果遇到编码错误后如何处理。最简单的方式是直接忽略:
1 | open('/Users/michael/gbk.txt', 'r', encoding='gbk', errors='ignore') f = |
StringIO和BytesIO
StringIO
和BytesIO
是在内存中操作string和bytes的方法,使得和读写文件具有一致的接口。
StringIO
很多时候,数据读写不一定是文件,也可以在内存中读写。StringIO顾名思义就是在内存中读写str。
要把str写入StringIO,我们需要先创建一个StringIO,然后,像文件一样写入即可:
1 | from io import StringIO |
getvalue()
方法用于获得写入后的str。
要读取StringIO,可以用一个str初始化StringIO,然后,像读文件一样读取:
1 | from io import StringIO |
BytesIO
StringIO操作的只能是str,如果要操作二进制数据,就需要使用BytesIO。
BytesIO实现了在内存中读写bytes,我们创建一个BytesIO,然后写入一些bytes:
1 | from io import BytesIO |
请注意,写入的不是str,而是经过UTF-8编码的bytes。
和StringIO类似,可以用一个bytes初始化BytesIO,然后,像读文件一样读取:
1 | from io import BytesIO |
文件定位
tell()
方法告诉你文件内的当前位置, 换句话说,下一次的读写会发生在文件开头这么多字节之后。
seek(offset [,from])
方法改变当前文件内的位置。Offset变量表示要移动的字节数。From变量指定开始移动字节的参考位置。
如果from被设为0,这意味着将文件的开头作为移动字节的参考位置。如果设为1,则使用当前的位置作为参考位置。如果它被设为2,那么该文件的末尾将作为参考位置。
1 | fo = open("foo.txt", "r+") |
以上实例输出结果:
1 | 读取的字符串是 : www.runoob |
重命名和删除文件
Python的os模块提供了帮你执行文件处理操作的方法,比如重命名和删除文件。
要使用这个模块,你必须先导入它,然后才可以调用相关的各种功能。
rename() 方法
rename()
方法需要两个参数,当前的文件名和新文件名。
1 | os.rename(current_file_name, new_file_name) |
remove()方法
你可以用remove()
方法删除文件,需要提供要删除的文件名作为参数。
1 | os.remove(file_name) |
Python里的目录:
所有文件都包含在各个不同的目录下,不过Python也能轻松处理。os模块有许多方法能帮你创建,删除和更改目录。
mkdir()方法
可以使用os模块的mkdir()
方法在当前目录下创建新的目录们。你需要提供一个包含了要创建的目录名称的参数。
1 | os.mkdir("newdir") |
chdir()方法
可以用chdir()
方法来改变当前的目录。chdir()
方法需要的一个参数是你想设成当前目录的目录名称。
1 | os.chdir("newdir") |
下例将进入"/home/newdir"目录。
1 | import os |
getcwd()方法:
getcwd()
方法显示当前的工作目录。
1 | os.getcwd() |
1 | import os |
rmdir()方法
rmdir()
方法删除目录,目录名称以参数传递。
在删除这个目录之前,它的所有内容应该先被清除。
1 | os.rmdir('dirname') |
以下是删除" /tmp/test"目录的例子。目录的完全合规的名称必须被给出,否则会在当前目录下搜索该目录。
1 | import os |
网络:从0到1,Python网络编程的入门之路
有删改但不多,只是加了一些标题用来划分不同内容以及一些加粗标注,写的挺好的,不过看之前建议看看《图解TCP/IP》这类的基础知识书。
最近在学习Python网络编程时看了一些相关的文章,发现大多数要么讲的晦涩难懂,要么讲的比较浅显,我就想为什么不在学习的过程中写一篇心得呢,于是有了这篇文章。我相信技术不全是冰冷的,从人的角度出发,才能更好地领悟编程的乐趣,本文将尝试以简洁的文字分享如何入门Python中的网络编程。
在Python世界里,喜欢用Python做爬虫的人不在少数,那么在请求页面的过程中发生了什么呢?
网络请求过程:TCP/IP的小例子
现在编写一个最简单的Client/Server程序:
首先执行下面的命令开启一个监听8000端口的HTTP服务器:
1
2python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 ...接着编写一个程序,来对这个服务器发起HTTP请求:
1
2
3import requests
r = requests.get('http://127.0.0.1:8000/')
print(r)再执行这个程序:
1
2bash-3.2$ python test.py
<Response [200]>
可以看到,服务器返回了一个200成功响应。
好,现在我们来总结请求过程:
- 客户端向服务器端发起了一个HTTP(GET)请求。
- 服务器端向客户端返回了一个HTTP(200)响应。
这是我们能看到的最抽象的过程,下面再用tcpdump细看发生了什么:
在命令行用tcpdump来监听本地网卡的tcp连接,
1 | tcpdump -i lo0 port 8000 |
或者你也可以用-w参数把信息写出到文件,再通过wireshark来观察结果:
1 | tcpdump -i lo0 port 8000 -w test.cap |
现在执行程序:
1 | bash-3.2$ python test.py |
不出意外的话,我们就能观察到tcpdump输出类似如下的结果:
1 | tcpdump: verbose output suppressed, use -v or -vv for full protocol decode |
通过结果能看到:
- 客户端发起一个SYN报文,向服务器请求建立一个TCP连接。
- 服务器端返回一个SYN+ACK报文,表示服务器收到了客户端传来的请求,并同意与客户端建立TCP连接。
- 客户端返回一个ACK报文,表示已经知道服务器同意建立TCP连接,这时候双方开始通信。
- 客户端和服务器端不断地交换信息,接收报文,返回应答。
- 最后数据传输完毕,服务器发起一个FIN报文,表示要结束通信,客户端返回一个ACK应答,接着又发送一个FIN报文,最后服务器端返回一个ACK应答,此时连接过程结束。
仔细一想,这个过程跟现实世界中的“打电话”是非常相似的,与之代替的不就是拨打电话、建立连接、确认应答、交换信息、关闭连接吗,我们经常说TCP是面向连接的也是这个道理。
现在再来看服务器端的状态,通过lsof命令来查看绑定8000端口的描述符信息:
1 | lsof -n -i:8000 |
通过结果可以观察到服务器的进程的一些信息,服务器进程处于LISTEN阶段,说明服务器处于保持着监听连接的状态:
现在用刚才的例子来解释TCP中状态迁移的概念,这时候,如果从客户端到来一个请求:
- 服务器端接收到客户端的SYN报文,返回SYN+ACK报文,服务器端进入SYN_RCVD状态。
- 服务器端收到客户端返回的ACK应答后,连接建立,进入ESTABLISHED状态。
- 服务器端的数据传输完毕,给客户端发送FIN报文,进入FIN_WAIT_1状态。
- 服务器端接收到客户端返回的ACK应答后,进入FIN_WAIT_2状态。
- 服务器端接收到客户端的FIN报文,接着返回一个ACK应答,等待连接关闭,进入TIME_WAIT状态。
- 服务器端经过2MSL时间后进入CLOSED状态,此时连接关闭。
至于客户端,在每个阶段也有各自的状态,下图表示了TCP状态迁移的过程:
下面来看TCP/IP的四层模型:
- 应用层,在这一层上的有HTTP、DNS、FTP、SSH等。
- 传输层,在这一层上的有TCP、UDP等。
- 网络层,在这一层上的有IP、ARP等。
- 网络接口层,在这一层上的有以太网、PPP等。
编者注:TCP/IP模型是由 OSI 模型演化而来,TCP/IP 模型将 OSI 模型由七层简化为五层(一开始为四层),应用层、表示层、会话层统一为应用层。
在上面的程序中,客户端与服务器端的通信都要经过这四个层来打交道。那么这段Python程序是如何操作连接的建立和关闭以及数据的传输呢?答案是通过socket提供的一系列方法。
socket
socket是一种IPC方法,它使得同一主机或不同主机的应用程序能交换数据,socket在上图中处于第三层和第四层之间,所以可以把socket理解为在传输层和应用层之间的一组通信接口,或者是一个抽象的通信设备,应用程序借助socket就能方便地与其他应用程序进行交流。
现在把客户端的代码简化为用socket表现的最简形式:
1 | import socket |
是不是感觉跟上面TCP的连接过程十分相似?只是用代码的方式把这一具现过程给抽象表现出来罢了。
再看服务器端的最简化代码:
1 | import socket |
过程同样很简单,总结一下它们的过程:
服务器端:
- 调用
socket.socket
建立一个socket对象,指定域(domain)和协议(protocol),此时一个文件描述符会绑定到这个socket对象。 - 调用
sock.setsockopt
设置这个socket选项,本例中把socket.SO_REUSEADDR设置为1,表示服务器端进程终止后,操作系统会为它绑定的端口保留一段时间,以防其他进程在它结束后抢占这个端口。 - 调用
sock.bind
为这个socket对象绑定到一个地址上,它需要一个主机地址和端口组成的元组作为参数。 - 调用
sock.listen
通知系统开始侦听来自客户端的连接,参数是在队列中最大的未决连接数量。 - 调用
sock.accept
阻塞调用直至返回一个元组,里面包含了用于与客户端进行对话的socket对象以及客户端的地址信息。 - 调用
cli_sock.recv
方法接受来自客户端发来的数据,在这个例子中拿到的是b’GET / HTTP/1.1\r\nHost: 127.0.0.1:8000\r\n\r\n’
。 - 调用
cli_sock.send
方法把数据发送给客户端。 - 调用
cli_sock.close
结束连接。
客户端:
- 调用
socket.socket
建立一个socket对象,指定域(domain)和协议(protocol),此时一个文件描述符会绑定到这个socket对象。 - 调用
sock.connect
通过指定的主机和端口连接到对端的服务器进程。 - 调用
sock.send
给服务器端发送数据。 - 调用
sock.recv
接收服务器端发来的数据。 - 调用
sock.close
关闭连接。
socket的数据是通过内核维护的读写缓冲区来获取的,如下图中的表示:
每次从缓冲区写入或读入数据都会发起标准的系统调用,如:
1 | int read(fd, buf, bufsize); |
来进行数据的写或读。当然对于大文件来说,执行多次read、write等系统调用的耗费是相当可观的,这时候就要用到sendfile系统调用:
socket的域
在上面的程序中我们建立socket对象都是使用了AF_INET这个参数,它表示这个socket是通过IPV4的方式进行通信的。
这种socket也被叫做Internet Domain Socket,它定义的地址形式是这样的:
1 | struct in_addr { |
与之相对的,还有一种socket类型为Unix Domain Socket,它通过AF_UNIX这个参数来创建。它定义的地址形式是这样的:
1 | struct sockaddr_un { |
当用Unix Domain Socket发起bind操作时,会在文件系统中创建一个条目,socket和路径名为一对一关系。一般来说,Unix Domain Socket只针对在同一主机下应用程序下的网络通信,它还有一个特点是可以使用目录权限来控制socket的访问。(例如我们使用mysql时用到的mysql.sock
就是使用unix domain sokcet
的载体)
socket的协议
在protocol上我们使用了SOCK_STREAM,表示这是个流式套接字(即TCP),除此之外我们还可以把它指定为SOCK_DGRAM,表示这是个数据报套接字(即UDP)。
TCP跟UDP的一些基本区别:
- TCP面向连接,UDP不面向连接。
- TCP面向字节,不存在消息边界,可能存在粘包问题。UDP则面向报文。
- TCP会尽力保证数据的可靠交付,而UDP默认不做保证。
- TCP头部20字节,UDP头部8字节。
编者注:如何理解1和3:
TCP协议是一种可靠的通信协议,它要求传输的过程是可靠的,因此需要经过三次握手的环节,确立连接关系之后,才可以进行传输。除此之外,TCP还有超时重传机制,还有排序的机制,有发送的窗口,有窗口大小等等,保证接收方接收到的就是发送方发送过去的。
UDP是一种不可靠的通信协议,它不需要建立连接,不需要对连接进行确认ACK的操作,不需要重传,不需要排序,它只管传输。
比如:
“我给你讲一个关于TCP的笑话。”
“好的你给我讲一个关于TCP的笑话。”
“好的。”
------确立连接关系,进行传输------
“苟。这是第一个字。”
“第一个字收到,请发第二个字。”
“利。这是第二个字。”
“第二个字收到,请发第三个字。”
“国。这是第三个字。”
------超时重传-------
“国。这是第三个字。”
“第三个字收到,请发第四个字。”
“家……”
……
“我讲完了。”
“好的。我听完了。”
“好的。”
------关闭连接------
“我给你讲一个关于UDP的笑话。”
“咦我好像听见一个关笑P话的U……?咦这苟啊国家啊这什么什么之是啥玩意?我让应用层看看……应用层说应该是两句诗?”
TCP 最适合用于对时序不太关心的,且要求高可靠性的应用程序。
UDP 最适合需要速度和效率的应用程序。
- 串流影片
- 线上游戏
- 现场直播
- 域名系统(DNS)
- 互联网协议语音(VoIP)
- 普通文件传输协议(TFTP)
socket的通道
一般来说,socket的信道是双向的,即一个socket既能读又能写。有时候你需要建立一个半开放的socket,这时候就要使用socket的shutdown调用,它接收一个标记,其中:
- SHUT_RD代表关闭连接的读端。
- SHUT_WR代表关闭连接的写端。
- SHUT_RDWR代表关闭连接的读端跟写端。
shutdown()不会显式关闭文件描述符,需要另外调用close()。
socket服务器
现在你应该对socket有一个大致的了解了,现在我们再来探讨一个socket服务器是怎么编写的。
再回到最开始的那段代码:
1 | python3 -m http.server 8000 |
我们直接用python内置的HTTPServer绑定了8000这个端口上。
查看python3的http.server
所在的源码:
1 | def test(HandlerClass=BaseHTTPRequestHandler, |
当http.server
以模块方式运行时会调用test方法,创建一个测试服务器,这个服务器默认使用了HTTPServer作为服务器的类,BaseHTTPRequestHandler作为请求的处理类。
看HTTPServer,也就是我们一开始使用的服务器:
1 | class HTTPServer(socketserver.TCPServer): |
它继承了socketserver.TCPServer这个类,找到socketserver所在的源码,发现有一段注释,说明了几个服务器类之间的关系。
1 | +------------+ |
可以看到,TCPServer继承自BaseServer,而UDPServer又继承自TCPServer。
找到TCPServer这个类,可以看到它默认使用socket.AF_INET(IPV4)
和socket.SOCK_STREAM(TCP)
协议,并会在初始化的时候建立一个socket对象,注意这时候这个socket对象仅仅只是被创建处理,它还没有做任何的绑定。
1 | class TCPServer(BaseServer): |
真正的绑定操作发生在self.server_bind()
这行代码里,现在我们查看这个方法,它把socket对象绑定到__init__
初始化中得到的地址上,并获取服务端的地址:
1 | def server_bind(self): |
绑定后的监听动作则发生在self.server_activate()
这行里,它紧跟着binding后进行,在这个方法里socket会在绑定的地址上监听到来的连接。
1 | def server_activate(self): |
现在我们关心的是,如果现在有一个客户端发起了连接请求,服务器类会怎么处理呢?我们可以在TCPServer继承的BaseServer找到答案。
找到BaseServer的serve_forever
方法:
1 | def serve_forever(self, poll_interval=0.5): |
当服务器没被shutdown时,就会在while循环中用select去轮询活跃的socket,返回活跃的文件描述符,当检测到当前有可读事件时,就会调用_handle_request_noblock
方法来处理socket:
1 | def get_request(self): |
在_handle_request_noblock
方法中,服务器拿到可读的socket(request),调用process_request方法来处理请求,当发生异常时调用handle_error
处理错误,接着调用shutdown_request
关闭请求。
1 | def process_request(self, request, client_address): |
最后来看process_request
方法做了什么事情,首先它调用finish_request
方法,实例化出一个RequestHandlerClass
(请求处理类)来处理本次请求,处理完成后调用shutdown_request
方法来结束请求。
看看UDPServer,几乎是换汤不换药,只修改了TCPServer的几个重要的参数:
1 | class UDPServer(TCPServer): |
服务器类差不多就这样了,再来看RequestHandler。
先看最原始的BaseRequestHandler类:
1 | class BaseRequestHandler: |
它接收一个请求(socket)作为参数,调用self.setup()
建立用于读写的文件描述符,接着调用self.handle()
来处理这次请求,最终调用self.finish()
结束处理。
现在看StreamRequestHandler类:
1 | class StreamRequestHandler(BaseRequestHandler): |
在setup
过程为socket建立了一个用于读的文件描述符以及一个用于写的文件描述符,在finish
的过程中会把写缓冲区刷新,关闭读写两个文件描述符。
从上面得知handle
是处理请求的核心过程,在BaseHTTPRequestHandler中是这样实现的,handler会处理一个socket请求,如果该请求是断续请求而且没有超时或异常的话,就会继续处理下一个请求(例如keep-alive、大数据传输):
1 | class BaseHTTPRequestHandler(socketserver.StreamRequestHandler): |
其他部分太琐碎就不贴了,完成这一步后,服务器端就完成了一个来自客户端的请求的处理。
有的人还是可能觉得BaseHTTPRequestHandler和SimpleHTTPRequestHandler这类的处理类太挫太不灵活了,针对这个http.server
模块还提供了一种处理类:CGIHTTPRequestHandler,它可以通过请求信息选择执行指向的cgi脚本。cgi虽然更灵活,但也有一些弊端,于是后面又有了各种方案:fastcgi、mod_python、wsgi…有兴趣的可以看HOWTO Use Python in the web。但在不复杂的情况下,这些自带的请求处理类也勉强够用了。
再谈到之前说的HTTPServer,在线上环境中一般没有人会这么傻,直接使用这个内置的HTTPServer的。因为它是单进程而且在请求的生命周期内都只能处理同一个请求,不过好在socketserver这个模块也提供了ThreadingMixIn以及ForkingMixIn,他们的目的是当一个请求到来时使用新建一个线程或一个进程去处理它。
使用方法十分简单,用ThreadingMixIn
或ForkingMixIn
与Server类组成混合类就行了:
1 | class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): |
通过ThreadingMixIn的源码确实可以看到它重写了process_request
这个方法,它会覆盖混合类中Server类的process_request
方法,当Server处理请求时就会调用到这个方法,在ThreadingMixIn的处理中,会新起一个线程来处理请求。这样一来,服务器的并发能力就比原来有了很大的提升了。
1 | class ThreadingMixIn: |
但有的人看到这里不一定会满意,一个请求一个线程,一百个请求一百个线程,一万个、十万个…还不得上天啊。在实际环境中,一般需要把线程控制在一定的数量内(例如线程池)以降低系统负载。
现在继续把目光转移到我们一开始讨论的socket上,再来扯IO模型的问题。
我们知道socket的输入需要两个阶段:
- 等待数据准备好。
- 从内核向进程复制数据。
因为等待的过程是阻塞式,所以我们上面使用多线程就是降低这个阻塞所带来的影响。
五种IO模型:
阻塞IO模型
recv->无数据报准备好->等待数据->数据报准备好->数据从内核复制到用户空间->复制完成->返回成功指示
非阻塞IO模型
recv->无数据报准备好->返回EWOULDBLOCK->recv->无数据报准备好->返回EWOULDBLOCK->数据报准备好->数据从内核复制到用户空间->复制完成->返回成功指示
特点:轮询操作,大量占用cpu时间。
IO复用模型
select->无数据报准备好->据报准备好->返回可读条件->recv->数据从内核复制到用户空间->复制完成->返回成功指示
信号驱动模型
建立信号处理程序(sigaction)->递交SIGIO->recv->数据从内核复制到用户空间->复制完成->返回成功指示
异步IO模型
aio_read->无数据准备好->数据报准备好->数据从内核复制到用户空间->复制完成->递交aio_read中指定的信号
特点:直到数据复制完成产生信号的过程中进程都不被阻塞。
毫无疑问,我们从开始一直使用着阻塞的IO模型,这个效率是低下的。
为了获取更好的性能,我们一般采用IO多路复用模型,例如select和poll操作,运行进程同时检查多个文件描述符以找出它们任意一个是否可以进行IO操作,内核一旦发现进程指定的一个或多个IO条件就绪(输入准备被读取,或描述符能承接更多的输出),它就通知进程。
但前面说了select和poll有一个弊端就是他们在检查可用描述符的时候都是不断地遍历又遍历,当要监听的socket的文件描述符数量庞大时,性能会急剧下降,CPU消耗严重。
信号驱动模型比他们优越的地方在于,当有输入数据来到指定的文件描述符时,内核向请求数据的进程发送一个信号,进程可以处理其他任务,通过接收信号以获得通知。
而epoll则更进一步,用事件驱动的方式来监听fd,避免了信号处理的繁琐,在文件描述符上注册事件函数,由系统监视这些文件描述符,当在文件描述符可就绪时,内核通知应用进程。
在一些高并发的网络操作上,epoll的性能通常比select跟poll好几个数量级。
IO调用中有两个概念:
- 水平触发:如果文件描述符可以非阻塞地进行io调用,此时认为他已经就绪)。(支持模型:select,poll,epoll等)
- 边缘触发:如果文件描述符自上次来的时候有了新的io活动(新的输入),触发通知。(支持模型:信号驱动,epoll等)
在实际开发中要注意他们的区别,知道边缘触发为什么可能产生socket饥饿问题,怎么解决。
用一张图总结5个IO模型是这样的:
使用多路IO复用模型能有效提高网络编程的质量。
HTTP
现在再来看HTTP,HTTP是在TCP之上的无状态的协议,处于四层模型中的应用层,HTTP使用TCP来传输报文数据。
以浏览器输入一个网址打开为例,看HTTP的请求过程:
- 浏览器首先从URL中解析出主机名,端口等信息,URL的通用格式为:*://:@:/;?#*。
- 浏览器把主机名转换为IP地址(DNS)。
- 浏览器与服务器建立一条TCP连接。
- 浏览器在TCP连接上发送一条HTTP请求报文。
- 服务器在TCP连接上返回一条HTTP响应报文。
- 关闭连接,浏览器渲染文档。
HTTP的请求信息包括几个要素:
- 请求行,例如*GET /index.html HTTP/1.1*,表示要请求index.html这个文件。
- 请求头(首部)。
- 空行。
- 消息体。
例如在第一个例子中,我们向8000端口发起请求:
1 | GET / HTTP/1.1 (请求行) |
会得到以下回应:
1 | HTTP/1.0 200 OK (响应行) |
HTTP的关键之处在于它的首部,HTTP的首部信息决定了客户端和服务器端能做什么事情。
HTTP状态码
HTTP & DOM
DOM,又称Document Object Module,即文档对象模型。我们在写爬虫的时候通常都需要对html页面进行解析,这时候就需要dom解析器来对抓取的页面进行分析。
平时我们用lxml和BeautifulSoup用得爽了,但他们是怎么去解析html的呢?
在python的html.parser模块中就带了一个HTML解析器:
1 | from html.parser import HTMLParser |
可以通过它的源码中来观察dom是如何被解析的。
HTTP & RESTful
推荐阅读:RESTful API 设计最佳实践
DNS
主机到IP的转换通常要经过DNS查询,DNS是一个庞大的分布式数据库,它将主机名组织在一个层级的空间中,一个节点的域名由该节点到根的路径所有节点组成的名字连接而成。
使用dnspython包可以方便地进行dns查询:
1 | import dns.resolver |
FTP
在python世界里,使用ftp非常简单,只需要使用内置的ftplib模块就可以使用ftp协议对远端机器进行操作:
1 | from ftplib import FTP |
XML-RPC
建立一个XML-RPC的服务器跟客户端同样很简单。
Server
1 | from xmlrpc.server import SimpleXMLRPCServer |
Client
1 | from xmlrpc.client import ServerProxy, MultiCall |