浅谈c++中的构造函数

来源:互联网 发布:网络春晚和春晚 编辑:程序博客网 时间:2024/05/18 13:45

下面所有的构造函数都将用Student这个类作为例子

class Student{private:    static int count;//不属于任何一个对象    std::string name;    char *gender;    int age;    int grade;public:    .........};int Student::count=0;

一:默认构造函数:
A default constructor is a constructor which can be called with no arguments (either defined with an empty parameter list, or with default arguments provided for every parameter). A type with a public default constructor is DefaultConstructible.
这是cppreference上对于默认构造函数的解释,大致意思是:一个默认构造函数就是一个可以在调用时不必传入参数的构造函数,无论在定义时它是没有参数还是已经提供了默认参数。
比如
Student() :name(“No Name”), age(0), grade(0) { gender = new char[10];++count; }
以及
Student(std::string n=”No Name”,int a=0,int g=0):name(n),age(a),grade(g){ gender = new char[10]; ++count;}

只要定义了这两者其中之一我们在定义一个Student类的对象的时候就可以不用传参而是直接定义就行了,就像下面这样:
Student stu; //那么这时stu对象中的数据成员都已经被初始化,且他们的值就是构造函数中的那样;
但是为什么刚才我说是定义这两者之中的一个就行了呢?
原因是c++规定一个类最多只能有一个默认构造函数,否则在构造对象的时候编译器会不知道具体调用哪一个默认构造函数。
那么这两个默认构造函数是完全相同的吗?但然不是!
可以看见,第一个默认构造函数不接受任何参数;而第二个默认构造函数能够接受三个参数,但是它本身已经为你提供了三个默认参数,如果你要向它传入自己的参数也可以,就像这样:Student stu(“Xiao Hua”,18,1);也可以这样传参 Student stu(“Xiao Hua”,18);那么name==”Xiao Hua”,age==18,grade==0;
但是有时候我们忘了定义默认构造函数时写出Student stu这样的代码编译器也不会报错,因为智能的c++编译器为我们合成了一个默认构造函数,这个默认构造函数就叫合成默认构造函数,这个合成默认构造函数做的事情就是把内置类型和复合类型(比如数组和指针)默认初始化。这里需要注意的是,有时候虽然我们没有定义自己的默认构造函数,但是编译器也不会为我们合成默认构造函数,这种情况一般出现在当我们自己定义了自己的构造函数,无论是拷贝构造函数还是移动构造函数。
还有,合成的默认构造函数只适用于非常简单的类,某些类并不能完全的依赖合成的默认构造函数,原因有二:
1.对于某些类,合成的默认构造函数可能执行错误的操作,正如刚才所说的,合成的默认构造函数在进行初始化时其行为是不确定的;
2.有时候编译器并不会为某些类合成默认构造函数。如某个类中包含了一个其他类类型的成员,那么编译器无法初始化该成员。

二:拷贝构造函数:
A copy constructor of class T is a non-template constructor whose first parameter is T&, const T&, volatile T&, or const volatile T&, and either there are no other parameters, or the rest of the parameters all have default values.
翻译一下:拷贝构造函数是一个非模板的构造函数,它的第一个参数是T&,const T&,volatile T&,或是const volatile&,并且没有其他的参数或者其他的参数都有默认的值。(注:这里的T是类的名字)
比如:
Student(Student & stu);//#1
Student(const Student & stu);//#2
Student(volatile Student & stu);//#3
Student(const volatile Student & stu);//#4
这四个都是拷贝构造函数,当然也能有其他形式的,比如Student(const Student & stu,int n=12);
那么,他们有什么区别呢?
首先来看看他们的共同点,有没有发现他们都带了引用符号。拷贝构造函数中必须有引用,否则就会陷入一个尴尬的境地:无限的调用自己。如果是Student(const Student stu);那么在拷贝构造的时候需要生成一个临时的stu,然后用这个stu来构造对象。但是这里生成stu的过程同样需要调用拷贝构造函数而非默认构造函数,如生成stu用的是默认构造函数的话,那么拷贝构造时传入的对象毫无意义。因此,拷贝构造函数中必须有&符号。幸运的是,现代编译器十分智能,如果你写了一个Student(const Student stu);这种拷贝构造函数,编译器是会报错的,至少我的vs2015会报错。
ok,再来看看这四个的区别:
//#1:只是单纯的引用了stu来构造对象,但是不能保证在构造的过程中自己不被修改
//#2:用了const限定符,表示你可以使用stu,但是你不能修改它
关于volatile我们以后再谈,这里记住合成的拷贝对volatile对象无效即可。

Student(Student & stu)    {        name = stu.name;        age = stu.age;        grade = stu.grade;        gender=new char[10];        strcpy(gender,stu.gender);        ++count;    }    Student(const Student & stu)    {        std::cout << "hhh" << std::endl;        name = stu.name;        age = stu.age;        grade = stu.grade;        gender=new char[10];        strcpy(gender,stu.gender);        //stu.age = 0;        ++count;    }

(不知道各位是否注意到这里直接访问了对象stu的私有成员,原因是:禁止访问类的私有成员是类的性质而非对象的性质,对于同一个类,他们的不同对象之间的私有成员是可以在成员函数和友元函数中访问的)

与默认构造函数不同的是,c++中允许一个类有多个拷贝构造函数,在调用的时候编译器会根据最相似原则来调用,举个例子:
比如我现在已经定义了一个对象stu1,现在想用这个stu1来构造另一个对象stu2:Student stu1;
Student stu2(stu1);那么编译器调用的是第一个拷贝构造函数;如果是const Student stu1;Student stu2(stu1);那么调用的是第二个拷贝构造函数。不信的话可以自己动手试试。
同样的,如果我们没有自定义一个拷贝构造函数,编译器会为我们提供一个拷贝构造函数,但这个拷贝构造函数执行的功能是一个字节一个字节的把内容拷贝过去,除此之外不执行任何其他操作。
在这里,我们展开来讲一下浅拷贝和深拷贝:
所谓的浅拷贝是纯粹的把一个对象的值拷贝给另一个对象,但是这么做往往会出现问题。比如将stu1的gender指针的值传给stu2,那么stu1和stu2的gender将共享同一块内存。那么问题来了,如果这时析构了stu1,那么stu2同时也会收到“连累”。更可怕的是,在析构stu2的时候编译器会报错,因为stu2的gender试图delete一个已经被delete掉的指针。事实上,编译器提供的拷贝构造函数就是这么干的,它只是单纯的把指针的值传给另一个对象,而没有为新对象的指针重新分配一块内存。所以建议大家采用深拷贝,就像上面我给的例子一样,为gender分配一块新的内存,同时用strcpy把原先对象中的内容拷贝过去。
下面看看调用拷贝构造函数的几种实例:

    Student s1(stu);    Student s2 = stu;    Student s3 = Student(stu);    Student *s4 = new Student(stu);

其中中间两种可能会直接使用拷贝构造函数创建新的对象,也可能是先用拷贝构造函数创建一个临时对象,然后将临时对象的内容赋给将要创建的对象,之后析构临时对象。
第一个就是直接使用拷贝构造函数创建了新对象;第四个是先创建一个临时对象,然后。。。。。

三:赋值运算符
赋值运算符可以说是另一种拷贝构造函数,它的实现是将=进行了重载。
比如:
Student & operator=(const Student & stu);
然后在函数体中进行赋值就行了。
要注意的是,在初始化的时候,如:Student s=stu;调用的并不一定是赋值运算符,而可能是调用拷贝构造函数(也不知道你是否注意到上面我在定义对象s2和s3的时候就用到了=),这取决于具体的实现。然而,现代c++编译器为了提高速度,已经对这个进行了优化,所以起有可能调用的是拷贝构造函数。
如果一个对象已经被定义出来,那么使用=是进行了赋值操作。
编译器也会提供一个默认的赋值运算符,如果你没有提供自己的赋值运算符。

四:移动构造函数
A move constructor of class T is a non-template constructor whose first parameter is T&&, const T&&, volatile T&&, or const volatile T&&, and either there are no other parameters, or the rest of the parameters all have default values.
移动构造函数和拷贝构造函数非常相似,最大的不同就在于一个是拷贝一个是移动;拷贝就是把自己的数据拷贝一份传过去,自己依旧还有这份数据,移动的话就是把自己的这份数据移动到另一个对象然后自己就失去了这份数据。换句话说,移动构造函数就相当于新对象接管了原先对象的资源。
比如:

    Student(Student && stu) :name("No Name"), gender(nullptr), age(0), grade(0)    {        name = stu.name;        age = stu.age;        grade = stu.grade;        stu.name = stu.gender = nullptr;        stu.age = stu.grade = 0;    }

一般来说移动构造函数是不需要为新的对象分配内存的,它接管移动源的内存,且完成构造之后移动源会被销毁,意味着将调用它的析构函数。
这里使用&&右值引用,如果对于这个有不明白的请自行查阅资料(《c++ primer》中文第五版p545有对它的介绍)。
有时候在编写一个不抛出异常的移动操作时,我们需要在构造函数中指明noexcept,以表示在移动过程中不会抛出异常。因为如果没有指明,那么可能在移动过程中发生了异常,那么由于移动源已经被修改,而新对象中还有一部分元素未被构造;为了避免这种潜在的危险,编译器将会调用拷贝构造函数而不是移动构造函数。
好,那么怎么使用呢?
举个例子:
如果想要把stu1移动到stu2,就得这么干

Student stu1;Student stu2(std::move(stu1));//如果是Student stu2(stu1);就调用了拷贝构造函数

五:移动赋值操作
如果大概明白了四的内容,在阅读五的时候就不会出现太大问题。
直接上代码:

Student & operator=(Student && stu)    {        if (this != &stu)        {            delete[]gender;            gender = stu.gender;            name = stu.name;            age = stu.age;            grade = stu.grade;            stu.name=stu.gender = nullptr;            stu.age = stu.grade = 0;        }        return *this;    }

这里进行了对两块内存的检查,当两者地址不一致时,进行移动,否则直接返回。
只有当一个类没有定义任何自己版本的拷贝控制成员且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数和移动赋值运算符;因此,编译器不会为所有类合成移动构造函数和运动赋值运算符。

最后做个总结:
如果你没有定义以下构造函数或运算符,编译器会为你合成一个:
默认构造函数
拷贝构造函数
赋值运算符
可能会为你合成的是:
移动构造函数
移动赋值运算符

当且仅当你没有定义任何构造函数的时候,编译器才会为你合成一个默认构造函数。

哦,最后还要加上一个析构函数

~Student(){delete[]gender;--count;}
0 0