类的定义和声明

面向对象的特征

面向对象的三个基本特征
面向对象的三个基本特征

类的定义

C++ 中,通过定义类来自定义数据类型。类定义了该类型的对象包含的数据和该类型的对象可以执行的操作。标准库类型 string、istream 和 ostream 都定义成类。

类定义以关键字 class 或 struct 开始,其后是该类的名字标识符。类体位于花括号里面。花括号后面必须要跟一个分号。

1
2
3
4
5
6
7
8
class Sales_item {
public:
// operations on Sales_item objects will go here
private:
std::string isbn;
unsigned units_sold;
double revenue;
};

或者

1
2
3
4
5
6
7
8
struct Sales_item {
// no need for public label, members
// operations on Sales_item objects are public by default
private:
std::string isbn;
unsigned units_sold;
double revenue;
};

如果使用 class 关键字来定义类,那么定义在第一个访问标号前的任何成员都隐式指定为 private;如果使用 struct 关键字,那么这些成员都是public。使用 class 还是 struct 关键字来定义类,仅仅影响默认的初始访问级别。

  • 用 class 和 struct 关键字定义类的唯一差别在于默认访问级别:默认情况下,struct 的成员为 public,而 class 的成员为 private。
  • 仅当只有数据时使用 struct,其它一概使用 class。

类成员

每个类可以没有成员,也可以定义多个成员,成员可以是数据、函数或类型别名。

一个类可以包含若干公有的、私有的和受保护的部分。

  • 在 public 部分定义的成员可被使用该类型的所有代码访问;
  • 在 private 部分定义的成员可被其他类成员访问;
  • 可以认为 protected 访问标号是 private 和 public 的混合:
    • 像 private 成员一样,protected 成员不能被类的用户访问;
    • 像 public 成员一样,protected 成员可被该类的派生类访问。

派生类只能通过派生类对象访问其基类的 protected 成员,派生类对其基类类型对象的 protected 成员没有特殊访问权限。(注意前者是基类,后者是基类的对象)

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

类的成员函数

成员函数的定义与普通函数的定义类似。和任何函数一样,成员函数也包含下面四个部分:

  • 函数返回类型。
  • 函数名。
  • 用逗号隔开的形参表(也可能是空的)。
  • 包含在一对花括号里面的函数体。

正如我们知道的,前面三部分组成函数原型。函数原型定义了所有和函数相关的类型信息:函数返回类型是什么、函数的名字、应该给这个函数传递什么类型的实参。函数原型必须在类中定义。但是,函数体则既可以在类中也可以在类外定义。

1
2
3
4
5
6
7
8
9
10
11
12
class Sales_item { 
public:
// operations on Sales_item objects
double avg_price() const;
bool same_isbn(const Sales_item &rhs) const
{ return isbn == rhs.isbn; }
// private members as before
private:
std::string isbn;
unsigned units_sold;
double revenue;
};

类的成员函数既可以在类的定义内也可以在类的定义外定义。

在类的定义内定义

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

在类 Sales_item 中,函数 same_isbn 就是在在类内定义的。

在类的定义外定义

函数 avg_price 则在类内声明,在类外定义。编译器隐式地将在类内定义的成员函数当作内联函数。

在类的定义外面定义成员函数必须指明它们是类的成员:

1
2
3
4
5
6
7
double Sales_item::avg_price() const 
{
if (units_sold)
return revenue/units_sold;
else
return 0;
}

上述定义和其他函数一样:该函数返回类型为 double,在函数名后面的圆括号起了一个空的形参表。新的内容则包括跟在形参表后面的 const 和函数名的形式。函数名:

1
Sales_item::avg_price 

使用作用域操作符指明函数 avg_price 是在类 Sales_item 的作用域范围内定义的。

隐含的 this 指针

每个成员函数(除了 static 成员函数外)都有一个额外的、隐含的形参 this。在调用成员函数时,形参 this 初始化为调用函数的对象的地址。

在成员函数中,不必显式地使用 this 指针来访问被调用函数所属对象的成员。 对这个类的成员的任何没有前缀的引用,都被假定为通过指针 this 实现的引用:

1
2
bool same_isbn(const Sales_item &rhs) const 
{ return isbn == rhs.isbn; }

在这个函数中 isbn 的用法与 this->units_soldthis->revenue 的用法一样。

由于 this 指针是隐式定义的,因此不需要在函数的形参表中包含 this 指针,实际上,这样做也是非法的。但是,在函数体中可以显式地使用 this 指针 。如下定义函数 same_isbn 尽管没有必要,但是却是合法的:

1
2
bool same_isbn(const Sales_item &rhs) const 
{ return this->isbn == rhs.isbn; }

何时使用 this 指针

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

某种类可能具有某些操作,这些操作应该返回引用,Screen 类就是这样的一个类。迄今为止,我们的类只有一对 get 操作。逻辑上,我们可以添加下面的操作。

  • 一对 set 操作,将特定字符或光标指向的字符设置为给定值。
  • 一个 move 操作,给定两个 index 值,将光标移至新位置。

理想情况下,希望用户能够将这些操作的序列连接成一个单独的表达式:

1
2
// move cursor to given position, and set that character
myScreen.move(4,0).set('#');

这个语句等价于:

1
2
myScreen.move(4,0);
myScreen.set('#');

返回 *this

在单个表达式中调用 move 和 set 操作时,每个操作必须返回一个引用,该引用指向执行操作的那个对象:

1
2
3
4
5
6
7
8
class Screen { 
public:
// interface member functions
Screen& move(index r, index c);
Screen& set(char);
Screen& set(index, index, char);
// other members as before
};

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

1
2
3
4
5
6
7
8
9
10
11
Screen& Screen::set(char c) 
{
contents[cursor] = c;
return *this; // 返回整个对象,需要用到 *this
}
Screen& Screen::move(index r, index c)
{
index row = r * width; // row location
cursor = row + c;
return *this; // 返回整个对象,需要用到 *this
}

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

const 成员函数

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

1
double avg_price() const; 
const 成员不能改变其所操作的对象的数据成员。 const 必须同时出现在声明和定义中,若只出现在其中一处,就会出现一个编译时错误。[1]
  1. 对应《高质量C++/C编程指南》 11.1.3节 const成员函数) ↩︎

