构造
委托构造
构造函数用于初始化一个对象,但是一个对象很可能有多种方式来完成初始化。但是多种方式之间,内部的逻辑可能相似,只是传参方式略有差别。对于这种情况,可以设置一个通用的init
函数来完成重复的逻辑。
例如:
class Rectangle
{
private:
int width;
int height;
public:
void init(int w, int h)
{
width = w;
height = h;
}
// 接受长宽的构造函数
Rectangle(int w, int h)
{
init(w, h);
}
// 创建正方形
Rectangle(int side)
{
init(side, side);
}
};
以上代码表示一个矩形类,它包含两种构造,一种接受两个参数,设置矩形的长和宽。另外一种只接受一个参数,表示创建一个正方形。
传入的长和宽需要初始化,使用init
函数来完成,两个构造函数最后调用相同的函数完成初始化。
在C++11后,对于这样类似逻辑的处理,可以使用委托构造
来完成。
委托构造可以让一个构造函数调用另外一个构造函数来完成初始化
想要触发委托构造,只需在构造函数的初始化列表调用另外一个构造函数。
示例:
class Rectangle
{
private:
int width;
int height;
public:
Rectangle(int w, int h)
: width(w)
, height(h)
{ }
Rectangle(int side)
: Rectangle(side, side)
{ }
};
同样的矩形类,第一个构造接受长宽,第二个正方形构造只接受一个side
。构造时,调用Rectangle(side, side)
,这是前面那个双参数的构造函数,此时Rectangle(int side)
就会委托Rectangle(int w, int h)
完成构造。
- 函数体
对于委托构造,构造函数本身的函数体是会正常执行的,并且是在委托构造完成后执行。
在刚才的两个构造函数的函数体中添加输出:
class Rectangle
{
private:
int width;
int height;
public:
Rectangle(int w, int h)
: width(w)
, height(h)
{
std::cout << "矩形构造完成" << std::endl;
}
Rectangle(int side)
: Rectangle(side, side)
{
std::cout << "正方形构造完成" << std::endl;
}
};
执行代码:
Rectangle rec(5);
输出:
矩形构造完成
正方形构造完成
- 初始化列表
在语法中,委托构造放在了构造函数的初始化列表中。当一个构造函数使用了委托构造,此时不允许在构造函数中存在其他成员进行初始化。
例如:
class Rectangle
{
private:
int width;
int height;
std::string color;
public:
Rectangle(int w, int h)
: width(w)
, height(h)
{ }
Rectangle(int side)
: Rectangle(side, side)
, color("red")
{ }
};
在这个矩形类中,添加了一个颜色属性。在正方形的构造中,将颜色设置为红色,但是这段代码无法编译。因为在正方形的构造中使用了委托构造,就不允许使用初始化列表再初始化其他属性了。
这是因为当调用委托构造,被委托的构造函数中可能已经对部分类内部的属性进行了初始化。如果当被委托的函数执行完,再回来执行原本的构造函数,可能就会导致一个成员被多次初始化。因此当使用委托构造时,这个构造函数的初始化列表就不能有其他成员进行初始化了。
- 委托链
在委托构造中,可以多次进行委托,形成一个委托链。
示例:
class Rectangle
{
private:
int width;
int height;
std::string color;
public:
Rectangle(std::string c)
: color(c)
{ }
Rectangle(int w, int h, std::string c)
: Rectangle(c)
{
width = w;
height = h;
}
Rectangle(int side, std::string c)
: Rectangle(side, side, c)
{ }
};
现在矩形类有三个构造函数,一个是方形,一个是矩形,还有一个专门处理颜色。
当调用方形的构造函数,会去委托矩形的构造,而矩形的构造又会委托第一个构造函数处理颜色,此时就形成了一个多层级的委托链。
要注意的是,如果委托形成了链条,不能形成环状结构,否则会造成死递归,导致栈溢出。
继承构造
在部分继承场景下,派生类可能继承了基类后,没有定义新的成员,只是添加了几个方法,此时派生类可能不需要改动任何构造函数,直接使用基类的构造函数就可以。
class Base
{
public:
Base()
{
std::cout << "Base()" << std::endl;
}
Base(int x)
{
std::cout << "Base(int x)" << std::endl;
}
Base(int x, int y)
{
std::cout << "Base(int x, int y)" << std::endl;
}
};
以上基类,有三个构造函数的重载。派生类中要保证初始化功能不变。
第一种方法,每一个构造函数,都进行一次显式调用:
class Derived
: public Base
{
public:
Derived()
: Base()
{ }
Derived(int x)
: Base(x)
{ }
Derived(int x, int y)
: Base(x, y)
{ }
};
对于每一种构造函数,派生类都要重写一份参数列表相同的构造函数代码,然后再在初始化列表内调用基类的构造函数进行参数转发。
第二种方法使用模板可变参,也可以这样写:
class Derived
: public Base
{
public:
template<typename... Arg>
Derived(Arg ...arg)
: Base(arg...)
{ }
};
这样也可以用较为简短的代码完成效果,但是这个特性是在C++11加入的,如果再早些版本,还是要重新写出每一份构造函数的代码。
为了应对这种情况,C++11又支持了继承构造
的特性,派生类可以一键继承基类的所有构造函数重载,只需要一行简短的using
。
class Derived
: public Base
{
using Base::Base;
};
以上代码中,派生类就可以使用基类的所有构造了。using Base::Base
这行代码,把所有基类的构造函数都下放到了派生类,派生类可以直接使用。
实际上,此处编译器会隐式的生成每一个基类构造函数的转发函数,效果等同于第一种方法,但是代码只需要一行。
要注意的是,使用这种方式完成构造函数后,如果派生类有自己的类成员,都会默认初始化。
- 默认构造
此处有一个特殊的注意点,就是对于默认构造,不同编译器的处理方式会有不同。
以以下代码为例:
class Base
{
public:
Base() {}
Base(int x, int y) {}
};
class Derived
: public Base
{
public:
using Base::Base;
Derived(int x) {}
};
int main()
{
Derived d;
return 0;
}
提问:这段代码可以运行吗?
这段代码比较特殊,经过我的测试,在VS2022
即MSVC
编译器,代码运行失败,因为对象d
找不到默认构造。而在Clion
即clang
编译器,代码成功运行。
- 在
VS2022
下:
派生类通过 using Base::Base
不会继承基类的默认构造函数,此时由于派生类定义了一个自己的带参数的构造函数,导致默认的构造函数没有生成,也就丢失了默认构造。代码 Derived d
就会找不到默认构造而编译失败。
如果把派生类自己定义的构造Derived(int x)
删掉,就可以运行了,因为派生类会自己生成默认构造,从基类继承下来的构造Base(int x, int y)
,不会影响派生类生成默认构造。
- 在
Clion
下:
派生类可以通过 using Base::Base
继承到基类的默认构造,所以就算派生类显式定义了构造函数,导致没有生成默认构造,但是也可以调用到继承下来的默认构造。
对于这种问题,最好还是直接在派生类通过= default
显式声明默认构造,不论在哪一个平台都可以正常运行,而且默认构造本身就会隐式调用基类的默认构造,也符合大部分场景。
- 多继承
在多继承场景下,派生类可以继承多个基类的构造。
class A
{
public:
A() { }
A(int x) { }
};
class B
{
public:
B() { }
B(int x, int y) { }
};
class C
: public A
, public B
{
public:
using A::A;
using B::B;
};
以上代码中,C
多继承了A
和B
,并在C
中继承了两者的构造,此时C
内部的函数等效于:
class C
: public A
, public B
{
public:
C() = default; // 编译器默认生成
C(int x) : A(x) {} // 从 A 继承
C(int x, int y) : B(x, y) {} // 从 B 继承
};
但是如果A
和B
提供的参数列表是相同的,C
就不知道继承哪一个,此时就会发生冲突。
比如:
class A
{
public:
A() { }
A(int x) { }
};
class B
{
public:
B() { }
B(int x) { }
};
class C
: public A
, public B
{
public:
using A::A;
using B::B;
};
此时A
和B
都提供了一个int
类型参数的构造函数,那么C
就会出错,相当于继承下来两个参数相同的构造函数。
多继承场景下,如果使用继承构造,不要让多个基类提供参数相同的构造。
接下来还是一个跨平台问题:
class A
{
public:
A() {}
A(int x) {}
};
class B
{
public:
B() {}
B(int x, int y) {}
};
class C : public A, public B
{
public:
using A::A;
using B::B;
};
int main()
{
C c;
return 0;
}
以上代码运行结果是什么?
在MSVC
下运行成功,虽然C
没有继承A
和B
的默认构造,但是由于没有自定义其他构造,可以隐式生成一个默认构造。
在Clang
下运行成功,与前者同理,C
没有显式声明构造,使用隐式生成的默认构造。
但是如果此时在C
中加一个构造函数C(double d)
呢?
此时MSVC
编译失败,报错:C() 没有合适的默认构造函数可用
。
而Clang
同样编译失败,报错:
error: call of overloaded 'C()' is ambiguous
note: candidate: 'A::A()'
note: candidate: 'B::B()'
虽然都报错了,但是报错明显不同,一个是根本找不到C()
,另外一个则是有多个C()
不知道选哪一个。
结合刚才的跨平台问题,其实也可以解释一下为什么两个平台报错不同。
在MSVC
下,派生类不会继承基类的构造,而C
又声明了其它构造C(double d)
,C
无法生成自己的默认构造,导致C
内部没有任何一个默认构造,最后报错
而在Clang
下,C
由于显式定义构造,同样无法生成默认构造。但是又从A
和B
分别继承下来了两个默认构造:
class C : public A, public B
{
public:
C() : A() {} // 从 A 继承
C() : B() {} // 从 B 继承
};
导致C
的默认构造歧义了,编译器无法判断使用哪一个默认构造,最后报错。
这也可以进一步证明,MSVC
下不会继承默认构造,而Clang
会继承。
- 权限
继承构造是通过using
引入基类的构造函数的,那么继承下来的构造函数权限是什么?
这个问题其实和普通的成员是一样的,权限为基类中成员的权限,以及继承方式的较小值。比如通过public
继承一个protect
方法,最后权限就是protect
。
要注意的是,这和using
的位置完全无关,比如:
class Derived
: public Base
{
public:
using Base::Base;
};
class Derived
: public Base
{
private:
using Base::Base;
};
以上两段代码没有任何区别,不论using
放在哪一个权限修饰符下,都不会对继承构造的权限有任何影响。
初始化
{ }初始化
在C++98中,允许使用花括号{ }
对数组或者结构体元素进行统一的列表初始化。
用{ }
初始化数组:
int arr[] = { 1, 2, 3, 4, 5 };
用{ }
初始化结构体:
struct stu
{
char name[20];
int age;
};
int main()
{
stu s1 = { "Jones", 18 };
return 0;
}
C++11扩大了
{ }
的适用范围,其可以用于所有内置类型和自定义类型的初始化
C++11希望通过这个语法,使得所有变量可以以一种统一的方式进行初始化
比如对于内置类型:
int i = { 1 };
double d = { 3.14 };
char c = { 'x' };
int* pf = { nullptr };
int& ri = { i };
我们可以通过大括号初始化整型,浮点型,指针,引用等等。在使用{ }
进行初始化时,可以省略掉=
,所以以上代码也可以写为:
int i{ 1 };
double d{ 3.14 };
char c{ 'x' };
int* pf{ nullptr };
int& ri{ i };
但是这个语法其实也比较鸡肋,因为这个写法完全没有直接int i = 1;
这样来的直接。
不过相比于直接=
赋值,其实{}
有更加严格的类型要求:相比于=
,{}
不允许收缩转换。
例如:
int a = 3.14; // 成功,a 得到 3
int b{ 3.14 }; // 失败
此处字面量3.14
的类型是double
,=
允许一个double
转向int
,这会导致精度丢失,但是{}
不允许这样精度丢失的转化,更加严格一点。
接着就是自定义类型的初始化:
struct stu
{
char name[20];
int age;
};
int main()
{
stu s{ "jack", 18 };
string str{ "hello world" };
int arr[5]{ 1, 2, 3, 4, 5 };
return 0;
}
以上代码中,通过直接在变量后面加一对大括号来实现初始化,数组和结构体初始化时的=
也可以省略掉。
在此,额外辨析一下现有的类的初始化方式:
现在有如下日期类:
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
,_month(month)
,_day(day)
{}
private:
int _year;
int _month;
int _day;
};
其含义三个变量,表示年月日,一个三参数的构造函数,初始化这个Date
。
我们有如下方式对其初始化:
Date d1(2024, 4, 3);
Date d2 = { 2024, 4, 3 };
Date d3{ 2024, 4, 3 };
请问这三个方式,分别是如何初始化一个类的?
- 对于
Date d1(2024, 4, 3);
,其就是最基础的构造函数调用语法,也就是直接构造 - 对于
Date d2 = { 2024, 4, 3 };
,很多人看到这个写法,再想到我们刚才讲的{ }
初始化,以为这个是C++11的新语法,其实并不是的。这个写法是多参数构造函数的类型转化,也就是说这个写法{ 2024, 4, 3 };
就是把三个int
类型转换为Date
类型。如果你用explicit
关键字修饰这个构造函数,那么类型转换功能就会被禁止,这个写法就会报错 - 对于
Date d3{ 2024, 4, 3 };
,这个写法即使用了{ }
,而且还省略了=
,这就是C++11提供的新语法了,当用explicit
修饰这个构造函数,这个写法依然有效。因为这个写法也是直接调用构造函数,而不是进行类型转换
其实整体上来说,C++11提供{ }
的意图在于提供统一的方式来初始化所有类型,但是奈何大部分程序员已经习惯了之前的写法,{ }
既没有带来效率的提高,也没有更加人性化的语法设计(甚至我感觉int i = 1
比int i{1};
更符合人类的习惯),因此这个语法并没有被广泛接受。
initializer_list
initializer_list
是一个新的C++类型,我先为大家创建一个initializer_list
类型:
auto li = { 1, 2, 3, 4 };
此时,li
的类型就是initializer_list
,这个时候有的人就疑惑了,{ 1, 2, 3, 4 }
分明是一个整型数组,怎么改了个名字就变成新类型了?initializer_list
翻译为中文就是初始化列表
,也就是说,这是一个用于初始化的工具。
假设现在你有以下数组:
int arr[5] = { 1, 2, 3, 4 };
你要如何用这个数组来初始化一个vector
,初始化一个list
,初始化一个set
呢?
我们好像只能粗暴的遍历数组,然后一个一个插入数据:
vector<int> v;
for (int i = 0; i < 5; i++)
{
v.push_back(arr[i]);
}
这着实有点麻烦了,但是C++11后,STL的所有容器都增加了新的构造函数,可以通过initializer_list
来初始化容器:
vector<int> v({ 1, 2, 3, 4, 5 });
以上代码中,{ 1, 2, 3, 4, 5 }
整体就是一个initializer_list
,作为参数传给v,调用vector
的构造函数。
当然,我们也可以这样写:
vector<int> v = { 1, 2, 3, 4, 5 };
这个写法,则是单参数的类型转化,因为{ 1, 2, 3, 4, 5 }
整体就是一个initializer_list
类型的参数。
相同的办法,我们还可以初始化map
:
map<string, string> m = { {"apple","苹果"}, {"strawberry","草莓"}, {"watermelon", "西瓜"} };
以上代码中,最外层的{ }
括起来的就是一个initializer_list
,内部的三个{ }
则是三个不同的pair<const char*, const char*>
,不过const char*
可以转为string
,因此最后pair<const char*, const char*>
会变成pair<string, string>
。
最外层的initializer_list
内部的三个pair
,会依次插入进map
中,也就是一次拿多个值初始化map
的多个节点。
至此,你应该理解了,initializer_list
就是在类构造时,如果我们想要一次性初始化多个节点,就把这些节点放进一个initializer_list
内部,这样就能在构造函数中直接构造好。
initializer_list
本质上也是一个容器,一个类模板:
因此{ 1, 2, 3, 4, 5 }
的准确类型应该是:initializer_list<int>
。
而initializer_list
的底层也非常简单,我们看看其仅有的四个接口:
一个构造函数constructor
,一个描述长度的接口size
,以及迭代器begin
,end
。
在initializer_list
内部,维护了一个数组,数组存储了所有元素。也就是说initializer_list
本质上是一个通过迭代器访问数组的容器。当其它容器通过initializer_list
构造自己,其实就是通过迭代器遍历initializer_list
的数组,然后把数组元素一个一个插入。
也就是说,以下两种情况,本质是一样的:
initializer_list<int> lt = { 1, 2, 3, 4 };
list<int> l1({ 1, 2, 3, 4 });
list<int> l2(lt.begin(), lt.end());
第一个list
通过initializer_list
初始化自己,第二个list
则通过迭代器初始化自己。不过前者更加方便,是C++11提供的,而后者是C++98提供的。
声明
auto
在C++11中,新增了关键字auto
,其可以自动推导类型:
auto i = 1;//整型
auto d = 3.14;//浮点型
auto p = &i;
此时i
就会被自动识别为int
,d
就自动识别为double
,p
自动识别为int*
。
auto
的主要作用在于对于有一些类型,它名字太长了,就可以用auto
一笔带过。
比如完整地定义一个迭代器:
vector<int> v;
vector<int>::iterator it = v.begin();
用auto
直接自动识别:
vector<int> v;
auto it = v.begin();
在定义迭代器的时候,auto
的使用还是比较常见的。
此外,auto
推导类型的时候,会忽略const
,引用属性,例如:
int a = 1;
int& ra = a;
auto b = ra; // b 为 int
const int ca = 1;
auto c = ca; // c 为 int
decltype
在C++11以前,有一个关键字typeid
,其可以识别一个类型,并且可以通过name
成员函数来输出类型名。
比如这样:
int i = 0;
int* pi = &i;
cout << typeid(i).name() << endl;
cout << typeid(pi).name() << endl;
输出结果为:
int
int * __ptr64
也就是说,我们可以通过typeid
来检测甚至输出变量类型。
而decltype
也是用于识别类型的,但是decltype
与typeid
应用方向不同。
decltype
可以检测一个变量的类型,并且拿这个类型去声明新的类型
比如这样:
int i = 0;
decltype(i) x = 5;
decltype(i)
检测出i
的类型为int
,于是decltype(i)
整体就变成int
,从而定义出一个新的变量x
。
相比于auto
,decltype
保留的类型更加精确,可以保留引用与const
,也可以识别出一个类型,作为模板参数传递给模板。
nullptr
在C++11后,推出了新的空指针nullptr
,明明已经有NULL
了,为啥还需要nullptr
?
NULL
在C语言中,表示的是((void*)0)
,也就是被强制转为void*
类型的0。但是在C++中,NULL
就是整数0
比如可以用刚才学的typeid
验证一下:
cout << typeid(NULL).name() << endl;
输出结果为:int
,这下就石锤了NULL
在C++中就是int
。
这会导致不少问题,比如这样:
void func(int x)
{
cout << "参数为整型" << endl;
}
void func(void* x)
{
cout << "参数为指针" << endl;
}
int main()
{
func(NULL);
return 0;
}
以上代码中,func
函数有两个重载,一个是参数为指针,一个是参数为整型。我现在就是想传一个空指针去调用指针版本的func
。但是最后还是会调用int
类型的。
而nullptr
不一样,nullptr
不仅不是整型,而且其也不是void*
。C++给了nullptr
一个专属类型nullptr_t
。这个类型有一个非常非常大的优势,该类型只能转化为其它指针类型,不能转化为指针以外的类型。
比如以下代码:
int x1 = NULL;//正确
int x2 = nullptr;//错误
因为NULL
本质是0,其可以转化为很多非指针类型,比如int
,double
,char
。但是nullptr
是nullptr_t
,它只能转化为其他指针。上述代码中,我们把nullptr
转化为一个int
,此时编译器会直接报错,绝对禁止这个行为。
但是这样是可以的:
void* p1 = nullptr;
int* p2 = nullptr;
char* p3 = nullptr;
double* p4 = nullptr;
可以看到,nullptr
保证了指针类型的稳定,空指针不会被传递到指针以外的类型。因此nullptr
在各方面都有足够的优势,以更加安全的形式给用户提供空指针。