文章目录

  • 定义抽象数据类型
    • 设计Sales_data类
      • 关键概念:不同的编程角色
      • 使用改进的Sales_data类
    • 定义改进的Sales_data类
      • 定义成员函数
      • 引入this
      • 引入const成员函数
      • 类作用域和成员函数
      • 在类的外部定义成员函数
      • 定义一个返回this对象的函数
    • 定义类相关的非成员函数
      • 定义read和print函数
      • 定义add函数
    • 构造函数
      • 合成的默认构造函数
      • 某些类不能依赖于合成的默认构造函数
      • 定义Sales_data的构造函数
      • = default的含义
      • 构造函数初始化列表
      • 在类的外部定义构造函数
    • 拷贝、赋值和析构
      • 某些类不能依赖于合成的版本
  • 访问控制与封装
    • 使用class或struct关键字
    • 友元
      • 关键概念:封装的益处
      • 友元的声明
  • 类的其他特性
    • 类成员再探
      • 定义一个类型成员
      • Screen类的成员函数
      • 令成员作为内联函数
      • 重载成员函数
      • 可变数据成员
      • 类数据成员的初始值
    • 返回*this的成员函数
      • 从const成员函数返回*this,它的返回类型将是常量引用
      • 基于const的重载
      • 建议:对于公共代码使用私有功能函数
    • 类类型
      • 类的声明(可先声明,暂时不定义)
    • 友元再探
      • 类之间的友元关系
      • 令成员函数作为友元
      • 函数重载和友元
      • 友元声明和作用域
  • 类的作用域
    • 作用域和定义在类外部的成员
    • 名字查找与类的作用域
      • 用于类成员声明的名字查找
      • 类型名要特殊处理
      • 成员定义中的普通块作用域的名字查找
      • 类作用域之后,在外围得作用域中查找
      • 在文件中名字得出现处对其进行解析
  • 构造函数再探
    • 构造函数初始值列表
      • 构造函数的初始值有时必不可少
      • 建议:使用构造函数初始值
      • 成员初始化的顺序
      • 默认实参和构造函数
    • 委托构造函数
    • 默认构造函数的作用
      • 使用默认构造函数
    • 隐式的类类型转换
      • 只允许一步类类型转换
      • 类类型转换不是总有效
      • explicit抑制构造函数定义的隐式转换
      • explicit构造函数只能用于直接初始化,不能用于拷贝初始化
      • 为转换显示地使用构造函数(可忽视explicit)
      • 标准库中含有显式构造函数的类
    • 聚合类
    • 字面值常量类
      • constexpr构造函数
  • 类的静态成员
    • 声明静态成员
      • 使用类的静态成员
      • 定义静态成员
      • 静态成员的类内初始化
      • 静态成员能用于某些场景,而普通成员不能
  • 一些术语

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

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

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

  • 由类的设计者负责考虑类的实现过程;
  • 使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。

定义抽象数据类型

在第1章中使用的Sales_item类是一个抽象数据类型,我们通过它的接口来使用一个Sales_item对象。我们不能访问Sales_item对象的数据成员,事实上,我们甚至根本不知道这个类有哪些数据成员。

与之相反,第2章Sales_data类不是一个抽象数据类型。它允许类的用户直接访问它的数据成员,并且要求由用户来编写操作。

要想把Sales_data变成抽象数据类型,我们需要定义一些操作以供类的用户使用。一旦 Sales_data定义了它自己的操作,我们就可以封装(隐藏)它的数据成员了。

设计Sales_data类

我们的最终目的是令Sales_data支持与Sales_item类完全一样的操作集合。Sales_item类有一个名为isbn的成员函数(member function),并且支持+、=、+=、<<和>>运算符。

我们将在第14章学习如何自定义运算符。现在,我们先为这些运算定义普通(命名的)函数形式。

综上所述,Sales_data的接口应该包含以下操作:

  • 一个isbn 成员函数,用于返回对象的ISBN编号
  • 一个combine 成员函数,用于将一个Sales_data对象加到另一个对象上
  • 一个名为 add 的函数,执行两个Sales_data对象的加法
  • 一个read函数,将数据从istream读入到Sales_data对象中
  • 一个print函数,将Sales_data对象的值输出到ostream

关键概念:不同的编程角色

程序员们常把运行其程序的人称作用户(user)。类似的,类的设计者也是为其用户设计并实现一个类的人;显然,类的用户是程序员,而非应用程序的最终使用者。

当我们提及“用户”一词时,不同的语境决定了不同的含义。如果我们说用户代码或者Sales data类的用户,指的是使用类的程序员;如果我们说书店应用程序的用户,则意指运行该应用程序的书店经理。

Note:C++程序员们无须刻意区分应用程序的用户以及类的用户。

在一些简单的应用程序中,类的用户和类的设计者常常是同一个人。尽管如此,还是最好把角色区分开来。当我们设计类的接口时,应该考虑如何才能使得类易于使用;而当我们使用类时,不应该顾及类的实现机理。

要想开发一款成功的应用程序,其作者必须充分了解并实现用户的需求。同样,优秀的类设计者也应该密切关注那些有可能使用该类的程序员的需求。作为一个设计良好的类,既要有直观且易于使用的接口,也必须具备高效的实现过程。

使用改进的Sales_data类

在考虑如何实现我们的类之前,首先来看看应该如何使用上面这些接口函数。

举个例子,我们使用这些函数编写第一章店程序的另外一个版本,其中不再使用Sales_item对象,而是使用Sales_data对象:

Sales_data total; // variable to hold the running sum
if (read(cin, total)) { // read the first transactionSales_data trans;  // variable to hold data for the next transactionwhile(read(cin, trans)) {  // read the remaining transactionsif (total.isbn() == trans.isbn()) // check the isbnstotal.combine(trans); // update the running total else {print(cout, total) << endl;   // print the resultstotal = trans; // process the next book}}print(cout, total) << endl;     // print the last transaction
} else { // there was no inputcerr << "No data?!" << endl; // notify the user
}

定义改进的Sales_data类

struct Sales_data {// new members: operations on Sales_data objectsstd::string isbn() const { return bookNo; }Sales_data& combine(const Sales_data&);double avg_price() const;//计算平均价格// data members are unchanged from § 2.6.1 (p. 72)std::string bookNo;//ISBN编号unsigned units_sold = 0;//本书销量double revenue = 0.0;//总销售收入
};// nonmember Sales_data interface functions
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);

定义和声明成员函数的方式与普通函数差不多。

成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部

作为接口组成部分的非成员函数,例如 add、read和 print等,它们的定义和声明都在类的外部。

Note:定义在类内部的函数是隐式的inline函数(第6章内容)。

定义成员函数

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

对于Sales_data类来说,isbn函数定义在了类内,而combine和 avg_price定义在了类外。
我们首先介绍isbn函数,它的参数列表为空,返回值是一个string对象:

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

