

问题引入#
无奖竞猜, 下面这段代码输出的结果是什么?
struct Person {
Person(const std::string_view name) : name { name } {}
Person(const int name) : name { std::to_string(name) } {}
std::string name;
};
int main() {
std::vector<Person> vec { "Tom", "Jerry" };
for (auto& e : vec) {
std::println("{}", e.name);
}
}
cppUB, 不过在我的电脑上 (clang19, libc++) 输出的结果如下:
84
111
109
0
plaintext很明显我们希望的是构造两个 name
分别为 "Tom"
和 "Jerry"
的 Person
, 但是结果却不是这样. 来看看发生了什么:
可以看到, 由于字符串字面量是一个指针, 而指针也是迭代器, 就调用了 vector(InputIt first, InputIt last)
这个构造函数. 而 "Tom"
和 "Jerry"
这两个字符串存储的地方正好相邻, 所以 vec
读入到 "Tom"
末尾的 '\0'
就结束了.
可以试试下面的代码, 不同优化等级输出会不同, 因为编译器可能会将不需要的 spike
给优化掉. 另外编译器也没有义务保证它们的存储是不是相邻的, 所以总之这就是个 UB.
auto tom = "Tom";
auto spike = "Spike";
auto jerry = "Jerry";
std::vector<Person> vec { tom, jerry };
cpp知识回顾#
为了搞清楚到底发生了什么, 我们先回顾一下 C++ 的知识.
std::vector
的构造函数#
首先回顾 std::vector
的构造函数, 它的构造函数有很多, 这里涉及到下面这两种:
vector(InputIt first, InputIt last)
vector(std::initializer_list<T> init)
cpp列表初始化语法#
然后回顾一下 C++ 的初始化. C++ 可以说是初始化方法最多的一种语言了, 多到有些逆天. 我们这里使用的是列表初始化语法. Cppreference 有如下描述:
- All constructors that take
std::initializer_list
as the only argument, or as the first argument if the remaining arguments have default values, are examined, and matched by overload resolution against a single argument of typestd::initializer_list
.- If the previous stage does not produce a match, all constructors of
T
participate in overload resolution against the set of arguments that consists of the initializer clauses of the brace-enclosed initializer list, with the restriction that only non-narrowing conversions are allowed. If this stage produces an explicit constructor as the best match for a copy-list-initialization, compilation fails (note, in simple copy-initialization, explicit constructors are not considered at all).
需要注意的是, 这里会先尝试匹配 std::initializer_list
作为参数的构造函数. 这也是为什么 std::vector<int>(5, 1)
和 std::vector<int>{5, 1}
得到的结果是不一样的.
隐式转换#
隐式转换有多种, 这里需要注意的是用户定义转换. 用户定义转换主要有以下两种:
- 非显式单实参转换构造函数
- 非显式转换函数
举个例子:
struct Foo {
Foo(float);
operator int();
// ...
}
Foo a { 42 }; // OK
Foo b { 3.14f }; // OK
cpp这个 Foo
类就可以由 float
或 int
隐式转换得到. 如果在前面加上 explicit
, 则必须进行显式转换, i.e. static_cast
.
initializer_list 构造函数#
前面在列表初始化语法中提到了会先匹配 initializer_list 构造函数, 那么这里发生了什么呢? C++ 的重载决议中有如下描述:
Otherwise, if the parameter type is
std::initializer_list<X>
and all the elements of the initializer list can be implicitly converted toX
, the implicit conversion sequence is the worst conversion necessary to convert an element of the list toX
, or if the initializer list has no elements, the identity conversion. This conversion can be a user-defined conversion even in the context of a call to an initializer-list constructor.
可以看到, 它会尝试将初始化器列表中的元素转换为 X
类型. 注意这里就是可能发生隐式转换的地方.
问题分析#
现在我们再看最开始的例子, 一步一步解释在构造 vec
时编译器做了什么.
- 使用列表初始化语法进行初始化, 有两个类型为
char*
的初始化器元素; - 先尝试匹配
std::initializer_list<Person>
作为参数的构造函数- 尝试将
char*
转换为Person
类型, 失败
- 尝试将
- 将初始化器元素作为函数参数尝试匹配
std::vector<Person>
所有的构造函数;- 经过重载决议, 匹配到了
vector(char*, char*)
, 这里的char*
满足InputIt
的模板要求;
- 经过重载决议, 匹配到了
- 经由迭代器使用传入的容器中的元素对 vector 中的
Person
进行逐个的构造.
所以代码没有执行预期功能的原因就是 char*
无法隐式转型为 Person
, 只有 std::string_view
和 int
可以进行隐式转型.
问题解决#
我们将代码改成下面这样就可以了:
using namespace std::literals;
struct Person {
Person(const std::string_view name) : name { name } {}
std::string name;
};
int main() {
std::vector<Person> vec { "Tom"sv, "Jerry"sv };
for (auto& e : vec) {
std::println("{}", e.name);
}
}
cpp这样初始化器中的元素类型都是 std::string_view
, 可以进行隐式转换.另外也直接可以使用下面的方案:
std::vector<Person> vec { {"Tom"}, {"Jerry"} };
cpp这样也可以的原因是发生了 char*
-> std::string_view
-> Person
两次隐式转换. 你可以在 C++ Insights ↗ 上分别尝试一下它们.
个人建议#
一般来说, 推荐对单参数构造函数使用 explicit
以避免隐式转换. 所以对于这个例子, 个人比较推荐的做法是:
using namespace std::literals;
struct Person {
explicit Person(std::string&& name) noexcept :
name { std::move(name) } {}
// other ctor
std::string name;
};
int main() {
std::vector names { "Tom"s, "Spike"s, "Jerry"s };
std::vector<Person> vec {
std::move_iterator(names.begin()),
std::move_iterator(names.end())
};
for (auto& e : vec) {
std::println("{}", e.name);
}
}
cpp因为最后总归是要构造一个 std::string
的 name
, 所以不如使用移动语义. 另外一可以看出使用迭代器是调用 Person
的构造函数, 而使用 initializer_list 是隐式转换, 这是不一样的. 另外隐式转换可能会构造临时对象, 这点也要注意.
不过也不是一定要用 explicit
, 个人觉得一般来说防止对较基础的类型的隐式转换就行了, 有的时候也利用隐式转换的方便之处, 例如类似这样的场景:
using namespace std::literals;
struct PersonInfo {
explicit PersonInfo(std::string&& name) noexcept :
name { std::move(name) } /*, other_info... */ {}
std::string name;
// many other infos
};
struct Person : public PersonInfo {
Person(const PersonInfo& info) :
PersonInfo { info } {}
void say() {
std::println("I'm {}.", name);
}
};
int main() {
PersonInfo tom { "Tom"s };
PersonInfo spike { "Spike"s };
PersonInfo jerry { "Jerry"s };
std::vector<Person> vec { tom, spike, jerry };
for (auto& someone : vec) {
someone.say();
}
}
cpp总结#
总的体会就是 C++ 初始化的方法正式太多太复杂了, 真是让人头晕. 比如对 std::array
是聚合初始化, 所以有了下面的区别:
struct S {
int i;
int j;
};
std::array<S, 2> arr {{ { 1, 2 }, { 3, 4 } }};
// std::array<S, 2> arr { { 1, 2 }, { 3, 4 } }; // error!
std::vector<S> vec { { 1, 2 }, { 3, 4 } }; // OK
cpp不过纠结这些也没太大用, 只是有趣, 避免 UB 才是最重要的.
Related#
- Hero Image: cat-milk/Anime-Girls-Holding-Programming-Books ↗
- List-initialization - cppreference.com ↗
- std::vector<T,Allocator>::vector - cppreference.com ↗
- List-initialization - cppreference.com ↗
- Overload resolution - cppreference.com ↗
- Implicit conversions - cppreference.com ↗
- User-defined conversion function - cppreference.com ↗
- Converting constructor - cppreference.com ↗
- [over.match.list] ↗
- [over.ics.list] ↗
- C++ Core Guidelines ↗