From 9528b7b26913cc1b91c54b50268ec95f41059b53 Mon Sep 17 00:00:00 2001 From: Steven Roose Date: Mon, 11 Jan 2016 00:17:30 +0100 Subject: [PATCH 1/7] Renamed _LinkedEntry to _LinkedMapEntry --- lib/src/collection/lru_map.dart | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/src/collection/lru_map.dart b/lib/src/collection/lru_map.dart index 13e094ac..30ec97f0 100644 --- a/lib/src/collection/lru_map.dart +++ b/lib/src/collection/lru_map.dart @@ -43,14 +43,14 @@ abstract class LruMap implements Map { * Simple implementation of a linked-list entry that contains a [key] and * [value]. */ -class _LinkedEntry { +class _LinkedMapEntry { K key; V value; - _LinkedEntry next; - _LinkedEntry previous; + _LinkedMapEntry next; + _LinkedMapEntry previous; - _LinkedEntry([this.key, this.value]); + _LinkedMapEntry([this.key, this.value]); } /** @@ -59,18 +59,18 @@ class _LinkedEntry { class LinkedLruHashMap implements LruMap { static const _DEFAULT_MAXIMUM_SIZE = 100; - final Map> _entries; + final Map> _entries; int _maximumSize; - _LinkedEntry _head; - _LinkedEntry _tail; + _LinkedMapEntry _head; + _LinkedMapEntry _tail; /** * Create a new LinkedLruHashMap with a [maximumSize]. */ factory LinkedLruHashMap({int maximumSize}) => - new LinkedLruHashMap._fromMap(new HashMap>(), + new LinkedLruHashMap._fromMap(new HashMap>(), maximumSize: maximumSize); LinkedLruHashMap._fromMap( @@ -133,8 +133,8 @@ class LinkedLruHashMap implements LruMap { /** * Creates an [Iterable] around the entries of the map. */ - Iterable<_LinkedEntry> _iterable() { - return new GeneratingIterable<_LinkedEntry>( + Iterable<_LinkedMapEntry> _iterable() { + return new GeneratingIterable<_LinkedMapEntry>( () => _head, (n) => n.next); } @@ -244,7 +244,7 @@ class LinkedLruHashMap implements LruMap { /** * Moves [entry] to the MRU position, shifting the linked list if necessary. */ - void _promoteEntry(_LinkedEntry entry) { + void _promoteEntry(_LinkedMapEntry entry) { if (entry.previous != null) { // If already existed in the map, link previous to next. entry.previous.next = entry.next; @@ -273,8 +273,8 @@ class LinkedLruHashMap implements LruMap { /** * Creates and returns an entry from [key] and [value]. */ - _LinkedEntry _createEntry(K key, V value) { - return new _LinkedEntry(key, value); + _LinkedMapEntry _createEntry(K key, V value) { + return new _LinkedMapEntry(key, value); } /** @@ -282,7 +282,7 @@ class LinkedLruHashMap implements LruMap { * If it does, replaces the existing [_LinkedEntry.value] with [entry.value]. * Then, in either case, promotes [entry] to the MRU position. */ - void _insertMru(_LinkedEntry entry) { + void _insertMru(_LinkedMapEntry entry) { // Insert a new entry if necessary (only 1 hash lookup in entire function). // Otherwise, just updates the existing value. final value = entry.value; From ac7197fdc4dabca38c3a69af51f35ca8f2b409d5 Mon Sep 17 00:00:00 2001 From: Steven Roose Date: Mon, 11 Jan 2016 00:51:26 +0100 Subject: [PATCH 2/7] Added LruSet implementation --- lib/collection.dart | 1 + lib/src/collection/lru_set.dart | 271 ++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 lib/src/collection/lru_set.dart diff --git a/lib/collection.dart b/lib/collection.dart index 269aaac2..3bfb2f1d 100644 --- a/lib/collection.dart +++ b/lib/collection.dart @@ -25,6 +25,7 @@ import 'package:quiver/iterables.dart'; part 'src/collection/bimap.dart'; part 'src/collection/lru_map.dart'; +part 'src/collection/lru_set.dart'; part 'src/collection/multimap.dart'; part 'src/collection/treeset.dart'; part 'src/collection/delegates/iterable.dart'; diff --git a/lib/src/collection/lru_set.dart b/lib/src/collection/lru_set.dart new file mode 100644 index 00000000..a042dc8c --- /dev/null +++ b/lib/src/collection/lru_set.dart @@ -0,0 +1,271 @@ +// Copyright 2014 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +part of quiver.collection; + +/** + * An implementation of a [Set] which has a maximum size and uses a (Least + * Recently Used)[http://en.wikipedia.org/wiki/Cache_algorithms#LRU] algorithm + * to remove items from the [Set] when the [maximumSize] is reached and new + * items are added. + * + * It is safe to access the iterator and contains method without affecting + * the "used" ordering - as well as using [forEach]. Other types of access, + * including lookup, promotes the key-value pair to the MRU position. + */ +abstract class LruSet implements Set { + /** + * Creates a [LruMap] instance with the default implementation. + */ + factory LruSet({int maximumSize}) = LinkedLruHashSet; + + /** + * Maximum size of the [Map]. If [length] exceeds this value at any time, + * n entries accessed the earliest are removed, where n is [length] - + * [maximumSize]. + */ + int maximumSize; +} + +/** + * Simple implementation of a linked-list entry that contains a [key] and + * [element]. + */ +class _LinkedSetEntry { + E element; + + _LinkedSetEntry next; + _LinkedSetEntry previous; + + _LinkedSetEntry([this.element]); +} + +/** + * A linked hash-table based implementation of [LruSet]. + */ +class LinkedLruHashSet extends SetBase implements LruSet { + static const _DEFAULT_MAXIMUM_SIZE = 100; + + final HashMap> _entries; + + int _maximumSize; + + _LinkedSetEntry _head; + _LinkedSetEntry _tail; + + /** + * Create a new LinkedLruHashMap with a [maximumSize]. + */ + factory LinkedLruHashSet({int maximumSize}) => + new LinkedLruHashSet._fromSet(new HashMap>(), + maximumSize: maximumSize); + + LinkedLruHashSet._fromSet( + this._entries, { + int maximumSize}) + // This pattern is used instead of a default value because we want to + // be able to respect null values coming in from MapCache.lru. + : _maximumSize = firstNonNull(maximumSize, _DEFAULT_MAXIMUM_SIZE); + + /** + * If [element] already exists, promotes it to the MRU position. + * + * Otherwise, adds [element] to the MRU position. + * If [length] exceeds [maximumSize] while adding, removes the LRU position. + */ + @override + bool add(E element) { + bool wasPresent = _entries.containsKey(element); + _insertMru(_createEntry(element)); + + // Remove the LRU item if the size would be exceeded by adding this item. + if (length > maximumSize) { + assert(length == maximumSize + 1); + _removeLru(); + } + return !wasPresent; + } + + /** + * Adds all key-value pairs of [other] to this set. + * + * The operation is equivalent to doing this[key] = value for each key and + * associated value in other. It iterates over other, which must therefore not + * change during the iteration. + * + * If the number of unique keys is greater than [maximumSize] then the least + * recently use keys are evicted. For items added by [other], the least + * recently user order is determined by [other]'s iteration order. + */ + @override + void addAll(Set other) => other.forEach((v) => this.add(v)); + + @override + void clear() { + _entries.clear(); + _head = _tail = null; + } + + /** + * If an object equal to object is in the set, return it. + * + * Checks if there is an object in the set that is equal to object. + * If so, that object is returned, otherwise returns null. + * + * The [element] will be promoted to the 'Most Recently Used' position. + */ + @override lookup(E element) { + final entry = _entries[element]; + if (entry != null) { + _promoteEntry(entry); + return entry.element; + } else { + return null; + } + } + + @override + bool contains(E element) => _entries.containsKey(element); + + /** + * Applies [action] to each key-value pair of the map in order of MRU to LRU. + * + * Calling `action` must not add or remove keys from the map. + */ + @override + void forEach(void action(E element)) { + var head = _head; + while (head != null) { + action(head.element); + head = head.next; + } + } + + @override + int get length => _entries.length; + + @override + bool get isEmpty => _entries.isEmpty; + + @override + bool get isNotEmpty => _entries.isNotEmpty; + + @override + Iterator get iterator => _iterable().map((e) => e.element).iterator; + + /** + * Creates an [Iterable] around the entries of the map. + */ + Iterable<_LinkedSetEntry> _iterable() { + return new GeneratingIterable<_LinkedSetEntry>( + () => _head, (n) => n.next); + } + + @override + int get maximumSize => _maximumSize; + + @override + void set maximumSize(int maximumSize) { + if (maximumSize == null) throw new ArgumentError.notNull('maximumSize'); + while (length > maximumSize) { + _removeLru(); + } + _maximumSize = maximumSize; + } + + @override + bool remove(E element) { + final _LinkedSetEntry entry = _entries.remove(element); + if (entry != null) { + if (entry == _head) { + _head = _head.next; + } else if (entry == _tail) { + _tail.previous.next = null; + _tail = _tail.previous; + } else { + entry.previous.next = entry.next; + } + return entry.element; + } + return null; + } + + @override + Set toSet() => _entries.keys.toSet(); + + + + @override + String toString() => _entries.keys.toString(); + + /** + * Moves [entry] to the MRU position, shifting the linked list if necessary. + */ + void _promoteEntry(_LinkedSetEntry entry) { + if (entry.previous != null) { + // If already existed in the map, link previous to next. + entry.previous.next = entry.next; + + // If this was the tail element, assign a new tail. + if (_tail == entry) { + _tail = entry.previous; + } + } + + // Replace head with this element. + if (_head != null) { + _head.previous = entry; + } + entry.previous = null; + entry.next = _head; + _head = entry; + + // Add a tail if this is the first element. + if (_tail == null) { + assert(length == 1); + _tail = _head; + } + } + + /** + * Creates and returns an entry from [key] and [value]. + */ + _LinkedSetEntry _createEntry(E value) { + return new _LinkedSetEntry(value); + } + + /** + * If [entry] does not exist, inserts it into the backing map. + * If it does, replaces the existing [_LinkedEntry.value] with [entry.value]. + * Then, in either case, promotes [entry] to the MRU position. + */ + void _insertMru(_LinkedSetEntry entry) { + // Insert a new entry if necessary (only 1 hash lookup in entire function). + // Otherwise, just updates the existing value. + final value = entry.element; + _promoteEntry(_entries.putIfAbsent(entry.element, () => entry)..element = value); + } + + /** + * Removes the LRU position, shifting the linked list if necessary. + */ + void _removeLru() { + // Remove the tail from the internal map. + _entries.remove(_tail.element); + + // Remove the tail element itself. + _tail = _tail.previous; + _tail.next = null; + } +} From 45c1e47c09745b3786a857e102da5617b4b866f3 Mon Sep 17 00:00:00 2001 From: Steven Roose Date: Mon, 11 Jan 2016 01:39:32 +0100 Subject: [PATCH 3/7] Added tests for LruSet --- test/collection/lru_set_test.dart | 218 ++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 test/collection/lru_set_test.dart diff --git a/test/collection/lru_set_test.dart b/test/collection/lru_set_test.dart new file mode 100644 index 00000000..ae39d277 --- /dev/null +++ b/test/collection/lru_set_test.dart @@ -0,0 +1,218 @@ +// Copyright 2014 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +library quiver.collection.lru_map_test; + +import 'package:quiver/collection.dart'; +import 'package:test/test.dart'; + +void main() { + group('LruSet', () { + /// A set that will be initialize by individual tests. + LruSet lruSet; + + test('the length property reflects how many elements are in the set', () { + lruSet = new LruSet(); + expect(lruSet, hasLength(0)); + + lruSet.addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + expect(lruSet, hasLength(3)); + }); + + test('accessing elements causes them to be promoted', () { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + expect(lruSet.toList(), ['C', 'B', 'A']); + + lruSet.lookup('B'); + + // In a LRU cache, the first element is the one that will be removed if the + // capacity is reached, so adding elements to the end is considered to be a + // 'promotion'. + expect(lruSet.toList(), ['B', 'C', 'A']); + }); + + test('new elements are added at the beginning', () { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + lruSet.add('D'); + expect(lruSet.toList(), ['D', 'C', 'B', 'A']); + }); + + test('setting values on existing elements works, and promotes the key', () { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + lruSet.add('B'); + expect(lruSet.toList(), ['B', 'C', 'A']); + }); + + test('the least recently used element is evicted when capacity hit', () { + lruSet = new LruSet(maximumSize: 3)..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + lruSet.add('D'); + expect(lruSet.toList(), ['D', 'C', 'B']); + }); + + test('setting maximum size evicts elements until the size is met', () { + lruSet = new LruSet(maximumSize: 5)..addAll(new Set.from([ + 'A', + 'B', + 'C', + 'D', + 'E' + ])); + + lruSet.maximumSize = 3; + expect(lruSet.toList(), ['E', 'D', 'C']); + }); + + test('accessing the iterator does not affect position', () { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + expect(lruSet.toList(), ['C', 'B', 'A']); + + Iterator iterator = lruSet.iterator; + while(iterator.moveNext()); + + expect(lruSet.toList(), ['C', 'B', 'A']); + }); + + test('clearing removes all elements', () { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + expect(lruSet.isNotEmpty, isTrue); + + lruSet.clear(); + + expect(lruSet.isEmpty, isTrue); + }); + + test('`contains` returns true if the element is in the set', () { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + expect(lruSet.contains('A'), isTrue); + expect(lruSet.contains('D'), isFalse); + }); + + test('`forEach` returns all items without modifying order', () { + final elements = []; + + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + expect(lruSet.toList(), ['C', 'B', 'A']); + + lruSet.forEach((element) { + elements.add(element); + }); + + expect(elements, ['C', 'B', 'A']); + expect(lruSet.toList(), ['C', 'B', 'A']); + }); + + group('`remove`', () { + setUp(() { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + }); + + test('returns the value associated with a key, if it exists', () { + expect(lruSet.remove('A'), true); + }); + + test('returns null if the provided element does not exist', () { + expect(lruSet.remove('D'), false); + }); + + test('can remove the head', () { + lruSet.remove('C'); + expect(lruSet.toList(), ['B', 'A']); + }); + + test('can remove the tail', () { + lruSet.remove('A'); + expect(lruSet.toList(), ['C', 'B']); + }); + + test('can remove a middle entry', () { + lruSet.remove('B'); + expect(lruSet.toList(), ['C', 'A']); + }); + }); + + group('`add`', () { + setUp(() { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + }); + + test('adds an item if it does not exist, and moves it to the MRU', () { + expect(lruSet.add('D'), true); + expect(lruSet.toList(), ['D', 'C', 'B', 'A']); + }); + + test('does not add an item if it exists, but does promote it to MRU', () { + expect(lruSet.add('B'), false); + expect(lruSet.toList(), ['B', 'C', 'A']); + }); + + test('removes the LRU item if `maximumSize` exceeded', () { + lruSet.maximumSize = 3; + expect(lruSet.add('D'), true); + expect(lruSet.toList(), ['D', 'C', 'B']); + }); + }); + }); +} From 39c4d5074f56a63181a159fc3da1ee632e9434c5 Mon Sep 17 00:00:00 2001 From: Steven Roose Date: Mon, 11 Jan 2016 01:41:19 +0100 Subject: [PATCH 4/7] Corrected error in LruSet.remove implementation --- lib/src/collection/lru_set.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/collection/lru_set.dart b/lib/src/collection/lru_set.dart index a042dc8c..edb91c3d 100644 --- a/lib/src/collection/lru_set.dart +++ b/lib/src/collection/lru_set.dart @@ -196,9 +196,9 @@ class LinkedLruHashSet extends SetBase implements LruSet { } else { entry.previous.next = entry.next; } - return entry.element; + return true; } - return null; + return false; } @override From dc35fa293d833a1bbec3eab213cee5091e158acb Mon Sep 17 00:00:00 2001 From: Steven Roose Date: Mon, 11 Jan 2016 02:26:43 +0100 Subject: [PATCH 5/7] Add explicit first and last getters to LruSet Because the inherited last getter iterated over the complete set to find the last element. The current implementation gives the last element in constant time. --- lib/src/collection/lru_set.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/src/collection/lru_set.dart b/lib/src/collection/lru_set.dart index edb91c3d..aca4f7f2 100644 --- a/lib/src/collection/lru_set.dart +++ b/lib/src/collection/lru_set.dart @@ -138,6 +138,23 @@ class LinkedLruHashSet extends SetBase implements LruSet { @override bool contains(E element) => _entries.containsKey(element); + @override + E get first { + if(isEmpty) throw new StateError("Set is empty"); + return _head.element; + } + + /** + * Returns the last element. + * + * This operation is performed in constant time. + */ + @override + E get last { + if(isEmpty) throw new StateError("Set is empty"); + return _tail.element; + } + /** * Applies [action] to each key-value pair of the map in order of MRU to LRU. * From c9beb5e927e3a3c327e6f2fc196e934b08671f70 Mon Sep 17 00:00:00 2001 From: Steven Roose Date: Mon, 11 Jan 2016 02:45:15 +0100 Subject: [PATCH 6/7] Tests for first and last --- test/collection/lru_set_test.dart | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/collection/lru_set_test.dart b/test/collection/lru_set_test.dart index ae39d277..6ff5cbf9 100644 --- a/test/collection/lru_set_test.dart +++ b/test/collection/lru_set_test.dart @@ -137,6 +137,27 @@ void main() { expect(lruSet.contains('D'), isFalse); }); + test('`first` returns first element', () { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + expect(lruSet.first, 'C'); + }); + + test('`last` returns last element and does not change order', () { + lruSet = new LruSet()..addAll(new Set.from([ + 'A', + 'B', + 'C' + ])); + + expect(lruSet.last, 'A'); + expect(lruSet.toList(), ['C', 'B', 'A']); + }); + test('`forEach` returns all items without modifying order', () { final elements = []; From 416821fc755f4cb775daeee20886355b4e572d56 Mon Sep 17 00:00:00 2001 From: Steven Roose Date: Mon, 11 Jan 2016 12:48:23 +0100 Subject: [PATCH 7/7] Add LruSet to README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 72d60fc3..4f051beb 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ false. `listsEqual`, `mapsEqual` and `setsEqual` check collections for equality. -`LruMap` is a map that removes the least recently used item when a threshold +`LruSet` and `LruMap` are collections that remove the least recently used item when a threshold length is exceeded. `Multimap` is an associative collection that maps keys to collections of