和其他函数一样,成员函数体也是一个块。

关于 isbn函数一件有意思的事情是:它是如何获得bookNo成员所依赖的对象的呢?

引入this

对isbn成员函数的调用:

total.isbn()

在这里,我们使用了点运算符来访问total对象的isbn成员,然后调用它。

本章将介绍一种例外的形式,当我们调用成员函数时,实际上是在替某个对象调用它。如果isbn指向Sales_data的成员(例如 bookNo),则它隐式地指向调用该函数的对象的成员。在上面所示的调用中,当isbn返回 bookNo时,实际上它隐式地返回total.bookNo。

成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。例如,如果调用

total.isbn()

则编译器负责把total的地址传递给isbn的隐式形参this,可以等价地认为编译器将该调用重写成了如下的形式:

//伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&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 是一个常量指针(第2章内容),我们不允许改变this中保存的地址。

引入const成员函数

isbn函数的另一个关键之处是紧随参数列表之后的 const关键字,这里,const的作用是修改隐式this指针的类型

默认情况下,this的类型是指向类类型非常量版本的常量指针。例如在Sales_data成员函数中, this 的类型是Sales_data *const。

尽管this是隐式的,但它仍然需要遵循初始化规则,意味着(在默认情况下)我们不能把this绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数

(MyNote:const对象不能调用普通成员函数。)

如果isbn是一个普通函数而且this是一个普通的指针参数,则我们应该把this声明成const Sales data *const。毕竟,在isbn 的函数体内不会改变this所指的对象,所以把this设置为指向常量的指针有助于提高函数的灵活性。(MyNote:这样可以让非常量对象 与 常量对象都可以调用。)

然而,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内的数据成员。

值得注意的是,即使 bookNo定义在isbn之后,isbn也还是能够使用bookNo。

编译器分两步处理类:

  1. 首先编译成员的声明,
  2. 然后才轮到成员函数体(如果有的话)。

因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

在类的外部定义成员函数

像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定const 属性。同时,类外部定义的成员的名字必须包含它所属的类名:

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

函数名Sales_data::avg_price使用作用域运算符来说明如下的事实:我们定义了一个名为avg_price的函数,并且该函数被声明在类Sales_data的作用域内。一旦编译器看到这个函数名,就能理解剩余的代码是位于类的作用域内的。因此,当avg_price使用revenue和 units_sold 时,实际上它隐式地使用了Sales_data的成员。

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

函数combine的设计初衷类似于复合赋值运算符+=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入函数:

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当前的值

total的地址被绑定到隐式的 this 参数上,而rhs 绑定到了trans 上。因此,当combine执行下面的语句时,

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

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

该函数一个值得关注的部分是它的返回类型和返回语句。一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回(,因此为了与它保持一致,combine函数必须返回引用类型。因为此时的左侧运算对象是一个Sales_data的对象,所以返回类型应该是Sales_data&。

如前所述,我们无须使用隐式的this指针访问函数调用者的某个具体成员,而是需要把调用函数的对象当成一个整体来访问:

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

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

定义类相关的非成员函数

类的作者常常需要定义一些辅助函数,比如 add、read和 print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。

我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。

Note:一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。

定义read和print函数

// input transactions contain ISBN, number of copies sold, and sales price
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;
}

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

除此之外,关于上面的函数还有两点是非常重要的。第一点,read和print分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。(MyNote:is, os不用const修饰,因为读取和写入的操作会改变流的内容)

第二点,print函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。

定义add函数

add函数接受两个Sales_data对象作为其参数,返回值是一个新的Sales_data,用于表示前两个对象的和:

Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{Sales_data sum = lhs; // copy data members from lhs into sumsum.combine(rhs); // add data members from rhs into sumreturn sum;
}

在函数体中,我们定义了一个新的Sales_data对象并将其命名为sum。sum将用于存放两笔交易的和,我们用lhs的副本来初始化 sum。默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。在拷贝工作完成之后,sum的bookNo.units_sold和revenue将和lhs一致。接下来我们调用combine函数,将rhs 的units_sold和revenue添加给sum。最后,函数返回sum的副本。

构造函数

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

在这一节中,我们将介绍定义构造函数的基础知识。构造函数是一个非常复杂的问题,我们还会在本章末和第13,15,18章介绍更多关于构造函数的知识。

构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。

不同于其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。

合成的默认构造函数

我们的Sales_data类并没有定义任何构造函数,可是之前使用了Sales_data对象的程序仍然可以正确地编译和运行。举个例子,程序定义了两个对象:

Sales_data total; //保存当前求和结果的变量
Sales_data trans; //保存下一条交易数据的变量

这时我们不禁要问:total和trans是如何初始化的呢?

我们没有为这些对象提供初始值,因此我们知道它们执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数( default constructor)。默认构造函数无须任何实参。

如我们所见,默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。

编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员。
  • 否则,默认初始化该成员。

因为Sales_data为units_sold和revenue提供了初始值,所以合成的默认构造函数将使用这些值来初始化对应的成员;同时,它把 bookNo默认初始化成一个空字符串。

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

合成的默认构造函数只适合非常简单的类,比如现在定义的这个Sales_data版本。对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:

第一个原因也是最容易理解的一个原因就是编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。这条规则的依据是,如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。

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

第二个原因是对于某些类来说,合成的默认构造函数可能执行错误的操作。回忆我们之前介绍过的,如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则它们的值将是未定义的。该准则同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。

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

第三个原因是有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。在第13章中我们将看到还有其他一些情况也会导致编译器无法生成一个正确的默认构造函数。

定义Sales_data的构造函数

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

  • 一个istream&,从中读取一条交易信息。
  • 一个const string&,表示ISBN编号;一个unsigned,表示售出的图书数量;以及一个 double,表示图书的售出价格。
  • 一个const string&,表示ISBN编号;编译器将赋予其他成员默认值。
  • 一个空参数列表(即默认构造函数),正如刚刚介绍的,既然我们已经定义了其他构造函数,那么也必须定义一个默认构造函数。
struct Sales_data {// constructors addedSales_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 &);// other members as beforestd::string isbn() const {return bookNo;}Sales_data& combine(const Sales_data&);double avg_price() const;std::string bookNo;unsigned units_sold = 0;double revenue = 0.0;
};

= default的含义

我们从解释默认构造函数的含义开始:

Sales_data() = default ;

首先请明确一点:因为该构造函数不接受任何实参,所以它是一个默认构造函数。我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数

在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上**= default**来要求编译器生成构造函数。其中,= default 既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。

WARNING:上面的默认构造函数之所以对Sales_data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表(马上就会介绍)来初始化类的每个成员。

构造函数初始化列表

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),它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。

含有三个参数的构造函数分别使用它的前两个参数初始化成员 bookNo和units_sold,revenue 的初始值则通过将售出图书总数和每本书单价相乘计算得到。

