· 11 min read
Not Using auto? Let Me Change Your Mind
I used to think that auto was just a keyword for lazy developers. It was a surprise to find out that it brings not only readability but also code safety.
Updated
auto is a keyword that got its current meaning in C++11. It lets the compiler deduce the type from the initializer, so you don’t have to write the type yourself. Even today it’s a controversial topic, and a lot of developers prefer to avoid it because it can make readability worse. I was one of those developers. But recently I changed my mind, because I realized that besides making code shorter, auto also makes it safer and saves you from potential bugs.
Fun fact: the
autokeyword wasn’t invented in C++11, as you might think. It comes from B, the language before C, where it meant the opposite of static: a local variable that lives on the stack only while the function runs. The keyword never went away: C and C++98 kept that original meaning, but local variables work that way anyway, so nobody wrote it. That’s why C++11 could give the keyword a new job.
Alright, what does it bring?
Let me first tell you what good it can do for you if you start using auto today.
1. Save time on typing
Suppose you have this map:
std::unordered_map<std::string, std::vector<int>> scores;
You want to find an element, so you need the find() method, which returns an iterator.
Without auto, your code looks like this:
std::unordered_map<std::string, std::vector<int>>::iterator it = scores.find("Bob");
With auto, it becomes a short, beautiful line that is a pleasure to write:
auto it = scores.find("Bob");
Maybe you’re a bit of a control freak, and you think that skipping the type makes code harder to read: “auto just hides the type, and I don’t know what’s inside it.” Then ask yourself: why do you actually need to know the type so badly? In this example, the whole logic tells you one thing: I need to find the scores of a user named Bob. If for some reason you need the exact type, you can always check the signature of find(), or just hover over the variable in your favourite IDE, which shows the deduced type.
It gets even better in modern C++, where the actual type can be much more complicated and you usually don’t care what exactly it is. Here is a C++20 ranges example:
auto result = std::ranges::find_if(
users,
[](const auto& user) {
return user.age > 18;
}
);
And sometimes there is no type name to write at all. Every lambda has its own unique type with no name, so auto is the only practical way to store it without paying for a std::function wrapper:
auto isAdult = [](const auto& user) {
return user.age > 18;
};
auto result = std::ranges::find_if(users, isAdult);
2. Avoid undefined behaviour
We can all agree that C++ is a powerful language. It’s like a very sharp knife: great in the hands of a professional, but very dangerous in the wrong hands. You need to be extremely careful and know what you’re doing to avoid mistakes that come at a very high cost. UB is one of them.
Perhaps one of the easiest ways to get UB is to use an uninitialized local variable:
int numberOfUsers; // Local variable is not initialized
You can forget to initialize the variable and then write something like this when you add a new user:
numberOfUsers++;
This is undefined behaviour, which we’d really like to avoid.
C++26 softens this a bit. Reading an uninitialized local variable becomes “erroneous behaviour”: the variable gets some value chosen by the compiler, and the program is allowed to report the mistake. So the behaviour is more predictable now, but your code is still wrong.
One of the easiest ways to protect yourself is the auto keyword:
auto numberOfUsers; // This won't compile: auto needs an initial value to deduce the type from.
Great, so the compiler forces us to give it an initial value:
auto numberOfUsers = 0; // Now we're safe
numberOfUsers++; // The number increases correctly, starting from 0
3. Avoid unwanted copies
Consider this example:
std::unordered_map<std::string, int> userScores;
for (const std::pair<std::string, int>& score : userScores)
{
...
}
You might be thinking: I’m using a const reference, so I’m definitely writing optimized code. But what makes you think you’re iterating over the right type? The correct type in this loop is not:
const std::pair<std::string, int>&
It’s:
const std::pair<const std::string, int>&
The key is const by design: if you could change it, the element would end up in the wrong hash bucket and the map could no longer find it. The worst part is that the code compiles and works, so it’s easy to miss that something is wrong.
The compiler assumes you really want this exact type without the const, and it finds a way to fulfill your wish. It creates a temporary copy of every element, key string included, and binds your reference to that copy.
GCC and Clang catch this with
-Wall, through the-Wrange-loop-constructwarning. MSVC stays silent.
It’s not really your fault, but you could definitely avoid this mistake with auto:
std::unordered_map<std::string, int> userScores;
for (const auto& score : userScores)
{
...
}
Now the compiler uses the correct type.
4. Save yourself from unexpected API changes
Imagine your software relies on an external library. It could even happen inside your own team. Your system uses a library function with this signature:
// API v1
int getUserCount();
And you use it just the way it was meant to be used:
int count = getUserCount();
The next day, the library authors realize they have so many users that int is no longer the right type, so they switch to long long:
// API v2
long long getUserCount();
But your code stays the same. And it still compiles! GCC and Clang won’t even warn you unless you enable -Wconversion, which is not part of -Wall.
This is a serious potential problem, because int can’t represent the huge numbers that could come from the library. The value is silently cut down to fit into an int: 3000000000 becomes -1294967296 on typical platforms, and your program will behave incorrectly without any crash to tell you.
With auto, you’re safe again:
auto count = getUserCount();
count simply becomes a long long, the type getUserCount() returns. There is no conversion at all, so you’re safe from narrowing conversions and signed/unsigned mistakes.
5. Avoid missing const by accident
It’s important to use const wherever possible, because it helps prevent a lot of bugs you don’t want to see in production.
auto won’t add const for you. When you loop over a non-const container, auto& gives you a reference you can modify, exactly like std::string& does. But it can make you less likely to forget const or &. You don’t have to focus on writing the type correctly, so const and & are the only things left to think about:
for (std::string& name : names) {
// const is missing
}
With auto:
for (const auto& name : names) {
// ...
}
Your intent is easy to see: you want a const reference, because you don’t want to modify or copy the element.
The & matters here, because plain auto drops references and top-level const. If the initializer is a const std::string&, you get a new std::string copy. When you want a reference, write auto& or const auto&.
Okay, so does it mean I should use auto all the time?
Not at all. You need to understand the context of what you’re writing. Here are the places where I’d think twice.
1. When the type is the point
Let’s take this example from before:
auto numberOfUsers = 0;
What would you tell your colleague when explaining this code: “It’s a signed int that keeps the number of users”, or just “It’s a variable that keeps the number of users”?
Probably the second one. Here you don’t really care about the type. It’s more of a technical detail than part of the big picture of your code.
For comparison, let’s take this example:
std::uint8_t header = 0;
std::uint8_t flags = 0;
std::uint16_t packetLength = 0;
std::uint32_t checksum = 0;
This code carries useful information. Here size matters: the types tell you what data you’re sending or receiving and how big each piece of it is.
2. When a function returns a proxy
Some functions return a proxy type rather than the type you’d probably expect, and auto happily keeps that proxy:
std::vector<bool> flags = {true, false};
auto flag = flags[0];
flags.clear();
std::cout << flag << '\n'; // We're screwed
Can you guess what’s wrong here? The type of flag is std::vector<bool>::reference, not bool as you might expect. After clear(), the object that flag refers to no longer exists. Using the proxy afterward can result in undefined behaviour.
Here you must insist on using bool instead of auto:
bool flag = flags[0];
flags.clear();
std::cout << flag << '\n'; // We're safe
This time the conversion to bool happens immediately.
If you prefer to keep auto, make the conversion visible with a cast:
auto flag = static_cast<bool>(flags[0]);
Scott Meyers calls this the “explicitly typed initializer idiom” in Effective Modern C++. Either way, the reader sees that you want a bool on purpose.
The same trap exists in math libraries like Eigen. There, a + b returns a lightweight expression object instead of a finished matrix, and auto sum = a + b; keeps that object, which still refers to a and b.
3. When the initial value has the wrong type
auto takes the type of the initial value, even when you had a different type in mind:
std::vector<double> prices = {9.99, 5.50, 3.25};
auto total = 0; // int, not double
for (double price : prices)
{
total += price;
}
std::cout << total << '\n'; // Prints 17, not 18.74
0 is an int, so total is an int too, and the sum is cut down to a whole number after every addition. Write double total = 0;, or give auto a value of the right type: auto total = 0.0;.
String literals have the same surprise:
auto name = "Bob"; // const char*, not std::string
If you want a std::string, write the type. Or add the s suffix (C++14), which turns the literal into a std::string right away:
using namespace std::string_literals;
auto name = "Bob"s; // std::string
4. When the reader can’t see the type
The IDE hover trick from the beginning doesn’t help everyone. In a code review on GitHub or GitLab, there is nothing to hover over:
auto result = parseUser(json);
Is result a User, a std::optional<User>, or maybe a pointer? Each one needs different handling. When the type isn’t clear from the line itself, writing it out saves the reader a trip to the header:
std::optional<User> result = parseUser(json);
Conclusion
How much auto is too much? There is no single right answer. Some teams follow Herb Sutter’s “almost always auto” advice. Others, like the Google and LLVM style guides, are more careful and ask for auto mainly where it makes the code clearer, for example when the type is already obvious from the line. Both ways work. Talk about it with your team, pick one, and stick to it, so the whole codebase reads the same way.
Whatever your team picks, you should know what the keyword gives you:
- A variable declared with
autocan’t be left uninitialized. - The type always matches the initializer, so there are no hidden conversions like the
std::paircopy or thelong longthat got cut down toint. - Your code keeps up when an API changes its return type.
- Long iterator types and unnamed lambda types stop being your problem.
And where you must be careful:
- When the exact type is part of the meaning, like field sizes in a network packet, write it out.
- Proxy types like
std::vector<bool>::referencestay proxies. When you mean a conversion, write the type or usestatic_cast. autotakes the type of the initial value:0is anint, and"Bob"is aconst char*.- When the type isn’t clear from the line and the reader can’t hover over it, spell it out.
- Plain
autocopies. Writeauto&orconst auto&when you want a reference.
I hope these examples gave you some motivation to give auto another try, and a clear idea of where to stop.