How can I detect whether a type is list-initializable?
Asked Answered
T

3

6

Background: I'm writing a wrapper type like Either<A, B>, and I'd like return {some, args}; to work from a function returning Either<A, B> exactly when it'd work from a function returning A or B. However, I also want to detect when both A and B could be initialized with {some, args}, and produce an error to protect users from the ambiguity.

To detect whether a type T can be initialized from some arguments, I tried writing a function like this:

template<typename T, typename... Args>
auto testInit(Args&&... args) -> decltype(T{std::forward<Args>(args)...});

// imagine some other fallback overload here...

I thought the expression testInit<T>(some, args) should be valid exactly when T{some, args} is valid — in the code below, the initialization auto x = MyType{1UL, 'a'}; works, and this test also passes:

struct MyType {
    MyType(size_t a, char b) {}
};
auto x = MyType{1UL, 'a'};  // ok
static_assert(std::is_same<MyType, decltype(testInit<MyType>(1UL, 'a'))>::value, "");  // ok

However, when we add a constructor from std::initializer_list<char>, it breaks:

struct MyType {
    MyType(size_t a, char b) {}
    MyType(std::initializer_list<char> x) {}  // new!
};
auto x = MyType{1UL, 'a'};  // still ok

// FAILS:
static_assert(std::is_same<MyType, decltype(testInit<MyType>(1UL, 'a'))>::value, "");

note: candidate template ignored: substitution failure [with T = MyType, Args = <unsigned long, char>]: non-constant-expression cannot be narrowed from type 'unsigned long' to 'char' in initializer list

auto testInit(Args&&... args) -> decltype(T{std::forward<Args>(args)...});
     ^                                      ~~~

Why is Clang refusing to resolve my (size_t, char) constructor in favor of the initializer_list constructor? How can I correctly detect whether return {some, args}; would work in a function returning T, regardless of whether it's an aggregate type, a user-defined constructor, or an initializer_list constructor?

Teodoor answered 17/11, 2017 at 4:9 Comment(0)
P
3

It's a little complicated.

And I'm not really an expert so I can say something non completely exact: take what I say with a pinch of salt.

First of all: when you write

auto x = MyType{1UL, 'a'};  // ok

the constructor called is the initializer list one, not the one that receive a std::size_t and the char.

And this works because the first value, 1UL is an unsigned long int but with a value (attention: a value) that can be narrowed to char. That is: works because 1UL is a value that fits in a char.

If you try

auto y = MyType{1000UL, 'a'};  // ERROR!

you get an error because 1000UL can't be narrowed to a char. That is: the 1000UL doesn't fit in a char.

This works also with decltype():

decltype( char{1UL} )    ch1; // compile
decltype( char{1000UL} ) ch2; // ERROR

But consider this function

auto test (std::size_t s)
   -> decltype( char{s} );

This function gives immediately a compilation error.

You can think: "but if a pass 1UL to test(), decltype() can narrow the std::size_t value to a char"

The problem is that C and C++ are strongly typed languages; if you permit that test(), works, returning a type, when receive some values of std::size_t, you can create (via SFINAE) a function that return a type for some values and another type with another types. This in unacceptable from the point of view of a strongly typed language.

So

auto test (std::size_t s)
   -> decltype( char{s} );

is acceptable only if decltype( char{s} ) is acceptable for all possible values of s. That is: test() is unacceptable because std::size_t can hold the 1000UL that doesn't fit in a char.

A little change now: make test() a template function

template <typename T>
auto test (T s)
   -> decltype( char{s} );

Now test() compile; because there are types T with all values that can be narrowed to a char (T = char, by example). So test(), templatized, isn't intrinsically wrong.

But when you use it with a std::size_t

decltype( test(1UL) ) ch;  // ERROR

you get an error because test() can't accept a std::size_t. Neither a value that can be narrowed to a char.

This is exactly the problem of your code.

Your testInit()

template <typename T, typename... Args>
auto testInit(Args&&... args)
   -> decltype(T{std::forward<Args>(args)...});

is acceptable because there are types T and Args... so that T{std::forward<Args>(args)...} is acceptable (example: T = int and Args... = int).