只有一个string类型参数的构造函数使用这个string对象初始化 bookNo,对于units_sold和 revenue则没有显式地初始化。当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。在此例中,这样的成员使用类内初始值初始化,因此只接受一个string参数的构造函数等价于

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

通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

Best Practices:构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

有一点需要注意,在上面的两个构造函数中函数体都是空的。这是因为这些构造函数的唯一目的就是为数据成员赋初值,一旦没有其他任务需要执行,函数体也就为空了。

在类的外部定义构造函数

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

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

构造函数没有返回类型,所以上述定义从我们指定的函数名字开始。和其他成员函数一样,当我们在类的外部定义构造函数时,必须指明该构造函数是哪个类的成员。因此,Sales_data::Sales_data的含义是我们定义Sales_data类的成员,它的名字是Sales_data。又因为该成员的名字和类名相同,所以它是一个构造函数。

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

没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始化。对于Sales_data来说,这意味着一旦函数开始执行,则bookNo将被初始化成空string对象,而units_sold和revenue将是0。

为了更好地理解调用函数read 的意义,要特别注意read的第二个参数是一个Sales_data对象的引用。在中曾经提到过,使用this来把对象当成一个整体访问,而非直接访问对象的某个成员。因此在此例中,我们使用*this 将“this”对象作为实参传递给read函数。

// 上文的read函数
// input transactions contain ISBN, number of copies sold, and sales price
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;
}

拷贝、赋值和析构

除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。

对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等(参见第6章的传参与返回章节)。

当我们使用了赋值运算符时会发生对象的赋值操作。

当对象不再存在时执行销毁的操作,比如一个局部对象会在创建它的块结束时被销毁,当vector对象(或者数组)销毁时存储在其中的对象也会被销毁。

如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。例如,当编译器执行如下赋值语句时,

total = trans; //处理下一本书的信息

它的行为与下面的代码相同

//Sales_data的默认赋值操作等价于:
total.bookNo = trans.bookNo;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;

我们将在第13章中介绍如何自定义上述操作。

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

尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。举个例子,第12章将介绍C++程序是如何分配和管理动态内存的。而在而第13章我们将会看到,管理动态内存的类通常不能依赖于上述操作的合成版本。

不过值得注意的是,很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的存储空间。使用vector或者string 的类能避免分配和释放内存带来的复杂性。However, it is worth noting that many classes that need dynamic memory can (and generally should) use a vector or a string to manage the necessary storage. Classes that use vectors and strings avoid the complexities involved in allocating and deallocating memory.

进一步讲,如果类包含vector或者string 成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector成员的对象执行拷贝或者赋值操作时,vector类会设法拷贝或者赋值成员中的元素。当这样的对象被销毁时,将销毁vector对象,也就是依次销毁vector中的每一个元素。这一点与string是非常类似的。

WARNING:在学习第13章关于如何自定义操作的知识之前,类中所有分配的资源都应该直接以类的数据成员的形式存储。

访问控制与封装

到目前为止,我们已经为类定义了接口,但并没有任何机制强制用户使用这些接口。我们的类还没有封装,也就是说,用户可以直达Sales_data对象的内部并且控制它的具体实现细节。在C++语言中,我们使用访问说明符(access specifiers)加强类的封装性:

  • 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
  • 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。

再一次定义Sales_data类,其新形式如下所示:

class Sales_data {public: // access specifier addedSales_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: // access specifier addeddouble avg_price() const {return units_sold ? revenue / units_sold : 0;}std::string bookNo;unsigned units_sold = 0;double revenue = 0.0;
};

使用class或struct关键字

在上面的定义中我们还做了一个微妙的变化,我们使用了class关键字而非struct开始类的定义。这种变化仅仅是形式上有所不同,实际上我们可以使用这两个关键字中的任何一个定义类唯一的一点区别是,struct和class的默认访问权限不太一样

类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public的;相反,如果我们使用class 关键字,则这些成员是private的。

出于统一编程风格的考虑,当我们希望定义的类的所有成员是 public的时,使用struct;反之,如果希望成员是private的,使用class。

WARNING:使用class和struct定义类唯一的区别就是默认的访问权限。

友元

既然Sales_data 的数据成员是private的,我们的read、print和 add函数也就无法正常编译了,这是因为尽管这几个函数是类的接口的一部分,但它们不是类的成员。

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

class Sales_data {// friend declarations for nonmember Sales_data operations addedfriend 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&);// other members and access specifiers as beforepublic: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;
};// declarations for nonmember parts of the Sales_data interface
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);

友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。我们将在下章节介绍更多关于友元的知识。

Tip:一般来说,最好在类定义开始或结束前的位置集中声明友元。

(MyNote:友元,好朋友声明,在类内部声明,表示这些函数是我类的好朋友,它们可以访问我们成员变量。)

关键概念:封装的益处

封装有两个重要的优点:

  • 确保用户代码不会无意间破坏封装对象的状态。

  • 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。

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

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

Note:尽管当类的定义发生改变时无须更改用户代码,但是使用了该类的源文件必须重新编译。

友元的声明

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

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

Note:许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。

一些编1译器允许在尚无友元函数的初始声明的情况下就调用它。不过即使你的编译器支持这种行为,最好还是提供一个独立的函数声明。这样即使你更换了一个有这种强制要求的编译器,也不必改变代码。

类的其他特性

类成员再探

为了展示这些新的特性,我们需要定义一对相互关联的类,它们分别是Screen和Window_mgr。

定义一个类型成员

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

除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public或者private中的一种:

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

我们在Screen的public部分定义了pos,这样用户就可以使用这个名字。Screen的用户不应该知道Screen使用了一个string对象来存放它的数据,因此通过把pos定义成public成员可以隐藏Screen实现的细节。

关于 pos 的声明有两点需要注意。首先,我们使用了typedef,也可以等价地使用类型别名:

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

其次,用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别,具体原因随后解释。因此,类型成员通常出现在类开始的地方。

Screen类的成员函数

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

class Screen {public:typedef std::string::size_type pos;Screen() = default; // needed because Screen has another constructor// cursor initialized to 0 by its in-class initializer//令用户能够定义屏幕的尺寸和内容Screen(pos ht, pos wd, char c): height(ht), width(wd),contents(ht * wd, c) { }char get() const // get the character at the cursor {return contents[cursor]; // implicitly inline}// 读取给定位置的字符inline char get(pos ht, pos wd) const; // explicitly inline//移动光标Screen &move(pos r, pos c); // can be made inline laterprivate:pos cursor = 0;pos height = 0, width = 0;std::string contents;
};

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

需要指出的是,第二个构造函数(接受三个参数)为cursor成员隐式地使用了类内初始值。如果类中不存在cursor的类内初始值,我们就需要像其他成员一样显式地初始化 cursor 了。

令成员作为内联函数

