构造函数详解
1. 构造函数基本概念
1)C++中的类可以定义与类名相同的特殊成员函数,这种与类名相同的成员函数叫做构造函数;
2)构造函数在定义时可以有参数;
3)没有任何返回类型的声明;
二个特殊的默认构造函数:
1)默认无参构造函数:当类中没有定义构造函数时,编译器提供一个默认的无参构造函数,并且其函数体为空。
2)默认拷贝构造函数:当类中没有定义拷贝构造函数时,编译器提供一个默认的拷贝构造函数,简单的进行成员变量的值复制。
构造函数调用规则:
1)当类中没定义任何构造函数时,C++编译器会提供默认无参构造函数和默认拷贝构造函数。
2)当类中定义了拷贝构造函数时,C++编译器不会提供无参数构造函数。
3)当类中定义了任意的非拷贝构造函数(即:当类中提供了有参构造函数或无参构造函数),C++编译器不会提供默认无参构造函数。
4)默认拷贝构造函数进行的是浅拷贝。
5)当类中定义了拷贝构造函数时,C++编译器不会提供移动构造函数了。
2. 构造函数的分类及调用
我们来看如下代码:
class Test { private: int a, b; public: Test() {} // 无参数构造函数 Test(int a, int b) {} // 带参数的构造函数 Test(const Test &obj) {} // 赋值构造函数 public: void init(int _a, int _b) { a = _a; b = _b; } };
1)无参数构造函数:调用方法如下
Test t1, t2; Test t1 = Test(); // 这样才是调用默认构造函数,这时必须带有括号
2)带参数构造函数
Test t1(20, 10); // 括号法: C++编译器默认调用有参构造函数 Test t2 = (20, 10); // 等号法: C++编译器默认调用有参构造函数 Test t3 = Test(20, 10); // 直接调用构造构造函数法: 程序员手工调用构造函数产生了一个对象
3)赋值(拷贝)构造函数:顾名思义,即由其它对象来初始化自己。下面介绍赋值构造函数的三种调用场景(调用时机)。
a. 定义变量时,用对象1初始化对象2
class Test { public: Test() { cout << "我是构造函数,自动被调用了" << endl; } Test(int _a) : a(_a) {} Test(const Test &obj2) { cout << "我也是构造函数,我是通过另外一个对象obj2,来初始化我自己" << endl; } ~Test() { cout<<"我是析构函数,自动被调用了"<<endl; } private: int a; }; int main() { Test a1; Test a2 = a1; // 用 a1 初始化 a2 Test a3(a1); // 这样写也是用 a1 初始化 a3 return 0; }
b. 实参变量初始化形参变量
class Location { public: Location(int x = 0, int y = 0) : X(x), Y(y) { cout << "Constructor Object.\n"; } Location(const Location &p) : X(p.X), Y(p.Y) { cout << "Copy_constructor called." << endl; } ~Location() { cout << X << "," << Y << " Object destroyed." << endl; } int GetX() { return X; } int GetY() { return Y; } private: int X, Y; }; void f(Location p) { cout << "Funtion:" << p.GetX() << "," << p.GetY() << endl; } int main() { Location A(1, 2); f(A); // 调用f会构造一个临时对象p,此时会调用拷贝构造函数 return 0; }
c. 函数返回匿名对象,会在栈上面通过拷贝构造函数产生一个临时对象(一般会被编译器优化),然后原来的栈变量被析构。
之后就取决于程序员怎么来接收这个匿名对象,不同的接法差别在于会不会多一次赋值运算符的调用。
注:可以在编译时设置编译选项-fno-elide-constructors用来关闭返回值优化效果。
class Location { public: Location(int x = 0, int y = 0) : X(x), Y(y) { cout << "Constructor Object.\n"; } Location(const Location &p) : X(p.X), Y(p.Y) { cout << "Copy_constructor called." << endl; } ~Location() { cout << X << "," << Y << " Object destroyed." << endl; } int GetX() { return X; } int GetY() { return Y; } private: int X, Y; }; void f(Location p) { cout << "Funtion:" << p.GetX() << "," << p.GetY() << endl; } /* * 当函数需要返回一个对象,他会在栈中创建一个临时对象,存储函数的返回值。 * 这个临时对象也是匿名对象,构造它时会调用拷贝构造函数,用A来初始化这个匿名对象。 * 然后函数调用结束,A被销毁. * 但是这个临时对象的构造一般会被编译器优化掉,所以自己测试的时候一般不会调用拷贝构造函数了。 */ Location g() { Location A(1, 2); return A; } int main() { Location B; B = g(); // 若返回的匿名对象,赋值给另外一个同类型的对象,那么匿名对象会被析构。(会调用赋值运算符) Location C = g(); // 若返回的匿名对象,来初始化另外一个同类型的对象,那么匿名对象会直接转成新的对象。(啥也不调用) return 0; }
4)移动构造函数:C++11引入移动语义----临时对象资源的控制权(堆内存)全部交给目标对象。注意一下,临时对象和目标对象是两个独立的不同对象,
移动构造函数也不是说将临时对象直接变成目标对象,只是将临时对象所控制的资源进行浅拷贝(拷贝指针),而没有了深拷贝,然后临时对象就无法
访问这个资源了,但临时对象本身还是要被析构的。因为浅拷贝是难以避免的,所以类如果没有堆上的资源,也就没必要实现移动构造函数。
下面举个例子:
static unsigned int cCount; //统计拷贝构造函数调用次数 static unsigned int mCount; //统计移动构造函数调用次数 class MyString { public: // 构造函数 MyString(const char* cstr = 0) { if (cstr) { m_data = new char[strlen(cstr) + 1]; strcpy(m_data, cstr); } else { m_data = new char[1]; *m_data = '\0'; } } // 拷贝构造函数 MyString(const MyString& str) { cCount++; m_data = new char[strlen(str.m_data) + 1]; strcpy(m_data, str.m_data); } // 移动构造函数 MyString(MyString&& str) { mCount++; m_data = str.m_data; // 目标对象接管堆上资源 str.m_data = nullptr; // 临时对象不再指向那个资源了 } ~MyString() { delete[] m_data; } private: char* m_data; }; int main() { vector<MyString> vecStr; vecStr.reserve(1000); // 先分配好1000个空间 for(int i = 0; i < 1000; i++) { vecStr.push_back(MyString("hello")); } cout << "cCount: " << cCount << endl; cout << "mCount: " << mCount << endl; return 0; }
运行可知道程序调用了1000次的移动构造函数,这样就不会去重新分配一块新的空间,将要拷贝的对象复制过来,而是"偷"了过来,将自己的指针
指向别人的资源,然后将别人的指针修改为nullptr
,这一步很重要,如果不将别人的指针修改为空,那么临时对象析构的时候就会释放掉这个资源,"偷"也白偷了。
抛出一个问题:我们知道const引用也是能够被右值初始化的,那编译器怎么知道调用哪个构造函数呢?是拷贝还是移动?
编译器判断传入的参数是一个右值,会认为移动构造函数是一个更好的匹配。
对于一个左值,肯定是调用拷贝构造函数了,但是有些左值是局部变量,生命周期也很短,能不能也移动而不是拷贝呢?C++11
为了解决这个问题,提供
了std::move()
方法来将左值转换为右值,从而方便应用移动语义。我觉得它其实就是告诉编译器,虽然我是一个左值,但是不要对我用拷贝构造函数,而是
用移动构造函数吧。。。
注意一下:将一个临时对象赋值给 T &&x 是延长临时对象的生命周期的做法(不会移动或者拷贝),是右值引用。若赋值给 T x 则会触发移动或者赋值构造函数。
还是上面的类,现在改写一下main函数。
int main() { vector<MyString> vecStr; vecStr.reserve(1000); //先分配好1000个空间 for(int i = 0; i < 1000; i++) { MyString tmp("hello"); vecStr.push_back(tmp); //调用的是拷贝构造函数 } cout << "cCount: " << cCount << endl; cout << "mCount: " << mCount << endl; cCount = 0; mCount = 0; vector<MyString> vecStr2; vecStr2.reserve(1000); //先分配好1000个空间 for(int i = 0; i < 1000; i++) { MyString tmp("hello"); /* * 调用的是移动构造函数 * 此时tmp指向的资源已经为null了,但对象在表达式结束时尚未析构,作用域结束后才析构 */ vecStr2.push_back(std::move(tmp)); //调用的是移动构造函数 } cout << "cCount: " << cCount << endl; cout << "mCount: " << mCount << endl; return 0; } // 输出如下 cCount:1000 mCount:0 cCount:0 mCount:1000
需要注意一下:如果我们没有提供移动构造函数,只提供了拷贝构造函数,std::move()
会失效但是不会发生错误,因为编译器找不到移动构造函数就
去寻找拷贝构造函数,也这是拷贝构造函数的参数是const T&
常量左值引用的原因!
3. 构造函数隐式转换
用单个实参(也可以有多个实参,但是除了第一个参数,其它参数必须有默认值)来调用的构造函数定义了从形参类型到类类型的一个隐式转换。
隐式转换没有特别的语法,只要类型满足构造函数的参数即可以触发。简单举个例子
class Test { public: bool same(const Test &rbs) const { return isbn == rbs.isbn; } Test(const std::string &book = "7115145547") : isbn(book) {} private: std::string isbn; }; int main() { Test trans; string null_book = "9-999-99999-9"; trans.same(null_book); // 这里会发生隐式类型转换,从string转换为test(因为有构造函数可以用一个string做参数),建立一个临时的类的对象 return 0; }
为了避免这个情况的发生,可以将类的构造函数声明为explicit,然后显示调用:
explicit Test(const std::string &book = "7115145547") : isbn(book) {} trans.same(Test(null_book));