【C++】【C++ Primer】7-类
1 定义抽象数据类型
类的基本思想是数据抽象和封装。
数据抽象是一种依赖于接口和实现分离的编程技术。类的接口包括用户所能执行的操作。类的实现则包括类的数据成员、接口的具体实现、定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了实现细节,类的用户只能使用接口,无法访问实现部分。
类想要实现数据抽象和封装,首先要定义一个抽象数据类型。在抽象数据类型中,类的设计者考虑类的实现过程,使用该类的程序员只需要抽象思考类型做了什么,无需了解类型的工作细节。
1.1 成员函数
1.1.1 成员函数的声明和定义
成员函数的声明必须在类的内部,定义既可以在类内部也可以在类外部。区别在于,定义在类内部的函数是隐式的inline函数。
作为接口组成部分的非成员函数,定义和声明都在类的外部。这些函数的声明要和类的声明写在同一个头文件中,这样一来,只要include一个头文件,就可以使用和该类有关的所有代码。
在下述Sales_data.h中,成员函数isbn的定义在类内,因此是隐式inline函数。成员函数combine和avg_price的定义则在类外。add、print、read这三个接口则是非成员函数,所以声明和定义都在类的外部。
/*
* Sales_data.h
*/
#ifndef SALES_DATA_H
#define SALES_DATA_H
#include <string>
struct Sales_data {
std::string isbn() const { return bookNo; }
Sales_data &combine(const Sales_data&);
double avg_price() const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
#endif
/*
* Sales_data.cpp
*/
#include <iostream>
#include "Sales_data.h"
using std::istream;
using std::ostream;
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
double Sales_data::avg_price() const
{
if (units_sold) {
return revenue / units_sold;
} else {
return 0;
}
}
istream &read(istream &is, Sales_data &item)
{
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs;
sum.combine(rhs);
return sum;
}
1.1.2 this
成员函数通过一个名为this的隐式参数来访问调用它的对象。当我们调用一个成员函数时,会用请求该函数的对象地址来初始化this。this总是指向发起调用的对象,所以this是常量指针,不允许改变。
譬如以下代码,将total的地址传递给isbn的隐式形参this:
total.isbn();
在成员函数内部可以直接使用调用该函数的对象的成员,无需使用成员访问运算符,因为this所指的就是这个对象。任何对类成员的直接访问都被看做this的隐式引用。
以下两种代码等效,但通常不会显式地把this->写出来:
std::string isbn() const
{
return bookNo;
}
std::string isbn() const
{
return this->bookNo;
}
1.1.3 常量成员函数
成员函数isbn的形参列表后面紧跟了一个const,它的作用是修改隐式this指针的类型。
默认情况下,this是指向非常量的常量指针,它所指向的对象是可以修改的。在这种默认情况下,不能把this绑定到一个常量对象上,即不能在常量对象上调用普通的成员函数。综上,将this设置为指向常量的指针,有益于提升函数的灵活性。
C++中,紧跟在成员函数参数列表后面的const关键字表示this是一个指向常量的指针。这种成员函数被称为常量成员函数。常量成员函数不能改变调用它的对象的内容。
常量对象、常量对象的引用或指针都只能调用常量成员函数。
1.1.4 类作用于和成员函数
编译器分两步处理类:
- 首先编译成员的声明;
- 再编译成员函数体。
因此,成员函数体可以随意使用类中的其他成员,不必在意定义的次序。譬如在Sales_data.h中,isbn的定义在bookNo定义之前,但没有报语法错误。
1.1.5 在类的外部定义成员函数
在类外部定义成员函数时,必须和其声明匹配。如果是常量成员函数,定义的参数列表后面也要加上const。此外,在类外部定义的成员函数,函数名必须包含其所属类名。这样编译器才知道这些代码是位于类的作用域内的。
1.2 定义类相关的非成员函数
类的作者常常需要定义一些辅助函数。这些函数定义的操作属于类的接口的组成部分,但并不属于类本身。
这些函数应当和类的声明在同一个头文件内,用户使用接口时,只需引入一个头文件即可。
1.3 构造函数
1.3.1 构造函数的基本概念
类通过构造函数来控制其对象的初始化过程。构造函数的任务是初始化类对象的数据成员,只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同。
构造函数没有返回类型。
构造函数有参数列表(可以为空)以及函数体(可以为空)。
类可以重载多个构造函数,各重载版本的参数数量或参数类型必须有所区别。
构造函数不能被声明为const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得const属性。因此,构造函数在const对象的构造过程中可以向其写值。
1.3.2 默认构造函数
默认构造函数的参数列表为空,它是特殊的构造函数。其特殊性主要体现在以下几方面:
- 如果某个类没有显式定义构造函数,编译器就会隐式地定义一个默认构造函数。这个由编译器自动创建的默认构造函数称作合成的默认构造函数。
- 一旦类中有显式定义的构造函数,编译器就不会再自动创建默认构造函数。此时应显式创建。
默认构造函数将按如下规则初始化类的数据成员:
- 如有类内初始值,则使用类内初始值初始化数据成员。
- 如无类内初始值,则默认初始化数据成员。
1.3.3 某些类不能依赖于合成的默认构造函数
合成的默认构造函数只适用于非常简单的类。对于普通的类来说,必须定义它自己的默认构造函数,原因有三:
- 仅当某个类中没有显式定义构造函数时,编译器才会合成默认构造函数。一旦类中有显式定义的构造函数,除非显式定义默认构造函数,否则该类就没有默认构造函数了。
- 对于某些类而言,合成默认构造函数可能执行错误的操作。如果类中定义了内置类型或复合类型(譬如数组和指针),且没有类内初始值,则这些成员的值是未定义的。如果类包含内置类型或复合类型的成员,只有这些成员都被赋予了类内初始值时,才适合使用合成默认构造函数。
- 编译器不能为某些类合成默认构造函数。如果类中包含其他类类型的对象,且该类型没有默认构造函数,编译器就无法初始化该成员。
1.3.4 =default
在C++11新标准中,如果需要默认的行为,可以在显式定义的默认构造函数的参数列表后面加上=default,要求编译器生成默认构造函数。
=default可以和声明一起出现在类内部,也可以作为定义出现在类外部。如果=default在类的内部,则默认构造函数是内联的。如果在类外部,则该成员在默认情况下不是内联的。
Sales_data() = default;
1.3.5 构造函数初始值列表
以下构造函数在形参列表和函数体之间加入了一段以冒号起始的代码,我们称之为构造函数初始值列表。
Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p * n) {}
构造函数初始值列表负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来(或花括号内)的成员初始值。不同成员之间用逗号分隔。
当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。
通常而言,构造函数使用类内初始值是个好的选择。如果编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
1.4 拷贝、赋值和析构
1.4.1 编译器合成的拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。如果我们不主动定义这些操作,编译器将合成它们。通常来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。
1.4.2 某些类不能依赖于合成的版本
上一节提到,编译器能替我们合成拷贝、赋值和析构的操作,但对于某些类来说合成的版本无法正常工作。尤其是当类需要分配类对象之外的资源时(譬如动态内存),合成的版本通常会失效。
很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的存储空间。使用vector或string的类能避免分配和释放内存带来的复杂性。进一步讲,如果类包含vector或string成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector成员的对象执行拷贝或赋值操作时,vector类会设法拷贝或者赋值成员中的元素。含有vector成员的对象被销毁时,将销毁vector对象,也就是依次销毁vector中的每个元素,这和string是非常类似的。
2 访问控制和封装
2.1 访问说明符
C++语言中,使用访问说明符加强类的封装性。
- public:定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
- private:定义在private说明符之后的成员可以被类的成员函数访问,但不能被使用该类的代码访问。private部分封装了类的实现细节。
一个类可以包含0个或多个访问说明符,而且对于每个访问说明符的出现次数没有严格规定。每个访问说明符指定了接下来的成员的访问级别,有效范围直到下一个访问说明符或到达类的结尾处为止。
2.2 使用class或struct关键字
class关键字和struct关键字均可用于定义类。唯一的区别是默认访问权限不同。类可以在第一个访问说明符之前定义成员。这种成员的访问权限依赖于类的定义方式:
- 使用struct关键字:public
- 使用class关键字:private
出于统一编程风格的考虑,如果希望类的所有成员都是public时,使用struct。如果希望类的所有成员都是private时,使用class。
2.3 友元
类可以允许其他类或者函数访问它的非公有成员,方法是将其他类或函数成为它的友元。
如果某个类想将一个函数作为它的友元,只需增加一条以friend关键字开始的函数声明语句即可。
友元声明只能出现在类定义的内部,但在类内出现的具体位置不限。习惯上,在类定义开始或结束前的位置集中声明友元。
友元不是类的成员,也不受它所在区域访问控制级别的约束。
class Sales_data
{
// 友元声明
friend Sales_data add(const Sales_data&, const Sales_data&);
public:
//
private:
//
};
Sales_data add(const Sales_data&, const Sales_data&);
友元的声明仅仅指定了反问的权限,而非一个通常意义上的函数声明。如果希望类的用户可以调用某个友元函数,就必须在友元函数之外再专门对函数进行一次声明。
3 类的其他特性
3.1 类成员再探
3.1.1 定义类型成员
除了定义数据成员和函数成员之外,类还可以通过typedef或using定义某种类型在类中的别名。由类定义的类型名字称为类型成员,也存在访问限制,可以是public或private中的一种。
class Screen {
public:
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
类型成员和普通成员之间有一点区别——用来定义类型的成员必须先定义后使用。因此,类型成员通常出现在类开始的地方。
3.1.2 令成员作为内联函数
在类中,常有一些规模较小的函数适合于被声明成内联函数。除了前文提到的,定义在类内部的成员函数是自动inline的,还可以在类内部把inline作为声明的一部分显式地声明成员函数。此外,也可以在类外部用inline关键字修饰函数的定义。
尽管在声明和定义的位置同时说明inline是合法的,但无须这样做。最好只在类外部定义的地方说明inline,以便于理解。
inline成员函数应该和相应的类定义在同一个头文件中。
3.1.3 重载成员函数
成员函数可以被重载,只要函数之间在参数数量或参数类型上有所区分即可。
3.1.4 可变数据成员
有时我们希望能修改类中的某个数据成员,即便是在一个const成员函数内。可以通过在变量声明中添加mutable关键字做到这一点。
可变数据成员永远不会是const,即便它是const对象的成员,也可以修改。因此,一个const成员函数可以改变可变数据成员的值。
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr; // 可变数据成员,即便在一个const对象内也能修改
};
void Screen::som_member() const
{
++access_ctr; // 在const成员函数中也可修改
}
3.1.5 类数据成员的初始值
提供类内初始值时,必须以符号=或花括号表示。
class Window_mgr {
private:
int window_id = 0;
vector<Screen> screens{Screen(24, 80, ' ')};
};