mutable

有时(但不是很经常),我们希望类的数据成员(甚至在 const 成员函数内)可以修改。这可以通过将它们声明为 mutable 来实现。

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

1
2
3
4
5
6
7
class Screen { 
public:
// interface member functions
private:
mutable size_t access_ctr; // may change in a const members
// other data members as before
};

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

1
2
3
4
5
void Screen::do_display(std::ostream& os) const 
{
++access_ctr; // keep count of calls to any member function
os << contents;
}

尽管 do_display 是 const,它也可以增加 access_ctr。该成员是可变成员,所以,任意成员函数,包括 const 函数,都可以改变 access_ctr 的值。

构造函数

在定义类时没有初始化它的数据成员,而是通过构造函数来初始化其数据成员。

构造函数是特殊的成员函数,与其他成员函数不同,构造函数和类同名,而且没有返回类型。而与其他成员函数相同的是,构造函数也有形参表(可能为空)和函数体。一个类可以有多个构造函数,每个构造函数必须有与其他构造函数不同数目或类型的形参

构造函数的形参指定了创建类类型对象时使用的初始化式。通常,这些初始化式会用于初始化新创建对象的数据成员。构造函数通常应确保其每个数据成员都完成了初始化。

Sales_item 类只需要显式定义一个构造函数:没有形参的默认构造函数。默认构造函数说明当定义对象却没有为它提供(显式的)初始化式时应该怎么办:

1
2
3
vector<int> vi;       // default constructor: empty vector 
string s; // default constructor: empty string
Sales_item item; // default constructor: ???

我们知道 string 和 vector 类默认构造函数的行为:这些构造函数会将对象初始化为合理的默认状态。string 的默认构造函数会产生空字符串上,相当于 “”。vector 的默认构造函数则生成一个没有元素的 vector 向量对象。

同样地,我们希望类 Sales_items 的默认构造函数为它生成一个空的 Sales_item 对象。这里的“空”意味着对象中的 isbn 是空字符串,units_soldrevenue 则初始化为 0。

构造函数的定义

和其他成员函数一样,构造函数也必须在类中声明,但是可以在类中或类外定义。由于我们的构造函数很简单,因此在类中定义它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Sales_item { 
public:
// operations on Sales_item objects
double avg_price() const;
bool same_isbn(const Sales_item &rhs) const
{ return isbn == rhs.isbn; }
// default constructor needed to initialize members of built-in type
Sales_item(): units_sold(0), revenue(0.0) { }
// private members as before
private:
std::string isbn;
unsigned units_sold;
double revenue;
};

在解释任何构造函数的定义之前,注意到构造函数是放在类的 public 部分的。通常构造函数会作为类的接口的一部分,这个例子也是这样。毕竟,我们希望使用类 Sales_item 的代码可以定义和初始化类 Sales_item 的对象。如果将构造函数定义为 private 的,则不能定义类 Sales_item 的对象,这样的话,这个类就没有什么用了。

对于定义本身:

1
2
// default constructor needed to initialize members of built-in type 
Sales_item(): units_sold(0), revenue(0.0) { }

上述语句说明现在正在定义类 Sales_item 的构造函数,这个构造函数的形参表和函数体都为空。令人感兴趣的是冒号和冒号与定义(空)函数体的花括号之间的代码。见下面的解释。

初始化列表

在冒号和花括号之间的代码称为构造函数的初始化列表 。构造函数的初始化列表 为类的一个或多个数据成员指定初值。它跟在构造函数的形参表之后,以冒号开关。构造函数的初始化式是一系列成员名,每个成员后面是括在圆括号中的初始值。多个成员的初始化用逗号分隔 [1]

上述例题的初始化列表表明 units_soldrevenue 成员都应初始化为 0。每当创建 Sales_item 对象时,它的这两个成员都以初值 0 出现。而 isbn 成员可以不必准确指明其初值。除非在初始化列表中有其他表述,否则具有类类型的成员皆被其默认构造函数自动初始化。于是,isbn 由 string 类的默认构造函数初始化为空串。当然,如果有必要的话,也可以在初始化列表中指明 isbn 的默认初值。

为什么要使用初始化列表

有些成员必须在构造函数初始化列表中进行初始化。对于这样的成员,在构造函数函数体中对它们赋值不起作用。没有默认构造函数的类类型的成员,以及 const 或引用类型的成员,不管是哪种类型,都必须在构造函数初始化列表中进行初始化。

默认实参与构造函数

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

1
2
3
Sales_item(): units_sold(0), revenue(0.0) { }
Sales_item(const std::string &book):
isbn(book), units_sold(0), revenue(0.0) { }

这两个构造函数几乎是相同的:唯一的区别在于,接受一个 string 形参的构造函数使用该形参来初始化 isbn,而默认构造函数(隐式地)使用 string 的默认构造函数来初始化 isbn。

默认构造函数

合成的默认构造函数

如果没有为一个类显式定义任何构造函数,编译器将自动为这个类生成默认构造函数。

由编译器创建的默认构造函数通常称为默认构造函数,它将依据如同变量初始化的规则初始化类中所有成员。对于具有类类型的成员,如 isbn,则会调用该成员所属类自身的默认构造函数实现初始化。内置类型成员的初值依赖于对象如何定义。如果对象在全局作用域中定义(即不在任何函数中)或定义为静态局部对象,则这些成员将被初始化为 0。如果对象在局部作用域中定义,则这些成员没有初始化。除了给它们赋值之外,出于其他任何目的对未初始化成员的使用都没有定义。

合成的默认构造函数 一般适用于仅包含类类型成员的类 。而对于 含有内置类型或复合类型成员 的类,则通常应该定义他们自己的默认构造函数初始化这些成员。由于合成的默认构造函数不会自动初始化内置类型的成员,所以必须明确定义 Sales_item 类的默认构造函数。

类通常应定义一个默认构造函数

如果定义了其他构造函数,则提供一个默认构造函数几乎总是对的。通常,在默认构造函数中给成员提供的初始值应该指出该对象是“空”的。

