Atomic function pointer call compiles in gcc, but not in clang and msvc
Asked Answered
C

1

18

When calling function from an atomic function pointer, like:

#include <atomic>
#include <type_traits>

int func0(){ return 0; }

using func_type = std::add_pointer<int()>::type;

std::atomic<func_type> f = { func0 };

int main(){
        f();
}

gcc doesn't complain at all, while clang and msvc have problem with call f():

  • [clang]: error: call to object of type 'std::atomic<func_type>' (aka 'atomic<int (*)()>') is ambiguous
  • [msvc]: there is more than one way an object of type "std::atomic<func_type>" can be called for the argument list

Clang additionally specifies possible call candidates to be:

  • operator __pointer_type() const noexcept
  • operator __pointer_type() const volatile noexcept

It seems like this difference in volatility is confusing for clang and msvc, but not gcc.

When call is changed from f() to f.load()(), the code works in all abovementioned compilers. Which is all the more confusing, since both load() and operator T() are said to have const and const volatile overloads - if implicit conversion doesn't work, I'd expect load() not to work as well. Are the rules somehow different within implicit conversions (versus member calls)?

So, is gcc wrong to accept that code? Are clang and msvc wrong to error out? Any other combination of being wrong or right?


This is mostly a theoretical question, but if there is some better way to have an atomic function pointer, I'd like to know.

Chemise answered 28/4, 2022 at 23:19 Comment(2)
All the compilers like (*f)(); but I am not sure what the difference is - I guess it's the same as f.load()() (as above).Unbraid
@RichardCritten That's interesting, though I think (*f)() is more like f(), because operator T() is used implicitly for both (whereas there's no conversion in f.load()()).Chemise
S
19

Clang and MSVC are correct.

For each conversion function to a function pointer of the class, a so-called surrogate call function is added to overload resolution, which if chosen would first convert the object via this operator overload to a function pointer and then call the function via the function pointer. This is explained in [over.call.object]/2.

However, the surrogate call function does not translate the cv-qualifiers of the conversion operator in any way. So, since std::atomic has a conversion operator which is volatile and one which is not, there will be two indistinguishable surrogate call functions. These are also the only candidates since std::atomic doesn't have any actual operator() and so overload resolution must always be ambiguous.

There is even a footnote in the standard mentioning that this can happen, see [over.call.object]/footnote.120.

With a direct call to .load() the volatile-qualifier will be a tie-breaker in overload resolution, so this issue doesn't appear.

With (*f)() overload resolution on the (built-in) operator* with the function pointer type as parameter is performed. There are two implicit conversion sequences via the two conversion functions. The standard isn't very clear on it, but I think the intention is that this doesn't result in an ambiguous conversion sequence (which would also imply ambiguous overload resolution when it is chosen). Instead I think it is intended that the rules for initialization by conversion function are applied to select only one of the conversions, which would make it unambiguously the volatile-qualified one.

Small answered 29/4, 2022 at 6:39 Comment(12)
@ixSci If the object is volatile-qualified, then overload resolution shouldn't be ambiguous. The surrogate call function is generated only for conversion functions which are at least as cv-qualified as the object is. (See linked standard reference.) So MSVC is wrong then.Small
You are right, filed a bug report.Hydra
Huh, that is interesting. But it begs the question: why doesn't standard say that this surrogate call function have the same cv-qualification as the conversion operator it's based on? Are there any problems with that?Chemise
@Chemise In "normal" overload resolution on a member function call the cv-qualifier of the function works by accordingly qualifying the implicit object parameter of the function. The conversion of the object expression to the implicit object parameter then has the mentioned tie-breaker applied.Small
In the case of the surrogate call function, the first parameter of the function submitted for overload resolution is not the implicit object parameter, but instead a parameter of the target function pointer type. This type already has its own cv-qualifications. So there is no place to sensibly add the cv-qualifiers of the conversion function. I am not sure there is any simple solution to this issue. Maybe some additional explicit tie-breakers for surrogate call functions, or std::atomic could add proper operator() overloads.Small
@Small So, surrogate call function is not a member?Chemise
@Chemise For overload resolution member functions are considered the same as non-member functions, except that an additional first parameter is added to them, called the implicit object parameter, which is a reference to the class type qualified according to the cv-qualifiers and ref-qualifiers of the member function. Since the surrogate call function "exists" only for the purpose of overload resolution, it isn't really useful to ask whether it is a member. It is not a member at least in the sense that the aforementioned transformation is not applied.Small
OK, I'm stupid. #1) where does volatile enter into it at all? (There's no such keyword in the example and I don't see any in std::atomic over there at cppreference either.) #2) Doesn't this mean you can't use the f() syntax to call any 0-arg function through an atomic pointer?Oleneolenka
@Oleneolenka I don't fully grasp that myself, but: #1) there are two overloads of conversion from atomic<T> to T (en.cppreference.com/w/cpp/atomic/atomic/operator_T) - one const volatile, one const. f not being volatile, is actually part of a problem here - if I understand this correctly, if f is volatile, only volatile conversion operator would be considered, and only one surrogate call function would be generated - there's no ambiguity. #2) I think it means you can't use f() to call any function through atomic pointer that isn't volatile (you can do (*f)(), etc.).Chemise
@Oleneolenka std::atomic has it's member functions duplicated, once with a volatile-qualifier and once without, so that volatile std::atomic variables can be used, but at the same time optimizations on non-volatile variables can be performed (see LWG 1147). std::atomic doesn't have a operator() overload at all, so it would seem that any call f(...) (no matter the number of arguments) shouldn't work at all. You would need to always load the function pointer from the std::atomic explicitly with .load() before calling it.Small
But there is a somewhat obscure rule in the standard that says that if the class has a conversion operator to a function pointer, then overload resolution may choose to first convert via this conversion operator and then call the resulting function pointer. That's the surrogate call function discussed above. However this rule does not work if there are multiple conversion operators which differ only in const/volatile-qualification as is the case for std::atomic. So, as long as f is not volatile-qualified, f(...) still does never work, no matter how many arguments are used.Small
I can't tell you whether this is an oversight in the design of std::atomic or whether it was not deemed important enough use case to add a proper operator() overload that would make it work. In any case, f.load()(...) with ... replaced by as many arguments as necessary to call the stored function will always work fine.Small

© 2022 - 2024 — McMap. All rights reserved.