第7章 类

  • 第7章 类
    • 7.1 定义抽象数据类型
      • 7.1.1 设计Sales_data类
        • 使用改进的Sales_data
      • 7.1.2 定义改进的Sales_data类
        • 定义成员函数
        • 引入this
        • 引入const成员函数
        • 类作用域和成员函数
        • 在类的外部定义成员函数
        • 定义一个返回this对象的函数
      • 7.1.3 定义类相关的非成员函数
        • 定义readprint函数
        • 定义add函数
      • 7.1.4 构造函数 (constructor)
        • 合成的默认构造函数
        • 某些类不能依赖于合成的默认构造函数
        • 定义Sales_data的构造函数
        • =default的含义
        • 构造函数初始值列表
        • 在类的外部定义构造函数
      • 7.1.5 拷贝、赋值和析构
        • 某些类不能依赖于合成的版本
    • 7.2 访问控制与封装
      • 使用classstruct关键字
      • 7.2.1 友元
        • 友元的声明
    • 7.3 类的其他特性
      • 7.3.1 类成员再探
        • 定义一个类型成员
        • Screen类的成员函数
        • 令成员作为内联函数
        • 重载成员函数
        • 可变数据成员
        • 类数据成员的初始值
      • 7.3.2 返回*this的成员函数
        • const成员函数返回*this
        • 基于const的重载
      • 7.3.3 类类型
        • 类的声明
      • 7.3.4 友元再探
        • 类之间的友元关系
        • 令成员函数作为友元
        • 函数重载和友元
        • 友元声明和作用域
    • 7.4 类的作用域
      • 作用域和定义在类外部的成员
      • 7.4.1 名字查找与类的作用域
        • 用于类成员声明的名字查找
        • 类型名要特殊处理
        • 成员定义中的普通快作用域的名字查找
        • 类作用域之后,在外围的作用域中查找
        • 在文件中名字的出现处对其进行解析
    • 7.5 构造函数再探
      • 7.5.1 构造函数初始值列表
        • 构造函数的初始值有时必不可少
        • 成员初始化的顺序
        • 默认实参和构造函数
      • 7.5.2 委托构造函数 (delegating constructor)
      • 7.5.3 默认构造函数的作用
        • 使用默认构造函数
      • 7.5.4 隐式的类类型转换
        • 只允许一步类类型转换
        • 类类型转换不是总有效
        • 抑制构造函数定义的隐式转换
        • explicit构造函数只能用于直接初始化
        • 为转换显式地使用构造函数
        • 标准库中含有显示构造函数的类
      • 7.5.5 聚合类 (aggregate class)
      • 7.5.6 字面值常量类
        • constexpr构造函数
    • 7.6 类的静态成员
      • 声明静态成员
      • 使用类的静态成员
      • 定义静态成员
      • 静态成员的类内初始化
      • 静态成员能用于某些场景,而普通成员不能

第7章 类

类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。

数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。
类的借口包括用户所能执行的操作:类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。

封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,类的用户只能使用接口而无法访问实现部分。

类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)。

7.1 定义抽象数据类型

7.1.1 设计Sales_data类

Sales_data支持Sales_item类完全一样的操作集合。
Sales_item类有一个名为isbn成员函数(member function),并且支持+=+=<<>>运算符。(p20)

使用改进的Sales_data

(p21)使用 Sales_item类:

#include 
#include "Sales_item.h"
int main()
{
     
    Sales_item total;   //保存下一条交易记录的变量
    // 读入第一条交易记录,并确保有书可以处理
    if (std::cin >> total) {
     
        Sales_item trans;   // 保存和的变量
        // 读入并处理剩余交易记录
        while (std::cin >> trans) {
     
            // 如果我们仍在处理相同的书
            if (total.isbn() == trans.isbn())
                total += trans; // 更新total值即总销售额
            else {
     
                // 打印前一天本书的结果
                std::cout << total << std::endl;
                total = trans; // total 现在表示下一本书的销售额
            }
        }
    } else {
     
        // 没有输入!警告读者
        std::cerr << "No data?!" << std::endl;
        return -1;  // 表示失败
    }
    return 0;
}

不再使用Sales_item对象,而是使用Sales_data对象:

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;          // 把trans赋给total,处理下一本书
        }
    }
    print(cout, total) << endl;     // 输出最后一条交易
} else {
                                 // 没有输入任何信息
    cerr << "No data?!" << endl;    // 通知用户
}

/* 因为 print 返回的是它的流参数的引用,所以我们可以把 print 的返回值作为 << 运算符的左侧运算对象。通过这种方式,我们输出 print 函数的处理结果,然后转到下一行。*/

练习7.1:使用2.6.1节练习定义的 Sales_data类为 1.6节的交易处理程序编写一个新版本。只要ISBN相同,就不断累积啊销量并重新计算平均售价,直至输入新的书籍为止。

/* 练习7.1:使用2.6.1节练习定义的 Sales_data类为 1.6节的交易处理程序编写一个新版本。

程序的思路是:只要ISBN相同,就不断累积啊销量并重新计算平均售价,直至输入新的书籍为止。*/

#include 
#include "Sales_data.h"
using namespace std;

int main()
{
     
    cout << "请输入交易记录(ISBN、销售量、原价、实际售价):" << endl;
    Sales_data total;

    if (cin >> total)       // 读入第一条交易记录,并确保有数据可以处理
    {
     
        Sales_data trans;       // 保存和的变量
        while (cin >> trans)    // 读入并处理剩余交易记录
        {
     
            // 如果我们仍在处理相同的书
            if (total.isbn() == trans.isbn())
                total += trans; // 更新总销售额
            else
            {
     
                cout << total << endl;  // 打印前一本书的结果
                total = trans;  // total 现在表示下一本书的销售额
            }
        }
        cout << total << endl;  // 打印最后一本书的结果
    }
    else
    {
     
        // 没有输入!警告读者
        cerr << "No data?!" << endl;
        return -1;              // 表示失败
    }
    return 0;
}

7.1.2 定义改进的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;
    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&);
  • 定义成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外面。
  • 作为借口组成部分的非成员函数,例如addreadprint等,它们的定义和声明都在类的外部。

定义成员函数

尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。

引入this

成员函数的调用,如用点运算符来访问total对象的isbn成员,然后调用它:

total.isbn()
// 实际上它隐式地返回:
total.bookNo

如果isbn指向Sales_data的成员(例如bookNo),则它隐式地指向调用该函数的对象的成员。在上面所示的调用中,当isbn返回bookNo时,实际上它隐式地返回**total.bookNo**。

成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。
当我们调用一个成员函数时,用请求该函数的对象地址初始化this
例如,如果调用total.isbn(),则编译器负责把total的地址传递给isbn的饮食形参this,可以等价地认为编译器将该调用重写成了如下的形式:
其中调用Sales_dataisbn成员时传入了total的地址。

// 伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&total)

在成员函数内部,我们可以直接使用调用该函数的对象的成员,二无须通过成员访问运算符来做到这一点,因为this所指的正式这个对象。
任何对类成员的直接访问都被看作this的隐式引用。
也就是说,当isbn使用bookNo时,它隐式地使用this指向的成员,就像我们书写了this->bookNo一样。

对于我们来说,this形参是隐式定义的。实际上,任何自定义名为this的参数或变量的行为都是非法的。
我们可以在成员函数体内部使用this,因此尽管没有必要,但我们还是能把isbn定义成如下的形式:

std::string isbn() const {
      return this -> bookNo; }

因为this的目的总是指向“这个”对象,所以this是一个常量指针,我们不允许改变this中保存的地址。

引入const成员函数

p231

const的作用是修改隐式this指针的类型。

默认情况下,this的类型是指向类类型非常量版本的常量指针。(疑惑:看不懂)
例如在Sales_data成员函数中,this的类型是Sales_data *const
我们不能在一个常量对象上调用普通的成员函数。

由于this是隐式的,并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就成为我们必须面对的问题。
C++允许把const关键字放在成员函数的参数列表之后,此时紧跟在参数列表后的const表示this是一个指向常量的指针。
像这样使用const的成员函数被称作常量成员函数 (const member function)。

// 可以把 isbn 的函数体想象成如下的形式:
// 伪代码,说明隐式的 this 指针是如何使用的
// 下面的代码是非法的:因为我们不能显式地定义自己的 this 指针
// 谨记此处的 this 是一个指向常量的指针,因为 isbn 是一个常量成员
std::string Sales_data::isbn(const Sales_data *const this)
    {
      return this -> isbn};

因为this是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。
在上例中,isbn可以读取调用它的对象的数据成员,但是不能写入新值。

Note: 常量对象,以及常量对象的引用或指针,都只能调用常量成员函数。

类作用域和成员函数

类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域之内。
isbn中用到的名字bookNo其实就是定义在Sales_data内的数据成员。

由于编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。
因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

在类的外部定义成员函数

如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定const属性。
同时,类外部定义的成员的名字必须包含它所属的类名:

double Sales_data::avg_price() const {
     
    if (units_sold)
        return revenue / units_sold;
    else
        return 0;
}

函数名Sales_data::avg_price使用作用域运算符来说明如下的事实:定义了一个名为avg_price的函数,并且该函数被声明在类Sales_data的作用域内。
因此,当avg_price使用revenueunits_sold时,实际上它隐式地使用了Sales_data的成员。

定义一个返回this对象的函数