在某些情况下,默认构造函数是由编译器隐式应用的。如果类没有默认构造函数,则该类就不能用在这些环境中。为了例示需要默认构造函数的情况,假定有一个 NoDefault 类,它没有定义自己的默认构造函数,却有一个接受一个string 实参的构造函数。因为该类定义了一个构造函数,因此编译器将不合成默认构造函数。NoDefault 没有默认构造函数,意味着:

  1. 具有 NoDefault 成员的每个类的每个构造函数,必须通过传递一个初始的 string 值给 NoDefault 构造函数来显式地初始化 NoDefault 成员。
  2. 编译器将不会为具有 NoDefault 类型成员的类合成默认构造函数。如果这样的类希望提供默认构造函数,就必须显式地定义,并且默认构造函数必须显式地初始化其 NoDefault 成员。
  3. NoDefault 类型不能用作动态分配数组的元素类型。
  4. NoDefault 类型的静态分配数组必须为每个元素提供一个显式的初始化式。
  5. 如果有一个保存 NoDefault 对象的容器,例如 vector,就不能使用接受容器大小而没有同时提供一个元素初始化式的构造函数。

使用默认构造函数

使用默认构造函数定义一个对象的正确方式:

1
Sales_item myobj;

下面的代码是错误的,它实际上声明了一个函数,而不是定义一个对象:

1
2
3
Sales_item myobj();
// ok: but defines a function, not an object
if (myobj.same_isbn(Primer_3rd_ed)) // error: myobj is a function

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

1
2
// ok: create an unnamed, empty Sales_item and use to initialize myobj
Sales_item myobj = Sales_item();

在这里,我们创建并初始化一个 Sales_item 对象,然后用它来按值初始化myobj。编译器通过运行 Sales_item 的默认构造函数来按值初始化一个Sales_item。

explicit

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

抑制由构造函数定义的隐式转换

1
2
3
4
5
6
7
class Sales_item {
public:
// default argument for book is the empty string
explicit Sales_item(const std::string &book = ""):
isbn(book), units_sold(0), revenue(0.0) {explicit Sales_item(std::istream &is);
// as before
};

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

1
2
3
4
5
// error: explicit allowed only on constructor declaration in class header
explicit Sales_item::Sales_item(istream& is)
{
is >> *this; // uses Sales_iteminput operator to read the members
}

为转换而显式地使用构造函数

只要显式地按下面这样做,就可以用显式的构造函数来生成转换:

1
2
3
4
string null_book = "9-999-99999-9";
// ok: builds a Sales_item with 0 units_sold and revenue from
// and isbn equal to null_book
item.same_isbn(Sales_item(null_book));

类成员的显式初始化

对于没有定义构造函数并且其全体数据成员均为 public 的类,可以采用与初始化数组元素相同的方式初始化其成员:

1
2
3
4
5
6
7
8
9
10
struct Data {
int ival;
char *ptr;
};
// val1.ival = 0; val1.ptr = 0
Data val1 = { 0, 0 };

// val2.ival = 1024;
// val2.ptr = "Anna Livia Plurabelle"
Data val2 = { 1024, "Anna Livia Plurabelle"};

这种形式的初始化从 C 继承而来,支持与 C 程序兼容。显式初始化类类型对象的成员有三个重大的缺点。

  1. 要求类的全体数据成员都是 public。
  2. 将初始化每个对象的每个成员的负担放在程序员身上。这样的初始化是乏味且易于出错的,因为容易遗忘初始化式或提供不适当的初始化式。
  3. 如果增加或删除一个成员,必须找到所有的初始化并正确更新。

复制构造函数

复制构造函数是一种特殊构造函数,具有单个形参,该形参(常用 const 修饰)是对该类类型的引用。当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用复制构造函数。当将该类型的对象传递给函数或函数返回该类型的对象时,将隐式使用复制构造函数。与默认构造函数一样,复制构造函数可由编译器隐式调用。复制构造函数可用于:

