编写高效且优雅的 Python 代码(1),,但很多教材并没有教授 P


Python 作为一门入门极易并容易上瘾的语音,相信已经成为了很多人 “写着玩” 的标配脚本语言。但很多教材并没有教授 Python 的进阶和优化。本文作为进阶系列的文章,从基础的语法到函数、迭代器、类,还有之后系列的线程 / 进程、第三方库、网络编程等内容,共同学习如何写出更加 Pythonic 的代码部分提炼自书籍:《Effective Python》&《Python3 Cookbook》,但也做出了修改,并加上了我自己的理解和运用中的最佳实践

Pythonic

列表切割

list[start:end:step]

  • 如果从列表开头开始切割,那么忽略 start 位的 0,例如list[:4]
  • 如果一直切到列表尾部,则忽略 end 位的 0,例如list[3:]
  • 切割列表时,即便 start 或者 end 索引跨界也不会有问题
  • 列表切片不会改变原列表。索引都留空时,会生成一份原列表的拷贝

b = a[:]
assert b == a and b is not a # true

列表推导式

  • 使用列表推导式来取代mapfilter

a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# use map
squares = map(lambda x: x ** 2, a)
# use list comprehension
squares = [x ** 2 for x in a]
# 一个很大的好处是,列表推导式可以对值进行判断,比如
squares = [x ** 2 for x in a if x % 2 == 0]
# 而如果这种情况要用 map 或者 filter 方法实现的话,则要多写一些函数

  • 不要使用含有两个以上表达式的列表推导式

# 有一个嵌套的列表,现在要把它里面的所有元素扁平化输出
list = [[
  [1, 2, 3],
  [4, 5, 6]
]]
# 使用列表推导式
flat_list = [x for list0 in list for list1 in list0 for x in list1]
# [1, 2, 3, 4, 5, 6]

# 可读性太差,易出错。这种时候更建议使用普通的循环
flat_list = []
for list0 in list:
    for list1 in list0:
        flat_list.extend(list1)

  • 数据多时,列表推导式可能会消耗大量内存,此时建议使用生成器表达式

# 在列表推导式的推导过程中,对于输入序列的每个值来说,都可能要创建仅含一项元素的全新列表。因此数据量大时很耗性能。
# 使用生成器表达式
list = (x ** 2 for x in range(0, 1000000000))
# 生成器表达式返回的迭代器,只有在每次调用时才生成值,从而避免了内存占用

迭代

  • 需要获取 index 时使用enumerate
  • enumerate可以接受第二个参数,作为迭代时加在index上的数值

list = ['a', 'b', 'c', 'd']

for index, value in enumerate(list):
    print(index)
# 0
# 1
# 2
# 3

for index, value in enumerate(list, 2):
    print(index)
# 2
# 3
# 4
# 5

  • zip同时遍历两个迭代器

list_a = ['a', 'b', 'c', 'd']
list_b = [1, 2, 3]
# 虽然列表长度不一样,但只要有一个列表耗尽,则迭代就会停止
for letter, number in zip(list_a, list_b):
    print(letter, number)
# a 1
# b 2
# c 3

  • zip遍历时返回一个元组

a = [1, 2, 3]
b = ['w', 'x', 'y', 'z']
for i in zip(a,b):
    print(i)

# (1, 'w')
# (2, 'x')
# (3, 'y')

  • 关于forwhile循环后的else
    • 循环正常结束之后会调用else内的代码
    • 循环里通过break跳出循环,则不会执行else
    • 要遍历的序列为空时,立即执行else

for i in range(2):
    print(i)
else:
    print('loop finish')
# 0
# 1
# loop finish

for i in range(2):
    print(i)
    if i % 2 == 0:
        break
else:
    print('loop finish')
# 0

反向迭代

对于普通的序列(列表),我们可以通过内置的reversed()函数进行反向迭代:

list_example = [i for i in range(5)]
iter_example = (i for i in range(5)) # 迭代器
set_example = {i for i in range(5)} # 集合

# 普通的正向迭代
# for i in list_example

# 通过 reversed 进行反向迭代
for i in reversed(list_example):
    print(i)
# 4
# 3
# 2
# 1
# 0

# 但无法作用于 集合 和 迭代器
reversed(iter_example) # TypeError: argument to reversed() must be a sequence

