Spython内置的字典类型可以很好地保存某个对象在其生命周期里的动态内部状态。所谓动态(dynamic),是指这些待保存的信息,其标志符无法提前获知。例如,要把许多学生的成绩记录下来,但这些学生的名字,我们事先并不知道。于是,可以定义一个类,把学生名字字典里面,这样就不用把每个学生都表示成对象了,也无需在每个对象中预设一个存放其名字的属性。
class SimpleGradebook(object):
def __init__(self):
self._grades = {}
def add_student(self, name):
self._grade[name] = []
def report_grade(self, name, score):
self._grades[name].append(score)
def average_grade(self, name):
grades = self._grades[name]
return sum(grades)/len(grades)
这个类用起来很简单。
book = SimpleGradebook()
book.add_student('Isaac Newton')
book.report_grade('Isaac Newton', 90)
# ....
print(book.average_grade('Isaac Newton'))
>>>
90.0
由于字典用起来很方便,所以有可能因为功能过分膨胀而导致代码出问题。例如,要扩充SimpleGradebook类,使它能够按照科目保存成绩,而不是像原来那样,把所有科目的成绩都保存到一卢。要实现这个功能,可以修改_grades字典的结构,把每个学生的名字与另外一份字典关联起来,使得学生的名字成为_grades字典中每个条目的键,使得另外的那份字典成为该键所对应的值。然后,在另外那份字典中,把每个科目当作键,把该科目下的各项成绩当作值,建立映射关系。
class BySubjectGradebook(object):
def __init__(self):
self._grades = {}
def add_student(self, name):
self._grades[name] = {}
上面这部分内容改起来很简单,但report_grade 和 average_grade 方法就比较复杂了,因为它们需要处理嵌套了两层的字典。改动后的代码虽然比原来多,但毕竟还是可以维护的。
class BySubjectGradebook(object):
def __init__(self):
self._grades = {}
def add_student(self, name):
self._grades[name] = {}
def report_grade(self, name, subject, grade):
by_subject = self._grades[name]
grade_list = by_subject.setdefault(subject, [])
grade_list.append(grade)
def average_grade(self, name):
by_subject = self._grades[name]
total, count = 0, 0
for grades in by_subject.values():
total += sum(grades)
count += len(grades)
return total/count
# 这个类的用法仍然比较简单
book = BySubjectGradebook()
book.add_student('Albert Einstein')
book.report_grade('Albert Einstein','Math', 75)
book.report_grade('Albert Einstein','Math', 65)
book.report_grade('Albert Einstein','Gym', 90)
book.report_grade('Albert Einstein','Gym', 95)
现在假设需求变了。除了要记录每次考试的成绩,还需记录此成绩占科目总成绩的权重,例如,其中考试和期末考试所占的分量,要比随堂考试大。实现该功能的方式之一,是修改内部的字典。原来我们是把科目当作键,把该科目各次考试的分数当作值,而现在,则改用一系列元组作为值,每个元组都具备 (score, weight) (分数, 权重)的形式。
class WeightedGradebook(object):
# ...
def report_grade(self,name, subject, score, weight):
by_subject = self._grades[name]
grade_list = by_subject.setdefault(subject, [])
grade_list.append(score, weight)
report_grade 方法修改起来不太难,只需把放入grade_list 中的元素从普通的成绩改为元组即可。然而 average_grade 方法就比较难懂了,因为它要在大循环里面嵌一层小循环。
def average_grade(self, name):
by_subject = self._grades[name]
score+_sum, score_count = 0, 0
for subject, scores in by_subject.items():
subject_avg, total_weight = 0, 0
for score, weight in scores:
# ...
return score_sum/ score_count
这个类用起来也比刚才麻烦。我们很难从调用代码中看出这些充当位置参数的数字究竟是何含义。
book.report_grade('Albert Einstein', 'Math', 80, 0.10)
如果代码已经变得如此复杂,那么我们就该从字典和元组迁移到类体系了。
起初我们并不知道后来需要实现带权重的分数统计,所以根据当时的复杂度来看,没有必要编写辅助类。我们很容易就能用Python内置的字典与元组类型构建出分层的数据结构,从而保存程序的内部状态。但是,当嵌套多于一层的时候,就应该避免使用这种做法了(例如,不要使用包含字典的字典)。这种多层嵌套的代码,其他程序员很难看懂,而且自己维护起来也很麻烦。
用来保存程序状态的数据结构一量变得过于复杂,就应该将其拆解为类,以便提供更为明确的接口,并更好地封装数据。这样做也能够在接口与具体实现之间创建抽象层。
那么怎么把嵌套结构重构为类呢?
我们可能从依赖关系树的最底层开始重构,也就是从每次考试的成绩开始做起。这么简单的信息,似乎没有必要专门写一个类,由于分数和权重都不变化,所以用元组也许就足够了。于是我们用 (score, weight) 元组来记录某科目的历次考试成绩:
grades = []
grades.append(95, 0.45)
# ...
total = sum(score * weight for score, weight in grades)
total_weight = sum(weight fro _, weight in grades)
average_grade = total/ total_weight
问题在于,普通的元组只是按位置来排布其中各项数值。如果我们要给每次考试的成绩上面附加一些信息,比如,把老师的一些评语记录上云, 那就需要重新修改原来使用二元组的那些代码,因为现在每个元组里面有三项,而不是两项。下面这段代码用_ 符号来表示每个元组的第三项,并将其跳过( python程序习惯用下划线表示无用的变量):
grades = []
grades.append((95, 0.45, 'Great job'))
# ...
total = sum(score * weight for score, weight, _ in grades)
total_weight = sum(weight fro _, weight, _ in grades)
average_grade = total/ total_weight
与字典嵌套层级逐渐变深一样,元组长度逐步扩张,也着代码渐趋复杂。元组里的元素一量超过两项,就应该考虑用其他方法来实现了。
collections模块中的namedtuple (具名元组) 类型非常适合实现这种需求。它很容易就能定义出精简而又不可变的数据类。
import collections
Grade = collections.namedtuple('Grade',('score', 'weight'))
构建这些具名元组时,既可以按位置指定其中各项,也可以采用关键字来指定。这些字段都可以通过属性名称访问。由于元组的属性都带名称,所以当需求发生变化,以致要级简单的数据容器添加新的行为时,很容易就能从namedtuple迁移到自己定义的类。
namedtuple的局限
尽管namedtuple 在很多场合都非常有用,但大家一定要明白,有些时候使用namedtuple 反而不好。
namedtuple 类无法指定各参数的默认值。对于可选属性比较多的数据来说, namedtuple 用起来很不方便。如果这些数据并不是一系列简单的属性,那还是定义自己的类比较好。
namedtuple补全的各项属性,依然可以通过下标及迭代来访问。这可能导致其他人以不符合设计意图的方式使用这些元组,从而使以后很难把它迁移到真正的类,对于那种公布给外界使用的api来说,更要注意这个问题。如果没办法完全控制 namedtuple 的实例的用法,那么最好是定义自己的类。
接下来编写表示科目的类,该类包含一系列考试成绩。
class Subject(object):
def __init__(self):
self._grades = []
def report_grade(self, score, weight):
self._grades.append(Grade(score, weight))
def average_grade(self):
total, total_weight = 0, 0
for grade in self._grades:
total += grade.score * grade.weight
total_weight += grade.weight
return total / total_weight
然后,可以编写表示学生的类,该类包含此学生正在学习的各项课程。
class Student(object):
def __init__(self):
self._subjects = {}
def subject(self, name):
if name not in self._subjects:
self._subjects[name] = Subject()
return self._subjects[name]
def average_grade(self):
total, count = 0, 0
for subject in self._subjects.values():
total += subject.average_grade()
count += 1
return total/count
最后,编写包含所有学生考试成绩的容器类,该容器以学生的名字为键,并且可以动态地添加学生。
class Gradebook(object):
def __init__(self):
self._students = {}
def student(self, name):
if name not in self._studeunts:
self._students[name] = Student()
return self._student[name]
这些类的代码量,基本上是刚才那种实现方式的两倍。但这种程序理解起来要比原来容易许多,而且这些类的代码写起来也比原来清晰、更易扩展。
book = Gradebook()
albert = book.student('Albert Einstein')
math = albert.subject('Math')
math.report_grade(80, 0.10)
yuwen = albert.subject('yuwen')
yuwen.report_grade(83, 0.10)
# ...
print(albert.average_grade())
>>>
81.5
要点:
- 不要使用包含其他字典的字典, 也不要使用过长的元组。
- 如果容器中包含简单而又不可变的数据,那么可以先使用namedtuple 来表示,待稍后有需要时,再修改为完整的类。
- 字典如果变得比较复杂,那就应该把这些代码拆解为多个类。
总结:这一篇比较长,我花了4天的零散时间来学完。主要的意思就是,当数据结构变得复杂时,不要用字典,尽量用类来实现。