Sales_data& Sales_data::combine(const Sales_data &rhs)
{
     
    units_sold += rhs.units_sold;   // 把 rhs 的成员加到 this 对象的成员上
    revenue += rhs.revenue;
    return *this;                   // 返回调用该函数的对象
}

// 当我们的交易处理程序调用如下的函数时,
total.combine(trans);               // 更新变量 total 当前的值

combie执行下面的语句时,

units_sold += rhs.units_sold;       // 把 rhs的成员添加到 this 对象的成员中

效果等同于求total.units_soldtrans.units_sold的和,然后把结果保存到total.units_sold中。

另外,返回类型和返回语句,为了保持一致,combine函数必须返回引用类型。
因为此时的左侧运算对象是一个Sales_data的对象,所以返回类型应该是Sales_data&

return *this;                       // 返回调用该函数的对象

其中,return语句解引用this指针以获得执行该函数的对象。换句话说,上面的这个调用返回total的引用。

练习7.5:在你的 Person 类中提供一些操作使其能够返回姓名和住址。

/* 练习7.5:在你的 Person 类中提供一些操作使其能够返回姓名和住址。
这些函数是否应该是 const 的呢?解释原因。*/

class Person
{
     
private:
    string strName;
    string strAddress;
public:
    string getName() const {
      return strName; }          // 返回姓名
    string getAddress() const {
      return strAddress; }    // 返回地址
};

我们把数据成员strNamestrAddress设置为private,这样可以避免用户程序不经意间修改和破坏它们;
同时把构造函数和两个获取数据成员的接口函数设置为public,以便于我们在类的外部访问。

7.1.3 定义类相关的非成员函数

定义readprint函数

read函数从给定流中将数据读到给定的对象里,print函数则负责将给定对象的内容打印到给定的流中。

// 输入的交易信息包括 ISBN、售出总数和售出价格
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;
}

练习7.8:为什么read函数将其Sales_data参数定义成普通的引用,而print将其参数定义成常量引用?

read函数将其Sales_data参数定义成普通的引用是因为我们需要从标准输入流中读取数据并将其写入到给定的Sales_data对象,因此需要有修改对象的权限。

print将其参数定义成常量引用是因为它只负责数据的输出,不对其做任何更改。

练习7.9:仿照 Sales_data类,为 Person 类添加相应的 read 和 print 函数。

/* 练习7.9:对于7.1.2节(p233)练习中的代码,添加读取和打印Person对象的操作。

【出题思路】仿照 Sales_data类,为 Person 类添加相应的 read 和 print 函数。*/

std::istream &read(std::istream &is, Person &per)
{
     
    is >> per.strName >> per.strAddress;
    return is;
}

std::ostream &print(std::ostream &os, const Person &per)
{
     
    os << per.getName() << per.getAddress();
    return os;
}


/* 练习7.5:在你的 Person 类中提供一些操作使其能够返回姓名和住址。
这些函数是否应该是 const 的呢?解释原因。*/

class Person
{
     
private:
    string strName;
    string strAddress;
public:
    string getName() const {
      return strName; }          // 返回姓名
    string getAddress() const {
      return strAddress; }    // 返回地址
};

练习7.10:在下面这条if语句中,条件部分的作用是什么?

if (read(read(cin, data1)), data2)

【出题思路】read函数的返回类型是std::istream &,体会这里使用引用的作用。

【解答】
因为read函数的返回类型是引用,所以read(cin, data1)的返回值可以继续作为外层read函数的实参使用。
该条件检验读入data1data2的过程是否正确,如果正确,条件满足;否则条件不满足。

定义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.7:使用这些新函数重写 7.1.2节(p233) 练习中的交易处理程序。

/* 练习7.7:使用这些新函数重写 7.1.2节(p233) 练习中的交易处理程序。

【出题思路】用 read 函数替代 >>,print 函数替代 <<,add 函数替代 combine 函数。
*/

#include 
#include "Sales_data.h"
using namespace std;

int main()
{
     
    cout << "请输入交易记录(ISBN、销售量、原价、实际售价:)" << endl;
    Sales_data total;
    // 读入第一条交易记录,并确保有数据可以处理
    if ( read(cin, total))
    {
     
        Sales_data trans;           // 保存和的变量
        while(read(cin, trans))     // 读入并处理剩余交易记录
        {
     
            if (total.isbn() == trans.isbn())   // 如果我们仍在处理相同的书
                total = add(total, trans);      // 更新总销售额
            else
            {
     
                print(cout, total); // 打印前一本书的结果
                cout << endl;
                total = trans;      // total 现在表示下一本书的销售额
            }
        }
        print(cout, total);         // 打印最后一本书的结果
        cout << endl;
    }
    else
    {
     
        cerr << "No data?!" << endl;// 没有输入!警告读者
        return -1;                  // 表示失败
    }
    return 0;
}

7.1.4 构造函数 (constructor)

每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数的名字和类名相同。构造函数没有返回类型
类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。

合成的默认构造函数

类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor),默认构造函数无须任何实参。如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地顶一个默认构造函数。

编译器创建的构造函数又被成为合成的默认构造函数(synthesized default constructor)。

某些类不能依赖于合成的默认构造函数

Note: 只有当类没有声明任何构造函数时,编译器才会自动地生成默认的构造函数。

WARNING: 如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。

定义Sales_data的构造函数

对于我们的Sales_data类来说,我们将使用下面的参数定义 4 个不同的构造函数:

  • 一个istream&,从中读取一条交易信息
  • 一个const string&,表示ISBN编号;一个unsigned,表示售出的图书数量;以及一个double,表示图书的售出价格。
  • 一个const string&,表示ISBN编号;编译器将赋予其他成员默认值。
  • 一个空参数列表(即默认构造函数),正如刚刚介绍的,既然我们已经定义了其他构造函数,那么也必须顶一个默认构造函数。
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;

因为该默认构造函数不接受任何实参,所以它是一个默认构造函数。
我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。

构造函数初始值列表

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) {
      }

出现的新的部分,即冒号以及冒号和花括号之间的代码,其中花括号定义了(空的)函数体。
我们把新出现的部分称为构造函数初始值列表(constructor initialize list),它负责为新创建的对象的一个或几个数据成员赋初值。

// 与上面定义的那个构造函数效果相同
Sales_data(const std::string &s) :
           bookNo(s), units_sold(0), revenue(0) {
      }

只有一个string类型参数的构造函数使用这个string对象初始化bookNo,对于units_soldrevenue则没有显式地初始化。
当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。

练习7.14:编写一个构造函数,令其用我们提供的类内初始值显式地初始化成员。

/* 练习7.14:编写一个构造函数,令其用我们提供的类内初始值显式地初始化成员。

【出题思路】构造函数初始值列表负责为新创建的对象的一个或几个数据成员赋初值。
构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的成员初始值,
不同成员的初始化通过逗号分隔开。 */

Sales_data (const std::string &book)
    : bookNo(book), units_sold(0), sellingprice(0), saleprice(0), discount(0) {
      }

在类的外部定义构造函数

与其他几个构造函数不同,以istream为参数的构造函数需要执行一些实际的操作。
在它的函数体内,调用了read函数以给数据成员赋以初值:

Sales_data::Sales_data(std::istream &is)
{
     
    read(is, *this);    // read 函数的作用是从 is 中读取一条交易信息然后
                        // 存入 this 对象中
}

Sales_data::Sales_data的含义是我们定义Sales_data类的成员,它的名字是Sales_data。又因为该成员的名字和类名相同,所以它是一个构造函数。

这个构造函数没有构造函数初始值列表,或者说,它的构造函数初始值列表时是空的。
尽管构造函数初始值列表是空的,但是由于执行了构造函数体,所以对象的成员仍然能被初始化。

特别注意read的第二个参数是一个Sales_data对象的引用。
使用this来把对象当成一个整体访问,而非直接访问对象的某个成员。
因此在此例中,我们使用*this将 “this” 对象作为实参传递给read函数。

练习7.11:在你的 Sales_data 类中添加构造函数,然后编写一段程序令其用到每个构造函数。

/* 练习7.11:在你的 Sales_data 类中添加构造函数,然后编写一段程序令其用到每个构造函数。

【出题思路】在不同情况下,初始化 Sales_data 对象所需的数据有所不同,分别为其设计构造函数,
同时也利用C++11新标准提供的 =default 定义默认构造函数。*/

#include 
using namespace std;

class Sales_data
{
     
public:                         // 构造函数的 4 种形式
    Sales_data() = default;
    Sales_data(const std::string &book) : bookNo(book) {
      }
    Sales_data(const std::string &book, const unsigned num,
               const double sellp, const double salep);
    Sales_data(std::istream &is);
public:
    std::string bookNo;         // 书籍编号,隐式初始化为空串
    unsigned units_sold = 0;    // 销售量,显式初始化为 0
    double sellingprice = 0.0;  // 原始价格,显式初始化为 0.0
    double saleprice = 0.0;     // 实售价格,显式初始化为 0.0
    double discount = 0.0;      // 折扣,显式初始化为 0.0
};

Sales_data::Sales_data(const std::string &book, const unsigned num,
                    const double sellp, const double salep)
{
     
    bookNo = book;
    units_sold = num;
    sellingprice = sellp;
    saleprice = salep;
    if(sellingprice != 0)
        discount = saleprice / sellingprice; // 计算实际折扣
}

