From ae2c5179dd9376a349ebaae594e3859d5ede8d0e Mon Sep 17 00:00:00 2001 From: Daniil Ovchinnikov Date: Tue, 23 Mar 2021 14:05:16 +0100 Subject: [PATCH] introduce FMap: immutable map for storing entries with relatively rare updates GitOrigin-RevId: 3ba3dba2d4935bd2e9b504e030e76bb6e1d6a939 --- .../util/src/com/intellij/util/SmartFMap.java | 1 + .../intellij/util/fmap/ArrayBackedFMap.java | 166 ++++++++++++++++++ .../src/com/intellij/util/fmap/EmptyFMap.java | 54 ++++++ .../util/src/com/intellij/util/fmap/FMap.java | 126 +++++++++++++ .../com/intellij/util/fmap/MapBackedFMap.java | 97 ++++++++++ .../com/intellij/util/fmap/OneKeyFMap.java | 84 +++++++++ .../com/intellij/util/fmap/TwoKeysFMap.java | 112 ++++++++++++ .../com/intellij/util/fmap/FMapTest.java | 164 +++++++++++++++++ 8 files changed, 804 insertions(+) create mode 100644 platform/util/src/com/intellij/util/fmap/ArrayBackedFMap.java create mode 100644 platform/util/src/com/intellij/util/fmap/EmptyFMap.java create mode 100644 platform/util/src/com/intellij/util/fmap/FMap.java create mode 100644 platform/util/src/com/intellij/util/fmap/MapBackedFMap.java create mode 100644 platform/util/src/com/intellij/util/fmap/OneKeyFMap.java create mode 100644 platform/util/src/com/intellij/util/fmap/TwoKeysFMap.java create mode 100644 platform/util/testSrc/com/intellij/util/fmap/FMapTest.java diff --git a/platform/util/src/com/intellij/util/SmartFMap.java b/platform/util/src/com/intellij/util/SmartFMap.java index 6660239a65fd..b3286c14af1a 100644 --- a/platform/util/src/com/intellij/util/SmartFMap.java +++ b/platform/util/src/com/intellij/util/SmartFMap.java @@ -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 implements Map { private static final SmartFMap EMPTY = new SmartFMap<>(ArrayUtilRt.EMPTY_OBJECT_ARRAY); diff --git a/platform/util/src/com/intellij/util/fmap/ArrayBackedFMap.java b/platform/util/src/com/intellij/util/fmap/ArrayBackedFMap.java new file mode 100644 index 000000000000..c476014fe4c7 --- /dev/null +++ b/platform/util/src/com/intellij/util/fmap/ArrayBackedFMap.java @@ -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 { + + 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 map) { + assert map.size() <= ARRAY_THRESHOLD; + Object[] data = new Object[map.size() * 2]; + int i = 0; + for (Map.Entry entry : map.entrySet()) { + data[i++] = entry.getKey(); + data[i++] = entry.getValue(); + } + assert i == data.length; + myData = data; + } + + @Override + public @NotNull FMap 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 map = toMapInner(); + map.put(key, value); + return new MapBackedFMap<>(map); + } + + @SuppressWarnings("unchecked") + @Override + public @NotNull FMap 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 keys() { + List result = new ArrayList<>(size()); + for (int i = 0; i < myData.length; i += 2) { + result.add((K)myData[i]); + } + return result; + } + + @Override + public @NotNull Map toMap() { + return Collections.unmodifiableMap(toMapInner()); + } + + @SuppressWarnings("unchecked") + @NotNull + private Map toMapInner() { + Map 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(); + } +} diff --git a/platform/util/src/com/intellij/util/fmap/EmptyFMap.java b/platform/util/src/com/intellij/util/fmap/EmptyFMap.java new file mode 100644 index 000000000000..2c2d55c93874 --- /dev/null +++ b/platform/util/src/com/intellij/util/fmap/EmptyFMap.java @@ -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 { + + static final EmptyFMap INSTANCE = new EmptyFMap<>(); + + @Override + public @NotNull FMap plus(K key, V value) { + return new OneKeyFMap<>(key, value); + } + + @Override + public @NotNull FMap 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 keys() { + return Collections.emptySet(); + } + + @Override + public @NotNull Map toMap() { + return Collections.emptyMap(); + } + + @Override + public String toString() { + return "[:]"; + } +} diff --git a/platform/util/src/com/intellij/util/fmap/FMap.java b/platform/util/src/com/intellij/util/fmap/FMap.java new file mode 100644 index 000000000000..7d69b7ca6f17 --- /dev/null +++ b/platform/util/src/com/intellij/util/fmap/FMap.java @@ -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}. + *

