Python:创建一个能够通过引用而非值修改列表的函数

15

我正在进行一些性能关键的Python工作,想要创建一个函数,如果某些元素符合特定标准,则从列表中删除它们。 由于列表中填充了许多非常大的对象,我不想创建任何副本。

我想要实现的功能:

def listCleanup(listOfElements):
    i = 0
    for element in listOfElements:
        if(element.meetsCriteria()):
            del(listOfElements[i])
        i += 1
    return listOfElements

myList = range(10000)
myList = listCleanup(listOfElements)

我不熟悉Python的底层工作原理。myList是按值传递还是按引用传递?

如何让这个更快?

是否可能扩展列表类并在其中实现listCleanup()?

myList = range(10000)
myList.listCleanup()

谢谢。- Jonathan


我认为你会发现这种思路带来的麻烦不值得。只需复制列表,修改它并返回修改后的副本即可。在迭代列表时直接修改它只会让你头疼不已。 - jathanism
1
del 是一种语句,而不是函数。不要用括号将其参数包起来。 - jemfinch
1
“在列表中”的对象的大小并不重要,因为Python并没有将对象存储在列表中;它存储的是对对象的引用。因此,性能问题与列表的长度和用于操作列表的算法有关,而不是所引用的对象的大小。 - Nathan
7个回答

30

Python的参数传递方式都是一样的,但称其为“按值传递”或“按引用传递”并不能完全澄清问题,因为Python的语义与通常适用这些术语的语言不同。如果我要描述它,我会说所有传递都是按值传递,并且该值是一个对象引用。(这就是为什么我不想这样说!)

如果你想从列表中过滤掉一些东西,你需要构建一个新列表。

foo = range(100000)
new_foo = []
for item in foo:
    if item % 3 != 0: # Things divisble by 3 don't get through
        new_foo.append(item)

或者,使用列表推导式语法。
 new_foo = [item for item in foo if item % 3 != 0]

Python不会复制列表中的对象,而是foonew_foo都将引用相同的对象。(Python从不隐式复制任何对象。)


您提出了对此操作的性能担忧。使用从旧列表中重复的del语句将导致代码不太惯用,处理起来更加混乱,并且会引入二次方级别的性能问题,因为整个列表必须每次重新排序。