除此以外,还可以通过实现类里的__reversed__方法,将类进行反向迭代:

Python
class Countdown:
    def __init__(self, start):
        self.start = start

    # 正向迭代
    def __iter__(self):
        n = self.start
        while n > 0:
            yield n
            n -= 1

    # 反向迭代
    def __reversed__(self):
        n = 1
        while n <= self.start:
            yield n
            n += 1

for i in reversed(Countdown(4)):
    print(i)
# 1
# 2
# 3
# 4
for i in Countdown(4):
    print(i)
# 4
# 3
# 2
# 1

try/except/else/finally

  • 如果try内没有发生异常,则调用else内的代码
  • else会在finally之前运行
  • 最终一定会执行finally,可以在其中进行清理工作

函数

使用装饰器

装饰器用于在不改变原函数代码的情况下修改已存在的函数。常见场景是增加一句调试,或者为已有的函数增加log监控

举个栗子:

def decorator_fun(fun):
    def new_fun(*args, **kwargs):
        print('current fun:', fun.__name__)
        print('position arguments:', args)
        print('key arguments:', **kwargs)
        result = fun(*args, **kwargs)
        print(result)
        return result
    return new_fun
    
@decorator_fun
def add(a, b):
    return a + b

add(3, 2)
# current fun: add
# position arguments: (3, 2)
# key arguments: {}
# 5

除此以外,还可以编写接收参数的装饰器,其实就是在原本的装饰器上的外层又嵌套了一个函数:

def read_file(filename='results.txt'):
    def decorator_fun(fun):
        def new_fun(*args, **kwargs):
            result = fun(*args, **kwargs)
            with open(filename, 'a') as f:
                f.write(result + '\n')
            return result
        return new_fun
    return decorator_fun

# 使用装饰器时代入参数
@read_file(filename='log.txt')
def add(a, b):
    return a + b

但是像上面那样使用装饰器的话有一个问题:

@decorator_fun
def add(a, b):
    return a + b

print(add.__name__)
# new_fun

也就是说原函数已经被装饰器里的new_fun函数替代掉了。调用经过装饰的函数,相当于调用一个新函数。查看原函数的参数、注释、甚至函数名的时候,只能看到装饰器的相关信息。为了解决这个问题,我们可以使用 Python 自带的functools.wraps方法。

stackoverflow: What does functools.wraps do?

functools.wraps是个很 hack 的方法,它本事作为一个装饰器,做用在装饰器内部将要返回的函数上。也就是说,它是装饰器的装饰器,并且以原函数为参数,作用是保留原函数的各种信息,使得我们之后查看被装饰了的原函数的信息时,可以保持跟原函数一模一样。

from functools import wraps

def decorator_fun(fun):
    @wraps(fun)
    def new_fun(*args, **kwargs):
        result = fun(*args, **kwargs)
        print(result)
        return result
    return new_fun
    
@decorator_fun
def add(a, b):
    return a + b

print(add.__name__)
# add

此外,有时候我们的装饰器里可能会干不止一个事情,此时应该把事件作为额外的函数分离出去。但是又因为它可能仅仅和该装饰器有关,所以此时可以构造一个装饰器类。原理很简单,主要就是编写类里的__call__方法,使类能够像函数一样的调用。

from functools import wraps

class logResult(object):
    def __init__(self, filename='results.txt'):
        self.filename = filename
    
    def __call__(self, fun):
        @wraps(fun)
        def new_fun(*args, **kwargs):
            result = fun(*args, **kwargs)
            with open(filename, 'a') as f:
                f.write(result + '\n')
            return result
        self.send_notification()
        return new_fun
    
    def send_notification(self):
        pass

@logResult('log.txt')
def add(a, b):
    return a + b

使用生成器

考虑使用生成器来改写直接返回列表的函数

# 定义一个函数,其作用是检测字符串里所有 a 的索引位置,最终返回所有 index 组成的数组
def get_a_indexs(string):
    result = []
    for index, letter in enumerate(string):
        if letter == 'a':
            result.append(index)
    return result

用这种方法有几个小问题:

  • 每次获取到符合条件的结果,都要调用append方法。但实际上我们的关注点根本不在这个方法,它只是我们达成目的的手段,实际上只需要index就好了
  • 返回的result可以继续优化
  • 数据都存在result里面,如果数据量很大的话,会比较占用内存