Sales_data::Sales_data(std::istream &is)
{
     
    is >> *this;
}

/* 在类的定义中,我们设计了 4 个构造函数。
第一个构造函数是默认构造函数,它使用了 C++11 新标准提供的 =default。它的参数列表为空,即不需要我们提供任何数据也能构造一个对象。

第二个构造函数只接受一个 const string&,表示书籍的 ISBN编号,编译器赋予其他数据成员类内初始值。

第三个构造函数接受完整的销售记录信息,const string& 表示书籍的 ISBN 编号,
const unsigned 表示销售量,后面两个 const double 分别表示书籍的原价和实际售价。

最后一个构造函数接受 istream& 并从中读取书籍的销售信息。*/

int main()
{
     
    Sales_data data1;
    Sales_data data2("978-7-121-15535-2");
    Sales_data data3("978-7-121-15535-2", 100, 128, 109);
    Sales_data data4(cin);

    cout << "The books' sales status is: " << endl;

    cout << data1 << "\n" << data2 << "\n" << data3 << "\n" << data4 << "\n";
    return 0;
}

练习7.12:把只接受一个 istream 作为参数的构造函数定义移到类的内部。

/* 练习7.12:把只接受一个 istream 作为参数的构造函数定义移到类的内部。

【出题思路】构造函数既可以定义在类的外部,也可以定义在类的内部。*/

#include 
using namespace std;

class Sales_data
{
     
public:                         // 构造函数的 4 种形式
    Sales_data() = default;
    Sales_data(const std::string &book) : bookNo(book) {
      }
    Sales_data(const std::string &book, const unsigned num,
               const double sellp, const double salep);
    Sales_data(std::istream &is) {
      is >> *this; }
public:
    std::string bookNo;         // 书籍编号,隐式初始化为空串
    unsigned units_sold = 0;    // 销售量,显式初始化为 0
    double sellingprice = 0.0;  // 原始价格,显式初始化为 0.0
    double saleprice = 0.0;     // 实售价格,显式初始化为 0.0
    double discount = 0.0;      // 折扣,显式初始化为 0.0
};

练习7.15:为你的 Person 类添加正确的构造函数。

练习7.22:修改你的 Person 类使其隐藏实现的细节。

/* 练习7.15:为你的 Person 类添加正确的构造函数。
   练习7.22:修改你的 Person 类使其隐藏实现的细节。

【出题思路】仿照 Sales_data 类,为 Person 类添加默认构造函数、
接受两个实参的构造函数和从标准输入流中读取数据的构造函数。

隐藏细节的含义是指把 Person 类的数据成员以及不应该被外部访问的函数成员设置成 private。*/

class Person
{
     
private:
    string strName;
    string strAddress;
public:
    Person() = default;
    Person(const string &name, const string &add)
    {
     
        strName = name;
        strAddress = add;
    }
Person(std::istream &is) {
      is >> *this; }
public:
    string getName() const {
      return strName; }          // 返回姓名
    string getAddress() const {
      return strAddress; }    // 返回地址
};

练习7.13:原来的程序使用 Sales_data类的默认构造函数,本题改为使用接受 istream的构造函数。

/*练习7.13:使用 istream 构造函数重写第 229 页的程序。

【出题思路】原来的程序使用 Sales_data类的默认构造函数,本题改为使用接受 istream 的构造函数。*/

// p229 程序:
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;          // 把trans赋给total,处理下一本书
        }
    }
    print(cout, total) << endl;     // 输出最后一条交易
} else {
                                 // 没有输入任何信息
    cerr << "No data?!" << endl;    // 通知用户
}

// 改写后的程序

#include 
#include "Sales_data.h"
using namespace std;

int main()
{
     
    Sales_data total(cin);                      // 保存当前求和结果的变量
    if (cin)
    {
                                                // 读入第一笔交易
        Sales_data trans(cin);                  // 保存下一条交易数据的变量
        do
        {
                                            // 读入剩余的交易
            if (total.isbn() == trans.isbn())   // 检查 isbn
                total.combine(trans);           // 更新变量 total 当前的值
            else
            {
     
                print(cout, total) << endl;     // 输出结果
                total = trans;                  // 处理下一本书
            }
        } while (read(cin, trans));
        print(cout, total) << endl;             // 输出最后一条交易
    }
    else
    {
                                                // 没有输入任何信息
        cerr << "No data?!" << endl;            // 通知用户
    }
    return 0;
}

7.1.5 拷贝、赋值和析构

除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。
当对象不再存在时执行销毁的操作,比如一个局部对象会在创建它的块结束时被销毁,当vector对象(或者数组)销毁时存储在其中的对象也会被销毁。

某些类不能依赖于合成的版本

尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。

不过值得注意的是,很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的存储空间。使用vector或者string的类能避免分配和释放内存带来的复杂性。

7.2 访问控制与封装

在类的定义中,可以包含0个或者多个访问说明符,并且对于某个访问说明符能出现多少次以及能出现在哪里都没有严格规定。

  • 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的借口。

    • 作为接口的一部分,构造函数和部分成员函数(即isbncombine)紧跟在public说明符之后;
  • 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,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;
}

使用classstruct关键字

唯一的区别是,structclass的默认访问权限不太一样。
类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。

  • 如果使用struct关键字,则定义在第一个访问说明符之前的成员是public的;
  • 相反,如果使用class关键字,则这些成员是private的。

关键概念:封装的益处:

封装是指保护类的成员不被随意访问的能力。通过把类的实现细节设置为private,我们就能完成类的封装。封装实现了类的接口和实现的分离。

如书中所述,封装有两个重要的优点:
一是确保用户代码不会无意间破坏封装对象的状态;
二是被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。

一旦把数据成员定义成private的,类的作者就可以比较自由地修改数据了。当实现部分发生改变时,只需要检查类的代码本身以确认这次改变有什么影响;换句话说,只要类的接口不变,用户代码就无须改变。
如果数据是public的,则所有使用了原来数据成员的代码都可能失效,这时我们必须定位并重写所有依赖于老版本实现的代码,之后才能重新使用该程序。

把数据成员的访问权限设成private还有另外一个好处,这么做能防止由于用户的原因造成数据被破坏。如果我们发现有程序缺陷破坏了对象的状态,则可以在有限的范围内定位缺陷:因为只有实现部分的代码可能产生这样的错误。因此,将错误的搜索限制在有限范围内将能极大地简化更改问题及修正程序等工作。

7.2.1 友元

类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。
如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:

class Sales data {
     
    // 为 Sales_data 的非成员函数所做的友元声明
    friend Sales_data add(const Sales_data&, const Sales_data&);
    friend std::istream &read(std::istream&, Sales_data&);
    friend std::ostream &print(std::ostream&, const Sales_data&);
    // 其他成员及访问说明符与之前一致
    public:
        Sales_data() = default;
        Sales_data(const std::string &s, unsigned n, double p) :
                    bookNo(s), units_sold(n), revenue(p*n) {
      }
        Sales_data(const std::string &s) : bookNo(s) {
      }
        Sales_data(std::istream&);
        std::string isbn() const {
      return bookNo; }
        Sales_data &combine(const Sales_data&);
    private:
        std::string bookNo;
        unsigned units_sold = 0;
        double revenue = 0.0;
};
// Sales_data 接口的非成员组成部分的声明
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);

练习7.20:友元在什么时候有用?请分别列举出使用友元的利弊。

友元为类的非成员接口函数提供了访问其私有成员的能力,这种能力的提升利弊共存。
当非成员函数确实需要访问类的私有成员时,我们可以把它声明成该类的友元。

  • 此时,友元可以“工作在类的内部”,像类的成员一样访问类的所有数据和函数。
  • 但是一旦使用不慎(比如随意设定友元),就有可能破坏类的封装性。

友元的声明

友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。
如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。

为了使友元对类的用户课件,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。
因此,我们的Sales_data头文件应为readprintadd提供独立的声明(除了类内部的友元声明之外)。

7.3 类的其他特性

本节讨论:类型成员、类的成员的类内初始值、可变数据成员、内联成员函数、从成员函数返回*this、关于如何定义并使用类类型及友元类的更多知识

7.3.1 类成员再探

练习7.23:编写你自己的 Screen 类。

/*练习7.23:编写你自己的 Screen 类。

【思路】对于 Screen 类来说,必不可少的数据成员有:
屏幕的宽度和高度、屏幕点内容以及光标的当前位置。
因此仅包含数据成员的 Screen类是:*/

class Screen
{
     
private:
    unsigned height = 0, width = 0;
    unsigned cursor = 0;
    string contents;
};

定义一个类型成员

Screen表示显示器中的一个窗口。
每个Screen包含了一个用于保存Screen内容的string成员和三个string::size_type类型的成员,它们分别表示光标的位置以及屏幕的高和宽。

class Screen {
     
public:
    typedef std::string::size_type pos;
private:
    pos cursor = 0;
    pos height = 0, width = 0;
    std::string contents;
};

关于pos的声明有两点需要注意:

首先,我们使用了typedef,也可以等价地使用类型别名:

class Screen {
     
public:
    // 使用类型别名等价地声明一个类型名字
    using pos = std::string::size_type;
    // 其他成员与之前的版本一致
};

其次,用来定义类型的成员必须先定义后使用。因此,类型成员通常出现在类开始的地方。

Screen类的成员函数

