c++ primer 第五版 翻译 第七章

第七章 类

在u盘中找回部分翻译,接着上翻译

内容:
7.1 定义抽象数据类型
7.2 访问控制和封装
7.3 类的其他特性
7.4 类作用域
7.5 构造器再探
7.6 static类成员
本章小结
专业术语

在c++中,使用类来自定义数据类型。通过定义新类型来反映待解决问题中的各种概念,并且也使得我们的程序更加容易编写,调试和修改。

本章继续第二章中的类的介绍。本章重点关注数据抽象的重要性,因为数据抽象可以让一个对象的实现与一个对象的操作相分离。在第十三章中将会介绍,对象的复制,移动,赋值和销毁。在第十四章将会介绍怎么定义自己的运算符

类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的技术。一个类的接口由这个类的使用者能够执行的操作组成。一个类的实现则包括类的数据成员,构成接口的函数的函数体,以及定义类需要的各种函数。

封装实现了类的接口和实现分离。一个类被封装,它就隐藏了他的实现。即这个类的使用者只能使用接口,而不能访问实现。

类要实现数据抽象和封装,就要定义一个抽象数据类型。在抽象数据类型中,类的设计者负责类的实现。使用这个类的程序员不需要知道这个类是怎么工作的,他们只需要抽象的思考这个类型是做什么的即可。

7.1 定义抽象数据类型

在第一章使用的Sales_item类就是一个抽象数据类型。通过使用他的接口来使用Sales_item类。我们不能存取在Sales_item对象中的数据成员。事实上,我们不知道这个类有哪些数据成员。

我们的Sales_data类(2.6.1小节)不是一个抽象的数据类型。因为类的使用者可以存取它的数据成员,并且强制使用者编写自己的操作.为了使Sales_data成为一个抽象的数据类型,我们需要定义Sales_data用户能使用的操作。一旦Sales_data定义了它自己的操作,那么就可以封装他的数据成员了。

7.1.1 设计Sales_data类

最终,我们想Sales_data类跟Sales_item类有相同的操作。Sales_item类有一个成员函数叫做,isbn,并且支持+,=,+=,<<,和>>运算符。

对于怎样定义自己的运算符,将在第十四章介绍。现在,我们只定义跟这些操作相同的普通函数。由于在14.1小节将会介绍的原因,执行加法和IO操作的函数不作为Sales_data的成员函数。而,将这些函数定义为普通的函数。复合赋值操作的函数将作为成员函数。并且这个类不需要定义赋值函数,这样做的原因将会在7.1.5小节中介绍。
因此,Sales_data的接口由下面的操作组成:

  1. 一个isbn函数,返回这个对象的ISBN号
  2. 一个combine函数,将一个Sales_data对象加到另外一个对象上
  3. 一个add函数,将两个Sales_data对象相加
  4. 一个read函数,从istream中读取数据到Sales_data对象中
  5. 一个print函数,打印Sales_data对象中的数据到ostrem中

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

程序员将运行他们程序的人称为用户。相似的,类的设计者设计并且实现了一个类,就是为了类的使用者。此时,用户是程序员,而不是应用的最终用户。

当我们提及用户一词时,不同的语境决定了其不同的意思。如果我们说用户代码或者Sales_data类的用户,指的是程序员。如果我们说书店应用的用户,指的是运行这个书店程序的管理者。

注意:c++程序员无须刻意区分应用程序的用户以及类的用户。

在一个简单的应用中,一个类的用户和这个类的设计者,可能是同一个人。尽管如此,还是应该将角色区分开来。当设计类的接口时,应该思考怎么使用这个类更容易。当使用类时,就不应该思考这个类是怎么工作的。

成功的应用程序的作者必须理解并且实现用户的需求。同样,一个好的类设计者也应该关心使用这个类的程序员的需求。一个设计良好的类必须要有直观且便于使用的接口,还要有高效的实现。

使用改进的Sales_data类

在思考怎么实现我们的类之前,让我们先看看怎么使用这些接口函数。例如,可以使用这些接口函数,写一个类似于1.6小节的书店程序,他们使用Sales_data,而不是使用Sales_item.

Sales_data total;		//保存和的变量
if(read (cin,total)){		//读取第一条交易记录
	Sales_data trans;	//保存下一条交易记录的变量
while(read(cin,trans)){
	if(total.isbn() == trans.isbn())
		total.combin(trans);
	else{
		print(cout,total) << end;
		total = trans;
	}
}
print(cout,total) << endl;
}else{
	cerr << “No data?!” <<endl;
}

最开始定义了一个Sales_data变量用于存放运行的总和。在if的条件表达式里面,调用read读取第一个交易记录到total里面。这个条件表达式的工作,跟其他的循环里面使用>>运算符一样,read函数也会返回他的stream形参,他被用着条件判断(4.11.2小节)。如果read失败,将跳转到else分支,然后打印错误信息。

如果有数据被读到,我们将定义一个trans变量,这个变量保存下一条交易记录。在while中的条件表达式也会检查read返回的stream形参。只要在read里面的输入运算符读取成功,这个条件表达式就为true,就表示有一条交易记录需要处理。

在while的循环体内部,调用total的isbn函数和trans的isbn函数,分别获取对应的ISBN号,如果total和trans都是相同的书籍,就调用combine将trans加到total里面。如果trans代表了新书,就打印前一种书的total。因为print返回的是他的stream形参的引用,所以可以使用print的结果作为<<运算符的左操作数。使用这种方法,输出print函数的结果,然后转到下一行。接下来将trans赋值给total,从而处理文件中下一本书的交易记录。

处理完所有的数据之后,一定要记住打印一下最后的交易记录。所以在while的循环体之后再次调用了print函数。

7.1.2 定义改进的Sales_data 类

改进类跟2.6.1小节定义的类有相同的数据成员:bookNo,字符串代表ISBN;units_sold,unsigned 类型,代表有多少书被卖出去了;revenue,double类型代表注本书的总销售额。

前面可以看见,我们的类有两个成员函数,一个combine,一个isbn。另外再加一个成员函数,返回售出书籍的平均价格。这个函数的名字叫做avg_price,因为这个函数不是通用的,所以这个函数是实现的一部分,不是接口的一部分。

定义和声明成员函数跟普通函数一样。成员函数必须被声明在类内部。成员函数可以定义在类的内部,也可以定义在类的外部。接口中的非成员函数,如add,read,print需要声明在类的外部。

根据这些信息,写如下的Sales_data类的实现:

struct 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 add(const Sales_data&,const Sales_data&);
std::ostream &print(std::ostream&,const Sales_data&);
std::istream &read(std::istream&,Sales_data&);

注意:定义在类内的函数隐式的为inline函数

定义成员函数

尽管成员函数必须声明在类内部,但是成员函数的实现既可以在类内部,也可以在类外部。在Sales_data中,isbn定义在了类的内部;combine和avg_print定义在其他地方。

首先解释一下isbn函数,他返回一个字符串,并且形参为空
std::string isbn() const {return bookNo;}
跟其他的函数一样,成员函数的函数体是一个块。此处,这个块只有一个return语句,这个return语句返回Sales_data对象的bookNo成员数据。比较有意思的是:这个函数如何获取到当前对象,然后依此来取得bookNo的呢?

引入this

再来看一下isbn的调用:
total.isbn();

此处,使用了点号运算符来获得total对象的isbn函数,然后再调用这个isbn函数。

除了在7.6小节介绍的例外以外,当我们调用成员函数,实际上是替某个对象调用。当isbn指向Sales_data的成员时,它也隐式的指向了被调函数的对象的成员。在这个调用中,当调用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,如下的定义,尽管是合法的,但是没必要:

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

因为this总是指向当前对象,this是一个const指针。我们没法改变this保存的指针。

引入const成员函数

isbn函数另外一个比较重要的部分是跟在形参列表后面的cont关键字。这个const隐式地修改this指针的类型。

默认情况下,this的类型是一个const指针,指向一个非const的对象。例如,默认情况下,this的类型为Sales_data *const.尽管this是隐式的,但是他也需要支持常见的初始化操作,这就意味着我们不能将this绑定到一个const对象上(2.4.2小节)。这就意味着,没法在一个const对象上,调用一个普通的成员函数。