因此,使用生成器generator会更好。生成器是使用yield表达式的函数,调用生成器时,它不会真的执行,而是返回一个迭代器,每次在迭代器上调用内置的next函数时,迭代器会把生成器推进到下一个yield表达式:

def get_a_indexs(string):
    for index, letter in enumerate(string):
        if letter == 'a':
            yield index

获取到一个生成器以后,可以正常的遍历它:

string = 'this is a test to find a\' index'
indexs = get_a_indexs(string)

# 可以这样遍历
for i in indexs:
    print(i)

# 或者这样
try:
    while True:
        print(next(indexs))
except StopIteration:
    print('finish!')

# 生成器在获取完之后如果继续通过 next() 取值,则会触发 StopIteration 错误
# 但通过 for 循环遍历时会自动捕获到这个错误

如果你还是需要一个列表,那么可以将函数的调用结果作为参数,再调用list方法

results = get_a_indexs('this is a test to check a')
results_list = list(results)

可迭代对象

需要注意的是,普通的迭代器只能迭代一轮,一轮之后重复调用是无效的。解决这种问题的方法是,你可以定义一个可迭代的容器类

class LoopIter(object):
    def __init__(self, data):
        self.data = data
    # 必须在 __iter__ 中 yield 结果
    def __iter__(self):
        for index, letter in enumerate(self.data):
            if letter == 'a':
                yield index

这样的话,将类的实例迭代重复多少次都没问题:

string = 'this is a test to find a\' index'
indexs = LoopIter(string)

print('loop 1')
for _ in indexs:
    print(_)
# loop 1
# 8
# 23

print('loop 2')
for _ in indexs:
    print(_)
# loop 2
# 8
# 23

但要注意的是,仅仅是实现__iter__方法的迭代器,只能通过for循环来迭代;想要通过next方法迭代的话则需要使用iter方法:

string = 'this is a test to find a\' index'
indexs = LoopIter(string)

next(indexs) # TypeError: 'LoopIter' object is not an iterator

iter_indexs = iter(indexs)
next(iter_indexs) # 8

使用位置参数

有时候,方法接收的参数数目可能不一定,比如定义一个求和的方法,至少要接收两个参数:

def sum(a, b):
    return a + b

# 正常使用
sum(1, 2) # 3
# 但如果我想求很多数的总和,而将参数全部代入是会报错的,而一次一次代入又太麻烦
sum(1, 2, 3, 4, 5) # sum() takes 2 positional arguments but 5 were given

对于这种接收参数数目不一定,而且不在乎参数传入顺序的函数,则应该利用位置参数*args

def sum(*args):
    result = 0
    for num in args:
        result += num
    return result

sum(1, 2) # 3
sum(1, 2, 3, 4, 5) # 15
# 同时,也可以直接把一个数组带入,在带入时使用 * 进行解构
sum(*[1, 2, 3, 4, 5]) # 15

但要注意的是,不定长度的参数args在传递给函数时,需要先转换成元组tuple。这意味着,如果你将一个生成器作为参数带入到函数中,生成器将会先遍历一遍,转换为元组。这可能会消耗大量内存:

def get_nums():
    for num in range(10):
        yield num

nums = get_nums()
sum(*nums) # 45
# 但在需要遍历的数目较多时,会占用大量内存

使用关键字参数

  • 关键字参数可提高代码可读性
  • 可以通过关键字参数给函数提供默认值
  • 便于扩充函数参数

定义只能使用关键字参数的函数

  • 普通的方式,在调用时不会强制要求使用关键字参数

# 定义一个方法,它的作用是遍历一个数组,找出等于(或不等于)目标元素的 index
def get_indexs(array, target='', judge=True):
    for index, item in enumerate(array):
        if judge and item == target:
            yield index
        elif not judge and item != target:
            yield index

array = [1, 2, 3, 4, 1]
# 下面这些都是可行的
result = get_indexs(array, target=1, judge=True)
print(list(result)) # [0, 4]
result = get_indexs(array, 1, True)
print(list(result)) # [0, 4]
result = get_indexs(array, 1)
print(list(result)) # [0, 4]

  • 使用 Python3 中强制关键字参数的方式

# 定义一个方法,它的作用是遍历一个数组,找出等于(或不等于)目标元素的 index
def get_indexs(array, *, target='', judge=True):
    for index, item in enumerate(array):
        if judge and item == target:
            yield index
        elif not judge and item != target:
            yield index