添加一个构造函数令用户能够定义屏幕的尺寸和内容,以及其他两个成员,分别负责移动光标和读取给定位置的字符:

class Screen {
     
public:
    typedef std::string::size_type pos;
    Screen() = default; // 因为 Screen 有另一个构造函数,所以本函数是必须的
    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;
};

因为我们已经提供了一个构造函数,所以编译器将不会自动生成默认的构造函数。
如果我们的类需要默认构造函数,必须显式地把它声明出来。
在此例中,我们使用 =default 告诉编译器为我们合成默认的构造函数。

第二个构造函数(接受三个参数)为cursor成员隐式地使用了类内初始值。

练习7.24:给你的Screen类添加三个构造函数。

/* 练习7.24:给你的Screen类添加三个构造函数:
一个默认构造函数;
另一个构造函数接受宽和高的值,然后将宽和高的值以及一个字符 contents 初始化成给定数量的空白;
第三个构造函数接受该字符作为初始化之后屏幕的内容。

【出题思路】同一个类可以包含多个构造函数,构造函数的定义可以再类的内部也可以在类的外部。*/

class Screen
{
     
private:
    unsigned height = 0, width = 0;
    unsigned cursor = 0;
    string contents;
public:
    Screen() = default;     // 默认构造函数
    Screen(unsigned ht, unsigned wd) : height(ht), width(wd), contents(ht * wd, ' ') {
      }
    Screen(unsigned ht, unsigned wd, char c)
        : height(ht), width(wd), contents(ht * wd, c) {
      }
};

令成员作为内联函数

我们可以再类的内部把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,这样可以使类更容易理解。

练习7.26:将 Sales_data::avg_price 定义成内联函数(两种途径)。

/* 练习7.26:将 Sales_data::avg_price 定义成内联函数。
【出题思路】
要想把类的成员函数定义成内联函数,有几种不同的途径。
第一种是直接把函数定义放在类的内部,
第二种是把函数定义放在类的外部,并且在定义之前显式地指定 inline。*/

// 隐式内联,把 avg_price 函数的定义放在类的内部:
class Sales_data
{
     
    public:
    double avg_price() const
    {
     
        if (units_sold)
            return revenue / units_sold;
        else
            return 0;
    }
};

// 显示内联,把 avg_price 函数的定义放在类的外部,并且指定 inline:
class Sales_data
{
     
    double avg_price() const;
}
inline double Sales_data:: avg_price() const
{
     
    if (units_sold)
        return revenue / units_sold;
    else
        return 0;
}

重载成员函数

和非成员函数一样,成员函数也可以被重载,只要函数之间在参数的数量和/或类型上有所区别就行。

两个版本的get函数,一个版本返回光标当前位置的字符;
另一个版本返回由行号和列号确定的位置的字符。
编译器根据实参的数量来决定运行哪个版本的函数:

Screen myscreen;
char ch = myscreen.get();   // 调用 Screen::get()
ch = myscreen.get(0, 0);    // 调用 Screen::get(pos, pos)

可变数据成员

有时我们希望能修改类的某个数据成员,即便是在一个const成员函数内。
可以通过在变量的声明中加入mutable关键字做到这一点。

一个可变数据成员(mutable data member)永远不会是const,即便它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。

举例:我们将给Screen添加一个名为access_ctr的可变成员,通过它我们可以追踪每个Screen的成员函数被调用了多少次:

class Screen {
     
public:
    void some_member() const;
private:
    mutable size_t access_ctr;  // 即使在一个 const 对象内也能被修改
    // 其他成员与之前的版本一致
};

void Screen::some_member() const
{
     
    ++access_ctr;       // 保存一个计数值,用于记录成员函数被调用的次数
    // 该成员需要完成的其他工作
}

类数据成员的初始值

我们希望 Window_mgr类开始时总是拥有一个默认初始化的Screen
最好的方式就是把这个默认值声明称一个类内初始值:

class Window_mgr {
     
private:
    // 这个 Window_mgr 追踪的 Screen
    // 默认情况下,一个 Window_mgr 包含一个标准尺寸的空白 Screen
    std::vector<Screen> screens {
     Screen(24, 80, ' ')};
};

Screen的构造函数接受两个尺寸参数和一个字符值,创建了一个给定大小的空白屏幕对象。
类内初始值必须使用=的初始化形式(初始化Screen的数据成员时所用的)或者
花括号括起来的直接初始化形式(初始化screens所用的)。

Note: 当我们提供一个类内初始值时,必须以符号=或者花括号表示。

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 对象作为左值返回
}

set成员的返回值是调用set的对象的引用,返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。

如果我们把一系列这样干的操作连接在一条表达式中的话,这些操作将在同一个对象上执行。

myScreen.move(4,0).set('#');

上面的表达式中,我们首先移动myScreen内的光标,然后设置myScreencontents成员。也就是说,上述语句等价于

myScreen.move(4,0);
myScreen.set('#');

我们令moveset返回Screen而非Screen&,则上述语句的行为将大不相同。
move的返回值将是*this的副本,因此调用set只能改变临时副本,而不能改变myScreen的值。

// 如果 move 返回 Screen 而非 Screen&
Screen temp = myScreen.move(4,0);   // 对返回值进行拷贝
temp.set('#');                      // 不会改变 myScreen 的 contents

const成员函数返回*this

Note: 一个const成员函数如果以引用形式返回*this,那么它的返回类型将是常量引用。

举例:添加一个名为display的操作,负责打印Screen的内容。
从逻辑上来说,显示一个Screen并不需要改变它的内容,因此我们令display为一个const成员,此时,this将是一个指向const的指针,而*thisconst对象。
由此推断,display的返回类型应该是const Sales_data&

然而,如果真的令display返回一个const引用,我们将不能把display嵌入到一组动作的序列中去。

Screen myScreen;
// 如果 display 返回常量引用,则调用 set 将引发错误
myScreen.display(cout).set('*');

即使 myScreen 是个非常量,对 set 的调用也无法通过编译。
displayconst 版本返回的是常量引用,而我们显然无权 set 一个常量对象。

基于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 指针将隐式地从指向非常量的指针转换成指向常量的指针。

7.3.3 类类型

struct First {
     
    int memi;
    int getMem();
};

struct Second {
     
    int memi;
    int getMem();
};
First obj1;
Second obj2 = obj1;

**Note:**即使两个类的成员列表完全一致,它们也是不同的类型。对于一个来说,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事儿。

我们可以把类名作为类型的名字使用,从而直接指向类类型。
或者,我们也可以把类名在关键字 class 或 struct 后面:

Sales_data item1;       // 默认初始化 Sales_data 类型的对象
class Sales_data item1; // 一条等价的声明

类的声明

class Screen;       // Screen 类的声明

这种声明被称为前向声明(forward declaration)。
对于类型Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。

在创建类的对象之前,该类必须被定义过,而不能仅仅被声明。否则,编译器就无法了解这样的对象需要多少存储空间。

类也必须首先被定义,然后才能用引用或者指针访问其成员。毕竟如果类尚未定义,编译器也就不清楚该类到底有哪些成员。

然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:

class Link_screen {
     
    Screen window;
    Link_screen *next;
    Link_screen *prev;
};

练习7.31:定义一对类 X 和 Y,其中 X 包含一个指向 Y 的指针,而 Y 包含一个类型为 X 的对象。

/* 练习7.31:定义一对类 X 和 Y,其中 X 包含一个指向 Y 的指针,而 Y 包含一个类型为 X 的对象。

【出题思路】理解类的声明和定义。
声明的作用是告知程序类的名字合法可用;定义的作用是规定类的细节。*/

class X;    // 声明类型 X
class Y     // 定义类型 Y
{
     
    X* x;   // X 有一个指向 Y 的指针
};

class X     // 定义类型 X
{
     
    Y y;    // Y 包含一个类型为 X 的对象
};

/* 类 X 的声明称为前向声明,它向程序中引入了名字 X 并且指明 X 是一种类类型。
对于类型 X 来说,此时我们己知它是一个类类型,但是不清楚它到底包含哪些成员, 所以它是一个不完全类型。
我们可以定义指向不完全类型的指针,但是无法创建不完全类型的对象。*/

// 如果试图写成下面的形式,将引发编译错误。

class Y;
class X
{
     
    Y y;
};

class Y
{
     
    X* x;
};

// 此时我们试图在类 X 中创建不完全类型 Y 的对象,编译器给出报错信息:
// error: filed 'y' has incomplete type

7.3.4 友元再探

类还可以把其他的类定义成友元,也可以把其他鳄梨(之前已定义过的)成员函数定义成友元。
此外,友元函数能定义在类的内部,这样的函数是隐式内联的。

类之间的友元关系

举例:假设我们需要为 Window_mgr 添加一个名为 clear 的成员,它负责把一个指定的 Screen的内容都设为空白。为了完成这一任务,clear 需要访问 Screen 的私有成员;而要想令这种访问合法,Screen 需要把 Window_mgr 指定成它的友元:

class Screen {
     
    // Window_mgr 的成员可以访问 Screen 类的私有部分
    friend class Window_mgr;
    // Screen 类的剩余部分
};

如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。
ScreenWindow_mgr 指定成它的友元,则 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 = string(s.height * s.width, ' ');
}

一开始,首先s定义成screens vector中的第i个位置上的Screen的引用,随后利用Screenheightwidth成员计算出一个新的string对象,并令其含有若干个空白字符,最后我们把这个含有很多空白的字符串赋给contents的成员。