在类中,常有一些规模较小的函数适合于被声明成内联函数。如我们之前所见的,定义在类内部的成员函数是自动inline的。因此,Screen的构造函数和返回光标所指字符的get函数默认是inline函数。

我们可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义:

inline // we can specify inline on the definition
Screen &Screen::move(pos r, pos c)
{pos row = r * width; // compute the row locationcursor = row + c ; // move cursor to the column within that rowreturn *this; // return this object as an lvalue
}char Screen::get(pos r, pos c) const // declared as inline in the class
{pos row = r * width; // compute row locationreturn contents[row + c]; // return character at the given column
}

虽然我们无须在声明和定义的地方同时说明inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明inline,这样可以使类更容易理解。

Note:和我们在头文件中定义inline函数的原因一样,inline成员函数也应该与相应的类定义在同一个头文件中。

重载成员函数

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

举个例子,我们的Screen类定义了两个版本的get函数。

  1. 一个版本返回光标当前位置的字符;

  2. 另一个版本返回由行号和列号确定的位置的字符。

编译器根据实参的数量来决定运行哪个版本的函数:

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; // may change even in a const object// other members as before
};void Screen::some_member() const
{++access_ctr; // keep a count of the calls to any member function// whatever other work this member needs to do
}

尽管some_member是一个const成员函数,它仍然能够改变access_ctr的值。该成员是个可变成员,因此任何成员函数,包括const函数在内都能改变它的值。

类数据成员的初始值

在定义好Screen类之后,我们将继续定义一个窗口管理类并用它表示显示器上的一组 Screen。这个类将包含一个Screen类型的 vector,每个元素表示一个特定的Screen。默认情况下,我们希望window_mgr类开始时总是拥有一个默认初始化的
Screen。在C++11新标准中,最好的方式就是把这个默认值声明成一个类内初始值:

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

当我们初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。在此例中,我们使用一个单独的元素值对vector成员执行了列表初始化,这个 Screen 的值被传递给vector<Screen>的构造函数,从而创建了一个单元素的vector对象。

具体地说,Screen 的构造函数接受两个尺寸参数和一个字符值,创建了一个给定大小的空白屏幕对象。

如我们之前所知的,类内初始值必须使用=的初始化形式(初始化Screen的数据成员时所用的)或者花括号括起来的直接初始化形式(初始化Screens所用的)。

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

返回*this的成员函数

接下来我们继续添加一些函数,它们负责设置光标所在位置的字符或者其他任一给定位置的字符

class Screen {public:Screen &set(char);Screen &set(pos, pos, char);// other members as before
};inline Screen &Screen::set(char c)
{contents[cursor] = c; // set the new value at the current cursor locationreturn *this; // return this object as an lvalue
}inline Screen &Screen::set(pos r, pos col, char ch)
{contents[r * width + col] = ch; // set specified location to givenvaluereturn *this; // return this object as an lvalue
}

和move操作一样,我们的set成员的返回值是调用set的对象的引用。返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。如果我们把一系列这样的操作连接在一条表达式中的话:

//把光标移动到一个指定的位置,然后设置该位置的字符值
myScreen.move(4, 0).set ('#’);

这些操作将在同一个对象上执行。在上面的表达式中,我们首先移动myScreen内的光标,然后设置myScreen 的contents成员。也就是说,上述语句等价于

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

如果我们令move和set返回Screen而非Screen&,则上述语句的行为将大不相同。在此例中等价于:

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

假如当初我们定义的返回类型不是引用,则move的返回值将是*this的副本,因此调用set 只能改变临时副本,而不能改变myScreen的值。

从const成员函数返回*this,它的返回类型将是常量引用

接下来,我们继续添加一个名为display的操作,它负责打印Screen的内容。我们希望这个函数能和move 以及 set出现在同一序列中,因此类似于move和set,display函数也应该返回执行它的对象的引用。

从逻辑上来说,显示一个Screen并不需要改变它的内容,因此我们令display为一个const成员,此时,this将是一个指向const的指针而*this是 const对象。由此推断,display的返回类型应该是const Sales_data&。然而,如果真的令display返回一个const 的引用,则我们将不能把display嵌入到一组动作的序列中去:

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

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

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

基于const的重载

通过区分成员函数是否是const 的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向const而重载函数的原因差不多。具体说来,因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const 成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。

在下面的这个例子中,我们将定义一个名为 do_display的私有成员,由它负责打印Screen的实际工作。所有的display操作都将调用这个函数,然后返回执行操作的对象:

class Screen {public:// display overloaded on whether the object is const or notScreen &display(std::ostream &os){ do_display(os); return *this; }const Screen &display(std::ostream &os) const{ do_display(os); return *this; }
private:// function to do the work of displaying a Screenvoid do_display(std::ostream &os) const {os << contents;}// other members as before
};

和我们之前所学的一样,当一个成员调用另外一个成员时,this指针在其中隐式地传递。因此,

  • 当display调用do_display时,它的this指针隐式地传递给do_display。
  • 而当display的非常量版本调用do_display时,它的this指针将隐式地从指向非常量的指针转换成指向常量的指针。

当do_display完成后,display函数各自返回解引用this所得的对象。

  • 在非常量版本中,this指向一个非常量对象,因此display返回一个普通的(非常量)引用;
  • 而const成员则返回一个常量引用。

当我们在某个对象上调用display 时,该对象是否是 const决定了应该调用display的哪个版本:

Screen myScreen(5,3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // calls non const version
blank.display(cout); // calls const version

建议:对于公共代码使用私有功能函数

有些读者可能会奇怪为什么我们要费力定义一个单独的do_display函数。毕竟,对do_display的调用并不比 do_display函数内部所做的操作简单多少。为什么还要这么做呢?实际上我们是出于以下原因的?

  • 一个基本的愿望是避免在多处使用同样的代码。
  • 我们预期随着类的规模发展,display函数有可能变得更加复杂,此时,把相应的操作写在一处而非两处的作用就比较明显了。
  • 我们很可能在开发过程中给do_display函数添加某些调试信息,而这些信息将在代码的最终产品版本中去掉。显然,只在 do_display一处添加或删除这些信息要更容易一些。
  • 这个额外的函数调用不会增加任何开销。因为我们在类内部定义了do_display,所以它隐式地被声明成内联函数。这样的话,调用 do_display就不会带来任何额外的运行时开销。

在实践中,设计良好的C++代码常常包含大量类似于do_display的小函数,通过调用这些函数,可以完成一组其他函数的“实际”工作。

(MyNote:重复代码->重构。)

类类型

每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。例如:

struct First{int memi;int getMem();
};
struct Second{int memi;int getMem();
};
First obj1;
second obj2 = obj1; //错误:obj1和obj2的类型不同

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

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

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

上面这两种使用类类型的方式是等价的,其中第二种方式从C语言继承而来,并且在C++语言中也是合法的。

类的声明(可先声明,暂时不定义)

就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:

class Screen;// Screen类的声明

这种声明有时被称作前向声明(forward declaration),它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。

不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。

对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则,编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或者指针访问其成员。毕竟,如果类尚未定义,编译器也就不清楚该类到底有哪些成员。(MyNote:只声明不定义的局限)

随后我们将描述一种例外的情况:直到类被定义之后数据成员才能被声明成这种类类型。换句话说,我们必须首先完成类的定义,然后编译器才能知道存储该数据成员需要多少空间。因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。

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

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

友元再探

我们的Sales_data类把三个普通的非成员函数定义成了友元。类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。

类之间的友元关系

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

class Screen {// Window_mgr members can access the private parts of class Screenfriend class Window_mgr;// ... rest of the Screen class
};

如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。通过上面的声明,Window_mgr被指定为Screen的友元,因此我们可以将Window_mgr的clear成员写成如下的形式:

class Window_mgr {public:// location ID for each Screen on the windowusing ScreenIndex = std::vector<Screen>::size_type;// reset the Screen at the given position to all blanksvoid clear(ScreenIndex);
private:std::vector<Screen> Screens{Screen(24, 80, ' ')};
};void Window_mgr::clear(ScreenIndex i)
{// s is a reference to the Screen we want to clearScreen &s = Screens[i];// reset the contents of that Screen to all blankss.contents = string(s.height * s.width, ' ');
}

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

如果clear不是Screen的友元,上面的代码将无法通过编译,因为此时clear将不能访问Screen的height、width和contents成员。而当Screen将window_mgr指定为其友元之后,Screen 的所有成员对于window_mgr就都变成可见的了。

必须要注意的一点是,友元关系不存在传递性。也就是说,如果Window_mgr有它自己的友元,则这些友元并不能理所当然地具有访问Screen的特权。

Note:每个类负责控制自己的友元类或友元函数。

令成员函数作为友元

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

class Screen {// Window_mgr::clear must have been declared before class Screenfriend void Window_mgr::clear(ScreenIndex);// ... rest of the Screen class
};

要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:

  • 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在 clear使用Screen的成员之前必须先声明Screen。
  • 接下来定义Screen,包括对于clear的友元声明。
  • 最后定义clear,此时它才可以使用Screen的成员。

函数重载和友元

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

// overloaded storeOn functions
extern std::ostream& storeOn(std::ostream &, Screen &);
extern BitMap& storeOn(BitMap &, Screen &);class Screen {// ostream version of storeOn may access the private parts of
Screen objectsfriend std::ostream& storeOn(std::ostream &, Screen &);// . . .
};

友元声明和作用域

类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。(MyNote:再本章前部分的“友元的声明”有相关说明。)

甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:

struct X {friend void f() { /* friend function can be defined in the class body */ }X() { f(); } // error: no declaration for fvoid g();void h();
};void X::g() { return f(); } // error: f hasn't been declared
void f(); // declares the function defined inside X
void X::h() { return f(); } // ok: declaration for f is now in scope

关于这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明

Note:请注意,有的编译器并不强制执行上述关于友元的限定规则。

(MyNote:友元函数声明不同于普通函数声明,它们是两码事。)

类的作用域

每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符(.)来访问。对于类类型成员则使用作用域运算符(::)访问。不论哪种情况,跟在运算符之后的名字都必须是对应类的成员:

Screen::pos ht = 24, wd = 80; // use the pos type defined by Screen
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get(); // fetches the get member from the object scr
c = p->get(); // fetches the get member from the object to which p points

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

一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,成员的名字被隐藏起来了。

一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无须再次授权了

例如,我们回顾一下Window _mgr类的clear成员,该函数的参数用到了Window_mgr类定义的一种类型:

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

因为编译器在处理参数列表之前已经明确了我们当前正位于Window_mgr类的作用域中,所以不必再专门说明ScreenIndex是Window_mgr类定义的。出于同样的原因,编译器也能知道函数体中用到的Screens也是在Window_mgr类中定义的。

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

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

class Window_mgr {public:// add a Screen to the window and returns its indexScreenIndex addScreen(const Screen&);// other members as before
};// return type is seen before we're in the scope of Window_mgr
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s)
{Screens.push_back(s);return Screens.size() - 1;
}

因为返回类型出现在类名之前,所以事实上它是位于Window_mgr类的作用域之外的。在这种情况下,要想使用ScreenIndex作为返回类型,我们必须明确指定哪个类定义了它。

名字查找与类的作用域

在目前为止,我们编写的程序中,名字查找(name lookup)(寻找与所用名字最匹配的声明的过程)的过程比较直截了当:

  • 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
  • 如果没找到,继续查找外层作用域。
  • 如果最终没有找到匹配的声明,则程序报错。

对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别,不过在当前的这个例子中体现得不太明显。类的定义分两步处理

  • 首先,编译成员的声明。
  • 直到类全部可见后才编译函数体。

Note:编译器处理完类中的全部声明后才会处理成员函数的定义。(MyNote:类中,先全部声明,后定义)

按照这种两阶段的方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。

相反,如果函数的定义和成员的声明被同时处理,那么我们将不得不在成员函数中只使用那些已经出现的名字。

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

这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。

例如:

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

当编译器看到balance函数的声明语句时,它将在Account类的范围内寻找对Money的声明。编译器只考虑Account中在使用Money前出现的声明,因为没找到匹配的成员,所以编译器会接着到Account的外层作用域中查找。

在这个例子中,编译器会找到Money的typedef语句,该类型被用作balance函数的返回类型以及数据成员bal的类型。另一方面,balance函数体在整个类可见后才被处理,因此,该函数的return 语句返回名为bal 的成员,而非外层作用域的string对象。

类型名要特殊处理

一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字

typedef double Money;
class Account {public:Money balance() { return bal; } // uses Money from the outer scope
private:typedef double Money; // error: cannot redefine MoneyMoney bal;// ...
};

需要特别注意的是,即使Account中定义的Money类型与外层作用域一致,上述代码仍然是错误的。

尽管重新定义类型名字是一种错误的行为,但是编译器并不为此负责。一些编译器仍将顺利通过这样的代码,而忽略代码有错的事实。(MyNote:┑( ̄Д  ̄)┍)

Tip:类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。

成员定义中的普通块作用域的名字查找

成员函数中使用的名字按照如下方式解析:(MyNote:本节重点)

  • 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。

  • 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。

  • 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。

(MyNote:初读这些界定词有点绕,结合下面程序理解。)

一般来说,不建议使用其他成员的名字作为某个成员函数的参数。不过为了更好地解释名字的解析过程,我们不妨在dummy_fcn函数中暂时违反一下这个约定:

//注意:这段代码仅为了说明而用,不是一段很好的代码
//通常情况下不建议为参数和成员使用同样的名字
int height; //定义了一个名字,稍后将在Screen中使用
class Screen {public:typedef std::string::size_type pos;void dummy_fcn(pos height) {cursor = width * height; // which height? the parameter}
private:pos cursor = 0;pos height = 0, width = 0;
};

当编译器处理dummy_fcn 中的乘法表达式时,它首先在函数作用域内查找表达式中用到的名字。函数的参数位于函数作用域内,因此 dummy_fcn函数体内用到的名字height指的是参数声明

在此例中,height参数隐藏了同名的成员。如果想绕开上面的查找规则,应该将代码变为:

// bad practice: names local to member functions shouldn't hide member names
void Screen::dummy_fcn(pos height) {cursor = width * this->height; // member height// alternative way to indicate the membercursor = width * Screen::height; // member height
}

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

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

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

在此例中,当编译器查找名字height时,显然在 dummy_fcn函数内部是找不到的。编译器接着会在Screen内查找匹配的声明,即使height的声明出现在dummy_fcn使用它之后,编译器也能正确地解析函数使用的是名为height的成员。

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

如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。

在我们的例子中,名字height定义在外层作用域中,且位于Screen的定义之前。然而,外层作用域中的对象被名为height 的成员隐藏掉了。因此,如果我们需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求:

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

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

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

当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。例如:

int height; // defines a name subsequently used inside Screen
class Screen {public:typedef std::string::size_type pos;void setHeight(pos);pos height = 0; // hides the declaration of height in the outer scope
};Screen::pos verify(Screen::pos);
void Screen::setHeight(pos var) {// var: refers to the parameter// height: refers to the class member// verify: refers to the global functionheight = verify(var);
}

请注意,全局函数verify的声明在Screen类的定义之前是不可见的。然而,名字查找的第三步包括了成员函数出现之前的全局作用域。在此例中,verify的声明位于setHeight的定义之前,因此可以被正常使用。

(MyNote:Java变量都定义在类内,C++的类内外都可声明定义,麻烦不止一点点。)

(MyNote:C++有关类的作用域:1. 成员函数作用域 2. 类作用域 3.类外围作用域。本节重点看“成员定义中的普通块作用域的名字查找”。)

构造函数再探

构造函数初始值列表

当我们定义变量时习惯于立即对其进行初始化,而非先定义、再赋值

string foo = "Hello world!" ; //定义并初始化
string bar; //默认初始化成空string对象
bar = "Hello world! "; //为bar赋一个新值

就对象的数据成员而言,初始化和赋值也有类似的区别。**如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。**例如:

// Sales_data构造函数的一种写法,虽然合法但比较草率:没有使用构造函数初始值
Sales_data::Sales_data(const string &s, unsigned cnt, double price){bookNo = s ;units_sold = cnt;revenue = cnt * price;
}

这段代码和前文的原始定义效果是相同的:

struct Sales_data {// constructors addedSales_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 &);//...
};

当构造函数完成后,数据成员的值相同。区别是原来的版本初始化了它的数据成员,而这个版本是对数据成员执行了赋值操作。这一区别到底会有什么深层次的影响完全依赖于数据成员的类型。

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

有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。例如:

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

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

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

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

// ok: explicitly initialize reference and const members
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) { }

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

建议:使用构造函数初始值

在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。In many classes, the distinction between initialization and assignment is strictly a matter of low-level efficiency: A data member is initialized and then assigned when it could have been initialized directly.

除了效率问题外更重要的是,一些数据成员必须被初始化。建议读者养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。

成员初始化的顺序

显然,在构造函数初始值中每个成员只能出现一次。否则,给同一个成员赋两个不同的初始值有什么意义呢?

不过让人稍感意外的是,构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。

成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。

构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了。

举个例子,考虑下面这个类:

class X {int i;int j;
public:// undefined: i is initialized before jX(int val): j(val), i(j) { }
};

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

有的编译器具备一项比较友好的功能,即当构造函数初始值列表中的数据成员顺序与这些成员声明的顺序不符时会生成一条警告信息。

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

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

例如,X的构造函数如果写成如下的形式效果会更好:

X(int val):i(val), j(val) { }

在这个版本中,i和j初始化的顺序就没什么影响了。

默认实参和构造函数

Sales_data 默认构造函数的行为与只接受一个string 实参的构造函数差不多。

唯一的区别是接受String实参的构造函数使用这个实参初始化bookNo,而默认构造函数(隐式地)使用string的默认构造函数初始化bookNo。我们可以把它们重写成一个使用默认实参(第6章内容)的构造函数:

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

当没有给定实参,或者给定了一个string实参时,两个版本的类创建了相同的对象。因为我们不提供实参也能调用上述的构造函数,所以该构造函数实际上为我们的类提供了默认构造函数。

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

值得注意的是,我们不应该为Sales_data接受三个实参的构造函数提供默认值。因为如果用户为售出书籍的数量提供了一个非零的值,则我们就会期望用户同时提供这些书籍的售出价格。

委托构造函数

C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数

和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。

举个例子,我们使用委托构造函数重写Sales_data类,重写后的形式如下所示:

class Sales_data {public:// nondelegating constructor initializes members from corresponding arguments//1.Sales_data(std::string s, unsigned cnt, double price):bookNo(s), units_sold(cnt), revenue(cnt*price) {}// remaining constructors all delegate to another constructor//2.Sales_data(): Sales_data("", 0, 0) {}//3.Sales_data(std::string s): Sales_data(s, 0,0) {}//4.Sales_data(std::istream &is): Sales_data() { read(is, *this); }// other members as before
};

在这个Sales_data类中,除了一个构造函数外其他的都委托了它们的工作。

  1. 第一个构造函数接受三个实参,使用这些实参初始化数据成员,然后结束工作。

  2. 我们定义默认构造函数令其使用三参数的构造函数完成初始化过程,它也无须执行其他任务,这一点从空的构造函数体能看得出来。

  3. 接受一个string 的构造函数同样委托给了三参数的版本。

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

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体皆被执行。在Sales_data类中,受委托的构造函数体恰好是空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。(MyNote:先函数体代码,再委托构造函数)

默认构造函数的作用

当对象被默认初始化(Default initialization) 或 值初始化(Value initialization)(第3章)时自动执行默认构造函数。

默认初始化在以下情况下发生:

  • 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。(第2章)

  • 当一个类本身含有类类型的成员且使用合成的默认构造函数时(本章)。

  • 当类类型的成员没有在构造函数初始值列表中显式地初始化时(本章)。

值初始化在以下情况下发生:

  • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时(第3章)。

  • 当我们不使用初始值定义一个局部静态变量时(第6章)。

  • 当我们通过书写形如T()的表达式显式地请求值初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小(第3章),它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。

类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。

不那么明显的一种情况是类的某些数据成员缺少默认构造函数:

class NoDefault {public:NoDefault(const std::string&);// additional members follow, but no other constructors
};struct A { // my_mem is public by default; NoDefault my_mem;
};A a; // error: cannot synthesize a constructor for Astruct B {B() {} // error: no initializer for b_memberNoDefault b_member; //没有默认构造函数
};