如果isbn是一个普通的函数,并且this也是一个普通的指针形参,那么可以声明this为const Sales_data *const类型。毕竟,isbn不会改变this所指对象的,所以当this指向一个const对象时,我们的函数更加灵活(6.2.3小节)。

但是,this是隐式的形参,不能出现在形参列表中,那么就没有地方让我们声明this为指向const的对象。c++为了解决这个问题,可以在形参列表后面跟一个const来解决。一个跟在形参列表后面的const表明,this是一个指向const对象的指针。这种函数称为:const成员函数。

可以将isbn的函数当作如下的形式:

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

这就意味着,const成员函数无法改变调用对象。所以isbn只能读不能写调用对象的成员数据。

注意:const对象,指向const对象的指针,以及绑定到const对象的引用,只能调用const成员函数。

类作用域和成员函数

回忆2.6.1小节所讲:一个类就是一个作用域。一个类的成员函数的定义被嵌入了这个类的作用域内。因此,在isbn里面的使用bookNo是定义在Sales_data里面的成员数据。

值得注意的是:就算bookNo定义在isbn之后,isbn可以也使用bookNo。在7.4.1小节将会介绍,编译器处理类分为两步:首先编译成员的声明,然后才是编译函数体(如果有的话)。因此,成员函数,可以使用成员数据,而不用管成员函数出现在什么地方。

定义在类外部的成员函数

跟其他的函数一样,当定义类外部的函数时,必须和函数的声明一样。即,返回类型,形参列表以及函数名,都必须和类内部的声明一样。如果成员函数被声明为const成员函数,那么这个定义也必须在形参列表后面写上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使用revenue和units_sold时,他也隐式的表示使用了Sales_data的成员。

定义返回this对象的函数

combine想要跟复合赋值运算符一样的行为。调用这个函数的对象代表了赋值运算符的左操作数。右操作数通过显示的传参:

Sales_data & Sales_data::combine(const Sales_data &rhs){
	units_sold += rhs.units_sold;
	revenue += rhs.revenue;
	return *this;
}

当交易处理程序调用如下函数时:

total.combine(trans);	

total的地址被隐式的绑定到了this形参上。并且rhs也被绑定到了trans上。所以当combin执行时:

units_sold += rhs.units_sold;

实际上是total.units_sold和trans.units_sold相加,然后将结果存放在total_units_sold.

这个函数有趣的地方在于返回类型和return语句。通常,当我们想定义一个跟内置运算相同行为的函数时,应该模仿这个运算符的行为。内置的赋值运算符将他的左操作数当作左值返回。因为左操作数是Sales_data对象,所以返回类型也应该是Sales_data&.

正如上面所见,我们不需要使用this指针访问调用对象的成员,但是我们需要使用this指针访问整个对象。

return *this;
此处,解引用this之后,获得调用对象,然后返回。即上面的调用返回的是total对象的引用。

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

类的作者经常定义辅助函数,例如add,read,print等。尽管这些函数是类的接口的一部分,但是他们却不是类的部分。

定义非成员函数跟其他函数一样。通常,将函数的声明和实现分离。这些函数概念上跟类是一部分,但是不会定义在类内部,经典的声明是:定义在同一个头文件中。这样用户就只需要包含一个文件,然后就可以使用这个接口的任何部分了。

注意:通常,一个类接口的非成员函数应该声明在这个类相同的头文件中。

定义read和print函数

read,print函数跟2.6.1小节中对应代码的工作相同。并且函数体也类似:

istream &read(istream &is,Sales_data &item){
	double price = 0;
	is >> item.bookNo >> item.units_sold >> price;
	item.revenue = price * item.units_solds;
	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类型不能复制,所以只能通过引用来传参。并且,读和写都改变了这个流,所以这两个函数的引用形参都是普通形参,不是const形参。

第二点需要注意的是:print不会输出一个新行。通常,做输出的函数,应该尽量减少格式化输出。这样,用户就可以自行决定是否需要一个新行。

定义add函数

add函数带有两个Sales_data对象,并且返回一个新的代表和的Sales_data对象。

Sales_data add(const Sales_data &lhs,const Sales_data &rhs){
	Sales_data sum = lhs;
	sum.combine(rhs);
	return sum;
}

在这个函数体内,定义了一个新的叫做sum的Sales_data对象,这个对象用于保存两个交易的和。使用lhs来初始化sum。默认情况下,复制类类型的对象,将会复制这个对象的成员。复制之后,sum的bookNo,units_sold和revenue跟lhs的成员一样。接下来调用combine将rhs的units_sold和revenue加到sum上面。一切就绪之后,返回sum的副本。

7.1.4 构造器

每一个类都需要定义这个类类型的对象如何被初始化。类通过一个或者多个成员函数来控制对象的初始化,这样的函数成为构造器(构造函数)。构造器的工作就是初始化类对象的数据成员。只要一个类对象被创建,这个构造器就会被运行。

本小节将会介绍如何定义构造器。构造器是一个非常复杂的主题,因此在7.5小节,15.7小节,18.1.3小节以及第十三章,都还有更加详细的介绍。

构造起跟类名相同。跟其他函数不同的是,构造器没有返回类型。跟其他函数一样的是,构造器也有形参列表(可能为空)和函数体(可能为空)。一个类可以有多个构造器。跟重载函数一样,多个构造器之间必须不同,即形参个数,或者形参类型互不相同。

不像其他函数,构造器不可能声明为const(7.1.2小节)。当创建一个const对象的时候,直到构造器完成对象的初始化之后,这个对象才呈现出const属性。因此,构造器可以在const对象构造期间,向其写值。

合成的默认构造器

我们的Sales_data类没有定义任何的构造器。但是我们写的程序也正常地编译Sales_data对象并且正确运行。举个例子:在255页(原书)程序定义了两个对象:

Sales_data total;
Sales_data trans;

自然产生的问题是:total和trans是如何初始化的?

此处没有提供初始值,因此是默认初始化。通过在类内部定义默认的构造器来控制类的默认初始化。默认构造器就是没有形参的构造器。

默认构造器有几个特殊,其中之一就是:如果类没有显式的定义任何构造器,那么编译器将会定义一个默认的构造器。

编译器生成的构造器称为:合成默认构造器。对于大多数的类来说,这个合成构造器按下面的方式初始化类中的每一个成员:
如果有类内的初始值(2.6.1),就用他来初始化成员。
否则,就执行成员的默认初始化(2.2.1)。
因为,Sales_data提供了units_sold和revenue的初始值,所以默认构造器,就使用这个值来初始化这些成员。默认初始化bookNo为空字符串。

一些类不能依赖于合成的默认构造器

简单类可以使用合成的默认构造器,例如Sales_data类。但是大多数的类都应该定义自己的默认构造器,而不是让编译器生成默认构造器。原因有三:其一,如果有其他任何的构造器,这个类就不会产生默认构造器,除非我们自己定义默认构造器。这个规则的思想是,如果类需要控制某种情况下的初始化,那么就应该控制所有情况的初始化。

注意:只有当类没有任何构造器的时候,编译器才产生默认构造器

其二,对于某些类来说,合成的默认构造器会执行错误的操作。回忆之前所讲,在块内的内置类型或者复合类型(例如数组和指针)的对象被默认初始化时,其值是未定义的。这也适用于类内的内置类型成员。因此,当类有内置类型或者复合类型的成员时,应该在类内初始化这些成员,或者定义默认构造器。否则用户可能创建带有未知值的对象。

警告:
一个类,有内置类型或者复合类型成员时,只有这些成员有类内初始值时,才能依赖于合成默认构造器

第三个原因是:有时编译器不能合成默认构造器,此时需要类自己定义默认构造器。例如,类有一个类类型的成员,并且这个成员没有默认构造器, 因此编译器不能初始化这个成员。对于这种情况,必须自己定义默认构造器。否则,类就不会有一个可用的默认构造器。在13.1.6小节将会看到另外的一些情况,也会让编译器没法生成合适的默认构造器.

定义Sales_data的默认构造器

对于Sales_data类,我们将定义如下的参数,定义四个构造器:

  • 一个istream& ,从这个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;

首先,这个构造器为默认构造器,因为他没有参数。我么定义这个构造器是因为,既需要其他的构造器,也需要默认构造器。我们想让这个构造器跟合成的默认构造器的内容一样。

在c++11新标准下,如果我们想要默认的行为,可以在形参列表写上=default,让编译器生成构造器。=default可以出现在类内部的声明中,也可以出现在类外部的定义中。跟其他函数一样,如果=default出现在类内部的声明中,默认构造器就是内联的;如果出现在类外部的定义中,就不是内联的。

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

新增的部分是冒号和大括号之间新增的代码。这个新增的部分就是构造器初始列表,它为类的一个或者多个数据成员指定初始值。构造器初始列表是一些列的成员名,这些成员后面用小括号括起来他们的初始值,多个初始值之间使用逗号分隔。

有三个形参的构造器,使用它的前面两个形参分别初始化bookNo和units_sold成员。而revenue的初始值则通过将p和n相乘得到。

有单个striing形参的构造器,使用这个string初始化bookNo,但是它没有显示的初始化units_sold和revenue成员。如果一个成员在构造器初始化列表里面省略时,那么它的初始化就和合成的默认构造器的初始化一样。此时,被类内初始值初始化。因此,这个构造器等价于:

Sales_data(const std::string &s):bookNo(s),units_sold(0),revenue(0){}

对于构造器来说,使用类内初始值是最好的,因为只要这个值存在,那么就能保证成员函数有正确的初始值。另一方面,如果编译器还没有支持类内初始值,那么构造器就必须显示的初始化类内的成员。

经验之谈
构造器不应该复写类内初始值,除非需要使用一个不同的值来初始化。如果没有使用类内初始值,那么就应该显示的初始化类内的内置成员。

值得注意的是,每一个构造器都有一个空的函数体。因为构造器唯一要做的工作是,给这些成员正确的值。如果没有其他工作,函数体可以为空。

定义在类外的构造器

跟其他构造器不同,带有一个istream的构造有其他的工作需要做。在这个函数体内,这个构造器调用read,获取新值:

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

构造器没有返回类型,因此,以函数名开始。跟其他成员函数一样,当在类外定义构造器时,需要指定这个构造器是属于那个类。因此,Sales_data::Sales_data表明:我们定义了Sales_data的Sales_data成员。这个成员是一个构造器,因为他和类名相同。

这个构造器没有构造器初始化列表,尽管如此,从技术上来说,构造器初始化列表为空也是正确的。尽管初始化列表为空,但是这个对象的类还是在构造器函数体运行之前就被初始化好了。

没有出现在构造器初始化列表中的成员,使用类内初始值初始化,或者默认初始化。对于Sales_data来说,当构造器的函数体开始执行时,bookNo已经为一个空字符串,units_sold和revenue为0.

为了理解read的调用,切记,read的第二个形参是Sales_data对象的引用。在7.1.2小节中,我们讲到,使用this来访问整个对象。此处,使用的是*this,表明将本对象作为实参传递给read函数。

7.1.5 复制,赋值和析构

除了定义类对象如何被初始化,还可以定义复制,赋值和析构类对象的行为。在某些情况下类对象执行复制操作,例如,通过值初始化一个对象,或者通过值传递/返回一个对象。当使用赋值运算符(4.4小节)的时候,对象执行赋值操作。当对象不存在时,对象执行销毁操作,例如,当退出这个对象创建的块时,会执行销毁操作。当vector销毁时,存储在vector中的对象也会执行销毁操作。

如果没有定义这些操作,编译器将自动合成。通常,编译器合成的这些操作,将会复制,赋值,销毁对象中的每个成员。例如,在我们的书店程序中,当编译器执行下面的赋值时:

total = trans;

就等同于下面的执行:

total.bookNo = trans.bookNo;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;

在第十三章将会介绍,如何定义这些行为。

某些类不能依赖于合成的函数

尽管编译器会合成复制,赋值,析构操作,但是对于某些类来说,合成的这些操作无法正确执行。尤其是,当对象需要分配对象之外的资源时,这些合成版本根本不可能正常工作。在第十二章将会看到一个例子,c++如何动态的分配和管理内存。在13.1.4小节也会看到,管理动态内存的类,不会依赖于合成的版本。

但是,对于大多数类来说,如果需要动态内存,基本上可以使用vector和string。使用vector和string的类避免了复杂的内存分配和释放的操作。

因此,对于有vector和string成员的类来说,他们的复制,赋值,析构的合成版本能够正确工作。当复制或者赋值一个含有vector成员的类时,将会对这个vector里面的成员进行复制或者赋值。当这个对象被销毁时,vector成员也被销毁,vector里面的元素也被销毁。string跟这个类似。

警告:除非你已经知道了第十三章要介绍的知识,否则对于类需要分配的资源,应该直接作为类的数据成员来存储。

7.2 访问控制和封装

到现在,我们已经定义了一个接口.但是并没有任何机制强制用户使用这些接口.我们的类还没有封装,用户可以直接访问Sales_data对象中的成员,并且影响他的实现。在c++中可以使用访问指示符强制进行封装:
定义在public指示符后面的成员,可以被程序的任何部分访问。public成员用来定义类的接口
定义在private指示符后面的成员,只能被类的成员函数访问,不能被类的用户访问。private段用于封装具体的实现。

重新定义Sales_data,如下:

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 ;
}

跟在public指示符后面的构造器和成员函数(isbn,combine)是接口的一部分。跟在private指示符后面的数据成员和其他的函数是具体的实现。

一个类可能有零个或者多个访问指示符。c++没有限制每个访问指示符出现多少次。每一个访问指示符指定了跟着其后的成员的访问级别。访问级别的范围为:直到遇到下一个访问指示符,或者类的结尾。

使用class或者struc关键字

我们也可以做更加微妙的改变:使用class关键字,而不是struc来定义类。这种改变仅仅是形式上面的改变。可以使用任何一个关键字来定义类类型,而唯一的不同就是默认的访问级别不一样。

类可以在第一个访问指示符之前定义成员。而这些成员的访问级别,依赖于类是怎么定义的。如果使用struct关键字定义,这些成员的访问级别为public。如果使用的是class关键定义的,这些成员的访问级别为private。

作为一种编程风格:当所有的成员都是public时,使用struct定义类;当有private成员时,使用class关键字。

7.2.1 友元

既然Sales_data的数据成员是private的,那么我们的read,print和add函数就不能编译。那么出现这个问题的原始是,尽管Sales_data是接口的一部分,但是不是这个类的成员。

通过将A类或者A函数,标记为友元,就可以让这个A类或者这个A函数访问另外一个B类的非public成员。要想使A函数成为B类的友元,则B类包含A函数的声明,并且这个声明跟在关键字friend 之后。

class Sales_data{
//友元声明
friend Sales_data add(const Sales_data&,const Sales_data&);
friend std::istream& read(std::istream&,Sales_data &);
friend std::ostream & print(std::ostream &,const Sales_data&);

public:
	Sales_data() = default;
	Sales_data(const std::string &s,unsigned n,double p):
	bookNo(s),units_sold(n),revenue(p*n){}
	Sales_data(std::istream&);
	std::string isbn() cons {return bookNo;}
	Sales_data &combine(const Sales_data&);
private:
	std::striing bookNo;
	unigned units_sold = 0;
	double revenue = 0.0;
};

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.3.4小节有更多关于友元的问题。

提示:在类定义的开头或者结尾,将所有的友元写在一起是非常好的注意

关键概念:封装的好处

封装提供两个非常重要的优势:
用户代码不会无意破坏被封装对象的状态;
封装好了的代码实现可以任意改变而不用改变用户代码
通过将数据成员定义为private,这个类的作者就可以自由的改变这些数据。此时,仅仅只有这个类的代码需要做一下测试,看看是否会有影响。而用户代码,只要接口不变,就不会有任何影响。如果数据是public,这些使用旧版本的代码,就可能会被破坏。因此必须找到,并且重写基于旧版本的代码。