如果clear不是Screen的友元,上面的代码将无法通过编译,因为此时clear将不能访问Screenheightwidthcontents成员。

令成员函数作为友元

除了令整个Window_mgr作为友元之外,Screen还可以只为clear提供访问权限。
当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:

class Screen {
     
    // Window_mgr::clear必须在Screen类之前被声明
    friend void Window_mgr::clear(ScreenIndex);
    // Screen类的剩余部分
};

函数重载和友元

// 重载的 storeOn 函数
extern std::ostream& storeOn(std::ostream &, Screen &);
extern BitMap& sotreOn(BitMap &, Screen &);
class Screen {
     
    // storeOn ostream 版本能访问 Screen 对象的私有部分
    friend std::ostream& storeOn(std::ostream &, Screen &);
    // ...
};

尽管重载函数的名字相同的,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。

Screen类把接受ostream&storeOn函数声明成它的友元,但是接受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 的声明在作用域中了

练习7.32:定义你自己的 Screen 和 Window_mgr,其中 clear 是 Window_mgr 的成员,是 Screen 的友元。

/* 练习7.32:定义你自己的 Screen 和 Window_mgr,其中 clear 是 Window_mgr 的成员,是 Screen 的友元。

【出题思路】类可以把其他类定义成友元,也可以把其他类的成员函数定义成友元。
当把成员函数定义成友元时,要特别注意程序的组织结构。

1. 我们必须首先定义 Window_mgr 类,其中声明 clear 函数,但是不能定义它;
2. 接下来定义 Screen 类,并且在其中指明 clear 函数;
3. 最后定义 clear 函数。*/

#include 
#include 
using namespace std;

class Window_mgr        // 1. 我们必须首先定义 Window_mgr 类,其中声明 clear 函数,但是不能定义它;
{
     
public:
    void clear();
};

class Screen            // 2. 接下来定义 Screen 类,并且在其中指明 clear 函数;
{
     
friend void Window_mgr::clear();
private:
    unsigned height = 0, width = 0;
    unsigned cursor = 0;
    string contents;
public:
    Screen() = default;
    Screen(unsigned ht, unsigned wd, char c)
        : height(ht), width(wd), contents(ht * wd, c) {
      }
};

void Window_mgr::clear()    // 3. 最后定义 clear 函数。
{
     
    Screen myScreen(10, 20, 'X');
    cout << "Before clear, the contenst are: " << endl;
    cout << myScreen.contents << endl;
    myScreen.contents = "";
    cout << "After clear, the contenst are: " << endl;
    cout << myScreen.contents << endl;
}

int main()
{
     
    Window_mgr w;
    w.clear();
    return 0;
}

/*Output:
Before clear, the contenst are:                                                                                                               XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
After clear, the contenst are:

*/

7.4 类的作用域

在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。
对于类类型成员则使用作用域运算符访问。

Screen::pos ht = 24, wd = 80;   // 使用 Screen 定义 的 pos 类型
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get();             // 访问 scr 对象的 get 成员
c = p->get();                   // 访问 p 所指对象的 get 成员

作用域和定义在类外部的成员

举例:回顾Window_mgr类的clear成员,该函数的参数用到了Window_mgr类定义的一种类型:
ScreenIndex正位于Window_mgr类的作用域中,所以不必再专门说明ScreenIndexWindow_mgr类定义的。
出于同样的原因,编译器也能知道函数体重用到的screens也是在Window_mgr类中定义的。

void Window_mgr::clear(ScreenIndex i)
{
     
    Screen &s = screens[i];
    s.contents = string(s.height * s.width, ' ');
}

函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。

举例:向Window_mgr类添加一个新的名为addScreen的函数,它负责向显示器添加一个新的屏幕。这个成员的返回类型将是ScreenIndex,用户可以通过它定位到指定的Screen

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.33:如果我们给 Screen 添加一个如下所示的 size 成员将发生什么情况?

/* 练习7.33:如果我们给 Screen 添加一个如下所示的 size 成员将发生什么情况?
如果出现了问题,请尝试修改它。*/

pos Screen::size() const
{
     
    return height * width;
}

/* 【出题思路】如果添加入题目所示的 size 函数将会出现编译错误。
因为该函数的返回类型 pos 本身定义在 Screen 类的内部,所以在类的外部无法直接使用 pos。
要想使用 pos,需要在它的前面加上作用域 Screen::。
修改后的程序是:*/
Screen::pos Screen::size() const
{
     
    return height * width;
}

7.4.1 名字查找与类的作用域

名字查找(name lookup):寻找与所用名字最匹配的声明的过程。

用于类成员声明的名字查找

typedef double Money;
string bal;
class Account {
     
public:
    Money balance() {
      return bal; }
private:
    Money bal;
    // ...
};

当编译器看到balance函数的声明语句时,将在Account类的范围内寻找对Money的声明。没找到匹配的成员,会接着到Account的外层作用域中查找。

类型名要特殊处理

在类中,如果成员使用了外层作用域中的某个名字,而改名字代表一种类型,则类不能在之后重新定义该名字:

typedef double Money;
class Account {
     
public:
    Money balance() {
     return bal; }  // 使用外层作用域的Money
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?是那个参数
        // 函数的参数位于函数作用域内,因此dummy_fcn函数体内用到的名字height指的是参数声明;类的成员被隐藏了
    }
private:
    pos cursor = 0;
    pos height = 0, width = 0;
};

如果想绕开上面的查找规则,应该将代码变为:

void Screen::dummy_fcn(pos height) {
     
    cursor = width * this->height;      // 成员 height
    // 另外一种表示该成员的方式
    cursor = width * Screen::height;    // 成员 height
}

Note:尽管类的成员被隐藏了,但我们仍然可以通过加上类的名字或显式地使用 this 指针来强制访问成员。

其实最好地确保我们使用height成员的方法是给参数七个其他名字:

// 建议的写法:不要把成员名字作为参数或其他局部变量使用
void Screen::dummy_fcn(pos ht) {
     
    cursor = width * height;        // 成员 height
}

类作用域之后,在外围的作用域中查找

Note:尽管外层的对象被隐藏掉了,但我们仍然可以用作用域运算符访问它。

// 不建议的写法:不要隐藏外层作用域中可能被用到的名字
void Screen::dummy_fcn(pos height) {
     
    cursor = width* ::height;       // 哪个 height?是那个全局的
}

在文件中名字的出现处对其进行解析

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: 全局函数
    height = verify(var);
}

此例中,verify 的声明位于 setHeight 的定义之前,因此可以被正常使用。

练习7.35:解释下面代码的含义,说明其中的 TypeinitVal 分别使用了哪个定义。如果代码存在错误,尝试修改它。

/* 练习7.35:解释下面代码的含义,说明其中的 Type 和 initVal 分别使用了哪个定义。
如果代码存在错误,尝试修改它。

【出题思路】
理解名字查找与类的作用域的关系,包括用于类成员声明的名字查找和成员定义中的名字查找。*/

typedef string Type;        // 声明类型别名 Type 表示 string
Type initVal();             // 声明函数 initVal,返回类型是 Type
class Exercise {
                 // 定义一个新类 Exercise
public:
    typedef double Type;    // 在内层作用域重新声明类型别名 Type 表示 double
    Type setVal(Type);      // 声明函数 setVal,参数和返回值的类型都是 Type
    Type initVal;           // 在内层作用域重新声明函数 initVal,返回类型是 Type
private:
    int val;                // 声明私有数据成员 val
};
                            // 定义函数 setVal,此时的 Type 显然是外层作用域的
Type Exercise::setVal(Type parm) {
     
    val = parm + initVal(); // 此处使用的是类内的 initVal 函数
    return val;
}

分析:
其中,在 Exercise 类的内部,函数 setValinitVal 用到的 Type 都是 Exercise 内部声明的类型别名,对应的实际类型是 double。

Exercise 类的外部,定义 Exercise::setVal 函数时形参类型 Type 用的是 Exercise 内部定义的别名,对应 double
返回类型 Type 用的是全局作用域的别名,对应 string
使用的 initVal 函数是 Exercise 类内定义的版本。

编译上述程序时在 setVal 的定义处发生错误,
此处定义的函数形参类型是 double、返回值类型是 string
而类内声明的同名函数形参类型是 double、返回值类型也是 double
二者无法匹配。

修改的措施是在定义 setVal 函数时使用作用域运算符强制指定函数的返回值类型。

Exercise::Type Exercise::setVal(Type parm) {
     
    val = parm + initVal(); // 此处使用的是类内的 initVal 函数
    return val;
}

7.5 构造函数再探

7.5.1 构造函数初始值列表

// (p237) 定义 `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;
};
// 这个版本对数据成员执行了赋值操作
// Sales_data 构造函数的一种写法,虽然合法但比较草率:没有使用构造函数初始值
Sales_data::Sales_data(const string &s, unsigned cnt, double price)
{
     
    bookNo = s;
    units_sold = cnt;
    revenue = cnt * price;
}

练习7.40:掌握创类类型的方法,理解不同构造函数的区别。

/*练习7.40:从下面的抽象概念中选择一个(或者你自己指定一个),思考这样的类需要哪些数据成员吗,提供一组合理的构造函数并阐明这样做的原因。

(a) Book    (b) Data    (c) Employee
(d) Vehicle (e) Object  (f) Tree

【出题思路】掌握创类类型的方法,理解不同构造函数的区别。*/

