AI中学生 CAICP 学习指南

2.5 进阶表达与面向对象

用推导式生成新容器

处理数据时,经常需要把每项数值按某种规则变换,或只留下符合条件的记录。普通循环可以完成这些工作。下面从五个数中选出正数,再把每个正数加倍,放进一个新列表。

values = [-2, 0, 3, 5, -1]
doubled = []
for x in values:
    if x > 0:
        doubled.append(x * 2)
print(doubled)  # [6, 10]

列表推导式可以把这里的遍历、筛选和生成新值合写在一个表达式中。它的一般形式是 [新值 for 元素 in 数据 if 条件],条件部分可以省略。阅读时先找到 for:依次取出元素,检查条件,满足条件才计算最前面的新值,把它放进结果列表。上面的程序用推导式写,就得到下面的形式。

values = [-2, 0, 3, 5, -1]
doubled = [x * 2 for x in values if x > 0]
print(doubled)  # [6, 10]

两段代码做的是同一件事。原列表中的负数和零没有进入结果,3 与 5 分别变成 6 与 10,顺序保持不变。若使用 [x if x > 0 else 0 for x in values],含义就不同了:每个元素都产生一项,正数保留,其余变成零,结果为 [0, 0, 3, 5, 0]。这里的 甲 if 条件 else 乙 是条件表达式,条件成立时取甲,否则取乙。因此,一种写法在筛选,另一种在逐项替换。

筛选会减少记录,逐项替换却可能保留原来的项数。处理模型数据时,要先决定需要哪一种结果。

字典推导式使用花括号,在前面写 键: 值。例如,{word: len(word) for word in ["cat", "bird"]} 得到 {'cat': 3, 'bird': 4}。如果多次生成同一个键,后面的值会覆盖前面的值。推导式也可以嵌套,例如 [[0, 0] for _ in range(3)] 每次重新创建一行,能得到三行相互独立的列表。表达式过长时,改写为普通循环通常更便于跟踪;简短应当建立在意思清楚的基础上。

函数也能作为参数

函数本身也是对象,可以赋给变量,也可以作为参数交给另一个函数。例如,已经有一个负责把数值加倍的函数 double,map() 可以安排它依次处理一组数据。

def double(x):
    return x * 2

result = list(map(double, [3, 5]))
print(result)  # [6, 10]

map() 的第一个参数是要使用的函数,第二个参数是要处理的数据。这里传入 double,表示把函数本身交过去,随后由 map() 对每个元素调用它;若写 double(3),则会立即调用函数,得到数值 6,含义不同。

map() 返回迭代器,即能够逐项提供数据、记住当前取到何处的对象;外面的 list() 把它提供的结果收集成列表。一个迭代器取完后,再遍历同一个迭代器不会自动从头重来。

如果一个函数只有很短的计算,还可以用 lambda 写成表达式,形式为 lambda 参数: 表达式,返回的就是冒号后表达式的结果。lambda x: x * 2 与上面 double 的计算相同,却不必另写一段带名称的函数定义。它适合简短的转换或判断,不能在里面堆放普通的多行语句。filter() 则根据给定函数的真假结果保留元素,也返回迭代器。下面先保留正数,再把它们加倍。

values = [-2, 0, 3, 5]
positive = filter(lambda x: x > 0, values)
result = list(map(lambda x: x * 2, positive))
print(result)  # [6, 10]

这里先筛选,再转换,得到的结果与前面的列表推导式相同。map() 不会替换原列表的元素,filter() 也不会直接从原列表删项。需要按某个特征排序时,还可以把函数交给 sorted() 的 key 参数。例如,sorted(["bird", "ox", "cat"], key=len) 按长度排列;若元素是一条条记录,key=lambda row: row[1] 表示使用每条记录的第二项比较。key 决定比较依据,排序结果里仍保留完整的原元素。

functools 模块中的 reduce() 则把数据逐步合并成一个结果。它接收一个有两个参数的函数:第一个参数是已经累积的结果,第二个参数是新取到的元素。提供初始值以后,就从这个初始值开始。例如,reduce(lambda total, x: total + x, [2, 3, 4], 0) 依次计算 0+2、2+3、5+4,最后得到 9。对于求和,直接使用 sum() 更清楚;reduce() 的意义在于表达更一般的逐步合并。