  • 根据另一个同类型的对象显式或隐式初始化一个对象。
  • 复制一个对象,将它作为实参传给一个函数。
  • 从函数返回时复制一个对象。
  • 初始化顺序容器中的元素。
  • 根据元素初始化式列表初始化数组元素。

合成的复制构造函数

如果我们没有定义复制构造函数,编译器就会为我们合成一个。与合成的默认构造函数不同,即使我们定义了其他构造函数,也会合成复制构造函数

合成复制构造函数的行为是,执行逐个成员(非static成员)初始化,将新对象初始化为原对象的副本。

每个成员的类型决定了复制该成员的含义。合成复制构造函数直接复制内置类型成员的值,类类型成员使用该类的复制构造函数进行复制。数组成员的复制是个例外。虽然一般不能复制数组,但如果一个类具有数组成员,则合成复制构造函数将复制数组。复制数组时合成复制构造函数将复制数组的每一个元素。

定义自己的复制构造函数

复制构造函数就是接受单个类类型引用形参(通常用 const 修饰)的构造函数:

1
2
3
4
5
6
class Foo {
public:
Foo(); //default constructor
Foo(const Foo&); //copy constructor
// ...
};

虽然也可以定义接受非 const 引用的复制构造函数,但形参通常是一个 const 引用。因为用于向函数传递对象和从函数返回对象,该构造函数一般不应设置为 explicit 。复制构造函数应将实参的成员复制到正在 构造的对象。

通常,定义复制构造函数最困难的部分在于认识到需要复制构造函数。只要能认识到需要复制构造函数,定义构造函数一般非常简单。复制构造函数的定义与其他构造函数一样:它与类同名,没有返回值,可以(而且应该)使用构造函数初始化列表初始化新创建对象的成员,可以在函数体中做任何其他必要工作。

禁止复制

有些类需要完全禁止复制。例如,iostream 类就不允许复制。如果想要禁止复制,似乎可以省略复制构造函数,然而,如果不定义复制构造函数,编译器将合成一个。

为了防止复制,类必须显式声明其复制构造函数为 private。如果想要连友元和成员中的复制也禁止,就可以声明一个(private)复制构造函数但不对其定义。

方便起见,我们可以使用 DISALLOW_COPY_AND_ASSIGN 宏:

1
2
3
4
5
6
// 禁止使用拷贝构造函数和 operator= 赋值操作的宏
// 应在类的 private: 中使用

#define DISALLOW_COPY_AND_ASSIGN(TypeName) \
TypeName(const TypeName&); \
void operator=(const TypeName&)

在 class foo: 中:

1
2
3
4
5
6
7
8
class Foo {
public:
Foo(int f);
~Foo();

private:
DISALLOW_COPY_AND_ASSIGN(Foo);
};

析构函数

析构函数是构造函数的互补:当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。析构函数可用于释放对象时构造或在对象的生命期中所获取的资源。不管类是否定义了自己的析构函数,编译器都自动执行类中非 static 数据成员的析构函数。

何时调用析构函数

撤销类对象时会自动调用析构函数。撤销一个容器(不管是标准库容器还是内置数组)时,也会运行容器中的类类型元素的析构函数。

1
2
3
4
5
6
7
8
9
// p points to default constructed object
Sales_item *p = new Sales_item;

{
// new scope
Sales_item item(*p); // copy constructor copies *p into item
delete p;
// destructor called on object pointed to by p
} // exit local scope; destructor called on item

变量(如 item )在超出作用域时应该自动撤销。因此,当遇到右花括号时,将运行 item 的析构函数。

动态分配的对象只有在指向该对象的指针被删除时才撤销。如果没有删除指向动态对象的指针,则不会运行该对象的析构函数,对象就一直存在,从而导致内存泄漏,而且,对象内部使用的任何资源也不会释放。

容器中的元素总是按逆序撤销:首先撤销下标为 size() - 1 的元素,然后是下标为 size() - 2 的元素…直到最后撤销下标为 [0] 的元素。

当对象的引用或指针超出作用域时,不会运行析构函数。只有删除指向动态分配对象的指针或实际对象(而不是对象的引用)超出作用域时,才会运行析构函数。

何时编写显式析构函数

许多类不需要显式析构函数,尤其是具有构造函数的类不一定需要定义自己的析构函数。仅在有些工作需要析构函数完成时,才需要析构函数。析构函数通常用于释放在构造函数或在对象生命期内获取的资源。

如果类需要析构函数,则它也需要赋值操作符和复制构造函数,这是一个有用的经验法则。这个规则常称为三法则,指的是如果需要析构函数,则需要所有这三个复制控制成员。

合成析构函数

与复制构造函数或赋值操作符不同,编译器总是会为我们合成一个析构函数。合成析构函数按对象创建时的逆序撤销每个非 static 成员,因此,它按成员在类中声明次序的逆序撤销成员。对于类类型的每个成员,合成析构函数调用该成员的析构函数来撤销对象

撤销内置类型成员或复合类型的成员没什么影响。尤其是,合成析构函数并不删除指针成员所指向的对象。

友元

在某些情况下,允许特定的非成员函数访问一个类的私有成员,同时仍然阻止一般的访问,这是很方便做到的。例如,被重载的操作符,如输入或输出操作符,经常需要访问类的私有数据成员。这些操作符不可能为类的成员。然而,尽管不是类的成员,它们仍是类的“接口的组成部分”。

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

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

友元关系:一个例子

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

1
2
3
4
5
class Screen { 
// Window_Mgr members can access private parts of class Screen
friend class Window_Mgr;
// ...rest of the Screen class
};

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

1
2
3
4
5
6
7
8
9
10
Window_Mgr& 
Window_Mgr::relocate(Screen::index r, Screen::index c,
Screen& s)
{
// ok to refer to height and width
s.height += r;
s.width += c;

return *this;
}

缺少友元声明时,这段代码将会出错:将不允许使用形参 s 的 height 和 width 成员。因为 Screen 将友元关系授予 Window_Mgr,所以,Window_Mgr 中的函数都可以访问 Screen 的所有成员。

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

使其他类的成员函数成为友元

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

1
2
3
4
5
6
7
8
class Screen { 
// Window_Mgrmust be defined before class Screen
friend Window_Mgr&
Window_Mgr::relocate(Window_Mgr::index,
Window_Mgr::index,
Screen&);
// ...rest of the Screen class
};

当我们将成员函数声明为友元时,函数名必须用该函数所属的类名字加以限定。

友元声明与作用域

为了正确地构造类,需要注意友元声明与友元定义之间的互相依赖。在前面的例子中,类 Window_Mgr 必须先定义。否则,Screen 类就不能将一个 Window_Mgr 函数指定为友元。然而, 只有在定义类 Screen 之后,才能定义 relocate 函数 ——毕竟,它被设为友元是为了访问类 Screen 的成员。

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

友元声明将已命名的类或非成员函数引入到外围作用域中。此外,友元函数可以在类的内部定义,该函数的作用域扩展到包围该类定义的作用域。

重载函数与友元关系

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

1
2
3
4
5
6
7
8
// overloaded storeOn functions 
extern std::ostream& storeOn(std::ostream &, Screen &);
extern BitMap& storeOn(BitMap &, Screen &);
class Screen {
// ostream version of storeOn may access private parts of Screen objects
friend std::ostream& storeOn(std::ostream &, Screen &);
// ...
};

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

static 类成员

通常,非 static 数据成员存在于类类型的每个对象中。不像普通的数据成员,static 数据成员独立于该类的任意对象而存在 ;每个 static 数据成员是与类关联的对象,并不与该类的对象相关联。

使用类的 static 成员的优点

使用 static 成员而不是全局对象有三个优点。

  1. static 成员的名字是在类的作用域中,因此可以避免与其他类的成员或全局对象名字冲突。
  2. 可以实施封装。static 成员可以是私有成员,而全局对象不可以。
  3. static 成员是与特定类关联的。这种可见性可清晰地显示程序员的意图。

定义 static 成员

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

1
2
3
4
5
6
7
8
9
10
11
12
class Account { 
public:
// interface functions here
void applyint() { amount += amount * interestRate; }
static double rate() { return interestRate; }
static void rate(double); // sets a new rate
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};

使用类的 static 成员

可以通过作用域操作符从类直接调用 static 成员,或者通过对象、引用或指向该类类型对象的指针间接调用。

1
2
3
4
5
6
7
Account ac1; 
Account *ac2 = &ac1;
// equivalent ways to call the static member rate function
double rate;
rate = ac1.rate(); // through an Account object or reference
rate = ac2->rate(); // through a pointer to an Account object
rate = Account::rate(); // directly from the class using the scope operator

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

1
2
3
4
5
class Account { 
public:
// interface functions here
void applyint() { amount += amount * interestRate; }
};

static 成员函数

Account 类有两个名为 rate 的 static 成员函数,其中一个定义在类的内部。当我们在类的外部定义 static 成员时,无须重复指定 static 保留字,该保留字只出现在类定义体内部的声明处:

1
2
3
4
void Account::rate(double newRate)
{
interestRate = newRate;
}

static 函数的一些限制