But T = MyType and Args... = std::size_t, char is unacceptable because the constructor used is the one with an initializer list of char and non all std::size_t values can be narrowed to a char.

Concluding: you get an error compiling decltype(testInit<MyType>(1UL, 'a') because you get an error compiling MyType{1000UL, 'a'}.

Bonus answer: I suggest an improvement (IMHO) for your testInit().

Using SFINAE and the power of the comma operator, you can write

template <typename T, typename... Args>
auto testInit (Args ... args)
   -> decltype( T{ args... }, std::true_type{} );

template <typename...>
std::false_type testInit (...);

So you can write some static_assert() simply as follows

static_assert( true == decltype(testInit<MyType>('a', 'b'))::value, "!"); 
static_assert( false == decltype(testInit<MyType>(1UL, 'b'))::value, "!"); 

Post scriptum: if you want that the MyType(size_t a, char b) {} constructor is called, you can use the round parentheses

auto y = MyType(1000UL, 'a');  // compile!

So if you write testInit() with round parentheses

template <typename T, typename... Args>
auto testInit (Args ... args)
   -> decltype( T( args... ), std::true_type{} );

template <typename...>
std::false_type testInit (...);

you pass both following static_assert()s

static_assert( true == decltype(testInit<MyType>('a', 'b'))::value, "!"); 
static_assert( true == decltype(testInit<MyType>(1UL, 'b'))::value, "!"); 
Placentation answered 17/11, 2017 at 14:19 Comment(2)
Thanks for the thorough answer. This is a great explanation of the current behavior. What I'm still missing is an answer to my original goal: "How can I correctly detect whether return {some, args}; would work in a function returning T?"Teodoor
@Teodoor - the answer was "your testInit() works correctly". return {some, args}; should give error if some is a std::size_t and you're returning a MyType; different if you want call the std::size_t/char constructor: you have to return T(some, args); (and modify testInit() consequently)Placentation
E
1

I think that @max66 thoroughly answered what was happening here; initializer_list constructors are greedy so we must be wary.

To answer your second question:

How can I correctly detect whether return {some, args}; would work in a function returning T, regardless of whether it's an aggregate type, a user-defined constructor, or an initializer_list constructor?

std::is_constructible is usually the way to go here, however it only checks that parenthetical construction works, so in your case, the following static_assert fails:

static_assert(std::is_constructible<MyType, char, char, char>::value, "");

Additionally, even if it did work, there'd be no way to tell if we needed to use curly braces or regular parentheses to perform the initialization.

So, let's alias is_constructible to be a more specific is_paren_constructible:

template<class T, class... Args>
using is_paren_constructible = std::is_constructible<T, Args...>;

template<class T, class... Args>
constexpr bool is_paren_constructible_v = 
    is_paren_constructible<T, Args...>::value;

Note that I will be using C++14 and C++17 features in this answer, but we can accomplish the same things with just C++11.

Now let's also distinguish between list-initialization with another trait, is_list_constructible. To do this, I will make use of the voider-pattern (std::void_t was introduced in C++17 to aid this, but I defined it myself to be more C++11 like):

struct voider{
  using type = void;
};

template<class... T>
using void_t = typename voider<T...>::type;

template<class T, class Args, class=void>
struct is_list_constructible : std::false_type{};

template<class T, class... Args>
struct is_list_constructible<T, std::tuple<Args...>,  
  void_t<
      decltype(T{std::declval<Args>()...})
        >
>: std::true_type{};

template<class T, class... Args>
constexpr bool is_list_constructible_v = 
    is_list_constructible<T, std::tuple<Args...>>::value;

This makes things a little weird for your testInit function. Should we use parenthetical construction or list-initialization? We could always split it into two...

template<class T, class... Args>
auto listInit(Args&&... args) -> decltype(T{std::forward<Args>(args)...});
static_assert(std::is_same<MyType, decltype(listInit<MyType>('0', 'a'))>::value, "");

template<class T, class... Args>
auto parenInit(Args&&... args) -> decltype(T(std::forward<Args>(args)...));
static_assert(std::is_same<MyType, decltype(parenInit<MyType>(1UL, 'a'))>::value, "");

But that's no fun, we'd rather have a single entry point that would "just do the right thing", so let's make a new function, do_init that will first attempt list-initialization (at compile-time), and failing that, will attempt parenthetical initialization:

template<class... Args>
MyType do_init(Args&&... args)
{
    constexpr bool can_list_init = is_list_constructible_v<MyType, Args...>;
    constexpr bool can_paren_init = is_paren_constructible_v<MyType, Args...>;
    static_assert(can_list_init || can_paren_init, "Cannot initialize MyType with the provided arguments");

    if constexpr(can_list_init)
        return MyType{std::forward<Args>(args)...};
    else
        return MyType(std::forward<Args>(args)...);
}

in main we can call our do_init function and it will construct MyType in the appropriate fashion (or fail a static_assert otherwise):

int main(){
    (void)do_init('a', 'b'); // list init
    (void)do_init(10000UL, 'c'); // parenthetical
    (void)do_init(1UL, 'd'); // parenthetical
    (void)do_init(true, false, true, false); // list init

    // fails static assert
    //(void)do_init("alpha");
}

We could even combine is_list_constructible and is_paren_constructible into a single trait, is_constructible_somehow:

template<class T, class... Args>
constexpr bool is_constructible_somehow = std::disjunction_v<is_list_constructible<T, std::tuple<Args...>>, is_paren_constructible<T, Args...>>;

Usage:

static_assert(is_constructible_somehow<MyType, size_t, char>, "");
static_assert(is_constructible_somehow<MyType, char, char, char>, "");

Demo

Euniceeunuch answered 11/2, 2020 at 15:19 Comment(0)
V
0

There is some strange behavior if a constructor exists with std::initializer_list if you init your object with {}.

Example:

struct MyType 
{
    MyType(size_t , char ) { std::cout << "Construct via size_t/char" << std::endl;}
    MyType(std::initializer_list<char> ) { std::cout << "Construct via list" << std::endl;}
};

auto x1 = MyType{1UL, 'a'};   
auto x2 = MyType((1UL), 'b');

For x1 it runs into the initializer llist constructor because you are using the {} syntax. If you use the () syntax, you get the expected constructor called. But you run into the most vexing parse problem, so additional braces are needed!

Back to your code, in your test function:

template<typename T, typename... Args>
auto testInit(Args&&... args) -> decltype(T{std::forward<Args>(args)...});

you are using {}. If you change to () everything works fine!

template<typename T, typename... Args>
auto testInit(Args&&... args) -> decltype(T(std::forward<Args>(args)...));

why:

§13.3.1.7 [over.match.list]/p1:

When objects of non-aggregate class type T are list-initialized (8.5.4), overload resolution selects the constructor in two phases:

  • Initially, the candidate functions are the initializer-list constructors (8.5.4) of the class T and the argument list consists of the initializer list as a single argument.
  • If no viable initializer-list constructor is found, overload resolution is performed again, where the candidate functions are all the constructors of the class T and the argument list consists of the elements of the initializer list.

If the initializer list has no elements and T has a default constructor, the first phase is omitted. In copy-list-initialization, if an explicit constructor is chosen, the initialization is ill-formed.

In addition, initializer list constructors do not allow narrowing!

§8.5.4 List-initialization

(3.4) Otherwise, if T is a class type, constructors are considered. The applicable constructors are enumerated and the best one is chosen through overload resolution ([over.match], [over.match.list]). If a narrowing conversion (see below) is required to convert any of the arguments, the program is ill-formed.

In your case, your test function catches the initializer list constructor as it is preferred if it is available and and list initialization is used. Simply that ends in the narrowing failure.

Vincenza answered 11/2, 2020 at 16:10 Comment(2)
Now it fails on static_assert(std::is_same<MyType, decltype(testInit<MyType>('a', 'b', 'c'))>::value, "");Euniceeunuch
@AndyG: Because it is not a initializer_list nor mathcing any other constructors. If you use static_assert(std::is_same<MyType, decltype(testInit<MyType>(std::initializer_list<char>{'a', 'b', 'c'}))>::value, ""); everything is fine. Before the test function do a conversion to initializer list which is not a good idea for a test function I believe.Vincenza

© 2022 - 2024 — McMap. All rights reserved.