/*首先选择(a) Book,
一本书通常包含书名、ISBN 编号、定价、作者、出版社等信息,
因此令其数据成员为:Name、ISBN、Price、Author、Publisher,
其中 Price 是 double 类型,其他都是 string 类型。
Book 的构造函数有三个:
一个默认构造函数、
一个包含完整书籍信息的构造函数、
一个接受用户输入的构造函数。
其定义如下: */

class Book
{
     
private:
    string Name, ISBN, Author, Publisher;
    double Price = 0;
public:
    Book() = default;
    Book(const string &n, const string &I, double pr, const string &a, const string &p)
    {
     
        Name = n;
        ISBN = I;
        Price = pr;
        Author = a;
        Publisher = p;
    }
    Book(std::istream &is) {
      is >> *this; }
};


/*也可以选择(f) Tree,一棵树通常包含树的名称、存活年份、树高等信息,因此令其数据成员为:Name、Age、Height,

其中 Name 是 string 类型,Age 是 unsigned 类型,Height 是 double 类型。

假如我们不希望由用户输入 Tree 的信息,则可以去掉接受 std::istream& 形参的构造函数,只保留默认构造函数和接受全部信息的构造函数。

其定义如下:*/

class Tree
{
     
private:
    string Name;
    unsigned Age = 0;
    double Height = 0;
public:
    Tree() = default;
    /* Tree(const string &n, unsigned a, double h)
        : Name(n), Age(a), Height(h); 错误代码 可能是typo*/

    Tree(const string &n, unsigned a, double h)
        : Name(n), Age(a), Height(h) {
      }
};

构造函数的初始值有时必不可少

如果成员是 const 或者是引用的话,必须将其初始化。

类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。

class ConstRef {
     
public:
    ConstRef(int ii);
private:
    int i;
    const int ci;
    int &ri;
};

和其他常量对象或者引用一样,成员 ciri 都必须被初始化。
如果没有为它们提供构造函数初始值的话将引发错误:

// 错误:ci 和 ri 必须被初始化
ConstRef::ConstRef(int ii)
{
      // 赋值
    i = ii;     // 正确
    ci = ii;    // 错误:不能给 const 赋值
    ri = i;     // 错误:ri 没被初始化
}

我们初始化 const 或者引用类型的数据成员的唯一机会就是通过构造函数初始值,
因此该构造函数的正确形式应该是:

// 正确:显式地初始化引用和 const 成员
ConstRef::ConstRef(int ii) :: i(ii), ci(ii), ri(i) {
      }

Note: 如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

建议:要养成使用构造函数初始值的习惯,特别是遇到有的类含有需要构造函数初始值的成员时。

成员初始化的顺序

注意:最好令构造函数初始值的顺序语成员声明的顺序保持一致。
而且如果可能的话,尽量避免使用某些成员初始化其他成员。

//
class X {
     
    int i;
    int j;
public:
    // 未定义的:i 在 j 之前被初始化
    X(int val) : j(val), i(j) {
      }
};

成员的初始化顺序语它们在类定义中的出现顺序一致。
构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

所以在此例中,从构造函数初始值的形式上来看仿佛是先用 val 初始化了 j,然后再用 j 初始化 i。实际上,i 先被初始化,因此这个初始值的效果是试图使用未定义的值 j初始化 i

最好用构造函数的参数作为成员的初始值,而尽量避免使用同一个对象的其他成员。
这样的好处是我们可以不必考虑成员的初始化顺序。
例如,X 的构造函数如果写成如下的形式效果会更好:

// 在这个版本中,i 和 j 初始化的顺序没有什么影响
X(int val) : i(val), j(val) {
      }

练习7.36:下面的初始值是错误的,请找出问题所在并尝试修改它。

/* 练习7.36:下面的初始值是错误的,请找出问题所在并尝试修改它。*/

struct X {
     
    X (int i, int j) : base(i), rem(base % j) {
      }
    int rem, base;
};

/*【出题思路】使用狗仔函数初始值列表时成员的初始化顺序,初始化顺序只与数据成员在类中出现的次序有关,而与初始值列表的顺序无关。

在类 X 中,两个数据成员出现的顺序是 rem 在前,base 在后,所以当执行 X 对象的初始化操作时先初始化 rem。

如上述代码所示,初始化 rem 要用到 base 的值,而此时 base 尚未被初始化,因此会出现错误。

该过程与构造函数初始值列表中谁出现在前面谁出现在后面没有任何关系。
*/


// 修改的方法很简单,只需要把变量 rem 和 base 的次序调换即可,形式是:
struct X {
     
    X (int i, int j) : base(i), rem(base % j) {
      }
    int base, rem;
};

默认实参和构造函数

class Sales_data {
     
public:
    // 定义默认构造函数,令其与只接受一个string的实参的构造函数功能相同
    Sales_data(std::string s = "") : bookNo(s) {
      }
    // 其他构造函数与之前一致
    Sales_data(std::string s, unsigned cnt, double rev) :
        bookNo(s), units_sold(cnt), revenue(rev * cnt) {
      }
    Sales_data(std::istream &is) {
      read (is, *this); }
};

Note: 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

练习7.37:使用本节提供的 Sales_data 类,确定初始化下面的变量时分别使用了哪个狗仔函数,然后罗列出每个对象所有数据成员的值。

Sales_data first_item(cin);

int main() {
     
    Sales_data next;
    Sales_data last("9-999-99999-9");
}

Sales_data first_item(cin); 使用了接受 std::istream& 参数的构造函数,该对象的成员值依赖于用户的输入。

Sales_data next; 使用了 Sales_data 的默认构造函数,其中 string 类型的成员 bookNo 默认初始化为空字符串,其他几个成员使用类内初始值初始化为 0

Sales_data last("9-999-99999-9"); 使用了接受 const string& 参数的构造函数,其中 bookNo 使用了实参初始化为 "9-999-99999-9",其他几个成员使用类内初始值初始化为 0

练习7.38:有些情况下我们希望提供 cin 作为接受 istream& 参数的构造函数的默认实参,请声明这样的构造函数。

【出题思路】可以直接在函数声明的地方为 istream& 类型的参数设置默认实参 cin

Sales_data(std::istream &is=std::cin) {
      is >> *this; }

此时该函数具有了默认构造函数的作用,因此我们原来声明的默认构造函数 Sales_data() = default; 应该去掉,否则会引起调用的二义性。

练习7.39:如果接受 string 的构造函数和接受 istream& 的构造函数都使用默认实参,这种行为合法吗?如果不,为什么?

如果我们为构造函数的全部形参都提供了默认实参(包括为只接受一个形参的构造函数提供默认实参),则该构造函数同时具备了默认构造函数的作用。此时即使我们不提供任何实参地创建类的对象,也可以找到可用的构造函数。

然而,如果按照本题的叙述,我们为两个构造函数同样都赋予了默认实参,则这两个构造函数都具有了默认构造函数的作用。一旦我们不提供任何实参地创建类的对象,则编译器无法判断这两个(重载的)构造函数哪个更好,从而出现了二义性错误。

7.5.2 委托构造函数 (delegating constructor)

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); }
    // 其他成员与之前的版本一致
};

类名,后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。

接受istream&的构造函数也是委托构造函数,它委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数。当这些受委托的构造函数执行完后,接着执行istream&的构造函数体的内容。它的构造函数题调用read函数读取给定的istream

/* 练习7.41:使用委托构造函数重新编写你的Sales data类,给每个构造函数体添加一条语句,令其一旦执行就打印一条信息。用各种可能的方式分别创建Sales data 对象,认真研究每次输出的信息直到你确实理解了委托构造函数的执行顺序。*/

#include 
#include 
using namespace std;

class Sales_data {
     
friend std::istream &read(std::istream &is, Sales_data &item);
friend std::ostream &print(std::ostream &os, const Sales_data &item);
public:
    // First
    Sales_data(const string &book, unsigned num, double sellp, double salep)
    : bookNo(book), units_sold(num), sellingprice(sellp), saleprice(salep)
    {
     
        if (sellingprice)
            discount = saleprice / sellingprice;
        cout << "该构造函数接受书号、销售量、原价、实际售价四个信息" << endl;
    }
    // Second
    Sales_data() : Sales_data("", 0, 0, 0)
    {
     
        cout << "该构造函数无须接受任何信息" << endl;
    }
    // Third
    Sales_data(const string &book) : Sales_data(book, 0, 0, 0)
    {
     
        cout << "该构造函数接受书号信息 " << endl;
    }
    // Last
    Sales_data(std::istream &is) : Sales_data()
    {
     
        read(is, *this);
        cout << "该构造函数接受用户输入的信息" << endl;
    }
private:
    std::string bookNo;         // 书籍编号,隐式初始化为空串
    unsigned units_sold = 0;    // 销售量,显式初始化为 0
    double sellingprice = 0.0;  // 原始价格,显式初始化为 0.0
    double saleprice = 0.0;     // 实售价格,显式初始化为 0.0
    double discount = 0.0;      // 折扣,显式初始化为 0.0
};

std::istream &read(std::istream &is, Sales_data &item)
{
     
    is >> item.bookNo >> item.units_sold >> item.sellingprice >> item.saleprice;
    return is;
}

std::ostream &print(std::ostream &os, const Sales_data &item)
{
     
    os << item.bookNo << " " << item.units_sold << " " << item.sellingprice
       << " " << item.saleprice << " " << item.discount;
    return os;
}

