Skip to content

Commit bbe2f60

Browse files
committed
Issue python#14159: Fix the len() of weak containers (WeakSet, WeakKeyDictionary, WeakValueDictionary) to return a better approximation when some objects are dead or dying.
Moreover, the implementation is now O(1) rather than O(n). Thanks to Yury Selivanov for reporting.
1 parent eb977da commit bbe2f60

File tree

5 files changed

+115
-3
lines changed

5 files changed

+115
-3
lines changed

Lib/_weakrefset.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def __iter__(self):
6363
yield item
6464

6565
def __len__(self):
66-
return sum(x() is not None for x in self.data)
66+
return len(self.data) - len(self._pending_removals)
6767

6868
def __contains__(self, item):
6969
try:

Lib/test/test_weakref.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,11 +812,71 @@ def __lt__(self, other):
812812
def __hash__(self):
813813
return hash(self.arg)
814814

815+
class RefCycle:
816+
def __init__(self):
817+
self.cycle = self
818+
815819

816820
class MappingTestCase(TestBase):
817821

818822
COUNT = 10
819823

824+
def check_len_cycles(self, dict_type, cons):
825+
N = 20
826+
items = [RefCycle() for i in range(N)]
827+
dct = dict_type(cons(o) for o in items)
828+
# Keep an iterator alive
829+
it = dct.items()
830+
try:
831+
next(it)
832+
except StopIteration:
833+
pass
834+
del items
835+
gc.collect()
836+
n1 = len(dct)
837+
del it
838+
gc.collect()
839+
n2 = len(dct)
840+
# one item may be kept alive inside the iterator
841+
self.assertIn(n1, (0, 1))
842+
self.assertEqual(n2, 0)
843+
844+
def test_weak_keyed_len_cycles(self):
845+
self.check_len_cycles(weakref.WeakKeyDictionary, lambda k: (k, 1))
846+
847+
def test_weak_valued_len_cycles(self):
848+
self.check_len_cycles(weakref.WeakValueDictionary, lambda k: (1, k))
849+
850+
def check_len_race(self, dict_type, cons):
851+
# Extended sanity checks for len() in the face of cyclic collection
852+
self.addCleanup(gc.set_threshold, *gc.get_threshold())
853+
for th in range(1, 100):
854+
N = 20
855+
gc.collect(0)
856+
gc.set_threshold(th, th, th)
857+
items = [RefCycle() for i in range(N)]
858+
dct = dict_type(cons(o) for o in items)
859+
del items
860+
# All items will be collected at next garbage collection pass
861+
it = dct.items()
862+
try:
863+
next(it)
864+
except StopIteration:
865+
pass
866+
n1 = len(dct)
867+
del it
868+
n2 = len(dct)
869+
self.assertGreaterEqual(n1, 0)
870+
self.assertLessEqual(n1, N)
871+
self.assertGreaterEqual(n2, 0)
872+
self.assertLessEqual(n2, n1)
873+
874+
def test_weak_keyed_len_race(self):
875+
self.check_len_race(weakref.WeakKeyDictionary, lambda k: (k, 1))
876+
877+
def test_weak_valued_len_race(self):
878+
self.check_len_race(weakref.WeakValueDictionary, lambda k: (1, k))
879+
820880
def test_weak_values(self):
821881
#
822882
# This exercises d.copy(), d.items(), d[], del d[], len(d).

Lib/test/test_weakset.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
class Foo:
1818
pass
1919

20+
class RefCycle:
21+
def __init__(self):
22+
self.cycle = self
23+
2024

2125
class TestWeakSet(unittest.TestCase):
2226

@@ -359,6 +363,49 @@ def testcontext():
359363
s.clear()
360364
self.assertEqual(len(s), 0)
361365

366+
def test_len_cycles(self):
367+
N = 20
368+
items = [RefCycle() for i in range(N)]
369+
s = WeakSet(items)
370+
del items
371+
it = iter(s)
372+
try:
373+
next(it)
374+
except StopIteration:
375+
pass
376+
gc.collect()
377+
n1 = len(s)
378+
del it
379+
gc.collect()
380+
n2 = len(s)
381+
# one item may be kept alive inside the iterator
382+
self.assertIn(n1, (0, 1))
383+
self.assertEqual(n2, 0)
384+
385+
def test_len_race(self):
386+
# Extended sanity checks for len() in the face of cyclic collection
387+
self.addCleanup(gc.set_threshold, *gc.get_threshold())
388+
for th in range(1, 100):
389+
N = 20
390+
gc.collect(0)
391+
gc.set_threshold(th, th, th)
392+
items = [RefCycle() for i in range(N)]
393+
s = WeakSet(items)
394+
del items
395+
# All items will be collected at next garbage collection pass
396+
it = iter(s)
397+
try:
398+
next(it)
399+
except StopIteration:
400+
pass
401+
n1 = len(s)
402+
del it
403+
n2 = len(s)
404+
self.assertGreaterEqual(n1, 0)
405+
self.assertLessEqual(n1, N)
406+
self.assertGreaterEqual(n2, 0)
407+
self.assertLessEqual(n2, n1)
408+
362409

363410
def test_main(verbose=None):
364411
support.run_unittest(TestWeakSet)

Lib/weakref.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def __delitem__(self, key):
7878
del self.data[key]
7979

8080
def __len__(self):
81-
return sum(wr() is not None for wr in self.data.values())
81+
return len(self.data) - len(self._pending_removals)
8282

8383
def __contains__(self, key):
8484
try:
@@ -290,7 +290,7 @@ def __getitem__(self, key):
290290
return self.data[ref(key)]
291291

292292
def __len__(self):
293-
return len(self.data)
293+
return len(self.data) - len(self._pending_removals)
294294

295295
def __repr__(self):
296296
return "<WeakKeyDictionary at %s>" % id(self)

Misc/NEWS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ Core and Builtins
127127
Library
128128
-------
129129

130+
- Issue #14159: Fix the len() of weak containers (WeakSet, WeakKeyDictionary,
131+
WeakValueDictionary) to return a better approximation when some objects
132+
are dead or dying. Moreover, the implementation is now O(1) rather than
133+
O(n).
134+
130135
- Issue #13125: Silence spurious test_lib2to3 output when in non-verbose mode.
131136
Patch by Mikhail Novikov.
132137

0 commit comments

Comments
 (0)