把数据成员做成private的另外一个好处是,可以防止用户对数据的破坏。如果有一个bug,破坏了对象的状态,可以在局部地区进行debug:因为只有实现部分才可能产生错误。因此,debug就在有限范围内,极大减轻了程序问题的维护和修正。

注意:尽管当类的定义改变时,用户代码不需要改变,但是使用这个类的所有用户代码都需要重新编译。

友元的声明

友元的声明仅仅指定了访问的权限。他不是函数的常用声明。如果我们想要某个类的用户代码能够调用这个类的友元函数,我们也必须另外再次声明这个函数。

为了使类的用户对于友元函数可见,通常将类本身和友元函数的声明放在同一个头文件中(类之外)。因此,我们的Sales_data应该为read,print,add提供分开的声明。

注意:许多编译器并不会强制规定,在使用友元函数之前,必须在类的外部声明

一些编译器也允许,在没有函数声明的前提下,调用友元函数。即使你的编译器支持这种调用,但是也最好为友元函数提供单独的声明。这样,当你切换编译器的时候,就不用更改代码了。

7.3 类的其他特性

尽管Sales_data类非常简单,但是,他也让我们探索了许多c++支持的特性。本段,将介绍Sales_data没有体现的其他的c++特性。这些特性包括:类型成员,类类型成员的类内初始值,可变数据成员,内联成员函数,返回*this的成员函数,以及更多关于如何定义和使用类类型和友元类。

7.3.1 类成员再谈

为了探索这些特性,我们将定义一对类,这两个类的名字为Screen和Window_mgr.

定义类型成员

Screen代表显示上面的一个窗口。每个Screen都有一个string成员,这个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;
}

第二,不想普通的成员,类型别名的声明必须出现在使用之前,具体原因将在7.4.1小节中介绍。因此,类型成员出现在类的开始。

Screen类的成员函数

为了使类更加有用,增加一个可以让用户定义大小和内容的构造器,还有移动光标的成员和获取给定位置字符的成员:

class Screen{
publictypedef std::string::size_type pos;
	Screen() = default;	//必须,因为还有其他的构造器
	Screen(pos ht,pos wd,char c):height(ht),width(wd),contents(ht*wd,c){}
	char get() const {return contents[cursor];}
	inline char get(pos ht,pos wd) const;
	Screen &move(pos r,pos c);
private:
	pos cursor = 0;
	pos height = 0,width = 0;
	std::string contents;
};

因为我们提供了一个构造器,所以编译器不会自动合成默认构造器。如果我们需要默认构造器的话,就必须显示的声明他。此处,使用了=default让编译器合成默认构造的定义。

值得注意的使:第二个构造器使用了三个参数,对于cursor成员来说,隐式的使用了类内初始值。如果cursor没有类内初始值,那么我们应该显示的初始cursor。

使成员成为内联函数

类内的小函数可以做成内联的。正如所见,定义在类内部的成员函数就是自动变成内联的(6.5.2)。因此,Screen的构造和get()函数是内联的。

我们还可以在类内,可以把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{
	pos  row = r*width;
	return contents[row + c];
}

尽管没必要在声明和定义两端都指定为inline,但是在两端都指定inline是合法的。因此,在类的定义处指定inline更有助于阅读。

注意:类的内联函数应该定义在类的声明的同一个头文件中,这根内联函数应该定义在头文件中的原因一样。

重载成员函数

跟非成员函数一样,成员函数也可以重载。只要函数的形参个数,或者类型不同即为重载。而对于成员函数的调用,进行的函数匹配处理,与非成员函数的匹配处理一样。

例如,Screen类定义了两个get。一个get返回当前光标的字符;另外一个get放回给定行列所表示的字符。编译器通过参数个数,来决定调用哪一个函数:

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

可变数据成员

有时(不经常)我们想修改const成员函数里面的类成员。通过在成员的声明中包含mutable关键字来表明这个成员可以被修改。

一个可变数据的成员就不再是const,就算他是const对象的成员,他还是可以改变。因此一个const成员函数可以改变可变成员的值。举个例子,给Screen 一个可变的成员,叫做access_ctr,使用这个变量来跟踪每个成员函数被调用了多少次:

class Screen{
public:
	void some_member() const;
private:
	mutable size_t access_ctr;
};

void Screen::some_member() const{
	++access_ctr
}

尽管some_member是const函数,但是,他还是可以修改access_ctr的值。这个成员是可变成员,因此任何成员函数,包括const成员函数,都可以改变他的值。

类类型数据成员初始值

除了定义Screen类以外,还要定义一个窗口管理类他代表了当前显示屏上面的Screen集合。这个管理类将会有一个Vector,它的每一个元素都是一个特定的Screen.我们想Window_mgr类一开始就有一个,默认的Screen。在c++11新标准下,最好的方式就是类内初始值(2.6.1小节):

class Window_mgr{
	private:
		std::vector<Screen> screens(Screen(24,80,’ ’));
};

当我们初始化类类型成员时,需要给一个符合构造函数的实参。此处,用了单个来列表初始化vector成员。这个初始值包含一个Screen对象,这个对象被传递到vector的构造器,然后创建含有一个Screen元素的vector。这个Screen对象有构造器创建,这个构造器需要传递两个表示大小的形参和一个字符。

正如所见,内类初始值必须使用=号初始化,或者大括号初始化。

注意:当提供类内初始值时,必须跟在等号,或者大括号内部。

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;
}

inline Screen & Screen:sett(pos r,pos col,char ch){
	contens[r*width + col] =ch;
	return *this;
}

跟move函数一样,这个函数返回调用对象的引用。返回引用的函数是左值,这就意味着返回的是对象本身,而不是对象的副本。

如果将这些操作连接成一个表达式:

myScreen.move(4,0).set(‘#’);

这些操作将在同一个对象上面执行。这个表达式中,首先myScreen内部的移动光标,然后将myScreen的内容设置为给定的字符。等价于下面的表达式:

myScreen.move(4,0);
myScreen.set(‘#’);

如果move和set的返回值为Screen而不是Screen&.那么上面链接在一起的表达式完全不同,此时,等价于:

Screen temp = myScreen.move(4,0);//返回值将被复制
temp.set(‘#’);	//在myScreen的内容依然没有改变

如果move的返回值为非引用,那么move的返回值就是*this的副本。调用set改变的是临时的副本,而不是myScreen。

从const成员函数返回*this

下面将新增一个操作,这个操作用来打印Screen里面的内容,它叫做display,我们还想这个操作能够跟在set和move的后面,所以这个display返回调用对象的引用。

逻辑上讲,显示screen里面的内容不会改变对象,所以应该定义display为const成员。如果display是cont成员,那么*this就是一个const对象。所以,display的返回类型必须是const Sales_data&.而一旦display是返回的const引用,那么就不能嵌入一系列的操作中:

Screen myScreen;
myScreen.display(cout).set(*);

尽管myScreen不是一个const对象,但是调用set是错误的。因为display返回的是一个const引用,在这个const引用上面不能调用set函数。

注意:一个返回*this的const成员函数,如果他是引用返回类型那么必定是const引用。

基于const的函数重载

基于是否为const可以对一个成员函数进行重载,就跟基于指针是否指向const对象的重载原因一样。对于const对象,他的非const成员是不可见的,仅能调用const的成员函数。而对于非const对象,他既可以调用const成员,也可以调用非const成员,但是非const成员才是最佳匹配。

此处将举个例子,这个例子将定义一个叫做do_display的函数,这个函数才是实际打印Screen里面的内容。每一个display都将调用这个函数,然后返回调用对象的引用:

class Screen{
public:
	Screen & display(std::ostream &os){
		do_display(os); return *this;
}
const Screen & display(std::ostream &os)const {
	do_display(os); return *this;
}
private:
	void do_display(std::ostream &os) const {os <<contents;}
};

在任何情况下,某个类的成员函数调用另外一个成员函数,this指针是隐式的被传递。因此当display调用do_display时,它自己的this指针被隐式的传递给了do_display函数。当display的非const版本调用do_display时,它的this指针由非const转换成了const。

当do_display执行完成,display函数通过解引用,返回调用对象的引用。在非const版本中,this指向的是一个非const的对象,因此这个display返回普通的引用;而const成员函数则返回const引用。

当在一个对象上调用display时,跟着这个对象是否为const来决定按一个版本被调用。

Screen myScreen(5,3);
const Screen blank(5,3);
myScreen.set(‘#’).display(cout);//调用非const版本
blank.display(cout); //调用const版本

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

有些读者可能非常惊讶,这里居然分出了do_display函数。毕竟,调用do_display比将do_display写在函数内部更复杂。我们如此做是基于下面几个原因:

  1. 一个基本思想是避免在多个地方编写相同的代码
  2. 当display操作变得更加复杂时,将代码写在一处比写在两处就更加明显了
  3. 很可能在开发阶段想要增加调试信息,而在发布阶段,需要去掉调试信息。如果do_display只有一处定义,那么增加和去掉这些调试信息就更加容易。
  4. 调用这个额外的函数并不会产生额外的负担。因为将do_display定义在了类内部,他是隐式的内联函数。
    事实上,一个良好设计得c++代码,有很多形如do_display的小函数,这些函数才是真正做工作的函数。

7.3.3 类类型

每一个类定义了唯一的一种类型。两个不同的类,就定义了两种不同的类型,即使他们有相同的成员,例如:

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

First obj1;
Second obj2 = obj1; //错误obj1和obj2的类型不同

注意:即使两个类有完全一样的成员,他们也是不同的类型。两个类的成员是互不相同的。

将类名当做类型名,我们可以直接使用类类型,另外还可以在class或者struct关键字后面使用类名,当做类型名。

Sales_data item1;
class Sales_data item1;

上面两个是等价的。第二方式源自于c,并且在c++中也是有效的。

类定义

函数的声明和定义可以分开,同样的类的定义和声明也可以分开:

class Screen;	//Screen类的声明

这种声明有时被称为前向声明,引入Screen的名字到程序中,然后表明Screen是类类型。声明之后定义之前,这个类是可见的,但是类类型Screen不是完整的类型,所以不知道这个类不含那些成员。

只有在有限的条件下才能使用非完整类型:定义指向这个类型的指针或者引用;或者声明(不是定义)带有这个类型或者返回这个类型的函数。

当编写创建某个类对象的代码之前,这个类必须被定义,但不一定被声明。否则,编译器不知道这个对象到底需要多少的存储。同样的,通过这个类的引用或者指针,访问成员之前,也必须定义这个类。如果累不定义,编译器则不知道这个类有哪些成员。

只有当这个类类型被定义之后,才能成为数据成员。唯一的一个例外在7.6小节讲解。类类型必须定义完成,因为编译器需要知道这个类的数据成员到底需要多少存储。在类的定义体没有完成之前,这个类是没有定义的,所以这个类内部不能有自身类型的数据成员。但是,只要类名可见,就可以作为声明。因此,类内部可以有指向自身类型的成员指针或者引用。

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

7.3.4 友元在探

Sales_data定义了三个常用的非成员函数作为友元(7.2.1小节)。一个类也可以将另外一个类声明为友元,也可以将另外一个类的成员函数声明为友元。此外,友元函数也可以被定义在类内部,这种函数也是inline函数。

在类之间的友元

来个例子,在Window_mgt类内部有成员需要访问Screen对象的内部数据。例如,嘉定Window_mgr有个clear函数,它将一个Screen的类容清空。为了做此工作,clear需要访问Screen的私有数据数据和成员。为了允许这种访问,Screen可以将Window_mgr作为他的友元。

class Screen{
	//Windwo_mgr的成员可以访问Screen的私有数据
	friend class Window_mgr;
};

一个友元类的成员函数可以访问授予友元类的所有的成员,包括非公有成员。现在Window_mgr是Screen的友元了,可以写clear成员函数了,如下:

class Window_mgr{
	public:
		using ScreenIndex = std::vector<Screen>::size_type;
		void clear(ScreenIndex);
	private:
		std::vector<Screen> screens{Screen(24,80,’ ’)};
};

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

定义i位置的Screen引用s。然后使用了这个Screen对象的widht和height成员来计算一个新的string,这个string有正确个数的空格字符。让后将这个空格字符串赋值给contents成员。

如果clear函数不是Screen函数的友元,,那么这个代码是编译不过的。clear函数不能访问Screen对象的width,height,和contents成员。因为Screen授予了Window_mgr友元的关系,因此,Screen的所有成员对于window_mgr的成员函数来说,都是可以访问的。

注意友元不能传递,即,如果Window_mgr有自己的友元,那么这些友元不能访问Screen的成员。

注意:每个类决定哪些类或者函数是它自己的友元

成员函数作为友元

Screen可以将clear成员函数指定为友元,而不是整个Window_mgr.当声明某个成员函数为友元是,必须指定这个类的类名:

class Screen{
	frend void Window::clear(ScreenIndex);
};

将成员函数作为友元需要留意成员的结构,以容纳声明和定义之间的依赖。在这个例子中,必须按如下的顺序进行定义:

  1. 首先,定义Window_mgr类,声明clear,但不定义它。在clear使用Screen的成员函数之前,Screen必须被声明。
  2. 接着,定义Screen类,包含clear的友元声明
  3. 最后,定义clear,此时它才可以访问Screen的成员。

重载函数和友元

尽管重载函数之间名字相同,但是他们仍然是不同的函数。因此,类必须为每一个重载函数都声明为友元:

extern std::ostream & storeOn(std::ostream &,Screen &);
extern BitMap& storeOn(BitMap &,Screen &);
class Screen{
	friend std::ostream& storeOn(std::ostream &,Screen &);
};

Screen类让带有一个ostream&的storeOn成为了友元。带有BitMap&的storeOn则不是友元。

友元声明和作用域

在将类和函数变成友元之前,这个类和非成员函数不需要声明。当名字第一次出现在友元声明中,就假定这个名字是在这个作用域内可见。然而,友元不一定真的声明在这个作用之中。

即使定义函数在类的内部,我们也必须提供一个类外部的声明,以保证以其可见。尽管从授权友元的类的成员函数中调用这个友元,也必须提供一个声明。

struct X{
	friend void f() {/*友元函数可以定义在类内部*/}
	X() {f();}	//错误:f还没有被声明
	void g();
	void h();
}

void X::g(){return f();}//错误:f还没有声明
void f();
void X::h(){return f();}//正确:f的声明在作用域中了

注意:记住,某些编译器不会对友元的规则进行强制检查(7.2.1小节)。

7.4 类作用域

每一个类都有他自己的作用域。在类作用域外,类中的普通数据和函数成员可以通过类对象,类指针,类引用和成员访问运算符来访问。访问类型成员,则是通过作用域运算符。在任何情况下,跟在运算符后面的名字必须是相应类的成员。

Screen::pos ht  = 24,wd = 80;	//使用定义在Screen内部的pos
Screen scr(ht,wd,’ ’);
Screen *p &scr;
char c= scr.get();
c = p->get();

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

一个类就是一个作用域,这就是为什么在类的外部定义函数时,必须写上类名。因为在类的外部成员函数时被隐藏的。

一旦类名可见,剩下的定义就在这个类的作用域内了,包括形参列表,函数体。因此,可以直接使用类的其他成员而不用再次写上类名。

例如,回忆clear函数的定义。这个函数使用了Winddow_mgr里面的类型:

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

因为编译器看见形参列表在后面,所以就认为这是在Window_mgr的作用域内。此时就不用再ScreenIndex前面加上Window_mgr::了。同样,在函数体内就直接使用了Window_mgr里面的名字了。

另外,返回类型通常出现在函数名之前。当成员函数定义在类外部时,任何在返回类型使用的名字,都是在作用域之外的。所以返回类型必须指出他是那个类的成员。例如,可以给Window_mgr一个函数,叫做addScreen,这个函数增加其他的Screen到显式中。这个函数将返回ScreenIndex值。

class Window_mgr{
public:
	ScreenIndex addScreen(const Screen&);
};
Window_mgr::ScreenIndex 
Window_mgr::addScreen(const Screen &s)
{
	screens.push_back(s);
	return screens.size()-1;
}

因为返回类型出现在类的名字被看见之前,所以,它在类的作用域之外。为了使用ScreenIndex在返回类型上,必选在前面写上Window_mgr::

7.4.1 名字查找和类作用域

到目前为止,我们所写程序的名字查找(寻找使用的名字与哪一个声明匹配的过程)都比较直截了当。

  1. 首先,在块内寻找被使用名字的声明,只考虑在使用之前出现的声明
  2. 如果没有找到,就查找外层作用域。
  3. 如果还是没有找到,就出错。

对于定义在类内部的成员函数查找名字的过程跟上诉规则有点区别。但是,在这个例子中,不太明显。类的定义分为两部分:

  1. 首先,成员声明被编译
  2. 然后,整个类可见之后,函数体被编译

注意:成员函数的编译过程在整个成员的声明处理过程之后。

用这种两段方式来处理类,更加便于对类代码进行组织。在整个类可见之前,成员函数的函数体不会被编译,所以他们可以使用定义在类内部的任何名字。如果函数的定义和成员的声明同时被处理,我们就不得不对成员函数进行排序,以便于能够调用到可见的名字。

类成员声明的名字查找

这两阶段只应用在成员函数体中的名字。而对于声明中的名字,包括返回类型的名字,以及形参中的名字,都必须在使用之前可见。如果在成员声明中使用了,还未在类内可见的名字,编译器则在这个类定义的作用域中寻找这个名字,例如:

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

当编译器看见balance函数的声明时,他将在Account里面寻找Money的声明,并且只会考虑在类内部且出现在Money使用之前的声明。因为没有找到,编译器继续寻找外层作用域中的声明。在这个例子中,编译器将找到Money的typedef。因此这个类型将被用在balance的返回类型和成员bal的声明上。另一方面balance函数体的处理在整个类可见之后。因此在函数体内的返回值是叫做bal的成员函数,而不是外部作用域的string变量。

类型名要特殊处理

通常,内部作用域可以重新定义来自于外部作用域的名字,就算这个名字已经在内部作用域内被使用了。但是,在类内,如果成员使用了来自于外部作用域的名字,并且这个名字是某种类型,那么这个类在后面就不能重新定义这个名字:

typedef double Money;
class Account{
public:
	Money balance(){return bal;}
private:
	typedef double Money;//错误:不能重新定义Money
	Money bal;
};

值得注意的是:尽管在Account内部对Money的定义与外层一致,但是这个代码也是错误的。

尽管重新定义名字是错误的,编译器也不强制要求诊断这种错误。即使程序是错误的,一些编译器仍然接受这种代码。

提示:类型名的定义通常应该出现在类的开始。通过这种方式,任何使用这种类型的成员都是在类型名的定义之后。

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

在成员函数体内部的名字,将按照如下进行处理:
•首先,在成员函数内寻找这个名字的声明。通常只有在函数内部并且在使用之前的声明才会被考虑。
•如果在函数成员内部没有找到声明,就在类的作用域内寻找。类中的所有成员都会被考虑。
•如果在类内还是没有找到,则在函数成员定义之前的作用域内寻找。

通常,成员函数的形参名与另外一个数据成员名字相同,是非常不好的习惯。但是,为了展示名字如何被查找的,我们将在dummy_fcn函数中违法这个约定;
//注意:这个代码仅仅是为了说明,这是一种非常不好的编程习惯
//将形参名与成员名,命名相同是非常不好的习惯

int height;
class Screen{
public:
	typedef std::string::size_type pos;
	void dummy_fcn(pos height){
		cursor = width * height;
}
private:
	pos cursor = 0;
	pos height = 0,width = 0;
};

当编译器处理dummy_fcn里面的乘法时,它首先寻找这个表达式所在作用域中的名字。函数的形参名也在这个作用域内。因此,height,被认为是形参。

此种情况,height形参隐藏了height成员。如果想要改变这个隐藏,可以如下操作:

void Screen::dummy_fcn(pos height){
	cursor = width * this->height;
	//另外一种写法
	cursor = width * Screen::height;
}

注意:尽管类成员被隐藏了,但是通过使用this指针,或者类名,他仍然可以是被使用

一个更好的方式是,保证成员height跟形参的名字不一样:
//好的编程习惯:不要将形参或者局部变量的名字跟成员名字相同

void Screen::dummy_fcn(){
	cursor = width * height;
}

此种情况下,当编译器寻找height时,不会在dummy_fcn中找到。编译器将会在Screen中进行寻找。尽管height的声明出现在dummy_fcn中的使用之后,但是编译器仍然能正确的找到正确的height,即Screen中的height数据成员。

类作用域之后,寻找外层作用域

如果编译器在函数或者类作用域中没有找到,它将继续在外层作用域中寻找。在我们例子中,height被定义在外层作用域中,并且出现在Screen的定义之前。如果我们想访问外层作用域中的名字,可以通过使用作用域运算符:

//不推荐使用:不要隐藏外层作用域中需要用到的名字
void Screen::dummy_fcn(pos height){
	cursor = width * ::height;	//哪一个height?最外层那个
}

注意:尽管外层对象被隐藏了,仍然可以使用作用域运算符来访问。

在文件中名字的出现初进行处理

当成员定义在类的外部时,名字寻找的第三步包括:成员定义作用域中的寻找和类定义作用域中的寻找。例如:

int height;//定义了一个在后续Screen类中会被使用的变量
class Screen{
public:
	typedef std::string::size pos;
	void setHeight(pos);
	pos height =0;	//隐藏了外部作用域中的声明
};
Screen::pos verify(Screen::pos);
void Screen::setHeight(pos var){
	//var:指向形参
	//height:指向类成员
	//verify:指向全局函数
	height = veriry(var);
}

注意在Screen类定义之前,verify函数不可见。但是名字寻找的第三步包含成员定义作用域中寻找。此例中,对于verify的声明出现在setHeight的定义之前,所以可以被找到,病被使用。

7.5 构造器再探

构造器是任何类的重要部分。在7.1.4小节中介绍了构造器的基本知识。本段将介绍构造器的另外的能力,并且对以前的内容更进一步的了解。

7.5.1 构造器初始化列表

当定义变量时,通常马上就会对其进行初始化,而不是先定义然后再赋值:

string  foo = “Hello World”;//定义并且初始化
string bar;		//默认初始化一个空的字符串
bar = “Hello World”;//赋值一个新的值给bar

初始化和赋值的区别,也同样适用于对象成员的初始化和赋值。如果没有在构造器初始化列表中显示的初始化成员,这个成员就会在构造器函数体执行之前,执行默认初始化。例如:

//合法,但是比较草率,因为没有使用构造器初始列表
Sales_data::Sales_data(const string &s,unsigned cnt,double price){
	bookNo = s;
	units_sold = cnt;
	revenue = cnt * price;
}

这个版本和以前的定义效果相同:当构造器结束时,数据成员保存有相同的值。唯一的不同就是:原始版本对成员进行初始化,这个版本对成员进行赋值。这两种操作到底有多么大的影响,完全取决于具体的类型。

构造函数初始值有时是必须的

我们有时常常(不是总是)忽视成员是该初始化还是该赋值。const或者引用成员必须被初始化。同样的,当类类型成员没有默认构造函数时,也必须初始化。例如:

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

跟其他conost对象和引用一样,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){}

注意:必须对const成员,引用成员,以及没有默认构造器的类类型成员,用构造器初始值列表进行初始化。

建议:使用构造器初始值
在许多类中,初始化和赋值之间的区别是一个非常关乎底层效率的事情:前者直接初始化,后者先初始化再赋值。

比效率更重要的是:有些数据只能初始化,不能赋值。使用构造初始值,可以避免有些编译错误,尤其是在某些类成员必须要构造初始值的情况。

成员初始化的顺序

不要惊讶,在构造器初始值列表中每个名字只能出现一次。否则,就意味着要给要给一个成员初始化多个值。

让人更感惊讶的是:构造器初始值列表只指定了初始值,不会指定初始化的顺序。

成员初始化的顺序依赖于成员定义的顺序:第一个成员首先被初始化,接着是第二个,以此类推。在构造函数初始值中出现的顺序不会改变成员初始化的顺序。

初始化的顺序通常没有什么大碍。但是,如果一个成员由另外一个初始化,那么成员初始化的顺序就非常重要了。

举个例子,思考下面的代码:

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

此例中,构造函数的写法,好像就是j由val初始化,然后j再去初始化i。但是,事实上是,i先被初始化。这种初始化的影响是:初始化i的初始值,即j的值,此时是未定义的。

有些编译器足够友好,如果构造器初始值的顺序和声明的顺序不一样,则会给出警告。

经验之谈
构造函数初始值列表建议跟成员的声明顺序一样。同时,避免使用一个成员去初始化另外一个成员

如果有可能,使用构造函数形参初始化成员,而不是另外一个成员来初始化成员。这样就可以避免成员初始化的顺序了。例如,对于X的构造器,下面的写法可能更好:

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

在这个版本中,i和j被初始化的顺序根本不用关心。

默认实参和构造器

Sales_data的默认构造的行为与带有一个string实参的构造器行为类似。唯一的区别是:带有string实参的构造器使用这个string初始化bookNo。而默认构造器使用string的默认初始化去初始化bookNo.通过带有默认实参,可以重写这些构造器:

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参数时,创建的对象都一样。因为可以不带参数调用这个构造器,所以相当于定义了一个默认的构造器。

注意:一个构造器为所有的形参都定义了默认实参,也相当于定义了默认构造器。

值得注意的是:不应为Sales_data带有三个形参的构造器提供默认实参。因为如果用于提供了一个非零的售卖数,那么就应该提供这本书被卖的价格。

7.5.2 委托构造函数

c++11新标准扩展了构造函数初始值的功能,可以让我们定义所谓的默认构造函数。委托构造器使用它自己类的其他构造函数执行它自己的初始化。之所以成为委托是因为他将工作的一些交给了其他构造器。

跟其他构造器一样,委托构造器也有成员初始值列表以及函数体。在委托构造器中,成员初始值列表,有唯一的一个入口,就是类名本身。跟其他的成员初始值一样,类名后面跟着小括号括起的实参。这个实参必须匹配类中的某个构造器。

例如,使用委托构造函数重写Sales_data类:

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);}
	//其他成员跟以前一样
};