int main()
{
     
    Sales_data first("978-7-121-15535-2", 85, 128, 109);
    // 该该构造函数接受书号、销售量、原价、实际售价四个信息
    Sales_data second;
    // 该该构造函数接受书号、销售量、原价、实际售价四个信息
    // 该构造函数无须接受任何信息
    Sales_data third("978-7-121-15535-2");
    // 该该构造函数接受书号、销售量、原价、实际售价四个信息
    // 该构造函数接受书号信息
    Sales_data last(cin);
    // 该构造函数接受用户输入的信息
    return 0;
}
/* 练习7.42:对于你在练习 7.40 中编写的类,确定哪些构造函数可以使用委托。
如果可以的话,编写委托构造函数。如果不可以,从抽象概念列表中重新选择一个你认为可以使用委托构造函数的,为挑选出的这个概念编写类定义。

【出题思路】
委托构造函数是指使用它所属类的其他构造函数执行它自己的初始化过程,因此在类中应该设计一些构造函数使其具备自主的构造函数功能,而把另外一些设计成委托构造函数。*/

class Book
{
     
private:
    string Name, ISBN, Author, Publisher;
    double Price = 0;
public:
    Book(const string &n, const string &I, double pr, const string &a, const string &p)
        : Name(n), ISBN(I), Price(pr), Author(a), Publisher(p) {
      }
    Book() : Book("", "", 0, "", "") {
      }
    Book(std::istream &is) : Book() {
      is >> *this; }
}

7.5.3 默认构造函数的作用

默认初始化的情况:

  • 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
  • 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
  • 当类类型的成员没有在构造函数初始值列表中显式地初始化时。

值初始化的情况:

  • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
  • 当我们不使用初始值定义一个局部静态变量时。
  • 当我们通过书写形如T()的表达式显式地请求初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。
class NoDefault {
     
    public:
    NoDefault(const std::string&);
    // 还有其他成员,但是没有其他构造函数了
};

struct A {
               // 默认情况下 my_mem 是 public 的
    NoDefault my_mem;
};
A a;                // 错误:不能为 A 合成构造函数

struct B {
     
    B() {
      }         // 错误:b_member 没有初始值
    NoDefault b_member;
};

使用默认构造函数

Sales_data obj();       // 正确:定义了一个函数而非对象
if (obj.isbn() == Primer_5th_ed.isbn()) // 错误:obj 是一个函数
// obj 含义是不接受任何参数的函数并且其返回值是 Sales_data 类型的对象
Sales_data obj;         // 正确:obj 是个默认初始化的对象
if (obj.isbn() == Primer_5th_ed.isbn()) // 正确:obj 是对象

WARNING:

Sales_data obj();       // 错误:声明了一个函数而非对象
Sales_data obj2;        // 正确:obj2 是一个对象而非函数

练习7.46:下面哪些论断是不正确的?为什么?

【出题思路】 本题旨在考查读者对默认构造函数原理的熟悉程度

(a) 一个类必须至少提供一个构造函数。
(a)是错误的,类可以不提供任何构造函数,这时编译器自动实现一个合成的默认构造函数。

(b) 默认构造函数是参数列表为空的构造函数。
(b)是错误的,如果某个构造函数包含若干形参,但是同时为这些形参都提供了默认实参,则该构造函数也具备默认构造函数的功能

© 如果对于类来说不存在有意义的默认值,则类不应该提供默认构造函数。
©是错误的,因为如果一个类没有默认构造函数,也就是说我们定义了该类的某些构造函数但是没有为其设计默认构造函数,则当编译器确实需要隐式地使用默认构造函数时,该类无法使用。所以一般情况下,都应该为类构建一个默认构造函数。

(d) 如果类没有定义默认构造函数,则编译器将为其生成一个并把每个数据成员初始化成相应类型的默认值。
(d)是错误的,对于编译器合成的默认构造函数来说,类类型的成员执行各自所属类的默认构造函数,内置类型和复合类型的成员只对定义在全局作用域中的对象执行初始化。

7.5.4 隐式的类类型转换

如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)。

Sales_data 类中,接受 string 的构造函数和接受 istream 的构造函数分别定义了这两种类型向 Sales_data 隐式转换的规则。也就是说,在需要使用 Sales_data 的地方,我们可以使用 string 或者 istream 作为替代:

string null_book = "9-999-99999-9";
// 构造一个临时的 Sales_data 对象
// 该对象的 units_sold 和 revenue 等于 0,bookNo 等于 null_book
item.combine(null_book);

我们用一个 string 实参调用了 Sales_datacombine 成员。该调用是合法的,编译器用给定的 string 自动创建了一个 Sales_data 对象。新生成的这个(临时) Sales_data 对象被传递给 combine
因为 combine 的参数是一个常量引用,所以我们可以给该参数传递一个临时量。

练习 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 的,又会发生什么呢?

【出题思路】

  • 构造函数如果不是 explicit 的,则 string 对象隐式地转换成 Sales_data 对象;
  • 相反,构造函数如果是 explicit 的,则隐式类类型转换不会发生。

【解答】在本题给出的代码中,第一行创建了一个 string 对象,第二行和第三行都是调用 Sales_data的构造函数(该构造函数接受一个 string)创建它的对象。
此处无须任何类类型转换,所以不论 Sales_data 的构造函数是不是 explicit 的, item1item2 都能被正确地创建,它们的 bookNo 成员都是 9-999-99999-9, 其他成员都是 0

练习 7.49:对于 combine 函数的三种声明,当我们调用 i.combine(s) 时分别发生什么情况?其中 i 是一个 Sales_data,而 s 是一个 string 对象。

Sales_data &combine(Sales_data);
Sales_data &combine(Sales_data&);
Sales_data &combine(const Sales_data&) const;

【解答】

(a) 是正确的,编译器首先用给定的 string 对象 s 自动创建一个 Sales_data 对象,然后这个新生成的临时对象传给 combine 的形参(类型是 Sales_data),函数正确执行并返回结果。

(b) 无法编译通过,因为 combine 函数的参数是一个非常量引用,而 s 是一个 string 对象,编译器用 s 自动创建一个 Sales_data 临时对象,但是这个新生成的临时对象无法传递给 combine 所需的非常量引用。
如果我们把函数声明修改为 Sales_data &combine(const Sales_data&); 就可以了。

© 无法编译通过,因为我们把 combine 声明成了常量成员函数,所以该函数无法修改数据成员的值

只允许一步类类型转换

下面的代码隐式地使用了两种转换规则,所以它是错误的:

// 错误:需要用户定义的两种转换
// (1) 把 "9-999-99999-9" 转换成 Sales_data
// (2) 再把这个(临时的) string 转换成 Sales_data
item.combine("9-999-99999-9");

如果想完成上述调用,可以显式地把字符串转换成 string 或者 Sales_data 对象:

// 正确:显式地转换成 string,隐式地转换成 Salese_data
item.combine(stirng("9-999-99999-9"));
// 正确:隐式地转换成 string,显式地转换成 Sales_data
item.combine(Sales_data("9-999-99999-9"));

类类型转换不是总有效

// 使用 istream 构造函数创建一个函数传递给 combine
item.combine(cin);

这段代码隐式地把 cin 转换成 Sales_data,这个转换执行了接受一个 istreamSales_data 构造函数。该函数通过读取标准输入创建了一个(临时的)Sales_data 对象,随后将得到的对象传递给 combine

Sales_data 对象是个临时量,一旦 combine 完成我们就不能再访问它了。
实际上,我们构建了一个对象,先将它的值加到 item 中,随后将其丢弃。

抑制构造函数定义的隐式转换

在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为 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 对象,之前的两种用法都无法通过编译。

item.combine(null_book);    // 错误:string 构造函数是 explicit 的
item.combine(cin);          // 错误:istream 构造函数是 explicit的

关键字 explicit 只对一个实参的构造函数有效。
需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为 explicit 的。

只能在类内声明构造函数时使用 explicit 关键字,在类外部定义时不应重复:

// 错误:explicit 关键字只允许出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream& is)
{
     
    read(is, *this);
}

练习 7.47:说明接受一个string参数的Sales_data构造函数是否应该是explicit的,并解释这样做的优缺点。

【出题思路】 explicit 用于抑制类类型的隐式转换,读者需要知道 explicit 的长处和不足。

【解答】 接受一个 string 参数的 Sales_data 构造函数应该是 explicit 的,否则,编译器就有可能自动把一个 string 对象转换成 Sales_data 对象,这种做法显得有些随意,某些时候会与程序员的初衷相违背。

使用 explicit 的优点是避免因隐式类类型转换而带来意想不到的错误,缺点是当用户的确需要这样的类类型转换时,不得不使用略显烦琐的方式来实现。

练习7.50:确定在你的 Person 类中是否有一些构造函数应该是 explicit 的。

【出题思路】explicit 的优点是可以避免程序员不期望的隐式类类型转换。

【解答】我们之前定义的 Person 类含有 3 个构造函数,因为前两个构造函数接受的参数个数都不是 1,所以它们不存在隐式转换的问题,当然也不必指定 explicit

Person 类的最后一个构造函数 Person(std::istream &is); 只接受一个参数,默认情况下它会把读入的数据自动转换成 Person 对象。
我们更倾向于严格控制 Person 对象的生成过程,如果确实需要使用 Person 对象,可以明确指定;
在其他情况下则不希望自动类型转换的发生。所以应该把这个构造函数指定为 explicit 的。

