Effective Python技巧

11月 21, 2020 · 4 分钟阅读时长

一、pythonic思维

1.1遵守PEP8风格

  • 关于命名
    1. 函数、变量、属性使用小写字母,每个单词之间使用下划线相连。
    2. 受保护的实例属性使用一个下划线开头。
    3. 私有的实例属性应当使用两个下划线开头。
    4. 类(异常)命名时候首字母应当大写。
    5. 模块级别常量所有字母大写,各个单词下划线连接。
    6. 类中的实例方法应当把第一个参数命名为self来表示对象本身。
    7. 类方法的第一个参数应答命名为cls表示类本身。
  • 关于表达式
    1. 采用行内否定(把否定词直接写在要否定的内容前)。如:if a is not b
    2. 判断是否为空可以直接进行(空则为False)。如if not somelist
    3. 表达式一行写不下应当用括号括起来并进行换行
    4. 多行表达式用括号括起来而不是使用\进行续行。
  • 关于引入
    1. import语句应当放在开头
    2. 使用绝对名称来进行引入
    3. 在引用的时候首先应当分为三部分:标准库、第三方模块、自己的模块。

1.2 使用插值格式字符串(f-string)

  在格式化字符串的时候,可以使用python特殊的f-string方法。

key = "my_var"
value = 1.23
print(f"{key!r:<10}={value:.2f}")

  通过在字符串前面加入f(format)来表示这个字符串为插入字符串,{}中的内容可以为变量,而:右边的则是格式化的方式。使用!表示把值转换为Unicode及repr形式的字符串。我们甚至可以在多重格式化

