Webinar: С++ semantics - 06.11
Your attention is invited to the fifth part of an e-book on undefined behavior. This is not a textbook, as it's intended for those who are already familiar with C++ programming. It's a kind of C++ programmer's guide to undefined behavior and to its most secret and exotic corners. The book was written by Dmitry Sviridkin and edited by Andrey Karpov.
In addition to undefined behavior, C++ delights us with unexpected behavior that arises from the following fascinating language features.
We can declare user-defined types and functions wherever and however we want.
template <class T>
struct STagged {};
using S1 = STagged<struct Tag1>; // the Tag1 struct forward
// declaration
using S2 = STagged<struct Tag2*>; // the Tag2 struct forward
// declaration
void fun(struct Tag3*); // the Tag3 struct forward declaration
void external_fun() {
int internal_fun(); // the function forward declaration!
internal_fun();
}
int internal_fun() { // the forward-declared function definition
std::cout << "hello internal\n";
return 0;
}
int main() {
external_fun();
}
However, we can't define entities everywhere. We can define types locally, within a function, but we can't define functions.
void fun() {
struct LocalS {
int x, y;
}; // OK
void local_f() {
std::cout << "local_f";
} // Compilation Error
}
Everything's fine except for one thing: C++ has constructors, which are called similar to declaring a function.
struct Timer {
int val;
explicit Timer(int v = 0) : val(v) {}
};
struct Worker {
int time_to_work;
explicit Worker(Timer t) : time_to_work(t.val) {}
friend std::ostream&
operator << (std::ostream& os, const Worker& w) {
return os << "Time to work=" << w.time_to_work;
}
};
int main() {
// THIS ISN'T A CONSTRUCTOR CALL!
Worker w(Timer()); // A forward declaration of the function
// that returns Worker and takes the function
// that returns Timer and doesn't take anything!
std::cout << w; // The function name is implicitly converted to a
// pointer that is implicitly converted to bool.
// 1 (true) is displayed
}
Such a bug can be difficult to detect if the forward-declared function is accidentally contextually converted to bool, or if the object being constructed can be called (its operator() is overloaded).
It may seem that the default constructor of the Timer class is to blame. However, it's C++. There, we can declare functions like this:
void fun(int (val)); // Parentheses are allowed around the parameter name!
So, you can get a nastier and harder to understand version of the error:
int main() {
const int time_to_work = 10;
Worker w(Timer(time_to_work)); // Forward declaration of the function
// that returns Worker
// and takes the parameter of
// the Timer type.
// time_to_work is this parameter name!
std::cout << w; // Prints 1
}
GCC and Clang can warn you about such things.
C++11 and its later versions offer uniform initialization (via {}), which isn't quite universal and has its own issues. C++20 offers another universal initialization but via () again...
We can avoid the issue by using the Almost Always Auto approach with the auto w = Worker(Timer()) initialization. Parentheses or curly braces aren't that important here (well, they are but in a different context).
Maybe someday declaring functions in the old C style will be banned in favor of trailing return type (auto fun(args) -> ret). And it will be much harder (but still possible!) to run into the discussed issue.
C++ has the const keyword that enables you to mark values as immutable. Also, C++ has const_cast to ignore this const. Sometimes you'll get away with it. And sometimes there'll be undefined behavior, a segfault, and other exciting things.
The difference between these "sometimes" lies in whether you're dealing with real constants, where any attempt to modify them leads to UB, or just references to constants referring to non-constants. Since the object is actually non-constant, modifying it is no problem.
For example, we can leverage this feature to avoid repeating the same code for const and non-const qualified versions of class member functions:
class MyMap {
public:
// some member function with a lengthy implementation:
const int& get_for_val_or_abs_val(int val) const {
const auto it_val = m.find(val);
if (it_val != m.end()) {
return it_val->second;
}
const auto abs_val = std::abs(val);
const auto it_abs = m.find(abs_val);
if (it_abs != m.end()) {
return it_abs->second;
}
throw std::runtime_error("no value");
}
int& get_for_val_or_abs_val(int val) {
return const_cast<int&>( // We discard const from the result.
// Since this is a non-constant member function,
// we know that the result
// isn't really a constant,
// and there won't be any issues.
std::as_const(*this) // We add const
// to call the const-qualified
// member function and avoid
// infinite recursion.
.get_for_val_or_abs_val(val));
}
void set_val(int val, int x) {
m[val] = x;
}
private:
std::map<int, int> m;
};
Avoid such code if possible. You can see that it's so fragile that a forgotten or accidentally deleted std::as_const breaks it. And without specific warnings configured, compilers are slow to inform about it.
Instead of using const_cast and bringing even more instability to the C++ world, we can eliminate code duplication by using a template method:
class MyMap {
public:
const int& get_for_val_or_abs_val(int val) const {
return get_for_val_or_abs_val_impl(*this, val); // *this — const&
}
int& get_for_val_or_abs_val(int val) {
return get_for_val_or_abs_val_impl(*this, val); // *this — &
}
void set_val(int val, int x) {
m[val] = x;
}
private:
template <class Self> static decltype(auto)
get_for_val_or_abs_val_impl(Self& self, int val)
{
auto&& m = self.m;
if (it_val != m.end()) {
// Additional parentheses to display the value category
return (it_val->second);
}
const auto abs_val = std::abs(val);
const auto it_abs = m.find(abs_val);
if (it_abs != m.end()) {
return (it_abs->second);
}
throw std::runtime_error("no value");
}
std::map<int, int> m;
};
This option has its drawbacks, and it's even easier to break (parentheses and decltype). But having written it once, one can be sure that some strange magic will scare away those who want to fix this code.
Of course, instead of decltype(auto) we can write a bit more code explicitly specifying the types of return values.
Operations on immutable data are perfectly optimized, parallelized, and run well in general.
However, all this fuss with removing and adding const anywhere in the code eliminates this set of optimizations. So, a repeated access by a constant reference to the same data member or member function doesn't need to be cached at all.
Note. It's worth mentioning that programmers have unrealistic expectations about the compiler optimizing code when they add more const. Here's a good note on the topic: "Why const Doesn't Make C Code Faster".
For example, iterating through a vector can't be optimized in such a simple case:
using predicate = bool (*) (int);
int count_if(const std::vector<int>& v, predicate p) {
int res = 0;
for (size_t i = 0; i < v.size(); ++i) // We can't save the v.size() value
{ // once in the register.
if (p(v[i])) // A specific 'p' can access
{ // the same 'v' via a reference to non-const.
++res;
}
// the size() member function has to be executed at each iteration!
}
return res;
}
The example that prohibits optimization may not be obvious, but it's simple:
std::vector<int> global_v = {1};
bool pred(int x) {
if (x == global_v.size()) {
global_v.push_back(x);
return true;
} else {
return false;
}
}
int main() {
return count_if(global_v, pred);
}
The code is terrible. It shouldn't be anywhere. And it would never pass code review. However, in theory, one can write it that way, so the optimization isn't done.
If we use a template parameter as a predicate type, we can create much more tricky examples without involving global variables.
Given the limited possibilities for automatic optimization, devs usually rewrite such a loop (doing exactly what the compiler was supposed to do):
int count_if(const std::vector<int>& v, predicate p) {
int res = 0;
// range-based-for doesn't access size()
// but it reads begin/end iterators once and handles
// them.
for (auto x : v) {
if (p(v[i])) {
++res;
}
}
return res;
}
In such a case, when passing a "bad" predicate that changes the vector, we get undefined behavior. But that's a story for another time...
Here's a less trivial example of const that doesn't contribute to optimization in any way:
void find_last_zero_pos(const std::vector<int>& v,
const int* *pointer_to_last_zero) {
*pointer_to_last_zero = 0;
// Again, we can't save value of v.size() once
for (size_t i = 0; i < v.size(); ++i) {
if (v[i] == 0) {
// Inside the vector, there are the 'int *' type data members,
// begin and end.
// What if 'pointer_to_last_zero' points to one of them!?
*pointer_to_last_zero = (v.data() + i);
}
// recalculating size!
}
}
If we follow the recommended C++ coding practices, we can't construct an example that demonstrates the optimization inapplicability. Encapsulation stops us. We can't legitimately get to the private data members of the vector.
However, nothing stops us from writing non-normal code! Let's use brute force:
int main() {
std::vector<int> a = {1,2,4,0};
const int* &data_ptr =
reinterpret_cast<const int* &>(a); // a reference to begin!
find_last_zero_pos(a, &data_ptr);
}
Here's a paradox: the mere chance to write clearly incorrect code forbids the compiler to optimize the loop! The whole concept of undefined behavior as a potential for optimization (claiming that there's no such thing as incorrect code) falls apart.
Well, the example at least gives a measure of stability: the original loop with a counter and the rewritten one to be range-based-for end with undefined behavior.
In modern languages (in Rust, for example, thanks to ownership semantics), all these loops can be successfully optimized.
Immutable objects are all well and good except for one thing: they are constant objects in C++. Once they get stuck somewhere, we can't get them out the normal way.
What does that mean?
Let's say there's a structure with a constant field:
struct Unit {
const int id;
int health;
};
Unit objects lose the assignment operation because of the constant data member. They can't be swapped, std::swap doesn't work anymore. We can't just sort std::vector<Unit> anymore... All in all, it's not convenient at all.
However, the most exciting part starts if we do something like this:
std::vector<Unit> units;
unit.emplace_back(Unit{1, 2});
std::cout << unit.back().id << " ";
unit.pop_back();
unit.emplace_back(Unit{2, 3});
std::cout << unit.back().id << "";
Depending on whether the vector implementation was capable of overcoming the compiler aggressive optimizations, such code may output either 1 2 (everything's fine) or 1 1 (the compiler optimized a constant data member!).
The compiler can interpret the things as follows:
Fortunately or not, it's impossible to reproduce such compiler behavior in the real world. However, code that can be used to implement self-describing std::optional provokes UB (and not just one instance!) by default:
using storage = std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
storage s;
new (&s) Unit{1,2};
std::cout << reinterpret_cast<Unit*>(&s)->id << "\n"; // UB
reinterpret_cast<Unit*>(&s)->~Unit(); // UB
new (&s) Unit{2,2};
std::cout << reinterpret_cast<Unit*>(&s)->id << "\n"; // UB
reinterpret_cast<Unit*>(&s)->~Unit(); // UB
Here's the fixed code:
using storage = std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
storage s;
auto p = new (&s) Unit{1,2};
std::cout << p->id << "\n";
p->~Unit();
p = new (&s) Unit{2,2};
std::cout << p->id << "\n";
p->~Unit();
However, supporting a pointer returned by the operator new isn't always possible. Storing it requires some space, which is inefficient when implementing optional: int32_t takes three times more space on a 64-bit system (4 bytes per storage + 8 bytes per pointer)!
That's why the standard library, starting from C++17, has a function of "laundering" pointers that came from nowhere—std::launder.
using storage =
std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
storage s;
new (&s) Unit{1,2};
std::cout <<
std::launder(reinterpret_cast<Unit*>(&s))->id << "\n";
std::launder(reinterpret_cast<Unit*>(&s))->~Unit();
new (&s) Unit{2,2};
std::cout <<
std::launder(reinterpret_cast<Unit*>(&s))->id << "\n";
std::launder(reinterpret_cast<Unit*>(&s))->~Unit();
So, what does const have to do with it? The combination of "real" constancy (variables and data members declared as const) and UB from using "wrong" pointers enable the compiler to produce the described special effects.
When designing the C++ standard library, its developers made a lot of controversial decisions that make us suffer. It's impossible to fix them for backward compatibility reasons.
One such puzzling decision is overloading constructors with drastically different behavior.
Here's a good example:
using namespace std::string_literals;
std::string s1 { "Modern C++", 3 };
std::string s2 { "Modern C++"s, 3 };
std::cout << "S1: " << s1 << "\n";
std::cout << "S2: " << s2 << "\n";
The code outputs the following:
S1: Mod
S2: ern C++
That's because std::basic_string has a single constructor that takes a pointer and the length of the string. There's also another constructor that takes "something that looks like a string" and the position to extract the substring from it!
The strange things don't end there.
std::string s1 {'H', 3};
std::string s2 {3, 'H'};
std::string s3 (3, 'H');
std::cout << "S1: " << s1.size() << "\n";
std::cout << "S2: " << s2.size() << "\n";
std::cout << "S3: " << s3.size() << "\n";
This example outputs the following:
S1: 2
S2: 2
S3: 3
This is because the string has a constructor that takes an n number and the c character that needs to be repeated n times. There's also a constructor that takes an initializer list (std::initializer_list<T>) consisting of characters. This constructor existence interacts with implicit type casting!
The std::vector type has the same problem:
std::vector<int> v1 {3, 2}; // v1 == {3, 2}
std::vector<int> v2 (3, 2); // v2 == {2,2,2}
Containers also have a constructor that takes a pair of iterators. It seems like we won't have any issues with them, but we have pointers, which are also iterators. And then, there's the bool type:
bool array[5] = {true, false, true, false, true};
std::vector<bool> vector {array, array + 5};
std::cout << vector.size() << "\n";
Only 2 will be displayed, not 5. This is because pointers are implicitly cast to bool!
Since C++20, converting pointers to bool is now considered narrowing, even in earlier standard versions. So, the latest compiler versions have started to either issue a warning by default, like GCC:
narrowing conversion of '(bool*)(& array)' from 'bool*' to 'bool'.
Or, like Clang, they refuse to compile:
error: type 'bool *' cannot be narrowed to
'bool' in initializer list [-Wc++11-narrowing]
So, these nice examples show why "universal" initialization isn't truly universal.
To prevent chaos in projects, one should be careful when declaring overloaded constructors for types. It's better to introduce a static function than to create overloaded constructors that unexpectedly interact with implicit type casting and initialization lists.
Starting with C++11, we have rvalue references and move semantics. And moving isn't destructive: the original object stays alive thus creating a lot of errors. There are still issues with how to avoid overhead when using moveable objects, but we can deal with that.
Despite bold claims, abstractions in C++ are far from free. An interesting example is std::unique_ptr, which is bound to move semantics.
void run_task(std::unique_ptr<Task> ptask) {
// do something
ptask->go();
}
void run(...){
auto ptask = std::make_unique<Task>(....);
....
run_task(std::move(ptask));
}
When run_task is called, the parameter is passed by value: a new unique_ptr object is created, while the original one remains but is left empty. Since there are two objects, there are also two calls to the destructor. In contrast, languages with destructive move semantics, like Rust, would require only one destructor call.
We can fix the issue by passing an rvalue reference instead:
void run_task(std::unique_ptr<Task>&& ptask) {
// do something
ptask->go();
}
By passing an rvalue reference, no additional object is created, so there's only one destructor call. Moreover, this approach introduces an additional level of indirection and memory access because of the reference.
However, the most important thing is that there is no actual movement, and this can hide a bug in the program logic.
void consume_v1(std::unique_ptr<int> p) {}
void consume_v2(std::unique_ptr<int>&& p) {}
void test_v1(){
auto x = std::make_unique<int>(5);
consume_v1(std::move(x));
assert(!x); // ok
}
void test_v2(){
auto x = std::make_unique<int>(5);
consume_v2(std::move(x));
assert(!x); // fire!
}
Running the second function will most likely end with Access Violation (SIGSEGV).
It brings us to the main issue.
Firstly, the std::move function doesn't do anything. It simply converts the lvalue reference to an rvalue one. It doesn't affect the object state in any way. The functions that operate on rvalue references can produce visible move effects. Most of them are move constructors and move operators.
Secondly, the C++ standard doesn't specify the exact state of an object after a move operation. The only requirement is that the object must remain valid to have the destructor called. The object doesn't have to be empty after the move operation. Its data members don't have to be zeroed. For example, std::thread can't have any of its member functions called after being moved. And std::unique_ptr is guaranteed to become empty (nullptr).
The most common and easiest way to run into use-after-move is to implement constructors that fill data members with passed arguments. Just give the same (or almost the same) names to data members and arguments.
struct Person {
public:
Person(std::string first_name,
std::string last_name) : first_name_(std::move(first_name)),
last_name_(std::move(last_name)) {
std::cerr << first_name; // wrong, use-after-move
}
private:
std::string first_name_;
std::string last_name_;
};
Of course, in such a case, the error will be found quickly, as it's guaranteed that after moving the std::string object, it will be empty. However, if we make the constructor template-based and pass trivially movable types to it, the error may not occur for a long time.
template <class T1, class T2>
Person(T1 first_name,
T2 last_name) : first_name_(std::move(first_name)),
last_name_(std::move(last_name))
{
std::cerr << first_name; // wrong, use-after-move
}
....
Person p("John", "Smith"); // T1, T2 = const char*
Another interesting use case in addition to the previous one is self-move-assignment, which can result in data suddenly disappearing from an object. Or they may not disappear. The outcome depends on how the move operation is implemented for a particular type.
For example, this naive implementation of the remove_if algorithm contains an error:
template <class T, class P>
void remove_if(std::vector<T>& v, P&& predicate) {
size_t new_size = 0;
for (auto&& x : v) {
if (!predicate(x)) {
v[new_size] = std::move(x); // self-move-assignment!
++new_size;
}
}
v.resize(new_size);
}
The error emerges when container elements contain data members that don't consider self-assignment.
struct Person {
std::string name;
int age;
};
std::vector<Person> persons = {
Person { "John", 30 }, Person { "Mary", 25 }
};
remove_if(persons, [](const Person& p) { return p.age < 20; });
for (const auto& p : persons){
std::cout << p.name << " " << p.age << "\n";
}
If we compile and run this code, we can make sure that all name are empty:
30
25
Static analyzers can track some post-move use cases. For example, PVS-Studio has the V1030 diagnostic rule, and Clang-Tidy has bugprone-use-after-move.
If you're implementing movable classes and want to consider the possibility of self-assignment/self-move, either use the copy/move-and-swap idiom, or don't forget to check if the addresses of the current and moved objects match:
MyType& operator=(MyType&& other) noexcept {
if (this == std::addressof(other)) { // addressof works
// if you have an overloaded &
return *this;
}
....
}
C++ templates, which began as a more refined version of copying-and-pasting with preprocessor macros and evolved with SFINAE rules, have spawned some pretty creepy, cumbersome, but powerful features for metaprogramming and compile-time computations.
The mechanism is highly useful yet extremely inconvenient. It's no surprise that the standard library has introduced different quality-of-life tools.
The details of how SFINAE works go far beyond the scope of this book. Here, we'll discuss things that were introduced in C++17; things that should have made coding easier but often fall short of expectations.
In short, the SFINAE (substitution failure is not an error) rule goes as follows:
Take a look at the example:
#include <type_traits>
template <class T>
decltype(void(std::declval<typename T::Inner>())) fun(T) { // 1
std::cout << "f1\n";
}
template <class T>
decltype(void(std::declval<typename T::Outer>())) fun(T) { // 2
std::cout << "f2\n";
}
struct X {
struct Inner {};
};
struct Y {
struct Outer {};
};
....
fun(X{}); // when substituted into template 2
// the X::Outer construct is invalid:
// X has no such type. It's discarded.
// Template 1 substitution
// goes without errors, "f1" is displayed.
fun(Y{}); // the same thing but in reverse.
// Y::Inner doesn't exist. Displays "f2"
The decltype(void(void(std::declval<typename T::Outer>)) construct used for "pattern matching" is terrible. The dark minds of the C++ masters have created even creepier things. However, for a less savvy user, it would be nice to have something simpler, more comprehensible, and user-friendly.
So, we have std::enable_if_t that allows us to trigger SFINAE not by a creepy custom construct but by a Boolean value.
template<class T>
std::enable_if_t<sizeof(T) <= 8> process(T) {
std::cout << "by value";
}
template<class T>
std::enable_if_t<sizeof(T) > 8> process(const T&) {
std::cout << "by ref";
}
...
process(5); // by value
const std::vector<int> v;
process(v); // by ref
Indeed, we can still use horrendous constructs in the std::enable_if argument, not just some predicates.
template <class T>
std::enable_if_t<std::is_same_v<typename T::Inner, typename T::Inner>>
fun(T) { // 1
std::cout << "f1\n";
}
template <class T>
std::enable_if_t<std::is_same_v<typename T::Outer, typename T::Outer>>
fun(T) { // 2
std::cout << "f2\n";
}
fun(X{}); // even though the std::is_same_v<T, T> value
// is always true, X::Outer doesn't exist.
// SFINAE is triggered not because of the predicate value
// but because of its arguments.
This is where the first trouble begins: std::enable_if vs. std::enable_if_t.
// approximately
template <bool cond, T = void>
struct enable_if {};
template <true, T = void>
struct enable_if {
using type = T;
};
template <bool cond, T = void>
using enable_if_t = typename enable_if<cond, T>::type;
You can easily mix them up when typing quickly with auto-complete. Omitting the _t suffix by mistake, causing many hours of debugging will be lost along with all that pattern-matching things:
// SFINAE is triggered by the predicate value and no longer works.
// std::enable_if<false> is a valid type.
// We get UB due to redefining the same entity
// in different ways
template<class T>
std::enable_if<sizeof(T) <= 8> process(T);
template<class T>
std::enable_if<sizeof(T) > 8> process(const T&);
// SFINAE is triggered by the predicate arguments and
// continues to operate.
// If you expected void as the return type,
// there may be UB because of the missing return.
template <class T>
std::enable_if<std::is_same_v<
typename T::Inner, typename T::Inner>> fun(T);
template <class T>
std::enable_if<std::is_same_v<
typename T::Outer, typename T::Outer>> fun(T);
The same applies to all the other mysterious creatures in the <type_traits> header. Each std::trait_X and std::trait_X_t are types that aren't always seen when mixed up.
As a rule of thumb, it's better to use std::enable_if to trigger SFINAE only via the predicate, there're less issues that way.
If a predicate doesn't exist, you can write one:
template <class T,
class = void> // the placeholder workaround for the checked "pattern"
struct has_inner_impl : std::false_type {};
template <class T>
struct has_inner_impl<T,
// the "pattern", the result type should be the same
// as the one specified in the placeholder above
decltype(void(std::declval<typename T::Inner>()))>
: std::true_type {};
template <class T>
constexpr bool has_inner_v = has_inner_impl<T>::value;
static_assert(has_inner_v<X>);
static_assert(!has_inner_v<Y>);
This is one of the most common and " straightforward" approaches to writing such predicates. The void pointer is most often used as a kludge placeholder. To avoid writing this terrible decltype(void(void(std::declval<X>())) for checking if the X type is valid, developers introduced the std::void_t template in C++17.
Here it is:
template <class...>
using void_t = void;
It should make things shorter and prettier:
template <class T>
struct has_inner_impl<T,
std::void_t<typename T::Inner>>
: std::true_type {};
Unfortunately, most of the time it doesn't work as expected. A defect (issue 1558 — WG21 CWG Issues), which has been found in the C++11 standard, allows this design to fail. In many not-so-brand-new compiler versions, such a predicate always returns true.
However, std::void_t is also broken in newer versions. Let's try using it to rewrite the very first example in this section:
template <class T>
std::void_t<typename T::Inner> fun(T) {
std::cout << "f1\n";
}
template <class T>
std::void_t<typename T::Outer> fun(T) {
std::cout << "f2\n";
}
None of the three major compilers (GCC, Clang, MSVC) will compile this code. Despite the fact that the first version with ugly decltype compiled.
This is because we have the concepts of "equivalence" and "functional equivalence" of declarations. The compiler checks the former, and the latter has to do with SFINAE. Here's a scary thing.
1980. Equivalent but not functionally-equivalent redeclarations. In an example like:
template<typename T, typename U> using X = T;
template<typename T> X<void, typename T::type> f();
template<typename T> X<void, typename T::other> f();
it appears that the second declaration of f is a redeclaration of the first but distinguishable by SFINAE, i.e., equivalent but not functionally equivalent.
Notes from the November, 2014 meeting: CWG felt that these two declarations should not be equivalent.
Here's a general tip: don't use std::void_t. Also, if nothing depends on template-alias parameters to the right of using =, don't try to build SFINAE on them.
template <class T>
struct my_void {
using type = void;
}
template <class T>
using my_void_t = void; // doesn't work
template <class T>
using my_void_t = typename my_void<T>::type; // ok
It's even better to switch to C++20 and avoid dealing with all this nonsense. There's a readable syntactic sugar created specifically for all those scary constructs. Of course, it has some hidden pitfalls, but that's a topic for another time.
Sometimes people refer to C and C++ as languages with a special syntax for writing invalid programs.
In C and C++, a function that returns something other than void doesn't necessarily have to be of the return-something kind.
int add(int x, int y) {
x + y;
}
This is a syntactically correct function that results in undefined behavior. The result could be a garbage, there may be an insertion in the code of the next function below, or everything may be fine.
This is what happens if we build this code:
#include <stdio.h>
int f(int a, int b) {
int c = a + b;
}
int main() {
int x = 5, y = 6;
printf("f(%d,%d) is %d\n", x, y, f(x,y));
return 0;
}
For example, if you are using GCC 5.2, then the sum will be "calculated" and the program will display the following:
f(5,6) is 11
However, don't think that undefined behavior for such code is only about whether to display a correct or random value. A missing return may cause the application to crash, just like the code above. All we need to do is change the compiler to GCC 14.1, and the program execution result will be:
Program terminated with signal: SIGILL
This confusion can be especially painful for those who came to C++ from an expression-oriented language where such code is perfectly fine:
fn add(x: i32, y: i32) -> i32 {
x + y
}
These are few reasons why we don't need to write return at the end of the function:
Of course, we can check if there is a formal return. However, we were allowed to omit the formal line in certain rare cases, and compilers were allowed not to treat it as a bug.
In several years of working on large projects combining C++, Rust, and Kotlin (two languages with optional return!), I've seen quite a few forgotten returns. And I've forgotten them too sometimes. In particular, return seems to be lost in custom assignment operators. After all, operator = is probably one of the few non-void functions the result of which is almost always ignored.
With the -Wreturn-type flag, GCC and Clang report the issue in many cases. Analyzers such as PVS-Studio also issue warnings. By the way, if we look at the bug collection gathered by the PVS-Studio team, we can see that the lack of return isn't some kind of an exotic bug, but a very common one. So, be vigilant.
The only exception, starting from C++11 (or C99, if we talk about pure C code), is the main function. In this case, a missing return doesn't lead to undefined behavior and is treated as a return of 0.
Author: Dmitry Sviridkin
Dmitry has over eight years of experience in high-performance software development in C and C++. From 2019 to 2021, Dmitry Sviridkin has been teaching Linux system programming at SPbU and C++ hands-on courses at HSE. Currently works on system and embedded development in Rust and C++ for edge servers as a Software Engineer at AWS (Cloudfront). His main area of interest is software security.
Editor: Andrey Karpov
Andrey has over 15 years of experience with static code analysis and software quality. The author of numerous articles on writing high-quality code in C++. Andrey Karpov has been honored with the Microsoft MVP award in the Developer Technologies category from 2011 to 2021. Andrey is a co-founder of the PVS-Studio project. He has long been the company's CTO and was involved in the development of the C++ analyzer core. Andrey is currently responsible for team management, personnel training, and DevRel activities.
0