explicit构造函数只能用于直接初始化

当我们执行拷贝形式的初始化时,我们只能使用直接初始化而不能使用 explicit 构造函数:

Sales_data item1(null_book);    // 正确:直接初始化
// 错误:不能将 explicit 构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;

当我们用 explicit 关键字声明构造函数时,它将只能以直接初始化的形式使用,不能用于拷贝形式的初始化过程。而且,编译器将不会再自动转换过程中使用该构造函数。

为转换显式地使用构造函数

尽管编译器不会将 explicit 的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:

// 正确:实参是一个显式构造的 Sales_data 对象
item.combine(Sales_data(null_book));
// 正确:static_cast 可以使用 explicit 的构造函数
item.combine(static_cast<Sales_data>(cin));

在第一个调用中,我们直接使用 Sales_data 的构造函数,该调用通过接受 string 的构造函数创建了一个临时的 Sales_data 对象。

在第二个调用中,我们使用 static_cast 执行了显式的而非隐式的转换。其中,static_cast 使用 istream 构造函数创建了一个临时的 Sales_data 对象。

标准库中含有显示构造函数的类

  • 接受一个单参数的 const char*string 构造函数不是 explicit 的。
  • 接受一个容量参数的 vector 构造函数是 explicit 的。

练习7.51: vector 将其单参数的构造函数定义成explicit的,而 String 则不是,你觉得原因何在?

【出题思路】
从参数类型到类类型的自动转换是否有意义依赖于程序员的看法,如果这种转换是自然而然的,则不应该把它定义成explicit的;
如果二者的语义距离较远, 则为了避免不必要的转换,应该指定对应的构造函数是 explicit的。

【解答】
string 接受的单参数是 const char* 类型,如果我们得到了一个常量字符指针(字符数组),则把它看作 string 对象是自然而然的过程,编译器自动把参数类型转换成类类型也非常符合逻辑,因此我们无须指定为 explicit 的。

string 相反,vector 接受的单参数是 int 类型,这个参数的原意是指定 vector 的容量。如果我们在本来需要 vector 的地方提供一个 int 值并且希望这个 int 值自动转换成 vector,则这个过程显得比较牵强,因此把 vector 的单参数构造函数定义成 explicit 的更加合理。

7.5.5 聚合类 (aggregate class)

聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。
当一个类满足如下条件时,我们说它是聚合的:

  • 所有成员都是 public 的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有 virtual 函数。
// 下面的类是一个聚合类:
struct Data {
     
    int ival;
    string s;
};

// 提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员
// val1.ival = 0; val1.s = string("Anna")
Data val1 = {
      0, "Anna" };

练习7.52:使用 p64 的 Sales_data 类,解释下面的初始化过程。如果存在问题,尝试修改它。

Sales_data item = {
     "978-0590353403", 25, 15.99};

【解答】

程序的意图是对 item 执行聚合类初始化操作,用花括号内的值初始化 item 的数据成员。然而实际过程与程序的愿意不符合,编译器会报错。

这是因为聚合类必须满足一些非常苛刻的条件,其中一项就是没有类内初始值,数据成员 units_soldrevenue 都包含类内初始值。

只要去掉这两个类内初始值,程序就可以正常运行了。

struct Sales_data
{
     
    string bookNo;
    unsigned units_sold;
    double revenue;
};

因为 Data 类是聚合类,所以它也是一个字面值常量类。

7.5.6 字面值常量类

constexpr 函数的参数和返回值必须是字面值类型。

除了算术类型、引用和指针外,某些类也是字面值类型。

和其他类不同,字面值类型的类可能含有 constexpr 函数成员。这样的成员必须符合 constexpr 函数的所有要求,它们是隐式 const 的。

constexpr构造函数

一个字面值常量类必须至少提供一个 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;    // 硬件错误,而非 IO 错误
    bool io;    // IO 错误
    bool other; // 其他错误
};

constexpr 构造函数用于生成 constexpr 对象以及 constexpr 函数的参数或返回类型:

constexpr Debug io_sub(false, true, false);     // 调试 IO
if (io_sub.any())                               // 等价于 if(true)
    cerr << "print appropriate error messages" << endl;
constexpr Debug prod(false);                    // 无调试
if (prod.any())                                 // 等价于 if(false)
    cerr << "print an error message" << endl;

练习7.54:Debug 中以 set_ 开头的成员应该被声明成 constexpr 吗?如果不,为什么?

【解答】
这些以 set_ 开头的成员不能声明成 constexpr,这些函数的作用是设置数据成员的值,而 constexpr 函数只能包含 return 语句,不允许执行其他任务。

具体分析 p268:

constexpr 构造函数可以声明成 =default 的形式(或者是删除函数的形式)。
否则,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 iterestRate;
    static double initRate();
};

每个 Account 对象将包含两个数据成员:owneramount。只存在一个 interestRate 对象而且它被所有 Account 对象共享。

静态成员函数不包含 this 指针。作为结果,静态成员函数不能声明成 const 的,而且我们也不能在 static 函数体内使用 this 指针。

练习7.57:编写你自己的 Account 类。

/* 练习7.57:编写你自己的 Account 类。

【解答】如果类的某些(某个)成员从逻辑上来说更应该与类本身关联,而不是与类的具体对象关联,则我们应该把这种成员声明成静态的。
在 Account 类中,很明显利率是相对稳定和统一的,应该是静态成员;
而开户人以及它的储蓄额则与对象息息相关,不能是静态的。*/

class Account
{
     
private:
    string strName;
    double dAmount = 0.0;
    static double dRate;
};

使用类的静态成员

我们使用作用域运算符直接访问静态成员:

double r;
r = Account::rate();    // 使用作用域运算符访问静态成员

虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:

Account ac1;
Account *ac2 = &ac1;
// 调用静态成员函数 rate 的等价形式
r = ac1.rate();     // 通过 Account 的对象或引用
r = ac2->rate();    // 通过指向 Account 对象的指针

成员函数不用通过作用域运算符就能直接使用静态成员:

class Account {
     
public:
    void calculate() {
      amount += amount * interestRate; }
private:
    static double itnerestRate;
    // 其他成员与之前的版本一致
};

定义静态成员

Note: 和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static 关键字则只出现在类内部的声明语句中。

void Account::rate(double newRate)
{
     
    interestRate = newRate;
}

静态数据成员不属于类的任何一个对象,它们不是由类的构造函数初始化的。
我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。

类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。

// 定义并初始化一个静态成员
// 类型名、类名、作用域运算符、成员自己的名字
double Account::interestRate = initRate();

静态成员的类内初始化

// 例如:我们可以用一个初始化了的静态数据成员指定数组成员的维度
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;              // 初始值在类的定义内提供

练习7.58:下面的静态数据成员的声明和定义有错误吗?请解释原因。

/* 练习7.58:下面的静态数据成员的声明和定义有错误吗?请解释原因。*/

// example.h
class Example {
     
public:
    static double rate = 6.5;
    static const int vecSize = 20;
    static vector<double> vec(vecSize);
};

// example.C
#include "example.h"
double Example::rate;
vector<double> Example::vec;


/*【出题思路】本题旨在考察静态成员的用法。

【解答】本题的程序存在以下几处错误:
在类的内部,rate 和 vec 的初始化是错误的,因为除了静态常量成员之外,其他静态成员不能再类的内部初始化。

另外,example.c 文件的两条语句也是错误的,因为在这里我们必须给出静态成员的初始值。*/

// example.h
class Example {
     
public:
    double rate;
    static const int vecSize = 20;
    vector<double> vec;
};

// example.C
#include "example.h"
double Example::rate = 6.5;
vector<double> Example::vec(vecSize);

// 上面修改的是自己写的,不知道对不对?

通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr

注意:即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

静态成员能用于某些场景,而普通成员不能

区别一:
静态数据成员的类型可以就是它所属的类类型。
而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:

class Bar {
     
public:
    // ...
private:
    static Bar mem1;    // 正确:静态成员可以是不完全类型
    Bar *mem2;          // 正确:指针成员可以是不完全类型
    Bar mem3;           // 错误:数据成员必须是完全类型
};

区别二:
静态成员和普通成员的的另外一个区别是我们可以使用静态成员作为默认实参。
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。

class Screen {
     
public:
    // bkground 表示一个在类中稍后定义的静态成员
    Screen& clear(char = bkground);
private:
    static const char bkground'
}

练习7.55:什么是类的静态成员?它有何优点?静态成员与普通成员有何区别?

静态成员是指声明语句之前带有关键字 static 的类成员,静态成员不是任意单独对象的组成部分,而是由该类的全体对象所共享。

静态成员的优点包括:作用域位于类的范围之内,避免与其他类的成员或者全局作用域的名字冲突;
可以是私有成员,而全局对象不可以;
通过阅读程序可以非常容易地看出静态成员与特定类关联,使得程序的含义清晰明了。

静态成员与普通成员的区别主要体现在:
普通成员与类的对象关联,是某个具体对象的组成部分;
而静态成员不从属于任何具体的对象,它由该类的所有对象共享。
另外,还有一个细微的区别,静态成员可以作为默认实参,而普通数据成员不能作为默认实参。

你可能感兴趣的:(C++,Primer,c++)