operator[] considered harmful

I’ve always felt conflicted about the subscription operator[] in standard containers. It makes sense for random access structures like vectors, but it gets a little bit problematic elsewhere.

Consider this seemingly innocent piece of code (it’s not 1:1, but this is code you can easily find in many codebases):

if(someMap[key].value < x)
    someMap[key].value = x;

2 lines of actual code, but more than 1 problem, this snippet is potentially incorrect and inefficient. Both issues stem from the same fact - operator[] tries to do 2 very different things:
1) find a given key
2) if not found - insert it (with a default value)
Now, it’s entirely possible - and quite likely - that author is aware, but the operator looks so innocent and is so convenient to use, it’s easy to gloss over these quirks. Container might not always be named in a way that makes an underlying type obvious so this code could be perfectly fine for some and quite dangerous for others.

Below is same snippet, a bit more verbose:

auto it = someMap.find(key);
if(it != someMap.end() && it->second < x)
    it->second = x;
(NOTE: it’s impossible to tell whether snippet #1 assumed key is present in the map or not, for the purpose of this exercise let’s say it’s not guaranteed),

Two major improvements - it is now obvious that we might not find our element and, more importantly, we only run look-up once. C++ 17 introduce a new map method - insert_or_assign, which works almost like operator[], but even then doesn’t really cover it 100%. Imagine there was a find_or_insert_or_assign and now try to imagine our snippet implemented using this fictional method. Ridiculous, right? Yet… this is what it’s essentially doing.

As a matter of fact, I’d argue you almost never want to use operator[], even if it’s technically correct. Here’s another simple loop that sums values for a given list of keys:

1for(const auto& key : keys)
3    sum += someMap[key];

Seems fine this time? We’re not inserting or modifying anything (assuming all keys are present). Yes, it’s still suboptimal, though. The final assembly depends on the compiler obviously, different compilers inline different functions, but the point still stands – it’ll try to find the element first and insert. We’ll have both a conditional jump somewhere and possibly some code we don’t care about stuck in our loop and wasting our icache.

It is even more important in “modern” C++ (C++14 or later for std::map, C++20 or later for unordered containers) – find method can now take an argument of a type that isn’t necessarily a key_type, but can be compared with one (+ can be hashed for unordered). For example, if your key is a string:

auto it = myMap.find("somestring"); // Doesn't construct a new string
auto v = myMap["somestring"]; // Constructs a new string

For more details see the documentation of find() method and/or std::less

Finally, I will grudgingly admit there is one case where operator[] is slightly superior to alternatives - histograms/counters. The easiest/most concise C++ implementation might look like:

1for(const auto& v : values)
3    counts[v]++;

An equivalent using find/insert:

1for(const auto& v : values)
3    auto it = counts.find(v);
4    if(it == counts.end())
5    {
6        it = counts.insert(std::make_pair(v, 0)).first;
7    }
8    ++it->second;

Personally, I still think if the price for banning operator[] from the codebase was having to use version#2, it is a worthy tradeoff, but I am willing to concede version #1 is “nicer”.
Other languages tried to solve this problem in different ways. Python even added an entire module . I quite like Python’s “native” way:

counter[letter] = counter.get(letter, 0) + 1
…but it does hit the dictionary twice.

Rust offers quite an interesting approach that leverages power of their enum type (returned entry is an enum with associated data) Our code could be something like:

counts.entry(v).and_modify(|counter| *counter += 1).or_insert(1);

or even shorter:

*counts.entry(v).or_insert(0) += 1;

We could try to achieve something similar in C++ using std::optional, but it doesn’t give us all the tools yet. C++23 will introduce monadic operations: and_then/transform/or_else , which should make it a bit easier, but until then, I will just do some extra typing.

More Reading