这个版本中,除了一个,其他都委托他们自己的工作。第一个构造器带有三个形参,使用者三个形参初始化了数据成员,并且没有做进一步的工作。在这个版本中,定义了默认构造器,默认构造器使用带有三个实参的构造器来进行初始化。正如空函数表明的那样,它也没有做进一步的工作。带有一个string的构造器也委托给了三个参数的构造器。

带有一个istream&的构造器也委托了。它委托给了默认构造器,最终委托到了三个实参的构造器。一旦这些构造器完成了他们的工作,istream&版本的构造器的函数体就开始执行。它的函数体调用read从给定的istream中读数据。

当一个构造器委托给另外一个构造器时,受委托构造器的初始值列表和函数都会被执行。在Sales_data中,受委托的构造器函数体恰巧为空。如果函数体包含代码,这些代码会先执行,然后才是将控制权返回给委托者的函数体。

7.5.3 默认构造函数的作用

对一个对象是默认初始化或者值初始化时,默认构造函数自动的被使用。默认初始化发生在如下情况:
•在块作用域中定义非静态变量或者数组,而不带初始值时
•在带有类类型成员的来中使用了合成的默认构造器时
•在类类型成员没有在构造器初始值列表中显式初始化时

值初始化发生在如下情况
•在定义一个静态对象而没有带初始值时
•在书写形如T()这样的表达式,来请求值初始化时。其中T是类型名(vector的带有一个实参的构造器,用于说明这个vector的大小。此时就是使用的这种方式对其元素进行值初始化)