Best Practices:在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。


默认初始化(default initialization)当对象未被显式地赋予初始值时执行的初始化行为。由类本身负责执行的类对象的初始化行为。全局作用域的内置类型对象初始化为0;局部作用域的对象未被初始化即拥有未定义的值。(来自第2章术语表)

值初始化(value initialization)是种初始化过程。内置类型初始化为0,类类型由类的默认构造函数初始化。只有当类包含默认构造函数时,该类的对象才会被值初始化。对于容器的初始化来说,如果只说明了容器的大小而没有指定初始值的话,就会执行值初始化。此时编译器会生成个值,而容器的元素被初始化为该值。(第3章术语表)

(MyNote:个人没有感觉默认初始化值初始化这两个术语有明显区别。(ーー゛)个人理解:值初始化主要用在数组,容器,其余的都是默认初始化。)

使用默认构造函数

下面的obj的声明可以正常编译通过:

Sales_data obj();//正确:定义了一个函数而非对象

但当我们试图使用obj 时,编译器将报错,提示我们不能对函数使用成员访问运算符。

if(obj.isbn () == Primer_5th_ed.isbn())//错误:obj是一个函数

问题在于,尽管我们想声明一个默认初始化的对象,obj实际的含义却是一个不接受任何参数的函数并且其返回值是Sales_data类型的对象。

