C++ Primer 第二遍阅读笔记(第十二章)

第三部分:类和数据抽象

定义了数据成员和函数成员:数据成员用于存储与该类类型的对象相关联的状态,而函数成员则负责执行赋予数据意义的操作。通过类我们能够将实现和接口分离,用接口指定类所支持的操作,而实现的细节只需类的实现者了解或关心。

一个类可以包含若干公有的、私有的和受保护的部分。我们已经使用过 public 和 private 访问标号:在 public 部分定义的成员可被使用该类型的所有代码访问;在 private 部分定义的成员可被其他类成员访问

所有成员必须在类的内部声明,一旦类定义完成后,就没有任何方式可以增加成员了。

创建一个类类型的对象时,编译器会自动使用一个构造函数来初始化该对象。构造函数是一个特殊的、与类同名的成员函数,用于给每个数据成员设置适当的初始值。

构造函数一般就使用一个构造函数初始化列表,来初始化对象的数据成员:

在类内部,声明成员函数是必需的,而定义成员函数则是可选的。在类内部定义的函数默认为 inline

在类外部定义的成员函数必须指明它们是在类的作用域中。Sales_item::avg_price 的定义使用作用域操作符来指明这是 Sales_item 类中 avg_price 函数的定义。

成员函数有一个附加的隐含实参,将函数绑定到调用函数的对象——当我们编写下面的函数时:
trans.avg_price()
就是在调用名 trans 的对象的 avg_price 函数。如果 trans 是一个 Sales_item 对象,则在 avg_price 函数内部对 Sales_item 类成员引用就是对 trans 成员的引用。

关键字 const 加在形参表之后,就可以将成员函数声明为常量:

double avg_price() const;

const 成员不能改变其所操作的对象的数据成员。const 必须同时出现在声明和定义中,若只出现在其中一处,就会出现一个编译时错误。

数据抽象是一种依赖于接口和实现分离的编程(和设计)技术。类设计者必须关心类是如何实现的,但使用该类的程序员不必了解这些细节。相反,使用一个类型的程序员仅需了解类型的接口,他们可以抽象地考虑该类型做什么,而不必具体地考虑该类型如何工作。

封装是一项低层次的元素组合起来的形成新的、高层次实体珠技术。函数是封装的一种形式:函数所执行的细节行为被封装在函数本身这个更大的实体中。被封装的元素隐藏了它们的实现细节——可以调用一个函数但不能访问它所执行的语句。同样地,类也是一个封装的实体:它代表若干成员的聚焦,大多数(良好设计的)类类型隐藏了实现该类型的成员。

在 C++ 中,使用访问标号来定义类的抽象接口和实施封装。一个类可以没有访问标号,也可以包含多个访问标号:
程序的所有部分都可以访问带有 public 标号的成员。类型的数据抽象视图由其 public 成员定义。
使用类的代码不可以访问带有 private 标号的成员。private 封装了类型的实现细节。


并非所有类型都必须是抽象的。标准库中的pair 类就是一个实用的、设计良好的具体类而不是抽象类。具体类会暴露而非隐藏其实现细节。


数据抽象和封装提供了两个重要优点
避免类内部出现无意的、可能破坏对象状态的用户级错误。
随时间推移可以根据需求改变或缺陷(bug)报告来完美类实现,而无须改变用户级代码


除了定义数据和函数成员之外,类还可以定义自己的局部类型名字。如果为 std::string::size_type 提供一个类型别名,那么 Screen 类将是一个更好的抽象:

类所定义的类型名遵循任何其他成员的标准访问控制。将 index 的定义放在类的 public 部分,是因为希望用户使用这个名字。Screen 类的使用者不必了解用 string 实现的底层细节。定义 index 来隐藏 Screen 的实现细节。将这个类型设为 public,就允许用户使用这个名字。

重载的成员函数和普通函数应用相同的规则:两个重载成员的形参数量和类型不能完全相同。调用非成员重载函数所用到的函数匹配过程也应用于重载成员函数的调用。

为了举例说明重载,可以给出 Screen 类的两个重载成员,用于从窗口返回一个特定字符。两个重载成员中,一个版本返回由当前光标指示的字符,另一个返回指定行列处的字符:

与任意的重载函数一样,给指定的函数调用提供适当数目和/或类型的实参来选择运行哪个版本:

在类内部定义的成员函数,例如不接受实参的 get 成员,将自动作为 inline 处理。也就是说,当它们被调用时,编译器将试图在同一行内扩展该函数。也可以显式地将成员函数声明为 inline:

可以在类定义体内部指定一个成员为inline,作为其声明的一部分。或者,也可以在类定义外部的函数定义上指定 inline。在声明和定义处指定 inline 都是合法的。在类的外部定义 inline 的一个好处是可以使得类比较容易阅读。
像其他 inline 一样,inline 成员函数的定义必须在调用该函数的每个源文件中是可见的。不在类定义体内定义的 inline 成员函数,其定义通常应放在有类定义的同一头文件中

不完全类型:
已声明但未定义的类型。不能使用不完全类型来定义变量或类成员。定义指向不完全类型的引用或指针是合法的。

因为只有当类定义体完成后才能定义类,因此类不能具有自身类型的数据成员。然而,只要类名一出现就可以认为该类已声明。因此,类的数据成员可以是指向自身类型的指针或引用:


类的前身声明一般用来编写相互依赖的类


定义了一个类类型之后,可以按以下两种方式使用



为什么类的定义以分号结束
分号是必需的,因为在类定义之后可以接一个对象定义列表。定义必须以分号结束:

通常,将对象定义成类定义的一部分是个坏主意。这样做,会使所发生的操作难以理解。对读者而言,将两个不同的实体(类和变量)组合在一个语句中,也会令人迷惑不解。


尽管在成员函数内部显式引用 this 通常是不必要的,但有一种情况下必须这样做:当我们需要将一个对象作为整体引用而不是引用对象的一个成员时。最常见的情况是在这样的函数中使用 this:该函数返回对调用该函数的对象的引用

某种类可能具有某些操作,这些操作应该返回引用,Screen 类就是这样的一个类。迄今为止,我们的类只有一对 get 操作。逻辑上,我们可以添加下面的操作。
一对 set 操作,将特定字符或光标指向的字符设置为给定值。
一个 move 操作,给定两个 index 值,将光标移至新位置。
在单个表达式中调用 move 和 set 操作时,每个操作必须返回一个引用,该引用指向执行操作的那个对象:

注意,这些函数的返回类型是 Screen&,指明该成员函数返回对其自身类类型的对象的引用。每个函数都返回调用自己的那个对象。使用 this 指针来访问该对象。下面是对两个新成员的实现:

函数中唯一需要关注的部分是 return 语句。在这两个操作中,每个函数都返回 *this。在这些函数中,this 是一个指向非常量 Screen 的指针。如同任意的指针一样,可以通过对 this 指针解引用来访问 this 指向的对象。


不能从 const 成员函数返回指向类对象的普通引用。const 成员函数只能返回 *this 作为一个 const 引用。

例如,我们可以给 Screen 类增加一个 display 操作。这个函数应该在给定的 ostream 上打印 contents。逻辑上,这个操作应该是一个 const 成员。打印 contents 不会改变对象。如果将 display 作为 Screen 的 const 成员,则 display 内部的 this 指针将是一个 const Screen* 型的 const。

这个用法暗示了 display 应该返回一个 Screen 引用,并接受一个 ostream 引用。如果 display 是一个 const 成员,则它的返回类型必须是 const Screen&。
不幸的是,这个设计存在一个问题。如果将 display 定义为 const 成员,就可以在非 const 对象上调用 display,但不能将对 display 的调用嵌入到一个长表达式中。下面的代码将是非法的:

问题在于这个表达式是在由 display 返回的对象上运行 set。该对象是 const,因为 display 将其对象作为 const 返回。我们不能在 const 对象上调用 set。

为了解决这个问题,我们必须定义两个 display 操作:一个是 const,另一个不是 const。基于成员函数是否为 const,可以重载一个成员函数;同样地,基于一个指针形参是否指向 const,可以重载一个函数。const 对象只能使用 const 成员。非 const 对象可以使用任一成员,但非 const 版本是一个更好的匹配。

现在,当我们将 display 嵌入到一个长表达式中时,将调用非 const 版本。当我们 display 一个 const 对象时,就调用 const 版本:



可变数据成员:永远都不能为 const,甚至当它是 const 对象的成员时也如此。
因此,const 成员函数可以改变 mutable 成员。要将数据成员声明为可变的,必须将关键字 mutable 放在成员声明之前


我们给 Screen 添加了一个新的可变数据成员 access_ctr。使用 access_ctr 来跟踪调用 Screen 成员函数的频繁程度:


在类作用域之外,成员只能通过对象或指针分别使用成员访问操作符 . 或 ->来访问。这些操作符左边的操作数分别是一个类对象指向类对象的指针。跟在操作符后面的成员名字必须在相关联的类的作用域中声明:

在定义于类外部的成员函数中,形参表成员函数体都出现在成员名之后。这些都是在类作用域中定义,所以可以不用限定而引用其他成员。例如,类 Screen 中 get 的二形参版本的定义:

该函数用 Screen 内定义的 index 类型来指定其形参类型。因为形参表是在 Screen 类的作用域内,所以不必指明我们想要的是 Screen::index。我们想要的是定义在当前类作用域中的,这是隐含的。
与形参类型相比,返回类型出现在成员名字前面。如果函数在类定义体之外定义,则用于返回类型的名字在类作用域之外。如果返回类型使用由类定义的类型,则必须使用完全限定名。例如,考虑 get_cursor 函数:

该函数的返回类型是 index,这是在 Screen 类内部定义的一个类型名。如果在类定义体之外定义 get_cursor,则在函数名被处理之前,代码在不在类作用域内。当看到返回类型时,其名字是在类作用域之外使用。必须用完全限定的类型名 Screen::index 来指定所需要的 index 是在类 Screen 中定义的名字。

按以下方式确定在类成员的声明中用到的名字。
1.检查出现在名字使用之前的类成员的声明。
2.如果第 1 步查找不成功,则检查包含类定义的作用域中出现的声明以及出现在类定义之前的声明。


在处理 balance 函数的声明时,编译器首先在类 Account 的作用域中查找 Money 的声明。编译器只考虑出现在 Money 使用之前的声明。因为找不到任何成员声明,编译器随后在全局作用域中查找 Money 的声明。只考虑出现在类 Account 的定义之前的声明。找到全局的类型别名 Money 的声明,并将它用作函数 balance 的返回类型和数据成员 bal 的类型。

必须在类中先定义类型名字,才能将它们用作数据成员的类型,或者成员函数的返回类型或形参类型。

编译器按照成员声明在类中出现的次序来处理它们。通常,名字必须在使用之前进行定义。而且,一旦一个名字被用作类型名,该名字就不能被重复定义:

按以下方式确定在成员函数的函数体中用到的名字。
1.首先检查成员函数局部作用域中的声明。
2.如果在成员函数中找不到该名字的声明,则检查对所有类成员的声明。
3.如果在类中找不到该名字的声明,则检查在此成员函数定义之前的作用域中出现的声明。


下面的函数使用了相同的名字来表示形参和成员,这是通常应该避免的。这样做的目的是展示如何确定名字:

查找 dummy_fcn 的定义中使用的名字 height 的声明时,编译器首先在该函数的局部作用域中查找。函数的局部作用域中声明了一个函数形参。dummy_fcn 的函数体中使用的名字 height 指的就是这个形参声明。
在本例中,height 形参屏蔽名为 height 的成员。
尽管类的成员被屏蔽了,但仍然可以通过用类名来限定成员名或显式使用 this 指针来使用它。

如果想要使用 height 成员,更好的方式也许是为形参取一个不同的名字

如果编译器不能在函数或类作用域中找到,就在外围作用域中查找。在本例子中,出现在 Screen 定义之前的全局作用域中声明了一个名为 height 的全局声明。然而,该对象被屏蔽了。
尽管全局对象被屏蔽了,但通过用全局作用域确定操作符来限定名字,仍然可以使用它。

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

注意,全局函数 verify 的声明在 Screen 类定义之前是不可见的。然而,名字查找的第 3 步要考虑那些出现在成员定义之前的外围作用域声明,并找到全局函数 verify 的声明。

构造函数的名字与类的名字相同,并且不能指定返回类型。像其他任何函数一样,它们可以没有形参,也可以定义多个形参。

构造函数不能声明为 const。

与任何其他函数一样,构造函数具有名字、形参表和函数体。与其他函数不同的是,构造函数也可以包含一个构造函数初始化列表:


构造函数初始化列表以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个数据成员后面跟一个放在圆括号中的初始化式。这个构造函数将 isbn 成员初始化为 book 形参的值,将 units_sold 和 revenue 初始化为 0。与任意的成员函数一样,构造函数可以定义在类的内部或外部。构造函数初始化只在构造函数的定义中而不是声明中指定。

在构造函数初始化列表中没有显式提及的每个成员,使用与初始化变量相同的规则来进行初始化。运行该类型的默认构造函数,来初始化类类型的数据成员。内置或复合类型的成员的初始值依赖于对象的作用域:在局部作用域中这些成员不被初始化,而在全局作用域中它们被初始化为 0。