# 自定义显示的小数的位数
places = 3 # 需要显示的位数
number = 1.2345
print(f"my number is {number:.{places}f})

1.3 使用赋值表达式

  这种赋值语句可以用于内嵌到其他语句(如if)之中,这个语句也同样可以用来调整不同语句体现出的重要程度。

# 普通写法
a = 1
if a=1:
    print("yes")
# 使用赋值表达式
if (a:=1)>0:
    print("yes")

使用之后降低了a的重要性,也使得语句更加连贯。同时我们也可以通过使用这种赋值方式来实现switch

# switch语句
if (count:=a)>=2:
    print("a is enough")
elif (count:=b)>=3:
    print("b is enough")
else:
    print("Nothing enough")

1.4 bytes与str的不同

  bytes包含的是原始数据(8位无符号值),而str对应的是Unicode码点,与人类语言之中的文本字符相对应。将bytes转变为str需要使用decode方法,而反向转变则使用encode方法。两个类型相互之间并不兼容,在程序运行的核心部分,应当使用Unicode数据运行,所以可以在程序开头分别进行解码和编码的操作,这被称为Unicode三明治

  这两种不同在使用open打开文件的时候需要特别注意:

with open("data.bin" ,"wb") as f:  # 打开二进制文件为f后进行操作
    pass
with open("text.txt","w") as f: # 打开文本文件
    pass
with open("data.bin","r",encoding="cp1252") as f: # 打开二进制文件并进行重新编码为文本
    pass

1.5 使用enumerate取代range

  当我们需要迭代一个迭代器并知道当前的位置的时候,我们可以选择使用enumerate来将迭代器封装为惰性生成器,可以进行手动推进,每一次返回一个当前标号(从0开始)与迭代器内容的元组。

l=["a","b","c"]
it = enumerate(l)
num,con = next(it) # 往后生成一位

1.6 使用zip来同时遍历两个迭代器

  有时候我们需要同时迭代两个迭代器,我们可以使用zip将两者一起封装为一个惰性生成器,之后通过upacking来使用里面的内容。需要注意,zip会使用更短的那个迭代器来作为自己的长度

a=[1,2,3]
b=["a","b","c"]
s = zip(a,b)
import itertools
l = itertools.zip_longest(a,b,fillvalue=None) # 选取两者长的作为总长度,缺失的使用第三个参数进行填充

二、列表与字典

2.1 学会切片

  python中对于实现了__getitem____setitem__的类都可以进行切片,其写法为list[start:end],表示的内容是从start取到end(不包括end)的位置,如果省略则表示取到开头(结尾),而使用负数则表示从后往前算,而如果索引超出了列表的大小范围则默认只取到最后。

  切割出来的列表是全新的对象,对其修改不会影响原始对象。对于一个列表的切片索引进行赋值,并不需要相同的大小,而是直接进行替换

l = [1,2,3]
l1 = l[:] # 利用切片的原理赋值出新的对象
l[:1]=[1,2,3] # 利用切片索引进行赋值

# 此时l1为[1,2,3],而l为[1,2,3,2,3]

  切片还可以指定步长list[start:end:step],可以指定取时的跨度,如果选用-1则可以实现字符串的反转,但一般情况下应当遵守:采取正数,起止下标记留空的原则便于阅读。

2.2 使用星号unpacking

  有时候我们需要unpacking一个列表中的几个元素,可以将不需要的元素全部放入一个带星号的其他元素之中,形成一个列表,我们也可以对生成器进行upacking,不过需要注意内存是否耗尽的问题。

注:一般来说一个元组不应该拆成超过三个普通变量或者两个普通变量一个万能变量

l = [1,2,3,4,5]
a,b,*c=l
#此时a=1,b=2,c=[3,4,5]

2.3 用sort实现排序

  有时候我们使用sort函数进行排序的时候,我们可以使用key参数自定义一个函数来进行排序。

a = ["abc","ad","b"]
a.sort(key = lambda x:len(x)) # 以字符串的长度对a进行升序排序
# 当有多个参数需要注意的时候,我们可以返回元组,根据元组进行排序
a.sort(key = lambda x:(-len(x),x) # 首先根据长短进行排逆序,而当长短相同的时候根据字母顺序

2.4使用get来获取字典中的内容

  在访问字典的内容的时候,可以通过使用键来直接索引,但有时候会出现键缺失的状况从而使程序报错。我们可以通过使用get函数来获取字典的内容,同时指定键缺失情况下返回的默认值(可以简化程序)。

d = {"a":1,"b":2}
d.get(c,0) #会返回设置的默认值0

  另外对于缺失键的情况,我们也可以通过使用defaultdict来使用默认值

from collections import defaultdict
d = defaultdict(list) # 当键对应的元素不存在的时候则设置为list类型
d["a"].append(1) # a为没有的键,但是会自动设置初始值为list并调用append加入1

2.5 利用__missing__构造依赖键的默认值

  有时候我们不光需要设置字典的默认值,同时还需要对其进行一些操作(如打开文件让文件句柄作为内容),我们这个时候就可以通过构建新的字典类型来设置默认值。

class FileDict(dict):
    def __missing__(self,path): # 在key已经有对应的值时(即文件已经打开),不会再打开一次
         handle = open(path)
         self[path] = handle
        return handle

三、函数

3.1 设计参数传入的方式

  在python中,参数有两种传入方式:按位置传入与关键字传入。默认情况下我们可以通过两种方式向函数传入内容,但有时我们更应该使用位置参数:不允许外部通过关键字进行调用,从而降低程序整体对于参数名的耦合性,而有时候反而更适合关键字参数:迫使调用者必须指明参数传给哪一个值

  我们在构建函数的时候可以对其进行限制

def function(a,b,/,d,*,e):
    pass

在 / 左边的只能由位置指定,在*右边的只能由关键字指定,而中间的则为默认模式。

3.2 更好的使用位置参数

  有时候,我们需要一次性传入多个位置参数,就可以使用列表“拆包”来一次性传入多个变量。

def function1(a,b,c):
    print(f"a={a},b={b},c={c}")
l = [1,2,3]
function1(*l)

而有时候我们并不确定需要接受多少个位置参数,我们也可以通过万能形参的方式把接受到的参数全部封装到一个元组中。

def func(a,*kwargs):
	pass
# 调用函数
func(1,2,3,4,5) # a=1,kwargs = (2,3,4,5)

3.3 更好的使用关键字参数

  一般来说,关键字参数有以下三个优点:

  1. 关键字调用函数可以让让函数在调用时含义更加明确。
  2. 关键字参数库带有默认值。
  3. 可以灵活的对函数进行扩充

而有时候,我们需要一次性传入多个关键字参数,就可以使用字典“拆包”来一次性传入多个变量。

def function1(a,b,c):
    print(f"a={a},b={b},c={c}")
d = {"b":1,"c":2}
function1(1,**d)

而有时候我们并不确定需要接受多少个关键字参数,我们也可以通过万能形参的方式把接受到的参数全部封装到一个字典中。

# 按照一定格式打印字典内容
def printd(**kwargs):
    for key,value in kwargs.items():
        print(f"{key}={value}")
# 调用函数
d = {"b":1,"c":2}
printd(**d)

  关键字参数通常会设置默认值,用于作为可选项,但需要注意的是默认值不应该为一个对象,因为对象只会在构建对象的时候生成一次,之后所有调用都会指向这个对象。所以对于要生成默认对象或者要变化的默认参数的时候,应当先设置为None。

def fun(d={}):
    return d # 之后每一次返回的都是同样的d

# 所以一开始应当设置为None,之后重新生成对象
def fun(d=None):
    if d is None:
        d = {}
    return d # 这样返回的d都不一样

def log(message,when=None): # 当调用的时候没有指定,则根据当前时间进行生成。
    if when is None:
        when = datatime.now()

3.4 函数的闭包

  Python支持函数闭包,即可以在大函数中定义小函数,而内部的小函数可以引用外部大函数的值。函数在python是头等对象,可以直接操作与引用,我们可以使用闭包函数来实现需要特殊处理的排序

# 用闭包实现特殊的排序
def sort_by_group(values,group):
    def helper(x):
        if x in group:
            return(0,x)
        return (1,x)
    values.sort(key=helper)

通过内层函数来实现在组内的优先,其次根据数值排序。

在上述的例子中,我们内层函数可以对外层的函数读取,但如果我们此时对内层函数的变量进行修改,则不会对外层函数的变量产生影响,产生这样的原因是在python的三层作用域中,即使当前函数的变量名与外围的变量名重复,当我们对其进行赋值的时候,也会在当前作用域产生新的变量,这是为了防止作用域相互污染的“作用域bug”。

三层作用域

  1. 当前函数的作用域
  2. 外围作用域(包含当前函数的函数)
  3. 包含当前代码的模块对应的作用域(全局作用域)

而如果我们需要拓展当前变量的作用域,我们可以使用nonlocal来扩展到外围作用域,使用global来拓展到全局作用域。

3.5 函数的装饰器

  Python可以用装饰器对某个函数进行封装,从而在执行函数之前与之后分别运行其他代码。这可以确保用户以正确的方式使用函数,也能够用来调试程序或者实现函数的注册。

import time
def trace(func):
	def wrapper(*args,**kwargs):
		stime = time.time()
		result = func(*args,**kwargs) # 被装饰函数的运行(作为外层函数的变量参数)
		etime = time.time()
		print(etime-stime)
		return result
	return wrapper # 返回被装饰过的函数

@trace # 对fun1函数进行加工,在前后加入了
	def fun1():
        print("hello")

上面的代码是一个计时装饰器,将一个函数封装为另一个函数(wrapper)并返回,在函数的前后分别加上了记录开始与结束时间的代码。

但我们会发现一个问题,被装饰过的函数其名字并不是本身,而是变成了装饰器所返回的名字与其他很多标准属性都发生改变,为了保留这些数据,我们需要使用另一个自带的装饰器来修饰函数,把重要的元数据保留。

import time
from functools import wraps

def trace(func):
    @wraps(func) # 用于保留函数的重要信息
	def wrapper(*args,**kwargs):
		stime = time.time()
		result = func(*args,**kwargs) # 被装饰函数的运行(作为外层函数的变量参数)
		etime = time.time()
		print(etime-stime)
		return result
	return wrapper # 返回被装饰过的函数

四、推导与生成

4.1 使用列表推导

  在python中可以根据某个可迭代对象派生出一份新的列表,这种写法叫做列表推导(同时也有相对应的字典、集合推导)。

l1 = range(10)
l2 = [x**2 for x in l1 if x%2 == 0] # 取出l1中所有偶数进行平方生成新的列表

除了上面的基本用法,列表推导也支持多层循环,多个if(and关系),但一般情况下不应该超过两个。

mat = [[1,2,3],[2,3,4],[3,4,5]]
squar_mat = [[x**2 for x in row] for row in matrix] # 使用两层嵌套对矩阵内每个y进行平方

而有时候我们需要同时先对一个值进行判断,然后再使用,此时我们可以在if中加入赋值表达式实现简化代码的效果。

d = {"A":1,"b":2,"C":2}
order = ["A"]
found = [(name,batches) for name in order if (batches := d.get(name,0))]

有些时候,当数据量过大,为防止占用过多内存可以改中括号为小括号,实现生成器推导,一次只计算一个结果。

4.2 使用生成器

  当有些时候函数需要返回大量列表式元素的时候,我们可以考虑使用生成器来进行操作。

# 返回句子中每个单词开头的位置
def index_words(test):
    if text: # 句子首字母不为空
        yield 0
    for index,letter in enumerate(text):
        if letter == " ":
            yield index+1

当函数的return被修改为yield时,则变成一个生成器,当被next等函数推进一次时,则运行一次,具有内存消耗低、代码目的明确等优点。但需要无法反复使用函数所返回的生成器:每个生成器具有相应的状态

  为了让每一次需要迭代器的时候都可以产生新的对象,我们可以新建一个容器,让他实现迭代器协议,从而成为可迭代容器而不是一般的迭代器。(系统自带的列表也是可迭代容器)

# 实现可迭代容器,每一次对其迭代都会有一个新的生成器对象
class ReadVisits:
    def __init__(self,data_path):
        self.data_path = data_path
    def __iter__(self):
        with open(self.data_path) as f:
            for line in f:
                yield int(line)

python在运行for x in l等迭代语句的时,会先运行iter(l),触发l.__iter__方法,这个方法会返回迭代器对象(这个对象本身实现了__next__方法),最后python会反复调用内置的next函数,直到数据用尽。

有时候我们需要连续使用多个生成器,可以通过yield from来简化代码

def move(period,speed):
    for _ in range(period):
        yield speed
def pause(delay):
    for _ in range(delay):
        yield 0
        
def action():
    yield from move(4,6)
    yield from pause(3)

另外,迭代器可以通过itertools中的工具进行拼装,包括连接、过滤、合成等多个用法。

五、类与接口

5.1 让简单接口接受函数

  python有许多内置的API允许我们传入一些无状态且带有明确的参数与返回值的函数来定制行为,这种函数被称为挂钩(hook),在API执行的适合会回调这些挂钩函数(如sort)。

  但有时候我们需要在函数被调用的时候也记录一些信息(如被调用次数),我们可以通过__call__可以被调用的类对象来作为挂钩函数被调用。

# 被调用就返回0同时记录一次
class CountMissing():
    def __init__(self):
        self.added = 0
    
    def __call__(self):
        self.added += 1
        return 0

5.2 类调用自身

  Python在实现类的多态拼装以及其他相关内容的时候,会把外部的一些函数写入内容,此时就需要调用类型自身,我们通过@classmethod来对其进行实现。

class PathInputData():
    def __init__(self, path):
        super().__init__()
        self.path = path
    
    def read(self):
        with open(self.path) as f:
            return f.read()
    
    @classmethod
    def generate_input(cls, config:dict):  # 这个方法调用自己,用来生成这一类型的对象。
        data_dir = config.get['data_dir'] # 找到在传入数据时候所选用的信息内容
        for name in os.listdir(data_dir):
            yield cls(os.path.join(data_dir, name))

5.3 类的属性设置

  Python类的属性有两种访问级别:public与private。private属性的名字以两个下划线开头,只能被自己的类进行使用。但其实python在实现对于类属性的私有的时候只是通过变换属性名来实现,其实是在属性名前加入了_classname而已。

class Name:
    def __init__(self,value):
        self.__value = value
        
n = Name(1)
n._Name__value

而另一种以单下划线开头的字段习惯上被称为受保护字段,在PEP8的标准中更建议使用这种方式来对于属性进行保护,因为这样虽然有可能导致子类的误用,但我们不应该直接对其进行限制,而可以在文档之中提供自己的建议。不过当超类是对于外界进行开放使用的API,为了防止属性名重复,可以设计为private属性。

5.4 初始化超类

  Python在继承之后初始化超类一般情况可以使用类型名以及__init__方法进行调用。

class MyBaseClass:
    def __init__(self, value):
        self.value = value


class TimesSeven(MyBaseClass):
    def __init__(self, value):
        MyBaseClass.__init__(self, value)
        self.value *= 7

但是这种方法如果出现多重继承时候容易出现问题(比如菱形继承的最基础类型会被反复继承),所以更应该使用super根据标准的方法顺序解析(MRO)。

class TimesSeven(MyBaseClass):
    def __init__(self, value):
        super().__init__(value)
        self.value *= 7

一般来说super不需要进行参数,不过也可以传入两个参数super(Class,self).__init__(value)第一个参数表示的是从哪个类型开始按照MRO进行初始化,而第二个根据所在类型的MRO决定解析的顺序。

不过一般来说,我们若要使用多重继承来方便逻辑封装,我们应该把有待继承的类写成mix-in类,这种类只提供一小套方法去给子类使用,不定义自己实例级别的属性与不使用构造函数。

# 把python对象表示成字典形式
class ToDictMixin:
    def to_dict(self):
        return self._traverse_dict(self.__dict__)
    
    def _traverse_dict(self, instance_dict):
        output = dict
        for key, value in instance_dict.items():
            output[key] = self._traverse(key, value)
        return output
    
    def _traverse(self, key, value):
        if isinstance(value, ToDictMixin):
            return value.to_dict()
        elif isinstance(value, dict):
            return self._traverse_dict(value)
        elif isinstance(value, list):
            return [self._traverse(key, i) for i in value]
        elif hasattr(value, "__dict__"):
            return self._traverse_dict(value.__dict__)
        else:
            return value

这样的一个类可以专用于被其他类型所继承并提供一些可进行细微调整的方法,包括实例方法与类方法等。

5.5 自定义容器

  有时候我们想要使得自己定义的类也可以像列表,元组等容器一样可以进行相应序列操作,除了实现__getitem__来满足列表,__len__来实现长度查询,我们可以使用python内置的collections.abc模块内继承抽象基类,再实现相应的方法。