  1. static 函数没有 this 指针。static 成员是类的组成部分但不是任何对象的组成部分,因此,static 成员函数没有 this 指针。通过使用非 static 成员显式或隐式地引用 this 是一个编译时错误。
  2. static 成员函数不能被声明为 const 。因为 static 成员不是任何对象的组成部分,所以static 成员函数不能被声明为 const。毕竟,将成员函数声明为 const 就是承诺不会修改该函数所属的对象。
  3. static 成员函数也不能被声明为虚函数。

static 数据成员

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

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

保证对象正好定义一次的最好办法,就是将 static 数据成员的定义放在包含类非内联成员函数定义的文件中。

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

可以定义如下 interestRate:

1
2
// define and initialize static class member 
double Account::interestRate = initRate();

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

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

完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>

using namespace std;

class Droid{
private:
string name;
public:
static int population;
Droid(const string &s);
~Droid(void);
void SayHi(void) const;
};

Droid::Droid(const string &s)
{
name = s;
cout << "Droid " << name << " generated!" << endl;
++Droid::population;
}

Droid::~Droid(void)
{
cout << "Droid " << name << " destroyed!" << endl;
--Droid::population;
}

void Droid::SayHi(void) const
{
cout << "Hello! My name is " << name << endl;
}

int Droid::population = 0;

int main(void)
{
Droid droid1("r2d2");
cout << "Population: " << Droid::population << endl;
droid1.SayHi();
Droid droid2("PD");
droid2.SayHi();
cout << "Population: " << Droid::population << endl;
return 0;
}

特殊的整型 const static 成员

一般而言,类的 static 成员,像普通数据成员一样,不能在类的定义体中初始化。相反,static 数据成员通常在定义时才初始化。

这个规则的一个例外是,只要初始化式是一个常量表达式,整型 const static 数据成员就可以在类的定义体中进行初始化:

1
2
3
4
5
6
7
8
class Account { 
public:
static double rate() { return interestRate; }
static void rate(double); // sets a new rate
private:
static const int period = 30; // interest posted every 30 days
double daily_tbl[period]; // ok: period is constant expression
};

用常量值初始化的整型 const static 数据成员是一个常量表达式。同样地,它可以用在任何需要常量表达式的地方,例如指定数组成员 daily_tbl 的维。

const static 数据成员在类的定义体中初始化时,该数据成员仍必须在类的定义体之外进行定义。

然而,在类外部提供初始化式时,成员的定义就不必再指定初始值了:

1
2
3
// definition of static member with no initializer; 
// the initial value is specified inside the class definition
const int Account::period;

static 成员不是类对象的组成部分

普通成员都是给定类的每个对象的组成部分。 static 成员独立于任何对象而存在,不是类类型对象的组成部分 。因为 static 数据成员不是任何对象的组成部分,所以它们的使用方式对于非 static 数据成员而言是不合法的。 例如,static 数据成员的类型可以是该成员所属的类类型。非 static 成员被限定声明为其自身类对象的指针或引用:

1
2
3
4
5
6
7
8
class Bar { 
public:
// ...
private:
static Bar mem1; // ok
Bar *mem2; // ok
Bar mem3; // error
};

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

1
2
3
4
5
6
7
8
class Screen { 
public:
// bkground refers to the static member
// declared later in the class definition
Screen& clear(char = bkground);
private:
static const char bkground = '#';
};

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

指针成员

设计具有指针成员的类时,类设计者必须首先需要决定的是该指针应提供什么行为。将一个指针复制到另一个指针时,两个指针指向同一对象。当两个指针指向同一对象时,可能使用任一指针改变基础对象。类似地,很可能一个指针删除了一对象时,另一指针的用户还认为基础对象仍然存在。

大多数 C++ 类采用以下三种方法之一管理指针成员:

  1. 指针成员采取常规指针型行为。这样的类具有指针的所有缺陷但无需特殊的复制控制,可能会出现悬停指针。
  2. 类可以实现所谓的“智能指针”行为。指针所指向的对象是共享的,但类能够防止悬垂指针。
  3. 类采取值型行为。指针所指向的对象是唯一的,由每个类对象独立管理。

常规指针型

示例: HasPtr 类包含一个 int 值和 一个指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// class that has a pointer member that behaves like a plain pointer
class HasPtr {
public:
// copy of the values we're given
HasPtr(int *p, int i): ptr(p), val(i) { }

// const members to return the value of the indicated data member
int *get_ptr() const { return ptr; }
int get_int() const { return val; }

// non const members to change the indicated data member
void set_ptr(int *p) { ptr = p; }
void set_int(int i) { val = i; }

// return or change the value pointed to, so ok for const objects
int get_ptr_val() const { return *ptr; }
void set_ptr_val(int val) const { *ptr = val; }

private:
int *ptr;
int val;
};

智能指针型

智能指针除了增加功能外,其行为像普通指针一样。具体而言,复制对象时,副本和原对象将指向同一基础对象,如果通过一个副本改变基础对象,则通过另一对象访问的值也会改变。

新的 HasPtr 类需要一个析构函数来删除指针,但是,析构函数不能无条件地删除指针。如果两个 HasPtr 对象指向同一基础对象,那么,在两个对象都撤销之前,我们并不希望删除基础对象。为了编写析构函数,需要知道这个 HasPtr对象是否为指向给定对象的最后一个。

引入使用计数

定义智能指针的通用技术是采用一个使用计数。智能指针类将一个计数器与类指向的对象相关联。使用计数跟踪该类有多少个对象共享同一指针。使用计数为 0 时,删除对象。使用计数有时也称为引用计数。

每次创建类的新对象时,初始化指针并将使用计数置为 1。当对象作为另一对象的副本而创建时,复制构造函数复制指针并增加与之相应的使用计数的值。对一个对象进行赋值时,赋值操作符减少左操作数所指对象的使用计数的值(如果使用计数减至 0,则删除对象),并增加右操作数所指对象的使用计数的值。最后,调用析构函数时,析构函数减少使用计数的值,如果计数减至 0,则删除基础对象。(这几个规则恰好体现了上述的三法则。为了管理具有指针成员的类,必须定义这三个复制控制成员。)

唯一的创新在于决定将使用计数放在哪里。

实现计数的策略

使用计数类

定义一个单独的具体类用以封闭使用计数和相关指针:

1
2
3
4
5
6
7
8
// private class for use by HasPtr only
class U_Ptr {
friend class HasPtr;
int *ip;
size_t use;
U_Ptr(int *p): ip(p), use(1) {}
~U_Ptr() { delete ip; }
};

这个类的所有成员均为 private。U_Ptr 类保存指针和使用计数,每个 HasPtr 对象将指向一个 U_Ptr 对象,使用计数将跟踪指向每个 U_Ptr 对象的 HasPtr 对象的数目。U_Ptr 定义的仅有函数是构造函数和析构函数,构造函数复制指针,而析构函数删除它。构造函数还将使用计数置为 1,表示一个 HasPtr 对象指向这个 U_Ptr 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/* smart pointer class: takes ownership of the dynamically allocated
* object to which it is bound
* User code must dynamically allocate an object to initialize a HasPtr
* and must not delete that object; the HasPtr class will delete it
*/
class HasPtr {
public:
// HasPtr owns the pointer; p must have been dynamically allocated
HasPtr(int *p, int i): ptr(new U_Ptr(p)), val(i) { }
// copy members and increment the use count
HasPtr(const HasPtr &orig):
ptr(orig.ptr), val(orig.val) { ++ptr->use; }
HasPtr& operator=(const HasPtr&);

// copy control and constructors as before
// accessors must change to fetch value from U_Ptr object
int *get_ptr() const { return ptr->ip; }
int get_int() const { return val; }

// change the appropriate data member
void set_ptr(int *p) { ptr->ip = p; }
void set_int(int i) { val = i; }

// return or change the value pointed to, so ok for const objects
// Note: *ptr->ip is equivalent to *(ptr->ip)

int get_ptr_val() const { return *ptr->ip; }
void set_ptr_val(int i) { *ptr->ip = i; }

// if use count goes to zero, delete the U_Ptr object
~HasPtr() { if (--ptr->use == 0) delete ptr; }

private:
U_Ptr *ptr; // points to use-counted U_Ptr class
int val;
};

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++rhs.ptr->use; // increment use count on rhs first
if (--ptr->use == 0)
delete ptr; // if use count goes to 0 on this object, delete it
ptr = rhs.ptr; // copy the U_Ptr object
val = rhs.val; // copy the int member
return *this;
}

值型

处理指针成员的另一个完全不同的方法,是给指针成员提供值语义。具有值语义的类所定义的对象,其行为很像算术类型的对象:复制值型对象时,会得到一个不同的新副本。对副本所做的改变不会反映在原有对象上,反之亦然。string 类是值型类的一个例子。

要使指针成员表现得像一个值,复制 HasPtr 对象时必须复制指针所指向的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/*
* Valuelike behavior even though HasPtr has a pointer member:
* Each time we copy a HasPtr object, we make a new copy of the
* underlying int object to which ptr points.
*/
class HasPtr {
public:
// no point to passing a pointer if we're going to copy it anyway
// store pointer to a copy of the object we're given
HasPtr(const int &p, int i): ptr(new int(p)), val(i) {}

// copy members and increment the use count
HasPtr(const HasPtr &orig):
ptr(new int (*orig.ptr)), val(orig.val) {}

HasPtr& operator=(const HasPtr&);
~HasPtr() { delete ptr; }
// accessors must change to fetch value from Ptr object
int get_ptr_val() const { return *ptr; }
int get_int() const { return val; }

// change the appropriate data member
void set_ptr(int *p) { ptr = p; }
void set_int(int i) { val = i; }

// return or change the value pointed to, so ok for const objects
int *get_ptr() const { return ptr; }
void set_ptr_val(int p) const { *ptr = p; }
private:
int *ptr;
// points to an int
int val;
};


HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
// Note: Every HasPtr is guaranteed to point at an actual int;
// We know that ptr cannot be a zero pointer
*ptr = *rhs.ptr; // copy the value pointed to
val = rhs.val; // copy the int
return *this;
}

复制构造函数不再复制指针,它将分配一个新的 int 对象,并初始化该对象以保存与被复制对象相同的值。每个对象都保存属于自己的 int 值的不同副本。因为每个对象保存自己的副本,所以析构函数将无条件删除指针。

派生类

为了定义派生类,使用类派生列表指定基类。类派生列表指定了一个或多个基类,具有如下形式:

1
class classname: access-label base-class

这里 access-label 称为访问标号, 可以是 public、protected 或 private 中的一种,base-class 是已定义的类的名字。类派生列表可以指定多个基类。

派生类继承基类的成员并且可以定义自己的附加成员。每个派生类对象包含两个部分:从基类继承的成员和自己定义的成员。一般而言,派生类只(重)定义那些与基类不同或扩展基类行为的方面。

派生类的声明

如果需要声明(但并不实现)一个派生类,则声明包含类名但不包含派生列表。例如,下面的前向声明会导致编译时错误:

1
2
// error: a forward declaration must not include the derivation list
class Bulk_item : public Item_base;

正确的前向声明为:

1
2
3
// forward declarations of bothderived and nonderived class
class Bulk_item;
class Item_base;

虚函数和动态绑定

虚函数

在 C++ 中,基类必须指出希望派生类重写哪些函数,定义为 virtual 的函数是基类期待派生类重新定义的,基类希望派生类继承的函数不能定义为虚函数。

动态绑定

通过动态绑定我们能够编写程序使用继承层次中任意类型的对象,无须关心对象的具体类型。使用这些类的程序无须区分函数是在基类还是在派生类中定义的。

动态绑定的条件

C++ 中的函数调用默认不使用动态绑定。要触发动态绑定,满足两个条件:

  1. 只有指定为虚函数的成员函数才能进行动态绑定,成员函数默认为非虚函数,非虚函数不进行动态绑定;
  2. 必须通过基类类型的引用或指针进行函数调用。

