diff --git a/Tests/hash-it.cc b/Tests/hash-it.cc index 435de1c5..5f771a28 100644 --- a/Tests/hash-it.cc +++ b/Tests/hash-it.cc @@ -44,9 +44,12 @@ # include # include # include +# include # include # include # include +# include +# include # include # include # include @@ -204,9 +207,10 @@ TEST(OLhashTable, NoResizeFillAndSearchMiss) { OLhashTable tbl(5, Aleph::dft_hash_fct, Aleph::equal_to(), hash_default_lower_alpha, hash_default_upper_alpha, false); - for (int i = 0; i < 5; ++i) + for (int i = 0; i < 4; ++i) ASSERT_NE(tbl.insert(i), nullptr); - EXPECT_EQ(tbl.size(), static_cast(5)); + EXPECT_EQ(tbl.insert(4), nullptr); // full: one bucket stays empty + EXPECT_EQ(tbl.size(), static_cast(4)); EXPECT_EQ(tbl.search(42), nullptr); // miss should terminate } @@ -254,15 +258,16 @@ TEST(OLhashTable, ConstantHashCollisionsNoResize) auto const_hash = [](int) { return static_cast(0); }; OLhashTable tbl(5, const_hash, Aleph::equal_to(), hash_default_lower_alpha, hash_default_upper_alpha, false); - for (int i = 0; i < 5; ++i) + for (int i = 0; i < 4; ++i) ASSERT_NE(tbl.insert(i), nullptr); + EXPECT_EQ(tbl.insert(4), nullptr); // full: one bucket stays empty - for (int i = 0; i < 5; ++i) + for (int i = 0; i < 4; ++i) EXPECT_NE(tbl.search(i), nullptr); auto stats = tbl.stats(); - EXPECT_EQ(stats.num_busy, static_cast(5)); - EXPECT_GE(stats.max_len, static_cast(5)); // long collision chain + EXPECT_EQ(stats.num_busy, static_cast(4)); + EXPECT_GE(stats.max_len, static_cast(4)); // long collision chain } TEST(OLhashTable, RehashAfterDeletesPreservesLiveKeys) @@ -357,9 +362,10 @@ TEST(OLhashTable, StatsExtremes) OLhashTable tbl_full(5, const_hash, Aleph::equal_to(), hash_default_lower_alpha, hash_default_upper_alpha, false); for (int i = 0; i < 5; ++i) - tbl_full.insert(i); + (void) tbl_full.insert(i); // the last one does not fit auto stats_full = tbl_full.stats(); - EXPECT_EQ(stats_full.num_busy, static_cast(5)); + EXPECT_EQ(stats_full.num_busy, static_cast(4)); + EXPECT_EQ(stats_full.num_empty, static_cast(1)); EXPECT_EQ(stats_full.num_deleted, static_cast(0)); EXPECT_FALSE(std::isnan(stats_full.avg)); EXPECT_FALSE(std::isnan(stats_full.var)); @@ -381,9 +387,11 @@ TEST(OLhashTable, InsertFailsWhenFullNoResize) hash_default_lower_alpha, hash_default_upper_alpha, false); ASSERT_NE(tbl.insert(1), nullptr); ASSERT_NE(tbl.insert(2), nullptr); - ASSERT_NE(tbl.insert(3), nullptr); + // Two keys fill three buckets, since one must stay empty + EXPECT_EQ(tbl.insert(3), nullptr); // With a full table and no resize, inserting a duplicate returns nullptr EXPECT_EQ(tbl.insert(2), nullptr); + EXPECT_EQ(tbl.size(), static_cast(2)); } TEST(OLhashTable, ResizeIncreasesCapacity) @@ -427,8 +435,9 @@ TEST(ODhashTable, RemoveMissingInFullTableDoesNotHang) hash_default_lower_alpha, hash_default_upper_alpha, /*with_resize*/false); - for (int i = 0; i < 5; ++i) + for (int i = 0; i < 4; ++i) ASSERT_NE(tbl.insert(i), nullptr); + EXPECT_EQ(tbl.insert(4), nullptr); // full: one bucket stays empty // Missing key must throw in finite time (no infinite loop) EXPECT_THROW(tbl.remove(99), std::domain_error); @@ -1532,7 +1541,8 @@ namespace // A key whose copies can be made to throw. struct Fragile_Key { - static inline bool fail = false; + // How many copies still succeed. Negative: none fails. + static inline long copies_before_failure = -1; long value = 0; @@ -1558,11 +1568,12 @@ namespace bool operator==(const Fragile_Key &) const = default; - private: static void may_throw() { - if (fail) + if (copies_before_failure == 0) throw std::runtime_error("no copy"); + if (copies_before_failure > 0) + --copies_before_failure; } }; @@ -1576,10 +1587,15 @@ namespace return second_hash_a(key.value); } + // While it lives, copying a key throws after `successful` copies. struct Failing_Copies { - Failing_Copies() { Fragile_Key::fail = true; } - ~Failing_Copies() { Fragile_Key::fail = false; } + explicit Failing_Copies(const long successful = 0) + { + Fragile_Key::copies_before_failure = successful; + } + + ~Failing_Copies() { Fragile_Key::copies_before_failure = -1; } }; template @@ -1771,4 +1787,684 @@ namespace const OD with_defaults(11, plus_ten, plus_ten); EXPECT_EQ(with_defaults.get_second_hash_fct()(1), 11u); } + + // ------------------------------------------------------------------------- + // An insertion that throws leaves an open addressing table as it was + // ------------------------------------------------------------------------- + + // A key with copy operations only, as many older classes are. Moving it + // copies it, so swapping two of them can throw. + struct Copy_Only_Key + { + long value = 0; + + Copy_Only_Key() = default; + + Copy_Only_Key(const long v) : value(v) {} + + Copy_Only_Key(const Copy_Only_Key & other) : value(other.value) + { + Fragile_Key::may_throw(); + } + + Copy_Only_Key & operator=(const Copy_Only_Key & other) + { + Fragile_Key::may_throw(); + value = other.value; + return *this; + } + + bool operator==(const Copy_Only_Key &) const = default; + }; + + static_assert(noexcept(std::swap(std::declval(), + std::declval()))); + static_assert(not noexcept(std::swap(std::declval(), + std::declval()))); + + // Hash functions that send many keys to the same buckets, so that the + // insertions go through the buckets of removed keys and, in the double + // hashing table, through its three kinds of probe. + template size_t crowded_hash(const Key & key) noexcept + { + return static_cast(key.value) % 7; + } + + template size_t crowded_second_hash(const Key & key) noexcept + { + return static_cast(key.value) % 5; + } + + template struct OpenTableInsertionThatThrows + : public ::testing::Test + { + using Key = typename Table::Key_Type; + + static Table make(const size_t len, const float upper_alpha, + const bool resizes) + { + if constexpr (requires (Table & t) { t.get_second_hash_fct(); }) + return Table(len, crowded_hash, crowded_second_hash, + Aleph::equal_to(), 0.05f, upper_alpha, resizes); + else + return Table(len, crowded_hash, Aleph::equal_to(), + 0.05f, upper_alpha, resizes); + } + + // Keys [0, 20) without six of them, whose buckets are left behind. + static Table with_keys_and_removals(const bool resizes) + { + Table table = make(31, 0.95f, resizes); + for (long k = 0; k < 20; ++k) + EXPECT_NE(table.insert(Key(k)), nullptr) << "key " << k; + for (const long k : {1L, 4L, 7L, 10L, 13L, 16L}) + table.remove(Key(k)); + return table; + } + + static std::vector keys_of(const Table & table) + { + std::vector keys; + table.for_each([&keys] (const Key & key) { keys.push_back(key.value); }); + std::sort(keys.begin(), keys.end()); + return keys; + } + + // What the probes depend on: the state of every bucket and, in the + // double hashing table, how many probe sequences go through it. In + // these tests every removed key's bucket lies on the probe sequence of + // a key still stored, so undoing an insertion must restore them all. + static std::vector> probes_of(const Table & table) + { + std::vector> state; + for (size_t i = 0; i < table.capacity(); ++i) + if constexpr (requires { table.table[0].probe_counter; }) + state.emplace_back(table.table[i].status, + table.table[i].probe_counter); + else + state.emplace_back(table.table[i].status, 0u); + return state; + } + }; + + using Tables_With_Fragile_Keys = + ::testing::Types, ODhashTable>; + TYPED_TEST_SUITE(OpenTableInsertionThatThrows, Tables_With_Fragile_Keys); + + // The bucket was marked as busy before the key was copied into it. When + // the copy threw, the table kept one entry more, holding whatever the + // bucket had: a key by default or one that had been removed. + TYPED_TEST(OpenTableInsertionThatThrows, KeyCopyThatThrowsLeavesNoEntry) + { + for (const bool resizes : {false, true}) + { + TypeParam table = this->with_keys_and_removals(resizes); + const auto keys = this->keys_of(table); + const auto probes = this->probes_of(table); + const size_t capacity = table.capacity(); + ASSERT_EQ(keys.size(), 14u); + + for (long k = 0; k < 60; ++k) + for (int operation = 0; operation < 4; ++operation) + { + const Fragile_Key key(k); + const bool present = table.contains(key); + bool threw = false; + { + Failing_Copies failing; + try + { + switch (operation) + { + case 0: (void) table.insert(key); break; + case 1: (void) table.search_or_insert(key); break; + case 2: (void) table.contains_or_insert(key); break; + default: (void) table[key]; break; + } + } + catch (const std::runtime_error &) + { + threw = true; + } + } + // a key that is already there is not copied + ASSERT_EQ(threw, not present) + << "key " << k << ", operation " << operation; + ASSERT_EQ(this->keys_of(table), keys) + << "key " << k << ", operation " << operation; + ASSERT_EQ(table.size(), keys.size()); + ASSERT_EQ(table.capacity(), capacity); + ASSERT_EQ(this->probes_of(table), probes) + << "key " << k << ", operation " << operation; + } + + // and the table goes on working + for (long k = 0; k < 28; ++k) + { + const bool present = table.contains(Fragile_Key(k)); + EXPECT_EQ(table.insert(Fragile_Key(k)) != nullptr, not present) + << "key " << k; + } + for (long k = 0; k < 28; ++k) + EXPECT_TRUE(table.contains(Fragile_Key(k))) << "key " << k; + for (long k = 0; k < 28; ++k) + table.remove(Fragile_Key(k)); + EXPECT_TRUE(table.is_empty()); + } + } + + // The key is stored and then the table grows, which copies the key once + // more and asks for memory. If that failed, the insertion threw and the + // key stayed in the table. + TYPED_TEST(OpenTableInsertionThatThrows, GrowthThatFailsTakesTheKeyOutAgain) + { + for (int operation = 0; operation < 5; ++operation) + { + TypeParam table = this->make(11, 0.5f, true); + for (long k = 0; k < 5; ++k) + ASSERT_NE(table.insert(Fragile_Key(k)), nullptr); + ASSERT_EQ(table.capacity(), 11u); // the sixth key makes it grow + const auto keys = this->keys_of(table); + const auto probes = this->probes_of(table); + + const Fragile_Key key(5); + { + // the copy into the bucket succeeds; the one made to grow fails + Failing_Copies failing(operation == 4 ? 0 : 1); + switch (operation) + { + case 0: + EXPECT_THROW((void) table.insert(key), std::runtime_error); + break; + case 1: + EXPECT_THROW((void) table.search_or_insert(key), + std::runtime_error); + break; + case 2: + EXPECT_THROW((void) table.contains_or_insert(key), + std::runtime_error); + break; + case 3: + EXPECT_THROW((void) table[key], std::runtime_error); + break; + default: // moved in: the only copy is the one made to grow + EXPECT_THROW((void) table.insert(Fragile_Key(5)), + std::runtime_error); + break; + } + } + + EXPECT_EQ(this->keys_of(table), keys) << "operation " << operation; + EXPECT_EQ(table.size(), 5u) << "operation " << operation; + EXPECT_EQ(table.capacity(), 11u) << "operation " << operation; + EXPECT_FALSE(table.contains(key)) << "operation " << operation; + EXPECT_EQ(this->probes_of(table), probes) + << "operation " << operation; + + ASSERT_NE(table.insert(key), nullptr); + EXPECT_GT(table.capacity(), 11u); + for (long k = 0; k < 6; ++k) + EXPECT_TRUE(table.contains(Fragile_Key(k))) << "key " << k; + } + } + + // A search that reaches an empty bucket stops there. An insertion that + // threw left the bucket it had reserved as the one of a removed key, so + // a few of them could use up the empty buckets of a table far from full. + TYPED_TEST(OpenTableInsertionThatThrows, FailuresDoNotUseUpEmptyBuckets) + { + TypeParam table = this->make(11, 0.35f, true); + for (const long k : {1L, 3L, 5L}) + ASSERT_NE(table.insert(Fragile_Key(k)), nullptr); + ASSERT_EQ(table.capacity(), 11u); // a fourth key makes it grow + const auto probes = this->probes_of(table); + const size_t empty = table.stats().num_empty; + + // each key goes to an empty bucket that a busy one follows + for (const long k : {0L, 2L, 4L}) + { + const Fragile_Key key(k); + { + Failing_Copies failing; // the copy into the bucket fails + EXPECT_THROW((void) table.insert(key), std::runtime_error) + << "key " << k; + } + { + Failing_Copies failing(1); // the copy made to grow fails + EXPECT_THROW((void) table.insert(key), std::runtime_error) + << "key " << k; + } + } + + EXPECT_EQ(table.size(), 3u); + EXPECT_EQ(table.capacity(), 11u); + EXPECT_EQ(table.stats().num_empty, empty); + EXPECT_EQ(this->probes_of(table), probes); + } + + // A removed key's bucket that no probe sequence goes through is not + // needed. When a failed insertion had reserved one, it is left empty, + // and so are the unneeded removed keys' buckets right before it. + TEST(OLhashTable, FailedInsertionClearsRemovedMarksNoOneNeeds) + { + OLhashTable table(11, crowded_hash, + Aleph::equal_to(), 0.05f, + 0.95f, false); + for (const long k : {1L, 2L, 3L}) + ASSERT_NE(table.insert(Fragile_Key(k)), nullptr); + table.remove(Fragile_Key(1)); + table.remove(Fragile_Key(2)); + ASSERT_EQ(table.stats().num_deleted, 2u); + + { + const Fragile_Key key(9); // its probe sequence starts at bucket 2 + Failing_Copies failing; + EXPECT_THROW((void) table.insert(key), std::runtime_error); + } + + EXPECT_EQ(table.size(), 1u); + EXPECT_TRUE(table.contains(Fragile_Key(3))); + EXPECT_EQ(table.stats().num_deleted, 0u); + EXPECT_EQ(table.stats().num_empty, 10u); + } + + // Real insertions and removals mixed with insertions that fail, checked + // against a std::set. + TYPED_TEST(OpenTableInsertionThatThrows, FailuresDoNotDisturbTheTable) + { + TypeParam table = this->make(11, 0.7f, true); + std::set oracle; + std::mt19937 rng(20261013); + + auto check = [&] (const int step) + { + ASSERT_EQ(table.size(), oracle.size()) << "step " << step; + for (long k = 0; k < 150; ++k) + ASSERT_EQ(table.contains(Fragile_Key(k)), oracle.count(k) == 1) + << "key " << k << ", step " << step; + }; + + for (int step = 0; step < 20000; ++step) + { + const long k = static_cast(rng() % 150); + const Fragile_Key key(k); + switch (rng() % 5) + { + case 0: // a real insertion + EXPECT_EQ(table.insert(key) != nullptr, oracle.insert(k).second); + break; + case 1: // a real removal + if (oracle.erase(k) == 1) + table.remove(key); + else + EXPECT_THROW(table.remove(key), std::domain_error); + break; + case 2: // the copy into the bucket fails + { + Failing_Copies failing; + try + { + EXPECT_EQ(table.search_or_insert(key) != nullptr, true); + EXPECT_EQ(oracle.count(k), 1u); // it was already there + } + catch (const std::runtime_error &) + { + EXPECT_EQ(oracle.count(k), 0u); + } + } + break; + default: // the copy made to grow fails, when the table grows + { + Failing_Copies failing(1); + try + { + if (table.insert(key) != nullptr) + oracle.insert(k); + } + catch (const std::runtime_error &) + { + EXPECT_EQ(oracle.count(k), 0u); + } + } + break; + } + if (step % 500 == 0) + check(step); + } + check(20000); + + for (const long k : oracle) + table.remove(Fragile_Key(k)); + EXPECT_TRUE(table.is_empty()); + } + + template struct OpenTableWithCopyOnlyKeys + : public OpenTableInsertionThatThrows {}; + + using Tables_With_Copy_Only_Keys = + ::testing::Types, ODhashTable>; + TYPED_TEST_SUITE(OpenTableWithCopyOnlyKeys, Tables_With_Copy_Only_Keys); + + // The insertions that take the key by rvalue swap it into the bucket. A + // swap that can throw could leave both the table and the argument + // broken, so such a key is copied, and the copy that fails is undone. + TYPED_TEST(OpenTableWithCopyOnlyKeys, MovedInKeyThatThrowsLeavesNoEntry) + { + for (const bool resizes : {false, true}) + { + TypeParam table = this->with_keys_and_removals(resizes); + const auto keys = this->keys_of(table); + const auto probes = this->probes_of(table); + + for (long k = 0; k < 60; ++k) + for (int operation = 0; operation < 3; ++operation) + { + Copy_Only_Key key(k); + const bool present = table.contains(key); + bool threw = false; + { + Failing_Copies failing; + try + { + switch (operation) + { + case 0: (void) table.insert(std::move(key)); break; + case 1: + (void) table.search_or_insert(std::move(key)); + break; + default: + (void) table.contains_or_insert(std::move(key)); + break; + } + } + catch (const std::runtime_error &) + { + threw = true; + } + } + ASSERT_EQ(threw, not present) + << "key " << k << ", operation " << operation; + ASSERT_EQ(key.value, k) << "the argument was not touched"; + ASSERT_EQ(this->keys_of(table), keys) + << "key " << k << ", operation " << operation; + ASSERT_EQ(this->probes_of(table), probes) + << "key " << k << ", operation " << operation; + } + + for (long k = 20; k < 28; ++k) + EXPECT_NE(table.insert(Copy_Only_Key(k)), nullptr) << "key " << k; + for (long k = 20; k < 28; ++k) + EXPECT_TRUE(table.contains(Copy_Only_Key(k))) << "key " << k; + } + } + + // Growing places the keys in new buckets, and such keys are copied there. + // A copy that failed halfway left the table with the keys placed so far: + // the others were lost, and the old buckets leaked. + TYPED_TEST(OpenTableWithCopyOnlyKeys, GrowthThatFailsHalfwayLeavesTheTable) + { + // the key is copied into its bucket and once more before growing; the + // next 6 copies place the 6 keys in the new buckets + for (size_t successful = 2; successful < 8; ++successful) + { + TypeParam table = this->make(11, 0.5f, true); + for (long k = 0; k < 5; ++k) + ASSERT_NE(table.insert(Copy_Only_Key(k)), nullptr); + ASSERT_EQ(table.capacity(), 11u); // the sixth key makes it grow + const auto keys = this->keys_of(table); + const auto probes = this->probes_of(table); + + const Copy_Only_Key key(5); + { + Failing_Copies failing(successful); + EXPECT_THROW((void) table.insert(key), std::runtime_error) + << successful << " copies"; + } + + EXPECT_EQ(this->keys_of(table), keys) << successful << " copies"; + EXPECT_EQ(table.size(), 5u) << successful << " copies"; + EXPECT_EQ(table.capacity(), 11u) << successful << " copies"; + EXPECT_EQ(this->probes_of(table), probes) << successful << " copies"; + + ASSERT_NE(table.insert(key), nullptr); + EXPECT_GT(table.capacity(), 11u); + for (long k = 0; k < 6; ++k) + EXPECT_TRUE(table.contains(Copy_Only_Key(k))) << "key " << k; + } + } + + // rehash() and resize() place the keys in new buckets the same way. + TYPED_TEST(OpenTableWithCopyOnlyKeys, RebuildThatFailsLeavesTheTable) + { + TypeParam table = this->with_keys_and_removals(false); + const auto keys = this->keys_of(table); + const auto probes = this->probes_of(table); + const size_t capacity = table.capacity(); + const size_t larger = Primes::next_prime(2 * capacity); + + for (size_t successful = 0; successful < keys.size(); ++successful) + { + { + Failing_Copies failing(successful); + EXPECT_THROW(table.rehash(), std::runtime_error) << successful; + } + ASSERT_EQ(this->keys_of(table), keys) << successful; + ASSERT_EQ(this->probes_of(table), probes) << successful; + ASSERT_EQ(table.capacity(), capacity) << successful; + + { + Failing_Copies failing(successful); + EXPECT_THROW((void) table.resize(larger), std::runtime_error) + << successful; + } + ASSERT_EQ(this->keys_of(table), keys) << successful; + ASSERT_EQ(this->probes_of(table), probes) << successful; + ASSERT_EQ(table.capacity(), capacity) << successful; + } + + table.rehash(); + EXPECT_EQ(this->keys_of(table), keys); + EXPECT_EQ(table.stats().num_deleted, 0u); + EXPECT_EQ(table.resize(larger), larger); + EXPECT_EQ(this->keys_of(table), keys); + } + + // ------------------------------------------------------------------------- + // A table that does not resize itself is full with capacity() - 1 keys + // ------------------------------------------------------------------------- + + template struct OpenTableWithoutResizing + : public ::testing::Test + { + static Table make(const size_t len) + { + if constexpr (requires (Table & t) { t.get_second_hash_fct(); }) + return Table(len, Aleph::dft_hash_ptr_fct, + Aleph::snd_hash_ptr_fct, Aleph::equal_to(), + hash_default_lower_alpha, hash_default_upper_alpha, false); + else + return Table(len, Aleph::dft_hash_ptr_fct, Aleph::equal_to(), + hash_default_lower_alpha, hash_default_upper_alpha, false); + } + + // Inserts keys 0, 1, 2... until the table refuses one, and returns how + // many it took. + static size_t fill(Table & table) + { + size_t taken = 0; + for (long k = 0; k < 1000 and table.insert(k) != nullptr; ++k) + ++taken; + return taken; + } + }; + + using Open_Tables_Of_Longs = + ::testing::Types, ODhashTable>; + TYPED_TEST_SUITE(OpenTableWithoutResizing, Open_Tables_Of_Longs); + + // A search that misses stops at an empty bucket, so the table keeps one. + // It used to take a key in every bucket, and the double hashing table + // then failed an assertion on the next insertion. + TYPED_TEST(OpenTableWithoutResizing, KeepsOneBucketEmpty) + { + TypeParam table = this->make(11); + ASSERT_EQ(table.capacity(), 11u); + + EXPECT_EQ(this->fill(table), 10u); + EXPECT_EQ(table.size(), 10u); + EXPECT_EQ(table.capacity(), 11u); + EXPECT_EQ(table.stats().num_empty, 1u); + + const long absent = 500; + EXPECT_EQ(table.insert(absent), nullptr); + EXPECT_EQ(table.insert(long(absent)), nullptr); + EXPECT_FALSE(table.contains(absent)); + EXPECT_EQ(table.size(), 10u); + + // a removal makes room for one key + table.remove(3L); + EXPECT_NE(table.insert(absent), nullptr); + EXPECT_EQ(table.insert(3L), nullptr); + EXPECT_EQ(table.size(), 10u); + EXPECT_EQ(table.stats().num_busy, 10u); + } + + // A full table still finds the keys it has, and refuses the others. + // operator[] dereferenced the null pointer that it got for a new key. + TYPED_TEST(OpenTableWithoutResizing, FullTableFindsItsKeysAndRefusesNewOnes) + { + TypeParam table = this->make(11); + ASSERT_EQ(this->fill(table), 10u); + const long present = 3, absent = 500; + long * const stored = table.search(present); + ASSERT_NE(stored, nullptr); + + EXPECT_EQ(table.search_or_insert(present), stored); + EXPECT_EQ(table.search_or_insert(long(present)), stored); + EXPECT_EQ(table.contains_or_insert(present), + std::make_pair(stored, true)); + EXPECT_EQ(table.contains_or_insert(long(present)), + std::make_pair(stored, true)); + EXPECT_EQ(&table[present], stored); + + EXPECT_EQ(table.search_or_insert(absent), nullptr); + EXPECT_EQ(table.search_or_insert(long(absent)), nullptr); + EXPECT_EQ(table.contains_or_insert(absent), + std::make_pair(static_cast(nullptr), false)); + EXPECT_EQ(table.contains_or_insert(long(absent)), + std::make_pair(static_cast(nullptr), false)); + EXPECT_THROW((void) table[absent], std::overflow_error); + + EXPECT_EQ(table.size(), 10u); + EXPECT_FALSE(table.contains(absent)); + } + + template struct OpenMapWithoutResizing : public ::testing::Test + {}; + + using Open_Maps = ::testing::Types, MapODhash>; + TYPED_TEST_SUITE(OpenMapWithoutResizing, Open_Maps); + + // The maps over open tables index through search_or_insert() too. + TYPED_TEST(OpenMapWithoutResizing, SubscriptOnAFullMapThrows) + { + TypeParam map(11, Aleph::dft_hash_ptr_fct, + Aleph::snd_hash_ptr_fct, Aleph::equal_to(), + hash_default_lower_alpha, hash_default_upper_alpha, false); + for (long k = 0; k < 10; ++k) + ASSERT_NE(map.insert(k, static_cast(k)), nullptr) << "key " << k; + EXPECT_EQ(map.insert(10L, 10), nullptr); // full + + map[3L] = 30; + const long present = 4; + map[present] = 40; + EXPECT_EQ(map.find(3L), 30); + EXPECT_EQ(map.find(4L), 40); + + const long absent = 500; + EXPECT_THROW((void) map[absent], std::overflow_error); + EXPECT_THROW((void) map[501L], std::overflow_error); + EXPECT_EQ(map.size(), 10u); + EXPECT_FALSE(map.contains(absent)); + } + + // ------------------------------------------------------------------------- + // Shrinking leaves the keys under the upper threshold + // ------------------------------------------------------------------------- + + template struct OpenTableShrinking : public ::testing::Test + { + static Table make(const size_t len, const float lower, const float upper) + { + if constexpr (requires (Table & t) { t.get_second_hash_fct(); }) + return Table(len, Aleph::dft_hash_ptr_fct, + Aleph::snd_hash_ptr_fct, Aleph::equal_to(), + lower, upper, true); + else + return Table(len, Aleph::dft_hash_ptr_fct, Aleph::equal_to(), + lower, upper, true); + } + }; + + TYPED_TEST_SUITE(OpenTableShrinking, Open_Tables_Of_Longs); + + // With a lower threshold over one half, a table of half the capacity may + // not hold the keys. remove_ptr() removed the key and then resize() + // threw std::overflow_error. Now the table waits until the keys fit. + TYPED_TEST(OpenTableShrinking, KeysFitInTheSmallerTable) + { + TypeParam table = this->make(101, 0.6f, 0.9f); + for (long k = 0; k < 60; ++k) + ASSERT_NE(table.insert(k), nullptr) << "key " << k; + ASSERT_EQ(table.capacity(), 101u); + + for (long k = 0; k < 55; ++k) + { + ASSERT_NO_THROW(table.remove_ptr(table.search(k))) << "key " << k; + ASSERT_FALSE(table.contains(k)) << "key " << k; + ASSERT_EQ(table.size(), static_cast(59 - k)); + ASSERT_LT(table.current_alpha(), 0.9f) << "key " << k; + ASSERT_LT(table.size(), table.capacity()) << "key " << k; + } + + EXPECT_LT(table.capacity(), 101u); // it shrank once the keys fitted + for (long k = 55; k < 60; ++k) + EXPECT_TRUE(table.contains(k)) << "key " << k; + } + + // A smaller table where the keys leave exactly one bucket empty is good + // enough: 6 keys fit in 7 buckets under 0.9. + TYPED_TEST(OpenTableShrinking, ShrinksWhenTheKeysLeaveOneBucketEmpty) + { + TypeParam table = this->make(11, 0.6f, 0.9f); + for (long k = 0; k < 7; ++k) + ASSERT_NE(table.insert(k), nullptr) << "key " << k; + ASSERT_EQ(table.capacity(), 11u); + + table.remove_ptr(table.search(0L)); + + EXPECT_EQ(table.capacity(), 7u); + EXPECT_EQ(table.size(), 6u); + EXPECT_EQ(table.stats().num_empty, 1u); + for (long k = 1; k < 7; ++k) + EXPECT_TRUE(table.contains(k)) << "key " << k; + } + + // With the default thresholds the table still shrinks as before. + TYPED_TEST(OpenTableShrinking, DefaultThresholdsStillShrink) + { + TypeParam table = this->make(11, hash_default_lower_alpha, + hash_default_upper_alpha); + for (long k = 0; k < 1000; ++k) + ASSERT_NE(table.insert(k), nullptr) << "key " << k; + const size_t grown = table.capacity(); + + for (long k = 0; k < 990; ++k) + table.remove_ptr(table.search(k)); + + EXPECT_LT(table.capacity(), grown / 4); + EXPECT_EQ(table.size(), 10u); + for (long k = 990; k < 1000; ++k) + EXPECT_TRUE(table.contains(k)) << "key " << k; + } } // namespace diff --git a/hash-dry.H b/hash-dry.H index 881b32a1..ec111800 100644 --- a/hash-dry.H +++ b/hash-dry.H @@ -54,19 +54,39 @@ \ private: \ \ + /* Grows the table if the key just stored in curr_bucket took it over */ \ + /* the upper threshold, and returns where the key is. */ \ Key * test_resize(Bucket * curr_bucket, const Key & key) \ { \ if (not ((with_resize and this->current_alpha() >= upper_alpha))) \ return &curr_bucket->key; \ \ - Key key_copy = key; \ - this->resize(Primes::next_prime(2*len)); \ + return grow_after_insertion(curr_bucket, key); \ + } \ + \ + /* The growth itself, apart from the test that every insertion runs. */ \ + /* If the table cannot grow, the key is taken out again and the */ \ + /* exception goes on: an insertion that throws leaves the table as */ \ + /* it was. */ \ + Key * grow_after_insertion(Bucket * curr_bucket, const Key & key) \ + { \ + try \ + { \ + Key key_copy = key; \ + this->resize(Primes::next_prime(2*len)); \ \ - Key * key_ptr = search(key_copy); \ - assert(key_ptr != nullptr); \ - assert(cmp(*key_ptr, key_copy)); \ + Key * key_ptr = search(key_copy); \ + assert(key_ptr != nullptr); \ + assert(cmp(*key_ptr, key_copy)); \ \ - return key_ptr; \ + return key_ptr; \ + } \ + catch (...) \ + { \ + /* resize() leaves the buckets as they were when it fails */ \ + this->undo_allocate_bucket(curr_bucket, curr_bucket->key); \ + throw; \ + } \ } \ \ static void update_stat_len(DynArray & lens, size_t i) \ diff --git a/hashDry.H b/hashDry.H index a01c5d79..5f159f07 100644 --- a/hashDry.H +++ b/hashDry.H @@ -54,6 +54,9 @@ * @author Leandro Rabindranath León */ +# include +# include + # include # include # include @@ -84,10 +87,17 @@ - keys() / items() - Get list of all keys - Iterator - Bidirectional iteration over entries + @par Capacity: + The table always keeps at least one empty bucket, where a search that + misses can stop, so it is full with `capacity() - 1` keys. A full table + that does not resize itself takes no more keys: the insertions return + nullptr and `operator[]` throws `std::overflow_error`. + @par Automatic Resizing: When `with_resize` is enabled in the derived class, the table automatically: - Grows when it becomes nearly full (before insert would fail) - - Shrinks when load factor drops below `lower_alpha` threshold + - Shrinks when load factor drops below `lower_alpha` threshold, if the + keys fit under `upper_alpha` in a table of half the capacity @par Thread Safety: This class is NOT thread-safe. External synchronization is required @@ -110,6 +120,114 @@ class OhashCommon */ const HashTbl * const_me() const { return static_cast(this); } + /// Whether two keys can be swapped without throwing. The keys whose swap + /// can throw are copied instead, so that a failure leaves the table as it + /// was. + static constexpr bool nothrow_key_swap = + noexcept(std::swap(std::declval(), std::declval())); + + /** @brief Stores a copy of a key in the bucket reserved for it. + + If copying the key throws, the reservation is undone, so the table + does not keep a bucket that counts as an entry and holds another key. + + @tparam Bucket The bucket type of the table. It is a parameter + because the table is not a complete type here. + @param bucket Bucket that allocate_bucket() or hard_allocate_bucket() + has just reserved for `key`. + @param key The key to store. + @throw Anything that copying the key throws. The table is left as it + was before the bucket was reserved. + */ + template + void store_copy(Bucket * bucket, const Key & key) + { + if constexpr (std::is_nothrow_copy_assignable_v) + bucket->key = key; + else + try + { + bucket->key = key; + } + catch (...) + { + me()->undo_allocate_bucket(bucket, key); + throw; + } + } + + /** @brief Moves a key into the bucket reserved for it. + + The key and the previous content of the bucket are swapped. A key + whose swap may throw is copied instead: a copy that fails leaves the + key intact, so the reservation can be undone. + + @tparam Bucket The bucket type of the table. + @param bucket Bucket that allocate_bucket() or hard_allocate_bucket() + has just reserved for `key`. + @param key The key to store. + @throw Anything that copying the key throws, when it is copied. The + table is left as it was before the bucket was reserved. + */ + template + void store_move(Bucket * bucket, Key & key) + { + if constexpr (nothrow_key_swap) + std::swap(bucket->key, key); + else + store_copy(bucket, key); + } + + /** @brief Places the keys of the table in `new_len` new buckets. + + The keys are swapped into the new buckets. A key whose swap may throw + is copied instead, so that the old buckets keep every key until all + of them are placed. + + @param new_len Number of new buckets. It must leave an empty bucket. + @throw std::bad_alloc If there is no memory for the new buckets, and + anything that copying a key throws. The table is left as it + was. + */ + void rebuild(const size_t new_len) + { + auto * new_table = new typename HashTbl::Bucket[new_len]; + typename HashTbl::Bucket * old_table = me()->table; + const size_t old_len = me()->len; + const size_t old_N = me()->N; + + me()->table = new_table; + me()->len = new_len; + me()->N = 0; + + try + { + for (size_t i = 0, c = 0; i < old_len and c < old_N; ++i) + if (old_table[i].status == HashTbl::BUSY) + { + Key & key = old_table[i].key; + auto bucket = me()->allocate_bucket(key); + if constexpr (nothrow_key_swap) + std::swap(bucket->key, key); + else + bucket->key = key; + ++c; + } + } + catch (...) + { + me()->table = old_table; + me()->len = old_len; + me()->N = old_N; + delete [] new_table; + throw; + } + + assert(old_N == me()->N); + + delete [] old_table; + } + public: /** @brief Returns the lower load factor threshold for automatic shrinking. @@ -171,31 +289,39 @@ public: me()->N = 0; } - /** @brief Checks if resize is needed before inserting and performs it. - - Called before each insert operation to ensure there is at least - one empty bucket available (required as a sentinel for probing). - If automatic resizing is enabled and the table is nearly full, - doubles the table capacity. + /** @brief Makes room for one more key, if the table can take it. + + Called before each insert operation. The table always keeps at least + one empty bucket, as a sentinel for probing, so it is full with + `capacity() - 1` keys. A full table doubles its capacity if automatic + resizing is enabled; otherwise it takes no more keys. A table whose storage was taken by a move has no buckets. It gets here a table of the default capacity, whether or not automatic resizing is enabled. + @return true if the table can take one more key; false if it is full + and does not resize itself. @throw std::bad_alloc If there is no memory for the new buckets. The table is left as it was. */ - void check_resize_before_insert() + bool check_resize_before_insert() { if (me()->len == 0) [[unlikely]] { empty(); - return; + return true; } // Keep at least one EMPTY bucket as a sentinel. - if (me()->with_resize and me()->N >= me()->len - 1) - me()->resize(Primes::next_prime(2 * me()->len)); + if (me()->N < me()->len - 1) [[likely]] + return true; + + if (not me()->with_resize) + return false; + + me()->resize(Primes::next_prime(2 * me()->len)); + return true; } /** @brief Inserts a key into the hash table (copy version). @@ -205,23 +331,28 @@ public: @param key The key to insert. @return Pointer to the inserted key, or nullptr if key already exists - or allocation failed. + or the table is full and does not resize itself. + + @throw Anything that copying the key throws, and std::bad_alloc if the + table cannot grow. The table is left as it was. @note This function is marked [[nodiscard]] - check the return value to detect insertion failures. - + @par Complexity: - Average case: O(1) amortized - Worst case: O(n) when rehashing is triggered */ [[nodiscard]] Key * insert(const Key & key) { - check_resize_before_insert(); + if (not check_resize_before_insert()) + return nullptr; + auto bucket = me()->allocate_bucket(key); if (bucket == nullptr) return nullptr; - bucket->key = key; + store_copy(bucket, key); return me()->test_resize(bucket, key); } @@ -233,23 +364,29 @@ public: @param key The key to insert (will be moved from on success). @return Pointer to the inserted key, or nullptr if key already exists - or allocation failed. + or the table is full and does not resize itself. + + @throw std::bad_alloc If the table cannot grow, and anything that + copying the key throws when the table has to copy it. The table + is left as it was; `key` may have been moved from. @note This function is marked [[nodiscard]] - check the return value to detect insertion failures. - + @par Complexity: - Average case: O(1) amortized - Worst case: O(n) when rehashing is triggered */ [[nodiscard]] Key * insert(Key && key) { - check_resize_before_insert(); + if (not check_resize_before_insert()) + return nullptr; + auto bucket = me()->allocate_bucket(key); if (bucket == nullptr) return nullptr; - std::swap(bucket->key, key); + store_move(bucket, key); return me()->test_resize(bucket, bucket->key); } @@ -261,18 +398,24 @@ public: @param key The key to search for or insert. @return Pointer to the existing or newly inserted key, or nullptr - if allocation failed. + if the key is not in the table and the table is full and + does not resize itself. + + @throw Anything that copying the key throws, and std::bad_alloc if the + table cannot grow. The table is left as it was. @note This function is marked [[nodiscard]] - the return value indicates success/failure and provides the key pointer. - + @par Complexity: - Average case: O(1) amortized - Worst case: O(n) when rehashing is triggered */ [[nodiscard]] Key * search_or_insert(const Key & key) { - check_resize_before_insert(); + if (not check_resize_before_insert()) + return me()->search(key); // full: only a key already there is found + auto r = me()->hard_allocate_bucket(key); auto bucket = get<0>(r); if (bucket == nullptr) @@ -280,7 +423,7 @@ public: if (get<1>(r)) return &bucket->key; - bucket->key = key; + store_copy(bucket, key); return me()->test_resize(bucket, bucket->key); } @@ -293,18 +436,25 @@ public: @param key The key to search for or insert (moved from on insert). @return Pointer to the existing or newly inserted key, or nullptr - if allocation failed. + if the key is not in the table and the table is full and + does not resize itself. + + @throw std::bad_alloc If the table cannot grow, and anything that + copying the key throws when the table has to copy it. The table + is left as it was; `key` may have been moved from. @note This function is marked [[nodiscard]] - the return value indicates success/failure and provides the key pointer. - + @par Complexity: - Average case: O(1) amortized - Worst case: O(n) when rehashing is triggered */ [[nodiscard]] Key * search_or_insert(Key && key) { - check_resize_before_insert(); + if (not check_resize_before_insert()) + return me()->search(key); // full: only a key already there is found + auto r = me()->hard_allocate_bucket(key); auto bucket = get<0>(r); if (bucket == nullptr) @@ -312,7 +462,7 @@ public: if (get<1>(r)) return &bucket->key; - std::swap(bucket->key, key); + store_move(bucket, key); return me()->test_resize(bucket, bucket->key); } @@ -325,9 +475,13 @@ public: @param key The key to search for or insert. @return A pair containing: - first: Pointer to the key (existing or newly inserted), - or nullptr on failure. + or nullptr if the key is not in the table and the + table is full and does not resize itself. - second: true if the key was already present, false if inserted. + @throw Anything that copying the key throws, and std::bad_alloc if the + table cannot grow. The table is left as it was. + @note This function is marked [[nodiscard]]. @par Example: @@ -345,7 +499,12 @@ public: */ [[nodiscard]] std::pair contains_or_insert(const Key & key) { - check_resize_before_insert(); + if (not check_resize_before_insert()) + { + Key * key_ptr = me()->search(key); // full: only a key already there + return { key_ptr, key_ptr != nullptr }; + } + auto r = me()->hard_allocate_bucket(key); auto bucket = get<0>(r); if (bucket == nullptr) @@ -353,7 +512,7 @@ public: if (get<1>(r)) return { &bucket->key, true }; - bucket->key = key; + store_copy(bucket, key); auto key_ptr = me()->test_resize(bucket, bucket->key); return { key_ptr, false }; @@ -367,9 +526,14 @@ public: @param key The key to search for or insert (moved from on insert). @return A pair containing: - first: Pointer to the key (existing or newly inserted), - or nullptr on failure. + or nullptr if the key is not in the table and the + table is full and does not resize itself. - second: true if the key was already present, false if inserted. + @throw std::bad_alloc If the table cannot grow, and anything that + copying the key throws when the table has to copy it. The table + is left as it was; `key` may have been moved from. + @note This function is marked [[nodiscard]]. @par Complexity: @@ -378,7 +542,12 @@ public: */ [[nodiscard]] std::pair contains_or_insert(Key && key) { - check_resize_before_insert(); + if (not check_resize_before_insert()) + { + Key * key_ptr = me()->search(key); // full: only a key already there + return { key_ptr, key_ptr != nullptr }; + } + auto r = me()->hard_allocate_bucket(key); auto bucket = get<0>(r); if (bucket == nullptr) @@ -386,7 +555,7 @@ public: if (get<1>(r)) return { &bucket->key, true }; - std::swap(bucket->key, key); + store_move(bucket, key); auto key_ptr = me()->test_resize(bucket, bucket->key); return { key_ptr, false }; @@ -492,10 +661,18 @@ public: @param key The key to access or insert. @return Reference to the stored key. + @throw std::overflow_error If the key is not in the table and the + table is full and does not resize itself. + @throw Anything that copying the key throws, and std::bad_alloc if the + table cannot grow. The table is left as it was. */ const Key & operator [] (const Key & key) { - return *search_or_insert(key); + Key * key_ptr = search_or_insert(key); + ah_overflow_error_if(key_ptr == nullptr) + << "Hash table is full and does not resize itself"; + + return *key_ptr; } /** @brief Removes an entry by pointer. @@ -504,13 +681,16 @@ public: must have been obtained from this table (via insert, search, etc.). If automatic resizing is enabled and the load factor drops below - the lower threshold, the table is shrunk. + the lower threshold, the table shrinks to about half its capacity, + provided the keys fit there under the upper threshold and leave an + empty bucket. With a lower threshold over one half they may not fit; + the table then keeps its capacity until enough keys are removed. @param key Pointer to the key to remove (must be in this table). - + @pre key must point to a valid entry in this table. @post The entry is removed and size() decreases by 1. - + @par Complexity: O(1) amortized. */ void remove_ptr(Key * key) @@ -519,7 +699,14 @@ public: me()->deallocate_bucket(bucket); if (me()->with_resize and me()->current_alpha() < me()->lower_alpha) - resize(Primes::next_prime(me()->len/2 + 1)); + { + // resize() would refuse a size without room for the keys, and the + // key is already gone; a size that leaves them over the upper + // threshold would make the next insertion grow the table again. + const size_t new_len = Primes::next_prime(me()->len/2 + 1); + if (me()->N < new_len and 1.0f*me()->N/new_len < me()->upper_alpha) + resize(new_len); + } } /** @brief Resizes the hash table to a new capacity. @@ -532,7 +719,10 @@ public: @return The actual new capacity (may differ from requested if new_size was 0 or equal to current capacity). @throw std::overflow_error If new_size is too small for current entries. - + @throw std::bad_alloc If there is no memory for the new buckets, and + anything that copying a key throws when its swap may throw. + The table is left as it was. + @par Complexity: O(n) where n is the number of entries. */ size_t resize(size_t new_size) @@ -544,28 +734,7 @@ public: ah_overflow_error_if(me()->N >= new_size) << "New size is not enough for current number of entries"; - auto * new_table = new typename HashTbl::Bucket[new_size]; - typename HashTbl::Bucket * old_table = me()->table; - const size_t old_len = me()->len; - - int old_N = me()->N; - - me()->table = new_table; - me()->len = new_size; - me()->N = 0; - - for (int i = 0, c = 0; i < old_len and c < old_N; ++i) - if (old_table[i].status == HashTbl::BUSY) - { - Key & key = old_table[i].key; - auto bucket = me()->allocate_bucket(key); - std::swap(bucket->key, key); - ++c; - } - - assert(old_N == me()->N); - - delete [] old_table; + rebuild(new_size); return me()->len; } @@ -575,7 +744,11 @@ public: Creates a new internal table with the same capacity and re-inserts all entries. This can help improve performance after many deletions by eliminating deleted bucket markers. - + + @throw std::bad_alloc If there is no memory for the new buckets, and + anything that copying a key throws when its swap may throw. + The table is left as it was. + @par Complexity: O(n) where n is the number of entries. */ void rehash() @@ -583,26 +756,7 @@ public: if (me()->len == 0) return; // the storage was taken by a move: nothing to rebuild - auto new_table = new typename HashTbl::Bucket [me()->len]; - auto old_table = me()->table; - - int old_N = me()->N; - - me()->table = new_table; - me()->N = 0; - - for (int i = 0, c = 0; i < me()->len and c < old_N; ++i) - if (old_table[i].status == HashTbl::BUSY) - { - Key & key = old_table[i].key; - auto bucket = me()->allocate_bucket(key); - std::swap(bucket->key, key); - ++c; - } - - assert(old_N == me()->N); - - delete [] old_table; + rebuild(me()->len); } /** @brief Clears all entries and resets to default capacity. diff --git a/tpl_dynMapOhash.H b/tpl_dynMapOhash.H index 118f2de8..e57d54bc 100644 --- a/tpl_dynMapOhash.H +++ b/tpl_dynMapOhash.H @@ -460,12 +460,18 @@ struct MapOpenHash : public HashTable, Dft_Pair_Cmp::value, "MapOpenHash::operator[] requires Data to be default-constructible"); - return this->search_or_insert(Pair(key, Data()))->second; + Pair * pair = this->search_or_insert(Pair(key, Data())); + ah_overflow_error_if(pair == nullptr) + << "Hash table is full and does not resize itself"; + + return pair->second; } /** @brief Access value by key (const version). @@ -484,12 +490,19 @@ struct MapOpenHash : public HashTable, Dft_Pair_Cmp::value, "MapOpenHash::operator[] requires Data to be default-constructible"); - return this->search_or_insert(Pair(std::move(key), Data()))->second; + Pair * pair = this->search_or_insert(Pair(std::move(key), Data())); + ah_overflow_error_if(pair == nullptr) + << "Hash table is full and does not resize itself"; + + return pair->second; } /** @brief Access value by key (const, move). diff --git a/tpl_odhash.H b/tpl_odhash.H index 44749765..452fd824 100644 --- a/tpl_odhash.H +++ b/tpl_odhash.H @@ -299,6 +299,15 @@ namespace Aleph deallocate_bucket_with_record(bucket, [](Bucket *) {}); } + /// Undoes what allocate_bucket() or hard_allocate_bucket() did when + /// they reserved `bucket` for `key`, which was never stored in it. The + /// probe counters are walked back with `key`, not with whatever the + /// bucket holds. + void undo_allocate_bucket(Bucket *bucket, const Key & key) noexcept + { + release_bucket(bucket, key, [](Bucket *) {}); + } + size_t index_forward(size_t & i) const noexcept { assert(i < len); @@ -367,16 +376,28 @@ namespace Aleph template void deallocate_bucket_with_record(Bucket *bucket, RecordBucket && record_bucket) noexcept + { + release_bucket(bucket, bucket->key, + std::forward(record_bucket)); + } + + /// Frees `bucket`, which was reached with the probe sequence of `key`, + /// and walks that sequence back: every bucket that the insertion of + /// `key` counted is counted down. `key` is the key stored in the + /// bucket, or the key that the bucket was reserved for when storing it + /// failed. `record_bucket` is called on each bucket before its counter + /// goes down. + template + void release_bucket(Bucket *bucket, const Key & key, + RecordBucket && record_bucket) noexcept { assert(bucket->status == BUSY); bucket->status = DELETED; - const Key & key = bucket->key; const size_t i_fst = hash_fct(key) % len; if (&table[i_fst] == bucket) { - assert(Cmp () (table[i_fst].key, key)); assert(table[i_fst].probe_type == FIRST_PROBE); } else @@ -384,7 +405,6 @@ namespace Aleph const size_t i_snd = second_hash_fct(key) % len; if (&table[i_snd] == bucket) { - assert(Cmp () (table[i_snd].key, key)); assert(table[i_snd].probe_type == SECOND_PROBE); record_bucket(&table[i_fst]); decrease_probe_counter(&table[i_fst]); @@ -402,7 +422,6 @@ namespace Aleph record_bucket(&table[i]); decrease_probe_counter(&table[i]); } - assert(Cmp () (table[i].key, key)); assert(table[i].probe_type == LINEAR_PROBE); } } diff --git a/tpl_olhash.H b/tpl_olhash.H index e0b13128..a2f77b88 100644 --- a/tpl_olhash.H +++ b/tpl_olhash.H @@ -612,6 +612,51 @@ namespace Aleph cleanup_deleted_chain(idx); } + /// Undoes what allocate_bucket() or hard_allocate_bucket() did when + /// they reserved `bucket` for a key that is not to stay in it. + /// + /// The bucket was empty or the one of a removed key. It stays marked + /// as removed only if a key stored after it, in the same run of + /// buckets that are not empty, was placed by a probe sequence that + /// went through it; otherwise it is empty again, and so are the + /// removed keys' buckets right before it. An empty bucket is never + /// lost: the searches that reached it stop there as before. + void undo_allocate_bucket(Bucket *bucket, const Key &) noexcept + { + assert(is_valid_bucket(bucket) and bucket->status == BUSY); + + --N; + const auto idx = static_cast(bucket - &table[0]); + size_t i = idx; + for (size_t steps = 1; steps < len; ++steps) + { + i = next_index(i); + const Bucket & b = table[i]; + if (b.status == EMPTY) + break; + if (b.status != BUSY) + continue; // a removed key's bucket: the run goes on + + // The probe sequence of b's key started `displacement` buckets + // before i, so it went through `bucket` if that is `steps` or more + const size_t displacement = (i + len - hash_fct(b.key) % len) % len; + if (displacement >= steps) + { + bucket->status = DELETED; + return; + } + } + + bucket->status = EMPTY; + size_t prev = prev_index(idx); + for (size_t count = 0; table[prev].status == DELETED and count < len; + ++count) + { + table[prev].status = EMPTY; + prev = prev_index(prev); + } + } + public: /** Remove the key referenced by key. key must be a valid reference