有些成员必须在构造函数初始化列表中进行初始化。对于这样的成员,在构造函数函数体中对它们赋值不起作用。没有默认构造函数的类类型的成员,以及 const 或引用类型的成员,不管是哪种类型,都必须在构造函数初始化列表中进行初始化。
因为内置类型的成员不进行隐式初始化,所以对这些成员是进行初始化还是赋值似乎都无关紧要。除了两个例外,对非类类型的数据成员进行赋值或使用初始化式在结果和性能上都是等价的。



记住,可以初始化 const 对象或引用类型的对象,但不能对它们赋值。在开始执行构造函数的函数体之前,要完成初始化。初始化 const 或引用类型数据成员的唯一机会是构造函数初始化列表中。编写该构造函数的正确方式为

建议:使用构造函数初始化列表
必须对任何 const 或引用类型成员以及没有默认构造函数的类类型的任何成员使用初始化式。



构造函数初始化列表仅指定用于初始化成员的值,并不指定这些初始化执行的次序。成员被初始化的次序就是定义成员的次序。第一个成员首先被初始化,然后是第二个,依次类推。

初始化的次序常常无关紧要。然而,如果一个成员是根据其他成员而初始化,则成员初始化的次序是至关重要的
考虑下面的类:

在这种情况下,构造函数初始化列表看起来似乎是用val 初始化 j,然后再用 j 来初始化 i。然而,i 首先被初始化。这个初始化列表的效果是用尚未初始化的 j 值来初始化 i!

按照与成员声明一致的次序编写构造函数初始化列表是个好主意。此外,尽可能避免使用成员来初始化其他成员。

一般情况下,通过(重复)使用构造函数的形参而不是使用对象的数据成员,可以避免由初始化式的执行次序而引起的任何问题。例如,下面这样为 X 编写构造函数可能更好:


一个初始化式可以是任意复杂的表达式。例如,可以给 Sales_item 类一个新的构造函数,该构造函数接受一个 string 表示 isbn,一个 int 表示售出书的数目,一个 double 表示每本书的售出价格:


再来看看默认构造函数和接受一个 string 的构造函数的定义:

可以通过为 string 初始化式提供一个默认实参将这些构造函数组合起来:

在这里,我们只定义了两个构造函数,其中一个为其形参提供一个默认实参。对于下面的任一定义,将执行为其 string 形参接受默认实参的那个构造函数:

在 empty 的情况下,使用默认实参,而 Primer_3rd_ed 提供了一个显式实参。

合成的默认构造函数(synthesized default constructor)使用与变量初始化相同的规则来初始化成员。具有类类型的成员通过运行各自的默认构造函数来进行初始化。内置和复合类型的成员,如指针和数组,只对定义在全局作用域中的对象才初始化。当对象定义在局部作用域中时,内置或复合类型的成员不进行初始化。

一个类哪怕只定义了一个构造函数,编译器也不会再生成默认构造函数。

如果类包含内置或复合类型的成员,则该类不应该依赖于合成的默认构造函数。它应该定义自己的构造函数来初始化这些成员。

初级 C++ 程序员常犯的一个错误是,采用以下方式声明一个用默认构造函数初始化的对象:

编译 myobj 的声明没有问题。然而,当我们试图使用 myobj 时

编译器会指出不能将成员访问符号用于一个函数!问题在于 myobj 的定义被编译器解释为一个函数的声明,该函数不接受参数并返回一个 Sales_item 类型的对象——与我们的意图大相径庭!使用默认构造函数定义一个对象的正确方式是去掉最后的空括号:
 

另一方面,下面这段代码也是正确的:



可以通过将构造函数声明为 explicit,来防止在需要隐式转换的上下文中使用构造函数:

explicit 关键字只能用于类内部的构造函数声明上。在类的定义体外部所做的定义上不再重复它:

通常,除非有明显的理由想要定义隐式转换,否则,单形参构造函数应该为 explicit。将构造函数设置为 explicit 可以避免错误,并且当转换有用时,用户可以显式地构造对象。


尽管大多数对象可以通过运行适当的构造函数进行初始化,但是直接初始化简单的非抽象类的数据成员仍是可能的。对于没有定义构造函数并且其全体数据成员均为 public 的类,可以采用与初始化数组元素相同的方式初始化其成员:


根据数据成员的声明次序来使用初始化式。例如,因为 ival 在 ptr 之前声明,所以下面的用法是错误的:


定义和使用构造函数几乎总是较好的。当我们为自己定义的类型提供一个默认构造函数时,允许编译器自动运行那个构造函数,以保证每个类对象在初次使用之前正确地初始化。


