C++中的类
7.1定义抽象数据类型
7.1.2定义改进的Sales_data类
定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,它们的定义和声明都在类的外部。改进之后的
Sales_data
类:
struct Sales_data {
// 新成员:关于Sales_data对象的操作
std::string isbn() const { return bookNo; }
Sales_data &combine(const Sales_data &);
double avg_price() const;
std::string bookNo; // ISBN编号
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
函数。
定义成员函数
尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。
引入this
当调用成员函数时,实际上是在替某个对象调用它。成员函数通过一个名为
this
的隐式参数来访问调用它的那个对象。当调用一个成员函数时,用请求该函数的对象地址初始化this
。例如,如果调用total.isbn()
,则编译器负责把total
的地址传递给isbn
的隐式参数this
,可以等价地认为编译器将该调用重写成:Sales_data::isbn(&total) // 伪代码,用于说明调用成员函数的实际执行过程
。
在成员函数内部,可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为this
所指的正是这个对象。任何对类成员的直接访问都被看作this
的隐式引用。也就是说,当isbn
使用bookNo
时,它隐式地使用this
指向的成员,即this -> bookNo
。因为this
的目的总是指向"这个"对象,所以this
是一个常量指针,不允许改变this
中保存的地址。
引入const成员函数
isbn
函数的另一个关键之处是紧随参数列表之后的const
关键字,这里,const
的作用是修改隐式this
指针的类型。
默认情况下,this
的类型是指向类类型非常量版本的常量指针(顶层const
)。例如在Sales_data
成员函数中,this
的类型是Sales_data *const
。尽管this
是隐式的,但它仍然需要遵循初始化规则,意味着(在默认情况下)不能把this
绑定到一个常量对象上。这一情况也就使得不能在一个常量对象上调用普通的成员函数(详见为什么const对象只能调用const成员函数)。
如果isbn
是一个普通函数而且this
是一个普通的指针参数,则应该把this
声明成const Sales_data *const
。毕竟,在isbn
的函数体内不会改变this
所指的对象,所以把this
设置为指向常量的指针有助于提高函数的灵活性。
然而,this
是隐式的并且不会出现在参数列表中,所以在哪儿将this
声明成指向常量的指针就成为必须面对的问题。C++语言的做法是允许把const
关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const
表示this
是一个指向常量的指针。像这样使用const
的成员函数被称作常量成员函数。可以把isbn
的函数体想象成:
/**
* 伪代码,说明隐式的this指针是如何使用的。
* 下面的代码是非法的:因为不能显式地定义自己的this指针。
* 谨记此处的this是一个指向常量的指针,因为isbn是一个常量成员。
*/
std::string Sales_data::isbn(const Sales_data *const this) {
return this -> bookNo;
}
常量对象,以及常量对象的指针或引用都只能调用常量成员函数。
类作用域和成员函数
类本身就是一个作用域,因此,编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
在类的外部定义成员函数
像其他函数一样,当在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定
const
属性。同时,类外部定义的成员的名字必须包含它所属的类名:
double Sales_data::avg_price() const {
if (units_sold) {
return revenue / units_sold;
} else {
return 0;
}
}
函数名使用作用域运算符来说明,定义的函数其声明在类的作用域内。一旦编译器看到这个函数名,就能理解剩余的代码是位于类的作用域内的。因此,可以直接使用类的成员。
定义一个返回this对象的函数
Sales_data &Sales_data::combine(const Sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this; // this是一个地址,对其解引用得到这个对象
}
一般来说,当定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。例如,内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,
combine
函数必须返回引用类型。因为此时的左侧运算对象是一个Sales_data
的对象,所以返回类型应该是Sales_data&
。
7.1.3定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,比如add、read和print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。
定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。
定义read和print函数
// 输入的交易信息包括ISBN、售出总数和售出价格
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;
}
需要注意的是,IO类属于不能被拷贝的类型,因此只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以一般函数接受的都是普通引用,而非对常量的引用。
一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。
定义add函数
/**
* add函数接受两个Sales_data对象作为其参数,返回值是一个新的Sales_data,
* 用于表示前两个对象的和
*/
Sales_data add(const Sales_data &lhs, const Sales_data &rhs) {
Sales_data sum = lhs; // 把lhs的数据成员拷贝给sum
sum.combine(rhs); // 把rhs的数据成员加到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.0;
};
= default的含义
在c++11新标准中,如果需要默认的行为,那么可以通过在参数列表后面写上
= default
来要求编译器生成构造函数。其中,= default
既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= default
在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。
构造函数初始值列表
构造函数初始值列表负责为新创建的对象的一个或几个数据成员赋值,不同成员的初始化通过逗号分隔开来。通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在,就能确保为成员赋予了一个正确的值。不过,如果编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
在类的外部定义构造函数
和其他成员函数一样,当在类的外部定义构造函数时,必须指明该构造函数是哪个类的成员:
Sales_data::Sales_data(std::istream &is) {
// read函数的作用是从is中读取一条交易信息然后存入this对象中
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
部分封装了(即隐藏了)类的实现细节。
class 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:
double avg_price() const { return units_sold ? revenue / units_sold : 0; }
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
使用class或struct关键字
struct
和class
两者的区别在于默认访问权限不太一样。如果使用struct
关键字,则定义在第一个访问说明符之前的成员是public
的;相反,如果使用class
关键字,则这些成员是private
的。
7.2.1友元
既然类存在着
private
的数据成员,那么类相关的非成员函数也就会出现无法编译的情况,这是因为尽管这些函数是类的接口的一部分,但它们不是类的成员。
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元。如果一个类想把一个函数作为它的友元,只需要增加一条以friend
关键字开始的函数声明语句即可:
class Sales_data {
// 为Sales_data的非成员函数所做的友元声明
friend Sales_data add(const Sales_data &lhs, const Sales_data &rhs);
friend std::istream &read(std::istream &is, Sales_data &item);
friend std::ostream &print(std::ostream &os, const Sales_data &item);
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:
double avg_price() const { return units_sold ? revenue / units_sold : 0; }
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// Sales_data接口的非成员组成部分的声明
Sales_data add(const Sales_data &lhs, const Sales_data &rhs);
std::istream &read(std::istream &is, Sales_data &item);
std::ostream &print(std::ostream &os, const Sales_data &item);
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。所以一般来说,最好在类定义开始或结束前的位置集中声明友元。
友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果希望类的用户能够调用某个友元函数,那么就必须在友元声明之外再专门对函数进行一次声明。
为了使友元对类的用户可见,通常把友元的声明与类本身放置在同一个头文件中
(类的外部)。
7.3类的其他特性
7.3.1类成员再探
为了展示新的特性,需要定义一对相互关联的类,分别是
Screen
和Window_mgr
。
定义一个类型成员
除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是
public
或者private
中的一种:
class Screen {
// 在public部分定义了pos,这样用户就可以使用这个名字
public:
// 等价于:using pos = std::string::size_type
// 必须先定义后使用,因此,类型成员通常出现在类开始的地方。
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0;
pos width = 0;
std::string contents;
};
// 通过定义别名来隐藏类的实现细节
Screen
类的成员函数
class Screen {
public:
typedef std::string::size_type pos;
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;
};
令成员作为内联函数
在类中,常有一些规模较小的函数适合于被声明成内联函数。定义在类内部的成员函数是自动
inline
的。可以在类的内部把inline
作为声明的一部分显式地声明成员函数,同样的,也能在类外部用inline
关键字修饰函数的定义:
// 可以在函数的定义处指定inline
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]; // 返回给定列的字符
}
最好只在类外部定义的地方说明
inline
,这样可以使类更容易理解。
可变数据成员
有时(但并不频繁)会发生这样一种情况,希望能修改某个类的数据成员,即使是在一个
const
成员函数内。可以通过在变量的声明中加入mutable
关键字做到这一点。
一个可变数据成员永远不会是const
,即使它是const
对象的成员。因此,一个const
成员函数可以改变一个可变成员的值。例如:
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr; // 即使在一个const对象内也能被修改
// 其他成员与之前的版本一致
};
void Screen::some_member() const {
++access_ctr; // 保存一个计数值,用于记录成员函数被调用的次数
// 该成员需要完成的其他工作
}
类数据成员的初始值
在定义好
Screen
类之后,继续定义一个窗口管理类并用它表示显示器上的一组Screen
。默认情况下,希望Window_mgr
类开始时总是拥有一个默认初始化的Screen
。在c++11新标准中,最好的方式就是把这个默认值声明成一个类内初始值:
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对象作为左值返回
}
从const
成员函数返回*this
从逻辑上来说,显示一个
Screen
并不需要改变它的内容,因此令display
作为一个const
成员,此时,this
将是一个指向const
的指针,而*this
是const
对象。由此推断,display
的返回类型应该是const Sales_data&
。然而,如果真的令display
返回一个const
引用,则不能把display
嵌入到一组动作的序列中去:
Screen myScreen;
myScreen.display(cout).set('*');
问题在于
display
的const
版本返回的是常量引用,显然无权set
一个常量对象。
一个const
成员函数如果以引用的形式返回*this
,那么它的返回类型将是常量引用。
struct Test {
// 必须显式声明返回const,否则编译器报错
const Test& test01() const {
return *this;
}
};
基于const
的重载
通过区分成员函数是否是
const
的,可以对其进行重载。具体说来,因为非常量版本的函数对于常量对象是不可用的,所以只能在一个常量对象上调用const
成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配:
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; }
// 其他成员与之前的版本一致
};
当一个成员调用另外一个成员时,
this
指针在其中隐式地传递。因此,当display
调用do_display
时,它的this
指针隐式地传递给do_display
。而当display
的非常量版本调用do_display
时,它的this
指针将隐式地从指向非常量的指针转换成指向常量的指针。
当do_display
完成后,display
函数各自返回解引用this
所得的对象。在非常量版本中,this
指向一个非常量对象,因此display
返回一个普通的引用;而const
成员则返回一个常量引用(由于传入的是一个常量对象, 因此解引用之后也是一个常量对象)。
7.3.3类类型
类的声明
就像可以把函数的声明和定义分离开来一样,也能仅仅声明类而暂时不定义它:
class Screen;
。这种声明有时被称作向前声明,它向程序中引入了名字并且指明是一种类类型。对于该类型来说,在它声明之后定义之前是一个不完全类型,也就是说,此时已知它是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
对于一个类来说,在创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或者指针访问其成员。
不过有一种例外情况:直到类被定义之后数据成员才能被声明成这种类类型。换句话说,必须首先完成类的定义,然后编译器才能知道存储数据成员需要多少空间。因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:
class Link_screen {
Screen window;
Link_screen *next;
Link_screen *prev;
};
7.3.4友元再探
类还可以把其他的类定义成友元,也可以把其他类(之前已经定义过)的成员函数定义成友元。如果一个类指定了友元类,则友元类的成员函数可以访问此类的所有成员。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
类之间的友元关系
假设需要为
Window_mgr
添加一个名为clear
的成员,它负责把一个指定的Screen
的内容都设为空白。为了完成这个任务,clear
需要访问Screen
的私有成员:
class Screen {
// Window_mgr的成员可以访问Screen类的私有部分
friend class Window_mgr;
// 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 = std::string(s.height * s.width, ' ');
}
必须要注意的一点是,友元关系不存在传递性,每个类负责控制自己的友元类或友元函数。
令成员函数作为友元
当把一个成员函数声明成友元时,必须明确指出该成员函数属于哪个类:
class Screen {
// Window_mgr::clear必须在Screen类之前被声明
friend Window_mgr::clear(ScreenIndex);
// Screen类的剩余部分
};
要想令某个成员函数作为友元,必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,必须按照如下方式设计程序:
- 首先定义
Window_mgr
类,其中声明clear
函数,但是不能定义它。在clear
使用Screen
的成员之前必须先声明Screen
。- 接下来定义
Screen
,包括对clear
的友元声明。- 最后定义
clear
,此时它才可以使用Screen
的成员。
函数重载和友元
如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。
// 重载的storeOn函数
extern std::ostream &storeOn(std::ostream &, Screen &);
extern BitMap &storeOn(BitMap &, Screen &);
class Screen {
// storeOn的ostream版本能访问Screen对象的私有部分
friend std::ostream &storeOn(std::ostream &, Screen &);
};
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。
甚至就算在类的内部定义该函数,也必须在类的外部提供相应的声明从而使得该函数可见。换句话说,即使仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:
struct X {
friend void f() { /* 友元函数可以定义在类的内部 */ }
X() { f(); } // 错误:f还没有被声明
void g();
void h();
};
void X::g() { return f(); } // 错误:f还没有被声明
void f(); // 声明那个定义在X中的函数(并不是成员函数)
void X::h() { return f(); } // 正确:现在f的声明在作用域中了
最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明。
7.4类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符访问。不论哪种情况,跟在运算符之后的名字都必须是对应类的成员:
Screen::pos ht = 24, wd = 80; // 使用Screen定义的pos类型
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get();
c = p -> get();
作用域和定义在类外部的成员
一个类就是一个作用域的事实能够很好地解释为什么当在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,成员的名字被隐藏起来了。
一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,可以直接使用类的其他成员而无须再次授权。
另一方面,函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于作用域之外。这时,返回类型必须指明它是哪个类的成员:
class Window_mgr {
public:
// 向窗口添加一个Screen,返回它的编号
ScreenIndex addScreen(const Screen &);
// 其他成员与之前的版本一致
};
// 首先处理返回类型,之后才进入Window_mgr的作用域
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s) {
screens.push_back(s);
return screens.size() - 1;
}
7.4.1名字查找与类的作用域
编译器处理完类中的全部声明后才会处理成员函数的定义。按照这种方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。
用于类成员声明的名字查找
这种处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找:
typedef double Money;
string bal;
class Account {
public:
/**
* 当编译器看到balance函数的声明语句时,它将在Account类的范围内寻找对Money的声明。
* 编译器只考虑Account中在使用Money前出现的声明,因为没找到匹配的成员,所以编译器
* 会接着到Account的外层作用域中查找。在这个例子中,编译器会找到Money的typedef语句。
* 另一方面,balance函数体在整个类可见后才被处理,因此,该函数的return语句返回名为bal的成员,
* 而非外层作用域的string对象
*/
Money balance() {
return bal; // 返回的是bal成员,而非外层作用域的string对象
}
private:
Money bal;
};
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:
typedef double Money;
class Account {
public:
// 使用外层作用域的Money
Money balance() {
return bal;
}
private:
// 错误,不能重新定义Money,即使Account中定义的Money类型与外层作用域一致
typedef double Money;
Money bal;
};
尽管重新定义类型名字是一种错误的行为,但是编译器并不为此负责。一些编译器仍将顺利通过这样的代码,而忽略代码有错的事实(在clion和vsstudio上测试没有问题)。
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没有找到该名字的声明,在成员函数定义之前的作用域内继续查找。
一般来说,不建议使用其他成员的名字作为某个成员函数的参数:
int height;
class Screen {
public:
typedef std::string::size_type pos;
/**
* 当编译器处理dummy_fcn的函数体时,它首先在函数作用域内查找表达式中用到的名字。
* 函数的参数位于函数的作用域内,因此dummy_fcn函数体内用到的名字height指的是参数声明
*/
void dummy_fcn(pos height) {
cursor = width * height; // 此处的height是形参
// cursor = width * this -> height; // 如果想使用类的成员
// cursor = width * Screen::height;
}
private:
pos cursor = 0;
pos height, width = 0;
};
如果想绕开上面的查找规则,可以通过加上类的名字或显式地使用
this
指针来强制访问成员。
类作用域之后,在外围的作用域中查找
如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。如果需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求。
在文件中名字的出现处对其进行解析
当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明:
int height; // 定义了一个名字,稍后将在Screen中使用
class Screen {
public:
typedef std::string::size_type pos;
void setHeight(pos);
pos height = 0; // 隐藏了外层作用域中的height
};
Screen::pos verify(Screen::pos);
void Screen::setHeight(pos var) {
// var:参数
// height:类的成员
// verify:全局函数
// 请注意,全局函数verify的声明在Screen类的定义之前是不可见的。然而,
// 名字查找的第三步包括了成员函数出现之前的全局作用域。在此例中,
// verify的声明位于setHeight的定义之前,因此可以被正常使用
height = verify(var);
}
7.5构造函数再探
7.5.1构造函数初始值列表
就对象的数据成员而言,初始化和赋值也有类似的区别。如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化:
// 构造函数的另一种写法,虽然合法但比较草率:没有使用构造函数初始值
Sales_data::Sales_data(const string &s, unsigned 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;
};
// 错误:ci和ri必须被初始化
ConstRef::ConstRef(int ii) {
i = ii;
ci = ii; // 错误:不能给const赋值
ri = i; // 错误:ri没被初始化
}
随着构造函数体一开始执行,初始化就完成了。因此,初始化
const
或者引用类型的数据成员的唯一机会就是通过构造函数初始值。
在很多类中,初始化和赋值的区别事关底层效率的问题:前者直接初始化数据成员,后者则先初始化再赋值。除了效率问题外更重要的是,一些数据成员必须被初始化,建议养成使用构造函数初始值的习惯。
成员初始化的顺序
在构造函数初始值中每个成员只能出现一次。不过,构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
成员的初始化顺序与它们在类定义中的出现顺序一致。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。一般来说,初始化的顺序没有什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了:
class X {
int i;
int j;
public:
// 未定义的:i在j之前被初始化
X(int val) : j(val), i(j) {}
};
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
默认实参和构造函数
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认的构造函数。
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() {
read(is, *this);
}
// 其他成员与之前的版本一致
};
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。
7.5.3默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况发生:
- 当在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时。
值初始化在以下情况发生:
- 当数组初始化的过程中如果提供的初始值数量少于数组的大小时。
- 当不使用初始值定义一个局部静态变量时。
- 当通过书写形如
T()
的表达式显式地请求值初始化时,其中T是类型名。类必须包含一个默认构造函数以便在上述情况下使用。
7.5.4隐式的类类型转换
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时把这种构造函数称作转换构造函数。能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则:
string null_book = "9-999-99999-9";
// 构造一个临时的Sales_data对象
// 该对象的units_sold和revenue等于0,bookNo等于null_book
item.combine(null_book);
只允许一步类类型转换
编译器只会自动地执行一步类型转换。例如,下面的代码隐式地使用了两种转换规则,所以它是错误的:
// 错误:需要用户定义的两种转换
// (1)把字面量转换成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"));
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,可以通过将构造函数声明为
explicit
加以阻止:
class Sales_data {
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p * n) {}
explicit Sales_data(const std::string &s) : bookNo(s) {}
explicit Sales_data(std::istream&);
// 其他成员与之前的版本一致
};
// 此时,没有任何构造函数能用于隐式地创建Sales_data对象。
需要注意的是,
explicit
只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,并且只能在类内声明构造函数时使用explicit
关键字,在类外部定义时不应重复。
explicit
构造函数只能用于直接初始化
发生隐式初始化的一种情况是当执行拷贝形式的初始化(使用
=
)。此时,只能使用直接初始化而不能使用explicit
构造函数:
Sales_data item1(null_book); // 正确:直接初始化
Sales_data item2 = null_book; // 错误:不能将explicit构造函数用于拷贝形式的初始化过程
当用
explicit
关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换的过程中使用该构造函数。
为转换显式地使用构造函数
尽管编译器不会将
explicit
的构造函数用于隐式转换过程,但是可以使用这样的构造函数显式地强制进行转换:
item.combine(Sales_data(null_book)); // 正确:实参是一个显式构造的对象
item.combine(static_cast<Sales_data>(cin)); // 正确:static_cast可以使用explicit的构造函数
7.5.5聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件,就说它是聚合的:
- 所有成员都是
public
的。- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有
virtual
函数。
struct Data {
int ival;
string s;
};
可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:
Data val1 = {0, "Anna"};
。
初始值的顺序必须与声明的顺序一致。与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。
值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点:
- 要求类的所有成员都是
public
的。- 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为很容易忘掉某个初始值,或者提供一个不恰当的初始值。
- 添加或删除一个成员后,所有的初始化语句都要更新。
7.5.6字面值常量类
除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有
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 set_io(bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bool b) { other = b; }
private:
bool hw;
bool io;
bool other;
};
constexpr
构造函数必须初始化所有数据成员,初始值或者使用constexpr
构造函数,或者是一条常量表达式。
constexpr
构造函数用于生成constexpr
对象以及constexpr
函数的参数或返回类型。
7.6类的静态成员
有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。
声明静态成员
通过在成员的声明之前加上关键字
static
使得其与类关联在一起。和其他成员一样,静态成员可以是public
或者private
的。静态数据成员的类型可以是常量、引用、指针、类类型等等:
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();
};
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。类似的,静态成员函数也不与任何对象绑定在一起,它们不包含
this
指针。作为结果,静态成员函数不能声明成const
的,而且也不能在static
函数体内使用this
指针。
使用类的静态成员
使用作用域运算符直接访问静态成员:
double r = Account::rate();
。虽然静态成员不属于类的某个对象,但是仍然可以使用类的对象、引用或者指针来访问静态成员。并且,成员函数不用通过作用域运算符就能直接使用静态成员。
定义静态成员
和其他成员函数一样,既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复
static
关键字,该关键字只出现在类内部的声明语句:
void Account::rate(double newRate) {
interestRate = newRate;
}
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,不能在类的内部初始化静态成员(因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的)。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此,一旦它被定义,就将一直存在于程序的整个生命周期中:double Account::interestRate = initRate(); // 定义并初始化一个静态成员
。
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化。然而,可以为静态成员提供
const
整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。例如,可以用一个初始化了的静态数据成员指定数组成员的维度:
class Account {
public:
static double rate() { return interestRate; }
static void rate(double);
private:
static constexpr int period = 30;
double daily_tbl[period];
};
如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的
const
或constexpr static
不需要分别定义。相反,如果将它用于值不能替换的场景中,则该成员必须有一条定义语句。
例如,如果period
的唯一用途就是定义daily_tbl
的维度,则不需要在Account
外面专门定义period
。但是,当需要把Account::period
传递给一个接受const int&
的函数时,必须定义period
。
如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了。因此,即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
静态成员能用于某些场景,而普通成员不能
静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
class Bar {
private:
static Bar mem1;
Bar *mem2;
Bar mem3; // 错误:数据成员必须是完全类型
};
静态成员和普通成员的另外一个区别是可以使用静态成员作为默认实参:
class Screen {
public:
Screen& clear(char = bkground);
private:
static const char bkground;
};
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。