为解决性能问题:

  • Get it up and running. You can't figure out what your performance is like unless you have code working. This will also tell you whether it is speed or space that you must optimize for; you mention concerns about both in your code, but oftentimes optimization involves getting one at the cost of the other.

  • Profile. You can use the stdlib tools for performance in time. There are various third-party memory profilers that can be somewhat useful but aren't quite as nice to work with.

  • Measure. Time or reprofile memory when you make a change to see if a change makes an improvement and if so what that improvement is.

  • To make your code more memory-sensitive, you will often want a paradigm shift in how you store your data, not microoptimizastions like not building a second list to do filtering. (The same is true for time, really: changing to a better algorithm will almost always give the best speedup. However, it's harder to generalize about speed optimizations).

    Some common paradigm shifts to optimize memory consumption in Python include

    1. Using Generators. Generators are lazy iterables: they don't load a whole list into memory at once, they figure out what their next items are on the fly. To use generators, the snippets above would look like

      foo = xrange(100000) # Like generators, xrange is lazy
      def filter_divisible_by_three(iterable):
          for item in foo:
              if item % 3 != 0:
                  yield item
      
      new_foo = filter_divisible_by_three(foo)
      

      or, using the generator expression syntax,

      new_foo = (item for item in foo if item % 3 != 0)
      
    2. Using numpy for homogenous sequences, especially ones that are numerical-mathy. This can also speed up code that does lots of vector operations.

    3. Storing data to disk, such as in a database.


1
pb[r]v(按[引用]-值传递)实际上可以应用于许多语言,包括(但不限于)Ruby、Java和C#(每种语言根据传递的“类型”略有不同的微妙/机制)。然而,当讨论pb[r]v语义时,我更喜欢说“一个对象就是它本身”,“在调用函数时,一个对象不会被隐式地复制/克隆/重复”(对于不可变/val类型,即使这是一个谎言,语义也类似),这样非常一致,可以让人们在处理高级语言时“忽略”引用/指针。 - user166390
1
使用Python的timeit进行性能分析可能是一个不错的主意。 - kriss
这些术语确实可以应用于非常广泛的语言。根据我的经验,当有人听说Python是其中之一时,他们会认为这意味着不真实的事情成为了真实。通常将Python归类为“按值传递”会让人们认为他们可以采取心理捷径,依赖于关于Python语义的额外信息,而这些信息并不正确。 - Mike Graham
@kriss,timeit并不适合分析性能,但它适合用于计时以进行微优化,以解决您在分析中发现的瓶颈问题。我在我的帖子中链接了timeit文档,尽管在SO上链接并不是很突出。 - Mike Graham
1
“每次都必须重新洗牌整个列表”(1)“洗牌”是含糊的(它不像一副牌那样被洗牌)(2)无论你如何称呼它,在删除alist[i]之后,该操作将alist[i+1:]指针向下移动一个插槽;它并不涉及整个列表。 - John Machin
是的,那段话有歧义。我用“shuffle”这个词想形象地描述所有小指针拖着它们的小脚在数组中移动。而我所说的“整个列表”错误地指代了delitem对于列表的O(n)特性。 - Mike Graham

6

在Python中,列表始终通过引用传递。

列表中对象的大小不会影响列表的性能,因为列表只存储对对象的引用。然而,列表中项的数量确实会影响某些操作的性能,例如删除元素,其时间复杂度为O(n)。

按照当前的写法,listCleanup的最坏情况是O(n**2),因为你有一个可能是O(n)的循环内部有O(n)的del操作。

如果元素的顺序不重要,您可以使用内置的set类型代替列表。 set具有O(1)的删除和插入操作。但是,您必须确保您的对象是不可变和可哈希的。

否则,最好重新创建列表。这是O(n),由于您需要检查每个元素,所以您的算法至少需要是O(n)。您可以像这样在一行中过滤列表:

listOfElements[:] = [el for el in listOfElements if el.MeetsCriteria()]

list转换为set可能不是尝试节省内存的好方法。 ;) - Mike Graham
1
据我所知,您的代码片段并没有比更标准的Python技术(如listOfElements = [el for el in listOfElements if el.MeetsCriteria()])节省内存或带来其他好处。 - Mike Graham
我真的没有看到任何我们知道的原因,需要在这里调用切片赋值。偶尔需要修改原始列表,但这并不是典型的情况。 - Mike Graham

2

看起来是过早的优化。在尝试优化之前,你应该尽量了解Python的工作原理。

在这种情况下,你不需要担心对象的大小。复制列表使用列表推导式或者切片只会执行表面复制(即复制对象的引用,即使术语在Python中并不适用)。但列表中的项目数量可能很重要,因为del的时间复杂度为O(n)。可能有其他解决方案,比如用None或常规空对象替换一个项目,或使用另一种数据结构,如set或dictionary,其中删除项目的成本要低得多。


2

我认为没有人提到实际使用过滤器。由于很多答案来自备受尊重的人,我相信我是那个错过了什么的人。有人能解释一下这个代码有什么问题吗:

new_list = filter(lambda o: o.meetsCriteria(), myList)


1

在迭代过程中修改数据结构就像是自己给脚打了一枪,会导致迭代失败。你最好听取他人的建议,创建一个新列表:

myList = [element for element in listOfElements if not element.meetsCriteria()]

旧列表——如果没有其他引用,将被释放并重新获得内存。更好的方法是,甚至不要复制列表。将上述内容改为生成器表达式,以获得更节省内存的版本:

myList = (element for element in listOfElements if not element.meetsCriteria())

所有Python对象的访问都是通过引用进行的。对象被创建,变量只是对这些对象的引用。然而,如果有人想要问最纯粹的问题,“Python使用什么类型的调用语义,按引用传递还是按值传递?”答案将不得不是“两者都不是,又都是”。原因是因为对于Python来说,调用约定比对象类型不那么重要。

