构造函数析构函数
C++的类成员函数如果声明成const
,则表示该函数不能修改类的成员。构造函数不能声明为const
,因为肯定要在构造函数里面对类进行初始化。只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。
代码实例:
class Sales_data {
Sales_data() = default;
Sales_data(const std::string &s) : bookNo(s) {}
Sales_data(const std::string &s, unsigned n, double p) :
bookNo(s), units_solds(n), revenue(p * n) {}
std::string isbn() const { return bookNo; }
std::string bookNo;
unsigned units_solds = 0;
double revenue = 0.0;
};
C++11标准中,如果需要默认的行为,那么需要早参数列表后面添加上= default
来要求编译器自动生成。若该关键字出现在类的内部,则默认的构造函数是内联的;如果在类的外部,成员默认不是内联的。
class
默认的访问权限是private
,而struct
关键字默认的成员是public
,二者定义类的唯一区别就是默认访问权限不同。。。
如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化,而且 必须通过函数的初始值列表进行初始化。
如果类的成员是const
或者引用的话,必须将其初始化。
构造函数初始化列表的初始化的顺序由变量在类中出现的先后次序决定,与初始化列表中摆放的顺序无关。尽量避免室友某些类的成员初始化其他累的成员。构造函数可以使用默认参数,这与函数的默认参数的规则相同。
委托构造函数:
一个构造函数使用类中的其他构造函数进行初始化,而且委托构造函数的初始化列表只能有唯一的一个构造函数,不能再添加其它的初始化成员了。
class Test {
public:
Test(int _a, int _b) : a(_a), b(_b) {}
Test(int _c) : c(_c) {}
Test() : Test(0, 0) {} // 委托构造函数
Test(int _a, int _b, int _c) : Test(_c) {} // 委托构造函数
private:
int a, b, c;
};
对象与对象函数的区别:
class Test {
public:
Test() = default;
int a = 1;
};
int main() {
Test obj(); // 这是一个函数,它的返回值是Test类型的对象
Test obj1; // 这是定义了一个对象
return 0;
}
转换构造函数:
构造函数值接受一个实参,则它实际上定义了转换为此类类型的隐式转化机制。将构造函数声明为explict
可以阻止隐式转换的程序。explict
只能用于直接初始化,不能用于拷贝形式的初始化过程。
析构函数用于释放资源,并销毁对象的非static
成员。析构函数不接受任何参数,不能被被重载,一个类只有唯一的一个析构函数。只要一个对象被销毁,那么就会调用它的析构函数:
- 变量离开作用域被销毁
- 对象被直接销毁
- 容器被销毁,元素也就被销毁了
- 动态分配的对象,指向它的指针被应用
delete
时 - 创建临时对象的完整表达式被销毁
一般来说,一个类需要自己定义一个析构函数时,那么这个类也需要一个拷贝构造函数和拷贝赋值运算符;需要拷贝构造函数的类也需要赋值操作。
阻止拷贝操作可以通过定义delete
函数实现:
class Test {
public:
Test() {}
Test(const Test &) = delete;
Test &operator=(const Test &) = delete;
};
析构函数不能是删除的成员。
友元
友元函数
友元函数用于访问类的私有成员,只需要函数前面添加friend
关键字即可。一般在类定义开始或者结束前的位置统一声明友元。友元的声明仅仅指定了访问的权限,不是普通意义上的函数声明,如果我们希望类的用户能够调用某个友元函数,那么就必须在友元声明之外再专门对函数进行一次声明。
class Test {
friend void change(Test &test); // 声明友元函数
int t;
};
void change(Test &test) { // 定义友元函数
test.t = 10;
}
int main() {
Test test;
change(test); // 友元的作用,可以通过编译
return 0;
}
友元类
如果一个类声明为另一个类的友元类,那么它可以访问另一个类的所有成员。
代码实例:
class A {
friend class B; // 声明为友元
int a;
};
class B {
public:
void change(A &a) { a.a = 10; }
private:
int b;
};
int main() {
A a;
B b;
b.change(a); // 友元声明,可以通过编译
return 0;
}
在上述代
码中,如果只是想B
的某些成员函数为A
的友元,可以单独进行声明:
class A; // A的前置声明
class B { // B在A前面声明
public:
void change(A &a);
};
void B::change(A &a) {
a.a = 10;
}
class A {
friend void B::change(A &a); // 声明友元函数
int a;
};
int main() {
A a;
B b;
b.change(a);
}
B
必须在-A
之前声明,否则A
无法判断B
的成员。A
要前置声明为类类型。
拷贝控制
定义任何C++类时,拷贝控制操作都是必要的部分。
拷贝、赋值与销毁
如果没有为一个类定义拷贝构造函数,编译器会为我们定义一个。即使定义了其它的构造函数,但是没有定义拷贝构造函数,编译器同样会为我们定义默认的拷贝构造函数。
代码实例:
class Test {
public:
Test(const Test &); // 拷贝构造函数
private:
string bookNo;
int n = 0;
};
Test::Test(const Test &test) :
bookNo(test.bookNo), n(test.n) {}
直接初始化是调用的有关默认构造函数,拷贝初始化调用的是拷贝构造函数。
拷贝初始化发生的场景:
- 一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 花括号列表初始化数组中的元素或者一个聚合类中的成员
因此,拷贝构造函数的参数必须是引用类型,否则永远不会发生自定义的拷贝构造过程。
拷贝赋值运算符可以通过运算符重载实现:
class Test {
public:
Test(const Test &);
Test &operator=(const Test &);
private:
string bookNo;
int n = 0;
};
Test::Test(const Test &test) :
bookNo(test.bookNo), n(test.n) {}
Test &Test::operator=(const Test &test) {
bookNo = test.bookNo;
n = test.n;
}
拷贝控制和资源管理
管理类外资源的类必须定义拷贝控制成员。一旦一个类需要析构函数,那么这个类一般也需要拷贝构造函数和拷贝赋值运算符。拷贝语义一般有两种:行为像值的和行为像指针的。行为像值的,一位置拷贝的副本与原来对象是完全独立的,改变副本不会影响原来的状态;拷贝像指针的类则共享行为状态,改变副本的值会同样的改变原来类的值。
具有行为像值的拷贝类:
#include <iostream>
using namespace std;
class HasPtr {
public:
HasPtr(const string &s = string(), int _i = 0) :
ps(new string(s)), i(_i) {}
HasPtr(const HasPtr &p) :
ps(new string(*p.ps)), i(p.i) {}
HasPtr &operator=(const HasPtr &);
~HasPtr() { delete ps; }
string *ps;
int i;
};
HasPtr &HasPtr::operator=(const HasPtr &rhs) {
auto newp = new string(*rhs.ps);
delete ps; // 释放就内存
ps = newp;
i = rhs.i;
return *this; // 返回本对象
}
int main() {
HasPtr hptr = HasPtr("First", 0);
HasPtr hptr1 = hptr;
cout << *hptr1.ps << " " << hptr1.i << endl;
*hptr1.ps = "Second";
hptr1.i = 1;
cout << *hptr.ps << " " << hptr.i << endl;
cout << *hptr1.ps << " " << hptr1.i << endl;
return 0;
}
/*
输出结果:
First 0
First 0
Second 1
*/
改变hptr1
后不会影响原来的值。
编写赋值运算符时,需要注意两点:
- 一个对象赋值给自身,可以正常工作
- 大多数赋值运算符组合了析构函数和拷贝构造函数
第一点尤其重要,比较安全的做法是销毁左侧运算符对象资源之前拷贝右侧的运算对象。
具有行为像指针的拷贝类:
class HasPtr {
public:
HasPtr(const string &s = string()) :
ps(new string(s)), i(0), use(new size_t(1)) {}
HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr &operator=(const HasPtr &);
~HasPtr();
private:
string *ps;
int i;
size_t *use; // 记录共享成员的个数
};
HasPtr::~HasPtr() {
if (--*use == 0) {
delete ps; // 释放string的内存
delete use; // 释放计数器的内存
}
}
HasPtr &HasPtr::operator=(const HasPtr &rhs) {
++*rhs.use; // 递增右侧运算对象的引用计数
if (--*use == 0) { // 递减本对象的引用计数
delete ps; // 没有其他用户,则释放本对象分配的成员
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}
上述代码的HasPtr
类共享一个string
的数据,因此传递的都是指针。为了能合理地记录当前指向string
的个数,需要把计数元素放到堆中。析构函数执行的时候,先判断计数器是否是0,如果是0,则释放内存空间。赋值操作的时候,需要同时处理左右两侧的计数器,左侧递减,右侧递增,左侧递减的时候,还需要判断引用计数是否是0,如果是0则释放内存。
交换操作
如果涉及到元素的大规模排序等的操作,那么就需要涉及到大量的交换操作。标准库的swap
操作的原理:
HasPtr temp = v1; // 创建v1的值的一个临时副本
v1 = v2;
v2 = temp;
也就是说,该代码把原来的string
多拷贝了两次,分别是第一个和第二个赋值语句,这两句是多余的。理想的方案是直接交换指针。我们需要自定义一个swap
函数,如果有自定义的swap
函数,那么编译器会自动替代系统的swap
函数。
理想的解决方案:
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
代码实例:
class HasPtr {
friend void swap(HasPtr &, HasPtr &);
private:
std::string *ps;
int i;
size_t *use; // 记录共享成员的个数
};
inline void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap; // 注意这里的写法
swap(lhs.ps, rhs.ps); // 注意这里使用标准库的swap函数
swap(lhs.i, rhs.i);
}
swap
并不是必要的,但是对于分配了资源的类,定义swap
函数是一种很重要的优化手段。
上述代码的swap
函数中,这是一种最佳的写法。因为如果类中还有自定义的成员,那么编译器会自动匹配相应的swap
函数。
如果自定义了swap
函数,我们可以在赋值运算符中直接使用swap
函数,这种技术称为拷贝交换技术。
HasPtr& HasPtr::operator=(HasPtr rhs) { // 注意是传值操作
swap(*this, rhs);
return *this;
}
补充内容
类类型
每个类定义了唯一的类型,及时他们的成员完全一样,这两个类也是不同的。
类的声明和定义是可以分开的,这点类似于函数。我们可恶意仅仅声明类而暂时不定义它,这属于前向声明,在声明后定义前的类是一种不完全类型,不清楚该类包含那些成员。这种场景一般应用在该情况:
- 定义指向之中类类型的指针或者引用
- 以不完全类类型作为函数的参数或者返回值
聚合类
聚合类是用户直接可以访问成员的类,有特殊的初始化语法,满足下面三个条件:
- 所有的成员都是
public
的 - 没有定义任何构造函数
- 没有类内的初始值
- 没有基类和
virtual
函数
一般我们都是使用结构体来实现的,例如:
struct Data {
int ival;
string s;
};
初始化方式使用大括号进行,而且必须按照顺序进行操作。
Data data = {0, "Anna"};
字面值常量类
符合下面4个要求的类:
- 数据成员必须是字面值类型
- 类中至少包含一个
constexpr
构造函数 - 如果一个数据成员含有类内的初始值,则内置类型成员的初始值必须是一条常量表达式;或者成员属于某种类类型,则初始值必须使用自己的
constexpr
构造函数 - 类必须使用析构函数的默认定义,该成员负责想回类的对象
类的静态成员
使用static
进行声明,静态成员只和类本身相关,与类的成员无关。对象中不包含任何与静态成员有关的数据,它被所有的对象共享,静态成员函数也是如此,它们没有this
指针。
静态成员不应该在类的内部初始化,我们可以为静态成员提供const
整数类型类内初始值。
class Test {
static int count;
public:
Test() = default;
int a = 1;
};