C++类拾遗

闲的时候捧起《C++ primer》重新复习了下类相关概念,主要是一些以前没有特别明确的。

类定义

class vs struct

以前的C里是没有class这个关键字的,struct只用于一些数据类型的结合。在C++里,这两个关键字的唯一区别是默认访问权限不同。

  • class默认是private
  • struct默认是public

在我的个人习惯里,struct仍然只有一般数据聚合的作用,并且最好是定长的,不包含任何其他方法,而class则用于定义类

this指针

成员函数可以通过this指针来访问调用它的对象,在成员函数内部可以省略

1
std::string isbn() { return this->bookNo; }

const成员函数

在类的成员函数末尾的cost关键字,可以修改this指针为const指针,可以避免成员函数修改类成员变量

1
std::string isbn() const { return this->bookNo; }

构造函数

默认构造函数

如果没有为对象提供构造函数,则编译器会自动创建构造函数,称为合成的默认构造函数。 合成默认构造函数规则:

  • 如果存在类内的初始值,则用初始值初始化类的数据成员
  • 默认初始化该成员

某些类不能依赖于合成的默认构造函数:

  • 编译器只有在发现类不包含任何构造函数的情况下才会生成默认构造函数
  • 合成的默认构造函数可能执行错误的操作(内置类型或复合类型默认初始化,则它们的值将是未定义的,似乎和C的兼容有关)
  • 有时编译器不能为某些类提供默认构造函数,因为类中包含的其他类型的成员包含了非默认构造函数

=default的含义

1
2
3
4
5
class SalesData{
std::string _bookNo;
SalesData() = default;
SalesData(const std::string &s) : _bookNo(s) {}
}

该构造函数(default)不接收任何参数,所以是默认构造函数。这个构造函数完全等同于合成默认构造函数,并且如果在类内部定义则是内联的,在外部定义则不是

构造函数初始化列表

1
SalesData(const std::string &s): bookNo(s) {}

冒号和花括号间的代码为构造函数初始值列表,负责为新创建的对象的数据成员赋初值。

注意:

  • 构造函数不应该轻易覆盖类内初始值
  • 如果你不能使用类内初始值,则所有构造函数均应该显示初始化每个内置类型

委托构造函数

这个是C++11里面的新功能,放上例子。在委托构造函数中,先执行其本身的代码(花括弧中的...),再去执行被委托的构造方法

1
2
3
4
5
6
7
8
class SalesData {
//非委托构造函数
SalesData(std::string s){}
SalesData(std::string s, unsigned count):bookNo(s), unitsSold(count){}
//委托构造函数
SalesData(): SalesData("",0){...}
void combine(const SalesData& item){}
}

explicit关键字

explicit关键字主要是用在隐式转换上。比如说item是一个SalesData对象,该对象有一个combine方法,接受一个SalesData类型的常量引用。那么

1
2
3
4
5
6
7
//显式转换成string,隐式转化成SalesData,正确
item.combine(string("99999"));
//隐式转换成string,显式转换成SalesData,正确
item.combine(SalesData("99999"));

//但是不能两步转换!错误!
item.combine("999999");

而explicit关键字能阻止隐式转换(顾名思义嘛,explicit是明确的意思)

不过要注意一点的是,explicit关键字只对单个参数的构造函数有效,需要多个参数的构造函数不适用隐式转换,因此也就不需要explicit关键字了。

类的拷贝、赋值、析构

大多数情况下编译器会自动生成默认的拷贝、赋值、析构函数,但是也不尽然,尤其是当类内有动态分配的内存时(比如涉及到new delete之类的)。事实上为了防止我们在内存的枪林弹雨里挣扎,《C++ Primer》建议能用std里的东西就用std里的东西,比如vector或string之类的。

类拷贝

拷贝构造函数指的是:一个构造函数的第一个参数是自身类型的引用,且任何额外参数都有默认值

1
2
3
4
5
class Foo {
public:
Foo();
Foo(const Foo&);
}

因此对于类而言

  • 直接初始化:要求编译器使用普通的函数匹配
  • 拷贝初始化:将右侧的运算对象拷贝到正在创建的对象中(可能还有类型转换)

以下是书中给的例子

1
2
3
4
5
6
7
//直接初始化
string dots(10, '.');
string s(dots);
string s2 = dots;
//拷贝初始化
string book = "999999";
string nine = string(100, '9');

