C++ 中,通过定义类来自定义数据类型。类定义了该类型的对象包含的数据和该类型的对象可以执行的操作。标准库类型 string、istream 和 ostream 都定义成类。
类定义以关键字 class 或 struct 开始,其后是该类的名字标识符。类体位于花括号里面。花括号后面必须要跟一个分号。
1 | class Sales_item { |
或者
1 | struct Sales_item { |
如果使用 class 关键字来定义类,那么定义在第一个访问标号前的任何成员都隐式指定为 private;如果使用 struct 关键字,那么这些成员都是public。使用 class 还是 struct 关键字来定义类,仅仅影响默认的初始访问级别。
每个类可以没有成员,也可以定义多个成员,成员可以是数据、函数或类型别名。
一个类可以包含若干公有的、私有的和受保护的部分。
派生类只能通过派生类对象访问其基类的 protected 成员,派生类对其基类类型对象的 protected 成员没有特殊访问权限。(注意前者是基类,后者是基类的对象)
所有成员必须在类的内部声明,一旦类定义完成后,就没有任何方式可以增加成员了。
成员函数的定义与普通函数的定义类似。和任何函数一样,成员函数也包含下面四个部分:
正如我们知道的,前面三部分组成函数原型。函数原型定义了所有和函数相关的类型信息:函数返回类型是什么、函数的名字、应该给这个函数传递什么类型的实参。函数原型必须在类中定义。但是,函数体则既可以在类中也可以在类外定义。
1 | class Sales_item { |
类的成员函数既可以在类的定义内也可以在类的定义外定义。
在类内部,声明成员函数是必需的,而定义成员函数则是可选的。在类内部定义的函数默认为 inline。
在类 Sales_item
中,函数 same_isbn
就是在在类内定义的。
函数 avg_price
则在类内声明,在类外定义。编译器隐式地将在类内定义的成员函数当作内联函数。
在类的定义外面定义成员函数必须指明它们是类的成员:
1 | double Sales_item::avg_price() const |
上述定义和其他函数一样:该函数返回类型为 double,在函数名后面的圆括号起了一个空的形参表。新的内容则包括跟在形参表后面的 const 和函数名的形式。函数名:
1 | Sales_item::avg_price |
使用作用域操作符指明函数 avg_price
是在类 Sales_item
的作用域范围内定义的。
每个成员函数(除了 static 成员函数外)都有一个额外的、隐含的形参 this。在调用成员函数时,形参 this 初始化为调用函数的对象的地址。
在成员函数中,不必显式地使用 this 指针来访问被调用函数所属对象的成员。 对这个类的成员的任何没有前缀的引用,都被假定为通过指针 this 实现的引用:
1 | bool same_isbn(const Sales_item &rhs) const |
在这个函数中 isbn 的用法与 this->units_sold
或 this->revenue
的用法一样。
由于 this 指针是隐式定义的,因此不需要在函数的形参表中包含 this 指针,实际上,这样做也是非法的。但是,在函数体中可以显式地使用 this 指针 。如下定义函数 same_isbn
尽管没有必要,但是却是合法的:
1 | bool same_isbn(const Sales_item &rhs) const |
尽管在成员函数内部显式引用 this 通常是不必要的,但有一种情况下必须这样做:当我们需要将一个对象作为整体引用而不是引用对象的一个成员时。最常见的情况是在这样的函数中使用 this:该函数返回对调用该函数的对象的引用。
某种类可能具有某些操作,这些操作应该返回引用,Screen 类就是这样的一个类。迄今为止,我们的类只有一对 get 操作。逻辑上,我们可以添加下面的操作。
理想情况下,希望用户能够将这些操作的序列连接成一个单独的表达式:
1 | // move cursor to given position, and set that character |
这个语句等价于:
1 | myScreen.move(4,0); |
在单个表达式中调用 move 和 set 操作时,每个操作必须返回一个引用,该引用指向执行操作的那个对象:
1 | class Screen { |
注意,这些函数的返回类型是 Screen&
,指明该成员函数返回对其自身类类型的对象的引用。每个函数都返回调用自己的那个对象。使用 this 指针来访问该对象。下面是对两个新成员的实现:
1 | Screen& Screen::set(char c) |
在这两个操作中,每个函数都返回 *this
。在这些函数中,this 是一个指向非常量 Screen 的指针。如同任意的指针一样,可以通过对 this 指针解引用来访问 this 指向的对象。
将关键字 const 加在形参表之后,就可以将成员函数声明为常量:
1 | double avg_price() const; |
对应《高质量C++/C编程指南》 11.1.3节 const成员函数) ↩︎
有时(但不是很经常),我们希望类的数据成员(甚至在 const 成员函数内)可以修改。这可以通过将它们声明为 mutable 来实现。
可变数据成员(mutable data member)永远都不能为 const ,甚至当它是 const 对象的成员时也如此。因此,const 成员函数可以改变 mutable 成员。要将数据成员声明为可变的,必须将关键字 mutable 放在成员声明之前:
1 | class Screen { |
我们给 Screen 添加了一个新的可变数据成员 access_ctr。使用 access_ctr 来跟踪调用 Screen 成员函数的频繁程度:
1 | void Screen::do_display(std::ostream& os) const |
尽管 do_display 是 const,它也可以增加 access_ctr。该成员是可变成员,所以,任意成员函数,包括 const 函数,都可以改变 access_ctr 的值。
在定义类时没有初始化它的数据成员,而是通过构造函数来初始化其数据成员。
构造函数是特殊的成员函数,与其他成员函数不同,构造函数和类同名,而且没有返回类型。而与其他成员函数相同的是,构造函数也有形参表(可能为空)和函数体。一个类可以有多个构造函数,每个构造函数必须有与其他构造函数不同数目或类型的形参。
构造函数的形参指定了创建类类型对象时使用的初始化式。通常,这些初始化式会用于初始化新创建对象的数据成员。构造函数通常应确保其每个数据成员都完成了初始化。
Sales_item
类只需要显式定义一个构造函数:没有形参的默认构造函数。默认构造函数说明当定义对象却没有为它提供(显式的)初始化式时应该怎么办:
1 | vector<int> vi; // default constructor: empty vector |
我们知道 string 和 vector 类默认构造函数的行为:这些构造函数会将对象初始化为合理的默认状态。string 的默认构造函数会产生空字符串上,相当于 “”。vector 的默认构造函数则生成一个没有元素的 vector 向量对象。
同样地,我们希望类 Sales_items
的默认构造函数为它生成一个空的 Sales_item
对象。这里的“空”意味着对象中的 isbn 是空字符串,units_sold
和 revenue
则初始化为 0。
和其他成员函数一样,构造函数也必须在类中声明,但是可以在类中或类外定义。由于我们的构造函数很简单,因此在类中定义它:
1 | class Sales_item { |
在解释任何构造函数的定义之前,注意到构造函数是放在类的 public 部分的。通常构造函数会作为类的接口的一部分,这个例子也是这样。毕竟,我们希望使用类 Sales_item
的代码可以定义和初始化类 Sales_item
的对象。如果将构造函数定义为 private 的,则不能定义类 Sales_item
的对象,这样的话,这个类就没有什么用了。
对于定义本身:
1 | // default constructor needed to initialize members of built-in type |
上述语句说明现在正在定义类 Sales_item
的构造函数,这个构造函数的形参表和函数体都为空。令人感兴趣的是冒号和冒号与定义(空)函数体的花括号之间的代码。见下面的解释。
在冒号和花括号之间的代码称为构造函数的初始化列表 。构造函数的初始化列表 为类的一个或多个数据成员指定初值。它跟在构造函数的形参表之后,以冒号开关。构造函数的初始化式是一系列成员名,每个成员后面是括在圆括号中的初始值。多个成员的初始化用逗号分隔 [1]。
上述例题的初始化列表表明 units_sold
和 revenue
成员都应初始化为 0。每当创建 Sales_item
对象时,它的这两个成员都以初值 0 出现。而 isbn 成员可以不必准确指明其初值。除非在初始化列表中有其他表述,否则具有类类型的成员皆被其默认构造函数自动初始化。于是,isbn 由 string 类的默认构造函数初始化为空串。当然,如果有必要的话,也可以在初始化列表中指明 isbn 的默认初值。
有些成员必须在构造函数初始化列表中进行初始化。对于这样的成员,在构造函数函数体中对它们赋值不起作用。没有默认构造函数的类类型的成员,以及 const 或引用类型的成员,不管是哪种类型,都必须在构造函数初始化列表中进行初始化。
再来看看默认构造函数和接受一个 string 的构造函数的定义:
1 | Sales_item(): units_sold(0), revenue(0.0) { } |
这两个构造函数几乎是相同的:唯一的区别在于,接受一个 string 形参的构造函数使用该形参来初始化 isbn,而默认构造函数(隐式地)使用 string 的默认构造函数来初始化 isbn。
由编译器创建的默认构造函数通常称为默认构造函数,它将依据如同变量初始化的规则初始化类中所有成员。对于具有类类型的成员,如 isbn,则会调用该成员所属类自身的默认构造函数实现初始化。内置类型成员的初值依赖于对象如何定义。如果对象在全局作用域中定义(即不在任何函数中)或定义为静态局部对象,则这些成员将被初始化为 0。如果对象在局部作用域中定义,则这些成员没有初始化。除了给它们赋值之外,出于其他任何目的对未初始化成员的使用都没有定义。
合成的默认构造函数 一般适用于仅包含类类型成员的类 。而对于 含有内置类型或复合类型成员 的类,则通常应该定义他们自己的默认构造函数初始化这些成员。由于合成的默认构造函数不会自动初始化内置类型的成员,所以必须明确定义 Sales_item
类的默认构造函数。
在某些情况下,默认构造函数是由编译器隐式应用的。如果类没有默认构造函数,则该类就不能用在这些环境中。为了例示需要默认构造函数的情况,假定有一个 NoDefault 类,它没有定义自己的默认构造函数,却有一个接受一个string 实参的构造函数。因为该类定义了一个构造函数,因此编译器将不合成默认构造函数。NoDefault 没有默认构造函数,意味着:
使用默认构造函数定义一个对象的正确方式:
1 | Sales_item myobj; |
下面的代码是错误的,它实际上声明了一个函数,而不是定义一个对象:
1 | Sales_item myobj(); |
另一方面,下面这段代码也是正确的:
1 | // ok: create an unnamed, empty Sales_item and use to initialize myobj |
在这里,我们创建并初始化一个 Sales_item 对象,然后用它来按值初始化myobj。编译器通过运行 Sales_item 的默认构造函数来按值初始化一个Sales_item。
1 | class Sales_item { |
explicit 关键字只能用于类内部的构造函数声明上。在类的定义体外部所做的定义上不再重复它:
1 | // error: explicit allowed only on constructor declaration in class header |
只要显式地按下面这样做,就可以用显式的构造函数来生成转换:
1 | string null_book = "9-999-99999-9"; |
对于没有定义构造函数并且其全体数据成员均为 public 的类,可以采用与初始化数组元素相同的方式初始化其成员:
1 | struct Data { |
这种形式的初始化从 C 继承而来,支持与 C 程序兼容。显式初始化类类型对象的成员有三个重大的缺点。
复制构造函数是一种特殊构造函数,具有单个形参,该形参(常用 const 修饰)是对该类类型的引用。当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用复制构造函数。当将该类型的对象传递给函数或函数返回该类型的对象时,将隐式使用复制构造函数。与默认构造函数一样,复制构造函数可由编译器隐式调用。复制构造函数可用于:
如果我们没有定义复制构造函数,编译器就会为我们合成一个。与合成的默认构造函数不同,即使我们定义了其他构造函数,也会合成复制构造函数。
合成复制构造函数的行为是,执行逐个成员(非static成员)初始化,将新对象初始化为原对象的副本。
每个成员的类型决定了复制该成员的含义。合成复制构造函数直接复制内置类型成员的值,类类型成员使用该类的复制构造函数进行复制。数组成员的复制是个例外。虽然一般不能复制数组,但如果一个类具有数组成员,则合成复制构造函数将复制数组。复制数组时合成复制构造函数将复制数组的每一个元素。
复制构造函数就是接受单个类类型引用形参(通常用 const 修饰)的构造函数:
1 | class Foo { |
虽然也可以定义接受非 const 引用的复制构造函数,但形参通常是一个 const 引用。因为用于向函数传递对象和从函数返回对象,该构造函数一般不应设置为 explicit 。复制构造函数应将实参的成员复制到正在 构造的对象。
通常,定义复制构造函数最困难的部分在于认识到需要复制构造函数。只要能认识到需要复制构造函数,定义构造函数一般非常简单。复制构造函数的定义与其他构造函数一样:它与类同名,没有返回值,可以(而且应该)使用构造函数初始化列表初始化新创建对象的成员,可以在函数体中做任何其他必要工作。
有些类需要完全禁止复制。例如,iostream 类就不允许复制。如果想要禁止复制,似乎可以省略复制构造函数,然而,如果不定义复制构造函数,编译器将合成一个。
方便起见,我们可以使用 DISALLOW_COPY_AND_ASSIGN 宏:
1 | // 禁止使用拷贝构造函数和 operator= 赋值操作的宏 |
在 class foo: 中:
1 | class Foo { |
析构函数是构造函数的互补:当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。析构函数可用于释放对象时构造或在对象的生命期中所获取的资源。不管类是否定义了自己的析构函数,编译器都自动执行类中非 static 数据成员的析构函数。
撤销类对象时会自动调用析构函数。撤销一个容器(不管是标准库容器还是内置数组)时,也会运行容器中的类类型元素的析构函数。
1 | // p points to default constructed object |
变量(如 item )在超出作用域时应该自动撤销。因此,当遇到右花括号时,将运行 item 的析构函数。
动态分配的对象只有在指向该对象的指针被删除时才撤销。如果没有删除指向动态对象的指针,则不会运行该对象的析构函数,对象就一直存在,从而导致内存泄漏,而且,对象内部使用的任何资源也不会释放。
容器中的元素总是按逆序撤销:首先撤销下标为 size() - 1 的元素,然后是下标为 size() - 2 的元素…直到最后撤销下标为 [0] 的元素。
许多类不需要显式析构函数,尤其是具有构造函数的类不一定需要定义自己的析构函数。仅在有些工作需要析构函数完成时,才需要析构函数。析构函数通常用于释放在构造函数或在对象生命期内获取的资源。
与复制构造函数或赋值操作符不同,编译器总是会为我们合成一个析构函数。合成析构函数按对象创建时的逆序撤销每个非 static 成员,因此,它按成员在类中声明次序的逆序撤销成员。对于类类型的每个成员,合成析构函数调用该成员的析构函数来撤销对象。
在某些情况下,允许特定的非成员函数访问一个类的私有成员,同时仍然阻止一般的访问,这是很方便做到的。例如,被重载的操作符,如输入或输出操作符,经常需要访问类的私有数据成员。这些操作符不可能为类的成员。然而,尽管不是类的成员,它们仍是类的“接口的组成部分”。
友元机制允许一个类将对其非公有成员的访问权授予指定的函数或类。友元的声明以关键字 friend 开始。它只能出现在类定义的内部。友元声明可以出现在类中的任何地方:友元不是授予友元关系的那个类的成员,所以它们不受声明出现部分的访问控制影响。
想像一下,除了 Screen 类之外,还有一个窗口管理器,管理给定显示器上的一组 Screen。窗口管理类在逻辑上可能需要访问由其管理的 Screen 对象的内部数据。假定 Window_Mgr 是该窗口管理类的名字,Screen 应该允许 Window_Mgr 像下面这样访问其成员:
1 | class Screen { |
Window_Mgr 的成员可以直接引用 Screen 的私有成员。例如,Window_Mgr 可以有一个函数来重定位一个 Screen:
1 | Window_Mgr& |
缺少友元声明时,这段代码将会出错:将不允许使用形参 s 的 height 和 width 成员。因为 Screen 将友元关系授予 Window_Mgr,所以,Window_Mgr 中的函数都可以访问 Screen 的所有成员。
友元可以是普通的非成员函数,或前面定义的其他类的成员函数,或整个类。将一个类设为友元,友元类的所有成员函数都可以访问授予友元关系的那个类的非公有成员。
如果不是将整个 Window_Mgr 类设为友元,Screen 就可以指定 只允许 relocate 成员访问 :
1 | class Screen { |
当我们将成员函数声明为友元时,函数名必须用该函数所属的类名字加以限定。
为了正确地构造类,需要注意友元声明与友元定义之间的互相依赖。在前面的例子中,类 Window_Mgr 必须先定义。否则,Screen 类就不能将一个 Window_Mgr 函数指定为友元。然而, 只有在定义类 Screen 之后,才能定义 relocate 函数 ——毕竟,它被设为友元是为了访问类 Screen 的成员。
更一般地讲, 必须先定义包含成员函数的类,才能将成员函数设为友元。另一方面,不必预先声明类和非成员函数来将它们设为友元。
友元声明将已命名的类或非成员函数引入到外围作用域中。此外,友元函数可以在类的内部定义,该函数的作用域扩展到包围该类定义的作用域。
类必须将重载函数集中每一个希望设为友元的函数都声明为友元:
1 | // overloaded storeOn functions |
类 Screen 将接受一个 ostream& 的 storeOn 版本设为自己的友元。接受一个 BitMap& 的版本对 Screen 没有特殊访问权。
通常,非 static 数据成员存在于类类型的每个对象中。不像普通的数据成员,static 数据成员独立于该类的任意对象而存在 ;每个 static 数据成员是与类关联的对象,并不与该类的对象相关联。
使用 static 成员而不是全局对象有三个优点。
在成员声明前加上关键字 static 将成员设为 static。static 成员遵循正常的公有/私有访问规则。
1 | class Account { |
可以通过作用域操作符从类直接调用 static 成员,或者通过对象、引用或指向该类类型对象的指针间接调用。
1 | Account ac1; |
像使用其他成员一样,类成员函数可以不用作用域操作符来引用类的 static 成员:
1 | class Account { |
Account 类有两个名为 rate 的 static 成员函数,其中一个定义在类的内部。当我们在类的外部定义 static 成员时,无须重复指定 static 保留字,该保留字只出现在类定义体内部的声明处:
1 | void Account::rate(double newRate) |
static 数据成员可以声明为任意类型,可以是常量、引用、数组、类类型,等等。
static 数据成员必须在类定义体的外部定义(正好一次)。不像普通数据成员,static 成员不是通过类构造函数进行初始化,而是应该在定义时进行初始化。
保证对象正好定义一次的最好办法,就是将 static 数据成员的定义放在包含类非内联成员函数定义的文件中。
定义 static 数据成员的方式与定义其他类成员和变量的方式相同:先指定类型名,接着是成员的完全限定名。
可以定义如下 interestRate:
1 | // define and initialize static class member |
这个语句定义名为 interestRate 的 static 对象,它是类 Account 的成员,为 double 型。像其他成员定义一样,一旦成员名出现,static 成员的就是在类作用域中。因此,我们可以没有限定地直接使用名为 initRate 的 static 成员函数,作为 interestRate 初始化式。注意,尽管 initRate 是私有的,我们仍然可以使用该函数来初始化 interestRate。像任意的其他成员定义一样,interestRate 的定义是在类的作用域中,因此可以访问该类的私有成员。
1 |
|
一般而言,类的 static 成员,像普通数据成员一样,不能在类的定义体中初始化。相反,static 数据成员通常在定义时才初始化。
这个规则的一个例外是,只要初始化式是一个常量表达式,整型 const static 数据成员就可以在类的定义体中进行初始化:
1 | class Account { |
用常量值初始化的整型 const static 数据成员是一个常量表达式。同样地,它可以用在任何需要常量表达式的地方,例如指定数组成员 daily_tbl 的维。
const static 数据成员在类的定义体中初始化时,该数据成员仍必须在类的定义体之外进行定义。
然而,在类外部提供初始化式时,成员的定义就不必再指定初始值了:
1 | // definition of static member with no initializer; |
普通成员都是给定类的每个对象的组成部分。 static 成员独立于任何对象而存在,不是类类型对象的组成部分 。因为 static 数据成员不是任何对象的组成部分,所以它们的使用方式对于非 static 数据成员而言是不合法的。 例如,static 数据成员的类型可以是该成员所属的类类型。非 static 成员被限定声明为其自身类对象的指针或引用:
1 | class Bar { |
类似地,static 数据成员可用作默认实参:
1 | class Screen { |
非 static 数据成员不能用作默认实参,因为它的值不能独立于所属的对象而使用。使用非 static 数据成员作默认实参,将无法提供对象以获取该成员的值,因而是错误的。
设计具有指针成员的类时,类设计者必须首先需要决定的是该指针应提供什么行为。将一个指针复制到另一个指针时,两个指针指向同一对象。当两个指针指向同一对象时,可能使用任一指针改变基础对象。类似地,很可能一个指针删除了一对象时,另一指针的用户还认为基础对象仍然存在。
大多数 C++ 类采用以下三种方法之一管理指针成员:
示例: HasPtr 类包含一个 int 值和 一个指针:
1 | // class that has a pointer member that behaves like a plain pointer |
智能指针除了增加功能外,其行为像普通指针一样。具体而言,复制对象时,副本和原对象将指向同一基础对象,如果通过一个副本改变基础对象,则通过另一对象访问的值也会改变。
新的 HasPtr 类需要一个析构函数来删除指针,但是,析构函数不能无条件地删除指针。如果两个 HasPtr 对象指向同一基础对象,那么,在两个对象都撤销之前,我们并不希望删除基础对象。为了编写析构函数,需要知道这个 HasPtr对象是否为指向给定对象的最后一个。
定义智能指针的通用技术是采用一个使用计数。智能指针类将一个计数器与类指向的对象相关联。使用计数跟踪该类有多少个对象共享同一指针。使用计数为 0 时,删除对象。使用计数有时也称为引用计数。
每次创建类的新对象时,初始化指针并将使用计数置为 1。当对象作为另一对象的副本而创建时,复制构造函数复制指针并增加与之相应的使用计数的值。对一个对象进行赋值时,赋值操作符减少左操作数所指对象的使用计数的值(如果使用计数减至 0,则删除对象),并增加右操作数所指对象的使用计数的值。最后,调用析构函数时,析构函数减少使用计数的值,如果计数减至 0,则删除基础对象。(这几个规则恰好体现了上述的三法则。为了管理具有指针成员的类,必须定义这三个复制控制成员。)
唯一的创新在于决定将使用计数放在哪里。
定义一个单独的具体类用以封闭使用计数和相关指针:
1 | // private class for use by HasPtr only |
这个类的所有成员均为 private。U_Ptr 类保存指针和使用计数,每个 HasPtr 对象将指向一个 U_Ptr 对象,使用计数将跟踪指向每个 U_Ptr 对象的 HasPtr 对象的数目。U_Ptr 定义的仅有函数是构造函数和析构函数,构造函数复制指针,而析构函数删除它。构造函数还将使用计数置为 1,表示一个 HasPtr 对象指向这个 U_Ptr 对象。
1 | /* smart pointer class: takes ownership of the dynamically allocated |
处理指针成员的另一个完全不同的方法,是给指针成员提供值语义。具有值语义的类所定义的对象,其行为很像算术类型的对象:复制值型对象时,会得到一个不同的新副本。对副本所做的改变不会反映在原有对象上,反之亦然。string 类是值型类的一个例子。
要使指针成员表现得像一个值,复制 HasPtr 对象时必须复制指针所指向的对象:
1 | /* |
复制构造函数不再复制指针,它将分配一个新的 int 对象,并初始化该对象以保存与被复制对象相同的值。每个对象都保存属于自己的 int 值的不同副本。因为每个对象保存自己的副本,所以析构函数将无条件删除指针。
为了定义派生类,使用类派生列表指定基类。类派生列表指定了一个或多个基类,具有如下形式:
1 | class classname: access-label base-class |
这里 access-label
称为访问标号, 可以是 public、protected 或 private 中的一种,base-class
是已定义的类的名字。类派生列表可以指定多个基类。
派生类继承基类的成员并且可以定义自己的附加成员。每个派生类对象包含两个部分:从基类继承的成员和自己定义的成员。一般而言,派生类只(重)定义那些与基类不同或扩展基类行为的方面。
如果需要声明(但并不实现)一个派生类,则声明包含类名但不包含派生列表。例如,下面的前向声明会导致编译时错误:
1 | // error: a forward declaration must not include the derivation list |
正确的前向声明为:
1 | // forward declarations of bothderived and nonderived class |
在 C++ 中,基类必须指出希望派生类重写哪些函数,定义为 virtual 的函数是基类期待派生类重新定义的,基类希望派生类继承的函数不能定义为虚函数。
通过动态绑定我们能够编写程序使用继承层次中任意类型的对象,无须关心对象的具体类型。使用这些类的程序无须区分函数是在基类还是在派生类中定义的。
C++ 中的函数调用默认不使用动态绑定。要触发动态绑定,满足两个条件:
1 | // calculate and print price for given number of copies, applying any discounts |
因为用的 item 形参是一个引用且 net_price 是虚函数,item.net_price(n)
所调 net_price 版本取决于在运行时绑定到 item 形参的实参类型:
1 | Item_base base; |
非 virtual 函数的调用在编译时就已经确定(如上例对 book 函数的调用),而 virtual 函数的调用可以在运行时才确定(如对 net_price 函数的调用)。如果希望覆盖虚函数机制并强制函数调用使用虚函数的特定版本,可以使用作用域操作符。
在某些情况下,希望覆盖虚函数机制并强制函数调用使用虚函数的特定版本,这里可以使用作用域操作符
1 | Item_base *baseP = &derived; |
这段代码强制将 net_price 调用确定为 Item_base 中定义的版本,该调用将在编译时确定。
基类本身指定对自身成员的最小访问控制。如果成员在基类中为 private,则只有基类和基类的友元可以访问该成员。派生类不能访问基类的 private 成员,也不能使自己的用户能够访问那些成员。如果基类成员为 public 或 protected,则派生列表中使用的访问标号决定该成员在派生类中的访问级别:
使用 class 保留字定义的派生默认具有 private 继承,而用 struct 保留字定义的类默认具有 public 继承。
友元关系不能继承。基类的友元对派生类的成员没有特殊访问权限。如果基类被授予友元关系,则只有基类具有特殊访问权限,该基类的派生类不能访问授予友元关系的类。
如果基类定义 static 成员,则整个继承层次中只有一个这样的成员。无论从基类派生出多少个派生类,每个 static 成员只有一个实例。
static 成员遵循常规访问控制:如果成员在基类中为 private,则派生类不能访问它。假定可以访问成员,则既可以通过基类访问 static 成员,也可以通过派生类访问 static 成员。一般而言,既可以使用作用域操作符也可以使用点或箭头成员访问操作符。
构造函数和复制控制成员不能继承,每个类定义自己的构造函数和复制控制成员。像任何类一样,如果类不定义自己的默认构造函数和复制控制成员,就将使用合成版本。
本身不是派生类的基类,其构造函数和复制控制基本上不受继承影响。构造函数看起来像已经见过的许多构造函数一样。继承对基类构造函数的唯一影响是,在确定提供哪些构造函数时,必须考虑一类新用户。像任意其他成员一样,构造函数可以为 protected 或 private,某些类需要只希望派生类使用的特殊构造函数,这样的构造函数应定义为 protected。
派生类的合成默认构造函数与非派生的构造函数只有一点不同:除了初始化派生类的数据成员之外,它还初始化派生类对象的基类部分。基类部分由基类的默认构造函数初始化。
对于 Bulk_item 类,合成的默认构造函数会这样执行:
因为 Bulk_item 具有内置类型成员,所以应定义自己的默认构造函数:
1 | class Bulk_item : public Item_base { |
除了默认构造函数之外,Item_base 类还使用户能够初始化 isbn 和 price 成员,我们希望支持同样 Bulk_item 对象的初始化,事实上,我们希望用户能够指定整个 Bulk_item 的值,包括折扣率和数量。
派生类构造函数的初始化列表只能初始化派生类的成员,不能直接初始化继承成员。相反派生类构造函数通过将基类包含在构造函数初始化列表中来间接初始化继承成员。
1 | class Bulk_item : public Item_base { |
这个构造函数使用有两个形参 Item_base 构造函数初始化基类子对象,它将自己的 book 和 sales_price 实参传递给该构造函数。这个构造函数可以这样使用:
1 | // arguments are the isbn, price, minimum quantity, and discount |
要建立 bulk,首先运行 Item_base 构造函数,该构造函数使用从 Bulk_item 构造函数初始化列表传来的实参初始化 isbn 和 price。Item_base 构造函数执行完毕之后,再初始化 Bulk_item 的成员。最后,运行 Bulk_item 构造函数的(空)函数体。
一个类只能初始化自己的直接基类。直接就是在派生列表中指定的类。如果类 C 从类 B 派生,类 B 从类 A 派生,则 B 是 C 的直接基类。虽然每个 C 类对象包含一个 A 类部分,但 C 的构造函数不能直接初始化 A 部分。
相反,需要类 C 初始化类 B,而类 B 的构造函数再初始化类 A。这一限制的原因是,类 B 的作者已经指定了怎样构造和初始化 B 类型的对象。像类 B 的任何用户一样,类 C 的作者无权改变这个规约。
作为更具体的例子,书店可以有几种折扣策略。除了批量折扣外,还可以为购买某个数量打折,此后按全价销售,或者,购买量超过一定限度的可以打折,在该限度之内不打折。
通过策略模式可以实现这一点:这些折扣策略都需要一个数量和一个折扣量。可以定义名为 Disc_item 的新类存储数量和折扣量,以支持这些不同的折扣策略。Disc_item 类可以不定义 net_price 函数,但可以作为定义不同折扣策略的其他类(如 Bulk_item 类)的基类。
如果派生类定义了自己的复制构造函数,该复制构造函数一般应显式使用基类复制构造函数初始化对象的基类部分:
1 | class Base { /* ... */ }; |
赋值操作符通常与复制构造函数类似:如果派生类定义了自己的赋值操作符,则该操作符必须对基类部分进行显式赋值。
1 | // Base::operator=(const Base&) not invoked automatically |
析构函数的工作与复制构造函数和赋值操作符不同:派生类析构函数不负责撤销基类对象的成员。编译器总是显式调用派生类对象基类部分的析构函数。每个析构函数只负责清除自己的成员:
1 | class Derived: public Base{ |
对象的撤销顺序与构造顺序相反:首先运行派生析构函数,然后按继承层次依次向上调用各基类析构函数。
如果删除基类指针,则需要运行基类析构函数并清除基类的成员,如果对象实际是派生类型的,则没有定义该行为。要保证运行适当的析构函数,基类中的析构函数必须为虚函数:
1 | class Item_base { |
如果析构函数为虚函数,那么通过指针调用时,运行哪个析构函数将因指针所指对象类型的不同而不同:
1 | Item_base *itemP = new Item_base; // same static and dynamic type |
像其他虚函数一样,析构函数的虚函数性质都将继承。因此,如果层次中根类的析构函数为虚函数,则派生类析构函数也将是虚函数,无论派生类显式定义析构函数还是使用合成析构函数,派生类析构函数都是虚函数。
构造派生类对象时首先运行基类构造函数初始化对象的基类部分。在执行基 类构造函数时,对象的派生类部分是未初始化的。实际上,此时对象还不是一个 派生类对象。
撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆序 撤销它的基类部分。
在这两种情况下,运行构造函数或析构函数的时候,对象都是不完整的。为 了适应这种不完整,编译器将对象的类型视为在构造或析构期间发生了变化。在 基类构造函数或析构函数中,将派生类对象当作基类类型对象对待。
在继承情况下,派生类的作用域嵌套在基类作用域中。如果不能在派生类作用域中确定名字,就在外围基类作用域中查找该名字的定义。
对象、引用或指针的静态类型决定了对象能够完成的行为。甚至当静态类型和动态类型可能不同的时候,就像使用基类类型的引用或指针时可能会发生的,静态类型仍然决定着可以使用什么成员。
1 | struct Base { |
注意第三个调用,要确定这个调用,编译器需要查找名字 memfcn,并在 Derived 类中找到。一旦找到了名字,编译器就不再继续查找了。这个调用与 Derived 中的 memfcn 定义不匹配,该定义希望接受 int 实参,而这个函数调用没有提供那样的实参,因此出错。
像其他任意函数一样,成员函数(无论虚还是非虚)也可以重载。派生类可以重定义所继承的 0 个或多个版本。
如果派生类想通过自身类型使用的重载版本,则派生类必须要么重定义所有重载版本,要么一个也不重定义(否则就变成了屏蔽)。
派生类不用重定义所继承的每一个基类版本,它可以为重载成员提供 using 声明。一个 using 声明只能指定一个名字,不能指定形参表,因此,为基类成员函数名称而作的 using 声明将该函数的所有重载实例加到派生类的作用域。将所有名字加入作用域之后,派生类只需要重定义本类型确实必须定义的那些函数,对其他版本可以使用继承的定义。
如果不想让某个类直接被实例化,可以将它声明为一个抽象基类(abstract base class)。含有(或继承)一个或多个纯虚函数的类是抽象基类。除了作为抽象基类的派生类的对象的组成部分,不能创建抽象类型的对象。
可以使类中的某个函数称为纯虚函数。在函数形参表后面写上 = 0
以指定纯虚函数:
1 | class Disc_item : public Item_base { |
将函数定义为纯虚能够说明,该函数为后代类型提供了可以覆盖的接口,但是这个类中的版本决不会调用。重要的是,用户将不能创建 Disc_item 类型的对象。
试图创建抽象基类的对象将发生编译时错误。
关于初始化列表的详细解释,可以参考《高质量C++/C编程指南》9.2 构造函数的初始化表 ↩︎