dokee's site

Back

一个有趣的 vector 初始化问题Blur image

问题引入#

无奖竞猜, 下面这段代码输出的结果是什么?

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);
    }
}
cpp

UB, 不过在我的电脑上 (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 type std::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 类就可以由 floatint 隐式转换得到. 如果在前面加上 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 to X, the implicit conversion sequence is the worst conversion necessary to convert an element of the list to X, 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_viewint 可以进行隐式转型.

问题解决#

我们将代码改成下面这样就可以了:

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::stringname, 所以不如使用移动语义. 另外一可以看出使用迭代器是调用 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 才是最重要的.

一个有趣的 vector 初始化问题
https://astro-pure.js.org/blog/c/vector-init
Author dokee
Published at March 12, 2025
Comment seems to stuck. Try to refresh?✨