c++令人头晕的构造函数 和 类的初始化
1,混合
c语言的结构体初始化是:
Mystruct {
int a;
int b;
int c;
}
Mystruct mystruct={1,2,3};
而c++对象初始化:MyClass myclass(1,2,3);
最终取c的“{”和c++的变量加括号的方法,最终杂交成正果: MyClass myclass{1,2,3};
2 进一步推广:
int i{5}; 这才是正宗啊!!!
3 注意 initializer_list<int> 自动推导
赋值列表初始化:MyClass a={arg1,2,3}
直接列表初始化:MyClass a{1, arg2, 3}
这是对一个类的构造函数调用,里面是参数。
3.1 当用上自动推导时
在c++ 17里:
auto a={11};
auto b={11,2};
赋值列表初始化结果为 initializer_list<int>。
而直接列表初始化是构造函数:
auto c{11}; //c被推导成 int
auto b{11,2};//error 这个是构造函数,不能这么调用,报错为:在直接列表初始化上下文中,类型“auto”只能从单个初始值设定项表达式推导出
而在c++14里,上面两种列表初始化是没有区别的,都是 赋值列表初始化的结果。
c++ 不zuo会死啊
统一初始化:
c++11以前:
结构体: Mystruct mystruct={1,2,3};
类: MyClass myclass(1,2,3);
C++11以后统一为:
结构体: Mystruct mystruct={1,2,3};
类: MyClass myclass={1,2,3};//模仿结构体的初始化
等号也可省略:
结构体: Mystruct mystruct{1,2,3};
类: MyClass myclass{1,2,3};
且不局限在类:
int a=3;
int b(3);
int c={3};
int d{3};
int e{} 赋初始值,可以是0,nullptr,0.0等,看类型而定。
阻止narrowing:
int x =3.14; ok
int x={3.14} 报错
初始化数组
int* parray=new int[4]{0,1,2,3};
构造函数中:
class myConstructor: mArray{0,1,2,3} {
}
不能像java那样,对于无参构造
AClass a();
C++应该这样: AClass a;// 因为上面那样在c++看来是个函数申明。
参考:C++转换构造函数:将其它类型转换为当前类的类型 (biancheng.net)
C++类型转换函数:将当前类的类型转换为其它类型 (biancheng.net)
类型转换函数的语法格式为:
operator type(){
//TODO:
return data;
}
operator 是 C++ 关键字,type 是要转换的目标类型,data 是要返回的 type 类型的数据。肯定返回类型和要转换的一致,所以返回值省略了。
#include <iostream> #include <cstdlib> using namespace std; class Complex{ public: Complex(double real = 0.0, double imag = 0.0): m_real(real), m_imag(imag){ } public: operator double() const { return m_real; } //类型转换函数,或者 类型转换运算符 private: double m_real; double m_imag; }; int main(){ //下面是正确的用法 int m = 100; Complex c(12.5, 23.8); long n = static_cast<long>(m); //宽转换,没有信息丢失 char ch = static_cast<char>(m); //窄转换,可能会丢失信息 int *p1 = static_cast<int*>( malloc(10 * sizeof(int)) ); //将void指针转换为具体类型指针 void *p2 = static_cast<void*>(p1); //将具体类型指针,转换为void指针 double real= static_cast<double>(c); //调用类型转换函数 //下面的用法是错误的 float *p3 = static_cast<float*>(p1); //不能在两个具体类型的指针之间进行转换 p3 = static_cast<float*>(0X2DF9); //不能将整数转换为指针类型 return 0; }
转换
int main(){ Complex a(10.0, 20.0); a = (Complex)25.5; //错误,转换失败 return 0; } 添加构造函数 Complex(double real): m_real(real), m_imag(0.0){ } //转换构造函数 a = (Complex)25.5; 相当于:a.Complex(25.5);
各种构造,体现了构造函数的本意——在创建对象时初始化对象。
Complex c1(26.4); //创建具名对象 Complex c2 = 240.3; //以拷贝的方式初始化对象: 将 240.3 转换为 Complex 类型(创建一个 Complex 类的匿名对象),然后再拷贝给 c2。 Complex(15.9); //创建匿名对象 c1 = Complex(46.9); //创建一个匿名对象并将它赋值给 c1
Complex c1(); //调用Complex() Complex c2(10, 20); //调用Complex(double real, double imag) Complex c3(c2); //调用Complex(const Complex &c) Complex c4(25.7); //调用Complex(double real)
在 C/C++ 中,不同的数据类型之间可以相互转换。无需用户指明如何转换的称为自动类型转换(隐式类型转换),需要用户显式地指明如何转换的称为强制类型转换。
自动类型转换示例:
- int a = 6;
- a = 7.5 + a;
编译器对 7.5 是作为 double 类型处理的,在求解表达式时,先将 a 转换为 double 类型,然后与 7.5 相加,得到和为 13.5。在向整型变量 a 赋值时,将 13.5 转换为整数 13,然后赋给 a。整个过程中,我们并没有告诉编译器如何去做,编译器使用内置的规则完成数据类型的转换。
强制类型转换示例:
- int n = 100;
- int *p1 = &n;
- float *p2 = (float*)p1;
p1 是int *
类型,它指向的内存里面保存的是整数,p2 是float *
类型,将 p1 赋值给 p2 后,p2 也指向了这块内存,并把这块内存中的数据作为小数处理。我们知道,整数和小数的存储格式大相径庭,将整数作为小数处理非常荒诞,可能会引发莫名其妙的错误,所以编译器默认不允许将 p1 赋值给 p2。但是,使用强制类型转换后,编译器就认为我们知道这种风险的存在,并进行了适当的权衡,所以最终还是允许了这种行为。
关于整数和小数在内存中的存储格式,请猛击《整数在内存中是如何存储的》《小数在内存中是如何存储的》。
不管是自动类型转换还是强制类型转换,前提必须是编译器知道如何转换,例如,将小数转换为整数会抹掉小数点后面的数字,将int *
转换为float *
只是简单地复制指针的值,这些规则都是编译器内置的,我们并没有告诉编译器。
换句话说,如果编译器不知道转换规则就不能转换,使用强制类型也无用,请看下面的例子:
- #include <iostream>
- using namespace std;
- //复数类
- class Complex{
- public:
- Complex(): m_real(0.0), m_imag(0.0){ }
- Complex(double real, double imag): m_real(real), m_imag(imag){ }
- public:
- friend ostream & operator<<(ostream &out, Complex &c); //友元函数
- private:
- double m_real; //实部
- double m_imag; //虚部
- };
- //重载>>运算符
- ostream & operator<<(ostream &out, Complex &c){
- out << c.m_real <<" + "<< c.m_imag <<"i";;
- return out;
- }
- int main(){
- Complex a(10.0, 20.0);
- a = (Complex)25.5; //错误,转换失败
- return 0;
- }
25.5 是实数,a 是复数,将 25.5 赋值给 a 后,我们期望 a 的实部变为 25.5,而虚部为 0。但是,编译器并不知道这个转换规则,这超出了编译器的处理能力,所以转换失败,即使加上强制类型转换也无用。
幸运的是,C++ 允许我们自定义类型转换规则,用户可以将其它类型转换为当前类类型,也可以将当前类类型转换为其它类型。这种自定义的类型转换规则只能以类的成员函数的形式出现,换句话说,这种转换规则只适用于类。
本节我们先讲解如何将其它类型转换为当前类类型,下节再讲解如何将当前类类型转换为其它类型。
转换构造函数
将其它类型转换为当前类类型需要借助转换构造函数(Conversion constructor)。转换构造函数也是一种构造函数,它遵循构造函数的一般规则。转换构造函数只有一个参数。
仍然以 Complex 类为例,我们为它添加转换构造函数:
- #include <iostream>
- using namespace std;
- //复数类
- class Complex{
- public:
- Complex(): m_real(0.0), m_imag(0.0){ }
- Complex(double real, double imag): m_real(real), m_imag(imag){ }
- Complex(double real): m_real(real), m_imag(0.0){ } //转换构造函数
- public:
- friend ostream & operator<<(ostream &out, Complex &c); //友元函数
- private:
- double m_real; //实部
- double m_imag; //虚部
- };
- //重载>>运算符
- ostream & operator<<(ostream &out, Complex &c){
- out << c.m_real <<" + "<< c.m_imag <<"i";;
- return out;
- }
- int main(){
- Complex a(10.0, 20.0);
- cout<<a<<endl;
- a = 25.5; //调用转换构造函数
- cout<<a<<endl;
- return 0;
- }
运行结果:
10 + 20i
25.5 + 0iComplex(double real);
就是转换构造函数,它的作用是将 double 类型的参数 real 转换成 Complex 类的对象,并将 real 作为复数的实部,将 0 作为复数的虚部。这样一来,a = 25.5;
整体上的效果相当于:
a.Complex(25.5);
将赋值的过程转换成了函数调用的过程。
在进行数学运算、赋值、拷贝等操作时,如果遇到类型不兼容、需要将 double 类型转换为 Complex 类型时,编译器会检索当前的类是否定义了转换构造函数,如果没有定义的话就转换失败,如果定义了的话就调用转换构造函数。
转换构造函数也是构造函数的一种,它除了可以用来将其它类型转换为当前类类型,还可以用来初始化对象,这是构造函数本来的意义。下面创建对象的方式是正确的:
- Complex c1(26.4); //创建具名对象
- Complex c2 = 240.3; //以拷贝的方式初始化对象
- Complex(15.9); //创建匿名对象
- c1 = Complex(46.9); //创建一个匿名对象并将它赋值给 c1
在以拷贝的方式初始化对象时,编译器先调用转换构造函数,将 240.3 转换为 Complex 类型(创建一个 Complex 类的匿名对象),然后再拷贝给 c2。
如果已经对+
运算符进行了重载,使之能进行两个 Complex 类对象的相加,那么下面的语句也是正确的:
- Complex c1(15.6, 89.9);
- Complex c2;
- c2 = c1 + 29.6;
- cout<<c2<<endl;
在进行加法运算符时,编译器先将 29.6 转换为 Complex 类型(创建一个 Complex 类的匿名对象)再相加。
需要注意的是,为了获得目标类型,编译器会“不择手段”,会综合使用内置的转换规则和用户自定义的转换规则,并且会进行多级类型转换,例如:
- 编译器会根据内置规则先将 int 转换为 double,再根据用户自定义规则将 double 转换为 Complex(int --> double --> Complex);
- 编译器会根据内置规则先将 char 转换为 int,再将 int 转换为 double,最后根据用户自定义规则将 double 转换为 Complex(char --> int --> double --> Complex)。
从本例看,只要一个类型能转换为 double 类型,就能转换为 Complex 类型。请看下面的例子:
- int main(){
- Complex c1 = 100; //int --> double --> Complex
- cout<<c1<<endl;
- c1 = 'A'; //char --> int --> double --> Complex
- cout<<c1<<endl;
- c1 = true; //bool --> int --> double --> Complex
- cout<<c1<<endl;
- Complex c2(25.8, 0.7);
- //假设已经重载了+运算符
- c1 = c2 + 'H' + true + 15; //将char、bool、int都转换为Complex类型再运算
- cout<<c1<<endl;
- return 0;
- }
运行结果:
100 + 0i
65 + 0i
1 + 0i
113.8 + 0.7i
再谈构造函数
构造函数的本意是在创建对象的时候初始化对象,编译器会根据传递的实参来匹配不同的(重载的)构造函数。回顾一下以前的章节,到目前为止我们已经学习了以下几种构造函数。
1) 默认构造函数。就是编译器自动生成的构造函数。以 Complex 类为例,它的原型为:
Complex(); //没有参数
2) 普通构造函数。就是用户自定义的构造函数。以 Complex 类为例,它的原型为:
Complex(double real, double imag); //两个参数
3) 拷贝构造函数。在以拷贝的方式初始化对象时调用。以 Complex 类为例,它的原型为:
Complex(const Complex &c);
4) 转换构造函数。将其它类型转换为当前类类型时调用。以 Complex 为例,它的原型为:
Complex(double real);
不管哪一种构造函数,都能够用来初始化对象,这是构造函数的本意。假设 Complex 类定义了以上所有的构造函数,那么下面创建对象的方式都是正确的:
- Complex c1(); //调用Complex()
- Complex c2(10, 20); //调用Complex(double real, double imag)
- Complex c3(c2); //调用Complex(const Complex &c)
- Complex c4(25.7); //调用Complex(double real)
这些代码都体现了构造函数的本意——在创建对象时初始化对象。
除了在创建对象时初始化对象,其他情况下也会调用构造函数,例如,以拷贝的的方式初始化对象时会调用拷贝构造函数,将其它类型转换为当前类类型时会调用转换构造函数。这些在其他情况下调用的构造函数,就成了特殊的构造函数了。特殊的构造函数并不一定能体现出构造函数的本意。
对 Complex 类的进一步精简
上面的 Complex 类中我们定义了三个构造函数,其中包括两个普通的构造函数和一个转换构造函数。其实,借助函数的默认参数,我们可以将这三个构造函数简化为一个,请看下面的代码:
- #include <iostream>
- using namespace std;
- //复数类
- class Complex{
- public:
- Complex(double real = 0.0, double imag = 0.0): m_real(real), m_imag(imag){ }
- public:
- friend ostream & operator<<(ostream &out, Complex &c); //友元函数
- private:
- double m_real; //实部
- double m_imag; //虚部
- };
- //重载>>运算符
- ostream & operator<<(ostream &out, Complex &c){
- out << c.m_real <<" + "<< c.m_imag <<"i";;
- return out;
- }
- int main(){
- Complex a(10.0, 20.0); //向构造函数传递 2 个实参,不使用默认参数
- Complex b(89.5); //向构造函数传递 1 个实参,使用 1 个默认参数
- Complex c; //不向构造函数传递实参,使用全部默认参数
- a = 25.5; //调用转换构造函数(向构造函数传递 1 个实参,使用 1 个默认参数)
- return 0;
- }
精简后的构造函数包含了两个默认参数,在调用它时可以省略部分或者全部实参,也就是可以向它传递 0 个、1 个、2 个实参。转换构造函数就是包含了一个参数的构造函数,恰好能够和其他两个普通的构造函数“融合”在一起。
在已分配的内存上,new一个对象放上。一般用于内存池类似地方。
void* p=很大一块内存 malloc( type* sizeof(type))
type obj = new (p) type();
在vector的源码中用到:
转自: Uniform Initialization and initializer_list (qq.com)
C++初始化数据的方式有许多种,例如:
1void func()
2{
3 int a = 1;
4 int b{1};
5 int c(1);
6 int d = int(1);
7}
我们可以通过assignment初始化,也能通过小括号初始化,还能通过花括号初始化。
这对于新手来说并不友好,常常会使人迷惑到底该怎样初始化一个变量或对象。
因此,C++11引进了一个概念,称为Uniform Initialization(一致性初始化)。对于任何变量或对象,你都可以使用一个共有的语法,这个语法就是使用{}初始化。
所以现在的初始化都可以这样写:
1std::vector<int> vec1 { 1, 2, 3, 4, 5, 6 };
2std::vector<std::string> vec2 { "Have", "a", "nice", "day", "!"};
3
4print_vector(vec1); // output: 1 2 3 4 5 6
5print_vector(vec2); // output: Have a nice day !
而这个花括号,也就是{ }所组成的值的类型,就是initializer_list。
编译器在遇到{ 1, 2, 3, 4, 5, 6 }时,便会生成一个initializer_list,若函数提供了initializer_list类型的参数,那么所生成的initializer_list便会直接传入该函数;若函数未提供该类型的参数,那么编译器会将元素逐一分解,传给函数。
比如遇到 { "Have", "a", "nice", "day", "!" },编译器就会创建一个initializer_list,而在initializer_list的内部,其实用的是array,所以实际类型就是array<string, 5><string,>,由于vector提供有initializer_list类型的构造函数,因此可以直接初始化。
然而initializer_list拥有高优先级,这往往会掩盖一些重载决议,而这些重载决议又极其容易让程序员误用。
vector就存在着这样的一个小bug,比如看下面这句代码:
1std::vector<int> vec3 { 1, 2 };
这的确算是一个bug,也许你在工作中已经踩过几次这个坑了。
若你还未看懂问题之所在,那么你可能不知道写这句代码的本意是要干嘛。
对比下面这句代码:
1std::vector<int> vec4(1, 2);
现在,你应该清楚vec3的问题了。
vec3的本意是要放1个2,就如同vec4那样。而由于initializer_list的高优先级,vec3实际放了1和2两个数。
就如同nullptr和0要区分一样,对于这样的模糊语义,编译器至少应该明确地给出重载模糊警告,以防止误用。
要想正确使用initializer_list,就得明确这些细节,因此,我们定义一个Point类来观察其行为:
1template <typename T>
2struct Point
3{
4 Point(T x, T y = T())
5 {
6 std::cout << "Point(T x, T y)\n" << x << ' ' << y << std::endl;
7 }
8
9 Point(std::initializer_list<T> init_list)
10 {
11 std::cout << "Point(std::initializer_list<T>)\n";
12 for (auto elem : init_list)
13 std::cout << elem << ' ';
14 std::cout << std::endl;
15 }
16
17 Point(T x, T y, T z) {
18 std::cout << "Point(T x, T y, T z)\n" << x << ' ' << y << ' ' << z << std::endl;
19 }
20};
Point有三个构造函数,第一个支持单类型实参,可以接受一个或两个参数;第二个支持initializer_list,可以接受任意个参数;第三个支持三个参数。
现在,构造实验数据,
1Point<int> p1{ 1 };
2Point<int> p2{ 1, 2 };
3Point<int> p3{ 1, 2, 3 };
4Point<int> p4{ 1, 2, 3, 4 };
5Point<int> p5 = { 1 };
6Point<int> p6 = { 1, 2 };
7Point<int> p7 = { 1, 2, 3 };
8Point<int> p8 = { 1, 2, 3, 4 };
9Point<int> p9(1);
10Point<int> p10(1, 2);
11Point<int> p11(1, 2, 3);
12//! Point<int> p12(1, 2, 3, 4);
除了p12,其它都有相匹配的构造函数。
那么它们最终会匹配到哪个版本呢?
输出如下:
使用{}初始化的对象,全部都会调用initializer_list参数的函数。
而单参,两参,三参的构造函数我们都专门提供的有,按理说就应该调用专门提供的版本,而在这种语义模糊的情境下,并没有任何的提示。因此,当使用initializer_list作为函数参数时,需要留心这个特性。
此外,initializer_list应对的也是变化问题,普通类型的函数只能支持固定个数的参数,而其可接受任意个数的参数。问题一旦是动态的,便不容易解决,这和Variadic Templates(可变参模板)有相似之处,只不过前者只支持相同类型的参数,而后者还支持不同类型的参数。
列表初始化
由于旧标准初始化方式太过繁杂,限制偏多,因此在新标准中统一了初始化方式,为了让初始化具有确定的效果,于是提出了列表初始化概念。
旧标准初始化方式
普通数组初始化:
int i_arr[3] = {1, 2, 3}
POD类型初始化(即plain old data类型,可以直接使用memcpy复制的对象):
struct A
{
int x;
struct B
{
int i;
int j;
} b;
} a = {1, {2, 3}};
拷贝初始化:
int i = 0;
class Foo
{
public:
Foo(int) {}
} foo = 123
直接初始化:
int j(0)
Foo bar(123)
C++11标准初始化方式
C++11标准中{}的初始化方式是对聚合类型的初始化,是以拷贝的形式来赋值的。
C++11标准中对非聚合类型则以构造函数来进行初始化的。
聚合类型:
- 类型是一个普通的数组
- 类型是一个类,并且满足:
- 无用户自定义的构造函数
- 无私有或保护的非静态数据成员
- 无基类
- 无虚函数
- 不能有 { } 和 = 直接初始化的非静态数据成员
初始化列表技术细节
观察下面这两个:
int arr[] {1, 2, 3}
std::set<int> ss = {1, 2, 3}
之所以可以实现STL中不指定个数进行初始化,依赖的就是与i个轻量级的类模板,也是C++11中的新特性std::initializer_list
initializer_list使用
class FooVector
{
std::vector<int> content_;
public:
FooVector(std::initializer_list<int> list){ //重要技术点
for(auto it = list.begin(); it != list.end(); ++it){
content_.back(*it)
}
}
}
FooVector foo_1 = {1, 2, 3, 4, 5} //不仅可以这样
FooVector foo_2({1, 2, 3, 4, 5}) //还可以传一个同种类型数据集合
initializer_list的特点:
- 它是一个轻量级的容器类型,内部定义了iterator等容器必需的概念
- 对于std::initializer_list来说,它可以接收任意长度的初始化列表,但要求元素类型必须是同种类型T(或者可转换为T)
- 它有3个成员接口:size () 、 begin() 、end()
- 它只能被整体初始化或赋值
注意:std::initializer_list 是非常高效的,因此内部并不负责保存初始化列表中的元素的拷贝,而是仅仅存储列表中元素的引用!因此不能用来返回临时变量!
避免类型收窄:
C++有隐式类型转换的特性,比如将一个浮点数赋值给一个整数,精度会丢失,小数点后会被直接截断。初始化列表可以帮助避免隐式类型转换。因为其不允许这种转换发生。
但是也会随着编译器的不同而不同:
float ff = 1.2
float ff = {1.2}
在gcc4.8下没有警告和错误,但Microsoft Visual C++2013中,收到编译错误。因为1.2默认是double类型,由double转换成float会发生隐式类型转换,但是并没有发生精度损失。
总结
C++11新增的初始化方式,为程序的编写带来了很多的便利,这也是新标准秉承的思想和改进的方向。