以下代码:
vector<vector<int>> a; a.push_back({1,2}); // 可以 a.emplace_back({1,2});// 报错
push_back() 可认为是对入参 {1, 2 } 做了初始化列表的隐式转换?
为什么到了 emplace_back() 中,入参 写 {1, 2} 就不让编译了?是无法隐式转换吗?
为方便表达,我们把 “vector<int>” 取别名为 VecInts :
using IntVec = std::vector<int>;
IntVec 有个构造函数,以 initializer_list<int> 作为入参,略去有默认值的第二个入参,它的原型大概这样子:
IntVec (initializer_list<int> data);
这个initizlizer_list<T> 的类型模板,是 C++11 引入的,这种类型的数据,如果是字面量,要怎么写呢?答:
{ V1, V2, V3, V4 ... Vn }
n 表示个数,即个数不定,另外还有个限制: 各个 V 都得是 T 类型,也就是 V1...Vn 是同类型的。套到本例,可以这样构造一个 initializer_list<int> 数据,并传给 IntVec 的构造函数:
initialize_list<int> d { 90, 88, 100 };
auto dv = IntVec(d);
等一下! 如果有一个函数叫 “foo(int a)”,那么,如果你想把 10 传给它,你会这样写吗?
void foo(int a); // 有个函数
int i = 10; // 先定义个临时变量
foo(i); // 再把变量作为实参传递
十有八九会这么写:
foo(10); // 多直观,多省事
foo(int) 函数的原型说:“我可以要一个int”,而你也恰好有一个整数 ,你就直接给它喽!
IntVec (构造)函数说:“我可以要一个 initialize_list<int> ”,而你手上正好有一个 {90, 88, 100},于是,你也就直接给它喽!所以,不用临时的 d 变量,直接这样写也行:
auto dv = IntVec ({99, 89, 60, 78});
由于 IntVec () 是一个类型的构造函数,所以当然也可以这样写:
IntVec dv ({99, 89, 60, 78});
再等一下!那对 ( ) 看起来很多余,所以,也可以这么写:
IntVec dv {99, 89, 60, 78};
插个话:大多数语言,包括C语言和C++自己,在定义并初始化一个变量时,都喜欢用 = ,所以下面这么写也很正确:
IntVec dv = {99, 89, 60, 78};
再再等一下!,既然提到了 C 语言,来看下面的 C 代码:
struct S
{
int i;
char c;
double d;
char *pc;
};
void demo()
{
S s = {99, 'A', 99.5, "abcdefg"};
}
这段代码当然也是一段合法的 C++ 代码。请问,该 C++ 代码中的 {99, 'A', 99.5, "abcdefg"} 是一个 initialize_list<T> 吗?
当然不是:四个元素的类型并不一致。
可是,你就没有内心阴暗地——想看编译器笑话的——坏坏地想到什么吗——我猜你有:
struct XLYA // 心里阴暗
{
int a, b, c, d;
};
void demo()
{
XLYA xlya = {99, 89, 60, 78};
}
现在这个 {99, 89, 60, 78},它是一个 initialize_list<int> 吗?
编译器如果会说话,肯定在嘲笑我们 :当然也不是,很简单的推理过程:人家 IntVec——也就是 std::vector<int> 是因为有一个构造函数用到了 initialize_list<int> 作入参,可你这个 struct XLYA 定义中,从头到尾没出现过 initialize_list<int> ,你们这是在心理阴暗什么?
且慢!很多人说 为了兼容C 让 C++背上的沉重的包袱,其实,在很长时间里,C++ 在兼容 C 这件事上,完全是主动“贴”上去的舔狗样。正常的兼容就是:来自C的代码,我C++能正确编译通过就是,可是曾经的C++对自己的要求是:我新加的语法,如果语义上和C差不多,那用这个新语法写的代码在语法和语义表现上,都要和 C 语言一样。
比如:
class C
{
public:
int a, b, c, d;
};
void demo()
{
C c = {1, 2, 3, 4};
}
哇,大家都来看,我们对C的兼容,是从根子上兼容的!class 和 public 都是 C 没有,C++才有的,但是,一个所有成员都是 public 的 class ,它和 struct 的作用非常非常地接近,所以,它的对象,也可以使用 { 数据列表 } 来直接初始化哦!
如果仅止于此,说C++是舔狗,确实有些过份了。可是,C++很快将目光看向了下面的代码:
class C
{
int a, b, c, d;
};
void demo_fail()
{
C c = {1, 2, 3, 4};
}
这段代码编译失败了——理由也很充分: a, b, c, d 都是 私有数据,当然不允许语法上,它们被类外的数据直接地初始化。
如果C++能止步于此,那么,就算是舔狗,它也是一只有自己的人生原则的舔狗,可是,它很快就“灵光一闪”!大叫一声,我们不是还新引入了构造函数吗?
于是,下面的代码,也被舔得非常的丝滑且合法:
class C
{
int a, b, c, d;
public:
// 让构造函数带个四个入参
C (int i, int j, int k, int m)
: a(i), b(j), c(k), d(m)
{}
};
void demo_ok()
{
C c = {1, 2, 3, 4}; // 合法了!
}
读到这里,如果你只是眼前一亮(觉得C++好棒),却没有心里又是一暗(想到可能出现的问题),那么 ,阻碍你学好的C++的最大原因,就是你的单纯。
#include <initializer_list>
class C
{
int a, b, c, d;
public:
// 令人眼前一亮的构造
C (int i, int j, int k, int m)
: a(i), b(j), c(k), d(m)
{ std::cout << "1" << std::endl; }
// 令人心里一暗的构造
C (std::initializer_list<int> data)
{ std::cout << "2" << std::endl; }
};
现在,如果我这么写:
C c {99, 89, 60, 78};
现在这里的 {99, 89, 60, 78} ,它是一个 initialize_list<int> 吗?这当然不是在问你,而是在问编译器。
编译器说:“别看我,依照语言标准,当有 initializer_list 的重载,就优先走 initializer_list 的版本,所以上面构造 c 时,输出 2 !”foo
“就这么简单吗?”
“嗯,对一个函数的入参是不是 initializer_list 的判断,应该就这么简单吧”。
“你骗人!!”
回到题目: std::vector<IntVec> 的 push_back() 函数虽然有4个重载版本(C++20版本),但全是以下声明的“变体”而已:
void push_back( const IntVec& dv);
根本就没有出现过 initializer_list <int> ,所以,人家题主来问你了,请看代码:
std::vector<IntVec> a;
a.push_back({1,2});
你是怎么推导出代码中的 “ {1, 2}” 是一个 initializer_list<int> 呢?
“push_back() 确实没有任何以 initializer_list<T> 为入参的重载,但是,它明确说明,它可以要一个 IntVec,而 IntVec 则同样明确地说,它可以要一个 initializer_list<int>,而用户写的 {1,2} 又确实可以是一个 initializer_list<int> ……”
“所以呢?”
“所以我决定,在同一处代码的类型推导,帮,且仅帮一次忙。”
“在哪个环节,帮了个什么忙?”
“push_back() 不能接受 {1, 2}——本来,在这个环节,我就可以报错、可以报怨,但是,我没报。”
“哦,你没报错,那你干嘛了?”
“我决定忍一忍,看看 {1, 2} 能不能一步转成 push_back() 真实想要的东西。 push_back() 真实想要的是一个 IntVec,而 {1, 2} 正好可以一步构造出一个 IntVec …… 这就是我帮的忙。”
“也就是说,一个函数声明要的 是 A,但用户给的B,但是,B可以一步构造出 A,那么 ,你就会帮这个忙是吗?”
“是的,这是一个通则,不管 B 一步转成 A 用的是 (a) 语言内置类型转换,还是上面演示过的:(b) 出于兼容 C 风格的 { } 初始化,(c) 基于C++的令人‘眼前一亮’的,参数个数与类型正好都匹配的,构造函数进行的转换,(d) 或者是基于C++的令人‘心里一暗’的带 initializer_list 入参的构造 。 其中的 c 和 d ,因为都是基于构造 函数,所以你们可以通过 explicit 关键字来告诉我:不要帮忙,哪怕只有一步,也不要帮忙!!!”
原来还可以禁止编译器的多事啊?先来试上面提到的 c 的情况:
class C
{
int a, b, c, d;
public:
// 让构造函数带个四个入参,但是 加了 explicit
explicit C (int i, int j, int k, int m)
: a(i), b(j), c(k), d(m)
{}
};
void demo_bad()
{
C c = {1, 2, 3, 4}; // 又不合法了!!!!!
C c1 {1, 2, 3, 4}; // 也不合法,无关 =
}
呀!之前我还以为,explicit 只能用在单参数版本的构造或赋值操作符重载上呢,原来用在多入参版本的 构造函数上也有相同的意义 :禁止暗中进行的转换。
既然直接构造出 C对象都不允许,下面的情况当然也会编译失败:
class C
{
int a, b, c, d;
public:
// 让构造函数带个四个入参,但是 加了 explicit
explicit C (int i, int j, int k, int m)
: a(i), b(j), c(k), d(m)
{}
};
void my_push_back(const C& c)
{
}
void demo_bad()
{
// 现在,编译器爱莫能助了,因为 explicit 限制了隐式地将 {1, 2, 3, 4} 转换成 C 对象
my_push_back({1, 2, 3, 4});
}
扯了大半天 push_back(T const v),那 emplace_back () 呢?
emplace_back()方法的入参肯定没有任何 initializer_list 的字眼出现。原则上它大概长这样子:
template< typename... Args >
reference emplace_back( Args&&... args );
尽管它也支持 个数不定的入参——似乎有点像 initializer_list ,但人家这个技术点,其实叫“可变个数模板参数”,不仅入参个数不定,而且每个入参的类型都不定。
既然入参根本不是 initializer_list,那么,当你像题主那样,传它一个 { 1, 2 } 时:
vector<IntVec> a;
a.emplace_back({1,2});
编译器其实一视同仁,同样没有直接报错(报怨),而是默默地看了一眼 emplace_back 的原型,然后就发现:相比 vector<T>::push_back( const T & v) ,vector<T>::emplace_back( ???? ) 根本没有明说自己要几个参数,以及每个参数应该是什么类型?
—— 这种感觉大概就是谈恋爱时,男生问女生:今晚吃什么?大要能吃多少 时,女生回答了一句 “随便,都可以……”差不多吧,编译器二眼一黑,双手一摊,不干了……
编译器就不能再耐心一些,先不决定 {1,2} 到底是——
- a 用来兼容某个C风格的结构?
- b 还是说,将来要用它来调用一个双参数版本的构造或普通函数?
- c 或者是一个initializer_list <int>?
然后直接 走入 emplace_back 的内心,等到碰上该方法的内部代码真的要使用 入参时,再来决定吗?
首先,这是编译器,不是解释器。
其次,好吧,就让编译器脾气超nice,愿意带着不确定的类型信息,继续往下走……但这就不叫“只帮一次忙了”了,特别的,更不是“在一个位置上,只帮一次忙了”,而编译这种事情,并不是让程序(编译器)帮忙越多越好——一口气帮的环节多了,最终结果很有可能连程序员都想不到的结果。
最后,担心“程序员想不到结果 ”,那程序员为什么不能去看函数的内部既体实现呢?因为程序员铁定可以看到的,只有函数(包括类方法)的原型,而函数(包括类方法)的实现大有可能是要事先编译成二进制的库文件,你让他怎么看?
最后的最后,万一 emplace_back() 函数内,又调用了一个类似的函数呢?
总而言之,在 emplace_back 入口处,语法上既无法推导出 {1, 2} 是什么,也无法通过只帮一次忙的原则推导出 {1, 2} 应该转换成什么时(比如:是转成一个 struct?还是一个 initializer_list),编译器就必须报错,再往下推导,就“做过了头”了。
人之所以会在这里产生这个问题,多半因为我们在语义上知道 emplace_back 最终就是要为容器插入一个 新元素,而那个元素的类型是确定的。
但在编译器看来 emplace_back 和 foo、bar 没什么两样——也许它需要引入ChatGPT?
更多C++知识点学(闲)习(扯),欢迎到 www.d2school.com 。