array = [1, 2, 3, 4, 1]
# 这样可行
result = get_indexs(array, target=1, judge=True)
print(list(result)) # [0, 4]
# 也可以忽略有默认值的参数
result = get_indexs(array, target=1)
print(list(result)) # [0, 4]
# 但不指定关键字参数则报错
get_indexs(array, 1, True)
# TypeError: get_indexs() takes 1 positional argument but 3 were given

  • 使用 Python2 中强制关键字参数的方式

# 定义一个方法,它的作用是遍历一个数组,找出等于(或不等于)目标元素的 index
# 使用 **kwargs,代表接收关键字参数,函数内的 kwargs 则是一个字典,传入的关键字参数作为键值对的形式存在
def get_indexs(array, **kwargs):
    target = kwargs.pop('target', '')
    judge = kwargs.pop('judge', True)
    for index, item in enumerate(array):
        if judge and item == target:
            yield index
        elif not judge and item != target:
            yield index

array = [1, 2, 3, 4, 1]
# 这样可行
result = get_indexs(array, target=1, judge=True)
print(list(result)) # [0, 4]
# 也可以忽略有默认值的参数
result = get_indexs(array, target=1)
print(list(result)) # [0, 4]
# 但不指定关键字参数则报错
get_indexs(array, 1, True)
# TypeError: get_indexs() takes 1 positional argument but 3 were given

关于参数的默认值

算是老生常谈了:函数的默认值只会在程序加载模块并读取到该函数的定义时设置一次

也就是说,如果给某参数赋予动态的值( 比如[]或者{}),则如果之后在调用函数的时候给参数赋予了其他参数,则以后再调用这个函数的时候,之前定义的默认值将会改变,成为上一次调用时赋予的值:

def get_default(value=[]):
    return value
    
result = get_default()
result.append(1)
result2 = get_default()
result2.append(2)
print(result) # [1, 2]
print(result2) # [1, 2]

因此,更推荐使用None作为默认参数,在函数内进行判断之后赋值:

def get_default(value=None):
    if value is None:
        return []
    return value
    
result = get_default()
result.append(1)
result2 = get_default()
result2.append(2)
print(result) # [1]
print(result2) # [2]

__slots__

默认情况下,Python 用一个字典来保存一个对象的实例属性。这使得我们可以在运行的时候动态的给类的实例添加新的属性:

test = Test()
test.new_key = 'new_value'

然而这个字典浪费了多余的空间 — 很多时候我们不会创建那么多的属性。因此通过__slots__可以告诉 Python 不要使用字典而是固定集合来分配空间。

class Test(object):
    # 用列表罗列所有的属性
    __slots__ = ['name', 'value']
    def __init__(self, name='test', value='0'):
        self.name = name
        self.value = value

test = Test()
# 此时再增加新的属性则会报错
test.new_key = 'new_value'
# AttributeError: 'Test' object has no attribute 'new_key'

__call__

通过定义类中的__call__方法,可以使该类的实例能够像普通函数一样调用。

class AddNumber(object):
    def __init__(self):
        self.num = 0

    def __call__(self, num=1):
        self.num += num

add_number = AddNumber()
print(add_number.num) # 0
add_number() # 像方法一样的调用
print(add_number.num) # 1
add_number(3)
print(add_number.num) # 4

通过这种方式实现的好处是,可以通过类的属性来保存状态,而不必创建一个闭包或者全局变量。

@classmethod & @staticmethod

资料:

  • Python @classmethod and @staticmethod for beginner
  • Difference between staticmethod and classmethod in python

@classmethod@staticmethod很像,但他们的使用场景并不一样。

  • 类内部普通的方法,都是以self作为第一个参数,代表着通过实例调用时,将实例的作用域传入方法内;
  • @classmethodcls作为第一个参数,代表将类本身的作用域传入。无论通过类来调用,还是通过类的实例调用,默认传入的第一个参数都将是类本身
  • @staticmethod不需要传入默认参数,类似于一个普通的函数

来通过实例了解它们的使用场景:

假设我们需要创建一个名为Date的类,用于储存 年/月/日 三个数据