+ * This map was adapted from {@link com.intellij.util.keyFMap.KeyFMap}. + * + * @param type of keys + * @param 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 FMap empty() { + return (FMap)EmptyFMap.INSTANCE; + } + + /** + * @return an {@code FMap} with a single key-value entry + */ + static <@NotNull K, @NotNull V> @NotNull FMap 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 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 from(@NotNull Map map) { + if (map.isEmpty()) { + return empty(); + } + else if (map.size() == 1) { + Map.Entry entry = map.entrySet().iterator().next(); + return of(entry.getKey(), entry.getValue()); + } + else if (map.size() == 2) { + Iterator> iterator = map.entrySet().iterator(); + Map.Entry entry1 = iterator.next(); + Map.Entry 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 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 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 keys(); + + @NotNull Map toMap(); +} diff --git a/platform/util/src/com/intellij/util/fmap/MapBackedFMap.java b/platform/util/src/com/intellij/util/fmap/MapBackedFMap.java new file mode 100644 index 000000000000..5986d1c00573 --- /dev/null +++ b/platform/util/src/com/intellij/util/fmap/MapBackedFMap.java @@ -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 { + + private final @NotNull Map myMap; + + MapBackedFMap(@NotNull Map map) { + assert map.size() > ArrayBackedFMap.ARRAY_THRESHOLD; + myMap = map; + } + + @Override + public @NotNull FMap plus(K key, V value) { + if (value.equals(myMap.get(key))) { + return this; + } + Map newMap = new THashMap<>(myMap); + newMap.put(key, value); + return new MapBackedFMap<>(newMap); + } + + @Override + public @NotNull FMap minus(K key) { + if (!myMap.containsKey(key)) { + return this; + } + + THashMap 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 keys() { + return Collections.unmodifiableSet(myMap.keySet()); + } + + @Override + public @NotNull Map 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 entry : myMap.entrySet()) { + sb.append(" ").append(entry.getKey()).append(": ").append(entry.getValue()).append(",\n"); + } + sb.append("]"); + return sb.toString(); + } +} diff --git a/platform/util/src/com/intellij/util/fmap/OneKeyFMap.java b/platform/util/src/com/intellij/util/fmap/OneKeyFMap.java new file mode 100644 index 000000000000..f3ec6bdf4cb8 --- /dev/null +++ b/platform/util/src/com/intellij/util/fmap/OneKeyFMap.java @@ -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 { + + private final K myKey; + private final V myValue; + + OneKeyFMap(K key, V value) { + myKey = key; + myValue = value; + } + + @Override + public @NotNull FMap 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 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 keys() { + return Collections.singleton(myKey); + } + + @Override + public @NotNull Map 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); + } +} diff --git a/platform/util/src/com/intellij/util/fmap/TwoKeysFMap.java b/platform/util/src/com/intellij/util/fmap/TwoKeysFMap.java new file mode 100644 index 000000000000..279bdb96cd15 --- /dev/null +++ b/platform/util/src/com/intellij/util/fmap/TwoKeysFMap.java @@ -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 { + + 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 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 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 keys() { + return Arrays.asList(myKey1, myKey2); + } + + @Override + public @NotNull Map toMap() { + Map 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); + } +} diff --git a/platform/util/testSrc/com/intellij/util/fmap/FMapTest.java b/platform/util/testSrc/com/intellij/util/fmap/FMapTest.java new file mode 100644 index 000000000000..2cc41d4e9766 --- /dev/null +++ b/platform/util/testSrc/com/intellij/util/fmap/FMapTest.java @@ -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.empty().plus("k", "v"), FMap.of("k", "v")); + assertEquals(FMap.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 map = createMap(size); + assertSize(size, map); + assertKeys(map); + assertValues(map); + testPlus(map); + testMinus(map); + assertEquals(FMap.from(map.toMap()), map); + } + + private static FMap createMap(int size) { + FMap map = FMap.empty(); + for (int i = 0; i < size; i++) { + map = map.plus(key(i), value(i)); + } + return map; + } + + private static FMap createMapReversed(int size) { + FMap 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 map) { + assertEquals(expectedSize == 0, map.isEmpty()); + assertEquals(expectedSize, map.size()); + } + + private static void assertKeys(@NotNull FMap map) { + assertSameElements(map.keys(), IntStream.range(0, map.size()).mapToObj(FMapTest::key).collect(Collectors.toList())); + } + + private static void assertValues(@NotNull FMap map) { + for (int i = 0; i < map.size(); i++) { + assertEquals(value(i), map.get(key(i))); + } + } + + private static void testPlus(@NotNull FMap 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 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 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 updatedMap, int removedKey) { + List 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 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)); + } + } +}