参考书籍:《C++ primer》学习后复习并整理如下,其中添加了一些自己的想法
在实现类的数据抽象和封装之前,需要先设计一个类。
我们实现sales_data类的接口要包含以下操作:
struct sales_data {
string isbn() const;
sales_data& combine(const sales_data&);
double avg_price() const;
// 允许类内初始化,可以使用等号赋值或者花括号,但不能使用圆括号
string book_no;// 书籍的ISBN号
unsigned units_sold = 0; // 表示某本书的销量
double revenue = 0.0;// 表示这本书的总销售收入
} ;
sales_data add(const sales_data&, const sales_data&);
ostream &print(ostream&, const sales_data&);
istream &read(istream&, sales_data&);
数据抽象是一种依赖于接口和实现分离的编程以及设计技术
类的接口包括用户所能执行的操作
成员函数的声明必须在类的内部,它的定义既可以在类的内部,也可以在类的外部;
struct sales_data {
string isbn() const;
sales_data& combine(const sales_data&);
double avg_price() const;
// 允许类内初始化,可以使用等号赋值或者花括号,但不能使用圆括号
string book_no;// 书籍的ISBN号
unsigned units_sold = 0; // 表示某本书的销量
double revenue = 0.0;// 表示这本书的总销售收入
} ;
每个类都分别定义了它的对象被初始化的方式,类通过一个或者几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。
构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同,构造函数没有返回类型,构造函数也有一个可能为空的参数列表和一个可能为空的函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
不同于其他成员函数,构造函数不能被声明成const的,当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量属性”,因此,构造函数在const对象的构造过程中可以向其写值。
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数,默认构造函数无需任何实参。
如果我们的类没有显示的定义构造函数,那么编译器就会为我们定义一个默认构造函数,编译器创建的构造函数又被称为合成的默认构造函数,对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
对于一个普通的类来说,必须定义自己的默认构造函数的原因有三:
声明sales_data类的构造函数
struct sales_data {
sales_data() = default;
sales_data(const string &s) : book_no(s) {
}
sales_data(const string &s, unsigned n, double p) : book_no(s), units_sold(n),revenue(p * n) {
} // 构造函数初始值列表初始化的顺序是按照数据成员定义的顺序来的。
sales_data(istream &);
string isbn() const;
sales_data& combine(const sales_data&);
double avg_price() const;
// 允许类内初始化,可以使用等号赋值或者花括号,但不能使用圆括号
string book_no;// 书籍的ISBN号
unsigned units_sold = 0; // 表示某本书的销量
double revenue = 0.0;// 表示这本书的总销售收入
} ;
C++11新标准扩展了构造函数初始值功能,使得我们可以定义委托构造函数。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些或者全部职责委托给了其他构造函数。
和其他的构造函数一样,委托构造函数也有一个成员初始值列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
例子:
struct sales_data {
sales_data(const string &s, unsigned n, double p) : book_no(s), units_sold(n),revenue(p * n) {
}
sales_data():sales_data("", 0, 0) {
}
sales_data(string s):sales_data(s, 0, 0) {
}
sales_data(istream &is):sales_data() {
};
} ;
sales_data::sales_data(istream &is):sales_data("", 0, 0)
{
read((is, *this);) // read函数是sales_data类的非成员函数接口,它的声明和定义在非成员函数模块中
}
受委托的构造函数执行完之后,接着执行委托构造函数体的内容。
构造函数定义的隐式转换规则
我们知道C++语言在内置类型之间定义了几种自动转换规则,同样的,类也有它的隐式转换规则即如果构造函数只接受一个实参,则它实际上定义了一条从构造函数的参数类型向类类型隐式转换的规则,有时候我们把这种构造函数称作转换构造函数。
例子:
string null_book = "9999999-99999-00001";
// 构造了一个临时的sale_data对象
//该对象的units_sold 和 revenue 等于0,book_no等于null_book
item.combine(null_book);
在这里我们用一个string实参调用了sales_data的combine成员,该调用是合法的。因为在sales_data类中接受string的构造函数和接受istream的构造函数分别定义了从这两种类型向sales_data隐式转换的规则,在本例中,编译器用给定的string自动创建了一个sales_data对象。新生成的这个(临时)sales_data对象被传递给combine。
只允许一步类类型转换
我们知道,编译器只会自动执行一步类类型转换,我们来看一个错误的转换示范:
// 如下的转换是错误的,因为需要用户定义的两种转换
step1 :先把"9999999-999999-0099"转换成string
step2 :再把这个(临时的)string转换成sales_data
item.combine("9999999-999999-0099");
我们可以显式的把字符串转换成string或者sales_data对象来完成上述调用
// 显式地转换成string,隐式地转换成sales_data;
item.combine(string("9999999-999999-0099"));
// 显式地转换成sales_data,隐式地转换成string;
item.combine(sales_data("9999999-999999-0099"));
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止:
// ...
{
explicit sales_data(const string &s): book_no(s) {
}
explicit sales_data(istream &);
} ;
// ...
此时,没有任何构造函数能用于隐式地创建sales_data对象,之前的两种隐式转换方式都无法通过编译。要注意的是:只能在类内声明构造函数时使用explicit关键字,在类的外部定义时,不应该重复。
explicit构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(=),此时我们只能使用直接初始化而不能使用explicit构造函数
sales_data item1(null_book); // 正确,直接初始化
sales_data item2 = null_book; // 错误不能将explicit构造函数用于拷贝形式的初始化过程
当我们用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用,而且,编译器将不会在自动转换过程中使用该构造函数。
为转换显示地使用构造函数
尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:
// 正确:实参是一个显式构造的sales_data对象
item.combine(sales_data(null_book));
// 正确:static_cast可以使用explicit的构造函数
item.combine(static_cast<sales_data>(cin));
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。
当一个类满足如下条件时,我们说它是聚合的:
例:
struct data {
int ival;
string s;
} ;
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员,初始值的顺序必须与声明的顺序一致,与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。
data val1{
0, "annnnnnn"};
显式的初始化类的对象的成员存在三个明显的缺点:
除了定义类的对象会如何初始化之外,类还需要控制拷贝,赋值和销毁对象时发生的行为。
对象会被拷贝 赋值 销毁 的几种情况:
如果我们不主动定义这些操作,编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝,赋值和销毁操作。
尽管编译器能替我们合成拷贝,赋值和销毁的操作,但是对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效,例如:管理动态内存的类通常不能依赖于上述操作
很多需要动态内存的类应该使用vector或者string对象管理必要的存储空间,使用vector或者string的类能避免分配和释放内存带来的复杂性。
通常,管理类外资源的类必须定义拷贝控制成员,为了定义这些成员,必须首先确定此类型对象的拷贝语义。
我们如何拷贝指针成员决定了像下面所示例的类具有类值行为还是类指针行为
行为像值的类
class HasPtr {
public :
HasPtr() = default;
// 动态分配了一个string类型的对象,并用p.ps指向的对象的值初始化,返回指向新分配的string对象的指针
// 因为在此处我们新分配了一个string对象,副本和原对象的底层数据并不相同,这样使得类的行为像一个值,因为副本和原对象完全是独立的,改变副本不会对原对象有任何的影响
HasPtr(const HasPtr &p) : ps(new string(*p.ps)), i(p.i) {
}
HasPtr & operator=(const HasPtr &orig);// 拷贝赋值运算符
~HasPtr() {
delete ps;}// 析构函数
HasPtr(const string &s = string()) : ps(new string(s)), i(0) {
}
private:
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;
}
行为像指针的类
定义一个使用引用计数的类
class HasPtr {
friend void swap(HasPtr &lhs, HasPtr &rhs);
public:
HasPtr(const string &s = string()) : ps(new string(s)), i(0), use(new size_t(1)){
}
HasPtr(cosnt HasPtr& p): ps(p.ps), i(p.i), use(p.use) {
++ *use;}
HasPtr& operator=(const HasPtr &);
~HasPtr();
private:
string *ps;
int i;
// 将计数器保存在动态内存中,当创建一个对象时,分配一个新的计数器,当拷贝或者赋值对象时,我们拷贝指向计数器的指针。
size_t *ues // 用来记录有多少个对象共享*ps
} ;
HasPtr::~HasPtr()
{
if(-- *use == 0) {
delete ps;
delete use;
}
}
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using st::swap;
swap(lhs.ps, rhs.ps); // 交换指针而不是string数据;
swap(lhs.i, rhs.i); // 交换int成员
}
HasPtr &HasPtr::operator=(cosnt HasPtr &rhs)
{
// 通过先递增右侧对象的计数值来处理自赋值的情况
++ *rhs.use;
if (*use == 0) {
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}
交换操作
编写自己的swap函数
swap函数应该调用sawp,而不是std::swap
如果一个类的成员有自己特定的swap函数,对该类型调用std::swap就是错误的了,如果存在特定类型的swap版本,其匹配程度会优于std中定义的版本。
类相关的非成员函数
执行加法和IO的函数不作为sales_data的成员,我们将其定义成普通函数
作为接口组成部分的非成员函数,它们的定义和声明都在类的外部;
struct sales_data {
sales_data() = default;
sales_data(const string &s) : book_no(s) {
}
sales_data(const string &s, unsigned n, double p) : book_no(s), units_sold(n),revenue(p * n) {
}
sales_data(istream &);
// 在类的内部声明成员函数,定义在外部;
string isbn() const;
sales_data& combine(const sales_data&);
double avg_price() const;
// 允许类内初始化,可以使用等号赋值或者花括号,但不能使用圆括号
string book_no;// 书籍的ISBN号
unsigned units_sold = 0; // 表示某本书的销量
double revenue = 0.0;// 表示这本书的总销售收入
} ;
// sales_data 的非成员函数接口
sales_data add(const sales_data&, const sales_data&);
ostream &print(ostream&, const sales_data&);
istream &read(istream&, sales_data&);
类的实现包括类的数据成员,负责接口实现的函数体以及定义类所需要的各种私有函数。
class sales_data {
friend sales_data add(const sales_data&, const sales_data&);
friend ostream &print(ostream&, const sales_data&);
friend istream &read(istream&, sales_data&);
public :
sales_data() = default;
sales_data(const string &s) : book_no(s) {
}
sales_data(const string &s, unsigned n, double p) : book_no(s), units_sold(n),revenue(p * n) {
}
sales_data(istream &);
// 在类的内部声明成员函数,定义在外部;
string isbn() const;
sales_data& combine(const sales_data&);
double avg_price() const;
private:
// 允许类内初始化,可以使用等号赋值或者花括号,但不能使用圆括号
string book_no;// 书籍的ISBN号
unsigned units_sold = 0; // 表示某本书的销量
double revenue = 0.0;// 表示这本书的总销售收入
} ;
// sales_data 的非成员函数接口
sales_data add(const sales_data&, const sales_data&);
ostream &print(ostream&, const sales_data&);
istream &read(istream&, sales_data&);
// 类外部定义的成员的名字必须包含它所属的类名
double sales_data::avg_price() const // 函数名sales_data::avg_price使用作用域运算符来说明如下事实:我们定义了一个名为avg_price的函数,并且该函数被声明在类sales_data的作用域内。
{
if(units_sold) {
return revenue / units_sold;
} else {
return 0;
}
}
string sales_data::isbn() const
{
return book_no;
}
sales_data& sales_data::combine(const sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
// 类相关的非成员函数
//类的作者常常需要定义一些辅助函数,尽管这些函数定义的操作从概念上来说属于类接口的组成部分, 但是它们实际上并不属于类本身;
// 如果函数在概念上属于类,但是不定义在类中,那么它一般应该与类声明在同一个头文件中,在这种方式下,用户使用接口的任何部分都只需要引入一个文件
istream &read(istream &is, sales_data &item) {
double price = 0;
is >> item.book_no >> 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;
}
// 在类的外部定义构造函数
// 以istream为参数的构造函数需要执行一些实际的操作,在它的函数体内,调用了read函数以给数据成员赋以初值:
sales_data::sales_data(istream &is)
{
read(is, *this);
}
封装实现了类的接口和实现的分离,封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分
类是如何封装的:通过关键词class和struct
为了使得类的非成员接口函数能访问到它的私有数据成员,我们将其设置为sales_data类的友元
为了使得友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,我们的sales_data头文件应该为read,print,add提供独立的声明;
class sales_data {
friend sales_data add(const sales_data&, const sales_data&);
friend ostream &print(ostream&, const sales_data&);
friend istream &read(istream&, sales_data&);
public :
sales_data() = default;
sales_data(const string &s) : book_no(s) {
}
sales_data(const string &s, unsigned n, double p) : book_no(s), units_sold(n),revenue(p * n) {
}
sales_data(istream &);
// 在类的内部声明成员函数,定义在外部;
string isbn() const;
sales_data& combine(const sales_data&);
double avg_price() const;
private:
// 允许类内初始化,可以使用等号赋值或者花括号,但不能使用圆括号
string book_no;// 书籍的ISBN号
unsigned units_sold = 0; // 表示某本书的销量
double revenue = 0.0;// 表示这本书的总销售收入
} ;
// sales_data 的非成员函数接口
sales_data add(const sales_data&, const sales_data&);
ostream &print(ostream&, const sales_data&);
istream &read(istream&, sales_data&);
类的声明
我们可以仅仅声明类而不定义类
class screen; // 类的声明
这种声明被称作前向声明,它向程序中引入了名字screen并且指明screen是一种类类型。
对于类型screen来说,它在声明之后定义之前是一个不完全类型,也就是说,我们已知screen是一个类类型,但是不清楚它到底包含哪些数据成员。
不完全类型的使用场景:
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明过,因为编译器需要了解这样的对象需要多少存储空间。
类也必须先被定义才能引用或者指针访问其成员,因为假如类尚未定义,编译器就不清楚该类到底有那些成员。
直到类被定义之后数据成员才能被声明成这种类类型(静态数据成员例外)也就是说,我们必须首先完成类的定义,然后编译器才能知道存储该数据成员需要多少空间,因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然而,一旦一个类的名字出现过之后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或者指针
类的作用域
每个类都会定义他自己的作用域,在类的作用域之外,普通的数据和函数成员只能由对象,引用或者指针使用成员运算访问符来访问类,对于类类型成员则使用作用域运算访问符。
作用域和定义在类外部的成员
名字查找与类的作用域
在一般情况下,名字查找(寻找与所用名字最匹配的声明的过程)的过程比较简单:
然而对于定义在类内部的成员函数来说,解析其中名字的方式与上述查找的规则有所区别,类的定义分为两步处理:
用于类成员声明的名字查找
这种两阶段的处理方式只适用于成员函数中使用的名字,声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过,然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义改名字:
using Money = double;
class account {
public:
Money balance() {
return bal;} // 使用外层作用域的Money
private:
using Money = double; // 错误:不能重新定义Money
Money bal;
// .....
} ;
即使account中定义的Money类型与外层作用域一致,上述代码仍然是错误的,尽管重新定义类型名字是一种错误行为,但是编译器并不为此负责,一些编译器仍然将顺利通过这样的代码,而忽略代码有错误的事实。
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类型名的定义之后。
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。
用来定义类型的成员必须先定义后使用,这一点与普通成员有所不同,原因已经在类的作用域阐明。
using pos = string::size_type;
有时候我们希望能修改类的某个数据成员,即使是在一个const成员函数内或者它是const对象的数据成员,我们通过在变量的声明中加入mutable关键字来做到这一点,我们将这样的数据成员称作可变数据成员
我们可以在类的内部把inline作为声明的一部分显示地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义,虽然我们无需在声明和定义的地方同时说明inline,但这么做其实是合法的,不过,最好只在类外部定义的地方说明inline,这样可以使得类更容易理解;
成员函数也可以被重载,只要函数之间在参数的数量或类型上有所区别就行
只能在一个常量对象上调用const成员函数;
有的时候类需要它的一些成员与类本身相关,而不是与类的各个对象保持关联
例如:一个银行账户类可能需要一个数据成员来表示当前的基准利率,在此例中我们希望利率与类关联,而非与类的每个对相关联。从实现效率的角度来看,没必要每个对象都存储利率信息。而且更加重要的是,一旦利率浮动,我们希望所有的对象都能使用新值。
我们通过在成员的声明之前加上static使得其与类关联在一起。静态成员也可以是public或者private的静态数据成员的类型通常可以是常量,引用,指针,类类型等。
例子:定义一个表示银行账户记录的类:
class account {
public :
void caculate() {
amount += amount * interest_rate; }
static double rate() {
return interest_rate; }
static void rate(double);
private:
string owner;
double amount;
static double interest_rate;
static double init_rate();
} ;
double account::interest_rate = init_rate();
void account::rate(double new_rate)
{
interest_rate = new_rate;
}
类的静态数据成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据因此,每个account对象包含两个数据成员:owner和amount,只存在一个interest_rate对象而且它被所有的account对象共享
静态成员函数也不与任何对象绑定在一起,它们不包含this指针作为结果静态成员函数不能声明成const的,而且我们也不能在static函数体内使用this指针,这一限制既适用于this的显式使用,也对调用非静态成员的隐式使用有效。
// 1
double r;
r = account::rate();
// 2
account ac1;
account *ac2 = &ac1;
auto r = ac1.rate();
r = ac2->rate();
// 3
class account {
public :
void caculate() {
amount += amount * interest_rate; }
static double rate() {
return interest_rate; }
static void rate(double);
private:
string owner;
double amount;
static double interest_rate;
static double init_rate();
} ;
和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数,要注意的是:当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句。
因为静态数据成员并不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的,这意味着它们不是由类的构造函数初始化的,而且一般来说,我们不能在类的内部初始化静态成员,必须在类的外部定义和初始化每个静态成员,和其他对象一样,一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外,因此它一旦被定义,就将一直存在于程序的整个声明周期中。
定义静态数据成员的方式和在类的外部定义成员函数差不多,需要指定对象的类型名,类名,作用域运算符以及成员自己的名字:
double account::interest_rate = init_rate();
通常情况下,类的静态成员不应该在类的内部初始化,但是我们可以为静态成员提供const整数类型的类内初始值,前提是静态成员必须是字面值常量类型的constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。
要注意的是:即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
静态数据成员可以是不完全类型
静态数据成员可以就是它所属的类类型
静态数据成员可以作为默认实参。
为了展示类的这些新的特性,我们需要定义一对相互关联的类,分别是screen类和window_mgr类。
class screen {
public :
// 通过把pos定义成public成员可以隐藏screen实现的细节,因为screen的用户不应该直到screen使用了一个string对象来存放它的数据;
using pos = string::size_type;
screen() = default;
screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) {
}
// 定义在类内部的成员函数是自动inline的
char get() const {
return contents[cursor];}// 读取光标处的字符
inline char get(pos ht, pos wd) const; // 显示内联
screen &move(pos r, pos c); // 能在之后被设为内联
void some_member() const;
private :
pos cursor = 0;// 光标的位置
pos height = 0, width = 0;// 屏幕的高和宽
string contents;// 用于保存screen内容的string
// 通过access_ctr我们追踪一个screen成员函数被调用了多少次
mutable size_t access_ctr; // 即使在一个const对象内也能被修改,const成员函数也可以改变它的值;
};
inline screen &screen::move(pos r, pos c)
{
pos row = r * width;
cursor = row + c;
return *this; // 以左值的形式返回对象
}
inline char screen::get(pos ht, pos wd) const
{
pos row = ht * width; // 计算行的字符
return contents[row + wd]; // 返回给定列的字符
}
void screen::some_member() const
{
++ access_ctr; // 保存一个计数值,用于记录该成员函数被调用的次数
........
}