下面删除空字符串,并按首次出现的顺序去重。普通集合无法单独保证这个顺序,因此每遇到一项,就检查它是否已经在累积结果中。

from functools import reduce

names = ["A", "", "B", "A", "C"]
result = reduce(
    lambda kept, x: (
        kept + [x] if x and x not in kept else kept
    ),
    names,
    []
)
print(result)  # ['A', 'B', 'C']

起始结果是空列表,遇到 "A" 时加入,遇到空字符串时保留原结果,遇到 "B" 时加入,再遇到 "A" 时不重复加入,最后加入 "C"。这里的条件表达式选择“新列表”或“已有列表”,并把选择的结果交给下一步。

自己的程序也完全可以用循环完成这项工作。看清每一步,比把它挤成一个表达式更有用。

类把数据与操作放在一起

一台设备有名称、电量,也有运行时消耗电量等操作。管理多台设备,如果把这些资料散放在各处,很容易弄混谁属于谁。类给同类对象规定共同的组织方式:具有哪些数据,可以做哪些操作;依据类建立的具体对象,称为实例。例如,同一个设备类建立的 A、B 两台设备,都遵守相同的运行规则,却分别保存自己的电量。对象中能通过名称访问的数据或功能称为属性,其中可调用的操作通常称为方法,前面的 append()、split() 就是方法。在设备类中,名称和电量是数据属性,运行可以写成方法。围绕对象组织数据与操作的方式,称为面向对象编程。

下面用 class 定义 Device 类,把这几项联系放在同一个例子里观察。

class Device:
    def __init__(self, name, battery):
        self.name = name
        self.battery = battery

    def run(self, cost):
        if cost < 0 or cost > self.battery:
            return False
        self.battery -= cost
        return True

类中使用 def 定义方法,写法与普通函数相近,不过这里的第一个参数 self 用来接收当前实例。self 是约定俗成的名称,self.battery 表示当前实例的电量属性。__init__ 是一个有特定用途的方法名,两侧各有两个下划线;建立实例时,它负责初始化,也就是设置对象开始使用时所需的数据。self.name = name 把传入的名称记到实例中,self.battery = battery 把传入的电量记到实例中。接着,调用 Device("A", 10),就能建立名称为 A、初始电量为 10 的实例,将它赋给 first,以后便可通过这个名字访问它。再调用一次,会新建另一台设备,各自保留数据。

first = Device("A", 10)
second = Device("B", 10)
print(first.run(6))      # True
print(first.run(5))      # False
print(first.battery)     # 4
print(second.battery)    # 10

first.run(6) 会把 first 自动交给方法的 self,只需要另外提供消耗量。第一次电量足够,扣去 6 后剩 4;第二次需要 5,方法返回 False,而且没有执行扣电语句。second 没有参加这些调用,电量仍为 10。方法一方面可以改变状态,另一方面可以返回结果,这两件事要分别观察。读这类程序时,列出每个对象的属性,再逐次跟踪方法调用,就能看清最后的状态。

实例属性与类属性

实例属性属于某个具体实例,通常通过 self.属性名 赋值。类属性直接定义在类中,可供实例共同访问。若每台设备都需要自己的一份记录,就应在初始化方法里写 self.records = []。若把 records = [] 直接写在类中,再通过实例调用 append(),可能修改的就是所有实例共同访问的那一个列表。

class Box:
    items = []

a = Box()
b = Box()
a.items.append("红")
print(b.items)  # ['红']
a.items = ["蓝"]
print(a.items)  # ['蓝']
print(b.items)  # ['红']

最初,两个实例都没有自己的 items 属性,于是访问的是类中的列表。append("红") 修改了这个列表,b 因而也能看到。随后,a.items = ["蓝"] 为 a 建立了同名的实例属性,此后从 a 访问该名称时,优先取得它自己的列表;类属性并没有被这次赋值替换,b 仍然取得原来的 ['红']。这与列表共享引用中的道理一致:修改一个对象,与给某个名字或属性换上另一个对象,是不同的操作。

类也可以描述数据集、绘图窗口和机器学习模型。后面的工具往往先创建模型对象,再调用方法训练、预测,参数和其他状态便保存在这个对象里。

发现错误或有没讲清楚的地方?欢迎提交勘误与建议。请注明章节及原文。

↑