如果一个对象是可变的,它可以在任何作用域中修改……只要您有一个有效的对象引用,该对象就可以被更改。如果对象是不可变的,则无论您在哪里或具有什么引用,该对象都无法更改。


1

在原地删除列表元素是可能的,但不能通过正向遍历列表来实现。你的代码根本不起作用——随着列表缩小,你可能会错过检查元素的机会。你需要倒序遍历,这样缩小部分就在你身后,需要编写相当可怕的代码。在我展示那个之前,有一些初步考虑:

首先,那些垃圾是如何进入列表的?预防胜于治疗。

其次,列表中有多少元素,有多少百分比可能需要删除?百分比越高,创建新列表的可能性就越大。

好的,如果你仍然想要在原地进行操作,请考虑以下内容:

def list_cleanup_fail(alist, is_bad):
    i = 0
    for element in alist:
        print "i=%d alist=%r alist[i]=%d element=%d" % (i, alist, alist[i], element)
        if is_bad(element):
            del alist[i]
        i += 1

def list_cleanup_ok(alist, is_bad):
    for i in xrange(len(alist) - 1, -1, -1):
        print "i=%d alist=%r alist[i]=%d" % (i, alist, alist[i])
        if is_bad(alist[i]):
            del alist[i]

def is_not_mult_of_3(x):
    return x % 3 != 0

for func in (list_cleanup_fail, list_cleanup_ok):
    print
    print func.__name__
    mylist = range(11)
    func(mylist, is_not_mult_of_3)
    print "result", mylist

这里是输出结果:

list_cleanup_fail
i=0 alist=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] alist[i]=0 element=0
i=1 alist=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] alist[i]=1 element=1
i=2 alist=[0, 2, 3, 4, 5, 6, 7, 8, 9, 10] alist[i]=3 element=3
i=3 alist=[0, 2, 3, 4, 5, 6, 7, 8, 9, 10] alist[i]=4 element=4
i=4 alist=[0, 2, 3, 5, 6, 7, 8, 9, 10] alist[i]=6 element=6
i=5 alist=[0, 2, 3, 5, 6, 7, 8, 9, 10] alist[i]=7 element=7
i=6 alist=[0, 2, 3, 5, 6, 8, 9, 10] alist[i]=9 element=9
i=7 alist=[0, 2, 3, 5, 6, 8, 9, 10] alist[i]=10 element=10
result [0, 2, 3, 5, 6, 8, 9]

list_cleanup_ok
i=10 alist=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] alist[i]=10
i=9 alist=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] alist[i]=9
i=8 alist=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] alist[i]=8
i=7 alist=[0, 1, 2, 3, 4, 5, 6, 7, 9] alist[i]=7
i=6 alist=[0, 1, 2, 3, 4, 5, 6, 9] alist[i]=6
i=5 alist=[0, 1, 2, 3, 4, 5, 6, 9] alist[i]=5
i=4 alist=[0, 1, 2, 3, 4, 6, 9] alist[i]=4
i=3 alist=[0, 1, 2, 3, 6, 9] alist[i]=3
i=2 alist=[0, 1, 2, 3, 6, 9] alist[i]=2
i=1 alist=[0, 1, 3, 6, 9] alist[i]=1
i=0 alist=[0, 3, 6, 9] alist[i]=0
result [0, 3, 6, 9]

0

只是为了明确:

def listCleanup(listOfElements):
    i = 0
    for element in listOfElements:
        if(element.meetsCriteria()):
            del(listOfElements[i])
        i += 1
    return listOfElements

myList = range(10000)
myList = listCleanup(listOfElements)

等同于

def listCleanup(listOfElements):
    i = 0
    for element in listOfElements:
        if(element.meetsCriteria()):
            del(listOfElements[i])
        i += 1

myList = range(10000)
listCleanup(listOfElements)

?


@Daniel:是的,如果你纠正了拼写错误,最后一行应该是 listCleanup(myList) - kriss
请记住,Python 中的每个“名称”只是一个引用。可变对象会在原地修改,除非您明确复制它们。 - jathanism

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接