class Date(object):
    def __init__(self, year=0, month=0, day=0):
        self.year = year
        self.month = month
        self.day = day
    
    @property
    def time(self):
        return "{year}-{month}-{day}".format(
            year=self.year,
            month=self.month,
            day=self.day
        )

上述代码创建了Date类,该类会在初始化时设置day/month/year属性,并且通过property设置了一个getter,可以在实例化之后,通过time获取存储的时间:

date = Date('2016', '11', '09')
date.time # 2016-11-09

但如果我们想改变属性传入的方式呢?毕竟,在初始化时就要传入年/月/日三个属性还是很烦人的。能否找到一个方法,在不改变现有接口和方法的情况下,可以通过传入2016-11-09这样的字符串来创建一个Date实例?

你可能会想到这样的方法:

date_string = '2016-11-09'
year, month, day = map(str, date_string.split('-'))
date = Date(year, month, day)

但不够好:

  • 在类外额外多写了一个方法,每次还得格式化以后获取参数
  • 这个方法也只跟Date类有关
  • 没有解决传入参数过多的问题

此时就可以利用@classmethod,在类的内部新建一个格式化字符串,并返回类的实例的方法:

# 在 Date 内新增一个 classmethod
@classmethod
def from_string(cls, string):
    year, month, day = map(str, string.split('-'))
    # 在 classmethod 内可以通过 cls 来调用到类的方法,甚至创建实例
    date = cls(year, month, day)
    return date

这样,我们就可以通过Date类来调用from_string方法创建实例,并且不侵略、修改旧的实例化方式:

date = Date.from_string('2016-11-09')
# 旧的实例化方式仍可以使用
date_old = Date('2016', '11', '09')

好处:

  • @classmethod内,可以通过cls参数,获取到跟外部调用类时一样的便利
  • 可以在其中进一步封装该方法,提高复用性
  • 更加符合面向对象的编程方式

@staticmethod,因为其本身类似于普通的函数,所以可以把和这个类相关的 helper 方法作为@staticmethod,放在类里,然后直接通过类来调用这个方法。

Python
# 在 Date 内新增一个 staticmethod
@staticmethod
def is_month_validate(month):
    return int(month) <= 12 and int(month) >= 1

将与日期相关的辅助类函数作为@staticmethod方法放在Date类内后,可以通过类来调用这些方法:

month = '08'
if not Date.is_month_validate(month):
    print('{} is a validate month number'.format(month))

创建上下文管理器

上下文管理器,通俗的介绍就是:在代码块执行前,先进行准备工作;在代码块执行完成后,做收尾的处理工作。with语句常伴随上下文管理器一起出现,经典场景有:

with open('test.txt', 'r') as file:
    for line in file.readlines():
        print(line)

通过with语句,代码完成了文件打开操作,并在调用结束,或者读取发生异常时自动关闭文件,即完成了文件读写之后的处理工作。如果不通过上下文管理器的话,则会是这样的代码:

file = open('test.txt', 'r')
try:
    for line in file.readlines():
        print(line)
finally:
    file.close()

比较繁琐吧?所以说使用上下文管理器的好处就是,通过调用我们预先设置好的回调,自动帮我们处理代码块开始执行和执行完毕时的工作。而通过自定义类的__enter____exit__方法,我们可以自定义一个上下文管理器。

class ReadFile(object):
    def __init__(self, filename):
        self.file = open(filename, 'r')
    
    def __enter__(self):
        return self.file
    
    def __exit__(self, type, value, traceback):
        # type, value, traceback 分别代表错误的类型、值、追踪栈
        self.file.close()
        # 返回 True 代表不抛出错误
        # 否则错误会被 with 语句抛出
        return True

然后可以以这样的方式进行调用:

with ReadFile('test.txt') as file_read:
    for line in file_read.readlines():
        print(line)

在调用的时候:

  1. with语句先暂存了ReadFile类的__exit__方法
  2. 然后调用ReadFile类的__enter__方法
  3. __enter__方法打开文件,并将结果返回给with语句
  4. 上一步的结果被传递给file_read参数
  5. with语句内对file_read参数进行操作,读取每一行
  6. 读取完成之后,with语句调用之前暂存的__exit__方法
  7. __exit__方法关闭了文件

要注意的是,在__exit__方法内,我们关闭了文件,但最后返回True,所以错误不会被with语句抛出。否则with语句会抛出一个对应的错误。

评论关闭