6.5 定义一种新的序列
当我们进行统计分析时,常常会需要基于一些数据计算平均数、众数和标准差。我们的21点模拟器会产生一些结果,我们必须对这些结果进行统计分析才能知道我们是否真的创建了一种更好的策略。
当我们模拟一个打牌的策略时,我们应该以一些结果数据作为结束,这些数据是一系列的数字,它们向我们展示了用某种特定策略打牌的结果。对于一张拥挤的桌子和一张只有一个玩家的桌子,打牌的速率从每小时50手到200手不等。我们会假设玩200手的21点就需要休息。
可以用一个内置的list类累加结果。我们可以通过
来计算平均值,N是x的总项数。
def mean( outcomes ):
return sum(outcomes)/len(outcomes)
标准差可以通过
计算:
def stdev( outcomes ):
n= len(outcomes)
return math.sqrt( nsum(x*2 for x in outcomes)-sum(outcomes)2 )/n
这些都是相对比较简单的计算函数。但是,随着问题变得更复杂,这种没有组织的函数就不是那么有用了。面向对象编程的一个优点就是可以将功能与数据整合在一起。
我们不会在第1个例子中重写任何list的特殊方法,只会继承list,然后增加一些用于统计的方法。这是一种非常常见的扩展方式。
我们会在第2个例子中再改进这个类,这样我们就能修改和扩展特殊方法。这需要仔细研究抽象基类的特殊方法,这样才知道需要增加和修改哪些方法才能正确地继承内置list类的所有特性。
因为我们正在探讨序列,所以必须使用Python的slice记号。我们会看看slice是什么以及它是如何与getitem、setitem和delitem一起工作的。
第2个重要的设计原则是封装。我们会创建一个list的封装类,然后介绍如何将方法委托给这个类。当涉及对象持久化时,封装具有一定的优势,这是第9章“序列化和保存——JSON、YAML、Pickle、CSV和XML”的内容。
我们也会介绍自定义一种新的序列需要做什么。
6.5.1 一个用于统计的list
将计算平均值和标准差的属性直接集成在list的子类中是一种非常明智的做法。我们可以这样扩展子类。
class Statslist(list):
@property
def mean(self):
return sum(self)/len(self)
@property
def stdev(self):
n= len(self)
return math.sqrt( nsum(x*2 for x in self)-sum(self)2 )/n
利用这个对内置list类的简单扩展,我们可以更容易地收集数据和报表统计。
可以设想有一个全局可以使用的模拟脚本,如下所示。
for s in SomePlayStrategy, SomeOtherStrategy:
sim = Simulator( s, SimpleBet() )
data = sim.run( hands=200 )
print( s.class.name, data.mean, data.stdev )
6.5.2 主动计算vs延迟计算
注意,我们的计算都是延迟的。只有在被请求的时候,它们才会执行。这也意味着每次被请求时,它们都会执行。根据这些类的对象所处的不同上下文,这可能是一个相当大的开销。
实际上,将这些统计汇总的计算转换为主动计算是很明智的做法,正如我们所知道的,从list添加或者删除元素就是主动的。尽管创建这些函数的主动计算版本增加了一些编码量,但是当数据规模很大时,它能显著地提高性能。
主动计算的关键是防止用循环求和。如果我们主动地做求和操作,由于list已经创建了,因此我们就不用再循环遍历数据。
如果我们查看Sequence类的所有特殊方法,就可以看到已经包含了用于添加、删除和修改这个序列的方法。我们可以利用这些方法计算出我们需要的两种不同总和。我们从Python标准库文档中的8.4.1节——collections.abc开始,这个部分的地址是http://docs.python.org/3.4/library/collections.abc.
html#collections-abstract-base-classes。
下面是实现MutableSequence类必须实现的方法:getitem、
setitem、 delitem、len、insert、append、reverse、extend、 pop、 remove和iadd。文档中也描述了从sequence中继承而来的方法。但是,由于那些方法都是为不可变序列设计的,因此可以暂时忽略它们。 下面列出了每个方法中必须实现的逻辑。 - getitem:无,因为不涉及状态的改变。 - setitem:这个方法改变了一个元素的状态。我们需要从每个总和中减去原本元素的值,然后再将新元素的值累加进总和中。 - delitem:这个方法会删除一个元素。我们需要从总和中移除被删除元素的值。 - len:无,因为也不涉及状态的改变。 - insert:由于这个方法插入一个新元素,因此我们需要将这个元素累加进总和中。 - append:这个方法也会添加一个新元素,所以我们同样需要将这个元素累加进总和中。 - reverse:无,因为不会影响平均值和标准差的计算。 - extend:这个方法会添加许多元素,例如init,所以在扩展list之前,我们需要处理每个新加入的元素。 - pop:这个方法会删除一个元素。我们需要从总和中移除对应元素。 - remove:这个方法也会删除一个元素。我们同样需要从总和中移除对应元素。 - iadd:这个方法实现了+=增量赋值语句,它和extend关键字完全相同。 我们不会详细讲解每个方法,因为实际上只有以下两种情况。 - 添加一个新值。 - 删除一个旧值。 替换的情况只是综合使用了添加和删除操作。 下面是一个主动的StatsList类的例子。我们只会展示insert和pop方法。class StatsList2(list):我们创建了3个内部变量,变量后的注释是这个类维护它们的方法。我们称这些变量为“和常量”(sum invariants),因为每个变量都包含了一种特定的和,并且这些和在类的状态被改变时仍然与类保持恒定的关系。这种主动计算的机制主要依赖于rmv()和_new()方法,当list被改变时,这两个方法会更新3个“和常量”,这样他们和类的关系仍然保持不变。 当我们用pop()操作成功删除一个元素后,必须更新这些“和常量”。当我们添加了一个元素(通过初始化或者用insert()方法),同样也必须更新我们的“和常量”。其他需要实现的方法会用上面这两个方法保证我们的3个“和常量”与类的关系保持恒定。我们保证L.sum0总是
"""Eager Stats."""
def init( self, args, kw ):
self.sum0 = 0 # len(self)
self.sum1 = 0 # sum(self)
self.sum2 = 0 # sum(x2 for x in self)
super().init( args, *kw )
for x in self:
self._new(x)
def _new( self, value ):
self.sum0 += 1
self.sum1 += value
self.sum2 += valuevalue
def rmv( self, value ):
self.sum0 -= 1
self.sum1 -= value
self.sum2 -= valuevalue
def insert( self, index, value ):
super().insert( index, value )
self._new(value)
def pop( self, index=0 ):
value= super().pop( index )
self._rmv(value)
return value
,
sum1总是,
sum2总是。
其他的方法,例如append()、extend()和remove(),和上面列举的方法很类似。我们没有在示例中实现它们是因为和上面的几个方法实现非常类似。
我们还有一个很重要的操作没有实现:通过list[index]=value替换特定元素。在后面的段落中,我们会深入讨论这个操作。
我们可以通过一些数据来看看现在这个list是如何工作的。
>>> sl2 = StatsList2( [2, 4, 3, 4, 5, 5, 7, 9, 10] )我们可以创建一个列表,初始化时会相应地计算3个“和常量”。后续的每次改变都会主动地更新对应的“和常量”。我们可以修改、删除、插入或者弹出一个元素,每次改变都会带来一组新的“和常量”。 剩下的就是将我们的计算平均数和标准差的逻辑加入代码中了,可以像下面这样。
>>> sl2.sum0, sl2.sum1, sl2.sum2
(9, 49, 325)
>>> sl2[2]= 4
>>> sl2.sum0, sl2.sum1, sl2.sum2
(9, 50, 332)
>>> del sl2[-1]
>>> sl2.sum0, sl2.sum1, sl2.sum2
(8, 40, 232)
>>> sl2.insert( 0, -1 )
>>> sl2.pop()
-1
>>> sl2.sum0, sl2.sum1, sl2.sum2
(8, 40, 232)
@property这个函数重用了已经算好的“和常量”。不再需要额外的循环来计算这两个统计项目。 6.5.3 使用getitem()、setitem()、delitem()和slice操作 StatsList2的例子中没有实现setitem()和delitem(),因为它们和slice操作有关。我们需要先了解slice操作的实现,然后才能正确地实现这两个方法。 序列包括了两种不同类型的索引。 - a[i]:这是一个简单的整数索引。 - a[i:j]或者a[i:j:k]:这些使用了start:stop:step值的slice表达式。slice表达式有7种不同的重载。 基本的语法主要基于3个上下文。 - 在一个表达式中,依赖于getitem()获取一个值。 - 作为赋值语句的左操作数时,依赖于setitem()设定一个值。 - 在del语句中时,依赖于delitem()删除一个值。 当我们做一些类似于seq[:-1]的操作时,我们就是在写slice表达式。底层的getitem()方法会接受一个slice对象作为参数,而不是一个简单的整数。 参考手册告诉了我们一些关于slice的信息。一个slice对象包含3个属性:start、stop和step。同时,它也有一个叫作indices()的函数,当上述任何属性缺失时,这个函数会正确地计算出缺失属性的值。 我们用一个扩展了list的简单类来探索slice对象。
def mean(self):
return self.sum1/self.sum0
@property
def stdev(self):
return math.sqrt( self.sum0self.sum2-self.sum1*self.sum1 )/self.sum0
class Explore(list):这个类会打印slice对象和indices()函数的返回值。然后,使用了基类中的实现,这样就可以让这个类的行为和普通的list一致。 有了这个类,我们可以尝试不同的slice表达式,看看会得到什么。
def getitem( self, index ):
print( index, index.indices(len(self)) )
return super().getitem( index )
>>> x= Explore('abcdefg')在上面的slice表达式中,我们可以看到一个slice对象有3个属性,并且这3个属性的值直接由Python语法提供。当我们为indices()函数提供了一个正确的长度值时,它就会返回一个带有start、stop和step值的元组。 6.5.4 实现getitem()、setitem()和_delitem()
>>> x[:]
slice(None, None, None) (0, 7, 1)
['a', 'b', 'c', 'd', 'e', 'f', 'g']
>>> x[:-1]
slice(None, -1, None) (0, 6, 1)
['a', 'b', 'c', 'd', 'e', 'f']
>>> x[1:]
slice(1, None, None) (1, 7, 1)
['b', 'c', 'd', 'e', 'f', 'g']
>>> x[::2]
slice(None, None, 2) (0, 7, 2)
['a', 'c', 'e', 'g']
当我们实现getitem()、setitem()和delitem()方法时,需要接受两种参数:int和slice。
当我们重载不同的序列方法时,必须正确地处理不同的slice情形。
下面是一个以slice为参数的setitem()方法的实现。
def setitem( self, index, value ):
if isinstance(index, slice):
start, stop, step = index.indices(len(self))
olds = [ self[i] for i in range(start,stop,step) ]
super().setitem( index, value )
for x in olds:
self.rmv(x)
for x in value:
self.new(x)
else:
old= self[index]
super().__setitem( index, value )
self._rmv(old)
self._new(value)
上面的方法有两个处理路径。
- 如果index是slice对象,我们会计算start、stop和step的值。然后,定位需要删除的旧值。接着,会调用基类中的操作并用新的值替代旧的值。
- 如果index是一个简单的int对象,那么旧的值和新的值都只是一个单一元素。
下面是一个以slice作为参数的delitem()方法的实现。
def delitem( self, index ):
# Index may be a single integer, or a slice
if isinstance(index, slice):
start, stop, step = index.indices(len(self))
olds = [ self[i] for i in range(start,stop,step) ]
super().delitem( index )
for x in olds:
self.rmv(x)
else:
old= self[index]
super()._delitem( index )
self._rmv(old)
同样地,上面的代码用slice来确定哪些值应该被删除。如果index是一个简单的整数,那么只有一个值会被删除。
当我们引入合理的slice操作到StatsList2类中时,就可以创建一个拥有所有list基类功能的列表,并且这个列表能够快速地返回当前列表中元素的平均数和标准差。
| 注意,这些方法函数会各自创建一个临时的list对象olds,这会带来一些可以优化的开销。作为读者的一个练习,在这些方法中使用_rmv()函数有助于避免使用olds变量。 |
6.5.5 封装list和委托
我们会看看要如何封装Python的一个内置容器类。封装一个内置的类意味着必须将一些方法委托给底层的容器。
由于每个内置的集合都包含了大量的方法,封装一个集合有可能需要大量的代码。当需要创建持久化类时,封装比扩展更有优势。这是第9章“序列化和保存——JSON、YAML、Pickle、CSV和XML”的主题。在某些情况下,我们需要将内部的集合暴露给大量的序列方法使用,因为这些方法需要将实现委托给一个内部的列表。
前面的统计数据类的一个限制是,它们都要求“只能插入”。接下来,我们会禁用一些方法。而封装正是为处理这种戏剧性的改变存在的。
例如,我们可以设计一个只支持append和getitem的类,它会封装一个list类。下面的代码可以用来累加模拟器中生成的数据。
class StatsList3:
def init( self ):
self.list= list()
self.sum0 = 0 # len(self), sometimes called "N"
self.sum1 = 0 # sum(self)
self.sum2 = 0 # sum(x*2 for x in self)
def append( self, value ):
self._list.append(value)
self.sum0 += 1
self.sum1 += value
self.sum2 += valuevalue
def getitem( self, index ):
return self.list.__getitem( index )
@property
def mean(self):
return self.sum1/self.sum0
@property
def stdev(self):
return math.sqrt( self.sum0self.sum2-self.sum1self.sum1
)/self.sum0
这个类中的_list对象是Python内置的list类。这个列表总是初始化为空列表。由于append()是唯一的更新列表的方式,我们可以很容易地维护不同种类的和。然而我们必须很小心地确保将相应的工作委托给基类完成,这样才能保证在我们的子类开始处理参数时当前列表中的值是最新的。
可以直接将getitem()委托给内部的_list对象,而不用去关心参数和结果。
可以像下面这样使用这个类。
>>> sl3= StatsList3()
>>> for data in 2, 4, 4, 4, 5, 5, 7, 9:
… sl3.append(data)
…
>>> sl3.mean
5.0
>>> sl3.stdev
2.0
我们创建了一个空列表,然后将元素添加到列表中。由于每次有元素添加到列表中时,都会更新“和常量”,因此可以快速地算出平均值和标准差。
我们并没有可以让类变成可迭代的,没有定义iter()。
由于getitem()的定义,现在有一些功能可以工作了。我们不止能够获取元素,同时也能看到有一个可以遍历所有值的默认实现。
这里是一个示例。
>>> sl3[0]
2
>>> for x in sl3:
… print(x)
…
2
4
4
4
5
5
7
9
上面的结果向我们展示了即使是一个最小程序的封装的集合通常也足以满足许多需求。
注意,例如,我们并没有让这个列表可以计算自身的长度。我们试图获取列表的大小,它会像下面这样抛出一个异常。
>>> len(sl3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: object of type 'StatsList3' has no len()
我们可能想添加一个len()方法并将它委托给内部的list对象。可能也会想将_hash设为None,但是需要很小心,因为这是一个可变对象。
我们可能想定义contains()并且也将真正的工作委托给内部的_list对象。这样一来,就可以创建一个极简单的容器,但是它仍然提供了一个容器所应具有的底层特性。
6.5.6 用iter()创建迭代器
当我们的设计涉及封装一个现有类时,需要确保类是可迭代的。当查看collections. abc.Iterable的文档后,就会知道我们只需要实现iter()就可以让一个对象可迭代。可以选择让iter()方法返回一个正确的Iterator对象,或者将它写成一个生成器函数。
尽管创建一个Iterator对象不是非常复杂,但是通常不需要这么做。创建生成器函数简单得多。对于一个封装的集合,应该总是简单地把iter()方法的行为委托给内部的集合。
对于StatsList3类,它看起来会像下面这样。
def iter(self):
return iter(self._list)
这个方法函数会将迭代操作委托给内部的_``list对象的Iterator。
