七、类
在 C++
语言中,我们使用类定义自己的数据类型。通过定义新的类型来反映待解决问题中的各种概念,可以使我们更容易编写、调试和修改程序。
类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
7.1 定义抽象数据类型
7.1.1 设计 Sales_data 类
Sales_data
的接口应该包含以下操作:
- 一个
isbn
成员函数,用于返回对象的ISBN
编号 - 一个
combine
成员函数,用于将一个Sales_data
对象加到另一个对象上 - 一个名为
add
的函数,执行两个Sales_data
对象的加法 - 一个
read
函数,将数据从istream
读入到Sales_data
对象中 - 一个
print
函数,将Sales_data
对象的值输出到ostream
7.1.2 定义改进的 Sales_data 类
定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,它们的定义和声明都在类的外部。
struct Sales_data {
// 成员函数
std::string isbn() const {
return bookNo;
}
Sales_data &(const Sales_data &);
double avg_price() const;
// 数据成员
std::string bookNo;
unsigned units_sold = 0;
double revenue = 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 &);
注:定义在类内部的函数是隐式的 inline
函数
-
定义成员函数:尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。如
Sales_data
类的isbn
成员函数。关于isbn
函数一件有意思的事情是:它是如何获得bookNo
成员所依赖的对象的呢? -
引入
this
:int main() { Sales_data total; if (read(std::cin, total)) { Sales_data trans; while (read(std::cin, trans)) { if (total.isbn() == trans.isbn()) { total.combine(trans); } else { print(std::cout, total) << std::endl; total = trans; } } print(std::cout, total) << std::endl; } else { std::cerr << "No data?!" << std::endl; } return EXIT_SUCCESS; }
在如上代码中,使用了点运算符来访问
total
对象的isbn
成员,然后调用它。成员函数通过一个名为this
的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this
。也就是说,哪个对象调用成员函数,this
就指向该对象(谁调用指向谁)。在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为
this
所指的正是这个对象。任何对类成员的直接访问都被看作this
的隐式调用。也就是说,当isbn
使用bookNo
时,它隐私地使用this
指向的成员,就像我们书写了this->bookNo
一样。对于我们来说,
this
形参是隐式定义的。实际上,任何自定义名为this
的参数或变量的行为都是非法的。尽管没有必要,我们仍然可以在成员函数体内部显式地使用this
。struct Sales_data { std::string isbn() const { return this->bookNo; } }
因为
this
的目的总是指向 “这个” 对象,所以this
是一个常量指针,不允许改变this
中保存的地址。 -
引入
const
成员函数:isbn
函数的另一个关键之处是紧随参数列表之后的const
关键字,这里,const
的作用是修改隐式this
指针的类型。默认情况下,
this
的类型是指向类类型非常量版本的常量指针。例如在Sales_data
成员函数中,this
的类型是Sales_data *const
。尽管this
是隐式的,但它仍然需要遵循初始化规则。意味着(默认情况下)我们不能把this
绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。struct Sales_data { std::string isbn() { return bookNo; } std::string bookNo; }; int main() { const Sales_data item; item.isbn(); // 'this' argument to member function 'isbn' has type 'const Sales_data', but function is not marked const }
如果
isbn
是一个普通函数而且this
是一个普通的指针参数,则我们应该把this
声明成const Sales_data *const
。毕竟,在isbn
的函数体内不会改变this
所指的对象,所以把this
设置为指向常量的指针有助于提高函数的灵活性。然而,
this
是隐式的并且不会出现在参数列表中,所以在哪儿将this
声明成指向常量的指针就成为我们必须面对的问题。C++
语言的做法是允许把const
关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const
表示this
是一个指向常量的指针。像这样使用const
的成员函数被称作常量成员函数。注:常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
-
类作用域和成员函数:类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域之内,因此
isbn
中用到的名字bookNo
其实就是定义在Sales_data
内的数据成员。值得注意的是,即使
bookNo
定义在isbn
之后,isbn
也还是能够使用bookNo
。因为编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体。因此,成员函数体可以任意使用类中的其他成员而无须在意这些成员出现的次序。 -
在类的外部定义成员函数:类外部定义的成员的名字必须包含它所属的类名:
double Sales_data::avg_price() const { if (units_sold) { return revenue / units_sold; } return 0; }
-
定义一个返回
this
对象的函数:Sales_data &Sales_data::combine(const Sales_data &data) { units_sold += data.units_sold; revenue += data.revenue; // return 语句解引用 this 指针以获得执行该函数的对象 return *this; // 返回调用函数 combine 的对象 }
7.1.3 定义类相关的非成员函数
通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不在类中,则它一般应与类的声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需引入一个文件。
-
定义
read
和print
函数:print
函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。std::istream &read(std::istream &is, Sales_data &item) { double price = 0; is >> item.bookNo >> item.units_sold >> price; item.revenue = price * item.units_sold; return is; } std::ostream &print(std::ostream &os, const Sales_data &item) { os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price(); return os; }
-
定义
add
函数:默认情况下,拷贝类的对象其实拷贝的是对象的数据成员Sales_data add(const Sales_data &lsd, const Sales_data &rsd) { Sales_data sum = lsd; // 把 lsd 的数据成员拷贝给 sum sum.combine(rsd); // 把 rsd 的数据成员的加到 sum 当中 return sum; }
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的普通函数。
不同于其他成员函数,构造函数不能被声明成 const
的。当我们创建类的一个 const
对象时,直到构造函数完成初始化过程,对象才能真正取得其常量属性。因此,构造函数在 const
对象的构造过程中可以向其写值。
-
合成的默认构造函数:类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数。默认构造函数无须任何实参。
默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
编译器创建的构造函数又被称为合成的默认构造函数。对于大多数类来说,这个合成的默认构造函数按照如下规则初始化类的数据成员:
- 如果存在类的初始值,用它来初始化成员
- 否则,默认初始化该成员
-
某些类不能依赖于合成的默认构造函数:合成的默认构造函数只适合非常简单的类。对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:
- 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,除非再人为定义一个默认的构造函数,否则该类将没有默认构造函数
- 对于某些类来说,合成的默认构造函数可能执行错误的操作。因为执行默认初始化的内置类型成员,他们的值将是未定义的
- 有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员
-
定义
Sales_data
的构造函数: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; };
-
= default
的含义:默认构造函数Sales_data() = default;
不接受任何实参,定义该构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。在
C++11
新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上= default
来要求编译器生成构造函数。其中,= default
既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= 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) {}
这两个定义中出现了新的部分,即冒号以及冒号和花括号之间的代码,其中花括号定义了空的函数体(因为这些构造函数的目的就是为数据成员赋初值,没有其他任务需要执行)。我们把新出现的部分称为构造函数的初始值列表。它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。
当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。在此例中,被忽略的数据成员
units_sold
和revenue
使用类内初始值初始化。通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
构造函数不应该轻易覆盖掉类内初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
-
在类的外部定义构造函数:
Sales_data::Sales_data(std::istream &is) { read(is, *this); }
这个构造函数初始值列表是空的。尽管构造函数初始值列表是空的,但是由于执行了构造函数体,所以对象成员仍然能被初始化。
7.1.5 拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等。当我们使用了赋值运算符时会发生对象的赋值操作。当对象不在存在时执行销毁的操作,比如一个局部对象会在创建它的块结束时被销毁,当 vector
对象(或者数组)销毁时存储在其中的对象也会被销毁。
如果我们不主动定义这些操作,则编译器将替我们合成他们。一般来说,编译器生成的版本对对象的每个成员执行拷贝、赋值和销毁操作。
-
某些类不能依赖于合成的版本:当类需要分配类对象之外的资源时,合成的版本常常会失效。不过值得注意的是,很多需要动态内存的类能(而且应该)使用
vector
对象或者string
对象管理必要的存储空间。使用vector
或者string
的类能避免分配和释放内存带来的复杂性。进一步讲,如果类包含
vector
或者string
成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector
对象的成员执行拷贝或者赋值操作时,vector
类会设法拷贝或者赋值成员中的元素。当这样的对象被销毁时,将销毁vector
对象,也就是依次销毁vector
中的每一个元素。这一点与string
是非常类似的。
7.2 访问控制与封装
在 C++
语言中,我们使用访问说明符加强类的封装性:
- 定义在
public
说明符之后的成员在整个程序内可被访问,public
成员定义类的接口 - 定义在
private
说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private
部分封装了类的实现细节
再一次定义 Sales_data
类,其新形式如下所示:
class Sales_data {
public:
// 构造函数
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 &);
private:
// 成员函数
double avg_price() const {
return units_sold ? revenue / units_sold : 0;
}
// 数据成员
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0;
};
一个类可以包含 0
个或多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾处为止。
-
使用
class
或struct
关键字:class
和struct
唯一的一点区别是二者的默认访问权限不同。类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。如果我们使用
struct
关键字,则定义在第一个访问说明符之前的成员是public
的;相反,如果我们使用class
关键字,则这些成员是private
的。出于统一编程风格的考虑,当我们希望定义类的所有成员是
public
的时,使用struct
;反之使用class
。
7.2.1 友元
既然使用 class
关键字定义的 Sales_data
类的数据成员是 private
的,我们的 read
、print
和 add
函数也就无法正常编译了,这是因为这几个函数是类的接口的一部分,但它们不是类的成员。
类可以允许其他类或者函数访问它的非公有成员,方式是令其他类或者函数成为它的友元。如果类想把一个函数作为它的友元,只需要增加一条以 friend
关键字开头的函数声明语句即可:
class Sales_data {
// 友元函数
friend Sales_data add(const Sales_data &, const Sales_data &);
friend std::ostream &print(std::ostream &, const Sales_data &);
friend std::istream &read(std::istream &, Sales_data &);
public:
// 构造函数
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 &);
private:
// 成员函数
double avg_price() const {
return units_sold ? revenue / units_sold : 0;
}
// 数据成员
std::string bookNo;
unsigned units_sold = 0;
double revenue = 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 &);
友元声明只能出现在类定义的内部,但是在类内的出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。一般来说,最好在类定义开始或结束前的位置集中声明友元。
封装有两个重要的优点:
- 确保用户代码不会无意间破坏封装对象的状态
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码
注:尽管当类的定义发生改变时无须更改用户代码,但是使用了该类的源文件必须重新编译
-
友元的声明:友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们必须在友元声明之外再专门对函数进行一次声明。
为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。
7.3 类的其他特性
在本节中,我们将继续介绍 Sales_data
类没有体现出来的一些类的特性。这些特性包括:类型成员、类的成员的类内初始值、可变数据成员、内联成员函数、从成员函数返回 *this
、关于如何定义并使用类类型以及友元类的更多知识。
7.3.1 类成员再探
-
定义一个类型成员:除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是
public
或者private
中的一种:class Screen { public: // 等价的 pos 定义:using pos = std::string::size_type; typedef std::string::size_type pos; private: pos cursor = 0; pos height = 0, width = 0; std::string contents; };
用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别。因此,类型成员通常出现在类开始的地方。
-
Screen
类的成员函数:class Screen { public: // 类型成员 typedef std::string::size_type pos; // 构造函数 Screen() = default; Screen(pos height, pos width, char c) : height(height), width(width), contents(height * width, c) {} // 成员函数 char get() const { return contents[cursor]; } // 隐式内联:读取光标处所在的字符 inline char get(pos height, pos width) const; // 显式内联 Screen &move(pos row, pos col); // 能在之后被设为内联 private: // 数据成员 pos cursor = 0; pos height = 0, width = 0; std::string contents; };
-
令成员作为内联函数:在类中,常有一些规模较小的函数适合于被声明成内联函数。定义在类内部的成员函数是自动
inline
的。我们可以在类的内部把inline
作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline
关键字修饰函数的定义:// 在类的内部声明成 inline char Screen::get(pos row, pos col) const { pos row_pos = row * width; return contents[row_pos + col]; } // 在函数定义处指定 inline inline Screen &Screen::move(Screen::pos row, Screen::pos col) { pos row_pos = row * width; cursor = row_pos + col; return *this; }
虽然我们无须在声明和定义的地方同时说明
inline
,但这么做其实是合法的。不过,最好只在类外部定义的地方说明inline
,这样可以使类更容易理解。和我们在头文件中定义
inline
函数的原因一样,inline
成员函数也应该与相应的类定义在同一个头文件中。 -
重载成员函数:和非成员函数一样,成员函数也可以被重载。如
Screen
类中的get
成员函数:int main() { Screen screen; char ch = screen.get(); ch = screen.get(10, 10); }
-
可变数据成员:有时会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个
const
成员函数内。可以通过在变量的声明中加入mutable
关键字做到这一点。一个可变数据成员永远不会是
const
,即使它是const
对象的成员。因此,一个const
成员函数可以改变一个可变成员的值。例如,我们给Screen
类添加一个名为access_ctr
的可变成员,通过它我们可以追踪每个Screen
的成员函数被调用了多少次:class Screen { public: void some_member() const; private: // 即使在一个 const 对象内也能被修改 mutable size_t access_ctr; }; void Screen::some_member() const { ++access_ctr; }
-
类数据成员的初始值:在
C++11
新标准中,最好的初始化类数据成员的初始值的方式就是把这个默认值声明成一个类内初始值。当我们提供一个类内初始值时,必须以符号=
或者花括号表示:class Window_mgr { private: // 默认情况下,一个 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 ch) {
contents[cursor] = ch;
return *this;
}
// 设置给定位置的新值
inline Screen &Screen::set(Screen::pos row, Screen::pos col, char ch) {
contents[row * width + col] = ch;
return *this;
}
和 move
操作一样,我们的 set
成员的返回值是调用 set
的对象的引用。返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。如果我们把一系列这样的操作连接在一条表达式中的话,这些操作将在同一个对象上执行:
int main() {
Screen screen;
screen.move(4, 0).set('#');
}
-
从
const
成员函数返回*this
:接下来,我们继续添加一个名为display
的操作,它负责打印Screen
的内容。我们希望这个函数能和move
和set
出现在同一序列中链式调用,因此类似于move
和set
,display
函数也应该返回执行它的对象的引用。从逻辑上来说,显示一个
Screen
并不需要改变它的内容,因此我们令display
为一个const
成员,此时,this
将是一个指向const
的指针而*this
是const
对象。由此推断,display
的返回类型应该是const Screen&
。然而,如果真的令display
返回一个const
的引用,则我们不能把display
嵌入到一组动作的序列中去:class Screen { public: const Screen &display(std::ostream &) const; }; const Screen &Screen::display(std::ostream &out) const { out << contents << std::endl; return *this; } int main() { const Screen screen(10, 10, '*'); // 编译错误:'this' argument to member function 'move' has type 'const Screen', but function is not marked const screen.display(std::cout).move(4, 0); }
即使
screen
是个非常量对象,对set
的调用也无法通过编译。问题在于display
的const
版本返回的是常量引用,而显然我们无权set
一个常量对象。int main() { Screen screen(10, 10, '*'); // 编译错误:No matching member function for call to 'set' screen.display(std::cout).set('#'); }
注:一个
const
成员函数如果以引用的形式返回*this
,那么它的返回类型将是常量引用。 -
基于
const
的重载:通过区分成员函数是否是const
的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向const
而重载函数的原因差不多。具体说来,因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const
成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配:class Screen { public: Screen &display(std::ostream &os) { do_display(os); return *this; } const Screen &display(std::ostream &os) const { do_display(os); return *this; } private: void do_display(std::ostream &os) const { os << contents; } } int main() { // 调用非常量版本 Screen s1(5, 5, '#'); s1.display(std::cout).set('*').display(std::cout); // 调用常量版本 const Screen s2(10, 10, '*'); s2.display(std::cout); }
7.3.3 类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是不同的类型。
我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,我们也可以把类型跟在关键字 class
或 struct
后面:
int main() {
Screen s1;
class Screen s2; // 从 C 语言继承而来
}
-
类的声明:就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:
class Screen;
这种声明有时被称作前向声明,它向程序中引入了名字
Screen
并且指明Screen
是一种类类型。对于类型Screen
来说,在它声明之后引入定义之前是一个不完全类型,也就是说,此时我们已知Screen
是一个类类型,但是不清楚它到底包含哪些成员。不完全类型只能在非常有限的情境下使用:可以定义指向这种类型的指针或引用,也可以声明以不完全类型作为参数或者返回类型的函数。
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则,编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或指针访问其成员。
在
7.6
节中我们将描述一种例外的情况:直到类被定义之后数据成员才能被声明成这种类类型。换句话说,我们必须首先完成类的定义,然后编译器才能知道存储该数据成员需要多少空间。因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:class Link_Screen { Screen window; Link_Screen *next; Link_Screen *prev; };
class Link_Screen { Screen window; // 编译错误:Field has incomplete type 'Link_Screen' Link_Screen next; Link_Screen prev; };
7.3.4 友元再探
之前的 Sales_data
类把三个普通的非成员函数定义成了友元。类还可以把其他的类定义为友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
-
类之间的友元关系:如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员
class Screen { // Window_mgr 的成员可以访问 Screen 类的私有部分 friend class Window_mgr; } class Window_mgr { public: // 窗口中每个屏幕的编号 using ScreenIndex = std::vector<Screen>::size_type; // 按照编号将指定的 Screen 重置为空白 void clear(ScreenIndex); private: // 默认情况下,一个 Window_mgr 包含一个标准尺寸的空白 Screen std::vector<Screen> screens{Screen(24, 80, ' ')}; }; void Window_mgr::clear(Window_mgr::ScreenIndex index) { Screen &s = screens[index]; s.contents = std::string(s.height * s.width, ' '); }
友元关系不存在传递性。也就是说,如果
Window_mgr
有它自己的友元,则这些友元并不能理所当然地具有访问Screen
的特权。 -
令成员函数作为友元:
class Screen; class Window_mgr { public: using ScreenIndex = std::vector<Screen>::size_type; void clear(ScreenIndex); private: std::vector<Screen> screens{}; }; class Screen { // 成员函数作友元 friend void Window_mgr::clear(Window_mgr::ScreenIndex index); } void Window_mgr::clear(Window_mgr::ScreenIndex index) { Screen &s = screens[index]; s.contents = std::string(s.height * s.width, ' '); }
-
函数重载与友元:尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。
-
友元声明和作用域:类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。
甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:
struct X { friend void f() {/* 友元函数可以定义在类的内部 */} X() { f(); } // 编译错误:f 还没有被声明 void g(); void h(); }; void X::g() { return f(); // 编译错误:f 还没有被声明 } void f(); void X::h() { return f(); // 正确:现在 f 的声明在作用域中了 }
关于这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的函数声明。需要之处的是,有的编译器并不强制执行上述关于友元的限定规则。
7.4 类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域访问运算符访问。不论哪种情况,跟在运算符之后的名字都必须是对应类的成员:
int main() {
Screen::pos height = 24, width = 80;
Screen s(width, height, ' ');
Screen *p = &s;
char ch = s.get();
ch = p->get();
}
-
作用域和定义在类外部的成员:一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,成员的名字被隐藏起来了。
一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余的部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无须再次授权。
例如,参见
Window_mgr
类的clear
成员,其可直接使用ScreenIndex
成员而无须声明所在的类:void Window_mgr::clear(ScreenIndex index) { Screen &s = screens[index]; s.contents = std::string(s.height * s.width, ' '); }
需要注意的是,函数的返回类型通常出现在函数名之前,此时返回类型必须指明它是哪个类的成员:
Window_mgr::ScreenIndex Window_mgr::add_screen(const Screen &s) { screens.push_back(s); return screens.size() - 1; }
7.4.1 名字查找与类的作用域
截至目前,我们编写的程序中,名字查找(寻找与所用名字最匹配的声明的过程)的过程比较直截了当:
- 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明
- 如果没找到,继续查找外层作用域
- 如果最终没有找到匹配的声明,则程序报错
对于定义在类内部的成员函数来说,解析其中的名字的方式与上述的查找规则有所区别。类的定义分两步处理:
- 首先,编译成员的声明
- 直到类全部可见后才编译函数体
按照这种两阶段的方式处理类可以简化类代码的组织方式。因为如果成员函数体直到整个类可见后才被处理,所以它能使用类中定义的任何名字。
-
用于类成员声明的名字查找:类这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。
typedef double Money; std::string bal; class Account { public: Money balance() const { return bal; } private: Money bal; };
-
类型名要特殊处理:一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:
typedef double Money; std::string bal; class Account { public: Money balance() const { // 使用外层作用域中的 Money return bal; } private: typedef double Money; // 编译错误:不能重新定义 Money Money bal; };
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
-
成员定义中的普通块作用域的名字查找:成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找
-
类作用域之后,在外围的作用域中查找:如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找
int height; class Screen { public: typedef std::string::size_type pos; void dummy_func(pos height) { cursor = width * height; // 参数 height cursor = width * this->height; // 成员 height cursor = width * Screen::height; // 成员 height cursor = width * ::height; // 外围 height } private: pos cursor = 0; pos height = 0, width = 0; };
-
在文件中名字的出现处对其进行解析:当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明:
int height; class Screen { public: typedef std::string::size_type pos; void setHeight(pos); pos height = 0; // 隐藏了顶级作用域中的 height }; Screen::pos verify(Screen::pos); void Screen::setHeight(Screen::pos h) { // h: 参数 // height:类的成员 // verify: 全局函数 height = verify(h); }
需要注意,全局函数
verify
的声明在Screen
类的定义之前是不可见的。然而,名字查找的第三步包括了成员函数出现之前的全局作用域。在此例中,verify
的声明位于setHeight
的定义之前,因此可以被正常使用。
7.5 构造函数再探
7.5.1 构造函数初始值列表
如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。
// Sales_data 构造函数的一种写法,虽然合法但不推荐:未使用构造函数初始值
Sales_data::Sales_data(const std::string &s, unsigned int cnt, double price) {
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}
如上代码,在执行构造函数体之前,类数据成员已完成默认初始化,而后对其进行赋值操作,此方式不如使用构造函数初始值列表效率高,并且这一改变会有什么深层次的影响完全依赖于数据成员的类型。
-
构造函数的初始值有时必不可少:如果成员是
const
或者引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化:class ConstRef { public: ConstRef(int ii); private: int i; const int ci; int &ri; }; // 编译错误:Constructor for 'ConstRef' must explicitly initialize the const member 'ci' ConstRef::ConstRef(int ii) { i = ii; ci == ii; // 错误:不能给 const 赋值,必须在构造函数初始值列表初始化 ri = i; // 错误:ri 没被初始化 } // 编译正确 ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(ii) {}
使用构造函数初始值:在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。
-
成员初始化的顺序:构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序:
class X { int i; int j; public: // 编译警告:Field 'j' is uninitialized when used here X(int val) : j(val), i(j) {} };
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
-
默认实参和构造函数:前文所述的
Sales_data
类的默认构造函数的行为与只接受一个string
实参的构造函数差不多,唯一的区别是接受string
实参的构造函数使用这个实参初始化bookNo
,而默认构造函数隐式地使用string
的默认构造函数初始化bookNo
。因此我们可以把它合并重写为一个使用默认实参的构造函数,如下所示:class Sales_data { public: Sales_data(std::string s = "") : bookNo(s) {} private: std::string bookNo; };
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
7.5.2 委托构造函数
C++11
新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数使用它属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
和其他函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
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() {}
private:
std::string bookNo;
unsigned units_sold;
double revenue;
};
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在 Sales_data
类中,受委托的构造函数体恰好时空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。
7.5.3 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。
默认初始化在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时
值初始化在以下情况下发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时
- 当我们不使用初始值定义一个局部静态变量时
- 当我们通过书写形如
T()
的表达式显式地请求值初始化时,其中T
是类型名(vector
的一个构造函数只接受一个实参用于说明vector
的大小,它就是使用一个这种形式的实参对它的元素初始化器进行值初始化)
class NoDefault {
public:
NoDefault(const std::string &);
};
struct A {
NoDefault a_mem;
};
A a; // 编译错误:Call to implicitly-deleted default constructor of 'A'
struct B {
B() {} // 编译错误:Constructor for 'B' must explicitly initialize the member 'b_mem' which does not have a default constructor
NoDefault b_mem;
};
类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。在实际中,如果定义了其他默认构造函数,那么最好也提供一个默认构造函数。
-
使用默认构造函数:如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对:
class Sales_data { }; // 正确:obj 是一个默认初始化的对象 Sales_data obj; // 错误:声明了一个函数而非对象 Sales_data func();
7.5.4 隐式的类类型转换
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换规则,有时我们把这种构造函数称作转换构造函数。也即能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。
在 Sales_data
类中,接受 string
的构造函数和接受 istream
的构造函数分别定义了从这两种类型向 Sales_data
隐式转的规则。也就是说,在需要使用 Sales_data
的地方,我们可以使用 string
或者 istream
作为替代:
class Sales_data {
public:
// 构造函数
Sales_data(std::string &) {}
Sales_data(std::istream &) {}
// 成员函数
Sales_data &combine(const Sales_data &);
};
int main() {
std::string nullBook = "241131048";
Sales_data salesData(nullBook);
salesData.combine(nullBook); // 隐式的 string -> Sales_data
salesData.combine(std::cin); // 隐式的 std::cin > Sales_data
}
-
只允许一步类类型转换:编译器只会自动地执行一步类型转换
Sales_data salesData("2411131036"); // 错误:隐式地使用了两种转换规则: // 1. "2411131041" -> std::string // 2. std::string -> Sales_data salesData.combine("2411131041");
-
类类型转换不是总有效:是否需要从
string
到Sales_data
的转换依赖于我们对用户使用该转换的看法。在此例中,这种转换可能是对的。nullBook
中的string
可能表示了一个不存在的ISBN
编号。另一个是从
istream
到Sales_data
的转换,这个转换接受一个istream
的Sales_data
构造函数,该构造函数通过读取标准输入创建了一个临时的Sales_data
对象,随后将得到的对象传递给combine
。Sales_data
对象是个临时量,一旦combine
完成我们就不能再访问它了。实际上,我们创建了一个对象,先将它的值加到item
上,随后将其丢弃。 -
抑制构造函数定义的隐式转换:在不需要隐式转换的程序上下文中,我们可以通过将构造函数声明为
explicit
加以阻止:class Sales_data { public: // 构造函数 explicit Sales_data(std::string &) {} explicit Sales_data(std::istream &) {} // 成员函数 Sales_data &combine(const Sales_data &); }; int main() { std::string nullBook = "241131048"; Sales_data salesData(nullBook); salesData.combine(nullBook); // 编译错误:No viable conversion from 'std::string' (aka 'basic_string<char>') to 'const Sales_data' salesData.combine(std::cin); // 编译错误:No viable conversion from 'std::istream' (aka 'basic_istream<char>') to 'const Sales_data' }
关键字
explicit
只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit
。只能在类内声明构造函数时使用explicit
关键字,在类外部定义时不应重复。 -
explicit
构造函数只能用于直接初始化:发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=
)。此时,我们只能使用直接初始化而不能使用explicit
构造函数:class Sales_data { public: explicit Sales_data(std::string &) {} }; int main() { std::string nullBook = "241131048"; Sales_data item(nullBook); // 正确:直接初始化 Sales_data item2 = nullBook; // 错误:不能将 explicit 构造函数用于拷贝形式的初始化过程 }
当我们用
explicit
关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不能在自动转换过程中使用该构造函数。 -
为转换显式地使用构造函数:尽管编译器不会将
explicit
的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:class Sales_data { public: // 构造函数 explicit Sales_data(std::string &) {} explicit Sales_data(std::istream &) {} // 成员函数 Sales_data &combine(const Sales_data &); }; int main() { std::string nullBook = "241131048"; Sales_data salesData(nullBook); salesData.combine(Sales_data(nullBook)); salesData.combine(static_cast<Sales_data>(std::cin)); }
-
标准库中含有显式构造函数的类:
- 接受一个单参数的
const char*
的string
构造函数不是explicit
的 - 接受一个容量参数
vector
构造函数是explicit
的
- 接受一个单参数的
7.5.5 聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是
public
的 - 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有虚函数
我们可以提供一个花括号括起来的成员初始值列表来初始化聚合类的数据成员,需注意初始值的顺序必须与声明的顺序一致:
struct Data {
int iVal;
std::string s;
};
int main() {
Data d = {0, "Anna"};
}
与初始化数组元素的规则一致,如果初始值列表中元素个数少于类的成员数量,则靠后的成员被值初始化。
7.5.6 字面值常量类
6.5.2
节提到过 constexpr
函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有 constexpr
函数成员。这样的成员必须符合 constexpr
函数的所有要求,它们是隐式 const
的。
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型
- 类必须至少含有一个
constexpr
构造函数 - 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的
constexpr
构造函数 - 类必须使用析构函数的默认定义,该成员负责销毁类的对象
-
constexpr
构造函数:尽管构造函数不能是const
的,但是字面值常量类的构造函数可以是constexpr
函数。事实上,一个字面值常量类必须至少提供一个constexpr
构造函数。constexpr
构造函数可以声明成= default
的形式(或者是删除函数的形式)。否则,constexpr
构造函数就必须既符合构造函数的要求(不能包含返回语句),又符合constexpr
函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点可知,constexpr
构造函数体一般来是应该是空的。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 setHw(bool hw) { Debug::hw = hw; } void setIo(bool io) { Debug::io = io; } void setOther(bool other) { Debug::other = other; } private: bool hw; // 硬件错误,而非 IO 错误 bool io; // IO 错误 bool other; // 其他错误 };
constexpr
构造函数必须初始化所有数据成员,初始值或者使用constexpr
构造函数,或者是一条常量表达式。constexpr
构造函数用于生成constexpr
对象以及constexpr
函数的参数或返回类型:int main() { constexpr Debug io_sub(false, true, false); if (io_sub.any()) { // 等价于 true std::cerr << "print appropriate error messages" << std::endl; } constexpr Debug prod(false); if (prod.any()) { // 等价于 flase std::cerr << "print an error message" << std::endl; } }
7.6 类的静态成员
有的时候类需要它的一些成员与类本身相关,而不是与类的各个对象保持关联。
-
声明静态成员:通过在成员的声明之前加上关键字
static
使得其与类关联在一起。和其他成员一样,静态成员可以是public
的或private
的。静态数据成员的类型可以是常量、引用、指针、类类型等。class Account { public: void calculate() { amount += amount * interestRate; } static double getRate() { return interestRate; } static void setRate(double); private: std::string owner; double amount; static double interestRate; static double initRate(); };
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此,每个
Account
对象将仅包含两个数据成员:owner
和amount
。只存在一个interestRate
对象而且它被所有Account
对象共享。类似地,静态成员函数也不与任何对象绑定在一起,它们不包含
this
指针。这就导致,静态成员函数不能声明成const
的,而且我们也不能在static
函数体内使用this
指针。这一限制既适用于this
的显式使用,也对调用非静态成员的隐式使用有效。 -
使用类的静态成员:
int main() { // 方式一:使用作用域访问符访问静态成员 double r = Account::getRate(); // 方式二:通过类的对象、引用或者指针来访问静态成员 Account account; Account *pAccount = &account; r = account.getRate(); r = pAccount->getRate(); }
-
定义静态成员:和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复
static
关键字,该关键字只能出现在类内部的声明语句:void Account::setRate(double newRate) { if (0 < newRate && newRate < 1) { interestRate = newRate; } }
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反地,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。
// 定义并初始化类 Account 的一个静态成员 interestRate。 // 从类名开始,这条定义语句的剩余部分就都位于类的作用域之内了, // 所以可以直接使用 initRate() 替代 Account::initRate() double Account::interestRate = initRate();
要想确保对象只定义一次,最好的办法就是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
-
静态成员的类内初始化:通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供
const
整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适用于常量表达式的地方。例如,我们可以用一个初始化了的静态数据成员指定数组成员的维度:class Account { public: static double gerRate() { return interestRate; } static void setRate(double); private: static double interestRate; static constexpr int period = 30; double daily_table[period]; };
如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的
const
或constexpr static
不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。例如,如果
period
的唯一用途就是定义daily_table
的维度,则不需要在Account
外面专门定义period
。此时,如果我们忽略了这条定义,那么对程序非常微小的改动也可能造成编译错误,因为程序找不到该成员的定义语句。举个例子,当需要把Account::period
传递给一个接受const int&
的函数时,必须定义period
。如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了:
// 一个不带初始值的静态成员的定义 constexpr int Account::period; // 初始值在类的定义内提供
即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
-
静态成员能用于某些场景,而普通成员不能:静态成员独立于任何对象。因此,在某些非静态成员可能非法的场合,静态成员却可以正常地使用。举个例子,静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
class Bar { public: static Bar mem1; // 正确:静态成员可以是不完全类型 Bar *mem2; // 正确:指针成员可以是不完全类型 Bar mem3; // 编译错误:数据成员必须是完全类型 };
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参:
class Screen { public: Screen &clear(char = bg); private: static const char bg; };
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。