类要有一个默认构造器就是为了在这种情况中使用,大多数情况都非常好判断。

不好判断的是,类的某些数据成员缺少默认构造函数:

class NoDefault{
public:
	NoDefault(const std::string);
};

struct A{
	NoDefault my_mem;
};

A a;	//错误:不能为A合成一个默认的构造器
struct B{
	B(){}		//错误:b_member没有初始值
	NoDefault b_member;
};

经验之谈:
事实上,如果有其他构造器,提供一个默认构造器总没有错。

使用默认构造器

下面的对于obj的声明不会问题,但是当尝试使用obj时:

Sales_data obj();
if(obj.isbn() == Primer_5th_en.isbn()) //错误obj是一个函数。

编译器出错,报:不能对函数进行成员访问。问题就来了:我们其实想声明一个默认初始化的对象obj,但是实际上声明了一个函数,这个函数没有形参,并且返回一个Sales_data类型的对象。

使用默认构造器定义一个对象的正确方式是,丢掉后面空的小括号:

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

警告:对于新手c++程序员来说,使用如下的默认构造声明对象,常常是错误的:

Sales_data obj();	//oops!  声明了一个函数,不是对象
Sales_data oboj2;	//正确: obj2是一个对象,不是函数

7.5.4 隐式的类型转换

正如在4.11小节中介绍的那样,c++对于内置类型定义了几种自动转换规则。我们也注意到类也有类似的隐式转换。如果一个构造器带有一个实参,那么这个构造器就定义了一种转换到此类类型的隐式规则,这种构造函数有时也称为转换构造器。我们将在14.9小节,介绍如何定义从一个类到另外一个类的转换规则。

注意:可以通过单个实参调用的构造器定义了一个隐含的从构造器形参类型转换成类类型的转换规则。

Sales_data的构造器带有一个string,还有一个构造器带有一个istream。他们都定义了从这些类型到Sales_data类型的转换规则。即,在使用Sales_data的地方都可以使用string和istream。

string  null_book =9-999-9999-9;
//构造了一个临时的Sales_data对象
//这个对象的units_sold和revenue等于0,并且bookNo等于null_book
item.combine(null_book);

此处调用Sales_data 的combine函数,传递了一个string实参。这个调用完全是合法的。编译器根据string自动创建Sales_data对象。这个新创建的Sales_data对象,被传递到coimbine函数。因为combine的形参是const,所以我们可以传递这个临时对象给形参。

仅仅只有一步类类型可以被转换

在4.11.2小节中,我们注意到编译器会自动的进行类类型转换。例如,下面的代码是错误的,因为他们隐式的转换了

//错误:需要进行两次转换
//(1) 将 “9-999-99999-9” 变成字符串
//(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.combin(cin);

这段代码隐式将cin转换成Sales_data.这个转换执行了带有一个istream的构造器。这个构造器从标准输入中读入数据,然后创建一个临时的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 Sales_data::Sales_data(istream & is){
	read(is, *this);
}

explicit构造器仅可以被用来直接初始化

隐式转换的发生的场景是:使用复制形式的初始化(使用=)(3.2.1小节)。这种形式的初始化不能使用explicit构造器,但是我们可以使用这种构造器进行直接初始化:

Sales_data item(null_book);//正确,直接初始化
//错误:不能使用复制形式的初始化,因为构造器时explicit的
Sales_data item2 = null_book;

注意:当一个构造器声明为explicit时,它仅能用作直接初始化(3.2.1)。而且编译器不会使用这个构造器进行自动转换。

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

尽管编译器不能使用explicit构造器进行隐式转换,但是我们可以使用这个构造器进行显式的强制转换:

//正确:实参被形式强制转换为Sales_data对象
item.combine(Sales_data(null_book));
//正确:static_cast 可以使用explicit构造器
item.combine(static_cast<Sales_data>(cin));