如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对:

//正确:obj是个默认初始化的对象
Sales_data obj;

WARNING

对于C++的新手程序员来说有一种常犯的错误,它们试图以如下的形式声明一个用默认构造函数初始化的对象

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

隐式的类类型转换

第4章曾经介绍过C++语言在内置类型之间定义了几种自动转换规则。

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

将在第14章介绍如何定义将一种类类型转换为另一种类类型的转换规则。

Note:能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。

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

只允许一步类类型转换

第4章指出,编译器只会自动地执行一步类型转换

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

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

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

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

类类型转换不是总有效

是否需要从string到 Sales_data的转换依赖于我们对用户使用该转换的看法。在此例中,这种转换可能是对的。null_book中的string可能表示了一个不存在的ISBN编号。


另一个是从istream到Sales_data的转换:

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

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

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

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

在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为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&);// remaining members as before
};

此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的两种用法都无法通过编译:

item.combine(null_book); // error: string constructor is explicit
item.combine(cin); // error: istream constructor is explicit

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

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

// error: explicit allowed only on a constructor declaration in a class header
explicit Sales_data::Sales_data(istream& is)
{read(is, *this);
}

explicit
英 [ɪkˈsplɪsɪt] 美 [ɪkˈsplɪsɪt]
adj. 清楚明白的;易于理解的;(说话)清晰的,明确的;直言的;坦率的;直截了当的;不隐晦的;不含糊的

explicit构造函数只能用于直接初始化,不能用于拷贝初始化

发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用explicit构造函数:

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

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


如果使用等号(=)初始化一个变量,实际上执行的是拷贝初始化(copy initialization),编译器把等号右侧的初始值拷贝到新创建的对象中去。

与之相反,如果不使用等号,则执行的是直接初始化(direct initialization)。

(来自第3章)

为转换显示地使用构造函数(可忽视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 (第4章内容)执行了显式的而非隐式的转换。其中,static_cast 使用istream构造函数创建了一个临时的Sales_data对象。

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

我们用过的一些标准库中的类含有单参数的构造函数:

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

(两个都是第3章内容)

聚合类

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

  • 所有成员都是public的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有virtual函数,关于这部分知将在第15章详细介绍。

例如,下面的类是一个聚合类:

struct Data {int ival;string s;
};

我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:

//val1.ival = 0; val1.s = string ( "Anna")
Data val1 = {0, "Anna"};

初始值的顺序必须与声明的顺序一致,也就是说,第一个成员的初始值要放在第一个,然后是第二个,以此类推。

下面的例子是错误的

//错误:不能使用"Anna"初始化ival,也不能使用1024初始化s
Data val2 = { "Anna", 1024};

与初始化数组元素的规则(第3章内容)一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化(第3章内容)。初始值列表的元素个数绝对不能超过类的成员数量。

值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点

  • 要求类的所有成员都是public的。
  • 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
  • 添加或删除一个成员之后,所有的初始化语句都需要更新。

字面值常量类

在第6章中提到过constexpr函数的参数返回值必须是字面值类型。

除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。

数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

  • 数据成员都必须是字面值类型。
  • 类必须至少含有一个constexpr构造函数。
  • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式(第2章内容);或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

constexpr构造函数

尽管构造函数不能是const 的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。

不同于其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。(本章构造函数内容)

constexpr函数(constexpr function)是指能用于常量表达式的函数。(第6章内容)

constexpr构造函数可以声明成=default的形式(或者是删除函数的形式,第13章内容)。另外,constexpr构造函数必须既符合构造函数的要求(意味着不能包含返回语句),又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句,第6章内容)。Otherwise, a constexpr constructor must meet the requirements of a constructor—meaning it can have no return statement—and of a constexpr function—meaning the only executable statement it can have is a return statement.

综合这两点可知,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; // hardware errors other than IO errorsbool io; // IO errorsbool other; // other errors
};

constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数,或者是一条常量表达式。

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

constexpr Debug io_sub(false, true, false); // debugging IO
if (io_sub.any()) // equivalent to if(true)cerr << "print appropriate error messages" << endl;constexpr Debug prod(false); // no debugging during production
if (prod.any()) // equivalent to if(false)cerr << "print an error message" << endl;

constexpr函数被隐式地指定为内联函数,内联函数可避免函数调用的开销。(第6章内容)

类的静态成员

有时,类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。例如,一个银行账户类可能需要一个数据成员来表示当前的基准利率。在此例中,我们希望利率与类关联,而非与类的每个对象关联。从实现效率的角度来看,没必要每个对象都存储利率信息。而且更加重要的是,一旦利率浮动,我们希望所有的对象都能使用新值。

(MyNote:个人来理解:类的静态成员指同一类的所有对象共享的成员。)

声明静态成员

我们通过在成员的声明之前加上关键字static使得其与类关联在一起。和其他成员一样,静态成员可以是public的或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。

举个例子,我们定义一个类,用它表示银行的账户记录:

class Account {public:void calculate() { amount += amount * interestRate; }static double rate(){ return interestRate; }static void rate(double);
private:std::string owner;double amount;static double interestRate;static double initRate();
};

类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此,

  • 每个Account对象将包含两个数据成员:owner和 amount。

  • 只存在一个interestRate对象而且它被所有Account对象共享。

类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果,静态成员函数不能声明成const的,而且我们也不能在static函数体内使用this指针。这一限制既适用于this的显式使用,也对调用非静态成员的隐式使用有效。