友元机制允许一个类将对其非公有成员的访问权授予指定的函数或类。友元的声明以关键字 friend 开始。它只能出现在类定义的内部。友元声明可以出现在类中的任何地方:友元不是授予友元关系的那个类的成员,所以它们不受声明出现部分的访问控制影响。

通常,将友元声明成组地放在类定义的开始或结尾是个好主意。

想像一下,除了 Screen 类之外,还有一个窗口管理器,管理给定显示器上的一组 Screen。窗口管理类在逻辑上可能需要访问由其管理的 Screen 对象的内部数据。假定 Window_Mgr 是该窗口管理类的名字,Screen 应该允许 Window_Mgr 像下面这样访问其成员:

Window_Mgr 的成员可以直接引用 Screen 的私有成员。例如,Window_Mgr 可以有一个函数来重定位一个 Screen:


如果不是将整个 Window_Mgr 类设为友元,Screen 就可以指定只允许 relocate 成员访问:


友元可以是普通的非成员函数,或前面定义的其他类的成员函数,或整个类。将一个类设为友元,友元类的所有成员函数都可以访问授予友元关系的那个类的非公有成员。

必须先定义包含成员函数的类,才能将成员函数设为友元。另一方面,不必预先声明类和非成员函数来将它们设为友元。

类必须将重载函数集中每一个希望设为友元的函数都声明为友元:

类 Screen 将接受一个 ostream& 的 storeOn 版本设为自己的友元。接受一个 BitMap& 的版本对 Screen 没有特殊访问权。


在成员声明前加上关键字 static 将成员设为 static。static 成员遵循正常的公有/私有访问规则。

例如,考虑一个简单的表示银行账户的类。每个账户具有余额和拥有者,并且按月获得利息,但应用于每个账户的利率总是相同的。可以按下面的这样编写这个类

这个类的每个对象具有两个数据成员:owner 和 amount。对象没有与 static 数据成员对应的数据成员,但是,存在一个单独的 interestRate 对象,由 Account 类型的全体对象共享。
可以通过作用域操作符从类直接调用 static 成员,或者通过对象、引用或指向该类类型对象的指针间接调用。

像使用其他成员一样,类成员函数可以不用作用域操作符来引用类的 static 成员:

static 成员是类的组成部分但不是任何对象的组成部分,因此,static 成员函数没有 this 指针。通过使用非 static 成员显式或隐式地引用 this 是一个编译时错误。

因为 static 成员不是任何对象的组成部分,所以 static 成员函数不能被声明为 const。毕竟,将成员函数声明为 const 就是承诺不会修改该函数所属的对象。最后,static 成员函数也不能被声明为虚函数。

static 数据成员可以声明为任意类型,可以是常量、引用、数组、类类型,等等。

static 数据成员必须在类定义体的外部定义(正好一次)。不像普通数据成员,static 成员不是通过类构造函数进行初始化,而是应该在定义时进行初始化。

定义 static 数据成员的方式与定义其他类成员和变量的方式相同:先指定类型名,接着是成员的完全限定名。

这个语句定义名为 interestRate 的 static 对象,它是类 Account 的成员,为 double 型。像其他成员定义一样,一旦成员名出现,static 成员的就是在类作用域中。因此,我们可以没有限定地直接使用名为 initRate 的 static 成员函数,作为 interestRate 初始化式。注意,尽管 initRate 是私有的,我们仍然可以使用该函数来初始化 interestRate。像任意的其他成员定义一样,interestRate 的定义是在类的作用域中,因此可以访问该类的私有成员。

像使用任意的类成员一样,在类定义体外部引用类的 static 成员时,必须指定成员是在哪个类中定义的。然而,static 关键字只能用于类定义体内部的声明中,定义不能标示为 static。

一般而言,类的 static 成员,像普通数据成员一样,不能在类的定义体中初始化。相反,static 数据成员通常在定义时才初始化。
这个规则的一个例外是,只要初始化式是一个常量表达式,整型 const static 数据成员就可以在类的定义体中进行初始化:


const static 数据成员在类的定义体中初始化时,该数据成员仍必须在类的定义体之外进行定义。
在类内部提供初始化式时,成员的定义不必再指定初始值:

static 数据成员的类型可以是该成员所属的类类型。非 static 成员被限定声明为其自身类对象的指针或引用:

类似地,static 数据成员可用作默认实参:

非 static 数据成员不能用作默认实参,因为它的值不能独立于所属的对象而使用。使用非 static 数据成员作默认实参,将无法提供对象以获取该成员的值,因而是错误的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值