C++ Primer 对象和类
C++中的类
类设计尽可能将公有接口与实现细节分开。公有接口表示设计的抽象组件。将实现细节放在一起并将它们与抽象分开被称为封装。数据隐藏(将数据放在类的私有部分中)是一种封装,将实现的细节隐藏在私有部分中,就像Stock类对set tot() 所做的那样,也是一种封装。封装的另一个例子是,将类函数定义和类声明放在不同的文件中。
控制对成员的访问:公有还是私有
无论类成员是数据成员还是成员函数,都可以在类的公有部分或私有部分中声明它。但由于隐藏数据是OOP主要的目标之一,因此数据项通常放在私有部分,组成类接口的成员函数放在公有部分:否则,就无法从程序中调用这些函数。也可以把成员函数放在私有部分。不能直接从程序中调用这种函数,但公有方法却可以使用它们。通常,程序员使用私有成员函数来处理不属于公有接口的实现细节。
不必在类声明中使用关键字 private, 因为这是类对象的默认访问控制:
为强调数据隐藏的概念,显式使用private。
类和结构
类描述看上去很像是包含成员函数以及public和private可见性标签的结构声明。实际上,C++对结构进行了扩展,使之具有与类相同的特性 它们之间惟一的区别是,结构的默认访问类型是public, 而类为private C++程序员通常使用类来实现类描述,而把结构限制为只表示纯粹的数据对象或没有私有部分的类。
实现类成员函数
成员函数定义与常规丽数定义非常相似,它们有函数头和函数体,也可以有返回类型和参数。但是它们还有两个特殊的特征:
- 定义成员函数时,使用作用域解析操作符(::) 来标识函数所属的类。
- 类方法可以访问类的private组件。
如:
类方法的完整名称中包括类名。我们说,Stock::update() 是函数的限定名(qualfied name);而简单的update()是全名的缩写(非限定名,unqualified name), 它只能在类作用域中使用。
这些方法定义可以放在单独一个文件中,也可以位于类声明所在的文件中。
内联方法
其定义位于类声明中的函数都将自动成为内联函数。如果愿意,也可以在类声明之外定义成员函数,并使其成为内联函数。为此只需要在类实现部分中定义函数时使用inline限定符即可。
内联函数的特殊规则要求在每个使用它们的文件中都对其进行定义。确保内联定义对多文件程序中的所有文件都可用的、最简便的方法是:将内联定义放在定义类的头文件中(有些开发系统包含智能链接程序,允许将内联定义放在一个独立的实现文件)。
方法与对象
所创建的每个新对象都有自己的存储空间,用于存储其内部变量和类成员;但同一个类的所有对象共享同一组类方法,即每种方法只有一个副本。例如,假设kate和joe都是Stock对象,则kate.shares将占据一个内存块,而je.shares 占用另一一个内存块, 但kate.show()和joe.show()都调用同一个方法,也就是说,它们将执行同一个代码块,只是将这些代码用于不同的数据。在OOP中,调用成员函数被称为发送消息,因此将同样的消息发送给2个不同的对象将调用同一个方法,但该方法被用于2个不同的对象。
类的构造函数和析构函数
C++提供了一个特殊的成员函数—类构造函数,专门用于构造新对象、将值赋给它们的数据成员。更准确地说,C++为这些成员函数提供了名称和使用方法,而程序员需要提供方法定义。构造函数的原型和函数头有一个有趣的特征—虽然没有返回值,但没有被声明为void类型。实际上,构造函数没有声明类型。
声明和定义构造函数
原型如下:
构造函数的一种可能定义:
程序声明对象时,将自动调用构造函数。
参数名不能与类成员相同,为避免混乱,通常的做法是在数据成员名中使用m_前缀:
使用构造函数
C++提供了两种使用构造函数来初始化对象的方式。第一种方式是显式地调用构造函数:
另一种方式是隐式地调用构造函数:
它与下面的显式调用等价:
每次创建类对象(甚至使用new动态分配内存)时,C++都将使用类构造函数。下面是将构造函数与new 起使用的方法:
这条语句创建一个Stock对象,将其初始化为参数提供的值,并将该对象的地址赋给pstock指针。在这种情况下,对象没有名称,但可以使用指针来管理该对象。
无法使用对象来调用构造函数,因为在构造函数构造出对象之前,对象是不存在的。因此构造函数被用来创建对象, 而不能通过对象来调用。
默认构造函数
默认构造函数是在未提供显式的初始化值时,被用来创建对象的构造函数。
如果没有提供任何构造函数,则C++将自动提供默认构造函数。它是默认构造函数的隐式版本,不做任何工作。对于Stock类来说,默认构造函数可能如下:
创建 stock 对象,但不初始化其成员,这和下面的语句
创建x,但没有提供值给它一样。默认构造函数没有参数,因为声明中不包含值。
当且仅当没有定义任何构造函数时,编译器才会提供默认构造函数。为类定义了构造函数后,程序员就必须为它提供默认构造函数。如果提供了非默认构造函数但没有提供默认构造函数,则将出错。如果要创建对象,而不显式地初始化, 则必须定义一个不接受任何参数的默认构造函数。
定义默认构造函数的方式有两种。一种是给已有构造函数的所有参数提供默认值:
另一种方式是通过函数重载来定义另一个构造函数一一一个没有参数的构造函数:
由于只能有个默认构造函数, 因此不要同时采用这两种方式。
在设计类时,通常应提供对所有类成员做隐式初始化的默认构造函数。
使用上述任何一种方式(没有参数或所有参数都有默认值)创建了默认构造函数后,便可以声明对象变量,而不对它们进行显式初始化:
隐式地调用默认构造函数时,不要使用圆括号。
析构函数
用构造函数创建对象后,程序负责跟踪该对象,直到其过期为止。对象过期时,程序将自动调用一个特殊的成员函数,该函数的名称令人生畏一析构函数。析构函数完成清理工作,因此实际上很有用。如,如果构造函数使用new 来分配内存,则析构函数将使用delete来释放这些内存。如果构造函数没有使用new,析构函数实际上没有需要完成的任务。此时,只需让编译器生成一个什么都不用做的隐式析构函数。
和构造函数一样,析构函数的名称也很特殊:在类名前加上(~)。因此,Stock类的析构函数为~Stock().另外, 和构造函数一样,析构函数也可以没有返回值和声明类型。与构造函数不同的是,析构函数没有参数,因此析构函数的原型必须是这样的:
什么时候调用析构函数由编译器决定,通常不应在代码中显式地调用析构函数。如果创建的是静态存储类对象,则其析构函数将在程序结束时自动被调用。如果创建的是自动存储类对象,则其析构函数将在程序执行完代码块时(该对象是在其中定义的)自动被调用。如果对象是通过new创建的,则它将驻留在堆栈内存或自由存储区中,当使用delete来释放内存时,其析构函数将自动被调用。最后,程序可以创建临时对象来完成特定的操作,在这种情况下,程序将在结束对该对象的使用时自动调用其析构函数。
由于在类对象过期时析构函数将自动被调用,因此必须有- 个析构函数。如果程序员没有提供析构函数,编译器将隐式地声明一个默认析构函数,并在发现导致对象破删除的代码后,提供默认析构函数的定义。
const成员函数
对于当前的C++来说,编译器将拒绝第二行。因为show()的代码无法确保调用对象不被修改一调用对象和const一样,不应被修改。一种新的句法—保证函数不会修改调用对象。C++的解决方法是将const关键字放在函数的括号后面。
show()声明:
函数定义的开头:
以这种方式声明和定义的类函数被称为const成员函数。就像应尽可能将const引用和指针用作函数形参一样, 只要类方法不修改调用对象,就应将其声明为const.从现在开始,我们将遵守这一规则。
新特性
接收一个参数的构造函数允许使用赋值句法将对象初始化为一个值。
this指针
每个成员函数(包括构造函数和析构函数)都有一个this 指针。this 指针指向调用对象。this是对象的地址,如果方法需要引用整个调用对象,则可以使用表达式 * this。在函数的括号后面使用const 限定符将this限定为const,这样将不能使用this 来修改对象的值。
对象数组
声明对象数组的方法与声明标准类型数组相同:
当程序创建未被现实初始化的类对象时,总是调用默认构造函数。此时要么是没有显示地定义任何构造函数,将使用不执行任何操作的隐式默认构造函数。要么定义了一个显式默认构造函数。每个元素都是Stock对象可以使用Stock方法。
可以用构造函数来初始化数组元素。此时,必须为每个元素调用构造函数。
初始化对象数组的方案是,首先使用默认构造函数创建数组元素,然后花括号中的构造函数将创建临时对象,然后将临时对象的内容复制到相应的元素中。因此,要创建类对象数组,则这个类必须有默认构造函数。
类作用域
在类中定义的名称(如类数据成员名和类成员函数名)的作用域都为整个类,作用域为整个类的名称只在该类中是已知的,在类外是不可知的。因此,可以在不同类中使用相同的类成员名而不会引起冲突。另外,类作用域意味着不能从外部直接访问类的成员,公有成员函数也是如此。也就是说,要调用公有成员函数,必须通过对象。在定义成员函数时,必须使用作用域解析操作符。
作用域为整个类的常量
使用关键字static,在类中定义常量。该常量将与其他静态变量存储在一起,而不是存储在对象中。只能使用这种结束声明值为整数或枚举的静态常量,而不能存储double常量。
使用类
操作符重载
要重载操作符,需使用被称为操作符函数的特殊函数形式。操作符函数的格式如下:
op是将要重载的操作符。operator+()重载+操作符,op必须是有效的C++操作符,不能虚构一个新的符号。例如,不能有operator@()这样的函数,因为C++中没有@操作符。但是,operator函数将重载[]操作符,因为【】是数组索引操作符。
友元简介
友元有3种:
- 友元函数
- 友元类
- 友元成员函数
对于重载操作符,左侧的操作数是调用对象。
其中要使用重载运算符就只能将B写在左侧。使用非成员函数,大多数操作符都可以通过成员或非成员函数来重载。非成员函数不是由对象调用的,它使用的所有值都是显示参数。这样编译器能够将表达式中B写到右侧。有一类特殊的非成员函数可以访问类的私有成员,为友元函数。
创建友元
创建友元函数的第一步,是将其原型放在类声明中,并在原型声明前加上关键字friend。
该原型意味着两点:
- 虽然operator * ()函数是在类声明中声明的,但它不是成员函数,因此不能使用成员函数操作符来调用。
- 虽然operator * ()函数不是成员函数,但它与成员函数的访问权限相同。
第二步是编写函数定义。因为它不是成员函数,所以不要使用Time::限定符。另外,不要再定义中使用关键字friend,定义如下:
简而言之,类的友元函数是非成员函数,其访问权限与成员函数相同。
友元是否有悖于OOP
只有类声明可以决定哪一个函数是友元,因此类声明仍然控制了哪些函数可以访问私有数据。简而言之,类方法和友元只是表达类接口的两种不同机制。
常用的友元:重载<<操作符
重载操作符:作为成员函数还是非成员函数
对于很多操作符来说,可以选择使用成员函数或非成员函数来实现操作符重载。一般来说,非成员函数应是友元函数, 这样它才能直接访问类的私有数据。例如,Time 类的加法操作符在Time类声明中的原型如下:
加法操作符需要两个操作数。对于成员函数版本来说,一个操作数通过this指针隐式地传递,另一个操作数作为函数参数显式地传递;对于友元版本来说,两个操作数都作为参数来传递。
非成员版本的重载操作符函数所需的形参数目与操作符使用的操作数数目相同;而成员版本所需的参数数目少一个,因为其中的一个操作数是被隐式地传递的调用对象。
类继承
一个简单的基类
从一个类派生出另一个类,原始类称为基类,继承类称为派生类。
冒号指出RatedPlayer类的基类是TableTennisplayer 类。上述特殊的声明头表明TableTennisPlayer 是一个公有基类,这被称为公有派生。派生类对象包含基类对象。使用公有派生,基类的公有成员将成为派生类的公有成员; 基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。
构造函数:访问权限的考虑
派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。具体地说,派生类构造函数必须使用基类构造函数。
创建派生类对象时,程序首先创建基类对象。从概念上说,这意味着基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员初始化列表句法来完成这种工作。RatedPlayer构造函数代码:
其中TableTennisPlayer(fn,ln,ht)是成员初始化列表,它调用TableTennisPlayer构造函数。
派生类构造函数要点:
- 基类对象首先被创建。
- 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数。
- 派生类构造函数应初始化派生类新增的数据成员。
如果没有提供显示构造函数,因此将使用隐式构造函数。释放对象的顺序与创建对象的顺序相反,即首先执行派生类的析构函数,然后自动调用基类的析构函数。
成员初始化列表
使用派生类
要使用派生类,程序必须要能够访问基类声明。可以将这两种类的声明置于同一个头文件中。也可以将每个类放在独立的头文件中,但由于这两个类是相关的,所以把其类声明放在一起更合适。
派生类和基类之间的特殊关系
派生类对象可以使用基类的方法,条件是方法不是私有的。
基类指针可以在不进行显式类型转换的情况下指向派生类对象。
基类引用可以在不进行显式类型转换的情况下引用派生类对象。
不过,基类指针或引用只能用户调用基类方法,而不能调用派生类的方法。通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外。不过,这种例外只是单向的,不可以将基类对象和地址赋给派生类引用和指针。
继承——is-a关系
派生类和基类之间的特殊关系是基于C++继承的底层模型。C++有3种继承方式:公有继承、保护继承和私有继承。公有继承是最常用的方式,它建立一种is-a关系,即派生类对象也是一个基类对象,可以对基类对象执行任何操作,也可以对派生类对象执行。
多态公有继承
希望同一个方法在派生类和基类中的行为是不同的,有两种重要的机制可用于实现多态公有继承:
- 在派生类中重新定义基类方法。
- 使用虚方法。
在某基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数,实现多态性,使用关键字virtual来声明虚方法。virtual 函数返回类型 函数名(参数表) {函数体};
静态联编和动态联编
将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在C++中,由于函数重载的缘故,这项任务更复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。C/C++编译器可以再编译过程完成这种联编。在编译过程中进行联编被称为静态联编(static binding)又称为早期联编。不过虚函数使这项工作变得更困难,使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)。
为什么有两种类型的联编以及为什么默认为静态联编
如果动态联编让您能够重新定义类方法,而静态联编在这方面很差; 为何不摒弃静态联编呢?原因有两个—效率和概念模型。
效率:为使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销。如果类不会用作基类,则不需要动态联编。如果派生类不重新定义基类的任何方法,也不需要使用动态联编。这时,使用静态联编更合理,效率也更高。由于静态联编的效率更高,因此被设置为C++的默认选择。概念模型,在设计类时,可能包含一些不在派生类重新定义的成员函数。不该将函数设置为虚函数,有两方面的好处:首先效率更高;其次指出不要重新定义该函数。
虚函数的工作原理
编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(vtbl)。虚函数表中存储了为类对象进行声明的虚函数地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。无论类中包含的虚函数是一个还是10个,都只需要在对象中添加一个地址成员,只是表的大小不同而已。
调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。
简而言之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:
- 每个对象都将增大,增大量为存储地址的空间。
- 对每个类,编译器都创建一个虚函数地址表(数组)。
- 每个函数调用都需要执行一步额外的操作,即到表中查找地址。
虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。
有关虚函数的注意事项
虚函数的要点:
- 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚拟的。
- 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
- 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚拟的。
对于虚函数还有。
1.构造函数,构造函数不能是虚函数。
2.析构函数,析构函数应当是虚函数,除非类不用做基类。
如
如果使用默认的静态联编,delete 语句将调用~Employee()析构函数。这将释放由Singer 对象中的Employee部分指向的内存,但不会释放新的类成员指向的内存。但如果析构函数是虚拟的,则上述代码将先调用~Singer析构函数释放由Singer 组件指向的内存,然后,调用~Employee() 析构函数来释放由Employee组件指向的内存。
通常应给基类提供一个虚拟析构函数,即使它并不需要析构函数。
3.友元,友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数
访问控制:protected
关键字 protected与 private相似,在类外只能用公有类成员来访问protected部分中的类成员。private和 protected之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说,保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。