示例

1
2
3
4
5
6
7
8
9
// calculate and print price for given number of copies, applying any discounts
void print_total(ostream &os,
const Item_base &item, size_t n)
{
os << "ISBN: " << item.book() // calls Item_base::book
<< "\tnumber sold: " << n << "\ttotal price: "
// virtual call: which version of net_price to callresolved at run time
<< item.net_price(n) << endl;
}

因为用的 item 形参是一个引用且 net_price 是虚函数,item.net_price(n) 所调 net_price 版本取决于在运行时绑定到 item 形参的实参类型:

1
2
3
4
5
Item_base base;
Bulk_item derived;
// print_total makes a virtual call to net_price
print_total(cout, base, 10); // calls Item_base::net_price
print_total(cout, derived, 10); // calls Bulk_item::net_price

非 virtual 函数的调用在编译时就已经确定(如上例对 book 函数的调用),而 virtual 函数的调用可以在运行时才确定(如对 net_price 函数的调用)。如果希望覆盖虚函数机制并强制函数调用使用虚函数的特定版本,可以使用作用域操作符。

覆盖虚函数机制

在某些情况下,希望覆盖虚函数机制并强制函数调用使用虚函数的特定版本,这里可以使用作用域操作符

1
2
3
Item_base *baseP = &derived;
// calls version from the base class regardless of the dynamic type of baseP
double d = baseP->Item_base::net_price(42);

这段代码强制将 net_price 调用确定为 Item_base 中定义的版本,该调用将在编译时确定。

访问标号

基类本身指定对自身成员的最小访问控制。如果成员在基类中为 private,则只有基类和基类的友元可以访问该成员。派生类不能访问基类的 private 成员,也不能使自己的用户能够访问那些成员。如果基类成员为 public 或 protected,则派生列表中使用的访问标号决定该成员在派生类中的访问级别:

  • 如果是公用继承,基类成员保持自己的访问级别:基类的 public 成员为派生类的 public 成员,基类的 protected 成员为派生类的 protected 成员。
  • 如果是受保护继承,基类的 public 和 protected 成员在派生类中为 protected 成员。
  • 如果是私有继承,基类的的所有成员在派生类中为 private 成员。

使用 class 保留字定义的派生默认具有 private 继承,而用 struct 保留字定义的类默认具有 public 继承。

接口继承和实现继承

  • 接口继承:public 派生类继承基类的接口,它具有与基类相同的接口。设计良好的类层次中,public 派生类的对象可以用在任何需要基类对象的地方。
  • 实现继承:使用 private 或 protected 派生的类不继承基类的接口,相反,这些派生通常被称为实现继承。派生类在实现中使用被继承但继承基类的部分并未成为其接口的一部分。
使用组合常常比使用继承更合理,如果使用继承的话,定义为 public 继承。

友元关系与继承

友元关系不能继承。基类的友元对派生类的成员没有特殊访问权限。如果基类被授予友元关系,则只有基类具有特殊访问权限,该基类的派生类不能访问授予友元关系的类。

继承与静态成员

如果基类定义 static 成员,则整个继承层次中只有一个这样的成员。无论从基类派生出多少个派生类,每个 static 成员只有一个实例。

static 成员遵循常规访问控制:如果成员在基类中为 private,则派生类不能访问它。假定可以访问成员,则既可以通过基类访问 static 成员,也可以通过派生类访问 static 成员。一般而言,既可以使用作用域操作符也可以使用点或箭头成员访问操作符。

构造函数和复制控制

构造函数和复制控制成员不能继承,每个类定义自己的构造函数和复制控制成员。像任何类一样,如果类不定义自己的默认构造函数和复制控制成员,就将使用合成版本。

基类构造函数和复制控制

本身不是派生类的基类,其构造函数和复制控制基本上不受继承影响。构造函数看起来像已经见过的许多构造函数一样。继承对基类构造函数的唯一影响是,在确定提供哪些构造函数时,必须考虑一类新用户。像任意其他成员一样,构造函数可以为 protected 或 private,某些类需要只希望派生类使用的特殊构造函数,这样的构造函数应定义为 protected。

派生类构造函数

合成的派生类默认构造函数

派生类的合成默认构造函数与非派生的构造函数只有一点不同:除了初始化派生类的数据成员之外,它还初始化派生类对象的基类部分。基类部分由基类的默认构造函数初始化。

对于 Bulk_item 类,合成的默认构造函数会这样执行:

  1. 调用 Item_base 的默认构造函数,将 isbn 成员初始化空串,将 price 成员初始化为 0。
  2. 用常规变量初始化规则初始化 Bulk_item 的成员,也就是说, qty 和 discount 成员会是未初始化的。

定义默认构造函数

因为 Bulk_item 具有内置类型成员,所以应定义自己的默认构造函数:

1
2
3
4
5
class Bulk_item : public Item_base {
public:
Bulk_item(): min_qty(0), discount(0.0){ }
// as before
};

向基类构造函数传递实参

除了默认构造函数之外,Item_base 类还使用户能够初始化 isbn 和 price 成员,我们希望支持同样 Bulk_item 对象的初始化,事实上,我们希望用户能够指定整个 Bulk_item 的值,包括折扣率和数量。

派生类构造函数的初始化列表只能初始化派生类的成员,不能直接初始化继承成员。相反派生类构造函数通过将基类包含在构造函数初始化列表中来间接初始化继承成员。

1
2
3
4
5
6
7
8
class Bulk_item : public Item_base {
public:
Bulk_item(const std::string& book, double sales_price,
std::size_t qty = 0, double disc_rate = 0.0):
Item_base(book, sales_price),
min_qty(qty), discount(disc_rate) { }
// as before
};

这个构造函数使用有两个形参 Item_base 构造函数初始化基类子对象,它将自己的 book 和 sales_price 实参传递给该构造函数。这个构造函数可以这样使用:

1
2
// arguments are the isbn, price, minimum quantity, and discount
Bulk_item bulk("0-201-82470-1", 50, 5, .19);

要建立 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
2
3
4
5
6
7
8
class Base { /* ... */ };

