C++构造函数的理解
相对于C语言来说,C++有一个比较好的特性就是构造函数,即类通过一个或者几个特殊的成员函数来控制其对象的初始化过程。构造函数的任务,就是初始化对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的语法
构造函数的名字必须和类名相同,与其他函数不一样的是,构造函数没有返回值,而且其必须是公有成员,因为私有成员不允许外部访问,且函数不能声明为const类型,构造函数的语法是这样的:
class Test
{
public:
Test(){std::cout<<"Hello world!"<<std::endl;}
};
Test object;
int main(){return 1;}
在main函数执行之前,object被定义时就会调用Test函数,输出"Hello world!"。
这里只是示范了一个最简单的构造函数的形式,其实构造函数是个比较复杂的部分,有非常多神奇的特性。
构造函数的种类
默认构造函数
当我们程序中并没有显式的定义构造函数时,系统会提供一个默认的构造函数,这种编译器创建的构造函数又被称为合成的默认构造函数,合成构造函数的初始化规则是这样的:
- 如果存在类内的初始值,用它来初始化成员。在C11的新特性中,C11支持为类内的数据成员提供一个初始值,创建对象时,类内初始值将用于初始化数据成员。如果在构造函数中又显式地初始化了数据成员,则使用显式初始化的值。
- 否则,默认初始化该成员。默认初始化意味着和C语言一样的初始化方式,当类对象为全局变量时,在系统加载时初始化为0,而作为局部变量时,由于数据在栈上分配,成员变量值不确定。
需要注意的是,只有当用户没有显式地定义构造函数时,编译器才会为其定义默认构造函数。
在某些情况下,默认构造函数是不合适的:
如上所说,内部定义的类调用默认构造函数会导致成员函数的值是未定义的。
如果类中包含其他类类型的数据成员或者继承自其他类,且这个类没有默认构造函数,那么编译器将无法初始化该成员。上面提到了可以在类内给成员一个初始值,但是这只对于普通变量,并不支持类的构造。 当我们除了自定义的其他构造函数,还需要一个默认构造函数时,我们可以这样定义:
Test() = default; 这个构造函数不接受任何参数,等于默认构造函数。
初始化列表的构造方式
首先,我们先需要分清初始化和赋值的概念,初始化就是在新创建对象的时候给予初值,而赋值是在两个已经存在的对象之间进行操作。在构造方式上,这两种是不同的。
构造函数支持初始化列表,它负责为新创建的对象的一个或者几个数据成员赋初值,初始化列表的语法是这样的:
class Test
{
public:
Test(int a):x(a),y(2){}
int x;
int y;
};
初始化的列表的一个优势是时间效率和空间效率比赋值要高,同时在const类型成员的构造时,普通的赋值构造函数是非法的。当我们创建一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其常量属性。
所以我们可以用这种方式为const成员变量写值。
拷贝构造函数
拷贝构造函数的一般形式是这样的:
class Test
{
public:
Test(const Test &ob){
x = ob.x;
y = ob.y;
}
private:
int x;
int y;
};
可以很清楚地看出来,构造过程就是将另一个同类对象的成员变量一一赋值,const修饰是因为限定传入对象的只读属性。看到上面的示例,不知道有没有朋友有所疑问:
为什么在构造函数中,用户可以访问到外部同类对象ob的私有变量,不是说私有变量只能通过类的公共函数(一般是get()方法)来访问吗,为什么这里可以直接使用ob.x,ob.y ??
如果你有这样的问题,首先不得不承认你是个善于观察且有一定基础的学者,但是对封装的概念并不是很清楚。
其实不仅仅构造函数可以访问同类对象的私有变量,普通成员函数也可以访问:
class Test
{
public:
Test(){};
void func(const Test& ob){
std::cout<<ob.x<<std::endl;
}
private:
int x=2;
};
这样的写法不会报错且能够正常运行,但是如果func()的函数是这样的:
void func(const AnotherClass& ob){
std::cout<<ob.x<<std::endl;
}
那我们还能不能访问ob的私有变量呢?答案肯定是不行的,这不用说。那我们回到上面的问题,为什么可以访问同类对象的私有变量?
其实答案并不难理解,类的封装性是针对类而不是针对类对象。
通俗地来说,我们定义类中成员访问权限的初衷是为了保护私有成员不被外部其他对象访问到,一般情况下私有成员被外部访问的方式就是通过公共的函数接口(public),而在类的内部,任何成员函数都能访问私有成员,这种保护是针对不同的类之间的,所以我们是在定义类的时候来指定访问权限,而不是在定义对象的时候再指定访问权限。
再者,相同类对象,对于所有的私有变量,彼此知根知底,也就没有什么保护的必要。
既然是这样,类内的构造函数以及其它函数都是类的成员函数,自然可以访问所有数据。
赋值运算符重载
同时,类的构造可以用重载赋值运算符来实现,即"="。
class Test
{
public:
Test& operator=(const Test &ob){
x = ob.x;
y = ob.y;
return this;
}
private:
int x;
int y;
};
在定义类的时候,我们可以这样:
Test ob1;
Test ob2 = ob1;
默认拷贝构造函数的陷阱
当我们没有指定拷贝构造函数或者没有重载赋值运算符时,系统会生成默认的对应构造函数,分别为合成拷贝构造函数和合成拷贝赋值运算符。即使用户没有在类中定义相对应拷贝赋值操作,我们照样可以使用它:
Test ob1;
Test ob2(ob1);
Test ob3 = ob2;
编译器生成的默认拷贝赋值构造函数会将对应的成员一一赋值,是不是非常方便?
既然编译器生成的默认拷贝赋值构造函数就能完成任务,为什么我们还要自己去定义构造函数呢?这是不是多此一举?
非也!!!
如果类型成员全部都是普通变量是没有问题的,但是如果涉及到指针,简简单单地复制指针也是没有问题的,最要命的是如果指针指向的动态内存,这样就会有两个不同类的成员指向同一片动态内存,而析构函数在释放内存时,必然造成double free,我们可以看下面的例子:
class Test
{
public:
Test(){p = new int(4);}
~Test(){delete p;}
int *p;
};
Test ob1;
Test ob2 = ob1;
int main(){}
然后编译运行:
g++ -std=c++11 test.cpp -o test
./test
这段程序不做任何事,仅仅是通过编译器生成的合成拷贝赋值运算符,运行结果:
*** Error in `./a.out': double free or corruption (fasttop): 0x085dca10 *** Aborted (core dumped)
很明显,和上面所提到的一样,动态内存的double free导致程序终止。为了观众朋友们能更清晰地理解这个过程,我们再来对程序做一个step by step解析:
- 构造类对象ob1,这是调用了构造函数,为ob1.p分配了内存空间。
- 用合成拷贝赋值构造函数构造类对象ob2 = ob1,相当于执行了语句:ob2.p = ob1.p;
- main()函数执行完毕,全局函数的运行周期结束,系统回收内存,先调用ob1的析构函数,将ob1.p指向的内存释放。
- 调用ob2的析构函数,将ob2.p指向的内存释放,但是由于ob2.p的内存已经在上一步被释放,所以造成了double free。
事实上,这种现象在C++中有两个专用名词来描述:"浅拷贝"和"深拷贝"。
所以,在使用编译器默认的合成构造函数时,我们要非常小心这一类的陷阱,即使是目前没有指针成员函数,也要自己写拷贝赋值构造函数,这样有利于代码的扩展和维护。
但是,话说回来,如果我每次实现一个很简单的需求,都要定义复制拷贝构造函数,一个一个成员去赋值,这样也是很烦人的,在新标准下,C++提供了一种方法来"解决"这个问题。
阻止拷贝
用户可以禁止使用拷贝函数,只要作这样声明:
Test(Test &ob) = delete;
Test &operator(Test &ob) = delete;
事实上,部分编译器默认禁止合成的拷贝赋值构造函数。
这样,在使用者想使用默认的拷贝赋值构造函数时,编译器将无情地报错。
移动构造函数
在说到移动构造函数之前,我们得先介绍一下新标准下一种新的引用类型——右值引用。右值引用就是必须绑定到右值的引用,左值的引用用&,而右值的引用则用&&。右值引用有一个重要的性质,即只能绑定到一个将要销毁的对象。
通俗地说,右值通常为临时变量,字面值,未接受的返回值等等,它们没有固定地址。
而左值通常是变量。总而言之,左值持久,右值短暂。
下面是引用和右值引用的示例:
int x = 30;
int &r = x; //正确,左值引用
int &&r = x; //错误,x为左值,&&r为右值引用
int &&r = 3; //正确,右值引用
const int &r = 3; //正确,const左值可以对右值引用
由于右值引用只能绑定到临时对象,我们可以知道它的特点:
- 所引用的对象将要被销毁
- 该对象没有其他用户 这两个特性则意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。可想而知,右值引用的特点是"窃取"而不是"生成",在效率上自然就有所提高。
如果现在有一个左值,我们想将它作为右值来处理,应该怎么办呢?答案是std::move()函数,语法是这样的:
int x = 30;
int &&r = std::move(x);
但是正如右值的特性而言,将左值转换成右值的时候,你得确保这个左值将不再使用,建议使用std::move(),因为这样的函数名总是容易出现命名冲突。
让我们再回到移动构造函数,各位朋友们应该从前面的铺垫已经猜到了这是个什么样的实现,是的,它的特点就是接受一个右值作为参数来进行构造。实现是这样的:
class Test
{
public:
Test(){p = new int(10);}
~Test(){delete p;}
Test(Test &&ob) noexcept{
p = ob.p;
ob.p = nullptr;
}
int *p;
};
可能朋友们看了上面的实现会有两个疑问:
- 为什么函数要加上noexcept声明?
- 为什么要加上 ob.p=nullptr 这个操作? 刚刚我们提到了拷贝赋值构造函数的浅拷贝问题(即指针部分仅仅是复制),很显然,那样是不行的。但是在移动构造函数中,我们依然是浅拷贝,为什么这样又可以?
从上面的示例可以看出移动构造函数的参数是一个右值引用,我们上面有提到,移动构造函数的特点是"窃取"而不是生成。就相当于将目标对象的内容"偷过来",既然目标对象的内存本来就是存在的,所以不会因为失败问题而抛出异常。当我们编写一个不抛出异常的移动操作时,有必要通知标准库,这样它就不会为了可能的异常处理而做一些额外工作,这样可以提升效率。
再者,我们将右值对象的内容偷过来,但是右值对象依然是存在的,它依旧会调用析构函数,如果我们不将右值的动态内存指针赋值为null,右值对象调用析构函数时将释放掉这部分我们好不容易偷过来的内存。就像上面的例子所示,我们不得不将ob.p指针置为空。
口说无凭,我们来看下面的示例:
class Test
{
public:
Test(void){p=new int(50);
}
Test(Test &&ob) noexcept{
p = ob.p;
//ob.p = nullptr;
}
~Test(){delete p;}
int *p;
};
Test ob1;
int main()
{
Test ob2 (std::move(ob1));
}
在示例中,我们将ob.p = nullptr;这条语句注释,然后使用无参构造函数构造ob1,然后将ob1转为右值来构造ob2.我们来看运行结果:
*** Error in `./a.out': double free or corruption (fasttop): 0x09f12a10 ***
Aborted (core dumped)
果然如我所料,出现了double free的错误,这是因为在移动构造函数中传入的右值对象ob在使用完后调用了析构函数释放了p,而对象ob2偷到的仅仅是一个指针的值,指针指向的内容已经被释放了,所以在程序执行完成之后再调用析构函数时就会出现double free的错误。
为了再验证一个问题,我们将上面的例子中加上ob.p = nullptr;,并将main()函数改成这样:
class Test
{
public:
Test(void){p=new int(50);
}
Test(Test &&ob) noexcept{
p = ob.p;
ob.p = nullptr;
}
~Test(){delete p;}
int *p;
};
Test ob1;
int main()
{
Test ob2 (std::move(ob1));
cout<<*ob1.p<<endl;
}
我们来看看已经被转换成右值的ob1个什么情况,运行结果是这样的:
Segmentation fault (core dumped)
好吧,其实这是显而易见的,ob1.p已经在移动构造函数中被置为nullptr了。
为什么C++11要添加这个新的特性呢?从效率上出发,在程序运行的时候,由于中间过程会出现各种各样的临时变量,每创建一个临时变量,就会多一次对资源的构造和析构的消耗,如果我们能将临时变量的资源接管过来,就可以省下相应的构造和析构所带来的消耗。
隐式转换构造函数
C++中,当类有一个构造函数接收一个实参,它实际上定义了转换为此类类型的隐式转换机制,又是我们把这种构造函数称为转换构造函数。
官方解释总是像数学公式一样难以理解,通俗地说,当一个类A有其中一个构造函数接受一个实参(类型B)时,在使用时我们可以直接使用那个构造函数参数类型B来临时构造一个类A的对象,好像我也没解释清楚?好吧,直接上代码看:
class Test{
public:
Test(string s,int para = 1){
str = s;
}
void add(Test ob){
str += ob.str;
}
string str;
};
Test ob1("downey");
int main()
{
ob1.add(string("downey!"));
cout<<ob1.str<<endl;
}
运行结果:
downeydowney!
如码所示,Test类有一个构造函数,可以接收一个string类的实参(可以由一个实参构造并不代表只能有一个形参),而add()方法接受一个Test类类型参数,在调用add()方法时,我们直接传入一个string类型,触发隐式转换功能,编译器将自动以string作为实参构造一个Test的临时类对象来传入add()方法,程序结束之后将释放临时变量。
需要注意的是,隐式转换只支持一次转换,如果我们将main()函数改成这样:
int main()
{
ob1.add("downey!");
cout<<ob1.str<<endl;
}
编译器需要将"downey"转换成string类型,然后再进行一次转换,这样是不支持的。在编译阶段就会报错:
error: no matching function for call to XXX
同时,如果我们在声明add()函数时习惯性地使用了左值引用:
void add(Test &ob){ //使用引用,&
str += ob.str;
}
这样又是什么结果呢?
答案是,编译出错。这又是为什么?如果你有仔细看上面的隐式转换过程就可以知道,在使用隐式转换时生成了一个临时变量(类型同函数形参),而临时变量是右值,是不能使用左值引用的。报错信息如下:
error: no matching function for call to XXX //左值引用不匹配,所以这里找不到匹配的方法。
阻止隐式转换
使用explicit关键字修饰函数可以阻止构造函数的隐式转换,而且explicit只支持直接初始化时使用,也就是在类内使用,同时,只对一个实参的构造函数有效。在STL中我们随时可以看到explicit的影子。
下面是示例:
class Test{
public:
explicit Test(string s,int para = 1){
str = s;
}
void add(Test ob){
str += ob.str;
}
string str;
};
Test ob1("downey");
int main()
{
ob1.add(string("downey!")); //报错,no matching function for call to XXX,因为这里不支持隐式转换
cout<<ob1.str<<endl;
}
同时,如果用户试图在类外声明时使用explicit关键字,将会报错:
error: only declarations of constructors can be ‘explicit’
结语
C++真是魔鬼!!!
好了,关于C++构造函数的讨论就到此为止啦,如果朋友们对于这个有什么疑问或者发现有文章中有什么错误,欢迎留言
原创博客,转载请注明出处!
祝各位早日实现项目丛中过,bug不沾身.