写在前面:由于我的失误操作,导致第七章被第六章的内容覆盖,怀着悲痛的心情准备写第二遍,不过也应该可以比第一遍写的更好,在此提醒大家,不要随意切换文件并点击系统弹出来的保存,并即使做好备份。这是一个十分悲痛的教训,望大家注意。
类
类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和**实现(implementation)**分离的编程(以及设计〉技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
定义抽象数据类型
设计
sales_data的接口应该包含以下操作:
- 一个isbn 成员函数,用于返回对象的ISBN编号
- 一个combine成员函数,用于将一个sales_data对象加到另一个对象上
- 一个名为add 的函数,执行两个sales data对象的加法
- 一个read函数,将数据从istream读入到sales_data对象中。
- 一个print函数,将sales data对象的值输出到ostream
使用改进的Sales_data类
在考虑如何实现我们的类之前,首先来看看应该如何使用上面这些接口函数。举个例子,我们使用这些函数编写1.6节(第21页)书店程序的另外一个版本,其中不再使用sales_item对象,而是使用sales_data对象:
1 | sales_data total ; //保存当前求和结果的变量 |
定义改进的Sales_data类
1 | struct sales_data { |
定义成员函数
所有成员必须声明在类内部,但是成员函数体可以在类外定义。
在isbn函数中是如何bookNo所依赖的对象的呢?
引入this
调用为:total .isbn ()
这样的调用实际上是隐式地指向调用该函数的对象成员,这里返回的就是total.bookNo。
成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this,如
total .isbn ()
则编译器负责把total的地址传递给isbn的隐式形参this,可以等价地认为编译器将该调用重写成了如下的形式://伪代码,用于说明调用成员函数的实际执行过程Sales_data : :isbn (&total)
引入const成员函数
const修饰的成员函数,实际修饰该成员函数隐藏的this指针,表明在该成员函数中不能对类的任何成员进行修改。
且const对象只能调用const函数。
1 | class Data{ |
类作用域和成员函数
类本身就是一个作用域,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
因为编译器是首先编译成员的声明,其次才是成员函数体。
在类的外部定义成员函数
我们可以在类的外部定义成员函数,这样做的作用可以保持类内代码看起来更加清晰简洁。
但在外部定义必须在类内提前声明,且与类外函数保持一致。不同之处在于需要加上类名:
1 | double sales_data: :avg_price () const { |
定义返回this对象的函数
我们可以把自己这个对象返回,如:
1 | Sales_data& Sales_data : :combine (const Sales_data &rhs){ |
这个函数可以把自己一些数据和参数的数据相加,然后以引用的形式返回。
定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,比如 add、read和 print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。
类中的输入与输出
1 | //输入的交易信息包括ISBN、售出总数和售出价格 |
read函数由于将流中数据读到给定对象,print函数将给定对象打印到流中。
由于与流数据有交互,所以需要将IO类的引用作为参数。
构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来,控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
合成的默认构造函数
当没有定义任何构造函数时,创建对象则会执行合成的默认的构造函数:
●如果存在类内的初始值(参见2.6.1节,第64页),用它来初始化成员。
●否则,默认初始化(参见2.2.1节,第40页)该成员。
但合成的默认构造知识和简单的类,复杂的类容易出错,所以尽量自己去定义默认构造函数。
定义的构造函数
1 | struct Sales_ data { |
默认构造函数
Sales_ data() = default;
这是一个默认的构造函数,他的作用和合成的默认构造函数一样。
上面的默认构造函数之所以对Sales data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表(马上就会介绍)来初始化类的每个成员。
构造函数初始值
1 | Sales_ data(const std: :string &s) : bookNo(s) { } |
使用初始值列表为一个或几个数据成员赋值,且构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
在类外定义构造函数
1 | Sales_ data::Sales_ data(std::istream &is) |
首先它没有返回类型,且必须指定类名,由于这里的初始值列表为空,所以初始化任务交给函数体,没没有被构造函数赋值的成员将执行默认初始化。如string为空string,int为0。
函数read的第二个形参为该对象的引用。
拷贝、赋值和析构
一般来说编译器会默认的合成拷贝、赋值和析构,例如赋值:
1 | total = trans; //处理下一本书的信息. |
不可依赖合成版本
编译器默认生成的函数常常会出现一些问题,所以后面会了解到如何自定义这些函数。
访问控制与封装
可以使用访问说明符加强类的封装性:
定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了( 即隐藏了)类的实现细节。
说明符数量不限,且作用域到下一个访问说明符为止。出于统一编程风格的考虑,当我们希望定义的类的所有成员是public的时,使用struct; 反之,如果希望成员是private的,使用class。
友元
若要想某些函数可以访问类内私有成员,我们可以将他声明为友元,只需要增加一条以friend关键字开始的函数声明语句即可:
1 | class Sales_data { |
友元必须在类内声明,最好写在开头和劫为的尾置。
封装的好处
- 确保用户代码不会无意间破坏封装对象的状态。
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。
友元的声明
友元声明相当于给这个函数开通了权限,函数还是需要声明和定义。
类的其他特性
类成员再探
1 | class screen { |
定义一个窗口类,其中使用typedef来重命名,其作用等同于:
1 | class Screen { |
Screen类成员函数
1 | class screen { |
这里第二个构造函数只接受了三个参数,所以另一个成员采用类内初始值的方式初始化。
令成员作为内联函数
类内的函数是固定为内联函数的,当类外函数需要作为类内成员时,可以加上inline声明成内联函数。
其可以在类内声明(不推荐,因为类内函数就是内联函数),也可以在类外声明,但最好只在类外声明。
重载成员函数
成员函数与非成员函数都可以被重载,使用时根据参数数量来决定用哪种函数。
1 | screen myscreen; |
可变数据成员
如何希望一个变量无论什么情况都可以被改变。可以在变量声明时加入mutable关键字。即使他是const对象的成员,或通过const函数赋值,都可以被改变。
类数据成员初始值
1 | class window_mgr { |
当初始化类类型成员,可以使用列表初始化的方式,类内初始值必须以=或者{}表示。
返回*this的成员函数
1 | class Screen{ |
set函数返回值是调用set的对象的引用,可以作为左值:
1 | //把光标移动到一个指定的位置,然后设置该位置的字符值 |
若返回值不是引用,则:
1 | //如果move返回Screen而非screen& |
const成员函数返回*this
若为前面的类定义一个display操作,因为打印不需要改变类中的成员,所以令display为一个const成员,所以*this是一个const对象。返回值是一个const对象的引用,所以:
1 | screen myScreen; |
基于const的重载
一个函数可以重载为const和非const,分别用在常量对象,和非常量对象的调用:
1 | class screen { |
其中do_display是一个公共代码,他的好处为:
- 一个基本的愿望是避免在多处使用同样的代码。
- 我们预期随着类的规模发展,display函数有可能变得更加复杂。
- 我们很可能在开发过程中给do_display函数添加某些调试信息,而这些信息将在代码的最终产品版本中去掉。显然,只在 do_display一处添加或删除这些信息要更容易一些。
- 这个额外的函数调用不会增加任何开销。因为我们在类内部定义了do_display,所以它隐式地被声明成内联函数。
类类型
每一个类都是唯一的,即使他们的成员完全一样,所以他们也不可以互相赋值。
类的声明
类的声明可以只声明不定义,也被称为向前声明,在未定义前他是一个不完全类型,不完全类型只能在非常有限的情景下使用:
- 可以定义指向这种类型的指针或引用,
- 可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
创建它的对象之前他必须被定义过!
友元再探
类之间的友元
如果想在A类的成员函数内可以控制另一个B类的成员,可以将A类在B类中声明称友元,如:
1 | class B{ |
则A类的所有成员函数都可以访问B类的私有成员。
注意:友元关系没有传递性,若A中声明了友元C类,C只可以访问A,而不能访问B。
成员函数作为友元
若不需要将整个类作为友元,则可以只为一个函数声明友元,且必须明确属于哪个类。且应注意一定的顺序:
- 定义A类,并声明其中的需要改变B类成员的函数(简称C函数吧),但不要定义。
- 定义B类,声明友元函数C。
- 最后定义C函数。
函数重载和友元
若一个函数名存在多个重载,则友元函数需要声明多个,且他们是一一对应的。
友元声明和作用域
友元声明不是必须在类或者函数之后,但无论如何一定要在类外声明一次。
1 | struct X{ |
根本还是因为友元只是开通了某个人进入这个地方的权限,而这个人需要被承认是一个人。才能使用该权限。
类的作用域
类有自己的作用域,在类外必须由对象、引用或指针使用成员。
1 | screen::pos ht = 24, wd = 80 ; //使用screen定义的pos类型 |
类外的成员函数,因为在类外,所以并不知道类内的成员,所以必须加上类名,包括返回值如果是类内的成员也必须加上类名。
名字查找与类的作用域
名字查找的大致过程为:
- 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
- 如果没找到,继续查找外层作用域。
- 如果最终没有找到匹配的声明,则程序报错。
对于定义在类内部的成员函数:
- 首先,编译成员的声明。
- 直到类全部可见后才编译函数体。
类成员声明的名字查找
声明过程中使用的名字必须在使用前确保可见,如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在类外中继续查找。
1 | typedef double Money; |
类型名要特殊处理
类内可以重新为一个类型定义名字,但如果已经使用过了,就不能在定义它了:
1 | typedef double Money; |
成员函数使用名字解析
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
如果成员函数参数名字与类成员名字冲突,那么使用类内的成员最好写成类名::的形式,更加清楚。当然,更好的办法是另起一个名字。
构造函数再探
构造函数初始值列表
定义变量时最好立即对其进行初始化,如没有初始化,则会执行默认初始化。
初始值有时必不可少
有时遇到无法默认初始化的类型、常量或者引用,则必须添加初始值。
初始化顺序
初始化顺序不是按参数的顺序,而是按照在类内声明的顺序。
建议:构造函数初始化顺序与成员声明最好一致,且不用成员去初始化成员
默认实参和构造函数
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
如果你希望用户给出一个非0实参的同时给处其他的实参,则建议不要给他形参添加默认值。例如图书管理程序,用户提供一本书的名字时,你需要他同时提供书的价格、序列号等,就不应该给形参设置默认值,这样用户就必须输入图书全部信息。
委托构造函数
1 | class Sales_data { |
它也有成员初始值列表和一个函数体,参数列表须与委托的构造函数匹配。最后一个构造函数委托的是默认构造函数,默认构造执行后,执行read()函数。
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在 sales data类中,受委托的构造函数体恰好是空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。
默认构造函数的作用
默认初始化在以下情况发生
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时。
值初始化在以下情况发生
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
- 当我们不使用初始值定义一个局部静态变量时。
- 当我们通过书写形如T( )的表达式显式地请求值初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。
一个常犯的错误
对于C++的新手程序员来说有一种常犯的错误,它们试图以如下的形式声明-一
个用默认构造函数初始化的对象:
1 | Sales_ data obj() ; //错误:声明了一个函数而非对象 |
隐式的类类型转换
如果构造函数接受一个实参,那么实际上也定义了隐式转换的机制,例如:A类中有一个构造函数只接受一个string类型的参数,那么在需要A类的地方,我们可以由string去代替,编译器会自动的将string转换为A。
只允许一步类类型的转换
如果直接把一个常量字符串用在A类的地方,需要先转换成string,再转换为A,所以是错误的。可以先显示的转化为string,如:string("999")
,再放到需要A的地方。
这种转换取决于用户对使用它的看法,并不总是有效。
抑制构造函数隐式转换
在构造函数前加上explicit用来阻止隐式转换的发生,它只对有一个参数的函数有效:
1 | class Sales_data{ |
explicit构造只用于直接初始化
1 | string null_book = "999"; |
使用该关键字后不可用于拷贝。
显示转换构造函数
explicit函数会阻止隐式的转换,但是我们依然可以用该函数显示的进行转换:
1 | //正确:实参是一个显式构造的Sales_ data对象 |
标准库显式的构造函数的类:
我们用过的-.些标准库中的类含有单参数的构造函数:
- 接受一个单参数的const char*的string构造函数不是explicit的。
- 接受一个容量参数的vector构造函数(参见3.3.1节,第87页)是explicit的。
聚合类
满足下列条件,可以说它是一个聚合类:
- 所有成员都是public的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有virtual函数。
如:
1 | struct Data{ |
聚合类的显示初始化方法:
1 | // val1. ival= 0; val1.s = string ( "Anna" ) |
显示初始化的缺点:
- 要求类的所有成员都是public的。
- 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供–个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
- 添加或删除-一个成员之后,所有的初始化语句都需要更新。
字面值常量类
数据成员都是字面值类型的聚合类就是字面值常量类。不是聚合但符合下列要求也是:
数据成员都必须是字面值类型。
类必须至少含有一个constexpr构造函数。
如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
类必须使用析构函数的默认定义,该成员负责销毁类的对象。
对于条件的理解:
满足条件1,就可以在编译阶段求值,这一点和聚合类一样。
满足条件2,就可以创建这个类的constexpr类型的对象。
满足条件3,就可以保证即使有类内初始化,也可以在编译阶段解决。
满足条件4,就可以保证析构函数没有不能预期的操作。
constexpr构造函数
构造函数不能是const的,但字面值常量类的构造函数可以是constexpr的,且必须至少有一个constexpr构造函数。
constexpr构造函数函数体一般来说是空的:
1 | class Debug { |
这样声明以后,就可以在使用constexpr表达式或者constexpr函数的地方使用字面值常量类了。