>
>
>
C++20: linker surprised by four lines o…

Guest
Articles: 22

C++20: linker surprised by four lines of code

Imagine that you are a student learning modern C++ features. And you have to complete a task concerning concepts/constraints. The teacher, of course, knows the proper way to do it – but you don't. You've already written spaghetti code that does not work. (And you keep adding more and more overloads and templates specializations to solve escalating compiler claims).

We published and translated this article with the copyright holder's permission. The author is Nikolay Merkin. The article was originally published on Habr.

Now imagine that you are a teacher who's watching this spaghetti code and wants to help the student. You start simplifying the code, and even comment on unit tests fragments to make this work somehow... But nothing's changed – the code doesn't work. Moreover, the code outputs different results or is not built at all, depending on the order of unit tests. Undefined behavior is hidden somewhere. But where is it?

First, the teacher (I) minimized the code as follows: https://gcc.godbolt.org/z/TaMTWqc1T

// suppose we have concept Ptr and concept Vec
template<class T> concept Ptr = requires(T t) { *t; };
template<class T> concept Vec = requires(T t) { t.begin(); t[0]; };

// and three overloaded functions recursively defined through each other
template<class T> void f(T t) {  // (1)
  std::cout << "general case " << __PRETTY_FUNCTION__ << std::endl;
}
template<Ptr T> void f(T t) {  // (2)
  std::cout << "pointer to ";
  f(*t);  // suppose the pointer is not null
}
template<Vec T> void f(T t) {  // (3)
  std::cout << "vector of ";
  f(t[0]);  // suppose the vector is not empty
}

// and a test set (in different files)
int main() {
  std::vector<int> v = {1};

  // test A
  f(v);
  // or test B
  f(&v);
  // or test C
  f(&v);
  f(v);
  // or test D
  f(v);
  f(&v);
}

We expect that

  • f(v) outputs "vector of general case void f(T) [T=int]"
  • f(&v) outputs "pointer to vector of general case void f(T) [T=int]"

But instead, we get

  • A: "vector of general case void f(T) [T=int]"
  • B: "pointer of general case void f(T) [T=std::vector<int>]" — ?
  • C: clang outputs "pointer to general case void foo(T) [T = std::vector<int>]" — as in B. "general case void foo(T) [T = std::vector<int>]", — not as in A! gcc — issues linker error
  • D: clang and gcc issue linker error

What's wrong with this code?!

Two things are wrong here. The first is that we see only (1) and (2) declarations of function (2), so the result of pointer dereference is called as (1).

Also, we can perfectly reproduce it without concepts and templates: https://gcc.godbolt.org/z/47qhYv6q4

void f(int x)    { std::cout << "int" << std::endl; }
void g(char* p)  { std::cout << "char* -> "; f(*p); }  // f(int)
void f(char x)   { std::cout << "char" << std::endl; }
void g(char** p) { std::cout << "char** -> "; f(**p); }  // f(char)
int main() {
  char x;
  char* p = &x;
  f(x);  // char
  g(p);  // char* -> int
  g(&p); // char** -> char
}

Unlike inline member functions in the class, where all members see all declarations — a free function sees only what is higher in the file.

That's why, we have to write declarations and definitions separately for mutually recursive functions.

Ok, we figured it out. Let's get back to templates. Why did we get something similar to an ODR violation in tests C and D?

If we rewrite the code as follows:

template<class T> void f(T t) {.....}
template<class T> void f(T t) requires Ptr<T> {.....}
template<class T> void f(T t) requires Vec<T> {.....}

nothing changes. This is just another way to write the code. We can write it in different ways to meet the concept requirements.

However, if we use good old SFINAE https://gcc.godbolt.org/z/4sar6W6Kq

// add a second argument char or int - to resolve ambiguity
template<class T, class = void> void f(T t, char) {.....}
template<class T> auto f(T t, int) -> std::enable_if_t<Ptr<T>, void> {.....}
template<class T> auto f(T t, int) -> std::enable_if_t<Vec<T>, void> {.....}
..... f(v, 0) .....
..... f(&v, 0) .....

or an old-school argument type matching, https://gcc.godbolt.org/z/PsdhsG6Wr

template<class T> void f(T t) {.....}
template<class T> void f(T* t) {.....}
template<class T> void f(std::vector<T> t) {.....}

then everything works. Not the way we wanted (recursion is still broken because of scope rules), but as we expected (the vector from f(T*) is seen as "general case", from main – as "vector").

What else is about concepts/constraints?

Thanks to RSDN.org, we brainstormed the code and found the way to optimize it!

Only 4 lines:

template<class T> void f() {}
void g() { f<int>(); }
template<class T> void f() requires true {}
void h() { f<int>(); }

It's better to use a constraint function than a function without constraints. Therefore, according to the scope rules, g() has the only one option to choose, but h() has two options and chooses the second one.

And this code generates an incorrect object file! It has two functions with the same mangled names.

It turns out that modern compilers (clang ≤ 12.0, gcc ≤ 12.0) do not know how to consider requires in name mangling. As it was with old and not so smart MSVC6 that did not take into account the template parameters if they did not affect the function type...

Considering the developers' replies, they do not know how and do not want to fix it. Here's what they say: "If, at different points in the program, the satisfaction result is different for identical atomic constraints and template arguments, the program is ill-formed, no diagnostic required" (however, ill-formed means "not compiled", but not "compiled randomly"...)

The issue is known since 2017, but there is no progress yet.

So, take it or leave it. Don't forget to declare mutually recursive functions before declarations. And if you notice strange linker errors, then at least now you know why they arise. (But if the compiler randomly inlines — bad luck!).