对象列表还是属性的并行数组?
这个问题其实很简单:在性能和设计上,使用一个Python类的对象列表和使用多个数值属性的列表,哪个更好呢?
我正在写一个科学模拟程序,涉及到一个比较大的相互作用的粒子系统。为了简单起见,我们可以想象有一组球在一个盒子里弹跳。每个球都有一些数值属性,比如x、y、z坐标,直径,质量,速度向量等等。那我们该怎么存储这些信息呢?我想到两个主要的选择:
第一个选择是创建一个“球”的类,里面包含这些属性和一些方法,然后存储这个类的对象列表,比如[b1, b2, b3, ...bn, ...],这样我们就可以通过bn.x、bn.y、bn.mass等方式访问每个球的属性;
第二个选择是为每个属性创建一个数字数组,这样对于第i个“球”,我们可以通过xs[i]访问它的'x'坐标,通过ys[i]访问'y'坐标,通过masses[i]访问'mass',依此类推;
在我看来,第一个选择在设计上更好。第二个选择看起来有点复杂,但在性能上可能更优,而且在使用numpy和scipy时可能会更方便,我尽量多用这两个库。
我仍然不确定Python的速度是否足够快,所以在Python初步原型完成后,可能需要用C++或其他语言重写。那在C/C++中,数据表示的选择会有所不同吗?如果用混合的方法,比如Python加上C++扩展呢?
更新:我从来没有期待并行数组本身会带来性能提升,但在像Python + Numpy这样的混合环境中(或者其他慢脚本语言加快速本地库),使用它们可能会让你把更多的工作从慢速脚本代码转移到快速本地库中。
4 个回答
我觉得这主要取决于你打算怎么使用这些东西,以及你会多频繁地处理(一个粒子的所有属性)和(所有粒子的一个属性)。前者更适合用面向对象的方法,后者则更适合用数组的方法。
几年前,我也遇到过类似的问题(虽然是在不同的领域)。那个项目在我真正实施这个阶段之前就被降级了,但我当时倾向于一种混合的方法,除了有一个Ball类外,我还想创建一个Ensemble类。这个Ensemble类不会只是一个简单的Ball列表或容器,而是会有自己的属性(这些属性会是数组)和自己的方法。Ensemble是从Balls创建的,还是Balls是从Ensemble创建的,这取决于你打算怎么构建它们。
我的一个同事主张用一种方案,基本的对象是一个Ensemble,它可能只包含一个Ball,这样调用代码就不需要知道你是在处理一个Ball(你在应用中有这样做过吗?)还是多个Ball。
我同意并行数组几乎总是个坏主意,但别忘了在设置时可以使用numpy数组的视图……(是的,我知道这实际上是在使用并行数组,但我觉得在这种情况下这是最好的选择……)
如果你事先知道要创建多少个“球”,那就太好了,因为你可以为坐标分配一个数组,并为每个球对象存储这个数组的视图。
你需要小心在坐标数组上进行原地操作,但这样可以让更新多个“球”的坐标变得快得多。
举个例子……
import numpy as np
class Ball(object):
def __init__(self, coords):
self.coords = coords
def _set_coord(self, i, value):
self.coords[i] = value
x = property(lambda self: self.coords[0],
lambda self, value: self._set_coord(0, value))
y = property(lambda self: self.coords[1],
lambda self, value: self._set_coord(1, value))
def move(self, dx, dy):
self.x += dx
self.y += dy
def main():
n_balls = 10
n_dims = 2
coords = np.zeros((n_balls, n_dims))
balls = [Ball(coords[i,:]) for i in range(n_balls)]
# Just to illustrate that that the coords are updating
ball = balls[0]
# Random walk by updating coords array
print 'Moving all the balls randomly by updating coords'
for step in xrange(5):
# Add a random value to all coordinates
coords += 0.5 - np.random.random((n_balls, n_dims))
# Display the coords for a particular ball and the
# corresponding row of the coords array
print ' Value of ball.x, ball.y:', ball.x, ball.y
print ' Value of coords[0,:]:', coords[0,:]
# Move an individual ball object
print 'Moving a ball individually through Ball.move()'
ball.move(0.5, 0.5)
print ' Value of ball.x, ball.y:', ball.x, ball.y
print ' Value of coords[0,:]:', coords[0,:]
main()
为了说明,这样输出的结果大概是:
Moving all the balls randomly by updating coords
Value of ball.x, ball.y: -0.125713650677 0.301692195466
Value of coords[0,:]: [-0.12571365 0.3016922 ]
Value of ball.x, ball.y: -0.304516863495 -0.0447543559805
Value of coords[0,:]: [-0.30451686 -0.04475436]
Value of ball.x, ball.y: -0.171589457954 0.334844443821
Value of coords[0,:]: [-0.17158946 0.33484444]
Value of ball.x, ball.y: -0.0452864552743 -0.0297552313656
Value of coords[0,:]: [-0.04528646 -0.02975523]
Value of ball.x, ball.y: -0.163829876915 0.0153203173857
Value of coords[0,:]: [-0.16382988 0.01532032]
Moving a ball individually through Ball.move()
Value of ball.x, ball.y: 0.336170123085 0.515320317386
Value of coords[0,:]: [ 0.33617012 0.51532032]
这里的好处是,更新一个numpy数组会比遍历所有球对象快得多,但你仍然保持了更面向对象的方法。
这只是我的一些想法……
编辑:为了让你了解速度差异,假设有1,000,000个球:
In [104]: %timeit coords[:,0] += 1.0
100 loops, best of 3: 11.8 ms per loop
In [105]: %timeit [item.x + 1.0 for item in balls]
1 loops, best of 3: 1.69 s per loop
所以,直接使用numpy更新坐标在处理大量球时大约快两个数量级。(当使用10个球时,差异会小一些,像例子中那样,大约是2倍,而不是150倍)
在这个例子中,每个球都有一个对象,这样的设计肯定更好。平行数组其实是为了那些不支持真正对象的编程语言而想出的变通办法。如果是在有面向对象编程(OO)能力的语言中,我不会使用平行数组,除非是一个很小的情况,刚好适合放在一个函数里(可能连这个情况都不算),或者我已经用尽了所有其他优化的方法,性能分析工具显示属性访问是问题所在。对于Python来说,这种情况比C++更严重,因为Python特别强调代码的可读性和优雅性。