在第一个调用中,我们直接使用了Sales_data构造器。这个调用使用构造器创建了一个临时的Sales_data对象。在第二个调用中,我们使用了static_cast来执行一个显式的转换。在这个调用中,static_const使用了带有istream的构造器创建了一个临时的Sales_data对象。

带有explicit构造器的标准库类

一些我们已经使用过的库类,带有一个单形参的构造器:

•带有单个const char*的string构造器,不是explicit的
•带有一个大小的vector构造器,是explicit的

7.5.5 聚合类

聚合类提供了直接访问成员的功能并且尤其特殊的语法。聚合类满足如下条件:
• 所有的数据成员都是public的
• 它没有定义任何构造器
• 它没有类内初始值
• 它没有基类或者虚拟函数,虚拟函数是跟类相关的特性,我们将在15章介绍。

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

struct Data{
	int ival;
	string s;
};

可以通过大括号将数据成员的初始值括起来,然后进行初始化:

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

初始值顺序,必须于数据成员的声明顺序一致。即,第一个成员用第一个初始值进行初始化,第二个成员使用第二个初始值进行初始化,以此类推。下面是一个错误的例子:

//错误:不能使用“Anna”初始化ival,也不能使用1024初始化s

Data val2  ={“Anna”,1024};

跟数组的初始化一样,如果初始化列表里面的值少于类的成员,那么剩下的成员就执行值初始化。初始值列表里面的值个数不能多于类的成员个数。

值得注意的是:显式的初始化类对象的成员,有三个明显的弊端

• 它要求所有的成员都必须是public
• 将初始化的负担交给了用户代码,这种初始化更容易出错。因为用户可能忘记初始化或者提供一个不正确的初始值。
• 如果一个成员增加或移出了,所有的初始化都必须进行更新。

7.5.6 字面量类

在6.5.2小节中,constexpr的形参和返回类型必须是字面量。除了算数类型,引用,和指针以外,某些类也是字面量。跟其他字面量不同,字面量类可以有constexpr的函数成员。这种函数成员必须满足所有的constexpr函数的要求,他是隐式的const成员(7.1.2小节)。

当一个聚合类的所有数据成员都是字面量类型是,这个类就是字面量类。如果一个类不是聚合类,如果他满足下面的条件,那么他也是字面量类:
•所有数据成员都必须是字面量类型
•类至少要有一个constexpr的构造器
•如果数据成员有类内初始值,当数据成员是内置类型时,这个成员的初始值必须是常量表达式;如果数据成员是类类型,这个成员的初始值必须是这个成员类型的constexpr构造器。

constexpr 构造函数

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

constexpr构造器可以被声明为=default的形式(或者删除函数的形式,我们将在13.1.6节中介绍相关知识)。否则constexpr构造器必须要满足构造器的要求——意味着他没有返回语句,又要满足constexpr函数的要求——意味着仅能在return语句中执行相应的逻辑。因此,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的对象以及用在constpexr函数的形参和返回类型上面。

constexpr Debug io_sub(false,true,false);
if(io_sub.any())
	cerr << “print appropriate error message” << endl;
constexpr Debug prod(false);
if(prod.any())
	cerr << “print an error message” << endl;

7.6 static 的类成员

类有时需要跟类相关的成员,而不是跟这个类对象相关的成员。例如,银行账户类可能需要一个数据成员,用来表示当前的网速。此时,我们想这个网速跟类相关,而不是跟每个每个分开的对象相关。从效率上来讲,没必要为每个对象都存储一个网速。更重要的是,如果网速改变,每个对象都得使用这个新值。

声明static成员

要想将一个成员与类相关,就在成员的声明前加上关键字static。跟其他的成员一样,static成员可以是public也可以是private。static成员的类型可以是const,引用,数组,类类型等。
举个例子,定义一个代表账户记录的类:

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

类的static成员存在于任何对象的外部。对象不会包含static数据成员。因此,每一个Account对象只包含两个数据成员owner和amount。此处只有一个interestRate对象是被所有的Account对象共享的。

同样的,static成员函数没有跟任何的对象绑定;他们没有this指针。因此,static成员函数不能声明为const,也不能使用this指针。这个即限制了在函数体内显式的使用this,也限制了隐式使用this去调用非static成员。

使用类的static成员

访问static成员,可直接通过作用域运算符访问:

double r;
r = Account::rate();	//访问静态成员

尽管静态成员不是对象的一部分,但是也可以通过对象的引用,指针等访问到static成员:

Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();

成员函数可以直接使用static成员,不用加上作用域运算符:

class Account{
public:
	void calculate(){amount += amount * interestRate;}
private:
	static double interestRatee;
	//剩下的和以前一样
};

定义static成员

跟其他的成员函数一样,可以在类的内部或者外部定义static成员函数。当定义static函数在类的外部时,就不要重复写static关键字了。这个关键字仅出现在类内部的声明中。

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

注意:跟其他的类成员一样,当要定义类的static成员在类的的外部时,也必须指出这个成员所在的类。statci关键字,仅被用在类内的成员声明中。

因为static数据成员不是类对象的一部分,所以当创建对象的时候,他们是为定义的。因此,他们不是在构造器中进行初始化的。并且,通常情况下,我们不能对类内的static成员进行初始化。相反,我们必须在类的外部定义和初始化static成员。跟其他的对象一样,static数据成员只能被定义一次。

跟全局对象一样,static数据成员被定义在任何函数之外。因此,只要他们已定义,他们就一直存在,直到程序结束。

定义static成员函数,跟定义类外部的成员函数一样。首先是对象的类型,然后是类名,跟上作用域,接着是成员自己的名字:


//定义并初始化一个static成员

double Account::interestRate = initRate();

这个语句定义了一个叫做interestRate的对象,这个对象是Account的静态成员,它的类型为double。一旦类名被看见,后面的定义都是在类的作用域中。因此,可以直接使用initRate,而不用前面加上Account::。还需要注意的是:尽管initRate是private的,但是我们还是可以使用这个函数去初始化interestRate. interestRate的定义跟其他的成员定义一样,可以访问类的private的成员。

提示:保证对象被定义一次的方法是:将static数据成员的定义跟其他的非inline函数的定义放在同一个文件中。

static成员的类内初始化

通常,static成员不能在类内进行初始化,但是,对于static成员为constexpr类型或者const类型,可以为其提供一个类内的初始值。初始值必须是常量表达式。这种成员本身就是常量表达式,他们可以用在任何需要常量表达式的地方。例如,可以使用一个static成员来表示一个数组的维度:

class Account{
public:
	static double rate(){return interestRate;}
	static void rate(double);
private:
	static constexpr int period = 30;	//period是一个常量表达式
	double daily_tbl[period];
};

如果某个static成员的引用场景仅仅限于编译器可以替换他的值,那么一个初始化的const或者constexpr的static成员不需要分开定义。但是,如果将其用于值不能替换的场景中,这些成员就必须要有一条定义。

例如,如果仅使用period来定义daily_tbl的维度,那么没必要在Account的外部定义period。但是,如果我们省略了定义,那么程序微小的改变也可能引起编译错误,因为没有定义。例如,当我们将Account::period传递到一个函数中,这个函数的形参为const int&,此时period必须被定义。

如果在类内初始值被提供了,那么成员的定义就不能提供一个初始值。

constexpr int Account::period;//初始值由类内提供

经验之谈:
即使const static在类内部初始化了,这个成员也应该在类的外部定义一下

static成员可以用在普通成员不能应用的场景

正如所见,static成员的存在是独立于其他对象的。因此,static成员可以用在,普通成员不能用的地方。例如,static成员可以是不完整的类型(7.3.3)。尤其是,static成员类型可以为所在类的类型。而非static成员,只能为所在类类型的指针,或者引用。

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

另外一个普通成员与static成员不同的是:可以使用static成员作为默认的实参。

class Screen{
public:
	Screen& clear(char = bkground);
private:
	static const char bkground;
};

一个非static数据成员,不能作为默认实参,因为他是对象的一部分。使用非static成员作为默认实参,相当于没有提供对象,从这个对象中获取这个成员的值,所以是错误的。

本章小结(译略)
术语(译略)

难免错误,望指正

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值