类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。
- 类的接口:用户所能执行的操作。
- 类的实现:类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型。在抽象数据类型中,由类的设计者负责考虑类的实现细节和过程;使用这个类的程序员只需要知道这个类的对象能完成什么动作和功能就行了,而不需要了解这个类是怎么实现的。
7.1 定义抽象数据类型
7.1.1 设计Sales_data类
Sales_item类有一个名为isbn的成员函数,并且支持各种运算符。
综上所述,Sales_data的接口应该包含以下的操作:
- 一个isbn成员函数,用于返回对象的ISBN编号。
- 一个combine成员函数,用于将一个Sales_data对象加到当前对象上。
- 一个名为add的函数(不是成员函数),执行两个Sales_data对象的加法。
- 一个read函数,将数据从istream读入到Sales_data对象中。
- 一个print函数,将Sales_data对象的值输出到ostream。
关键概念:程序员是类的用户,程序员使用类来编写程序,程序的用户可能是书店老板。当然在一些简单的程序中,类的设计者和用户是一个人。当我们设计类时,应该考虑如何才能使得类易于使用;而当我们实用类时,不应该顾及类是怎么样实现的。
使用改进的Sales_data类
7.1.1节练习
练习7.1:使用2.6.1节练习定义的Sales_data类为1.6节(第21页)的交易程序编写一个新的版本。
#include
#include
struct Sales_data {
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
int main(int argc, char const *argv[])
{
Sales_data total;
if (std::cin >> total.bookNo >> total.units_sold >> total.revenue) {
Sales_data trans;
while (std::cin >> trans.bookNo >> trans.units_sold >> trans.revenue) {
if (trans.bookNo == total.bookNo) {
total.units_sold += trans.units_sold;
total.revenue += trans.revenue;
} else {
std::cout << total.bookNo << " ";
std::cout << total.units_sold << " ";
std::cout << total.revenue << std::endl;
total.bookNo = trans.bookNo;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;
}
}
// 输出最后一本书
std::cout << total.bookNo << " ";
std::cout << total.units_sold << " ";
std::cout << total.revenue << std::endl;
} else {
std::cerr << "No Data" << std::endl;
return -1;
}
return 0;
}
7.1.2 定义改进的Sales_data类
改进的Sales_data类应当如下所示:
struct Sales_data {
// 新成员
std::string isbn() const { return bookNo; } // 类内定义的函数,该函数是返回一个值
Sales_data& combine(const Sales_data& rhs);
double avg_price() const;
// 旧成员
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::ostream& print(std::ostream& os, const Sales_data& rhs);
std::istream& read(std::istream& is, const Sales_data& rhs);
定义在类内部的函数是隐式的inline函数。
定义成员函数
尽管所有的成员都必须在类的内部进行声明,但是成员函数体可以定义在类内也可以定义在类外。
引入this指针
当我们调用某个成员函数时,实际上是在使用该类的某个对象进行调用。于是被调用的成员函数隐式的指向了调用它的对象。成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,就会用请求该函数的对象地址初始化this指针。
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无需通过成员访问运算符来做到这一点,因为this指向的正是这个对象。任何对类成员的直接访问都被看做是对this的隐式引用。
任何自定义名为this的参数或者变量都是非法的。以为this被设计出来的目的总是指向当前调用函数的对象,所以this是一个常量指针,我们不允许改变this中保存的地址。
引入const成员函数 ★
参数列表之后的const关键字是用来修改隐式this指针的类型。
默认情况下,this的类型是指向类类型非常量版本的常量指针,即不可修改指针本身,但是可以修改指向地址上的内容的指针。在默认情况下,我们不能把this绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。即,如果我们定义了一个常量的对象,就无法调用isbn这个成员函数了。
#include
#include
struct Book {
Book(const std::string name) :
book_name(name) { }
std::string get_name() { return book_name; }
// 我们将成员函数写成非const版本的
std::string book_name;
};
int main(int argc, char const *argv[])
{
const Book b1("Gone with the wind"); // const对象无法调用非const成员函数
std::cout << b1.get_name() << std::endl;
return 0;
}
// 于是编译器报错:
// a.cc: In function 'int main(int, const char**)':
// a.cc:15:30: error: passing 'const Book' as 'this' argument discards qualifiers [-fpermissive]
// std::cout << b1.get_name() << std::endl;
// ^
// a.cc:8:17: note: in call to 'std::__cxx11::string Book::get_name()'
// std::string get_name() { return book_name; }
// ^~~~~~~~
由此可见,参数列表后是否有const决定哪些对象可以进行调用,如果添加了const,则常量对象也可以调用这个方法;如果没有添加const,则只有非常量的对象可以调用。
这样使用的const的成员函数被称作常量成员函数。
常量对象,以及常量对象的引用或者指针都只能调用常量成员函数。
类作用域和成员函数
我们注意到,即使成员变量bookNo定义在isbn之后,isbn还是能使用bookNo;这是因为编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体。因此,成员函数体可以随意使用类中的其他成员而无须在意成员出现的顺序。
在类的外部定义成员函数
成员函数的声明必须和定义保持一致,也就是说,返回类型、参数列表和函数名都得和类内部的声明保持一致。如果成员被声明为常量成员函数,那么它的定义也必须在参数列表后明确指定const属性。同时,类外部定义的成员名字必须包含它所属的类名:
struct Sales_data {
// 新成员
std::string isbn() const { return bookNo; } // 类内定义的函数,该函数是返回一个值
Sales_data& combine(const Sales_data& rhs);
double avg_price() const;
// 旧成员
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
double Sales_data::avg_price() const
{
if (units_sold != 0) {
return revenue / units_sold;
} else {
return 0;
}
}
类外定义的函数名使用了作用域运算符来说明如下事实:我们定义了一个名为avg_price的函数,并且该函数被声明在类Sales_data的作用域内。一旦编译器看到这个函数名,就能理解剩余的代码是位于类的作用域内的。
定义一个返回this对象的函数
我们可以使用返回当前对象的的类型的引用来返回当前这个对象(的引用)。
7.1.2节练习
练习7.2:曾在2.6.2节的练习中编写了一个Sales_data类,请向这个类添加combine和isbn成员。
struct Sales_data {
// 新成员
std::string isbn() const { return bookNo; } // 类内定义的函数,该函数是返回一个值
Sales_data& combine(const Sales_data& rhs);
double avg_price() const;
// 旧成员
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
Sales_data& Sales_data::combine(const Sales_data& rhs)
{
if (bookNo == rhs.bookNo) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
}
// 如果isbn号不相同则不执行任何操作
return *this;
}
练习7.3:修改7.1.1节的交易处理程序,令其使用这些成员。
#include
#include
#include "Sales_data.h"
int main(int argc, char const *argv[])
{
Sales_data total;
if (std::cin >> total.bookNo >> total.units_sold >> total.revenue) {
Sales_data trans;
while (std::cin >> trans.bookNo >> trans.units_sold >> trans.revenue) {
if (trans.isbn() == total.isbn()) {
total.combine(trans); // 这一使用新的成员函数
} else {
std::cout << total.isbn() << " ";
std::cout << total.units_sold << " ";
std::cout << total.revenue << std::endl;
total = trans; // 这里可以直接使用默认拷贝赋值运算符
}
}
// 输出最后一本书
std::cout << total.isbn() << " ";
std::cout << total.units_sold << " ";
std::cout << total.revenue << std::endl;
} else {
std::cerr << "No Data" << std::endl;
return -1;
}
return 0;
}
练习7.4:编写一个名为Person的类,使其表示人员的姓名和住址。使用string对象存放这些元素,接下来的练习将不断充实这个类的其他操作特征。
#include
struct Person {
std::string name;
std::string address;
};
练习7.5:在你的Person中提供一些操作使其能够返回姓名和住址。这些函数是否应该是const的?解释原因。
#include
struct Person {
std::string get_name() const { return name; }
std::string get_address() const { return address; }
std::string name;
std::string address;
};
这里的get_name和get_address应当被设置为常量成员函数,因为函数体内部存在对成员变量的修改,即如果设置为常量成员函数该类常量的对象也就可以调用这两个函数。
7.1.3 定义类相关的非成员函数
我们定义非成员函数的方式和定义其他函数一样,通常把函数的声明和定义分离开来。
一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个文件里。
定义read和print函数
std::istream& read(std::istream& is, Sales_data& rhs)
{
double single_price = 0.00
is >> rhs.bookNo >> rhs.units_sold >> single_price;
rhs.revenue = single_price * rhs.units_sold;
return is;
}
std::ostream& print(std::ostream& os, const Sales_data& rhs)
{
os << rhs.isbn() << " ";
os << rhs.units_sold << " ";
os << rhs.revenue << " ";
os << rhs.get_price();
return os;
}
- read和print分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因为我们只能使用引用来进行传递。并且在函数体内需要修改IO对象的状态,所以IO对象不能被设置成const。
- 一切输出的函数不能进行换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制。
定义add函数
Sales_data add(const Sales_data& lhs, const Sales_data& rhs)
{
Sales_data ret = lhs;
ret.combine(rhs);
return ret;
}
7.1.3节练习
练习7.6:对于函数add、read和print,定义你自己的版本。
std::istream& read(std::istream& is, Sales_data& rhs)
{
double single_price = 0.00
is >> rhs.bookNo >> rhs.units_sold >> single_price;
rhs.revenue = single_price * rhs.units_sold;
return is;
}
std::ostream& print(std::ostream& os, const Sales_data& rhs)
{
os << rhs.isbn() << " ";
os << rhs.units_sold << " ";
os << rhs.revenue << " ";
os << rhs.get_price();
return os;
}
Sales_data add(const Sales_data& lhs, const Sales_data& rhs)
{
Sales_data ret = lhs;
ret.combine(rhs);
return ret;
}
练习7.7:使用这些新的函数重写7.1.2节(第233页)练习中的交易处理程序。
#include
#include
#include "Sales_data.h"
int main(int argc, char const *argv[])
{
Sales_data total;
if (read(std::cin, total)) {
Sales_data trans;
while (read(std::cin, trans)) {
if (trans.isbn() == total.isbn()) {
total.combine(trans); // 这一使用新的成员函数
} else {
print(std::cout, total);
total = trans; // 这里可以直接使用默认拷贝赋值运算符
}
}
// 输出最后一本书
print(std::cout, total);
} else {
std::cerr << "No Data" << std::endl;
return -1;
}
return 0;
}
练习7.8:为什么read函数将其Sales_data参数定义成普通的引用,而print将其参数定义成常量引用?
因为在read函数中,需要从输入流中读取数据然后修改Sales_data对象,所以需要将参数定义为普通类型的;而print函数中不涉及对于Sales_data对象的修改,故将参数设定成为常量,防止函数修改传入的对象。
练习7.9:对于7.1.2节练习中的代码,添加读取和打印Person对象的操作。
#include
struct Person;
std::istream& read(std::istream& is, Person& rhs);
std::ostream& print(std::ostream& os, const Person& rhs);
struct Person {
std::string get_name() const { return name; }
std::string get_address() const { return address; }
std::string name;
std::string address;
};
std::istream& read(std::istream& is, Person& rhs)
{
is >> rhs.name >> address;
return is;
}
std::ostream& print(std::ostream& os, const Person& rhs)
{
os << rhs.name << " " << rhs.address;
return os;
}
练习7.10:在下面这条if语句中,条件部分的作用是什么?
if (read(read(std::cin, data1), data2)) { ... }
先从标准输入流中向data1写入数据,如果这个动作成功,则继续向data2中写入数据。
7.1.4 构造函数
类通过一个或者几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同。构造函数没有返回类型。构造函数的参数列表和函数体都可能为空。类可以包含多个构造函数,但是不同的构造函数之间必须在参数数量或者参数类型上有所区别。
构造函数不能被声明成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(n * p) { }
Sales_data(std::istream& is);
// ...
};
= default的含义
我们来解释第4行的程序。首先明确一点:因为该函数不接受任何实参,所以它是一个默认构造函数。
在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上= 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(n * p) { }
我们把新出现的部分称为构造函数初始值列表,它负责为新创建的对象的一个或者几个数据成员赋初始值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的成员初始值。不同成员初始化通过逗号分隔开来。
当某个数据成员被构造函数初始值列表忽略时,它将被默认初始化。
构造函数不应该轻易覆盖掉类内的初始值,除非新的值与类内初始值不同。如果不能使用类内初始值,则所有构造函数都应该显式的初始化每个内置类型成员。
在类的外部定义构造函数
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(n * p) { }
Sales_data(std::istream& is);
// ...
};
Sales_data::Sales_data(std::istream& is) // 这里没有初始值列表
{
read(is, *this);
}
这里一定要注意构造函数没有返回类型,所以上述定义从我们制定的函数名字开始。如果在类外定义成员函数,一定要使用作用域运算符指明当前函数属于哪个类!
7.1.4节练习
练习7.11:在你的Sales_data类中添加构造函数,然后编写一段程序令其用到每个构造函数。
#include
#include
struct Sales_data {
Sales_data() = default;
Sales_data(const Sales_data& rhs) :
bookNo(rhs.bookNo), units_sold(rhs.units_sold), revenue(rhs.revenue) { }
Sales_data(const std::string& s) :
bookNo(s) { }
Sales_data(const std::string& s, unsigned n, double p) :
bookNo(s), units_sold(n), revenue(n * p) { }
Sales_data(std::istream& is);
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
int main(int argc, char const *argv[])
{
Sales_data sd1;
Sales_data sd2(std::string("Gone with the wind"));
Sales_data sd3(std::string("Red and black"), 10, 3.99);
Sales_data sd4(std::cin);
Sales_data sd5(sd2);
return 0;
}
练习7.12:把只接受一个istream作为参数的构造函数的定义移到类的内部。
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(n * p) { }
Sales_data(std::istream& is) { read(is, *this); }
};
练习7.13:使用istream构造函数重写第229页的程序。
#include
#include
#include "Sales_data.h"
int main(int argc, char const *argv[])
{
if (std::cin) {
Sales_data total(std::cin);
while (std::cin) {
Sales_data trans(std::cin);
if (total.isbn() == trans.isbn()) {
total.combine(trans);
} else {
printf(std::cout, total) << std::endl;
total = trans;
}
}
print(std::cout, total) << std::endl;
} else {
std::cerr << "No Data" << std::endl;
return -1;
}
return 0;
}
练习7.14:编写一个构造函数,令其用我们提供的类内初始值显示的初始化成员。
Sales_data() :
bookNo(std::string()), units_sold(0), revenue(0.0) { }
练习7.15:为你的Person类添加正确的构造函数。
#include
#include
struct Person;
std::istream& read(std::istream& is, Person& rhs);
std::ostream& print(std::ostream& os, const Person& rhs);
struct Person {
Person() = default;
Person(const std::string& s, const std::string& a) :
name(s), address(a) { }
Person(const Person& rhs) :
name(rhs.get_name()), address(rhs.get_address()) { }
Person(std::istream& is) { read(is, *this); }
std::string get_name() const { return name; }
std::string get_address() const { return address; }
std::string name;
std::string address;
};
std::istream& read(std::istream& is, Person& rhs)
{
is >> rhs.name >> address;
return is;
}
std::ostream& print(std::ostream& os, const Person& rhs)
{
os << rhs.name << " " << rhs.address;
return os;
}
7.1.5 拷贝、赋值和析构
当我们使用赋值运算符时会发生对象的赋值操作。当对象不再存在时执行销毁的操作。
某些类不能依赖于合成的版本
管理动态内存的类通常不能依赖于上述操作的合成版本。
7.2 访问控制与封装
在C++语言中,我们使用访问说明符加强类的封装性:
- 定义在public说明符之后的成员在整个程序内可以被访问,public成员定义类的接口。
- 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了类的实现细节。
一个类可以包含0个或者多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定。每个访问说明符的范围直到下一个访问说明符或者类的结尾为止。
使用class或者struct关键字
我们可以使用这两个关键字中的任何一个定义类。唯一的一点区别是,struct和class的默认访问权限不一样。
类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式:如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public;如果使用的是class关键字,则这些成员默认是private的。
使用class和struct定义类唯一的区别就是默认的访问权限。
7.2节练习
练习7.16:在类的定义中对于访问说明符出现的位置和次数有限定吗?如果有,是什么?什么样的成员应该定义在public说明符之后?什么样的成员应该定义在private说明符之后?
类的定义中对于访问说明符出现的位置和次数没有限定。在类中应当将接口定义在public说明符之后,而将成员变量定义在private说明符之后。
练习7.17:使用class和struct时有区别吗?如果有,是什么?
有区别:使用class关键字时成员的默认访问权限是public,而使用struct关键字时成员的默认访问权限是private。
练习7.18:封装是何含义?它有什么用处?
封装的含义是类将其部分成员对使用其对象的代码隐藏起来,使这些用户代码无法直接访问其成员变量。封装的意义在于使用户代码不能直接修改内部成员变量,提高了健壮性。
练习7.19:在你的Person类中,你将把哪些成员声明成public的?哪些声明成private的?解释你这样做的原因。
要将Person类中的成员变量即name和address声明为私有成员;然后将除此之外的成员声明成为公有的。这样做的原因是防止用户代码对成员变量直接进行修改。
7.2.1 友元
允许一个用户代码访问一个类的非公有成员,方法是:令这部分用户代码成为该类的友元。
如果一个类想把一个函数作为它的友元,只需要添加一条以friend关键字开始函数声明语句即可:
class Sales_data {
friend Sales_data add (const Sales_data& lhs, const Sales_data& rhs);
// ...
};
友元声明只能出现在类的内部,但是在类内出现的具体位置不限。因为友元不是类的成员,也不受它所在区域访问控制级别的约束。
一般来说,最好在类定义开始或者结束前的位置集中声明友元。
封装的优点:
- 确保用户代码不会无意间破坏封装对象的状态。
- 被封装的具体实现细节可以随时改变,而无需调整用户级别的代码。
- 防止因为用户的原因造成数据被破坏。
友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明!
为了使友元对类的用户可见,通常把友元的声明与类本身放置在同一个头文件中(类的外部)。
再次强调:必须在类的外部提供一个友元函数的独立声明。
7.2.1节练习
练习7.20:友元什么时候有用?请分别列举出友元的利弊。
当一个类希望外部代码访问该类的非公有成员时,即可将这部分代码设置为这个类的友元。友元的优点在于可以使外部代码直接访问非公有成员并且修改数据,弊端在于如果外部代码进行非法修改也没有好的办法进行阻止。
练习7.21:修改你的Sales_data类使其隐藏实现的细节。你之前编写的关于Sales_data操作的程序应该继续使用,借助类的新定义重新编译该程序,确保其正常工作。
// 统一在类的外部进行独立声明:
class Sales_data;
Sales_data add (const Sales_data& lhs, const Sales_data& rhs);
std::istream& read(std::istream& is, Sales_data& rhs);
std::ostream& print(std::ostream& os, const Sales_data& rhs);
class Sales_data {
// 将三个外部函数声明为友元即可:
friend Sales_data add (const Sales_data& lhs, const Sales_data& rhs);
friend std::istream& read(std::istream& is, Sales_data& rhs);
friend std::ostream& print(std::ostream& os, const Sales_data& rhs);
// 旧的代码...
};
练习7.22:修改你的Person类使其隐藏实现的细节。
#include
#include
class Person;
std::istream& read(std::istream& is, Person& rhs);
std::ostream& print(std::ostream& os, const Person& rhs);
class Person {
friend std::istream& read(std::istream& is, Person& rhs);
friend std::ostream& print(std::ostream& os, const Person& rhs);
public:
Person() = default;
Person(const std::string& s, const std::string& a) :
name(s), address(a) { }
Person(const Person& rhs) :
name(rhs.get_name()), address(rhs.get_address()) { }
Person(std::istream& is) { read(is, *this); }
std::string get_name() const { return name; }
std::string get_address() const { return address; }
private:
std::string name;
std::string address;
};
std::istream& read(std::istream& is, Person& rhs)
{
is >> rhs.name >> rhs.address;
return is;
}
std::ostream& print(std::ostream& os, const Person& rhs)
{
os << rhs.name << " " << rhs.address;
return os;
}
7.3 类的其他特性
7.3.1 类成员再探
为了展示新的特性,我们需要定义一对相互关联的类:Screen和Window_mgr。
定义一个类型成员
Screen表示显示器中的一个窗口。每个Screen包含一个用于保存Screen内容的string成员和三个string::size_type类型的成员,它们分别表示光标的位置以及该窗口的高和宽。
类中还可以使用typedef关键字定义类型的别名。这个别名也可以设置访问权限。
class Screen {
public:
typedef std::string::size_type pos; // 用户可以使用这个别名
// 这里也可以写作:
// using pos = std::string::size_type;
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(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; // 获取坐标为ht,wd的字符
Screen& move(pos r, pos c); // 移动窗口
private:
pos cursor = 0;
pos height = 0;
pos width = 0;
std::string contents;
};
令成员作为内联函数
一些规模较小的函数适合于被声明成内联函数。定义在类内部的成员函数是自动inline的。
当然,我们也能在类的外部使用inline关键字来修饰函数得定义。
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) { }
char get() const { return contents[cursor]; }
inline char get(pos ht, pos wd) const; // 获取坐标为ht,wd的字符
Screen& move(pos r, pos c); // 移动窗口
private:
pos cursor = 0;
pos height = 0;
pos width = 0;
std::string contents;
};
inline // 这里显式的声明为内联函数
Screen& Screen::move(pos r, pos c)
{
pos row = r * width;
corsor = row + c;
return *this;
}
char Screen::get(pos r, pos c) const
{
pos row = r * width;
return contents[row + c];
}
inline成员函数应当与类定义在同一个头文件中。
重载成员函数
成员函数也可以被重载,只要函数之间在参数的数量和/或类型上有所区别就行。
可变数据成员
如果希望修改类的某个数据成员,即使是在一个const成员函数内。可以通过在该变量声明中加入mutable关键字。
一个可变数据成员永远不会是const,即使它包含在const对象中。因此,一个const成员函数可以改变一个可变成员的值。
类数据成员的初始值
现在开始定义一个窗口管理类并用它表示显示器上的一组Screen。这个类将包含一个Screen类型的vector,每个元素表示一个特定的Screen。
class Window_mgr {
private:
// 至少要有一个窗口可以进行管理
std::vector screens {Screen(24, 80, ' ')};
};
类内初始值必须使用赋值运算符的初始化形式,或者花括号括起来的直接初始化形式。
7.3.1节练习
练习7.23:编写你自己的Screen类。
class Screen {
public:
using pos = std::string::size_type;
Screen() = default;
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;
pos width = 0;
std::string contents;
};
inline
Screen& Screen::move(pos r, pos c)
{
pos row = r * width;
corsor = row + c;
return *this;
}
char Screen::get(pos r, pos c) const
{
pos row = r * width;
return contents[row + c];
}
练习7.24:给你的Screen类添加三个构造函数:一个默认构造函数;一个接受宽和高的值,然后将contents初始化成给定数量的空白;最后的构造函数接受宽高以及一个字符,该字符作为初始化之后屏幕的内容。
class Screen {
public:
Screen() = default;
Screen(int h, int w) :
height(h), width(w), contents(h * w, ' ');
Screen(int h, int w, char c) :
height(h), width(w), contents(h * w, c);
// ...
};
练习7.25:Screen类能安全的依赖于拷贝和操作赋值的默认版本吗?如果能,为什么?如果不能,为什么?
Screen类可以安全依赖默认版本,因为成员变量以及成员函数不涉及任何的动态内存管理,并且使用的类型都是内置类型和标准库类型。
练习7.26:将Sales_data::avg_price()
定义成内联函数。
class Sales_data {
public:
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data& rhs);
double avg_price() const { return revenue / units_sold; } // 定义在类的内部的成员函数是自动inline的
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
Sales_data& Sales_data::combine(const Sales_data& rhs)
{
if (bookNo == rhs.bookNo) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
}
return *this;
}
7.3.2 返回*this的成员函数
我们来编写一些函数,它们负责设置光标所在位置的字符或者其他任一给定位置的字符:
class Screen {
public:
inline Screen& set(char ch); // 不指定位置,以当前位置为指定位置
inline Screen& set(pos h, pos w, char ch); // 指定位置
};
inline Screen& set(char ch)
{
contents[cursor] = ch;
return *this;
}
inline Screen& set(pos r, pos c, char ch)
{
contents[r * width + c] = ch;
return *this;
}
返回调用者对象的引用的好处就是可以编写链式调用的代码:
myScreen.move(4, 0).set('#');
即:把光标先移动到(4,0)然后再将其设置为字符#。调用运算符是左结合的。
从const成员函数返回*this
现在我们添加一个名为display的操作,它负责打印Screen的内容。这个函数也应当返回执行它的对象的引用。
这里存在一个问题,当一个const成员函数以引用的形式返回当前对象,那么其返回类型为常量引用。
基于const的重载
通过区分成员函数是否是const的,我们也可以对其重载。这是因为常量对象是不能调用非常量成员函数的,所以常量的对象会自动调用常量版本的成员函数。
建议:对于公共代码使用私有功能函数
- 一个基本的的愿望是避免在多处使用相同的代码。
- 我们预期随着类的规模发展,display函数有可能变得更加复杂,此时,把相应的操作写在一处而非两处的作用就比较明显了。
- 如果增加调试信息,只需要在私有功能中添加即可,免去了更多的麻烦。
- 额外的函数调用不会增加或者很少增加任何的开销。
7.3.2节练习
练习7.27:给你自己的Screen类添加move、set和display函数,通过执行下面的代码检验你的类是否正确。
#include
#include
class Screen {
public:
using pos = std::string::size_type;
Screen() = default;
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;
inline Screen& set(char ch);
inline Screen& set(pos h, pos w, char ch);
Screen& move(pos r, pos c);
Screen& display(std::ostream& os);
const Screen& display(std::ostream& os) const;
private:
pos cursor = 0;
pos height = 0;
pos width = 0;
std::string contents;
void display_kernel(std::ostream& os) const;
};
Screen& Screen::display(std::ostream& os)
{
display_kernel(os);
return *this;
}
const Screen& Screen::display(std::ostream& os) const
{
display_kernel(os);
return *this;
}
void Screen::display_kernel(std::ostream& os) const
{
os << contents;
}
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
{
pos row = r * width;
return contents[row + c];
}
inline Screen& Screen::set(char ch)
{
contents[cursor] = ch;
return *this;
}
inline Screen& Screen::set(pos r, pos c, char ch)
{
contents[r * width + c] = ch;
return *this;
}
int main(int argc, char const *argv[])
{
Screen myScreen(5, 5, 'X');
myScreen.move(4, 0).set('#').display(std::cout);
std::cout << std::endl;
myScreen.display(std::cout);
std::cout << std::endl;
return 0;
}
练习7.28:如果move、set和display函数的返回值不是Screen&而是Screen,则在上一个练习中将会发生什么情况?
如果move、set和display函数的返回值不是Screen&而是Screen,那么输出将会是:
XXXXXXXXXXXXXXXXXXXX#XXXX
XXXXXXXXXXXXXXXXXXXXXXXXX
练习7.29:修改你的Screen类,令move、set和display函数返回Screen并检查程序运行结果,再上一个练习中你的推测正确吗?
只需要修改7.27中的程序即可,结果完全正确。
练习7.30:通过this指针使用成员的做法虽然合法,但是优点多余。讨论显式的使用指针访问成员的优缺点。
- 优点:如果调用的成员函数有多个传入的对象,使用this指针可以明确哪个是当前调用函数的对象。
- 缺点:如果当前调用中没有设计其他当前类的对象,那么就会使程序显得繁琐。
7.3.3 类类型
每个类定义了唯一的类型。对于两个类来说,即使他们的成员完全一样,这两个类也是两个不同的类型。
即使连个类的成员列表完全已知,它们也是不同的类型。对于一个类来说,它的成员和其他任何类(或者其他任何作用域)的成员都不是一回事。
类的声明
我们可以仅仅声明类而暂时不定义它:class Screen; // 仅仅声明
。
这种声明被称作向前声明,它向程序中引入了名字Screen并且指明Screen是一种类型。对于这种声明后的类型来说,在它声明之后定义前是一个不完全类型,也就是说,此时我们已知Screen是一个类类型,但是不清楚它包含哪些成员。
不完全类型只能在非常有限的情境下使用:可以定义指向这种类型的指针或者引用,也可以声明以不完全类型作为参数或者返回类型函数。
一个类在创建对象前必须被定义过!
7.3.3节练习
练习7.31:定义一对类X和类Y,其中X包含一个指向Y的指针,而Y包含一个类型为X的对象。
struct X;
struct Y;
struct X {
Y* y_ptr;
};
struct Y {
X x_obj;
};
7.3.4 友元再探
类还可以把其他的类定义成友元,也可以把其他类(已定义)的成员函数定义成友元。友元函数能定义在类的内部,这样的函数是隐式内联的。
类之间的友元关系
现在我们的Window_mgr添加一个名为clear的成员函数,它负责把一个指定的Screen的内容都设为空白。这时Window_mgr::clear就需要访问Screen的私有成员了。所以这里,Screen就需要把Window_mgr设置为它的友元:
class Screen {
friend class Window_mgr; // 这样Window_mgr类的成员就可以访问Screen类的私有成员
// ...
};
每个类负责控制自己的友元或者友元函数。
令成员函数作为友元
上个部分是将整个类设置为友元,现在我们还可以只为clear函数提供访问权限。当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于那个类:
class Screen {
// Window_mgr::clear必须在Screen类之前被声明!
friend void Window_mgr::clear(int screen_index);
// ...
};
这里注意:要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在以上的例子中,我们必须按照如下的方式设计程序:
- 首先声明并且定义Window_mgr类,其中必须声明clear函数,但是不能定义它。因为在clear定义和使用Screen的成员之前必须先声明Screen。
- 接下来声明并且定义Screen类,包括对于clear的友元声明。
- 最后定义clear,此时它才能使用Screen的成员。
函数重载和友元
如果一个类想把一组重载函数生命成它的友元,它需要对这组函数中的每一个分别声明。
友元声明和作用域
即使我们仅仅是用声明友元的类的成员调用该友元函数,他也必须是被声明过的。
7.3.4节练习
练习7.32:定义你自己的Screen和Window_mgr,其中clear是Window_mgr的成员,是Screen的友元。
class Window_mgr;
class Screen;
class Window_mgr {
void clear(int screen_index);
};
class Screen {
friend void Window_mgr::clear(int screen_index);
};
void Window_mgr::clear(int screen_index)
{
// ...函数定义
}
7.4 类的作用域
在类的作用域外,普通的数据和成员函数只能由对象、引用或者指针使用成员运算符来访问。对于类类型成员则使用作用域运算符来访问。
作用域和定义在类外部的成员
一个类就是一个作用域,当我们在类的外部要使用类内定义的无论是变量,函数或者类型时,都要使用作用域运算符来指定特定的类。
7.4节练习
练习7.33:如果我们给Screen添加一个如下所示的size成员将发生什么情况?如果出现了问题,请尝试修改它。
// 修改前:
pos Screen::size() const { return height * width; }
// 这里会产生编译错误,即类型pos未被定义
// 修改后:
Screen::pos Screen::size() const { return height * width; }
7.4.1 名字查找与类的作用域
名字查找的过程比较直接:
- 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
- 如果没找到,继续查找外层作用域。
- 如果最终没有找到匹配声明,则程序报错。
对于定义在类内部的成员来说,类的定义分两步处理:
- 首先,编译成员的声明。
- 直到类全部可见之后才开始编译函数体。
编译器处理完类中的全部声明后才会处理成员函数的定义。
用于类成员声明的名字查找
声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。
类名要特殊处理
内层作用域的定义可以覆盖定义在外层作用域的名字,即使外层的名字已经在内层使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
成员定义中的普通块作用于的名字查找
成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才会被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
不建议使用其他成员的名字作为某个成员函数的参数。
类作用域之后,在外围的作用域中查找
如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。
当外层作用域中的名字被内层的隐藏,但是有需要使用,那么就需要使用显式的调用作用域运算符来获取全局变量:
// 不建议的写法:不要隐藏外层作用域中可能被用到的名字
#include
int val = 10; // 1
void func(int val) // 2
{
std::cout << val << std::endl; // 2
std::cout << ::val << std::endl; // 1
}
int main(int argc, char const *argv[])
{
func(45);
return 0;
}
在文件中名字的出现处对其进行解析
当成员函数定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还要考虑在成员函数定义之前的全局作用域中的声明。
练习7.34:如果我们把第256页Screen类的pos的typedef放在类的最后一行会发生什么情况?
首先在他的下一行就会出现类型没有定义
的编译错误。
练习7.35:解释下面代码的含义,说明其中的Type和InitVal分别使用了哪个定义。如果代码存在错误,尝试修改它。
#include
#include
// 这里加上函数体是为了方便运行检测
typedef std::string Type;
Type InitVal() { return "Hello"; }; // std::string
class Exercise {
public:
typedef double Type;
Type setVal(Type parm); // double
Type InitVal() { return 3.14; } // double
private:
int val = 0;
};
// 修改之前的函数定义:
// Type Exercise::setVal(Type parm)
// {
// val = parm + InitVal();
// return val;
// }
// 编译报错:
// a.cc:26:6: error: no declaration matches 'Type Exercise::setVal(Exercise::Type)'
// Type Exercise::setVal(Type parm)
// ^~~~~~~~
// a.cc:11:10: note: candidate is: 'Exercise::Type Exercise::setVal(Exercise::Type)'
// Type setVal(Type parm); // double
// ^~~~~~
// a.cc:8:7: note: 'class Exercise' defined here
// class Exercise {
// ^~~~~~~~
// 修改后的函数定义:
Exercise::Type Exercise::setVal(Exercise::Type parm)
{
val = parm + InitVal(); // 成员函数,返回double类型的变量
return val;
}
7.5 构造函数再探
7.5.1 构造函数初始值列表
如果没有在构造函数的初始值列表中显式的初始化成员,则该成员将在构造函数体之前执行默认初始化。例如:
class Foo {
public:
Foo(const int v, const std::string& s) {
val = v; // 这样看似是执行的初始化,其实初始化在函数体之前已经完成
str = s; // 现在执行的是赋值
}
private:
int val;
std::string str;
};
书上的代码和之前使用初始值列表的定义是相同的:当构造函数完成后,数据成员的值相同。区别是原来的版本初始化了它的数据成员,而这个版本是对数据成员执行了赋值操作。两种方式的差异会产生什么影响依赖于数据本身。
构造函数的初始值有时必不可少
如果成员是const的话,我们就必须使用初始值列表将其初始化而非赋值。
随着构造函数体开始执行,初始化就完成了,也就是说,“初始化”这个动作是在构造函数函数体执行之前完成的。我们初始化const或者引用类型数据成员的唯一机会就是通过构造函数初始值。
如果成员是const、引用,或者属于某种为提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初始值。
建议:使用构造函数初始值
初始化和赋值的区别事关底层效率的问题:前者直接初始化数据成员,后者则先初始化再赋值。而且一些数据成员必须被初始化。
成员初始化的顺序
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
成员的初始化顺序与它们在类定义中出现的顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某个成员初始化其他成员。
默认实参和构造函数
如果一个构造函数为所有的参数都提供了默认实参,则它实际上也定义了默认构造函数。
7.5.1节练习
练习7.36:下面的初始值是错误的,请找出问题所在并尝试修复它。
// 修改前:
struct X {
X(int i, int j) :
base(i), rem(base % j) { }
// 因为rem是先被初始化的,所以当rem初始化时,base还是未定义的
// 这时rem其实是被拿一个未定义的值给初始化了
int rem;
int base;
};
// 修改后:
struct X {
X(int i, int j) :
base(i), rem(base % j) { }
int base;
int rem;
};
练习7.37:使用本节提供的Sales_data类,确定初始化下面的变量时分别使用了哪个构造函数,然后罗列出每个对象所有数据成员的值。
Sales_data first_item(std::cin); // Sales_data(std::istream& is);
int main(int argc, char const *argv[]) {
Sales_data next; // Sales_data(std::string s = ""); 功能相当于默认构造函数
Sales_data last("9-999-99999-9"); // Sales_data(std::string s);
return 0;
}
练习7.38:有些情况下我们希望提供cin作为接受istream&参数的构造函数的默认实参,请声明这样的构造函数。
Sales_data(std::istream& is = std::cin);
练习7.39:如果接受string的构造函数和接受istream&的构造函数都使用默认实参,这种行为合法吗?如果不,为什么?
不合法,因为当一个对象要使用默认初始化时,编译器则不清楚到底该使用哪一个构造函数进行初始化。
练习7.40:从下面的抽象概念中选择一个,思考这样的类需要哪些数据成员,提供一组合理的构造函数并阐明这样做的原因。
(a) Book (b) Date (c) Employee (d) Vehicle (e) Object (f) Tree
class Book {
public:
Book() = default; // 默认构造函数
Book(std::string name, std::string no) :
book_name(name), book_no(no) { } // 至少提供一个书名和一个编码
Book(std::string name, std::string no, int us; double pr) :
book_name(name), book_no(no), units_sold(us), price(pr) { } // 全部提供
Book(std::istream& is); // 依靠输入流进行构造
private:
std::string book_name; // 书名
std::string book_no; // 书编号
int units_sold = 0; // 卖出总数
double price = 0.0; // 单价
};
7.5.2 委托构造函数
一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些或者全部指责委托给了其他构造函数。
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。也就是说,接受委托的构造函数的初始化列表和函数体先执行,执行完毕之后才会把控制权交给委托者的函数体。
7.5.2节练习
练习7.41:使用委托构造函数重新编写你的Sales_data类,给每个构造函数体添加一条语句,令其一旦执行就打印一条信息。用各种可能的方式分别创建Sales_data对象,人真研究每次输出的信息直到你确实理解了委托构造函数的执行顺序。
struct Sales_data
{
Sales_data() = default;
Sales_data(const Sales_data& rhs) :
bookNo(rhs.bookNo), units_sold(rhs.units_sold), revenue(rhs.revenue) { }
Sales_data(const std::string& s, unsigned n, double p) :
bookNo(s), units_sold(n), revenue(n * p) { std::cout << "Sales_data(const std::string& s, unsigned n, double p)" << std::endl; }
Sales_data(const std::string& s) :
Sales_data(s, 0, 0.0) { std::cout << "Sales_data(const std::string& s)" << std::endl; }
Sales_data(std::istream& is);
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
练习7.42:对于你在练习7.40中编写的类,确定哪些构造函数可以使用委托。如果可以的话,编写委托构造函数。如果不可以,从抽象概念列表中重新选择一个你认为可以使用委托构造函数的,为挑选出的这个概念编写类定义。
class Book {
public:
Book() = default; // 默认构造函数
Book(std::string name, std::string no, int us; double pr) :
book_name(name), book_no(no), units_sold(us), price(pr) { } // 全部提供
Book(std::string name, std::string no) :
Book(name, no, 0, 0.0) { } // 委托上面的构造函数进行构造
Book(std::istream& is); // 依靠输入流进行构造
private:
std::string book_name; // 书名
std::string book_no; // 书编号
int units_sold = 0; // 卖出总数
double price = 0.0; // 单价
};
7.5.3 默认构造函数的作用
默认初始化在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
- 当类类型的成员没有在构造函数初始值列表中显式的初始化时。
值初始化在以下情况发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组大小时。
- 当我们不使用初始值定义一个局部静态变量时。
- 当我们通过书写形如
T()
的表达式显式的请求值初始化时。
类必须包含一个默认构造函数以便在上述情况下使用,其中大多数情况非常容易判断。
在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。
使用默认构造函数
当我们想使用默认构造函数定义一个对象时,应当写成如下形式:Sales_data obj;
当我们写成Sales_data obj();
时,我们定义的不是一个通过默认构造函数构造的对象,而是声明了一个返回值为Sales_data,函数名为obj的函数。
7.5.3节练习
练习7.43:假定有一个名为NoDefault的类,它有一个接受int的构造函数,但是没有默认构造函数。定义类C,C有一个NoDefault类型的成员,定义C的默认构造函数。
class NoDefault {
public:
NoDefault(int i) : val(i) { std::cout << "NoDefault Construtor" << std::endl; }
private:
int val = 0;
};
class C {
public:
C() : menb(10) { std::cout << "C Construtor" << std::endl; } // 这里才是初始化
private:
NoDefault menb; // 这里只是声明
};
练习7.44:下面的这条声明合法吗?如果不,为什么?
vector
不合法,因为NoDefault没有默认构造函数。
练习7.45:如果在上一个练习中定义的vector的元素类型是C,则声明合法吗?为什么?
合法,因为C有默认构造函数。
练习7.46:下面哪些论断是不正确的?为什么?
- 一个类必须至少提供一个构造函数。 答:不正确,如果一个类一个构造函数都不提供,编译器会合成一个默认构造函数。
- 默认构造函数是参数列表为空的构造函数。答:不正确,如果有一个类有参数列表但是每个形参都有默认值则也可以被作为默认构造函数。
- 如果对于类来说不存在有意义的默认值,则类不应该提供默认构造函数。答:不正确,因为有些类型无法被默认初始化。
- 如果类没有定义默认构造函数,则编译器将为其生成一个并把每个数据成员初始化成相应类型的默认值。答:不正确,只有在用户没有提供任何构造函数的时候编译器才会为类合成一个默认构造函数。
7.5.4 隐式的类类型转换
如果一个构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制。我们把这种构造函数称作转换构造函数。
能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。
在前面的Sales_data类中,接受string的构造函数和接受istream的构造函数分别定义了从这两种类型向Sales_data隐式转换的规则。也就是说,在需要使用Sales_data的地方,我们可以使用string或者istream作为替代。
只允许一步类类型转换
编译器只会自动地执行一步类型转换。
类类型转换不是总有效
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止。
explicit构造函数只能用于直接初始化
当我们用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。
为转换显式的使用构造函数
我们可以使用explicit构造函数进行强行转换:
// 正确:实参是一个显式构造的Sales_data对象
item.combine(Sales_data(null_book));
// 正确:static_cast可以使用explicit构造函数
item.combine(static_cast(std::cin));
标准库中含有显式构造函数的类
7.5.4节练习
练习7.47:说明接受一个string参数的Sales_data构造函数是否应该是explicit的,并解释这样做的优缺点。
不应该被设置为explicit的,这样的话可以直接使用string类型来进行构造Sales_data对象。优点是比较便捷;缺点是需要经历一次隐式转换,有可能造成性能问题。
练习7.48:假定Sales_data的构造函数不是explicit的,则下述定义将执行什么样的操作?
string null_isbn("9-999-99999-9");
Sales_data item1(null_isbn); // 正确
Sales_data item2("9-999-99999-9"); // 正确
如果Sales_data的构造函数是explicit的,又会发生什么呢?
string null_isbn("9-999-99999-9");
Sales_data item1(null_isbn); // 正确
Sales_data item2("9-999-99999-9"); // 错误:不能发生隐式转换
练习7.49:对于combine函数的三种不同声明,当我们调用i.combine(s)时分别发生了什么情况?其中i是一个Sales_data,而s是一个string对象。
Sales_data& combine(Sales_data rhs); // 将s的值赋给形参rhs
Sales_data& combine(Sales_data& rhs); // 直接将s赋给rhs,也就是传引用
Sales_data& combine(const Sales_data& rhs) const; // 错误,this指针将会变成底层const,无法修改this指向的的内容
练习7.50:确定在你的Person类中是否有一些构造函数应该是explicit的。
没有,因为最少传值都达到了两个参数,所以不存在隐式转换的问题。
练习7.51:vector将其单参数的构造函数定义成explicit的,而string不是,你觉得原因何在?
string对象需要将字符串字面量转换为当前对象的内容,而vector需要类型和数量两个指标,所以避免发生需要转换错误。
7.5.5 聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是public的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有虚函数。
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员。这里初始值的顺序必须与声明的顺序一致。
如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。
7.5.5节练习
练习7.52:使用2.6.1节(第64页)的Sales_data类,解释下面的初始化过程。如果存在问题,尝试修改它。
Sales_data item = {"978-0590353403", 25, 15.99};
struct Sales_data {
std::string bookNo; // "978-0590353403"
unsigned units_sold = 0; // 25
doube revenue = 0.0; // 15.99
};
7.5.6 字面值常量类
constexpr函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其它类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,他们是隐式const的。
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但是它符合下述要求,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型。
- 类必须至少含有一个constexpr构造函数。
- 类内初始值必须是常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
- 类必须使用析构函数的默认版本。
constexpr构造函数
字面值常量类的构造函数可以试constexpr函数。一个字面值常量类必须至少提供一个constexpr构造函数。
7.6 类的静态成员
有时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。
声明静态成员
我们通过在成员的声明之前加上关键字static使得其与类关联在一起。和其他成员一样,静态成员可以是公有或者私有的。静态数据成员的类型可以是常量、引用、指针、类类型等。
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。
类似的,静态成员函数也不与任何对象绑在一起,它们不包含this指针。静态成员函数不能声明成const的,并且也不能在static函数体内使用this指针。无论显式还是隐式。
使用类的静态成员
我们使用作用域运算符直接访问静态成员。虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象,引用或者指针来访问静态成员。
成员函数不通过作用域运算符就能直接使用静态成员。
定义静态成员
既可以在类的内部,也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句。
和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static关键字则只出现在类内部的声明语句中。
因为静态数据成员不属于类的的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。一个静态数据成员只能定义一次。
静态数据成员定义在任何函数之外。因此一旦被定义,就将一直存在于程序的整个生命周期中。
要想确保对象只定义一次,最好的办法就是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
静态成员的类内初始化
类的静态成员不应该在类的内部初始化。然而,可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr。
静态成员能用于某些场景,而普通成员不能
静态成员独立于任何对象。静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类的类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或者引用。
我们可以使用静态成员作为默认实参。而非静态数据成员不能作为默认实参。
7.6节练习
练习7.56:什么是类的静态成员?它有何优点?静态成员与普通成员有何区别?