《c++ primer》 chapter 7:类
类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。接口包括用户所能执行的操作,实现则包括类的数据成员,负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离,封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要实现数据抽象和封装,需要首先定义一个抽象数据类型,由类的设计者负责考虑类的实现细节,而类的使用者无须了解类型的工作细节。
1. 定义抽象数据类型
1.1 设计Sales_data类
我们尝试定义一个Sales_data类,它的接口包含以下操作:
- 一个isbn成员函数,用于返回对象的ISBN编号
- 一个combine成员函数,用于将一个Sales_data对象加到另一个对象上去
- 一个名为add的函数,执行两个Sales_data对象的加法
- 一个read函数,将数据从istream读入到Sales_data对象中
- 一个print函数,将Sales_data对象的值输出到ostrem
首先来看看如何使用上面这些接口函数:
Sales_data total; // 保存当前求和结果的变量
if (read(cin, total)) { // 读入第一笔交易
Sales_data trans; // 保存下一条交易数据的变量
while (read(cin, trans)) { // 读入剩余的交易
if (total.isbn() == trans.isbn()) // 检查isbn
total.combine(trans); // 更新变量total当前的值
else {
print(cout, total) << endl; // 输出结果
total = trans; // 处理下一本书
}
}
print (cout, total) << endl; // 输出最后一条交易
} else {
cerr << "No data?!" << endl; // 通知用户
}
1.2 定义改进的Sales_data类
定义成员函数的方式和普通函数差不多,成员函数的声明必须在类的内部,它的定义既可以在类的内部,也可以在类的外部。作为接口组成部分的非成员函数,例如add,read和print等,它们的定义和声明都在类的外部。
struct Sales_data {
// 关于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的非成员接口函数
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函数
考察对isbn成员函数的调用:total.isbn()
这里我们使用点运算符来访问total对象的isbn成员,然后调用它。上面的调用中,当isbn返回bookNo时,实际上它隐式地返回total.bookNo。成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象,当我们调用一个成员函数时,用请求该函数的对象地址初始化this。故上面的调用会让编译器把total的地址传递给isbn的隐式形参this。
用伪代码说明调用成员函数的实际执行过程:
Sales_data::isbn(&total)
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算做到这一点,因为this所指的正是这个对象。this新参虽然是隐式的,但任何自定义名为this的参数或变量的行为都是非法的。
// 尽管没有必要,我们还是可以把isbn定义成如下形式
std::string isbn() const {return this.bookNo;}
isbn函数的一个关键处是紧随参数列表后的const关键字,这里,const的作用是修改隐式this指针的类型。默认的this类型是执行类类型的非常量版本的常量指针,例如这里的Sales_data成员函数中,this的类型是Sales_data *const。尽管this是隐式的,我们默认不能把this绑定到一个常量对象上,这也使得我们不能在一个常量对象上调用普通的成员函数。若isbn是一个普通函数且this是一个普通的指针参数,我们应该把this声明成const Sales_data *const,毕竟,在isbn的函数体内不会改变this所指的对象。
然而,this是隐式的,并不出现在参数列表中,因此,C++允许我们把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后的const表示this是一个指向常量的指针。像这样使用const的成员函数称为常量成员函数。
常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
类作用域和成员函数
我们知道类本身就是一个作用域,类的成员函数的定义嵌套在类的作用域之内,因此isbn中用到的名字bookNo其实就是定义在Sales_data内的数据成员。注意,编译器分两步处理类,先编译成员函数的声明,然后才轮到成员函数体,因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
类外定义成员函数
当我们在类外部定义成员函数时,成员函数的定义应该与它的声明匹配,即,返回类型,参数列表,常量声明和函数名都应该和类内部的声明保存一致。
// 定义avg_price,注意作用域运算符的使用
double Sales_data::avg_price() const {
return units_sold ? revenue/units_sold : 0;
}
返回this对象的函数
// 定义combine函数
Sales_data& Sales_data::combine(const Sales_data & rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this; // 返回调用该函数的对象
}
// combine类似于内置的赋值运算符,把它的左侧运算对象当成左值返回,它返回了引用类型
定义类相关的非成员函数
前面的add,read和print函数,其定义的操作从概念上来说属于类的接口的组成部分,但它们实际上不属于类本身。
定义非成员函数的方式与定义其他函数类似,通常把函数的声明和定义分离开来,如果函数在概念上属于类但不定义在类中,则它一般应与类声明(而非定义)在同一个文件内。这种方式下,用户使用接口的任何部分都只需引入一个文件。
// 定义read和print函数; IO类属于不能被拷贝的类型,因此只能通过引用来传递它们
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;
}
// 定义add函数; 默认情况,拷贝类的对象其实拷贝的是对象的数据成员
Sales_data add(const Sales_data &lhs, const Sales_data& rhs) {
Sales_data sum = lhs; // 把lhs的数据成员拷贝给sum
sum.combine(rhs); // 把rhs的数据成员加到sum中
return sum;
}
1.3 构造函数
每个类都定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫构造函数。构造函数的任务是初始化类对象的数据成员。
构造函数的名字与类名相同,且没有返回类型,除此之外类似与其他函数,类也可以包含多个构造函数。不同于其他成员函数的是,构造函数不能声明成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"的含义
Sales_data() = default,它是一个默认构造函数,C++11新标准中,如果我们需要默认行为,可以通过在参数列表后面写“=default”来要求编译器生成构造函数。其中“=default”既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部,与其他函数一样,若它在类内部,则默认构造函数是内联的;如果它在类外部,则默认不是内联的。
Sales_data() = default下面的两个构造函数定义中出现了新的部分,即冒号以及冒号和花括号之间的代码,其中花括号定义了空函数体,新出现的部分称为构造函数初始值列表。它负责为新建对象的一个或几个数据成员赋初值,构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的成员初始值。譬如,含有三个参数的构造函数分别使用它的前两个参数初始化成员bookNo和units_sold,revenue的初值则通过计算获得。只有一个string类型参数的构造函数使用string初始化bookNo,其他数据成员将以合成默认构造函数相同的方式隐式初始化。
构造函数不应该轻易覆盖类内初始值,除非新赋予的值与原来不同,若编译器不支持类内初始值,则所有构造函数都应该显示地初始化每个内置类型的成员。
类外定义构造函数
// 从is中读取一条交易信息然后存入this对象
Sales_data::Sales_data(std::istream &is) {
read(is, *this) // 第二个参数是一个Sales_data对象的引用
}
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/unites_sold : 0;
}
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
一个类可以包含0个或多个访问说明符,而且对于某个访问说明符出现多少次也没有限定。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到下一个访问说明符出现或者到达类的结尾处为止。
上面的类定义使用了class关键字,class和struct唯一的区别只是默认的访问权限不一样。类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式,如果使用struct关键字,则定义在第一个访问说明符之前的成员是public的,反之,若使用class关键字,则这些成员是private的。当我们希望定义的类的所有成员是public时,使用struct,反之,如果希望成员是private的,使用class。
2.1 友元
Sales_data的数据成员设为private,我们的read,print和add函数,作为类接口的一部分,不是类的成员,也就无法正常编译了。可以让类允许其他类或函数访问它的非公有成员,方法是令其他类或者函数成为它的友元,若类想把一个函数作为它的友元,只需要增加一条有以friend关键字开始的函数声明语句即可:
class 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): 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/unites_sold : 0;
}
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&);
友元声明只能出现在类定义的内部,但出现在类内的具体位置不限。它仅仅指定了访问权限,非一个通常意义上的声明,如果希望类的用户能调用某个友元函数,就必须在友元声明之外再专门对函数进行一次声明。为了让友元对类的用户可见,通常把友元声明与类本身放在头一个头文件中(类的外部)。
3. 类的其他特性
3.1 类成员再探
我们定义一对互相关联的类,它们分别是Screen和Window_mgr。Screen表示显示器中的一个窗口。
定义类型成员:除了定义数据和函数之外,我们还可以自定义某种类型在类中的别名,由类定义的类型名字和其他成员一样存在访问限制,可以是public或private的。
class Screen {
public:
typedef std::string::size_type pos;
// 另一等价声明:using pos = std::string::size_type;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
}
我们在Screen的public部分定义了pos,这样用户就可以使用这个名字,Screen的用户不应该知道Screen使用了一个string对象来存放它的数据,因此把pos定义为public成员可以隐藏Screen的实现细节。需要注意的是,用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别,因此,类型成员通常出现在类开始的地方。
完整的Screen类定义如下:
class Screen {
public:
typedef std::string::size_type pos;
Screen() = default;
Screen(pos ht, pos wd, char c): height(ht), width(wd), contents(ht * wd, c) {} // cursor被其类内初始值初始化为0
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的,因此,Screen的构造函数和返回光标所指字符的get函数默认是inline的。我们可以在类内部把inline作为声明的一部分显式声明成员函数,也可以在类外部用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,方便于理解。inline成员函数也应该与相应的类定义在同一个头文件中。
成员函数也可以被重载,只要函数之间在参数的数量或类型上有区别就行,其匹配过程与非成员函数非常类似。
可变数据成员:
一个可变数据成员不会是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类型的vector,每个元素表示一个特定的Screen。
// 我们希望Window_mgr类开始时总有一个默认初始化的Screen, C++11中,最好的方式是把这个默认值声明成一个类内初始值
class Window_mgr {
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
// 如我们所知,类内初始值必须使用=的初始化形式(初始化Screen的数据成员),或者花括号括起来的直接初始化形式(初始化screens)
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 c, char ch) {
contents[r*width + col] = ch;
return *this; // 将this对象作为左值返回
}
上面set函数返回值是调用set的对象的引用,返回引用的函数是左值的,因此这些函数返回的是对象本身而非其副本。
myScreen.move(4, 0).set('#');
// 上述语句等价于
myScreen.move(4, 0);
myScreen.set('#');
从const成员函数返回*this
接下来添加一个display操作,负责打印Screen的内容,显示一个Screen的内容并不需要改变它的内容,因此我们令display为一个const成员。我们希望这个操作也能嵌入到一组动作中去:
Screen myScreen;
myScreen.diplay(cout).set('*');
// dispaly定义为const的,返回的是常量引用,我们无权set一个常量对象
一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。
通过区分成员函数是否是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;}
// 其他成员与之前版本一致
};
// 当一个成员调用另一成员时,this指针在其中隐式传递,因此,display调用do_display时,它的this指针隐式传给do_display,当display的非常量版本调用do_display时,它的this指针将隐式地从指向非常量的指针转换为指向常量的指针。
Screen myScreen(5, 1);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // 调用非常量版本
blank.display(cout); // 调用常量版本
3.3 类类型
可以把类名作为类型的名字使用,从而直接指向类类型,或者,也可把类名跟在关键字class或struct后面:
Sales_data item1; // 默认初始化Sales_data类型的对象
class Sales_data item1; // 一条等价的声明
我们也能仅仅声明类而暂时不定义它:
class Screen; // Screen类的声明
这种声明成为前向声明,它向程序引入了名字Screen并指明了Screen是一种类类型,对于类型Screen来说,在它声明后定义前是一个不完全类型。
不完全类型只能在有限的场景下使用:定义指向这种类型的引用或指针,也可以声明(不能定义)以不完全类型作为参数或返回类型的函数。
一般而言,对于一个类,我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。因为只有当一个类全部完成后才算被定义,所以一个类的成员类型不能是该类自己,然而一个类的名字一旦出现,就被认为被声明过了,因此类允许包含指向它自身类型的引用或指针:
class Link_screen {
Screen window;
Link_screen *next;
Link_screen *prev;
};
3.4 友元再探
类可以把其他类定义成友元,也可以把其他类(之前定义过)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
类之间的友元关系:
我们的Window_mgr类的某些成员可能要访问它管理的Screen类的内部数据,把Window_mgr指定为Screen类的友元:
class Screen {
// Window_mgr的成员可以访问Screen类的私有部分
friend class Window_mgr;
// Screen类的剩余部分
};
// 此时Wind_mgr的clear成员写成如下形式
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) {
Screen &s = screens[i];
s.contents = string(s.height * s.width, ' ')
}
需要注意,友元关系不存在传递性,如果Window_mgr有它自己的友元,则这些友元并不理所当然地具有访问Screen的特权。
成员函数作为友元:
class Screen {
// Window_mgr::clear必须在Screen类之前被声明
friend void Window_mgr::clear(ScreenIndex);
// Screen剩余部分
};
要让某个成员函数作为友元,必须仔细组织程序结构以满足声明和定义的彼此依赖关系。这个例子中,必须遵照如下设计:
- 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。
- 接下来定义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 &);
// ...
};
// 接受BitMap&作为参数的版本仍然不能访问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的声明在作用域中了
4. 类的作用域
在类的作用域外,普通的数据和函数成员由对象,引用或指针使用成员访问运算符来访问,对于类类型成员则使用作用域运算符访问。
Screen::pos ht = 24, wd = 80;
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 &) {
screens.push_back(s);
return screens.size() - 1;
}
4.1 名字查找与作用域
一般编写的程序中,名字查找的过程大致如下:
- 首先在名字所在的块中去寻找其声明语句,只考虑在名字使用前出现的声明。
- 若没找到,继续查找外层作用域。
- 若最终没有找到匹配的声明,则程序报错。
类的定义过程可分为两步:首先编译成员的声明,直到类全部可见后才编译函数体。
类成员函数声明中使用的名字,包括返回类型,参数列表,都必须在使用前确保可见。
typedef double Money;
string bal;
class Account {
public:
// Money在外层作用域中找到,而函数体在整个类可见后才会被处理,所以返回的bal是类的数据成员,而非外层作用域string对象
Money balance() {return bal; }
private:
Money bal;
// ...
};
类型名要特殊处理
类中如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能再之后重新定义该名字:
typedef double Money;
class Account {
public:
Money balance() {return bal; }
private:
typedef double Money; // 错误,不能重新定义Money,即使类型不改变
Money bal;
// ...
};
// 类型名的定义最好出现再类开始处
成员定义在普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
- 首先在成员函数内查找该名字出现的声明。
- 如果在成员函数内没有被找到,则在类内继续查找。
- 如果类内也没有找到该名字的声明,在成员函数定义前的作用域内继续查找。
// 不建议使用其他成员的名字作为某个成员函数的参数,这段代码仅用于说明
int height;
class Screen {
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height) {
cursor = width * height; // 参数里的那个height
}
private:
pos cursor = 0;
pos height = 0, width = 0;
};
// 若要使用类的成员height,应该将代码变为:
void Screen::dummy_fcn(pos height) {
cursor = width * this->height;
// 或cursor = width * Screen::height;
}
// 建议的写法:不要把成员名字作为形参或其他局部变量使用
void Screen::dummy_fcn(pos ht) {
cursor = width * height;
}
// 若要使用全局的height
void Screen::dummy_fcn(pos height) {
cursor = width * ::height;
}
当成员函数定义在外部时,名字查找的第三步不仅要考虑类定义前的全局作用域中的声明,还要考虑成员函数定义前的全局作用域中的声明。
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(pos var) {
height = verify(var); // verify的声明出现在setHeight定义前,可以被正常使用
}
5. 构造函数再探
5.1 构造函数初始值列表
如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。
// Saels_data构造函数另一种比较草率的写法,没有使用构造函数初始值,而是执行了赋值操作
Sales_data::Sales_data(const string &s, unsigned cnt, double price) {
bookNo = s;
unites_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;
ri = i;
}
// 正确,显式地初始化引用和const成员
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) {}
成员初始化的顺序与它们在类定义中的出现顺序一致,构造函数初始值列表中初始值的前后位置关系不会影响实际初始化顺序。
class X {
int i;
int j;
public:
X(int val): j(val), i(j) {} // 未定义的,i在j之前被初始化
};
// 建议的构造函数初始化方式:
X(int val): i(val), j(val) {}
默认实参和构造函数
我们把Sale_data重写成一个使用默认实参的构造函数:
class Sales_data {
public:
Sales_data(std::string s = ""): bookNo(s) {}
// 其他构造函数和成员与之前一致
}
// 我们不提供实参也能调用上述构造函数,所以该构造函数实际上为我们提供了默认构造函数。
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
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): {read(is, *this)}
}
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体依次执行。
5.3 默认构造函数的作用
class NoDefault {
public:
NoDefault(const std::string&);
// 还有其他成员,但没有其他构造函数了
};
struct A {
NoDefault my_mem;
};
A a; // 错误,不能为A合成构造函数
struct B {
B() {} // 错误,b_member没有初始值
NoDefault b_member;
}
使用默认构造函数初始化对象的方式:
Sales_data obj; // 正确,obj是个默认初始化的对象
Sales_data book(); // 正确,定义了一个函数而非对象
if (book.isbn() == Primer_5th_ed.isbn()) //错误,book是一个函数
5.4 隐式的类类型转换
C++能为类定义隐式类型转换,如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时把这种构造函数称为转换构造函数,它能通过一个实参调用的构造函数,完成从构造函数的实参类型向类类型的隐式转换。
string null_book = "9-999-9999-9"
item.combine(null_book); // 将构造一个临时的,用一个字符串实参初始化的Sales对象
// 需要注意,编译器只会自动地执行一步类型转换
item.combine("9-999-9999-9"); // 错误,需要两步转换
// 另一个转换是从istream到Sales_data
item.combine(cin); // 隐式地把cin转换成Sales_data
可以通过将构造函数声明为explicit加以阻止这种隐式转换
class Sales_data {
public:
Sales_data() = default;
Sales_data(std::string s, unsigned cnt, double price): bookNo(s), units_sold(cnt), revenue(cnt*price) {}
explicit Sales_data(std::string s): bookNo(s) {}
explicit Sales_data(std::istream &is);
// 其他成员与之前一致
};
item.combine(null_book); // 错误, string构造函数是explicit的
item.combine(cin); // 错误,istream构造函数是explicit的
explict关键字只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,故无须将这些构造函数置为explicit的。此外,只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复。
explict构造函数只能用于直接初始化
Sales_data item1(null_book); // 正确,直接初始化
Sales_data item2 = null_book; // 错误,不能将explicit构造函数用于拷贝形式初始化过程
我们依然可以对explicit构造函数用于显式强制转换:
item.combine(Sales_data(null_book)); // 正确,实参是一个显式构造的Sales_data对象
item.combine(static_cast<Sales_data>(cin)); // 正确,static_cast可以使用explict构造函数
标准库中含有显式构造函数的类
标准库中的接受一个但参数的const char*的string构造函数,不是explicit的,而接受一个容量参数的vector构造函数是explicit的。
5.5 聚合类
聚合类让用户可以直接访问其成员,且具有特殊的初始化语法形式,一个聚合类需要满足下列条件:
- 所有成员都是public的
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有virtual函数
struct Data {
int ival;
string s;
};
// 可以提供一个花括号括起来的成员初始值列表来初始化聚合类的数据成员
Data val1 = {0, "and"};
需注意,初始值的顺序必须与声明的顺序一致,如果初始值列表中元素个数少于类的成员数量,则靠后的成员被值初始化。显式地初始化类的对象的成员由三个缺点:
- 类的成员都要是public的
- 需要记住每一个成员
- 添加或删除一个成员后,所有初始化语句都要更新
5.6 字面值常量类
前面介绍了constexpre函数,其参数和返回值都要是字面值类型,我们也可以定义字面值类型的类。
数据成员都是字面值类型的聚合类是字面值常量类,此外,符合下述要求的类也是字面值常量类:
- 数据成员都是字面值类型
- 类必须至少含有一个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) {hw = b;}
private:
bool hw;
bool io;
bool other;
};
// constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数或者是一条常量表达式
// constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型
constexpr Debug io_sub(flase, true, flase);
if (io_sub.any())
cerr << "print appropriate error messages" << endl;
constexpr Debug prod(false);
if (io_sub.any())
cerr << "print an error message" << endl;
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;
r = Account::rate(); // 使用作用域运算符访问静态成员
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate(); // 通过Account的对象或引用访问
r = ac2->rate(); // 通过指向Account对象的指针访问
// 成员函数不通过作用域运算符就能访问静态成员
// 见上面calculate函数的定义:void calculate() {amount += amount * interestRate; }
定义静态成员
我们既可以在类内部,也可以在类外部定义静态成员函数,在类外定义静态成员时,不能重复static关键字,该关键字只能出现在类内部的声明语句:
void Account::rate(double newRate) {interestRate = newRate}
静态数据成员不属于类的任何一个对象,故它们不是在创建类的对象时被定义的,这意味着它们不是由类的构造函数初始化的,而且一般来说,我们不能在类的内部初始化静态成员,相反,必须在类的外部定义和初始化静态成员,和其他对象一样,一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外,因此,一旦被定义,就一直存在于程序的整个生命周期中。我们需要指定对象的类型名,然后是类名,作用域运算符以及成员自己的名字:
double Account::interestRate = initRate(); // 定义并初始化一个静态成员
// 从类名开始,这条语句的剩余部分就在类的作用域之内了,虽然initRate是私有的,我们也能用它初始化interestRate
要想确保对象只定义一次,最好是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
虽然静态成员不能在类的内部初始化,但是,可以为静态成员提供const整数类型的类内初值,不过要求静态成员必须是字面值常量类型的constexpr,初值必须是常量表达式。
class Account {
public:
static double rate() {return interestRate; }
static void rate(double);
private:
static constexpr int period = 30; // period是常量表达式
double daily_tbl[period];
}
若某个静态成员用于值不能被替换的场景中,则该成员必须有一条定义语句。
如果类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了:
// 一个不带初始值的静态成员定义
constexpr int Account::period; // 初始值在类的定义内提供
即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
静态成员能用于某些场景,而普通成员不能
静态数据成员可以是不完全类型,特别地,静态数据成员的类型可以就是它所属的类类型,而非静态成员不能;此外,静态成员和普通成员的另一个区别是我们可以使用静态成员作为默认实参:
class Bar {
public:
// ...
private:
static Bar mem1;
Bar *mem2;
Bar mem3; // 错误,数据成员必须是完全类型
};
class Screen {
public:
Screen& clear(char = bkground);
private:
static const char bkground;
};