class Derived: public Base {
public:
// Base::Base(const Base&) not invoked automatically
Derived(const Derived& d):
Base(d) /* other member initialization */ { /*...};
*/ }

派生类赋值操作符

赋值操作符通常与复制构造函数类似:如果派生类定义了自己的赋值操作符,则该操作符必须对基类部分进行显式赋值。

1
2
3
4
5
6
7
8
9
10
// Base::operator=(const Base&) not invoked automatically
Derived &Derived::operator=(const Derived &rhs)
{
if (this != &rhs) {
Base::operator=(rhs); // assigns the base part
// do whatever needed to clean up the old value in the derived part
// assign the members from the derived
}
return *this;
}

派生类析构函数

析构函数的工作与复制构造函数和赋值操作符不同:派生类析构函数不负责撤销基类对象的成员。编译器总是显式调用派生类对象基类部分的析构函数。每个析构函数只负责清除自己的成员:

1
2
3
4
5
6
class Derived: public Base{
public:
~Derived() { /* do what it takes to clean up derived members */ }

// Base::~Base invoked automatically
};

对象的撤销顺序与构造顺序相反:首先运行派生析构函数,然后按继承层次依次向上调用各基类析构函数

虚析构函数

  1. 即使析构函数没有工作要做,继承层次的根类也应该定义一个虚析构函数。
  2. 在复制控制成员中,只有析构函数应定义为虚函数,构造函数不能定义为虚函数。
  3. 将类的赋值操作符设为虚函数很可能会令人混淆,而且不会有什么用处。

如果删除基类指针,则需要运行基类析构函数并清除基类的成员,如果对象实际是派生类型的,则没有定义该行为。要保证运行适当的析构函数,基类中的析构函数必须为虚函数:

1
2
3
4
5
6
class Item_base {
public:
// no work, but virtual destructor needed
// if base pointer that points to a derived object is ever deleted
virtual ~Item_base() { }
};

如果析构函数为虚函数,那么通过指针调用时,运行哪个析构函数将因指针所指对象类型的不同而不同:

1
2
3
4
Item_base *itemP = new Item_base; // same static and dynamic type
delete itemP; // ok: destructor for Item_base called
itemP = new Bulk_item; // ok: static and dynamic types differ
delete itemP; // ok: destructor for Bulk_item called

像其他虚函数一样,析构函数的虚函数性质都将继承。因此,如果层次中根类的析构函数为虚函数,则派生类析构函数也将是虚函数,无论派生类显式定义析构函数还是使用合成析构函数,派生类析构函数都是虚函数。

构造函数和析构函数中的虚函数

如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本。

构造派生类对象时首先运行基类构造函数初始化对象的基类部分。在执行基 类构造函数时,对象的派生类部分是未初始化的。实际上,此时对象还不是一个 派生类对象。

撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆序 撤销它的基类部分。

在这两种情况下,运行构造函数或析构函数的时候,对象都是不完整的。为 了适应这种不完整,编译器将对象的类型视为在构造或析构期间发生了变化。在 基类构造函数或析构函数中,将派生类对象当作基类类型对象对待。

继承情况下的类作用域

在继承情况下,派生类的作用域嵌套在基类作用域中。如果不能在派生类作用域中确定名字,就在外围基类作用域中查找该名字的定义。

对象、引用或指针的静态类型决定了对象能够完成的行为。甚至当静态类型和动态类型可能不同的时候,就像使用基类类型的引用或指针时可能会发生的,静态类型仍然决定着可以使用什么成员。

屏蔽(覆盖)

  1. 与基类成员同名的派生类成员将屏蔽对基类成员的直接访问。可以使用作用域操作符访问被屏蔽的基类成员。
  2. 在基类和派生类中使用同一名字的成员函数,其行为与数据成员一样:在派生类作用域中派生类成员将屏蔽基类成员。即使函数原型不同,基类成员也会被屏蔽。
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Base {
int memfcn();
};

struct Derived : Base {
int memfcn(int); // hides memfcn in the base
};

Derived d; Base b;
b.memfcn(); // calls Base::memfcn
d.memfcn(10); // calls Derived::memfcn
d.memfcn(); // error: memfcn with no arguments is hidden
d.Base::memfcn(); // ok: calls Base::memfcn

注意第三个调用,要确定这个调用,编译器需要查找名字 memfcn,并在 Derived 类中找到。一旦找到了名字,编译器就不再继续查找了。这个调用与 Derived 中的 memfcn 定义不匹配,该定义希望接受 int 实参,而这个函数调用没有提供那样的实参,因此出错。

回忆一下,局部作用域中声明的函数不会重载全局作用域中定义的函数,同样,派生类中定义的函数也不重载基类中定义的成员。通过派生类对象调用函数时,实参必须与派生类中定义的版本相匹配,只有在派生类根本没有定义该函数时,才考虑基类函数

重载函数

像其他任意函数一样,成员函数(无论虚还是非虚)也可以重载。派生类可以重定义所继承的 0 个或多个版本。

如果派生类想通过自身类型使用的重载版本,则派生类必须要么重定义所有重载版本,要么一个也不重定义(否则就变成了屏蔽)。

派生类不用重定义所继承的每一个基类版本,它可以为重载成员提供 using 声明。一个 using 声明只能指定一个名字,不能指定形参表,因此,为基类成员函数名称而作的 using 声明将该函数的所有重载实例加到派生类的作用域。将所有名字加入作用域之后,派生类只需要重定义本类型确实必须定义的那些函数,对其他版本可以使用继承的定义。

纯虚函数

如果不想让某个类直接被实例化,可以将它声明为一个抽象基类(abstract base class)。含有(或继承)一个或多个纯虚函数的类是抽象基类。除了作为抽象基类的派生类的对象的组成部分,不能创建抽象类型的对象。

可以使类中的某个函数称为纯虚函数。在函数形参表后面写上 = 0 以指定纯虚函数:

1
2
3
4
class Disc_item : public Item_base {
public:
double net_price(std::size_t) const = 0;
};

将函数定义为纯虚能够说明,该函数为后代类型提供了可以覆盖的接口,但是这个类中的版本决不会调用。重要的是,用户将不能创建 Disc_item 类型的对象。

试图创建抽象基类的对象将发生编译时错误。


  1. 关于初始化列表的详细解释,可以参考《高质量C++/C编程指南》9.2 构造函数的初始化表 ↩︎

Comments