Why an unordered_map Lookup Can Insert a Key
unordered_map::operator[] is not a read-only lookup. If a key is missing, it attempts to insert a mapped value. With the default allocator and the string-to-int map used here, that value is zero. Consequently, using cache[key] to decide whether an entry exists can turn “no result” into “an existing zero” during the check itself.
Contents
Cache code, memoized algorithms, and counters need different missing-key contracts. The useful distinction is between an absent key and a present key whose legitimate value is zero. This C++17 program checks map size, lookup results, and load counts before choosing between find, at, and subscripting. 中文版.
What one subscript expression changes
Start with scores containing just known-zero mapped to zero. Reading scores[“absent”] also produces zero, but size grows from one to two and find(“absent”) now succeeds. The two mapped values compare equal. That does not establish that both keys existed before the lookup.
The element-access clause in the C++17 working draft N4659 specifies subscripting through try_emplace and access to the mapped value. A missing key therefore takes an insertion path. For the int in this example, the new value is zero.
known-zero → 0
size = 1
Insert absent → 0
Two distinct keys map to zero
size = 2
Zero is not uniquely problematic. An empty string, an empty collection, or a default application object can also be a valid result. Inspecting only a returned value collapses two states that the application may need to distinguish. If a subscript read is followed by find, the later check sees the newly inserted key. Using if(cache[key]) instead means a legitimate zero is treated as a miss. These mistakes point in different directions, but both lose the presence information that existed before the access.
Use find when absence is a normal branch
find returns an iterator, with end representing absence. Access second only after verifying that the iterator is not end. The branch supplies both presence and the existing value. There is no need to count first and then subscript, and checking a missing key does not add a placeholder entry.
The complete program binds a const reference named view to clean and performs view.find. The missing-key query leaves size at one. Looking up known-zero succeeds and exposes a second value of zero. The const view constrains this access path against modifying the container, and unordered_map does not provide its subscript interface on that const view.
A function that only reads can express that intent with a const-reference parameter. Const is not a synchronization mechanism, however. It does not make simultaneous writes from another thread safe. This program runs in one thread and establishes no concurrent-cache guarantee. The benefit here is a clearer interface contract, not a substitute for ownership and synchronization design.
Use at when the entry is required to exist
at does not manufacture a missing value either. It throws out_of_range when the key is absent. The program catches that exception type and verifies that map size remains unchanged; at on known-zero returns zero normally. This is useful where the caller has established an existence requirement and cannot continue normally if it is violated.
A cache miss that should trigger loading is an ordinary branch, so find fits it naturally. Reading a configuration item after required initialization can instead justify at. Decide what absence means before choosing syntax. The shorter expression is not necessarily the expression that preserves the intended behavior.
Return end
No new key
Throw out_of_range
No new key
Insert mapped value
Return its reference
Make loading and insertion explicit
The get lambda uses find first and returns immediately on a hit. Only a miss invokes load and then emplace. Calling get(“item”) twice returns 42 twice, with loads equal to one and cache.size equal to one. No zero sentinel is involved. A future loader returning a legitimate zero would not require treating that value as another miss.
The order also makes a boundary visible: this example inserts only after load completes. It has no TTL, capacity bound, invalidation policy, or merging of concurrent requests. It is not a production cache implementation. Multiple threads require a separate synchronization and loading-coordination design; a passing single-thread example is not evidence of thread safety.
This does not mean subscripting should be banned everywhere. In a frequency counter, ++counts[key] deliberately initializes a missing count to zero before incrementing it. An assignment such as cache[key] = value can also intentionally insert or replace. Ask whether that line is allowed to change the key set, rather than assigning a universal good or bad label to one interface.
The full program and nineteen checks
The example was compiled and run with Apple clang 21.0.0, -std=c++17, and the warning options -Wall -Wextra -pedantic. It prints 19 checks passed. The checks cover insertion by a missing subscript, a legitimate zero, const lookup, the at exception, one load across two calls, and find on a mapped type without a default constructor. No network access, performance benchmark, or cross-thread test was performed.
#include <iostream>
#include <stdexcept>
#include <string>
#include <unordered_map>
int checks = 0;
void check(bool ok) {
if (!ok) throw std::logic_error("check failed");
++checks;
}
int main() {
std::unordered_map<std::string, int> scores{{"known-zero", 0}};
check(scores.size() == 1);
check(scores["absent"] == 0);
check(scores.size() == 2);
check(scores.find("absent") != scores.end());
check(scores.at("known-zero") == scores.at("absent"));
std::unordered_map<std::string, int> clean{{"known-zero", 0}};
const auto& view = clean;
auto missing = view.find("absent");
check(missing == view.end());
check(clean.size() == 1);
auto zero = view.find("known-zero");
check(zero != view.end());
check(zero->second == 0);
try {
(void)view.at("absent");
throw std::logic_error("at should throw");
} catch (const std::out_of_range&) {
check(clean.size() == 1);
}
check(view.at("known-zero") == 0);
int loads = 0;
auto load = [&] { ++loads; return 42; };
std::unordered_map<std::string, int> cache;
auto get = [&](const std::string& key) {
auto it = cache.find(key);
if (it != cache.end()) return it->second;
int value = load();
cache.emplace(key, value);
return value;
};
check(get("item") == 42);
check(get("item") == 42);
check(loads == 1);
check(cache.size() == 1);
struct Entry {
int value;
explicit Entry(int v) : value(v) {}
};
std::unordered_map<std::string, Entry> entries;
entries.try_emplace("item", 7);
auto found = entries.find("item");
check(found != entries.end());
check(found->second.value == 7);
check(entries.find("missing") == entries.end());
check(entries.size() == 1);
std::cout << checks << " checks passed\n";
}
The final Entry type provides only a constructor taking an argument. try_emplace(“item”, 7) constructs it explicitly, and find reads it without requesting a default Entry. Do not add entries[“missing”] to this positive runnable example: with this mapped type and the default allocator, subscripting cannot default-construct the missing mapped value. A separate minimal negative example was syntax-checked and produced the expected no-matching-constructor error for Entry; it is kept outside the positive program above.
Establish semantics before evaluating cost
For this get implementation, a hit performs one find; a miss performs a lookup followed by an insertion operation. The unordered-container requirements distinguish average from worst-case complexity. Hash-table access is not a promise of constant time under every condition, and hashing and comparing keys have their own costs. No latency was measured here, so nineteen correctness checks do not demonstrate a performance improvement.
An unintended insertion can have consequences beyond size increasing by one. Insertion may trigger rehashing, which invalidates iterators but does not invalidate element pointers or references merely because of that rehash. These are different lifetime rules. The example does not dereference an invalid iterator or promise that a particular insertion will rehash. During diagnosis, identify the unintended write first, then separately review the lifetime of iterators retained across insertion.
The earlier article about lru_cache and duplicate computations examines a Python concurrency contract. This article starts one level earlier: has a purported lookup already changed the data structure? Preserving absence separately from a legitimate value is often a more direct first repair than replacing the entire container.
Sources are the C++17 working draft N4659 clauses on element access and unordered-container requirements. Both diagrams and the nineteen checks are original teaching material. Zero and 42 are synthetic example values, not measured service results.
Support
If the explanations or examples helped you solve a problem or save time, you’re welcome to support this site with a voluntary tip.
Click a QR code to enlarge. More options: support page。


