introduce FMap: immutable map for storing entries with relatively rare updates

GitOrigin-RevId: 3ba3dba2d4935bd2e9b504e030e76bb6e1d6a939
This commit is contained in:
Daniil Ovchinnikov
2021-03-23 19:10:45 +00:00
committed by intellij-monorepo-bot
parent d58ed02bca
commit ae2c5179dd
8 changed files with 804 additions and 0 deletions
@@ -13,6 +13,7 @@ import java.util.function.BiConsumer;
* An immutable map optimized for storing few entries with relatively rare updates.
*
* @author peter
* @see com.intellij.util.fmap.FMap
*/
public final class SmartFMap<K,V> implements Map<K,V> {
private static final SmartFMap<?, ?> EMPTY = new SmartFMap<>(ArrayUtilRt.EMPTY_OBJECT_ARRAY);
@@ -0,0 +1,166 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.util.fmap;
import gnu.trove.THashMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
final class ArrayBackedFMap<@NotNull K, @NotNull V> implements FMap<K, V> {
static final int ARRAY_THRESHOLD = 8;
private final @NotNull Object @NotNull [] myData;
ArrayBackedFMap(@NotNull Object @NotNull ... data) {
assert 3 * 2 <= data.length; // at least 3 key-value pairs
assert data.length <= ARRAY_THRESHOLD * 2; // at most ARRAY_THRESHOLD key-value pairs
myData = data;
}
ArrayBackedFMap(@NotNull Map<K, V> map) {
assert map.size() <= ARRAY_THRESHOLD;
Object[] data = new Object[map.size() * 2];
int i = 0;
for (Map.Entry<K, V> entry : map.entrySet()) {
data[i++] = entry.getKey();
data[i++] = entry.getValue();
}
assert i == data.length;
myData = data;
}
@Override
public @NotNull FMap<K, V> plus(K key, V value) {
for (int i = 0; i < myData.length; i += 2) {
if (myData[i].equals(key)) {
if (myData[i + 1].equals(value)) {
return this;
}
else {
Object[] newData = myData.clone();
newData[i + 1] = value;
return new ArrayBackedFMap<>(newData);
}
}
}
if (size() < ARRAY_THRESHOLD) {
int length = myData.length;
Object[] newData = Arrays.copyOf(myData, length + 2);
newData[length] = key;
newData[length + 1] = value;
return new ArrayBackedFMap<>(newData);
}
Map<K, V> map = toMapInner();
map.put(key, value);
return new MapBackedFMap<>(map);
}
@SuppressWarnings("unchecked")
@Override
public @NotNull FMap<K, V> minus(K key) {
for (int i = 0; i < myData.length; i += 2) {
if (myData[i].equals(key)) {
if (size() == 3) {
if (i == 0) {
return new TwoKeysFMap<>((K)myData[2], (V)myData[3], (K)myData[4], (V)myData[5]);
}
else if (i == 2) {
return new TwoKeysFMap<>((K)myData[0], (V)myData[1], (K)myData[4], (V)myData[5]);
}
else {
assert i == 4;
return new TwoKeysFMap<>((K)myData[0], (V)myData[1], (K)myData[2], (V)myData[3]);
}
}
else {
Object[] newData = new Object[myData.length - 2];
System.arraycopy(myData, 0, newData, 0, i);
System.arraycopy(myData, i + 2, newData, i, myData.length - 2 - i);
return new ArrayBackedFMap<>(newData);
}
}
}
return this;
}
@SuppressWarnings("unchecked")
@Override
public @Nullable V get(K key) {
for (int i = 0; i < myData.length; i += 2) {
if (myData[i].equals(key)) {
return (V)myData[i + 1];
}
}
return null;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public int size() {
return myData.length / 2;
}
@SuppressWarnings("unchecked")
@Override
public @NotNull Collection<K> keys() {
List<K> result = new ArrayList<>(size());
for (int i = 0; i < myData.length; i += 2) {
result.add((K)myData[i]);
}
return result;
}
@Override
public @NotNull Map<K, V> toMap() {
return Collections.unmodifiableMap(toMapInner());
}
@SuppressWarnings("unchecked")
@NotNull
private Map<K, V> toMapInner() {
Map<K, V> map = new THashMap<>(size());
for (int i = 0; i < myData.length; i += 2) {
map.put((K)myData[i], (V)myData[i + 1]);
}
return map;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ArrayBackedFMap<?, ?> map = (ArrayBackedFMap<?, ?>)o;
if (size() != map.size()) return false;
return toMapInner().equals(map.toMapInner());
}
@Override
public int hashCode() {
int keysHash = 0;
int valuesHash = 0;
for (int i = 0; i < myData.length; i += 2) {
keysHash = keysHash ^ myData[i].hashCode();
valuesHash = valuesHash ^ myData[i + 1].hashCode();
}
return keysHash + 31 * valuesHash;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("[\n");
for (int i = 0; i < myData.length; i += 2) {
sb.append(" ").append(myData[i]).append(": ").append(myData[i + 1]).append(",\n");
}
sb.append("]");
return sb.toString();
}
}
@@ -0,0 +1,54 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.util.fmap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
final class EmptyFMap<@NotNull K, @NotNull V> implements FMap<K, V> {
static final EmptyFMap<?, ?> INSTANCE = new EmptyFMap<>();
@Override
public @NotNull FMap<K, V> plus(K key, V value) {
return new OneKeyFMap<>(key, value);
}
@Override
public @NotNull FMap<K, V> minus(K key) {
return this;
}
@Override
public @Nullable V get(K key) {
return null;
}
@Override
public boolean isEmpty() {
return true;
}
@Override
public int size() {
return 0;
}
@Override
public @NotNull Collection<K> keys() {
return Collections.emptySet();
}
@Override
public @NotNull Map<K, V> toMap() {
return Collections.emptyMap();
}
@Override
public String toString() {
return "[:]";
}
}
@@ -0,0 +1,126 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.util.fmap;
import gnu.trove.THashMap;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Debug;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.Iterator;
import java.util.Map;
/**
* An immutable map optimized for storing few entries with relatively rare updates.
* To construct a map, start with {@link FMap#empty()} and call {@link #plus} and {@link #minus},
* or use {@link #of} or {@link #from}.
* <p>
* This map was adapted from {@link com.intellij.util.keyFMap.KeyFMap}.
*
* @param <K> type of keys
* @param <V> type of values
*/
@Debug.Renderer(
text = "\"size = \" + size()",
hasChildren = "!isEmpty()",
childrenArray = "toMap().entrySet().toArray()"
)
@ApiStatus.NonExtendable
public interface FMap<@NotNull K, @NotNull V> {
/**
* @return an empty {@code FMap}. No additional instances of empty {@code FMap} should be created as they are indistinguishable
*/
@SuppressWarnings("unchecked")
static <K, V> FMap<K, V> empty() {
return (FMap<K, V>)EmptyFMap.INSTANCE;
}
/**
* @return an {@code FMap} with a single key-value entry
*/
static <@NotNull K, @NotNull V> @NotNull FMap<K, V> of(K key, V value) {
return new OneKeyFMap<>(key, value);
}
/**
* @return an {@code FMap} with a two key-value entries
*/
static <@NotNull K, @NotNull V> @NotNull FMap<K, V> of(K key1, V value1, K key2, V value2) {
assert !key1.equals(key2);
return new TwoKeysFMap<>(key1, value1, key2, value2);
}
/**
* @return an {@code FMap} containing entries from the specified {@code map}
*/
static <@NotNull K, @NotNull V> @NotNull FMap<K, V> from(@NotNull Map<K, V> map) {
if (map.isEmpty()) {
return empty();
}
else if (map.size() == 1) {
Map.Entry<K, V> entry = map.entrySet().iterator().next();
return of(entry.getKey(), entry.getValue());
}
else if (map.size() == 2) {
Iterator<Map.Entry<K, V>> iterator = map.entrySet().iterator();
Map.Entry<K, V> entry1 = iterator.next();
Map.Entry<K, V> entry2 = iterator.next();
return of(entry1.getKey(), entry1.getValue(), entry2.getKey(), entry2.getValue());
}
else if (map.size() <= ArrayBackedFMap.ARRAY_THRESHOLD) {
return new ArrayBackedFMap<>(map);
}
else {
return new MapBackedFMap<>(new THashMap<>(map));
}
}
/**
* Returns a {@code FMap} which consists of the same elements as this {@code FMap},
* but the key {@code key} is associated with the supplied {@code value}.
* May return itself if the {@code key} is already associated with the supplied {@code value}.
*
* @param key a key to add or replace
* @param value a value to be associated with the key
* @return an updated {@code FMap} (this or newly created)
*/
@NotNull FMap<K, V> plus(K key, V value);
/**
* Returns a {@code FMap} which consists of the same elements as this FMap,
* except the supplied key which is removed.
* May return itself if the supplied key is absent in this map.
*
* @param key a key to remove
* @return an updated {@code FMap} (this or newly created)
*/
@NotNull FMap<K, V> minus(K key);
/**
* Returns a value associated with given key in this {@code FMap}, or {@code null} if no value is associated.
* Note that unlike {@link java.util.HashMap} {@code FMap} cannot hold null values.
*
* @param key a key to get the value associated with
* @return a value associated with a given {@code key} or {@code null} if there's no such value
*/
@Nullable V get(K key);
/**
* @return {@code true} if this {@code FMap} is empty, otherwise {@code false}
*/
boolean isEmpty();
/**
* @return size (number of keys) of this {@code FMap}
*/
int size();
/**
* @return a collection of all keys present in this {@code FMap}, in no particular order
*/
@NotNull Collection<K> keys();
@NotNull Map<K, V> toMap();
}
@@ -0,0 +1,97 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.util.fmap;
import gnu.trove.THashMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
final class MapBackedFMap<@NotNull K, @NotNull V> implements FMap<K, V> {
private final @NotNull Map<K, V> myMap;
MapBackedFMap(@NotNull Map<K, V> map) {
assert map.size() > ArrayBackedFMap.ARRAY_THRESHOLD;
myMap = map;
}
@Override
public @NotNull FMap<K, V> plus(K key, V value) {
if (value.equals(myMap.get(key))) {
return this;
}
Map<K, V> newMap = new THashMap<>(myMap);
newMap.put(key, value);
return new MapBackedFMap<>(newMap);
}
@Override
public @NotNull FMap<K, V> minus(K key) {
if (!myMap.containsKey(key)) {
return this;
}
THashMap<K, V> newMap = new THashMap<>(myMap);
newMap.remove(key);
if (newMap.size() > ArrayBackedFMap.ARRAY_THRESHOLD) {
return new MapBackedFMap<>(newMap);
}
else {
return new ArrayBackedFMap<>(newMap);
}
}
@Override
public @Nullable V get(K key) {
return myMap.get(key);
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public int size() {
return myMap.size();
}
@Override
public @NotNull Collection<K> keys() {
return Collections.unmodifiableSet(myMap.keySet());
}
@Override
public @NotNull Map<K, V> toMap() {
return Collections.unmodifiableMap(myMap);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MapBackedFMap<?, ?> map = (MapBackedFMap<?, ?>)o;
return myMap.equals(map.myMap);
}
@Override
public int hashCode() {
return Objects.hash(myMap);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("[\n");
for (Map.Entry<K, V> entry : myMap.entrySet()) {
sb.append(" ").append(entry.getKey()).append(": ").append(entry.getValue()).append(",\n");
}
sb.append("]");
return sb.toString();
}
}
@@ -0,0 +1,84 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.util.fmap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
final class OneKeyFMap<@NotNull K, @NotNull V> implements FMap<K, V> {
private final K myKey;
private final V myValue;
OneKeyFMap(K key, V value) {
myKey = key;
myValue = value;
}
@Override
public @NotNull FMap<K, V> plus(K key, V value) {
if (myKey.equals(key)) {
if (myValue.equals(value)) {
return this;
}
else {
return new OneKeyFMap<>(key, value);
}
}
else {
return new TwoKeysFMap<>(myKey, myValue, key, value);
}
}
@Override
public @NotNull FMap<K, V> minus(K key) {
return myKey.equals(key) ? FMap.empty() : this;
}
@Override
public @Nullable V get(K key) {
return myKey.equals(key) ? myValue : null;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public int size() {
return 1;
}
@Override
public @NotNull Collection<K> keys() {
return Collections.singleton(myKey);
}
@Override
public @NotNull Map<K, V> toMap() {
return Collections.singletonMap(myKey, myValue);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OneKeyFMap<?, ?> map = (OneKeyFMap<?, ?>)o;
return myKey.equals(map.myKey) && myValue.equals(map.myValue);
}
@Override
public int hashCode() {
return Objects.hash(myKey, myValue);
}
@Override
public String toString() {
return String.format("[%s: %s,]", myKey, myValue);
}
}
@@ -0,0 +1,112 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.util.fmap;
import gnu.trove.THashMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
final class TwoKeysFMap<@NotNull K, @NotNull V> implements FMap<K, V> {
private final K myKey1;
private final K myKey2;
private final V myValue1;
private final V myValue2;
TwoKeysFMap(K key1, V value1, K key2, V value2) {
myKey1 = key1;
myKey2 = key2;
myValue1 = value1;
myValue2 = value2;
}
@Override
public @NotNull FMap<K, V> plus(K key, V value) {
if (myKey1.equals(key)) {
if (myValue1.equals(value)) {
return this;
}
else {
return new TwoKeysFMap<>(key, value, myKey2, myValue2);
}
}
else if (myKey2.equals(key)) {
if (myValue2.equals(value)) {
return this;
}
else {
return new TwoKeysFMap<>(myKey1, myValue1, key, value);
}
}
else {
return new ArrayBackedFMap<>(myKey1, myValue1, myKey2, myValue2, key, value);
}
}
@Override
public @NotNull FMap<K, V> minus(K key) {
return myKey1.equals(key)
? new OneKeyFMap<>(myKey2, myValue2)
: myKey2.equals(key)
? new OneKeyFMap<>(myKey1, myValue1)
: this;
}
@Override
public @Nullable V get(K key) {
return myKey1.equals(key)
? myValue1
: myKey2.equals(key)
? myValue2
: null;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public int size() {
return 2;
}
@Override
public @NotNull Collection<K> keys() {
return Arrays.asList(myKey1, myKey2);
}
@Override
public @NotNull Map<K, V> toMap() {
Map<K, V> map = new THashMap<>(2);
map.put(myKey1, myValue1);
map.put(myKey2, myValue2);
return Collections.unmodifiableMap(map);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TwoKeysFMap<?, ?> map = (TwoKeysFMap<?, ?>)o;
return myKey1.equals(map.myKey1) && myKey2.equals(map.myKey2) &&
myValue1.equals(map.myValue1) && myValue2.equals(map.myValue2) ||
myKey1.equals(map.myKey2) && myKey2.equals(map.myKey1) &&
myValue1.equals(map.myValue2) && myValue2.equals(map.myValue1);
}
@Override
public int hashCode() {
return (myKey1.hashCode() ^ myKey2.hashCode())
+ 31 * (myValue1.hashCode() ^ myValue2.hashCode());
}
@Override
public String toString() {
return String.format("[\n %s: %s,\n %s: %s,\n]", myKey1, myValue1, myKey2, myValue2);
}
}
@@ -0,0 +1,164 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.util.fmap;
import org.jetbrains.annotations.NotNull;
import org.junit.Test;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static com.intellij.testFramework.UsefulTestCase.assertSameElements;
import static com.intellij.util.fmap.ArrayBackedFMap.ARRAY_THRESHOLD;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
public class FMapTest {
@Test
public void equality() {
for (int i : new int[]{0, 1, 2, 3, ARRAY_THRESHOLD, ARRAY_THRESHOLD + 1, ARRAY_THRESHOLD + 5}) {
var expected = createMap(i);
var map = createMap(i);
var reversedMap = createMapReversed(i);
assertEquals(expected.hashCode(), map.hashCode());
assertEquals(expected, map);
assertEquals(expected.hashCode(), reversedMap.hashCode());
assertEquals(expected, reversedMap);
}
}
@Test
public void factoryOf() {
assertEquals(FMap.<String, String>empty().plus("k", "v"), FMap.of("k", "v"));
assertEquals(FMap.<String, String>empty().plus("k1", "v1").plus("k2", "v2"), FMap.of("k1", "v1", "k2", "v2"));
}
@Test
public void emptyMap() {
testMap(0);
}
@Test
public void oneKey() {
testMap(1);
}
@Test
public void twoKeys() {
testMap(2);
}
@Test
public void threeKeys() {
testMap(3);
}
@Test
public void arrayThresholdKeys() {
testMap(ARRAY_THRESHOLD);
testMap(ARRAY_THRESHOLD + 1);
}
@Test
public void manyKeys() {
testMap(ARRAY_THRESHOLD + 5);
}
private static void testMap(int size) {
FMap<String, String> map = createMap(size);
assertSize(size, map);
assertKeys(map);
assertValues(map);
testPlus(map);
testMinus(map);
assertEquals(FMap.from(map.toMap()), map);
}
private static FMap<String, String> createMap(int size) {
FMap<String, String> map = FMap.empty();
for (int i = 0; i < size; i++) {
map = map.plus(key(i), value(i));
}
return map;
}
private static FMap<String, String> createMapReversed(int size) {
FMap<String, String> map = FMap.empty();
for (int i = size - 1; i >= 0; i--) {
map = map.plus(key(i), value(i));
}
return map;
}
private static @NotNull String key(int i) {
return "k" + i;
}
private static @NotNull String value(int i) {
return "v" + i;
}
private static void assertSize(int expectedSize, @NotNull FMap<String, String> map) {
assertEquals(expectedSize == 0, map.isEmpty());
assertEquals(expectedSize, map.size());
}
private static void assertKeys(@NotNull FMap<String, String> map) {
assertSameElements(map.keys(), IntStream.range(0, map.size()).mapToObj(FMapTest::key).collect(Collectors.toList()));
}
private static void assertValues(@NotNull FMap<String, String> map) {
for (int i = 0; i < map.size(); i++) {
assertEquals(value(i), map.get(key(i)));
}
}
private static void testPlus(@NotNull FMap<String, String> map) {
int size = map.size();
for (int i = 0; i < size; i++) {
String key = key(i);
String value = value(i);
assertSame(map, map.plus(key, value)); // equal key, equal value
var updatedMap = map.plus(key, "x" + value); // equal key, new value
assertSize(size, updatedMap);
assertKeys(updatedMap);
assertValues(updatedMap, i);
}
}
private static void assertValues(@NotNull FMap<String, String> map, int changedKey) {
for (int i = 0; i < map.size(); i++) {
String key = key(i);
String expectedValue = i == changedKey ? "xv" + i : value(i);
assertEquals(expectedValue, map.get(key));
}
}
private static void testMinus(@NotNull FMap<String, String> map) {
assertSame(map, map.minus("nonexistentKey"));
int size = map.size();
for (int i = 0; i < size; i++) {
var updatedMap = map.minus(key(i));
assertSize(size - 1, updatedMap);
assertKeys(size, updatedMap, i);
assertValues(size, updatedMap, i);
}
}
private static void assertKeys(int sizeBefore, @NotNull FMap<String, String> updatedMap, int removedKey) {
List<String> expectedKeys = IntStream.range(0, sizeBefore)
.filter(i -> i != removedKey)
.mapToObj(FMapTest::key)
.collect(Collectors.toList());
assertSameElements(updatedMap.keys(), expectedKeys);
}
private static void assertValues(int sizeBefore, @NotNull FMap<String, String> updatedMap, int removedKey) {
for (int i = 0; i < sizeBefore; i++) {
String key = key(i);
String expectedValue = i == removedKey ? null : value(i);
assertEquals(expectedValue, updatedMap.get(key));
}
}
}