使用类的静态成员

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

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

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

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

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

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

定义静态成员

和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句:

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

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

因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。

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

我们定义静态数据成员的方式和在类的外部定义成员函数差不多。我们需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字:

//定义并初始化一个静态成员
double Account::interestRate = initRate();

这条语句定义了名为interestRate的对象,该对象是类Account的静态成员,其类型是double。从类名开始,这条定义语句的剩余部分就都位于类的作用域之内了。因此,我们可以直接使用initRate函数。

注意,虽然initRate是私有的,我们也能用它初始化interestRate。和其他成员的定义一样,interestRate的定义也可以访问类的私有成员。

Tip:要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。

静态成员的类内初始化

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

初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。例如,我们可以用一个初始化了的静态数据成员指定数组成员的维度:

class Account {public:static double rate() { return interestRate; }static void rate(double);
private:static constexpr int period = 30;// period is a constant expressiondouble daily_tbl[period];
};

如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的const或constexpr static不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。

例如,如果period的唯一用途就是定义daily_tbl的维度,则不需要在Account外面专门定义period。此时,如果我们忽略了这条定义,那么对程序非常微小的改动也可能造成编译错误,因为程序找不到该成员的定义语句。举个例子,当需要把Account::period传递给一个接受const int&的函数时,必须定义period。

如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了:

//一个不带初始值的静态成员的定义
constexpr int Account::period; //初始值在类的定义内提供

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

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

如我们所见,静态成员独立于任何对象。因此,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。举个例子,静态数据成员可以是不完全类型(指先声明,暂时不定义,本章“类类型内容”)。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用

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

静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参:

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

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

一些术语

类(class)C++提供的自定义数据类型的机制。类可以包含数据、函数和类型成员。一个类定义一种新的类型和一个新的作用域。

《C++ Primer 5th》笔记(7 / 19):类相关推荐

  1. C++ Primer 5th笔记(chap 19 特殊工具与技术)异常类层次

    1. 类 exception . bad_cast 和 bad_alloc 定 义 了 默 认 构 造 函 数 runtime_error 和 logic_error没有默认构造函数, 但是有一个可以 ...

  2. C++ Primer 5th笔记(chap 19 特殊工具与技术)type_info 类

    1. type_info 的操作 操作 描述 t1 == t2 如果type_info对象t1和t2表示同一种类型,则返回true t1 != t2 如果type_info对象t1和t2表示不同的类型 ...

  3. C++ Primer 5th笔记(chap 19 特殊工具与技术)嵌套类

    1. 嵌套类( nested class)或嵌套类型( nested type ) 一个类可以定义在另一个类的内部,这个类常用于定义作为实现部分的类. class TextQuery {public: ...

  4. C++ Primer 5th笔记(chap 19 特殊工具与技术)类成员指针

    1. 成员指针( pointer to member) 指可以指向类的非静态成员的指针. 成员指针指示的是类的成员, 而非类的对象. 类的静态成员不属于任何对象, 因此无须特殊的指向静态成员的指针 指 ...

  5. C++ Primer 5th笔记(chap 18 大型程序工具)多重继承下的类作用域

    1. 派生类的作用域嵌套在直接基类和间接基类的作用域中. 查找过程沿着继承体系自底向上进行, 直到找到所需的名字.派生类的名字将隐藏基类的同名成员. 在多重继承的情况下, 相同的查找过程在所有直接基类 ...

  6. C++ Primer 5th笔记(chap 18 大型程序工具)类型转换与多个基类

    1. 在只有一个基类的情况下, 派生类的指针或引用能自动转换成一个可访问基类的指针或引用. 我们可以令某个可访问基类的指针或引用直接指向一个派生类对象. eg. 一个ZooAnimal. Bear 或 ...

  7. C++ Primer 5th笔记(chap 17 标准库特殊设施)bernoulli_distribution 类

    1. 定义 一个普通类, 而非模板. 此分布总是返回一个 bool 值. 它返回 true 的概率是一个常数, 此概率的默认值是 0.5 string resp; default_random_eng ...

  8. C++ Primer 5th笔记(chap 17 标准库特殊设施)正则表达式类和输入序列类型

    1. 多种类型的输入 可以搜索多种类型的输入序列. RE 库为这些不同的输入序列类型都定义了对应的类型,eg. 普通 char 数据.wchar_t 数据 字符可以保存在标准库string或是 cha ...

  9. C++ Primer 5th笔记(chap 18 大型程序工具) 类、 命名空间与作用域

    1. 命名空间内部名字的查找规则 由内向外依次查找每个外层作用域. 外层作用域也可能是一个或多个嵌套的命名空间, 直到最外层的全局命名空间查找过程终止. 只有位于开放的块中且在使用点之前声明的名字才被 ...

  10. C++ Primer 5th笔记(chap 19 特殊工具与技术)运行时类型识別RTTI

    1. 运行时类型识别(run-time type identification ) 当我们将这两个运算符用于某种类型的指针或引用, 并且该类型含有虚函数时, 运算符将 使用指针或引用所绑定对象的动态类 ...

最新文章

  1. LaTex中的documentclass{type}应该填什么
  2. 基于mvc三层架构和ajax技术实现最简单的文件上传
  3. python下载不了-python安装不了
  4. spring源码分析之定时任务Scheduled注解
  5. 自定义 Spring Boot Starter
  6. Nginx的动态代理,负载均衡,动静分离的简单使用
  7. javaSE各阶段练习题--数组
  8. springboot 系列教程十:springboot单元测试
  9. java语言中的数据是如何定义_java中的数据类型
  10. python 学习中遇到的问题(持续更新中)
  11. vue 数组转集合_思想实验:如何在Vue中使localStorage具有响应式?
  12. focal loss dice loss源码_0815——W2V的TF源码阅读
  13. RSync实现文件备份同步,rsync服务器
  14. 工作中常用Linux命令总结一
  15. mac原版镜像下载 macos官方镜像下载
  16. MES主要功能模块介绍
  17. BZOJ 2037 [Sdoi2008] Sue的小球
  18. 计算机毕业优秀作品展观后感,毕业设计作品展观后感
  19. springboot-高校医院体检系统
  20. Django之 migration 原理

热门文章

  1. socket 编程入门教程(一)TCP server 端:6、创建“通讯 ”嵌套字
  2. ARM汇编ADR,LDR等伪指令
  3. WinCE Boot方式及 Bootloader架构概述
  4. python能不能爬数据库_python爬取数据后不能写入到数据库中
  5. 【转】DICOM的常用Tag分类和说明
  6. NET 提供了执行异步操作的三种模式
  7. 让TFS忽略packages文件夹的更改
  8. 一步步编写操作系统80 扩展内联汇编1
  9. edusoho linux 500错误,EduSoho网校系统如何处理500 Internal Server Error? - EduSoho官网
  10. Pandas数据可视化工具:图表工具-Seaborn