Brace-initialized vector constructor argument raises ambiguity error in gcc, but not in clang, and depends on size of vector

Viewed 276

I have a C++ class that accepts a std::vector of std::pair<std::string, std::string> as its argument. I'm finding that when I call the constructor with a braced-initializer list, I get an ambiguous constructor error from gcc, but only if the vector contains only one element. The error does not occur if the vector contains two elements. Also, clang can compile the program even if the vector contains only one element! I tried versions of gcc from 5.x through 10.x, and clang from 10.x to 12.x. My minimal reproducing case looks like this (compiled with g++ -lstdc++ -std=c++14):

#include <utility>
#include <vector>
#include <string>

class Foo {
 public:
  explicit Foo(const std::vector< std::pair<std::string, std::string> >& list) {
      (void)list;
  }

  Foo(const Foo& other)
  {
      (void)other;
  }
};

int main() {
    Foo f = Foo({{"a", "b"}, {"c", "d"}}); // Compiles with clang and gcc.
    Foo g = Foo({{"a", "b"}}); // Compiles with clang, but not gcc.
    (void)f;
    (void)g;
}

Clang compiles this without an error, but with gcc-10.3 (for example), I get:

<source>: In function 'int main()':
<source>:19:29: error: call of overloaded 'Foo(<brace-enclosed initializer list>)' is ambiguous
   19 |     Foo g = Foo({{"a", "b"}}); // Compiles with clang, but not gcc.
      |                             ^
<source>:11:3: note: candidate: 'Foo::Foo(const Foo&)'
   11 |   Foo(const Foo& other)
      |   ^~~
<source>:7:12: note: candidate: 'Foo::Foo(const std::vector<std::pair<std::__cxx11::basic_string<char>, std::__cxx11::basic_string<char> > >&)'
    7 |   explicit Foo(const std::vector< std::pair<std::string, std::string> >& list) {
      |            ^~~

Does anyone know why this doesn't work in gcc? I would have thought that the explicit keyword would be enough to disambiguate, and it really odd that it works when the vector has more that one element. Thanks in advance for any guidance!

2 Answers

Consider the following program:

#include <iostream>
#include <string>

class Foo {
public:
    explicit Foo(const std::string& str)
    {
        std::cout << str;
    }
};

int main() {
    Foo f({ "a", "b", });
}

MSVC and gcc will compile it without any warnings or errors but the program crashes when you try to run it.

Let us try to figure out what is going on.

std::string has many constructors, see https://en.cppreference.com/w/cpp/string/basic_string/basic_string

One of them allows us do nifty things like the following:

std::string a("hello");
std::string b(a.begin(), a.end());

The same constructor can, unfortunately, be misused to create a mess like the following:

std::string c("a", "b");

The problem with your code is that the compiler does not know how to interpret the initialization list.

When you only have {"a", "b"} it can either be the constructor for a std::string or a pair of values.

The best you can do is to try not to confuse the compiler and rather use std::make_pair or use the correct brackets:

Foo f = Foo({std::make_pair("a", "b"), std::make_pair("c", "d")});
Foo g = Foo({std::make_pair("a", "b")}); 

Here is my attempt at explanation:

This line:

Foo f = Foo({{"a", "b"}, {"c", "d"}});

is roughly equivalent to the following:

Foo f = 
    Foo(
        std::initializer_list<std::initializer_list<const char*>>{
            std::initializer<const char*>{"a", "b"},
            std::initializer<const char*>{"c", "d"}
        }
    );

Since std::pair has a constructor for any types U1 and U2, std::pair can be constructed from an initializer_list<const char*>, or two const char*. The compiler most likely constructs two std::pair<std::string, std::string>, as it sees that we have an overload for a std::vector of the aforementioned type:

Foo f = 
    Foo(
        std::initializer_list<std::pair<std::string, std::string>>{
            std::pair<std::string, std::string>("a", "b"),
            std::pair<std::string, std::string>("c", "d")
        }
    );

Since std::vector<T> has a constructor that takes a std::initializer_list<T>, then the correct overload is chosen.

Let's take a look at this:

Foo g = Foo({{"a", "b"}});

which turns into:

Foo f = 
    Foo(
        std::initializer_list<std::initializer_list<const char*>>{
            std::initializer_list<const char*>{"a", "b"}
        }
    );

Here it is not so obvious what we are doing. The compiler (assuming that we are just idiots) thinks that we might be trying to create a Foo from either an initializer_list<const char*> and then copying that Foo, or that we are directly creating the Foo with an initilizer_list<initializer_list<const char*>>. Since the constructor for std::vector<std::pair<std::string,std::string>> takes a std::initializer_list<std::pair<std::string,std::string>> and not a std::initializer_list<std::initializer_list<const char*>>, it is not clear to a dumb compiler what we are trying to do.

This is wild! However, consider what would happen here if the foo copy constructor was marked as explicit:

Foo f = Foo({{"a", "b"}, {"c", "d"}}); // Compiles with clang and gcc.
// Foo g = Foo({{"a", "b"}});
Foo f2 = Foo({f});                     // No errors! Equivalent to Foo{f}

Since Foo({f}) is equivalent to Foo{f}, then g could be considered the same as:

Foo g = Foo{{"a", "b"}};

The compiler won't do the same for f because there is no foo constructor that takes two arguments (i.e., {"a","b"} and {"c","d"}).

In addition, consider what happens when you explicitly construct a std::string from "a" and "b":

Foo g = Foo({{std::string("a"), std::string("b")}});

This runs just fine. That is because the compiler can clearly see that std::vector<std::pair<std::string, std::string>> is a better choice than Foo because std::pair can be constructed from two std::string's

In conclusion: One of the two things is most likely true:

  • GCC simply is confused about what we are trying to do, as it sees Foo(const Foo&) and Foo(const std::vector<std::pair<std::string,std::string>>&) both take one argument and both seem to be equally valid.
  • Since Foo({...}) is equivalent to Foo{...}, the compiler is confused by the fact that we are calling Foo{{"a","b"}}, trying to create a std::vector<std::pair<std::string,std::string>> from "a" and "b".

The solutions:

  • Use Foo{{{"a", "b"}}} instead; or:
  • Put using namspace std::string_literals in your code and replace Foo({{"a", "b"}}) with Foo({{"a"s, "b"s}}); or:
  • Use std::make_pair

EDIT: One more important point: if you delete the Foo copy constructor, it becomes apparent that the compiler is trying to create a pair from "a" and another from "b". Take a look at the error message for this code:

#include <iostream>
#include <utility>
#include <vector>
#include <string>

class Foo {
public:
    Foo(const std::vector< std::pair<std::string, std::string> >& list) {}
    Foo(const Foo&) = delete;
};

int main() {
    Foo foo1({ {"a", "b"}, {"c", "d"} });
    Foo foo2({ {"a", "b"} });
}

error: no matching function for call to ‘std::pair, std::__cxx11::basic_string >::pair(const char&)’

PLEASE NOTE: this is just a combination of what I know about std::initializer_list's and my inferences about what is happening. Take this answer with a grain of salt, what is possibly occurring.

Related