第 7 章 类
7.1 定义抽象数据类型
7.1.1 设计 Sales_data类(*)
7.1.2 定义改进的 Sales_data类
struct Sales_data {
// 新成员∶关于 Sales data 对象的操作
std::string isbn() const { return this->bookNo; }
Sales_data& combine (const Sales_data&);
double avg price() const;
// 数据成员
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// Sales_data 的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales data&);
定义在类内部的函数是隐式的 inline 函数。
(1)引入 this
成员函数通过一个名为 this 的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化 this。
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为 this 所指的正是这个对象。任何对类成员的直接访问都被看作 this 的隐式引用,也就是说,当 isbn 使用 bookNo 时,它隐式地使用 this 指向的成员,就像我们书写了 this->bookNo
一样。
this 形参是隐式定义的。任何自定义名为 this 的参数或变量的行为都是非法的。我们可以在成员函数体内部使用 this。尽管没有必要,但我们还是能把 isbn 定义成如下的形式:
std::string isbn() const { return this->bookNo; }
this 是一个常量指针,我们不允许改变 this 中保存的地址。
(2)引入 const 成员函数
isbn 函数的另一个关键之处是紧随参数列表之后的 const 关键字,这里,const 的作用是修改隐式 this 指针的类型。
默认情况下,this 的类型是指向类类型非常量版本的常量指针。(在默认情况下)我们不能把 this 绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。
如果 isbn 是一个普通函数而且 this 是一个普通的指针参数,则我们应该把 this 声明成const Sales data *const
。毕竟,在 isbn 的函数体内不会改变 this 所指的对象,所以把 this 设置为指向常量的指针有助于提高函数的灵活性。
C++语言允许把 const 关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的 const 表示 this 是一个指向常量的指针。像这样使用 const 的成员函数被称作常量成员函数。
(3)类作用域和成员函数
类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域之内,因此,isbn 中用到的名字 bookNo 其实就是定义在 Sales_data 内的数据成员。
值得注意的是,即使 bookNo 定义在 isbn 之后,isbn 也还是能够使用 bookNo。其原因是,编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
7.1.3 定义类相关的非成员函数(*)
7.1.4 构造函数
struct 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_sold(n), revenue(p * n) { }
Sales_data(std::istream &);
// 之前已有的其他成员
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;
};
(1)默认构造函数
当类没有声明任何构造函数时,编译器会自动地生成默认构造函数,也叫合成的默认构造函数。其将按照以下规则初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员
- 否则,默认初始化
(2)= default
在 C++11 新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = default 来要求编译器生成构造函数。这个函数的作用完全等同于之前使用的合成默认构造函数。
如果 = default 在类的内部,则默认构造函数是内联的;
如果它在类的外部,则该成员默认情况下不是内联的。
(3)构造函数初始值列表
Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p * n) { }
当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。
通常情况下,构造函数使用类内初始值不失为一种好的选择。
7.1.5 拷贝、赋值和析构(*)
7.2 访问控制与封装
访问说明符:public、private
struct 和 class 的唯一区别: 默认访问权限不一样。struct 的默认访问权限是 public,而 class 是 private。
7.2.1 友元
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。
为了使友元对类的用户可见,通常把友元的声明与类本身放置在同一个头文件中,并提供独立的声明。
class Sales_data
{
// 为 Sales_data 的非成员函数所做的友元声明
friend Sales_data add(const Sales_data&, const Sales_data&);
friend std::istream &read(std::istream&, Sales_data&);
friend std::ostream &print(std::ostream&, const Sales_data&); // 其他成员及访问说明符与之前一致
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p * n) { }
Sales_data(const std::string &s): bookNo(s){ }
Sales_data (std::istream&);
std::string isbn() const { return bookNo; }
Sales_data &combine (const Sales_data&);
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// Sales data 接口的非成员组成部分的声明
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
7.3 类的其他特性
定义一对相互关联的类来展示新特性:Screen 和 Window_mgr。
Screen 表示显示器中的一个窗口。每个 Screen 包含一个用于保存 Screen 内容的 string 成员和三个 string::size type 类型的成员,它们分别表示光标的位置以及屏幕的高和宽。
class Screen
{
public:
typedef std::string::size_type pos; // 等价于 using pos = std::string::size_type;
Screen() = default; // 因为 Screen 有另一个构造函数,所以本函数是必需的
// cursor 被其类内初始值初始化为 0
Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents (ht * wd, c) { }
char get() const { return contents[cursor]; } // 读取光标处的字符
inline char get(pos ht, pos wd) const; // 显式内联
Screen &move (pos r, pos c); // 能在之后被设为内联
private:
pos Cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
7.3.1 类成员再探
(1)令成员作为内联函数
可以在类的外部用 inline 关键字修饰函数的定义:
inline // 在函数的定义处指定 inline
Screen &Screen::move(pos r, pos c)
{
pos row = r * width; // 计算行的位置
cursor = row + c; // 在行内将光标移动到指定的列
return *this; // 以左值的形式返回对象
}
char Screen::get(pos r, pos c) const // 在类的内部声明成 inline
{
pos row = r * width; // 计算行的位置
return contents[row + c]; // 返回给定列的字符
}
最好再类外部定义的地方说明 inline,使类更容易理解。
inline 函数也应该与相应的类定义在同一个头文件中。
(2)可变数据成员
使用关键字 mutable 声明一个可变数据成员,即使是 const 成员函数也能改变其对应的值。
class Screen
{
public:
void some_member() const;
private:
mutable size_t access_ctr; // 即使在一个const 对象内也能被修改
// 其他成员与之前的版本一致
};
void Screen::some_member() const // 保存一个计数值,用于记录成员函数被调用的次数
{
++access_ctr;
//该成员需要完成的其他工作
}
(3)类数据成员的初始值
定义一个窗口管理类 Window_mgr 来表示显示器上的一组 Screen。我们希望 Window_mgr 类开始时总是有一个默认初始化的 Screen,因此采用类内初始值的方式声明:
class Window_mgr
{
private:
// 这个 Window_mgr 追踪的 Screen
// 默认情况下,一个 Window_mgr 包含一个标准尺寸的空白 Screen
std::vector<Screen> screens{ Screen(24, 80, ' ') };
};
当我们提供一个类内初始值时,必须以符号 ‘=’ 或者花括号表示。
7.3.2 返回 *this 的成员函数
添加一些函数,负责设置光标所在位置的字符或者其他任意给定的位置的字符:
class Screen
{
public:
Screen &set(char);
Screen &set(pos, pos, char);
// 其他成员和之前的版本一致
};
inline Screen &Screen::set(char c)
{
contents[cursor] = c; // 设置当前光标所在位置的新值
return *this; // 将 this 对象作为左值返回
}
inline Screen &Screen::set(pos r, pos col, char ch)
{
contents [r * width + col]= ch; // 设置给定位置的新值
return *this; // 将 this 对象作为左值返回
}
因为返回类型为引用类型,因此可以进行下列的拼接操作:
myScreen.move(4, 0).set('#');
(1)从 const 成员函数返回 *this
添加一个 display 的操作,负责打印 Screen 的内容,令其为 const 成员函数,返回一个 const 引用。
如若此做,我们则不能将 display 嵌入到一组动作的序列中去:
myScreen.display(cout).set('*');
(2)基于 const 的重载
我们将定义一个名为 do_display 的私有成员函数,由它负责打印 Screen 的实际工作。所有的 display 操作都将调用这个函数,然后返回执行操作的对象:
class Screen
{
public:
// 根据对象是否是 const 重载了 display 函数
Screen &display(std::ostream &os) { do_display(os); return *this; }
const Screen &display(std::ostream &os) const { do_display(os); return *this; }
private:
// 该函数负责显示 Screen 的内容
void do_display(std::ostream &os) const { os<< contents; }
// 其他成员与之前的版本一致
};
当 display 的非常量版本调用 do_display 时,它的 this 指针将隐式地从指向非常量的指针转换成指向常量的指针。当 do_display 完成后,display 函数各自返回解引用 this 所得的对象。在非常量版本中,this 指向一个非常量对象,因此 display 返回一个普通的(非常量)引用;而 const 成员则返回一个常量引用。
当我们在某个对象上调用 display 时,该对象是否是 const 决定了应该调用 display 的哪个版本。
7.3.3 类类型
我们可以声明类而不定义它:class Screen;
这种声明有时被称作前向声明,它向程序中引入了名字 Screen 并且指明 Screen 是一种类类型。
对于类型 Screen 来说,在它声明之后定义之前是一个不完全类型,我们已知 Screen 是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的情境下使用:
- 定义指向这种类型的指针或引用
- 声明(不能定义)以其作为参数或者返回类型的函数
7.3.4 友元再探
(1)类之间的友元关系
假设我们需要为 Window_mgr 添加一个名为 clear 的成员,它负责把一个指定的 Screen 的内容都设为空白。为了完成这一任务,clear 需要访问 Screen 的私有成员:
class Window_mgr
{
public:
// 窗口中每个屏幕的编号
using ScreenIndex = std::vector<Screen>::size_type;
// 按照编号将指定的 Screen 重置为空白
void clear(ScreenIndex);
private:
std::vector<Screen> screens{ Screen(24, 80, '') };
};
void window_mgr::clear(ScreenIndex i)
{
// s 是一个 Screen 的引用,指向我们想清空的那个屏幕
Screen &s = screens [i];
// 将那个选定的 Screen 重置为空白
s.contents = string(s.height * s.width, '');
}
(2)令成员函数作为友元(*)
7.4 类的作用域
7.4.1 名字查找与类的作用域
名字查找(寻找与所用名字最匹配的声明)的过程:
- 在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明
- 如果没找到,继续查找外层作用域
- 最终没找到匹配的声明,则程序报错
类的定义分两步处理:
- 首先,编译成员的声明
- 直到类全部可见后才编译函数体
(1)用于声明类成员的名字查找
typedef double Money;
string bal;
class Account
{
public:
Money balance() { return bal; }
private:
Money bal;
// ...
};
当编译器看到 balance 函数的声明语句时,它将在 Account 类的范围内寻找对 Money 的声明。编译器只考虑 Account 中在使用 Money 前出现的声明,因为没找到匹配的成员,所以编译器会接着到 Account 的外层作用域中查找。在这个例子中,编译器会找到 Money 的 typedef 语句,该类型被用作 balance 函数的返回类型以及数据成员 bal 的类型。另一方面,balance 函数体在整个类可见后才被处理,因此,该函数的 return 语句返回名为 bal 的成员,而非外层作用域的 string 对象。
(2)类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。
然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:
typedef double Money;
class Account
{
public:
Money balance() { return bal; } // 使用外层作用域的 Money
private:
typedef double Money; // 错误:不能重新定义 Money
Money bal;
// ...
};
(3)成员定义中的普通快作用域的名字查找
成员函数中使用的名字按照如下方式解析∶
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
(4)类作用域之后,在外围的作用域查找
如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。在我们的例子中,名字 height 定义在外层作用域中,且位于 screen 的定义之前。然而,外层作用域中的对象被名为 height 的成员隐藏掉了。因此,如果我们需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求:
// 不建议的写法∶不要隐藏外层作用域中可能被用到的名字
void Screen::dummy_fcn (pos height)
{
cursor = width * ::height; // 哪个 height?是那个全局的
}
7.5 构造函数再探
7.5.1 构造函数初始值列表
(1)构造函数的初始值有时必不可少
如果成员是 const 或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。例如∶
class ConstRef
{
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
和其他常量对象或者引用一样,成员 ci 和 ri 都必须被初始化。因此,如果我们没有为它们提供构造函数初始值的话将引发错误∶
// 错误∶ ci和ri必须被初始化
ConstRef::ConstRef(int ii)
{
// 赋值∶
i= ii; // 正确
ci = ii; // 错误∶不能给 const赋值
ri= i; // 错误∶ ri没被初始化
}
随着构造函数体一开始执行,初始化就完成了。我们初始化 const 或者引用类型的数据成员的唯一机会就是通过构造函数初始值,因此该构造函数的正确形式应该是:
// 正确∶ 显式地初始化引用和const 成员
ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(i) { }
建议养成使用构造函数初始值的习惯。
(2)成员初始化的顺序
成员的初始化顺序与它们在类定义中的出现顺序一致;第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
class X
{
int i;
int j;
public:
// 未定义的,i 在 j 之前被初始化
X(int val) : j(val), i(j) { }
};
在此例中,从构造函数初始值的形式上来看仿佛是先用 va1 初始化了 j,然后再用 j 初始化 i。实际上,i 先被初始化,因此这个初始值的效果是试图使用未定义的值 j 初始化i!
尽量使用构造函数的参数作为成员的初始值,例如:
X(int val) : j(val), i(val) { }
这样 i 和 j顺序就没有什么影响了。
(3)默认实参和构造函数
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
7.5.2 委托构造函数
class Sales_data
{
public:
// 非委托构造函数使用对应的实参初始化成员
Sales_data(std::string s, unsigned cnt, double price) : bookNo(s), units_sold(cnt), revenue(cnt * price) { }
// 其余构造函数全都委托给另一个构造函数
Sales_data() : Sales_data("", 0, 0) { }
Sales_data(std::string s) : Sales_data(s, 0, 0) { }
Sales_data(std::istream &is) : Sales_data() { read(is, *this); }
// 其他成员与之前的版本一致
}
7.5.3 默认构造函数的作用(*)
7.5.4 隐式的类类型转换(转换构造函数)
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制。我们把这种构造函数称为转换构造函数。
(1)只允许一步类类型转换
编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以它是错误的∶
// 错误:需要用户定义的两种转换:
//(1)把"9-999-99999-9"转换成string
//(2)再把这个(临时的)string转换成 Sales_data
item.combine ("9-999-99999-9");
如果我们想完成上述调用,可以显式地把字符串转换成 string 或者 Sales data对象∶
// 正确:显式地转换成 string,隐式地转换成 Sales_data
item.combine(string("9-999-99999-9"));
// 正确:隐式地转换成 string,显式地转换成 Sales_data
item.combine(Sales_data("9-999-99999-9"));
(2)抑制构造函数定义的隐式转换
使用关键字 explicit 来阻止隐式转换。
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用 explicit 构造函数:
// 正确:直接初始化
Sales_data item1(null_book);
// 错误:不能将 explicit 构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;
(3)标准库中的转换构造函数
- const char*
->
string:不是 explicit - 单容量参数
->
vector:是 explicit
7.5.5 聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是 public 的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有virtual 函数。
struct Data
{
int ival;
string s;
}
可以使用花括号来初始化聚合类:
Data val1 = { 0, "Anna" };
7.5.6 字面值常量类
在 C++ 中,类也可以是字面值类型。和其他类不同,字面值类型的类可能含有 constexpr 函数成员。这样的成员必须符合 constexpr 函数的所有要求,它们是隐式 const 的。
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类∶
- 数据成员都必须是字面值类型。
- 类必须至少含有一个 constexpr 构造函数。
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达
式;或者如果成员属于某种类类型,则初始值必须使用成员自己的 constexpr 构造函数。 - 类必须使用析构函数的默认定义,该成员负责销毁类的对象。
(1)constexpr 构造函数
一个字面值常量类必须至少提供一个 constexpr 构造函数。其要么声明成 = default,要么函数体为空。
class Debug
{
public:
constexpr Debug(bool b = true) : hw(b), io(b), other(b) { }
constexpr Debug(bool h, bool i, bool o) : hw (h), io(i), other(o) { }
constexpr bool any() { return hw || io || other; }
void set_io(bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bool b) { hw = b; }
private:
bool hw; // 硬件错误,而非 IO 错误
bool io; // IO 错误
bool other; // 其他错误
};
7.6 类的静态成员
class Account
{
public:
void calculate() { amount += amount * interestRate; }
static double rate() { return interestRate; }
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};
(1)使用类的静态成员
- 使用作用域运算符访问静态成员:
double r;
r = Account::rate();
- 可以使用类的对象、引用或者指针访问静态成员:
Account ac1;
Account *ac2 = &ac1;
// 调用静态成员函数 rate() 的等价形式
r = ac1.rate();
r = ac2->rate();
- 成员函数能直接访问静态成员。
(2)定义静态成员
static 关键字只出现在类内部的声明语句中,在类外部定义静态成员时,不能重复出现 static。
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。
double Account::interestRate = initRate(); // 类外部初始化静态成员
从类名开始,这条定义语句的剩余部分就都位于类的作用域内了。尽管 initRate 函数是 private 的,也能使用它进行初始化。
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
(3)静态成员的类内初始化
可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr。
如果在类的内部提供了一个初始值,则成员的定义就不能在指定一个初始值了。
即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
(4)某些特殊场景的应用
- 静态数据成员可以是不完全类型。特别的,可以就是他所属类的类型。
- 可以使用静态成员作为默认实参,而普通成员不可以。