到目前为止,您已经了解了如何在函数中模块化代码,以及如何用类中的代码封装数据。 您还了解了如何使用模板编写泛型代码。 类和封装允许您将代码和数据组合为一个对象。 在本章中,您将学习如何通过继承和组合重用代码,以及如何使用类继承来编写面向对象的代码。
到目前为止,您看到的类都是完整的类:您可以在免费存储或堆栈上创建类的实例。 之所以可以这样做,是因为已经定义了类的数据成员,因此可以计算对象需要多少内存,并且已经提供了类的全部功能。 这些被称为具体类。
如果您在一个类中有一个被证明有用的例程,并且您想在一个新的类中重用,那么您有几个选择。 第一种称为合成。 通过组合,您可以将实用程序类的实例添加为将使用该例程的类的数据成员。 一个简单的例子是string
类--它提供了您想要从字符串中获得的所有功能。 它将根据必须存储的字符数分配内存,并在字符串对象被销毁时释放其使用的内存。 您的类使用字符串的功能,但它本身不是字符串,因此它将字符串作为数据成员。
第二个选项是使用继承。 使用继承的方法有很多种,本章将提到其中的一些。 简而言之,继承是当一个类扩展另一个类时,被扩展的类称为基类、父类或超类,进行扩展的类称为派生类、子类或子类。 但是,对于继承,有一个重要的概念需要理解:派生类与基类的关系。 它通常以IS-a的形式给出。 如果派生类是基类的类型,则关系是继承。 一个 mp3 文件是一个操作系统文件,所以如果您有一个os_file
类,那么您可以合法地从它派生出一个mp3_file
类。
派生类具有基类的功能和状态(尽管它可能不具有对它们的完全访问权限,稍后将对其进行解释),因此它可以使用基类的功能。 在本例中,它类似于作曲。 然而,两者之间存在着显著的差异。 通常,在合成中,合成的对象由类使用,而不是直接向类的客户端公开。 通过继承,派生类的对象是基类的对象,因此客户端代码通常会看到基类功能。 但是,派生类可以隐藏基类的功能,因此客户端代码将看不到隐藏的基类成员,并且派生类可以重写基类方法并提供其自己的版本。
对于应该使用继承还是组合来重用代码,C++ 社区中有很多分歧,而且每种方法都有优缺点。 两者都不是十全十美的,通常需要妥协。
考虑一个包装操作系统的类。 这将提供许多方法来访问通过调用操作系统函数获得的文件的创建日期、修改日期和大小等信息。 它还可以提供打开文件、关闭文件、将文件映射到内存以及其他有用功能的方法。 以下是几位这样的成员:
class os_file
{
const string file_name;
int file_handle;
// other data members
public:
long get_size_in_bytes();
// other methods
};
Mp3 文件是操作系统文件,但有其他操作系统功能可以访问其数据。 我们可以决定创建一个派生自os_file
的mp3_file
类,使其具有操作系统文件的功能,并使用 mp3 文件的功能对其进行扩展:
class mp3_file : public os_file
{
long length_in_secs;
// other data members
public:
long get_length_in_seconds();
// other methods
};
mp3_file
类的第一行指示它使用public**继承(我们稍后将解释公共继承的含义,但值得指出的是,这是从类派生的最常见方式)。 派生类继承数据成员和方法,派生类的用户可以通过派生类使用基类的成员,但要遵守访问说明符。 在本例中,如果某些代码有mp3_file
对象,它可以从mp3_file
类调用get_length_in_seconds
方法,也可以从基类调用get_size_in_bytes
方法,因为该方法是public
。
基类方法最有可能访问基类数据成员,这说明了重要的一点:派生对象包含基类数据成员。 从概念上讲,在内存中,可以将派生对象视为具有派生对象中定义的额外数据成员的基类对象数据成员。 也就是说,派生对象是基类对象的扩展版本。 这一点如下图所示:
在内存中,os_file
对象有两个数据成员file_name
和file_handle
,而mp3_file
对象有这两个数据成员和一个额外的数据成员length_in_secs
。
封装原则在 C++ 中很重要。 虽然mp3_file
对象包含file_name
和file_handle
数据成员,但它们只能由基类方法更改。 在这段代码中,这是通过将它们设置为private
到os_file
类来强制执行的。
当创建派生对象时,必须首先创建基对象(使用适当的构造函数),类似地,当销毁派生对象时,首先销毁对象的派生部分(通过派生类的析构函数),然后再调用基类析构函数。 使用前面文本中讨论的成员,考虑以下代码片段:
class os_file
{
public:
os_file(const string& name)
: file_name(name), file_handle(open_file(name))
{}
~os_file() { close_file(file_handle); }
};
class mp3_file : public os_file
{
public:
mp3_file(const string& name) : os_file(name) {}
~mp3_file() { /* clean up mp3 stuff*/ }
};
open_file
和close_file
函数是一些操作系统函数,用于打开和关闭操作系统文件。
派生类不再需要执行关闭文件的操作,因为基类析构函数~os_file
是在调用派生类析构函数之后自动调用的。 mp3_file
构造函数通过其构造函数成员列表调用基类构造函数。 如果没有显式调用基类构造函数,则编译器将调用基类的默认构造函数作为派生类构造函数的第一个操作。 如果成员列表初始化数据成员,则这些成员将在调用任何基类构造函数后初始化。
派生类继承基类的功能(取决于方法的访问级别),因此可以通过派生类的对象调用基类方法。 派生类可以实现与基类方法具有相同原型的方法,在这种情况下,基类方法被派生类方法重写,并且派生类提供该功能。 派生类通常会重写基类方法以提供特定于派生类的功能;但是,它可以通过使用名称解析运算符调用方法来调用基类方法:
struct base
{
void f(){ /* do something */ }
void g(){ /* do something */ }
};
struct derived : base
{
void f()
{
base::f();
// do more stuff
}
};
请记住,结构是一种class
类型,其中成员默认为public
,继承默认为public
。
在这里,base::f
和base::g
方法将执行该类实例的用户可用的一些操作。 derived
类继承了这两个方法,由于当derived
类的实例调用g
方法时,它不实现方法g
,因此它们实际上将调用base::g
方法。 derived
类实现自己版本的f
方法,因此当derived
类的实例调用f
方法时,它们将调用derived::f
,而不是基类版本。 在此实现中,我们决定需要基类版本的一些功能,因此derived::f
显式调用base::f
方法:
derived d;
d.f(); // calls derived::f
d.g(); // calls base::g
在上一个示例中,该方法在提供自己的实现之前首先调用基类版本。 这里没有具体的惯例。 类库有时是专门为您实现的,以便从基类派生并使用类库代码。 类库的文档将说明您是否需要替换基类实现,或者是否需要添加到基类实现中,如果是,您将在代码之前还是之后调用基类方法。
在此示例中,派生类提供了一个具有确切原型的方法,作为基类上的方法来重写它。 事实上,在基类中添加与方法同名的任何方法都会向使用派生实例的客户端代码隐藏该基类方法。 因此,考虑如下实现derived
类:
struct derived : base
{
void f(int i)
{
base::f();
// do more stuff with i
}
};
在这种情况下,base::f
方法对创建derived
对象的代码隐藏,即使该方法具有不同的原型:
derived d;
d.f(42); // OK
d.f(); // won't compile, derived::f(int) hides base::f
具有相同名称的基类方法被隐藏,因此最后一行将不会编译。 但是,您可以通过提供基类名称来显式调用该函数:
derived d;
d.derived::f(42); // same call as above
d.base::f(); // call base class method
derived *p = &d; // get an object pointer
p->base::f(); // call base class method
delete p;
乍一看,这个语法看起来有点奇怪,但是一旦您知道.
和->
操作符提供对成员的访问,并且操作符后面的符号是成员的名称,在本例中,使用类名和作用域解析操作符显式指定。
通常,到目前为止显示的代码称为实现继承,其中类从基类继承实现。
在 C++ 中,您可以使用&
运算符获得指向对象(内置类型或自定义类型)在内存中所在位置的指针。 指针是类型化的,因此使用该指针的代码假定该指针指向该类型的对象的内存布局。 类似地,您可以获取对对象的引用,该引用是该对象的别名,也就是说,对该引用的操作发生在该对象上。 指向派生类实例的指针(或引用)可以隐式转换为指向基类对象的指针(或引用)。 这意味着您可以使用基类对象的行为编写作用于基类对象的函数,并且只要参数是指向基类的指针或引用,就可以将任何派生类对象传递给该函数。 该函数既不知道也不关心派生类功能。
您应该将派生对象视为基类对象,并接受它可以用作基类对象。 显然,基类指针只能访问基类上的成员:
如果派生类隐藏了基类的成员,这意味着指向派生类的指针将通过成员名称调用派生版本,但基类指针将只看到基类成员,而看不到派生版本。
如果您有基类指针,则可以使用static_cast
将其强制转换为派生类指针:
// bad code
void print_y(base *pb)
{
// be wary of this
derived *pd = static_cast<derived*>(pb);
cout << "y = " << pd->y << endl;
}
void f()
{
derived d;
print_y(&d); // implicit cast to base*
}
这里的问题是print_y
函数如何保证基类指针作为参数传递给特定的派生对象? 如果没有使用该函数的开发人员的约束,它就不能保证他们永远不会传递不同类型的派生类指针。 即使内存中不包含derived
对象,static_cast
运算符也会返回指向该对象的指针。 有一种机制可以对被强制转换的指针执行类型检查,我们将在本章后面介绍这一点。
到目前为止,我们已经看到类成员的两个访问说明符:public
和private
。 在类和中声明的成员可以由类和中的代码访问,类外的代码可以在对象上访问,或者如果成员是static
,则使用类名。 在private
节中声明的成员只能由同一类中的其他成员访问。 派生类可以访问基类的private
成员,但不能访问private
成员。 还有第三种类型的成员访问:protected
。 在protected
部分中声明的成员可以由同一类中的方法访问,也可以由任何派生类中的方法和朋友访问,但不能由外部代码访问:
class base
{
protected:
void test();
};
class derived : public base
{
public:
void f() { test(); }
};
在此代码中,test
方法可以由derived
类中的成员调用,但不能由类外的代码调用:
base b;
b.test(); // won't compile
derived d;
d.f(); // OK
d.test(); // won't compile
如果您编写的基类只打算用作基类(客户端代码不应该创建它的实例),那么使用析构函数protected
是有意义的:
class base
{
public:
// methods available through the derived object
protected:
~base(){}
};
编译器不允许您在空闲存储上创建此类对象,然后使用delete
销毁,因为此操作符将调用析构函数。 同样,编译器不允许您在堆栈上创建对象,因为当对象超出作用域时,编译器将调用不可访问的析构函数。 此析构函数将通过派生类的析构函数调用,因此可以确保对基类进行正确的清理。 这种模式并不意味着您总是打算只使用指向派生类的指针通过调用delete
操作符来销毁对象。
重写派生类中的方法时,对该方法的访问由派生类定义。 因此,如果基类方法是protected
或public
,则派生类可以更改访问权限:
class base
{
protected:
void f();
public:
void g();
};
class derived : public base
{
public:
void f();
protected:
void g();
};
在前面的示例中,base::f
方法是protected
,因此只有derived
类可以访问它。 derived
类覆盖此方法(如果使用完全限定名,则可以调用基类方法)并使其成为public
。 类似地,base::g
方法是public
,但是derived
类覆盖了该方法并使其成为protected
(如果需要,还可以使该方法成为private
)。
还可以使用using
语句将派生类中的protected
基类公开为public
成员:
class base
{
protected:
void f(){ /* code */};
};
class derived: public base
{
public:
using base::f;
};
现在,derived::f
方法是public
,没有派生类创建新方法。 此功能的更好用途是创建一个方法private
,使其不可用于派生类(或者,如果它是public
,则通过实例),或者使其成为protected
,以便外部代码无法访问该成员:
class base
{
public:
void f();
};
class derived: public base
{
protected:
using base::f;
};
前面的代码可以这样使用:
base b;
b.f(); // OK
derived d;
d.f(); // won't compile
最后一行不能编译,因为f
方法是protected
。 如果要使该方法仅在派生类中可用,而不能在可能从它派生的任何类中使用,则可以在派生类的private
部分中使用using
语句;这类似于删除基类方法:
class derived: public base
{
public:
void f() = delete;
void g()
{
base::f(); // call the base class method
}
};
f
方法不能通过derived
类使用,但该类可以调用base
类方法。
前面已经看到,要从类派生,需要提供基类名称并提供继承访问说明符;到目前为止,示例都使用了public
继承,但也可以使用protected
或private
继承。
这是 class 和 struct 之间的另一个区别。 对于类,如果遗漏了继承访问说明符,编译器将假定它是私有的;对于结构,如果遗漏了继承访问说明符,编译器将假定它是公共的。
继承说明符应用了更多的访问限制,它不会放松这些限制。 访问说明符并不确定它对基类成员的访问权限,而是通过派生类更改这些成员的可访问性(即通过类的实例,或者如果另一个类从该类派生)。 如果基类有private
成员,并且类使用public
继承,则派生类仍然不能访问private
成员;它只能访问派生类的public
和protected
成员,并且派生类的对象只能访问public
成员,而从该类派生的类只能访问public
和protected
成员。
如果派生类通过受保护的继承派生,则它对基类的访问权限仍与public
和protected
成员相同,但基类public
和protected
成员现在将通过派生类被视为protected
,因此它们可以由另一个派生类访问,但不能通过实例访问。 如果类通过私有继承派生,则所有基类成员都将成为派生类中的private
;因此,尽管派生类可以访问public
和protected
成员,但从它派生的类不能访问任何基类成员。
看待受保护继承的一种方法是,如果派生类在类的protected
部分中对基类的每个public
成员都有一条using
语句。 类似地,私有继承就像删除了基类的public
和protected
方法一样。
一般来说,大多数继承将通过公共继承进行。 但是,当您希望访问基类中的某些功能但不希望其功能对从您的类派生的类可用时,私有继承很有用。 这有点像合成,在这种情况下,您正在使用功能,但不希望直接公开该功能。
C++ 允许您从多个基类继承。 当与接口一起使用时,这是一个功能强大的工具,我们将在本章后面部分发现这一点。 它对实现继承很有用,但可能会导致一些问题。 语法很简单:提供要继承的类的列表:
class base1 { public: void a(); };
class base2 { public: void b(); };
class derived : public base1, public base2
{
public:
// gets a and b
};
使用多个继承的一种方法是构建每个类都提供某些功能或服务的类库。 要将这些服务添加到您的类中,可以将库中的类添加到基类列表中。 这种通过实现继承创建类的构建块方法存在问题,我们稍后会看到,通常更好的方法是使用组合。
当您考虑多重继承时,仔细检查您是否需要通过继承获得服务,或者组合是否更合适,这一点很重要。 如果某个类提供了一个您不希望实例使用的成员,并且您决定需要将其删除,则应该考虑组合是一个好兆头。
如果两个类都有同名的成员,则存在潜在问题。 最明显的情况是基类有同名的数据成员:
class base1 { public: int x = 1; };
class base2 { public: int x = 2; };
class derived : public base1, public base2 {};
在前面的示例中,两个基类都有一个名为x
的数据成员。 derived
类从这两个类继承,因此这是否意味着它只获得一个名为x
的数据成员? 不是的。 如果是这样,那么这将意味着base1
类将能够在不知道它正在影响另一个类的情况下更改base2
类中的数据成员,类似地,base2
类将发现其数据成员被base1
类更改,即使那个类不是friend
。 因此,当您从具有同名数据成员的两个类派生时,派生类将同时获得这两个数据成员。
这再次说明了维护封装的重要性。 这样的数据成员应该是private
,并且只能由基类更改。
派生类(以及使用实例的代码,如果数据成员是可访问的)可以使用它们的全名来区分它们:
derived d;
cout << d.base1::x << endl; // the base1 version
cout << d.base2::x << endl; // the base2 version
这个类可以用下图来总结,说明了三个类base1
、base2
和derived
占用的内存:
如果保持封装并使数据成员private
并且仅通过访问器方法授予访问权限,则派生类将无法直接访问数据成员,也不会出现此问题。 但是,同样的问题也会发生在方法上,但即使这些方法具有不同的原型,问题也会出现:
class base1 { public: void a(int); };
class base2 { public: void a(); };
class derived : public base1, public base2 {};
在本例中,这两个基类有一个同名的方法a
,但具有不同的原型。 这在使用derived
类时会导致问题,即使通过参数可以明显看出应该调用什么方法:
derived d;
d.a(); // should be a from base2, compiler still complains
这段代码不会编译,编译器会抱怨方法调用不明确。 同样,这个问题的解决方案很简单,您只需要指定要使用哪个基类方法:
derived d;
d.base1::a(42); // the base1 version
d.base2::a(); // the base2 version
多重继承可能会变得更加复杂。 如果您有两个派生自同一基类的类,然后创建另一个派生自这两个基类的类,则会出现问题。 新类是否获得最顶层基类成员的两个副本--通过其每个直接基类获得一个副本?
在第一级继承中,每个类(base1
和base2
)从最终基类继承数据成员(这里,数据成员都称为base::x
,以说明它们是从最终基类base
继承的)。 派生程度最高的类derived
继承了两个数据成员,那么哪个是base::x
呢? 答案是,它们中只有一个是base1::x
是base::x
,因为它是继承列表中的第一个。 当base
方法更改它时,将在base1
到base1::x
中看到更改。 base2::x
成员是单独的数据成员,当base
更改base::x
时不受影响。 这可能是一个意想不到的结果:派生最多的类从两个父类继承x
。
这可能不是您想要的行为。 这个问题通常称为钻石继承问题,从上面的图表可以明显看出这个名称的由来。 解决方案很简单,将在本章后面介绍。
在本章的前面部分,您已经看到,如果使用指向派生对象的基类指针,则只能安全地访问基类成员。 其他成员仍在那里,但它们只能通过适当的派生类指针进行访问。
但是,如果将派生类对象强制转换为基类对象,则会发生其他事情:创建一个新对象,该对象就是基类对象,而只是基类对象。 您已强制转换的变量将只有基类对象的内存,因此结果只有派生对象的基类对象部分:
struct base { /*members*/ };
struct derived : base { /*members*/ };
derived d;
base b1 = d; // slicing through the copy constructor
base b2;
b2 = d; // slicing through assignment
这里,对象b1
和b2
是通过分割derived
类对象d
中的额外数据而创建的。 这段代码看起来有点不对劲,您不太可能编写它,但如果您按值将对象传递给函数,则很可能会发生这种情况:
void f(base b)
{
// can only access the base members
}
如果将derived
对象传递给此函数,则将调用base
复制构造函数来创建新对象,从而分割出derived
类数据成员。 在大多数情况下,您不希望出现此行为。 如果基类具有虚方法并期望虚方法提供的多态功能(虚方法将在本章后面介绍),则此问题也会出现意外行为。 通过引用传递对象几乎总是一个更好的主意。
多态来自希腊语许多形状。 到目前为止,您已经了解了多态性的基本形式。 如果使用指向对象的基类指针,则可以访问基类行为,如果有派生类指针,则会获得派生类行为。 这并不像看起来那么简单,因为派生类可以实现其自己版本的基类方法,因此您可以拥有该行为的不同实现。
一个基类可以派生多个类:
class base { /*members*/ };
class derived1 : public base { /*members*/ };
class derived2 : public base { /*members*/ };
class derived3 : public base { /*members*/ };
由于 C++ 是强类型的,这意味着指向一个派生类的指针不能用于指向另一个派生类。 因此不能使用derived1*
指针访问derived2
的实例,它只能指向类型为derived1
的对象。 即使类具有相同的成员,它们仍然是不同的类型,它们的指针也不同。 但是,所有派生类都有一个共同点,那就是基类。 派生类指针可以隐式转换为基类指针,因此base*
指针可以指向base
、derived1
、derived2
或derived3
的实例。 这意味着可以向接受base*
指针作为参数的泛型函数传递指向这些类中任何一个的指针。 这是接口的基础,我们将在后面看到。
多态方面是,通过指针(或引用),类的实例可以被视为其继承层次结构中任何类的实例。
仅提供对基类功能的访问的基类指针或引用,这是有意义的,但它是有限制的。 如果您有一个为汽车提供接口的car
类、一个油门踏板和一个用于改变速度的刹车、一个方向盘和一个倒档来改变方向-您可以从这个类派生出各种其他类型的汽车:跑车、SUV 或家用轿车。 当你踩油门时,如果你的车是 SUV,你希望它有 SUV 的扭矩,如果你的车是跑车,你希望它有跑车的速度。 类似地,如果在car
指针上调用accelerate
方法,且该指针指向suv
,则期望获得反映 SUV 扭矩的方法,如果car
指针指向sportscar
对象,则性能加速。 前面我们说过,如果您通过基类指针访问派生类实例,那么您将获得基类方法的实现。 这意味着,在指向suv
或sportscar
对象的car
指针上调用accelerate
方法时,仍然会得到car::accelerate
的实现,而不是您想要的suv::accelerate
或sportscar::accelerate
。
这种通过基类指针调用派生方法的行为称为方法调度。 通过基类指针调用方法的代码不知道指针指向的对象类型,但它仍然获得该对象的功能,因为调用了该对象上的方法。 默认情况下不应用此方法分派,因为它在内存和性能方面都会涉及一些额外成本。
可以参与方法调度的方法在基类中用关键字virtual
标记,因此通常称为虚方法。 当您通过基类指针调用此类方法时,编译器会确保调用实际对象的类上的方法。 由于每个方法都有一个this
指针作为隐藏参数,因此方法调度机制必须确保在调用该方法时this
指针是合适的。 请考虑以下示例:
struct base
{
void who() { cout << "base "; }
};
struct derived1 : base
{
void who() { cout << "derived1 "; }
};
struct derived2 : base
{
void who() { cout << "derived2 "; }
};
struct derived3 : derived2
{
void who() { cout << "derived3 "; }
};
void who_is_it(base& r)
{
p.who();
}
int main()
{
derived1 d1;
who_is_it(d1);
derived2 d2;
who_is_it(d2);
derived3 d3;
who_is_it(d3);
cout << endl;
return 0;
}
有一个基类和两个子类derived1
和derived2
。 通过derived2
到称为derived3
的类还有更深一层的继承。 基类实现了一个名为who
的方法,该方法打印类名。 在每个派生类上都适当地实现了此方法,因此当在derived3
的对象上调用此方法时,该方法将在控制台中打印derived3
。 main
函数创建每个派生类的实例,并通过引用调用who
方法的名为who_is_it
的函数来传递每个派生类。 该函数有一个引用base
的参数,因为这是所有类的基类(对于derived3
,它的直接基类是derived2
)。 运行此代码时,结果如下所示:
base base base
此输出来自对who_is_it
函数的三次调用,传递的对象是derived1
、derived2
和derived3
类的实例。 由于该参数是对base
的引用,这意味着调用了base::who
方法。
做一个简单的改变就会彻底改变这一行为:
struct base
{
virtual void who() { cout << "base "; }
};
所有的改变都是在基类中的who
方法中添加了virtual
关键字,但结果是显著的。 运行此代码时,结果如下所示:
derived1 derived2 derived3
您没有更改who_is_it
函数,也没有更改派生类上的方法,但是who_is_it
的输出与以前大不相同。 who_is_it
函数通过引用调用who
方法,但是现在,调用引用别名的实际对象上的who
方法而不是调用base::who
方法。 who_is_it
函数没有做任何额外的工作来确保调用派生类函数--它与前面的完全相同。
*derived3
类不是直接从base
派生的,而是从derived2
派生的,derived2
本身就是base
的子类。 即便如此,方法分派仍然适用于derived3
类的实例。 这说明无论继承链virtual
应用到哪一层,方法调度仍将作用于派生类的继承方法。
需要指出的是,方法分派仅应用于基类中应用了virtual
的方法和。 基类中未标记为virtual
的任何其他方法都将在不进行方法调度的情况下被调用。 派生类将继承virtual
方法并自动获得方法分派,它不必在其重写的任何方法上使用virtual
关键字,但对于如何调用该方法是一个有用的可视指示。
通过实现virtual
方法的派生类,您可以使用单个容器来保存指向所有此类实例的指针,并调用它们的virtual
方法,而无需调用代码知道对象的类型:
derived1 d1;
derived2 d2;
derived3 d3;
base *arr[] = { &d1, &d2, &d3 };
for (auto p : arr) p->who();
cout << endl;
在这里,arr
内置数组保存指向这三种类型的对象的指针,Rangefor
循环遍历数组并虚拟调用该方法。 这提供了预期的结果:
derived1 derived2 derived3
关于前面的代码,有三个要点:
- 这里使用内置数组很重要;标准库容器(如
vector
)存在问题。 - 重要的是,数组保存的是指针,而不是对象。 如果您有一个由
base
个对象组成的数组,它们将通过切片派生对象进行初始化。 - 使用堆栈对象的地址也很重要。 这是因为析构函数存在问题。
后面几节将介绍这三个问题。
对于要使用方法调度调用的virtual
方法,派生类方法必须在名称、参数和返回类型方面与基类的virtual
方法匹配相同的签名。 如果其中任何一个是不同的(例如,不同的参数),那么编译器会认为派生方法是一个新函数,因此当您通过基指针调用virtual
方法时,您将获得基方法。 这是一个相当隐蔽的错误,因为代码可以编译,但您会得到错误的行为。
最后一段的一个例外是,如果两个方法的返回类型不同,它们是协变的,也就是说,一种类型可以转换为另一种类型。
您只需要知道通过虚方法进行方法分派的行为,但是了解 C++ 编译器如何实现方法分派会很有帮助,因为它突出了virtual
方法的开销。
当编译器在类上看到virtual
方法时,它将创建一个名为vtable的方法指针表,并在表中放置指向类中每个virtual
方法的指针。 这门课将有一份vtable
的复印件。 编译器还将在类的每个实例中添加一个指向该表的指针,称为vptr。 因此,当您将一个方法标记为virtual
时,将会有一个在运行时为该类创建的vtable
的内存开销,以及从该类创建的每个对象的额外数据成员vptr
的内存开销。 通常,当客户端代码调用(非内联)方法时,编译器会将跳转放置到该方法的客户端代码中的函数。 当客户端代码调用virtual
方法时,编译器必须取消引用vptr
以获得vtable
,然后使用存储在那里的适当地址。 显然,这涉及到额外的间接性。
对于基类中的每个virtual
方法,在vtable
中都有一个单独的条目,按照它们被声明的顺序。 当您使用virtual
方法从基类派生时,派生类也将具有vptr
,但编译器将使其指向派生类的vtable
,即编译器将使用派生类中的virtual
方法实现的地址填充vtable
。 如果派生类没有实现它继承的virtual
方法,则vtable
中的指针将指向基类方法。 这一点如下图所示:
在左侧,有两个类;基类有两个虚函数,派生类只实现其中一个。 右手边是内存布局的插图。 两个对象显示为base
对象和derived
对象。 每个对象都有一个vptr
,后面跟着类的数据成员,数据成员的排列方式是先排列基类数据成员,然后排列派生类数据成员。 vtable
指针包含指向virtual
方法的方法指针。 在基类的情况下,方法指针指向在base
类上实现的方法。 对于派生类,在derived
类中只实现了第二个方法,因此该类的vtable
具有指向base
类中的一个虚方法和derived
类中的另一个虚方法的指针。
这就提出了一个问题:如果派生类引入了基类中不可用的新方法,并使其成为virtual
,会发生什么情况? 这并不是不可想象的,因为最终的基类可以只提供所需行为的一部分,而派生自它的类可以提供更多通过子类上的虚方法调度来调用的行为。 实现非常简单:编译器为类上的所有virtual
方法创建一个vtable
,因此如果派生类有额外的virtual
方法,则这些方法的指针出现在指向从基类继承的virtual
方法的指针之后的vtable
中。 当通过基类指针调用对象时,无论该类在继承层次结构中的哪个位置,它都将只看到与其相关的vtable
个条目:
如果一个类从多个类派生,并且父类具有virtual
方法,则派生类的 vtable 将是其父类的 vtable 的组合,按照父类在派生列表中列出的顺序排列:
如果通过基类指针访问对象,则vptr
可以访问与该基类相关的vtable
部分。
在构造函数完成之前,不会构造对象的派生类部分,因此如果调用virtual
方法,则不会设置vtable
条目来调用正确的方法。 类似地,在析构函数中,对象的派生类部分已经被销毁-包括它们的数据成员,因此派生类上的virtual
方法不能被调用,因为它们可能试图访问不再存在的数据成员。 如果在这些情况下允许virtual
方法调度,结果将是不可预测的。 不应在构造函数或析构函数中调用virtual
方法,如果这样做,调用将解析为该方法的基类版本。
如果期望使用virtual
方法分派通过基类指针调用一个类,则应该将析构函数设置为virtual
。 我们这样做是因为用户可能会删除基类指针,在这种情况下,您会希望调用派生析构函数。 如果析构函数不是virtual
,并且基类指针被删除,则只调用基类析构函数,这可能会导致内存泄漏。
通常,基类析构函数应该是protected
非虚的,或者是public
和virtual
的。 如果打算通过基类指针使用类,则析构函数应该是public
和virtual
,以便调用派生类析构函数,但是如果基类打算用于提供只能通过派生类对象提供的服务,则不应该直接访问基类对象,因此析构函数应该是protected
和非虚的。
virtual
方法的一个优点是将基类相关的对象放入容器中;前面,我们看到了使用内置基类指针数组的特定情况,但是标准库容器呢? 例如,假设您有一个类层次结构,其中有一个基类base
和三个派生类derived1
、derived2
和derived3
,每个类都实现了前面使用的virtual
方法who
。 将对象放入容器的一种尝试可能如下所示:
derived1 d1;
derived2 d2;
derived3 d3;
vector<base> vec = { d1, d2, d3 };
for (auto b : vec) b.who();
cout << endl;
问题是向量包含base
个对象,因此当初始化列表中的项被放入容器中时,它们实际上用于初始化新的base
对象。 因为vec
的类型是vector<base>
,所以push_back
方法将对对象进行切片。 因此,对每个对象调用who
方法的语句将打印一个字符串base
。
为了实现virtual
方法分派,我们需要将整个对象放入容器中。 我们可以使用指针或引用来完成此操作。 要使用指针,只要vector
不比容器中的对象存活时间长,就可以使用堆栈对象的地址。 如果您使用在堆上创建的对象,则需要确保适当地删除这些对象,您可以使用智能指针来实现这一点。
您可能会想创建一个引用容器:
vector<base&> vec;
这将导致一系列错误;不幸的是,这些错误中没有一个完全指出了问题。 vector
必须包含可复制、可构造和可赋值的类型。 引用则不是这样,因为它们是实际对象的别名。 有一个解决方案。 <functional>
标头包含名为reference_wrapper
的适配器类,该适配器类具有复制构造函数和赋值运算符。 该类将对象的引用转换为指向该对象的指针。 现在您可以编写以下内容:
vector<reference_wrapper<base> > vec = { d1, d2, d3 };
for (auto b : vec) b.get().who();
cout << endl;
使用reference_wrapper
的缺点是,要调用包装对象(及其虚拟方法),需要调用get
方法,该方法将返回对包装对象的引用。
在 C++ 中,友谊不是继承的。 如果一个类使另一个类(或函数)成为朋友,这意味着该朋友可以访问其private
和protected
成员,就好像该朋友是该类的成员一样。 如果从friend
类派生,则新类不是第一个类的朋友,并且它没有访问第一个类的成员的权限。
在上一章中,我们了解了如何通过编写全局插入操作符并使其成为类的friend
来将对象插入到ostream
对象中以打印它。 在下面的示例中,friend
函数是内联实现的,但它实际上是一个单独的全局函数,无需使用类名进行对象或名称解析即可调用:
class base
{
int x = 0;
public:
friend ostream& operator<<(ostream& stm, const base& b)
{
// thru b we can access the base private/protected members
stm << "base: " << b.x << " ";
return stm;
}
};
如果我们派生自base
类,则需要实现一个friend
函数来将派生对象插入到流中。 由于该函数是朋友,因此它可以访问派生类的private
和protected
成员,但不能访问基类的private
成员。 这种情况意味着,作为派生类的朋友的插入操作符只能打印出对象的一部分。
如果将derived
类对象强制转换为base
类,例如,在按引用传递时通过指针或引用,并且打印该对象,则将调用base
版本的插入运算符。 插入运算符是friend
函数,因此它可以访问类的非公共数据成员,但作为朋友不足以使其成为virtual
方法,因此没有virtual
方法调度。
虽然friend
函数不能作为virtual
方法调用,但它可以调用virtual
方法并得到方法调度:
class base
{
int x = 0;
protected:
virtual void output(ostream& stm) const { stm << x << " "; }
public:
friend ostream& operator<<(ostream& stm, const base& b)
{
b.output(stm);
return stm;
}
};
class derived : public base
{
int y = 0;
protected:
virtual void output(ostream& stm) const
{
base::output(stm);
stm << y << " ";
}
};
在这个版本中,只有一个插入运算符,它是为base
类定义的。 这意味着可以使用此运算符打印任何可以转换为base
类的对象。 打印输出对象的实际工作被委托给名为output
的virtual
函数。 此函数受保护,因为它仅供类或派生类使用。 它的base
类版本打印出基类的数据成员。 derived
类版本有两个任务:打印出base
类中的数据成员,然后打印出特定于derived
类的数据成员。 第一个任务通过使用基类名称限定名称来调用方法的base
类版本来完成。 第二个任务很简单,因为它可以访问自己的数据成员。 如果要从derived
派生另一个类,则其output
函数的版本将类似,但它将调用derived::output
。
现在,当对象插入到类似cout
的ostream
对象中时,将调用插入操作符,并将对output
方法的调用分派给适当的派生类。
如前所述,如果您输入了错误的派生virtual
方法的原型,例如,使用了错误的参数类型,编译器将把该方法视为新方法并对其进行编译。 派生类不重写基类的方法是完全合法的;这是您经常想要使用的功能。 但是,如果您在键入派生virtual
方法的原型时出错,则在您打算调用新版本时将调用基方法。 override
说明符旨在防止此错误。 当编译器看到这个说明符时,它知道您打算重写从基类继承的virtual
方法,并且它将搜索继承链以找到合适的方法。 如果找不到这样的方法,则编译器将发出错误:
struct base
{
virtual int f(int i);
};
struct derived: base
{
virtual int f(short i) override;
};
在这里,derived::f
将不会编译,因为继承链中没有具有相同签名的方法。 override
说明符让编译器执行一些有用的检查,因此在所有派生覆盖的方法上使用它是一个好习惯。
C++ 11 还提供了一个名为final
的说明符,您可以将其应用于方法以指示派生类不能重写它,也可以将其应用于类以指示您不能从其派生:
class complete final { /* code */ };
class extend: public complete{}; // won't compile
你很少会想要用这个。
早些时候,我们讨论了所谓的多重继承的菱形问题,即一个类通过两个基类从单个祖先类继承。 当一个类从另一个类继承时,它将获取父类的数据成员,以便将派生类的实例视为由基类数据成员和派生类数据成员组成。 如果父类派生自同一祖先类,则它们将各自获得祖先类的数据成员,从而导致最终派生类从每个父类获得祖先类的数据成员的副本:
struct base { int x = 0; };
struct derived1 : base { /*members*/ };
struct derived2 : base { /*members*/ };
struct most_derived : derived1, derived2 { /*members*/ };
创建most_derived
类的实例时,对象中有两个base
副本:分别来自derived1
和derived2
。 这意味着most_derived
对象将拥有数据成员x
的两个副本。 显然,目的是让派生类只获得祖先类的数据成员的一个副本,那么如何实现这一点呢? 此问题的解决方案是虚拟继承:
struct derived1 : virtual base { /*members*/ };
struct derived2 : virtual base { /*members*/ };
在没有虚拟继承的情况下,派生类只调用其直接父级的构造函数。 使用virtual
继承时,most_derived
类负责调用最顶层父类的构造函数,如果不显式调用基类构造函数,编译器将自动调用默认构造函数:
derived1::derived1() : base(){}
derived2::derived2() : base(){}
most_derived::most_derived() : derived1(), derived2(), base(){}
在前面的代码中,most_derived
构造函数调用base
构造函数,因为这是其父类虚拟继承的基类。 virtual
基类总是在非虚拟基类之前创建。 尽管在most_derived
构造函数中调用了base
构造函数,但我们仍然必须在派生类中调用base
构造函数。 如果我们进一步从most_derived
派生,则该类还必须调用base
的构造函数,因为将在那里创建base
对象。 虚拟继承比单一或多重继承更昂贵。
具有virtual
方法的类仍然是个具体类--您可以创建该类的实例。 您可能决定只提供部分功能,目的是让用户具有从类派生并添加缺少的功能。
要做到这一点,一种方法是提供一个没有代码的virtual
方法。 这意味着您可以在类中调用virtual
方法,并且在运行时将调用派生类中的方法版本。 然而,尽管这为您在代码中调用派生方法提供了一种机制,但它并不强制实现那些virtual
方法。 相反,派生类将继承空的virtual
方法,如果不覆盖它们,客户端代码将能够调用空方法。 您需要一种机制来强制派生类提供那些virtual
方法的实现。
C++ 提供了一种称为纯虚方法的机制,它指示该方法应该由派生类覆盖。 语法很简单,您可以用= 0
标记该方法:
struct abstract_base
{
virtual void f() = 0;
void g()
{
cout << "do something" << endl;
f();
}
};
这是完整的类;这是该类为方法f
的定义提供的全部内容。 即使方法g
调用没有实现的方法,该类也会编译。 但是,以下代码将不会编译:
abstract_base b;
通过声明纯虚函数,您可以使类成为抽象的,这意味着您不能创建实例。 但是,您可以创建指向该类的指针或引用,并对其调用代码。 此函数将编译:
void call_it(abstract_base& r)
{
r.g();
}
该函数只知道类的公共接口,并不关心它是如何实现的。 我们实现了方法g
来调用方法f
,以说明您可以在同一个类中调用纯虚方法。 实际上,您也可以在类外部调用纯虚函数;下面的代码同样有效:
void call_it2(abstract_base& r)
{
r.f();
}
使用抽象类的唯一方法是从它派生并实现纯虚函数:
struct derived1 : abstract_base
{
virtual void f() override { cout << "derived1::f" << endl; }
};
struct derived2 : abstract_base
{
virtual void f() override { cout << "derived2::f" << endl; }
};
下面是从抽象类派生的两个类,它们都实现纯虚函数。 这些是具体的类,您可以创建它们的实例:
derived1 d1;
call_it(d1);
derived2 d2;
call_it(d2);
抽象类用于指示特定功能必须由派生类提供,而= 0
语法指示方法体不是由抽象类提供的。 事实上,它比这更微妙;类必须是派生的,派生类上调用的方法必须在派生类上定义,但抽象基类也可以为方法提供主体:
struct abstract_base
{
virtual int h() = 0 { return 42; }
};
同样,这个类不能实例化,您必须从它派生,并且您必须实现该方法才能实例化对象:
struct derived : abstract_base
{
virtual int h() override { return abstract_base::h() * 10; }
};
派生类可以调用抽象类中定义的纯虚函数,但当外部代码调用此类方法时,它总是(通过方法调度)调用派生类上的虚方法的实现。
C++ 提供类型信息,也就是说,您可以获得该类型唯一的信息,以及标识该类型的信息。 C++ 是一种强类型语言,因此编译器将在编译时确定类型信息,并在变量类型之间进行转换时强制执行类型规则。 编译器执行的任何类型检查,您都可以作为开发人员执行。 根据一般经验,如果您需要使用static_cast
、const_cast
、reinterpret_cast
或类似 C 的强制转换进行强制转换,那么您将使类型做一些它们不应该做的事情,因此您应该重新考虑重写代码。 编译器非常善于告诉您哪里有类型不对齐的地方,因此您应该将此作为重新评估代码的提示。
不强制转换规则可能有点太严格,而且使用强制转换的代码通常更易于编写和阅读,但这样的规则确实会让您始终质疑是否需要强制转换。
当您使用多态性时,您通常会得到一个指向与对象类型不同的类型的指针或引用,当您转到接口编程时尤其如此,在接口编程中,实际对象通常并不重要,因为它是重要的行为。 在某些情况下,您可能需要获取类型信息,而编译器在编译时无法帮助您。 C++ 提供了一种获取类型信息的机制,称为Runtime Type Information(RTTI),因为您可以在运行时获取此信息。 此信息是使用对象上的typeid
运算符获得的:
string str = "hello";
const type_info& ti = typeid(str);
cout << ti.name() << endl;
在命令行中打印的结果如下所示:
class std::basic_string<char,struct std::char_traits<char>,
class std::allocator<char> >
这反映了string
类实际上是模板化类basic_string
的typedef
,具有char
作为字符类型,具有由char_traits
类的专门化描述的字符特征,以及一个分配器对象(用于维护字符串使用的缓冲区),它是allocator
类的专门化。
typeid
操作符返回对type_info
对象的const
引用,在本例中,我们使用name
方法返回指向对象类型名称的const char
指针。 这是类型名称的可读版本。 类型名实际上存储在紧凑的修饰名中,这是通过raw_name
方法获得的,但如果您想根据对象的类型(例如,在字典对象中)存储对象,那么更有效的机制是使用从hash_code
方法返回的 32 位整数,而不是修饰名。 在所有情况下,相同类型的所有对象的返回值都相同,但与其他类型的对象不同。
type_info
类没有复制构造函数或复制赋值运算符,因此此类的对象不能放入容器中。 如果要将type_info
对象放入类似map
的关联容器中,则有两个选择。 首先,您可以将指向type_info
对象的指针放入容器中(指针可以从引用中获得);在这种情况下,如果容器是有序的,则需要定义一个比较运算符。 type_info
类有一个before
方法,可用于比较两个type_info
对象。
第二个选项(在 C++ 11 中)是使用type_index
类的对象作为关联容器的键,该类用于包装type_info
对象。
type_info
类是只读的,创建实例的唯一方法是通过typeid
运算符。 但是,您可以对type_info
对象调用比较运算符==
和!=
,这意味着您可以在运行时比较对象的类型。
由于您可以在变量和类型上应用typeid
运算符,这意味着您可以使用该运算符执行安全的强制转换,这些类型不会被切片或强制转换为完全不相关的类型:
struct base {};
struct derived { void f(); };
void call_me(base *bp)
{
derived *dp = (typeid(*bp) == typeid(derived))
? static_cast<derived*>(bp) : nullptr;
if (dp != nullptr) dp->f();
}
int main()
{
derived d;
call_me(&d);
return 0;
}
此函数可以接受从base
类派生的任何类的指针。 第一行使用条件运算符,其中比较是函数参数指向的对象的类型信息与类derived
的类型之间的比较。 如果指针指向derived
对象,则强制转换将起作用。 如果指针指向另一个派生类型的对象,而不是derived
类,则比较将失败,并且表达式的计算结果为nullptr
。 只有当指针指向derived
类的实例时,call_me
函数才会调用f
方法。
C++ 提供了执行运行时的强制转换操作符,这种运行时的类型检查称为dynamic_cast
。 如果可以将对象强制转换为请求的类型,则操作将成功并返回有效指针。 如果无法通过请求的指针访问对象,则强制转换失败,操作符返回nullptr
。 这意味着无论何时使用dynamic_cast
,都应该在使用之前检查返回的指针。 可以按如下方式重写call_me
函数:
void call_me(base *bp)
{
derived *dp = dynamic_cast<derived*>(bp);
if (dp != nullptr) dp->f();
}
这基本上与前面的代码相同;dynamic_cast
操作符执行运行时类型检查并返回适当的指针。
请注意,您既不能向下转换到virtual
基类指针,也不能向下转换到通过protected
或private
继承派生的类。 dynamic_cast
运算符可以用于除向下强制转换之外的强制转换;显然,它将用于向上强制转换(到基类,尽管不是必需的),它可以用于横向强制转换:
struct base1 { void f(); };
struct base2 { void g(); };
struct derived : base1, base2 {};
这里有两个基类,因此如果通过其中一个基类指针访问派生对象,则可以使用dynamic_cast
运算符强制转换为另一个基类的指针:
void call_me(base1 *b1)
{
base2 *b2 = dynamic_cast<base2*>(b1);
if (b2 != nullptr) b2->g();
}
如果要使用动态创建的对象,则需要使用智能指针来管理其生存期。 好消息是virtual
方法分派通过智能指针工作(它们只是对象指针的包装器),坏消息是在使用智能指针时类关系会丢失。 让我们来研究一下原因。
例如,以下两个类通过继承相关:
struct base
{
Virtual ~base() {}
virtual void who() = 0;
};
struct derived : base
{
virtual void who() { cout << "derivedn"; }
};
这很简单:它实现了一个virtual
方法,该方法指示对象的类型。 有一个virtual
析构函数,因为我们要将生存期管理移交给一个智能指针对象,并且我们希望确保正确调用derived
类析构函数。 您可以使用make_shared
或shared_ptr
类的构造函数在堆上创建对象:
// both of these are acceptable
shared_ptr<base> b_ptr1(new derived);
shared_ptr<base> b_ptr2 = make_shared<derived>();
派生类指针可以转换为基类指针,这在第一条语句中是显式的:new
返回derived*
指针,该指针被传递给需要base*
指针的shared_ptr<base>
构造函数。 第二个声明中的情况稍微复杂一些。 函数的作用是:返回一个临时的shared_ptr<derived>
对象,该对象被转换为一个shared_ptr<base>
对象。 这是由shared_ptr
类上的转换构造函数执行的,该构造函数调用名为__is_convertible_to
的编译器内部,它确定是否可以将一种指针类型转换为另一种指针类型。 在本例中,存在向上转换,因此允许转换。
编译器内部本质上是由编译器提供的函数。 在本例中,__is_convertible_to(derived*, base*)
将返回true
,__is_convertible_to(base*, derived*)
将返回false
。 除非您正在编写库,否则您几乎不需要了解内部函数。
由于临时对象是使用make_shared
函数在语句中创建的,因此使用第一个语句效率更高。
shared_ptr
对象上的operator->
将提供对包装指针的直接访问,因此这意味着以下代码将按照预期执行virtual
方法调度:
shared_ptr<base> b_ptr(new derived);
b_ptr->who(); // prints "derived"
智能指针将确保在b_ptr
超出作用域时通过基类指针销毁派生对象,由于我们有virtual
析构函数,因此将发生适当的销毁。
如果你有多重继承,你可以使用dynamic_cast
(和 RTTI)在指向基类的指针之间进行转换,这样你就可以只选择你需要的行为。 请考虑以下代码:
struct base1
{
Virtual ~base1() {}
virtual void who() = 0;
};
struct base2
{
Virtual ~base2() {}
virtual void what() = 0;
};
struct derived : base1, base2
{
virtual void who() { cout << "derivedn"; }
virtual void what() { cout << "derivedn"; }
};
如果您有指向这两个基类之一的指针,则可以将一个基类转换为另一个基类:
shared_ptr<derived> d_ptr(new derived);
d_ptr->who();
d_ptr->what();
base1 *b1_ptr = d_ptr.get();
b1_ptr->who();
base2 *b2_ptr = dynamic_cast<base2*>(b1_ptr);
b2_ptr->what();
可以在derived*
指针上调用who
和what
方法,因此可以在智能指针上调用它们。 以下几行获得一个基类指针,以便访问特定的行为。 在此代码中,我们调用get
方法从智能指针获取原始指针。 此方法的问题在于,现在有一个指向不受智能指针生存期管理保护的对象的指针,因此代码可能会在指针b1_ptr
或b2_ptr
上调用delete
,并在以后智能指针尝试删除该对象时导致问题。
这段代码可以工作,并且在这段代码中对动态创建的对象进行了正确的生存期管理,但是像这样访问原始指针本质上是不安全的,因为不能保证原始指针不会被删除。 诱人之处在于使用智能指针:
shared_ptr<base1> b1_ptr(d_ptr.get());
问题是,即使类base1
和derived
相关,但类shared_ptr<derived>
和shared_ptr<base1>
不是相关的,因此每个智能指针类型将使用不同的控制块,即使它们引用相同的对象*。 shared_ptr
类将使用控制块引用计数,并在引用计数降为零时删除对象。 拥有两个不相关的shared_ptr
对象和同一个对象的两个控制块意味着它们将尝试彼此独立地管理derived
对象的生命周期,这最终将意味着一个智能指针在另一个智能指针完成对象之前将其删除。*
这里有三条消息:智能指针是指针周围的轻量级包装器,因此您可以使用方法分派来调用virtual
方法;但是,在使用从智能指针获得的原始指针时要小心,请记住,尽管可以有多个shared_ptr
对象指向同一对象,但它们必须是相同类型的,以便只使用一个控制块。
纯虚函数和虚方法分派导致了一种非常强大的编写面向对象代码的方式,称为接口。 接口是没有功能的类;它只有纯虚函数。 接口的目的是定义行为。 从接口派生的具体类必须提供该接口上所有方法的实现,因此这使得该接口成为一种契约。 实现接口的对象的用户可以保证,具有该接口的对象将实现该接口的所有个个方法。 接口编程将行为与实现解耦。 客户端代码只对行为感兴趣,而对提供接口的实际类不感兴趣。
例如,通过IPrint
接口可以访问打印文档的行为(设置页面大小、方向、份数,并告诉打印机打印文档)。 通过IScan
界面可以访问扫描纸张的行为(分辨率、灰度或颜色,以及旋转和裁剪等调整)。 这两个界面是两种不同的行为。 如果要打印文档,客户端代码将使用IPrint
,如果要扫描文档,则使用IScan
接口指针。 这样的客户端代码并不关心它是实现IPrint
接口的printer
对象还是同时实现IPrint
和IScan
接口的printer_scanner
对象。 传递给IPrint*
接口指针的客户端代码保证可以调用每个方法。
在下面的代码中,我们定义了IPrint
接口(define
使我们更明显地将抽象类定义为接口):
#define interface struct
interface IPrint
{
virtual void set_page(/*size, orientation etc*/) = 0;
virtual void print_page(const string &str) = 0;
};
类可以实现此接口:
class inkjet_printer : public IPrint
{
public:
virtual void set_page(/*size, orientation etc*/) override
{
// set page properties
}
virtual void print_page(const string &str) override
{
cout << str << endl;
}
};
void print_doc(IPrint *printer, vector<string> doc);
然后,您可以创建printer
对象并调用函数:
inkjet_printer inkjet;
IPrint *printer = &inkjet;
printer->set_page(/*properties*/);
vector<string> doc {"page 1", "page 2", "page 3"};
print_doc(printer, doc);
我们的喷墨打印机也是扫描仪,所以我们可以让它实现IScan
接口:
interface IScan
{
virtual void set_page(/*resolution etc*/) = 0;
virtual string scan_page() = 0;
};
下一版本的inkject_printer
类可以使用多重继承来实现此接口,但请注意存在一个问题。 该类已经实现了一个名为set_page
的方法,由于打印机的页面属性将不同于扫描仪的页面属性,因此我们希望为IScan
接口使用不同的方法。 我们可以通过两种不同的方法来解决这个问题,并限定它们的名称:
class inkjet_printer : public IPrint, public IScan
{
public:
virtual void IPrint::set_page(/*etc*/) override { /*etc*/ }
virtual void print_page(const string &str) override
{
cout << str << endl;
}
virtual void IScan::set_page(/*etc*/) override { /*etc*/ }
virtual string scan_page() override
{
static int page_no;
string str("page ");
str += to_string(++ page_no);
return str;
}
};
void scan_doc(IScan *scanner, int num_pages);
现在,我们可以获取inkjet
对象上的IScan
接口,并将其称为 scanner:
inkjet_printer inkjet;
IScan *scanner = &inkjet;
scanner->set_page(/*properties*/);
scan_doc(scanner, 5);
由于inkject_printer
类派生自IPrinter
和IScan
接口,因此您可以获取一个接口指针并通过dynamic_cast
操作符强制转换为另一个接口指针,因为这将使用 RTTI 来确保强制转换是可能的。 因此,假设您有一个IScanner
接口指针,您可以进行测试,看看是否可以将其转换为IPrint
接口指针:
IPrint *printer = dynamic_cast<IPrint*>(scanner);
if (printer != nullptr)
{
printer->set_page(/*properties*/);
vector<string> doc {"page 1", "page 2", "page 3"};
print_doc(printer, doc);
}
实际上,如果指针所指向的对象上另一个接口表示的行为不可用,则使用dynamic_cast
运算符请求一个接口指针。
接口是一种约定;一旦您定义了它,您就应该永远不要更改它。 这不会限制您更改类。 事实上,这是使用接口的优点,因为类实现可以完全更改,但只要它继续实现客户端代码使用的接口,类的用户就可以继续使用该类(尽管需要重新编译)。 有些情况下,您会发现您定义的接口不够用。 可能有一个输入错误的参数需要修复,或者可能需要添加其他功能。
例如,假设您要告诉打印机对象一次打印整个文档,而不是一页。 方法是从需要更改的接口派生并创建一个新接口;接口继承:
interface IPrint2 : IPrint
{
virtual void print_doc(const vector<string> &doc) = 0;
};
接口继承意味着IPrint2
有三个方法:set_page
、print_page
和print_doc
。 由于IPrint2
接口是IPrint
接口,这意味着当您实现IPrint2
接口时,您也实现了IPrint
接口,因此您需要将类更改为从IPrint2
接口派生以添加新功能:
class inkjet_printer : public IPrint2, public IScan
{
public:
virtual void print_doc(const vector<string> &doc) override {
/* code*/
}
// other methods
};
从实现IPrint
接口开始,IPrint2
接口上的另外两个方法已经存在于这个类中。 现在,客户端可以从该类的实例中获取IPrint
指针和IPrint2
指针。 您已经扩展了类,但旧的客户端代码仍将编译。
微软的组件对象模型(COM)将这一概念更进一步。 COM 基于接口编程,因此 COM 对象只能通过接口指针访问。 额外的步骤是,可以使用动态加载库将此代码加载到您的进程中,或者加载到您的计算机或另一台计算机上的另一个进程中,而且由于您使用接口编程,因此无论对象位于什么位置,都会以完全相同的方式访问这些对象。
*# 阶级关系
继承似乎是重用代码的理想方式:以尽可能泛型的方式编写一次,然后从基类派生一个类并重用代码,必要时对其进行专门化。 然而,你会发现很多反对这一点的建议。 有些人会告诉您,继承是重用代码的最糟糕的方式,您应该使用组合。 事实上,情况介于两者之间:继承提供了一些好处,但不应被视为最佳或唯一的解决方案。
设计类库是有可能的,而且有一个总的原则需要牢记:您编写的代码越多,您(或其他人)需要做的维护工作就越多。 如果更改一个类,则依赖它的所有其他类也会更改。
在最高级别,您应该意识到要避免的三个主要问题:
- 刚性:更改一个类太难了,因为任何更改都会影响太多其他类。
- 脆弱性:当您更改类时,可能会导致其他类发生意外更改。
- 固定:很难重用类,因为它太依赖于其他类。
当您在类之间具有紧密耦合时,就会发生这种情况。 通常,您应该设计类来避免这种情况,接口编程是一种很好的方法,因为接口只是一种行为,而不是特定类的实例。
当您有个依赖倒置,也就是说,使用组件的较高级别代码依赖于较低级别组件如何实现的细节时,就会出现这样的问题。 如果您的代码执行某些操作,然后在您编写日志记录以使用特定设备(比如cout
对象)时记录结果,那么代码将严格耦合到该日志记录设备,并依赖于该日志记录设备,并且您将来没有更改到其他设备的选项。 如果您通常通过接口指针来抽象功能,那么您就打破了这种依赖,从而使代码能够在将来与其他组件一起使用。
另一个原则是,一般来说,您应该将您的类设计为可扩展的。 继承是一种很强的扩展类的机制,因为您正在创建一个全新的类型。 如果只需要改进功能,那么继承可能是一种矫枉过正的做法。 改进算法的一种更轻量级的形式是将方法指针(或函数器)或接口指针传递给类的方法,以便该方法在适当的时间调用以改进其工作方式。
例如,大多数排序算法要求您传递一个方法指针,以便对它正在排序的类型的两个对象执行比较。 排序机制是通用的,它以最有效的方式对对象进行排序,但它的基础是告诉它如何对两个对象进行排序。 由于大多数算法保持不变,为每种类型编写一个新类是过分的。
Mixin技术允许您为类提供可扩展性,而不会出现组合的生命周期问题或原始继承的重量级问题。 这里的想法是,您拥有一个具有特定功能的库,可以将其添加到对象中。 要做到这一点,一种方法是将其作为具有public
方法的基类应用,因此如果派生类公开派生自该类,则它也将具有作为public
方法的那些方法。 除非该功能要求派生类也在这些方法中执行某些功能,否则这种方法工作得很好,在这种情况下,库的文档将要求派生类重写该方法,调用基类实现,并将它们自己的代码添加到该方法以完成实现(基类方法可以在额外的派生类代码之前或之后调用,文档必须指定这一点)。 到目前为止,我们已经在本章中多次看到这一点,并且它是一些较老的类库使用的技术,例如,微软的基础类库(MFC)。 Visual C++ 使这一点变得更容易,因为它使用向导工具生成 MFC 代码,并且有关于开发人员应该将代码添加到何处的注释。
这种方法的问题在于,它要求从基类派生的开发人员实现特定的代码并遵循规则。 开发人员可能会编写编译和运行的代码,但由于它不是按照所需的规则编写的,因此在运行时会有错误的行为。
Mixin 类颠覆了这个概念。 与开发者从库提供的基类派生并扩展所提供的功能不同,库提供的 Mixin 类是从开发者提供的类派生的。 这解决了几个问题。 首先,开发人员必须提供文档要求的特定方法,否则 Mixin 类(将使用这些方法)将无法编译。 编译器强制执行类库作者的规则,要求使用库的开发人员提供特定代码。 其次,Mixin 类上的方法可以准确地在需要的地方调用基类方法(由开发人员提供)。 使用类库的开发人员不再获得有关如何开发代码的详细说明,除此之外,他们还必须实现某些方法。
那么,如何才能做到这一点呢? 类库作者不知道客户端开发人员将编写的代码,也不知道客户端开发人员将编写的类的名称,因此无法从此类类派生。 C++ 允许您通过模板参数提供类型,以便在编译时使用此类型实例化类。 对于 Mixin 类,通过模板参数传递的类型是将用作基类的类型的名称。 开发人员只需提供一个具有特定方法的类,然后使用它们的类作为模板参数创建 Mixin 类的专门化:
// Library code
template <typename BASE>
class mixin : public BASE
{
public:
void something()
{
cout << "mixin do something" << endl;
BASE::something();
cout << "mixin something else" << endl;
}
};
// Client code to adapt the mixin class
class impl
{
public:
void something()
{
cout << "impl do something" << endl;
}
};
此类的用法如下:
mixin<impl> obj;
obj.something();
如您所见,mixin
类实现了一个名为something
的方法,它调用了一个名为something
的基类方法。 这意味着使用 Mixin 类功能的客户端开发人员必须实现具有该名称和相同原型的方法,否则不能使用 Mixin 类。 编写impl
类的客户端开发人员不知道如何或在哪里使用他们的代码,只知道他们必须提供具有特定名称和原型的方法。 在这种情况下,mixin::something
方法在它提供的功能之间的代码中调用基类方法,impl
类的编写者不需要知道这一点。 此代码的输出如下所示:
mixin do something
impl do something
mixin something else
这表明mixin
类可以在它认为合适的地方调用impl
类。 impl
类只需提供功能;mixin
类决定如何使用它。 事实上,任何实现具有正确名称和原型的方法的类都可以作为参数提供给mixin
类的模板-甚至是另一个 Mixin 类!
template <typename BASE>
class mixin2 : public BASE
{
public:
void something()
{
cout << "mixin2 do something" << endl;
BASE::something();
cout << "mixin2 something else" << endl;
}
};
这可以像这样使用:
mixin2< mixin<impl> > obj;
obj.something();
结果如下:
mixin2 do something
mixin do something
impl do something
mixin something else
mixin2 something else
请注意,除了实现了适当的方法之外,mixin
和mixin2
类对彼此一无所知。
由于 Mixin 类不能在没有 Template 参数提供的类型的情况下使用,因此它们有时被称为抽象子类。
如果基类只有一个默认构造函数,那么这种方法就可以很好地工作。 如果实现需要另一个构造函数,那么 Mixin 必须知道要调用哪个构造函数,并且必须有适当的参数。 此外,如果您链接了 Mixin,那么它们将通过构造函数进行耦合。 解决此问题的一种方法是使用两阶段构造,即提供一个命名方法(例如,init
),用于在构造后初始化对象中的数据成员。 Mixin 类仍将像前面一样使用其默认构造函数创建,因此类之间不存在耦合,也就是说,mixin2
类将对mixin
或impl
的数据成员一无所知:
mixin2< mixin<impl> > obj;
obj.impl::init(/* parameters */); // call impl::init
obj.mixin::init(/* parameters */); // call mixin::init
obj.init(/* parameters */); // call mixin2::init
obj.something();
这是可行的,因为只要限定方法的名称,就可以调用公共基类方法。 这三个init
方法中的参数列表可以不同。 然而,这确实带来了一个问题,即客户端现在必须初始化链中的所有基类。
这是 Microsoft 的ActiveX 模板库(ATL)(现在是 MFC 的一部分)用来提供标准 COM 接口实现的方法。
在下面的示例中,我们将创建模拟 C++ 开发人员团队的代码。 代码将使用接口来分离类,这样就可以在不更改类的情况下更改类使用的服务。 在这个模拟中,我们有一个管理团队的经理,所以经理的一个属性就是他们的团队。 此外,每个员工,无论是经理还是团队成员,都有一些共同的属性和行为--他们都有自己的名字和工作岗位,都做着某种工作。
为章节创建一个文件夹,并在该文件夹中创建一个名为team_builder.cpp
的文件,由于此应用将使用vector
、智能指针和文件,因此请在文件顶部添加以下行:
#include <iostream>
#include <string>
#include <vector>
#include <fstream>
#include <memory>
using namespace std;
应用将具有命令行参数,但目前只需提供main
函数的空副本:
int main(int argc, const char *argv[])
{
return 0;
}
我们将定义接口,因此在main
函数之前添加以下内容:
#define interface struct
这只是语法上的甜头,但它使代码更具可读性,以显示抽象类的用途。 在此下面,添加以下接口:
interface IWork
{
virtual const char* get_name() = 0;
virtual const char* get_position() = 0;
virtual void do_work() = 0;
};
interface IManage
{
virtual const vector<unique_ptr<IWork>>& get_team() = 0;
virtual void manage_team() = 0;
};
interface IDevelop
{
virtual void write_code() = 0;
};
所有工人都将实现第一个接口,该接口提供对他们的姓名和工作职位的访问,以及一个告诉他们做一些工作的方法。 我们将定义两种类型的工作者,一种是通过安排时间来管理团队的经理,另一种是编写代码的开发人员。 管理器有一个由IWork*
个指针组成的vector
个指针,由于这些指针将指向在空闲存储上创建的对象,因此vector
个成员是包装这些指针的智能指针。 这就是说,管理者维护这些对象的生命周期:当管理者对象存在时,他们的团队也会存在。
第一个操作是创建一个帮助器类,它执行工人的基本工作。 这样做的原因将在后面的示例中一目了然。 此类将实现IWork
接口:
class worker : public IWork
{
string name;
string position;
public:
worker() = delete;
worker(const char *n, const char *p) : name(n), position(p) {}
virtual ~worker() {}
virtual const char* get_name() override
{ return this->name.c_str(); }
virtual const char* get_position() override
{ return this->position.c_str(); }
virtual void do_work() override { cout << "works" << endl; }
};
必须使用名称和职位创建worker
对象。 我们还将为一位经理提供一个助手类:
class manager : public worker, public IManage
{
vector<unique_ptr<IWork>> team;
public:
manager() = delete;
manager(const char *n, const char* p) : worker(n, p) {}
const vector<unique_ptr<IWork>>& get_team() { return team; }
virtual void manage_team() override
{ cout << "manages a team" << endl; }
void add_team_member(IWork* worker)
{ team.push_back(unique_ptr<IWork>(worker)); }
virtual void do_work() override { this->manage_team(); }
};
请注意,do_work
方法是根据虚函数manage_team
实现的,这意味着派生类只需要实现manage_team
方法,因为它将从其父函数继承do_work
方法,而方法调度将意味着调用了正确的方法。 类的其余部分很简单,但请注意,构造函数调用基类构造函数来初始化名称和工作位置(经理毕竟是工人),并且manager
类具有将项添加到智能指针中共享的团队的函数。
要测试这一点,我们需要创建一个管理开发人员的manager
类:
class project_manager : public manager
{
public:
project_manager() = delete;
project_manager(const char *n) : manager(n, "Project Manager")
{}
virtual void manage_team() override
{ cout << "manages team of developers" << endl; }
};
这将覆盖对基类构造函数的调用,该基类构造函数传递项目经理的姓名和描述作业的文字。 该类还覆盖manage_team
来说明管理器的实际工作。 此时,您应该能够创建project_manager
并将一些成员添加到他们的团队中(使用worker
对象,您将很快创建开发人员)。 在main
函数中添加以下内容:
project_manager pm("Agnes");
pm.add_team_member(new worker("Bill", "Developer"));
pm.add_team_member(new worker("Chris", "Developer"));
pm.add_team_member(new worker("Dave", "Developer"));
pm.add_team_member(new worker("Edith", "DBA"));
此代码将进行编译,但在运行时不会输出,因此请创建一个方法来打印经理团队:
void print_team(IWork *mgr)
{
cout << mgr->get_name() << " is "
<< mgr->get_position() << " and ";
IManage *manager = dynamic_cast<IManage*>(mgr);
if (manager != nullptr)
{
cout << "manages a team of: " << endl;
for (auto team_member : manager->get_team())
{
cout << team_member->get_name() << " "
<< team_member->get_position() << endl;
}
}
else { cout << "is not a manager" << endl; }
}
此函数显示接口有多有用。 您可以将任何工人传递给该函数,它将打印出与所有工人相关的信息(姓名和工作职位)。 然后,它通过请求IManage
接口来询问对象是否为管理器。 如果对象实现此接口,则该函数只能获取经理行为(在本例中,拥有一个团队)。 在main
函数结束时,在最后一次调用program_manager
对象之后,调用此函数:
print_team(&pm)
编译此代码(记住使用/EHsc
开关)并运行代码。 您将获得以下输出:
Agnes is Project Manager and manages a team of:
Bill Developer
Chris Developer
Dave Developer
Edith DBA
现在我们将添加一个多态性级别,因此在print_team
函数之前添加以下类:
class cpp_developer : public worker, public IDevelop
{
public:
cpp_developer() = delete;
cpp_developer(const char *n) : worker(n, "C++ Dev") {}
void write_code() { cout << "Writing C++ ..." << endl; }
virtual void do_work() override { this->write_code(); }
};
class database_admin : public worker, public IDevelop
{
public:
database_admin() = delete;
database_admin(const char *n) : worker(n, "DBA") {}
void write_code() { cout << "Writing SQL ..." << endl; }
virtual void do_work() override { this->write_code(); }
};
您可以更改main
函数,以便不使用worker
对象,而是对 Bill、Chris 和 Dave 使用cpp_developer
,对 Edith 使用database_admin
:
project_manager pm("Agnes");
pm.add_team_member(new cpp_developer("Bill"));
pm.add_team_member(new cpp_developer("Chris"));
pm.add_team_member(new cpp_developer("Dave"));
pm.add_team_member(new database_admin("Edith"));
print_team(&pm);
现在,您可以编译和运行代码,并且可以看到,您不仅可以将不同类型的对象添加到经理团队中,还可以通过IWork
界面打印适当的信息。
下一个任务是添加代码来序列化和反序列化这些对象。 序列化意味着将对象的状态(和类型信息)写入流,反序列化将获取该信息并创建具有指定状态的适当类型的新对象。 为此,每个对象都必须有一个构造函数,该构造函数接受指向反序列化程序对象的接口指针,并且构造函数应该调用该接口来提取正在创建的对象的状态。 此外,此类类应该实现一个方法来序列化对象的状态并将其写入序列化程序对象。 让我们首先来看一下序列化。 在文件顶部,添加以下接口:
#define interface struct
interface IWork;
// forward declaration interface ISerializer { virtual void write_string(const string& line) = 0; virtual void write_worker(IWork *worker) = 0; virtual void write_workers ( const vector<unique_ptr<IWork>>& workers) = 0; }; interface ISerializable { virtual void serialize(ISerializer *stm) = 0; };
因为ISerializer
接口使用IWork
接口,所以需要转发声明。 第一个接口ISerializer
由提供序列化服务的对象实现。 这可以基于文件、网络套接字、数据库或您想要用来存储对象的任何东西。 底层存储机制对于该接口的用户来说并不重要;重要的是该接口可以存储字符串,并且它可以存储使用IWork
接口指针或此类对象的集合传递的整个对象。
可以序列化的对象必须实现ISerializable
接口,该接口只有一个方法,该方法接受指向将提供序列化服务的对象的接口指针。 在定义接口之后,添加以下类:
class file_writer : public ISerializer
{
ofstream stm;
public:
file_writer() = delete;
file_writer(const char *file) { stm.open(file, ios::out); }
~file_writer() { close(); }
void close() { stm.close(); }
virtual void write_worker(IWork *worker) override
{
ISerializable *object = dynamic_cast<ISerializable*>(worker);
if (object != nullptr)
{
ISerializer *serializer = dynamic_cast<ISerializer*>(this);
serializer->write_string(typeid(*worker).raw_name());
object->serialize(serializer);
}
}
virtual void write_workers(
const vector<unique_ptr<IWork>>& workers) override
{
write_string("[[");
for (const unique_ptr<IWork>& member : workers)
{
write_worker(member.get());
}
write_string("]]"); // end marker of team
}
virtual void write_string(const string& line) override
{
stm << line << endl;
}
};
该类为文件提供了ISerializer
接口,因此write_string
方法使用ifstream
插入操作符将字符串写入文件中的一行。 write_worker
方法将 Worker 对象写入文件。 为此,它首先询问 Worker 对象是否可以通过封装IWork
接口和ISerializable
接口来序列化自己。 如果 Worker 对象实现此接口,则序列化程序可以通过将ISerializer
接口指针传递给 Worker 对象上的serialize
方法来要求 Worker 对象序列化自己。 由 Worker 对象决定必须序列化的信息。 Worker 对象除了ISerializer
接口之外对file_writer
类一无所知,而file_writer
类除了实现IWork
和ISerializable
接口之外对 Worker 对象一无所知。
如果 Worker 对象是可序列化的,write_worker
方法做的第一件事就是获取有关该对象的类型信息。 IWork
接口将位于类(project_manager
、cpp_developer
或database_admin
)上,因此取消引用指针将使typeid
操作符能够访问类类型信息。 我们将原始类型名存储在序列化程序中,因为它是紧凑的。 一旦类型信息被序列化,我们就要求对象通过调用其ISerializable
接口上的serialize
方法来序列化自己。 Worker 对象将存储它需要的任何信息。
经理对象需要序列化他们的团队,他们通过将 Worker 对象的集合传递给write_workers
方法来实现这一点。 这表明正在序列化的对象是一个数组,方法是将它们写入两个标记[[
和]]
。 请注意,因为容器有unique_ptr
个对象,所以没有复制构造函数,因为这意味着共享所有权。 因此,我们通过索引操作符访问项,这将为我们提供对容器内的unique_ptr
对象的引用。
现在,对于每个可以序列化的类,您必须从ISerializable
派生类并实现serialize
方法。 类继承树意味着一种 Worker 类型的每个类都派生自worker
类,因此我们只需要此类从ISerializable
接口继承:
class worker : public IWork, public ISerializable
约定是,类只序列化自己的状态,并委托其基类序列化基类对象。 继承树的顶部是worker
类,因此在该类的底部添加以下接口方法:
virtual void serialize(ISerializer *stm) override
{
stm->write_string(name);
stm->write_string(position);
}
这只是将姓名和工作位置序列化到序列化程序。 请注意,Worker 对象不知道序列化程序将如何处理此信息,也不知道哪个类提供ISerializer
接口。
在cpp_developer
类的底部,添加此方法:
virtual void serialize(ISerializer* stm) override
{ worker::serialize(stm); }
cpp_developer
类没有任何附加状态,因此它将序列化委托给其父类。 如果 Developer 类有一个状态,那么它将在序列化基对象之后序列化该状态。 将完全相同的代码添加到database_admin
类的底部。
project_manager
类也调用其基类,但这是manager
,因此将以下内容添加到project_manager
类的底部:
virtual void serialize(ISerializer* stm) override
{ manager::serialize(stm); }
manager::serialize
更加复杂,因为该类具有应该序列化的状态:
virtual void serialize(ISerializer* stm) override
{
worker::serialize(stm);
stm->write_workers(this->team);
}
第一个操作是序列化基类:worker
对象。 然后,代码序列化manager
对象的状态,这意味着通过将此集合传递给序列化程序来序列化team
数据成员。
为了能够测试序列化,请在main
方法之上创建一个方法,将project_manager
代码移到新方法中,然后添加代码以序列化对象:
void serialize(const char* file)
{
project_manager pm("Agnes");
pm.add_team_member(new cpp_developer("Bill"));
pm.add_team_member(new cpp_developer("Chris"));
pm.add_team_member(new cpp_developer("Dave"));
pm.add_team_member(new database_admin("Edith"));
print_team(&pm);
cout << endl << "writing to " << file << endl;
file_writer writer(file);
ISerializer* ser = dynamic_cast<ISerializer*>(&writer);
ser->write_worker(&pm);
writer.close();
}
前面的代码为指定的文件创建一个file_writer
对象,获取该对象的ISerializer
接口,然后序列化项目管理器对象。 如果您有其他团队,则可以在关闭writer
对象之前将它们序列化到文件中。
main
函数将接受两个参数。 第一个是文件名,第二个是字符r
或w
(读取或写入文件)。 添加以下代码以替换main
函数:
void usage()
{
cout << "usage: team_builder file [r|w]" << endl;
cout << "file is the name of the file to read or write" << endl;
cout << "provide w to file the file (the default)" << endl;
cout << " r to read the file" << endl;
}
int main(int argc, char* argv[])
{
if (argc < 2)
{
usage();
return 0;
}
bool write = true;
const char *file = argv[1];
if (argc > 2) write = (argv[2][0] == 'w');
cout << (write ? "Write " : "Read ") << file << endl << endl;
if (write) serialize(file);
return 0;
}
现在,您可以编译并运行此代码,给出一个文件名:
team_builder cpp_team.txt w
这将创建一个名为cpp_team.txt
的文件,其中包含有关团队的信息;在命令行中使用**type cpp_team.txt**
键入该文件:
.?AVproject_manager@@
Agnes
Project Manager
[[
.?AVcpp_developer@@
Bill
C++ Dev
.?AVcpp_developer@@
Chris
C++ Dev
.?AVcpp_developer@@
Dave
C++ Dev
.?AVdatabase_admin@@
Edith
DBA
]]
该文件不是供人读取的,但如您所见,它的每一行都有一条信息,并且每个序列化的对象前面都有类的类型。
现在,您将编写反序列化对象的代码。 代码需要一个读取序列化数据并返回 Worker 对象的类。 此类与序列化程序类紧密耦合,但应该通过接口访问它,这样它就不会耦合到 Worker 对象。 在声明ISerializable
接口之后,添加以下内容:
interface IDeserializer
{
virtual string read_string() = 0;
virtual unique_ptr<IWork> read_worker() = 0;
virtual void read_workers(vector<unique_ptr<IWork>>& team) = 0;
};
第一个方法获取序列化字符串,其他两个方法获取单个对象和对象集合。 由于这些工作对象将在空闲存储上创建,因此这些方法使用智能指针。 每个类都可以序列化自己,所以现在您将使每个可序列化类能够反序列化自己。 为此,对于实现ISerializable
的每个类,添加一个接受IDeserializer
接口指针的构造函数。 从worker
类开始;添加以下公共构造函数:
worker(IDeserializer *stm)
{
name = stm->read_string();
position = stm->read_string();
}
本质上,这与serialize
方法的作用相反,它从反序列化程序读取名称和位置字符串,顺序与它们传递给序列化程序的顺序相同。 由于cpp_developer
和database_admin
类没有状态,因此除了调用基类构造函数外,它们不需要执行任何其他反序列化工作。 例如,将以下公共构造函数添加到cpp_developer
类:
cpp_developer(IDeserializer* stm) : worker(stm) {}
向database_admin
类添加类似的构造函数。
经理们有一个状态,所以需要做更多的工作来反序列化他们。 将以下内容添加到manager
类:
manager(IDeserializer* stm) : worker(stm)
{ stm->read_workers(this->team); }
初始值设定项列表构造基类,运行后,构造函数通过调用IDeserializer
接口上的read_workers
来使用零个或多个工作对象初始化team
集合。 最后,project_manager
类派生自manager
类,但没有添加额外的状态,因此添加以下构造函数:
project_manager(IDeserializer* stm) : manager(stm) {}
现在,每个可序列化的类都可以反序列化自己,下一个操作是编写将读取文件的反序列化程序类。 在file_writer
类之后添加以下内容(请注意,没有内联实现两个方法):
class file_reader : public IDeserializer
{
ifstream stm;
public:
file_reader() = delete;
file_reader(const char *file) { stm.open(file, ios::in); }
~file_reader() { close(); }
void close() { stm.close(); }
virtual unique_ptr<IWork> read_worker() override;
virtual void read_workers(
vector<unique_ptr<IWork>>& team) override;
virtual string read_string() override
{
string line;
getline(stm, line);
return line;
}
};
构造函数打开指定的文件,析构函数将其关闭。 read_string
接口方法从文件中读取一行并将其作为字符串返回。 主要工作在这里没有实现的两个接口方法中执行。 read_workers
方法将读取IWork
对象的集合,并将它们放入通过引用传递的集合中。 此方法将为文件中的每个对象调用read_worker
方法,并将其放入集合中,因此读取文件的主要工作在此方法中执行。 read_worker
方法是类中唯一与可序列化类有耦合的部分,因此,它必须在 Worker 类的定义下定义。 在serialize
全局函数上方添加以下内容:
unique_ptr<IWork> file_reader::read_worker()
{
}
void file_reader::read_workers(vector<unique_ptr<IWork>>& team)
{
while (true)
{
unique_ptr<IWork> worker = read_worker();
if (!worker) break;
team.push_back(std::move(worker));
}
}
read_workers
方法将使用read_worker
方法从文件中读取每个对象,该方法返回unique_ptr
对象中的每个对象。 我们希望将此对象放入容器中,但因为指针应该具有独占所有权,所以我们需要将所有权移到容器中的对象中。 有两种方法可以做到这一点。 第一种方法是简单地使用对read_worker
的调用作为push_back
的参数。 read_worker
方法返回一个临时对象,它是一个右值,因此编译器在容器中创建对象时将使用移动语义。 我们之所以不这样做,是因为read_worker
方法可能返回nullptr
(我们要测试它),因此我们创建了一个新的unique_ptr
对象(Move 语义将所有权传递给该对象),一旦我们测试出该对象不是nullptr
,我们就调用标准库函数move
,将该对象复制到容器中。
如果read_worker
方法读取数组的结束标记,则它返回nullptr
,因此read_workers
方法循环,读取每个 Worker 并将其放入集合中,直到返回nullptr
。
按如下方式实现read_worker
方法:
unique_ptr<IWork> file_reader::read_worker()
{
string type = read_string();
if (type == "[[") type = read_string();
if (type == "]]") return nullptr;
if (type == typeid(worker).raw_name())
{
return unique_ptr<IWork>(
dynamic_cast<IWork*>(new worker(this)));
}
return nullptr;
}
第一行从文件中读取 Worker 对象的类型信息,以便它知道要创建什么对象。 由于文件将具有指示团队成员数组的标记,因此代码必须检测这些标记。 如果检测到数组的开始,则忽略标记字符串,并读取下一行以获取组中第一个对象的类型。 如果读取了结束标记,则这是数组的末尾,因此返回nullptr
。
此处显示了worker
对象的代码。 if
语句测试以检查类型字符串是否与worker
类的原始名称相同。 如果是,那么我们必须创建一个worker
对象,并通过调用接受IDeserializer
指针的构造函数来请求它反序列化自己。 在空闲存储上创建worker
对象,并调用dynamic_cast
操作符以获取IWork
接口指针,然后使用该指针初始化智能指针对象。 unique_ptr
的构造函数是explicit
,所以您必须调用它。 现在为所有其他可序列化类添加类似的代码:
if (type == typeid(project_manager).raw_name())
{
return unique_ptr<IWork>(
dynamic_cast<IWork*>(new project_manager(this)));
}
if (type == typeid(cpp_developer).raw_name())
{
return unique_ptr<IWork>(
dynamic_cast<IWork*>(new cpp_developer(this)));
}
if (type == typeid(database_admin).raw_name())
{
return unique_ptr<IWork>(
dynamic_cast<IWork*>(new database_admin(this)));
}
最后,您需要创建一个file_reader
并反序列化一个文件。 在serialize
函数之后,添加以下内容:
void deserialize(const char* file)
{
file_reader reader(file);
while (true)
{
unique_ptr<IWork> worker = reader.read_worker();
if (worker) print_team(worker.get());
else break;
}
reader.close();
}
这段代码简单地创建了一个基于文件名的file_reader
对象,然后从打印出该对象的文件中读取每个 Worker 对象,如果是project_manager
,则打印出他们的团队。 最后,在main
函数中添加一行以调用此函数:
cout << (write ? "Write " : "Read ") << file << endl << endl;
if (write) serialize(file);
else deserialize(file);
现在,您可以编译代码并使用它读入包含以下内容的序列化文件:
team_builder cpp_team.txt r
(请注意r
参数。)。 代码应该打印出您序列化到文件中的对象。
前面的示例表明,您可以编写不知道用于序列化的机制的可序列化对象。 如果要使用与平面文件不同的机制(例如,XML 文件或数据库),则不需要更改任何 Worker 类。 相反,您需要编写一个适当的类来实现ISerializer
接口和IDeserailizer
接口。 如果需要创建另一个 Worker 类,只需更改read_worker
方法来反序列化该类型的对象。
在本章中,您了解了如何使用 C++ 继承重用代码并提供对象之间的 is-a 关系。 您还了解了如何使用它来实现多态性,其中相关对象可以被视为具有相同的行为,同时仍然可以保持调用每个对象的方法的能力,以及将行为分组在一起的接口。 在下一章中,您将看到 C++ 标准库的特性以及它提供的各种实用程序类。**