值得注意的是,拷贝初始化不仅在使用=的时候发生,还包括了:

  • 将一个对象作为实参传递给一个非引用类型形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括弧列表初始化一个数组中的元素或一个聚合类中的成员

前两点比较好理解。在我的个人实践中,为了防止超大的vector在函数调用中引起的内存拷贝,一般这么写(不知道有没有更优雅的方式)

1
void fun(const vector<int> &arg, vector<int> &res);

最后一点中的聚合类指的是只包含数据的struct,可以如此初始化

1
2
3
4
5
6
struct Foo
{
int a;
double b;
};
Foo foo = { 1, 2 };

这里有一点值得注意,即拷贝初始化和explicit关键字。如果我们使用的初始化值是通过一个explicit构造函数进行类型转换,那么就要注意。这里有点微妙,来用vector说明一下。

1
2
3
4
5
vector<int> v1(10);//正确的直接初始化,这个构造函数是explicit的
vector<int> v2 = 10//错误,这里有隐式转换,但是被explicit阻止了
void f(vector<int>);//f的参数是拷贝初始化
f(10);//错误,不能隐式地将10转为vector<int>
f(vector<int>(10));//正确,显式地将10转为vector

类赋值

这里主要是涉及到了赋值运算符的重载。对于默认的赋值运算符(即合成拷贝赋值运算符),会对右侧的对象每个非static成员赋予左边,用的是拷贝构造函数。也就是说,大部分上都是直接内存拷贝,正确地使用是不会有野指针问题。当然可以重载,这个就要小心啦,指针容易乱飞,尤其是再掺上多线程,啧啧,那酸爽。

类析构

析构函数不接受参数,也没有返回值,不能被重载。一般而言,内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。但是指针类型的需要小心!不然会出现内存泄露。

什么时候会调用析构函数:

  • 变量在离开作用域时
  • 一个对象被销毁,其成员也被销毁
  • 容器(包括标准库容器和数组)销毁时,其元素也被销毁
  • 动态分配内存的对象,对其指向其指针使用delete时
  • 临时对象,创建它的完整表达式结束时被销毁

书中提到一点,析构函数本身并不直接销毁成员。成员是在析构函数体之后隐含的析构阶段中被销毁的。析构函数在某些时候用于阻止对象被销毁,这里就可能造成new和delete不能成对出现(其实现在我还是想不通这又有什么用。。。)。如下

1
2
3
4
5
6
7
class NoDeconstruct {
NoDeconstruct() = default;
~NoDeconstruct() = delete;//表示删除该方法
}
NoDeconstruct n;//错误:析构方法不存在
NoDeconstruct *p = new NoDeconstruct();//正确
delete p;//错误

专门在Xcode上试了一下,还真可以。不过这点在三/五法则中直接禁止,个人认为析构函数在绝大多数情况下都不会被删除。

三/五法则

三指的是拷贝构造函数、拷贝赋值函数、析构函数。五指的是对应的五条法则。

1.需要析构函数的类也需要拷贝构造函数和拷贝赋值函数

典型的情况就是类内包含指针成员。指针成员需要析构函数来释放其指向的内存,那么如果执行默认的拷贝构造函数,意味着内存共享,然而这个共享是不靠谱的。想象一下拷贝构造出来的对象被销毁了,意味着那块共享的内存也没了

2.需要拷贝操作的类也需要赋值操作,反之亦然

需要拷贝操作代表这个类在拷贝时需要进行一些额外的操作。赋值操作=先析构+拷贝,所以拷贝需要的赋值也需要。反之亦然。

3.析构函数不能是删除的

上面已经说明

4.如果一个类有删除的或不可访问的析构函数,那么其默认和拷贝构造函数会被定义为删除的

个人觉得这条是上面的补充说明。如果没有这条规则,可能会创造出无法被删除的对象(除非是动态分配)。 理论上来说,当析构函数不能被访问时(比如被删除),任何静态定义的对象都不能通过编译器的编译。所以这种情况只会出现在与动态分配有关的拷贝/默认构造函数身上,就是上面说的奇葩用法,只能new不能delete。

5.如果一个类有const或引用成员,则不能使用合成的拷贝赋值操作

因为const成员只在初始化的时候赋值一次。而合成的拷贝赋值会对所有成员赋值,无法通过编译。