使用 C 和 C++ 的编程语言概念/面向对象和 C++ 中的继承
继承的逻辑在不同的编程语言中变化不大。例如,如果基类和派生类共享相同的公共接口,则派生类被称为其基类的子类型,并且它的实例可以被视为基类的实例。或者,由于动态调度,继承可以用来提供多态性。
但是,从 Java 或任何其他面向对象的编程语言转到 C++ 的新手可能会遇到一些意外。本章将介绍 C++ 中的继承,并重点介绍与其他编程语言的区别。
C++ 的第一个特殊之处在于它为程序员提供的各种继承种类:public、protected 和 private。这使得新手在尝试第一个继承示例时就遇到了第一个问题。请看以下代码片段。
class D : B { ... }
这段看似无害的代码声称从 B
派生 D
。但是,它不允许您将 D
的对象视为 B
的对象。没错:D
未被视为 B
的子类型。看起来 C++ 或程序员出错了。不完全是!与默认节是 private
相似,继承,除非另有说明,被认为是所谓的私有种类。我们将延迟回答私有继承的含义,并将对上述代码进行修改,以满足我们的预期,如下所示。
class D : public B { ... }
解决了这个特殊情况,让我们看看一些代码示例。在这些示例中,我们将使用以下类定义。
class B { public: B(void) { } void f1s(void) { cout << "In B::f1s(void)" << endl; } virtual void f1d(void) { cout << "In B::f1d(void)" << endl; } virtual void f2(int i) { cout << "In B::f2(int)" << endl; } ... }; // 结束 class B class D : public B { public: D(void) { } void f1s(void) { cout << "In D::f1s(void)" << endl; } virtual void f1d(void) { cout << "In D::f1d(void)" << endl; } virtual void f2(short s) { cout << "In D::f2(short)" << endl; } ... int _m_i; short _m_s; }; // 结束 class D
... D* d = new D(); cout << d->_m_i << " " << d->_m_s << endl; ...
作为 Java 程序员,你可能会认为上面的代码片段应该连续输出两个 0。但是,它会输出两个随机值。与 Java 和 C# 不同,在 C++ 中,除非被覆盖,否则数据成员不会被赋予默认初始值。如果需要将它们初始化为 0 或任何其他值,则必须由程序员显式完成。还要注意,不能用初始值声明数据成员。也就是说,将 int _m_i;
更改为 int _m_i = 0;
在 D
中将导致语法错误。
D() : _m_i(0), _m_s(0) { }
或D() { _m_i = 0; _m_s = 0; }
C++ 编译器对程序员充满信心。毕竟,C++ 程序员不会犯错。在 Java 中被视为可怕错误的起源的容易出错的语句,被认为是无所不知的程序员的明智决定。这不是 Bug,而是特性!
默认调度类型是静态调度
[edit | edit source]... B* b; // the static type of b is B* if (bool_expr) b = new D(); // if this branch is taken b’s dynamic type will be D* else b = new B(); // if control falls through to this limp, dynamic type of b will be B* b->f1s(); ...
作为一个熟练的 Java 程序员,你希望这会根据 bool_expr
的值输出——"In B::f1s(void)"
或 "In D::f1s(void)"
。但是,在 C++ 中,它总是输出 "In B::f1s(void)"
!与 Java 不同,C++ 除非另有说明,否则使用静态调度绑定函数调用。这意味着调用 f1s
时调用的函数地址将静态解析。也就是说,编译器将使用标识符的静态类型。换句话说,可以检查程序文本来找出作为调用结果调用的函数。
... B* b; if (bool_expr) b = new D(); else b = new B(); b->f1d(); ...
虚函数是动态调度的。因此,与前面的示例不同,本示例将编译并生成根据 bool_expr
的值而定的输出。如果它评估为 true
,它将输出 "In D::f1d(void)",否则它将输出 "In B::f1d(void)"。
按名称隐藏重载
[edit | edit source]... D* d = new D(); int i = 3; d->f2(i); // will be dispatched to D::f2(short) ...
使用 Java 语义,上面的代码输出 "In B::f2(int)"。毕竟,d
也是类型为 B
的对象,并且可以像真正的 B
对象一样使用 B
的公共接口。因此,D::f2(short)
和 B::f2(int)
都对 d
的客户端公开。在 C++ 中不是这样!与 Java 不同,在 Java 中基类和派生类成员函数构成一组重载函数,而 C++ 将此集合限制在单个范围内。由于派生类和基类是不同的范围,因此任何名称与基类函数重合的派生类函数将隐藏基类中的所有函数。从技术上讲,我们说 C++ 按名称隐藏,而 Java 则被称为 *按签名隐藏*。
但这不违背继承的逻辑吗?你声称 d
是 B
的对象(通过公共继承关系),并且不允许其客户端使用出现在 B
的公共接口中的某些函数?没错,C++ 提供了满足您期望的方法。
示例:委托 |
---|
|
无论调用的是否是虚函数,在函数调用中显式使用类名都会导致函数静态分派。例如,在以下函数中,[尽管 this
的类型为 D*
且 f2d(int)
是 virtual
],第二个语句中的函数调用将被分派到 B::f2d(int)
。
请注意,这种静态分派可用于调用接收对象类中的任何函数或任何一个祖先类中的任何函数。
void f2d(int i) { cout << "Delegating..."; B::f2d(i); } ... }; // end of class D ... D* d = new D(); int i = 3; d->f2d(i); // will be delegated to B::f2d(int) through D::f2d(int) short s = 5; d->f2d(s); // will be dispatched to D::f2d(short) ...
示例:使用声明 |
---|
|
多重继承,无根类
[edit | edit source]与 Java 不同的是,Java 中一个类只能从一个类派生,而 C++ 支持从多个类派生。考虑到接口的概念不支持,此功能被大量用于实现接口。
class D : public B1, public B2 { ... }
在 C++ 中需要注意的另一点是它缺少根类。也就是说,没有像 Java 中的 Object
类这样的类,它作为不同类之间的共同点。因此,人们谈论的是类有向无环图而不是类树。
测试程序
[edit | edit source]#include <iostream>
#include <string>
using namespace std;
namespace CSE224 {
namespace DS {
class B {
public:
B(void) { }
void f1s(void) { cout << "In B::f1s(void)" << endl; }
virtual void f1d(void) { cout << "In B::f1d(void)" << endl; }
virtual void f2(int i) { cout << "In B::f2(int)" << endl; }
virtual void f2d(int i) { cout << "In B::f2d(int)" << endl; }
virtual void f2u(string s) { cout << "In B::f2u(string)" << endl; }
virtual void f2u(void) { cout << "In B::f2u(void)" << endl; }
}; // end of class B
class D : public B {
public:
D(void) { }
void f1s(void) { cout << “In D::f1s(void)” << endl; }
virtual void f1d(void) { cout << “In D::f1d(void)” << endl; }
virtual void f2(short s) { cout << “In D::f2(short)” << endl; }
virtual void f2d(short s) { cout << “In D::f2d(short)” << endl; }
virtual void f2d(int i) { cout << “Delegating...”; this->B::f2d(i); }
virtual void f2u(float f) { cout << “In D::f2u(float)” << endl; }
using B::f2u;
int _m_i;
short _m_s;
}; // end of class D
} // end of namespace DS
} // end of namespace CSE224
using namespace CSE224::DS;
void default_is_static_dispatch(void) {
cout << "TESTING DEFAULT DISPATCH TYPE" << endl;
cout << "b: Static type: B*, Dynamic type: D*" << endl;
B* b = new D();
cout << "Sending (non-virtual) f1s(void) to b..."; b->f1s();
cout << "Sending (virtual) f1d(void) to b..."; b->f1d();
} // end of void default_is_static_dispatch(void)
void call_delegation(void) {
cout << "Testing delegation..." << endl;
D* d = new D();
int i = 3;
cout << "Sending (virtual) f2d(int) to d...";
d->f2d(i);
short s = 5;
cout << "Sending (virtual) f2d(short) to d...";
d->f2d(s);
} // end of void call_delegation(void)
void using_declaration(void) {
cout << "Testing the using declaration..." << endl;
D* d = new D();
float f = 3.0;
cout << "Sending (virtual) f2u(float) to d...";
d->f2u(f);
string s = string(“abc”);
cout << "Sending (virtual) f2u(string) to d...";
d->f2u(s);
cout << "Sending (virtual) f2u(void) to d...";
d->f2u();
} // end of void using_declaration(void)
void CPP_hides_by_name(void) {
cout << "TESTING HIDE-BY NAME" << endl;
D* d = new D();
int i = 3;
cout << "Sending (virtual) f2(int) to d...";
d->f2(i);
call_delegation();
using_declaration();
} // end of void CPP_hides_by_name(void)
void no_member_initialization(void) {
cout << "TESTING MEMBER INITIALIZATION" << endl;
D* d = new D();
cout << "_m_i: " << d->_m_i << " _m_s: " << d->_m_s << endl;
} // end of void no_member_initialization(void)
int main(void) {
no_member_initialization();
cout << endl;
default_is_static_dispatch();
cout << endl;
CPP_hides_by_name();
return 0;
} // end of int main(void)
gxx –o Test.exe Peculiarities.cxx↵ Test↵ TESTING MEMBER INITIALIZATION _m_i: -1 _m_s: 9544 TESTING DEFAULT DISPATCH TYPE b: Static type: B*, Dynamic type: D* Sending (non-virtual) f1s(void) to b...In B::f1s(void) Sending (virtual) f1d(void) to b...In D::f1d(void) TESTING HIDE-BY NAME Sending (virtual) f2(int) to d...In D::f2(short) Testing delegation... Sending (virtual) f2d(int) to d...Delegating...In B::f2d(int) Sending (virtual) f2d(short) to d...In D::f2d(short) Testing the using declaration... Sending (virtual) f2u(float) to d...In D::f2u(float) Sending (virtual) f2u(string) to d...In B::f2u(string) Sending (virtual) f2u(void) to d...In B::f2u(void)
Java 风格的继承
[edit | edit source]在本部分讲义中,我们提供了一个关于 C++ 和 Java 在继承方面的相关性的见解。这是通过使用 C++ 中发现的概念来模拟 Java 中发现的概念来实现的。这种方法不应被视为对 Java 的宣传活动;不用说,Java 并非没有竞争。它应该被视为对上述概念的内部运作提供线索的不完整尝试。
根类和接口概念
[edit | edit source]在 Java 中,通过根类和接口概念可以表达不相关对象之间的共同属性。前者定义了所有类之间的共同点,而后者用于对一组类进行分类。[1] 例如,由于它在 Object
中列出,因此所有对象都可以测试其与兼容类型对象的相等性;或者声明为 Comparable
的类对象可以与兼容对象进行比较。
这两个概念在 C++ 中不被支持作为语言抽象。相反,程序员期望诉诸于使用约定或通过其他结构来模拟它。例如,测试相等性是通过覆盖 ==
运算符的默认实现来实现的;接口概念,它不被直接支持,可以通过具有纯虚函数的抽象类来模拟。
#ifndef OBJECT_HXX
#define OBJECT_HXX
namespace System {
class Object {
我们引入头文件 Object
的目的是定义一个根类,可以作为通用函数中的多态类型使用,例如 compareTo
函数在 IComparable
中定义;我们并不打算提供任何像 Java 中 Object
类那样提供的共享功能。然而,仅仅定义一个空类并不能实现这一目标。为了使一个类型在 C++ 中成为多态的,它必须至少包含一个虚函数。因此,我们在类定义中包含一个虚拟的哑函数。
但为什么我们要将它的访问修饰符设置为 protected
呢?首先,它不能是 public
,因为我们不想通过这个类暴露任何功能。那将 no_op 声明为 private
呢?毕竟,将它声明为 protected
意味着派生类现在可以发送 no_op
消息。答案在于多态的本质:为了使多态成为可能,应该能够覆盖在基类中找到的动态分派函数的定义。这意味着这些函数应该至少对派生类开放。事实上,C++ 编译器甚至不允许你在 <syntaxhighlightlang="cpp" enclose="none">private</syntaxhighlight>
部分中声明 virtual
函数。
protected:
virtual void no_op(void) { return; }
}; // end of class Object
} // end of namespace System
#endif
定义:纯虚函数是一个在声明类中没有给出函数体的虚函数。因此,声称是具体的派生类必须为这样的函数提供实现。
在 C++ 支持的概念方面,接口是一个“无字段”的抽象类,其所有函数都是纯虚函数。
#ifndef ICOMPARABLE_HXX
#define ICOMPARABLE_HXX
#include "Object"
namespace System {
class IComparable {
public:
virtual int compareTo(const Object&) const = 0;
}; // end of class IComparable
} // end of namespace System
#endif
#ifndef RATIONAL_HXX
#define RATIONAL_HXX
#include <iostream>
using namespace std;
#include "IComparable"
#include "Object"
using namespace System;
#include "math/exceptions/NoInverse"
#include "math/exceptions/ZeroDenominator"
#include "math/exceptions/ZeroDivisor"
using namespace CSE224::Math::Exceptions;
namespace CSE224 {
namespace Math {
在将接口概念定义为类概念的一种变体之后,我们自然应该谨慎地谈论实现关系。这确实是 C++ 中的情况:人们只能谈论扩展关系。因此,对多重继承的支持是必须的。
class Rational : public Object, public IComparable {
public:
Rational(long num = 0, long den = 1) throw(ZeroDenominator) {
if (den == 0) {
cerr << "Error: ";
cerr << "About to throw ZeroDenominator exception" << endl;
throw ZeroDenominator();
} // end of if (den == 0)
_n = num;
_d = den;
this->simplify();
} // end of constructor(long=, long=)
Rational(Rational& existingRat) {
_n = existingRat._n;
_d = existingRat._d;
} // end of copy constructor
请注意,以下函数与其他成员函数不同,它们将被静态分派。在 Java 中,可以通过将方法声明为 final
来实现这种效果。
long getNumerator(void) const { return _n; }
long getDenominator(void) const { return _d; }
除了将函数标记为 virtual
之外,我们还声明它们返回引用。这是因为引用是作为 Java 中句柄的最佳候选者:它是一个继承感知的、编译器管理的指针。[2] 也就是说,我们可以将属于以 Rational
类为根的类层次结构中的对象作为参数传递给下一个函数(或者任何期望 Rational
的引用的函数)。引用的解引用由编译器生成的代码自动完成。
作为替代方案(尽管生成的代码的可写性和可读性会降低),我们可以使用普通的指针。但是,使用普通的对象类型是不可能的。这是因为多态性与继承相结合,需要向可能大小不同的对象发送相同的消息(这就是多态性的本质),而这反过来又意味着传递和返回大小可变的对象。这是编译器无法处理的!我们应该提供一些帮助,我们通过在两者之间注入一个固定大小的编程实体来实现:指针或引用。
virtual Rational& add(const Rational&) const;
virtual Rational& divide(const Rational&) const throw(ZeroDivisor)
virtual Rational& inverse(void) const throw(NoInverse);
virtual Rational& multiply(const Rational&) const;
virtual Rational& subtract(const Rational&) const;
virtual int compareTo(const Object&) const;
请注意以下函数的作用类似于 Java 中的 toString
函数。如果使用 sstream 替换 ostream
并相应地更改实现,就可以使类比更加完美。
friend ostream& operator<<(ostream&, const Rational&);
private:
long _n, _d;
long min(long n1, long n2);
Rational& simplify(void);
}; // end of class Rational
} // end of namespace Math
} // end of namespace CSE224
#endif
#include <iostream>
#include <memory>
using namespace std;
#include "Object"
using namespace System;
#include "math/exceptions/NoInverse"
#include "math/exceptions/ZeroDenominator"
#include "math/exceptions/ZeroDivisor"
using namespace CSE224::Math::Exceptions;
#include "math/Rational"
namespace CSE224 {
namespace Math {
Rational& Rational::
add(const Rational& rhs) const {
请注意缺少的 try-catch
块!与 Java 不同,C++ 不强制程序员将所有可能出现问题的代码放入受保护的区域。可以说,所有 C++ 异常都像从 Java 中 RuntimeException
类派生的异常一样处理。这给了程序员一定的自由度,使她能够编写更简洁的代码。例如,到达下一行意味着我们正在添加两个格式良好的 Rational
对象。这种操作的结果永远不会造成问题!
Rational* sum = new Rational(_n * rhs._d + _d * rhs._n, _d * rhs._d);
return sum->simplify();
} // end of Rational& Rational::add(const Rational&) const
Rational& Rational::
divide(const Rational& rhs) const throw(ZeroDivisor) {
try {
Rational& tmp_inv = rhs.inverse();
Rational& ret_rat = this->multiply(tmp_inv);
既然我们已经完成了保存 rhs
的逆的临时对象,我们必须将其返回给内存分配器,否则将会在每次使用该函数时产生垃圾。这很烦人!但话又说回来,C/C++ 程序员不会犯这种低级错误。
请注意 tmp_inv
之前的取地址运算符。将此运算符应用于引用将返回该引用别名所指向的区域的起始地址。[请记住,引用在其使用点会自动解引用] 在我们的例子中,这将是通过向 rhs
发送 inverse
消息而创建的对象的地址。
delete &tmp_inv;
return ret_rat;
} catch (NoInverse e) {
cerr << "Error: About to throw ZeroDivisor exception" << endl;
throw ZeroDivisor();
}
} // end of Rational& Rational::divide(const Rational&) const
Rational& Rational::
inverse(void) const throw(NoInverse) {
try {
Rational *res = new Rational(_d, _n);
return *res;
} catch(ZeroDenominator e) {
cerr << "Error: About to throw NoInverse exception" << endl;
throw NoInverse(_n, _d);
}
} // end of Rational& Rational::inverse(void) const
Rational& Rational::
multiply(const Rational& rhs) const {
Rational *res = new Rational(_n * rhs._n, _d * rhs._d);
return res->simplify();
} // end of Rational& Rational::multiply(const Rational&) const
Rational& Rational::
subtract(const Rational& rhs) const {
我们将减法公式化为其他操作:我们不减去一个值,而是加上负值。为此,我们创建了两个临时对象,它们只在当前调用期间有意义。在返回调用者之前,我们应该将它们返回给内存分配器。
一个所谓的智能指针对象正是我们想要的。这样的对象被初始化为指向由 new 表达式创建的动态分配对象,并在其(智能指针的)生命周期结束时释放它(动态分配的对象)。下面的图显示了执行第 47 行后的内存布局,这应该使这一点更加清晰。
与智能指针对象一起创建了对函数本地化的堆对象,而智能指针对象本身是运行时堆栈上创建的本地对象。9 这意味着该智能指针对象的分配构造函数调用和析构函数调用-释放将由编译器生成的代码处理。换句话说,程序员无需担心智能指针对象的生存期管理。因此,如果我们能保证堆对象与该智能指针一起被销毁-释放,那么它的生存期管理将不再是一个问题。这是通过在相关智能指针对象的析构函数中删除堆对象来实现的,这意味着在智能指针对象销毁-释放完成之前,堆对象将已经被销毁-释放。以下描述了智能指针和相关堆对象的生存期。
- 在运行时堆栈中创建智能指针。
- 将相关堆对象传递给智能指针的构造函数。
- 使用堆对象。
- 通过编译器生成的代码调用智能指针的析构函数。
- 从智能指针的析构函数中删除堆对象。
根据此,在以下定义中创建的匿名 Rational
对象(new Rational(-1)
和 &(rhs.multiply(*neg_1))
)将在离开函数之前被自动(即无需程序员干预)返回。
auto_ptr< Rational > neg_1(new Rational(-1));
auto_ptr< Rational > tmp_mul(&(rhs.multiply(*neg_1)));
观察以下对解引用运算符的应用,其操作数是一个非指针变量,这乍看起来可能是一个错误。毕竟,*
通过返回其唯一操作数指示的内存内容来工作。然而,这个相当有限的描述忽略了重载解引用运算符的可能性。实际上,正是该运算符的重载版本使非指针类型能够使用。以下对 *
的应用使用了在 auto_ptr
类中定义的重载版本,该版本返回智能指针管理的堆对象的内容。
为了使事情更加清晰,我们可以为 auto_ptr
类建议以下实现。
模板 <类 ManagedObjectType> 类 auto_ptr { 公共: auto_ptr(ManagedObjectType* managedObj) { _managed_heap_object = managedObj; ... } // end of constructor(ManagedObjectType*) ... ManagedObjectType 运算符*(空) { ... 返回 *_managed_heap_object; } // end of ManagedObjectType operator*(void) ... 私有: ManagedObjectType* _managed_heap_object; } // end of class auto_ptr<ManagedObjectType>
Rational &ret_rat = add(*tmp_mul);
return(ret_rat);
} // end of Rational& Rational::subtract(const Rational&) const
int Rational::
compareTo(const Object& rhs) const {
double this_equi = ((double) _n) / _d;
除了传统的 C 风格强制类型转换,C++ 还提供了多种强制类型转换运算符:const_cast
,dynamic_cast
,static_cast
和 reinterpret_cast
。这些运算符中的每一个都执行传统强制类型转换运算符提供的功能的一个子集,因此可以说新的运算符没有添加任何新功能。但是,由于编译器的额外支持,它们使得编写更类型安全的程序成为可能。使用新的运算符,我们明确地说明了我们的意图,从而获得更易于维护的代码。
示例:移除对象的 const 属性。 |
---|
|
需要注意的是,const_cast 也可以用于更改对象的 volatile 属性。 |
由于我们移除 const
属性的意图已由相关运算符明确表示,代码维护者将更快地发现强制类型转换的发生,并意识到正在执行的操作。使用 C 风格强制类型转换的替代方案缺乏这些特性:很难找到强制类型转换的位置,也很难确定是否正在移除 const
属性。
使用 dynamic_cast
也会为我们提供更安全代码的优势。此特定运算符用于在多态类之间进行双向强制类型转换,即至少具有一个虚函数的类,这些类通过公共派生相互关联。
问题 | ||
---|---|---|
dynamic_cast 只能用于在指针/引用类型之间进行转换。为什么? | ||
|
定义:从一个类转换到同一类层次结构中的另一个类,如果目标类型更专业,则称为 *向下转换*。如果目标类型不太专业,则强制类型转换的行为称为 *向上转换*。
向上转换到公共基类总是成功的,因为目标类型接口中列出的消息是源类型接口的一个子集。另一方面,从派生类强制类型转换为其非公共基类之一会导致编译时错误。同样,向下转换可能会导致运行时错误,因为我们可能会发送源类型接口中找不到的消息。
示例:使用 dynamic_cast 获得更安全的代码。 |
---|
|
上面的代码将一个 PB*
变量向下转换为 PD*
,通过它可以发送名为 g
的额外消息。对于此示例,这似乎不是问题。但是,如果 pb
用于指向一个 PB
对象而不是 PD
对象怎么办?如果它被用于指向不同类型的对象,如以下代码片段所示,会怎样呢?
如果(some_condition) { ... ; pb = 新 D; ... } 否则 { ... pb = 新 B; ... } ... PD* pd = dynamic_cast<PD*>(pb);
我们不能保证可以向底层对象发送 g
,它可以是 PB
或 PD
类型。只有在运行时检查对象类型的情况下,才能提供我们正在寻求的这种保证。这正是 dynamic_cast
所做的:通过检查指针/引用的兼容性(静态类型)与对象(动态类型),dynamic_cast
决定正在进行的强制类型转换是否有效。如果是,则返回一个合法值。否则,如果强制类型转换指针失败,则返回一个 NULL
值,这基本上消除了发送非法消息的可能性;如果强制类型转换引用失败,则抛出 std::bad_cast
异常。当源类型和目标类型没有继承关系时,也会采取相同的操作。
请注意,由编译器生成的代码执行运行时检查而导致的此成本,在使用传统强制转换运算符时不会出现。这是因为 C 样式强制转换运算符不使用任何运行时信息。
观察向上转换类层次结构——由于派生类对象接收的消息是其基类的消息子集——不需要任何运行时检查。这意味着由于 dynamic_cast
造成的成本是不合理的:为什么我们要为进行一个结果已知的控制而付费?C++ 提供的解决方案是另一个强制转换运算符,它使用编译时信息来完成其工作:static_cast
。此运算符可用于执行编译器隐式执行的转换,并在反方向执行这些隐式转换。如果可以保证跳过运行时检查是安全的,它也可以用作 dynamic_cast
的替代。
示例 |
---|
|
这并不能完全涵盖传统强制转换运算符提供的功能。例如,不相关/不兼容指针类型之间的转换缺失。此缺失的功能由 reinterpret_cast operator
涵盖,它还可以执行指针和整型之间的转换。
示例 |
---|
|
应该记住,此运算符不检查源类型和目标类型;它只是将目标内容按位复制到源中。
double rhs_equi = ((double) dynamic_cast<const Rational&>(rhs)._n) / dynamic_cast<const Rational&>(rhs)._d;
if (this_equi > rhs_equi) return 1;
else if (this_equi == rhs_equi) return 0;
else return -1;
} // end of int Rational::compareTo(const Object&) const
long Rational::
min(long n1, long n2) { return (n1 > n2 ? n1 : n2); }
Rational& Rational::
simplify(void) {
long upperLimit = min(_n, _d);
for (long i = 2; i <= upperLimit;)
if ((_n % i == 0) && (_d % i == 0)) { _n /= i; _d /= i; }
else i++;
if (_d < 0) { _n *= -1; _d *= -1; }
return(*this);
} // end of Rational& Rational::simplify(void)
ostream& operator<<(ostream& os, const Rational& rat) {
os << rat._n << " ";
if (rat._d != 1) os << "/ " << rat._d;
return os;
} // end of ostream& operator<<(ostream&, const Rational&)
} // end of namespace Math
} // end of CSE224
接口 (整体)
[edit | edit source]#ifndef WHOLE_HXX
#define WHOLE_HXX
#include <iostream>
using namespace std;
#include "math/Rational"
namespace CSE224 {
namespace Math {
class Whole : public Rational {
public:
请记住,Whole
派生自 Rational
。换句话说,由于继承可以被视为编译器管理的组合,因此所有 Whole
对象在其内存布局中都包含一个 Rational
子对象。在成员初始化列表中使用不引用正在初始化的成员的表示法,将初始化在正在构造的 Whole
对象中找到的 Rational
子对象。
Whole(long num) : Rational(num) { }
Whole(void) : Rational(0) { }
Whole(Whole& existingWhole) :
Rational(existingWhole.getNumerator()) { }
Whole& add(const Whole& rhs) const;
virtual Rational& add(const Rational&) const;
}; // end of class Whole
} // end of namespace Math
} // end of namespace CSE224
#endif
实现 (整体)
[edit | edit source]#include <iostream>
using namespace std;
#include "math/Rational"
#include "math/Whole"
namespace CSE224 {
namespace Math {
Rational& Whole::
add(const Rational& rhs) const {
cout << "[In Whole::add(Rational)] ";
return (Rational::add(rhs));
} // end of Rational& Whole::add(const Rational&) const
Whole& Whole::
add(const Whole& rhs) const {
cout << "[In Whole::add(Whole)] ";
Whole *res = new Whole(getNumerator() + rhs.getNumerator());
return *res;
} // end of Whole& Whole::add(const Whole&) const
} // end of namespace Math
} // end of namespace CSE224
异常类
[edit | edit source]#ifndef NOINVERSE_HXX
#define NOINVERSE_HXX
#include <iostream>
using namespace std;
namespace CSE224 {
namespace Math{
namespace Exceptions {
class NoInverse {
public:
NoInverse(long num, long den) {
cerr << "Error: Throwing a NoInverse exception" << endl;
_n = num; _d = den;
} // end of constructor(long, long)
void writeNumber(void) {
cerr << "The problematic number is " << _n << "/" << _d << endl;
} // end of void writeNumber()
private:
long _n, _d;
}; // end of class NoInverse
} // end of namespace Exceptions
} // end of namespace Math
} // end of namespace CSE224
#endif
#ifndef ZERODENOMINATOR_HXX
#define ZERODENOMINATOR_HXX
namespace CSE224 {
namespace Math {
namespace Exceptions {
class ZeroDenominator {
public:
ZeroDenominator(void) {
cerr << "Error: Throwing a ZeroDenominator exception" << endl;
} // end of default constructor
}; // end of class ZeroDenominator
} // end of namespace Exceptions
} // end of namespace Math
} // end of namespace CSE224
#endif
#ifndef ZERODIVISOR_HXX
#define ZERODIVISOR_HXX
#include <iostream>
using namespace std;
namespace CSE224 {
namespace Math {
namespace Exceptions {
class ZeroDivisor {
public:
ZeroDivisor(void) {
cerr << "Error: Throwing a ZeroDivisor exception" << endl;
} // end of default constructor
}; // end of class ZeroDivisor
} // end of namespace Exceptions
} // end of namespace Math
} // end of namespace CSE224
#endif
测试程序
[edit | edit source]#include <iostream>
#include <memory>
using namespace std;
#include "math/Whole"
using namespace CSE224::Math;
#include "math/exceptions/ZeroDenominator"
using namespace CSE224::Math::Exceptions;
int main(void) {
cout << "Creating a Whole object(five) and initializing it with 5..." << endl;
auto_ptr < Whole > fivep(new Whole(5));
Whole& five = *fivep;
cout << "Creating a Rational object(three) and initializing it with 3..." << endl;
auto_ptr < Rational > threep(new Rational(3));
Rational& three = *threep;
cout << "Result of five.multiply(three) = ";
cout << five.multiply(three) << endl;
cout << "***************************************************" << endl;
cout << "Result of three.add(three) = ";
cout << three.add(three) << endl;
cout << "Result of three.add(five) = ";
cout << three.add(five) << endl;
cout << "Result of five.add(three) = ";
cout << five.add(three) << endl;
cout << "Result of five.add(five) = ";
cout << five.add(five) << endl;
cout << "***************************************************" << endl;
cout << "Setting a Rational object(ratFive) as an alias for a Whole object(five)..." << endl;
Rational& ratFive = five;
cout << "Result of ratFive.add(three) = ";
cout << ratFive.add(three) << endl;
cout << "Result of ratFive.add(five) = ";
cout << ratFive.add(five) << endl;
cout << "Result of ratFive.add(ratFive) = ";
cout << ratFive.add(ratFive) << endl;
cout << "Result of five.add(ratFive) = ";
cout << five.add(ratFive) << endl;
cout << "Result of three.add(ratFive) = ";
cout << three.add(ratFive) << endl;
cout << "***************************************************" << endl;
cout << "Creating a Rational object(r1) and initializing it with 3/2..." << endl;
auto_ptr < Rational > r1p(new Rational(3, 2));
Rational& r1 = *r1p;
cout << "Result of five.multiply(r1) = ";
cout << five.multiply(r1) << endl;
cout << "Result of five.divide(r1) = ";
cout << five.divide(r1) << endl;
return 0;
} // end of int main(void)
私有继承
[edit | edit source]编程是一项务实的活动,必须尽一切努力以最高效率地进行。可以说,实现这一点最有效的方式是重复使用在先前项目不同阶段中已经使用过的工件。[3] 通过这样做,我们节省了开发和测试时间,这意味着我们的下一个产品可以更快地进入市场。
实现重用的一种方式是继承。对许多人来说,这似乎是唯一负担得起的方式。但是,还有一个竞争者:组合。此技术是通过将一个对象作为另一个对象的成员来实现的。
class C {... B b; ...};
对于上面的例子,我们说一个 C
类的对象,除了其他东西之外,还包含一个 B
类的对象。换句话说,我们说一个 C
类的对象拥有(包含)一个 B
类的对象。这与继承关系肯定不同,继承关系被定义为“is-a”关系。
在 C++ 中,可以通过所谓的私有继承来实现类似的效果。
class C : /* private */ B { ... }
#ifndef LIST_HXX
#define LIST_HXX
#include "ds/exceptions/List_Exceptions"
using namespace CSE224::DS::Exceptions;
namespace CSE224 {
namespace DS {
class List {
friend ostream& operator<<(ostream&, const List&);
public:
List(void) : _size(0), _head(NULL), _tail(NULL) { }
List(const List&);
~List(void);
List& operator=(const List&);
bool operator==(const List&);
double get_first(void) throw(List_Empty);
double get_last(void) throw(List_Empty);
void insert_at_end(double new_item);
void insert_in_front(double new_item);
double remove_from_end(void) throw(List_Empty);
double remove_from_front(void) throw(List_Empty);
bool is_empty(void);
unsigned int size(void);
private:
下面是嵌套类的定义,嵌套类是在另一个类中定义的类。当嵌套类在 private
部分定义时,它在其封闭类外部不可见。当两个类紧密耦合时,这种方案很有用,例如我们的例子:一个 List_Node
对象仅在 List
对象的上下文中使用。构成我们的 List
对象的实现细节,不应被其用户关注。
虽然可能很想在嵌套类和 Java 的内部类之间建立平行关系,但这将是一个错误。与内部类与其封闭类之间的特殊关系相反,在 C++ 中,封闭类对嵌套在其内部的类没有特殊访问权限。因此,将 public
更改为 private
或 protected
不是一个好主意。
另一个需要说明的是,C++ 的嵌套类不会在内部类的对象中保留任何关于封闭对象的记录,这使得它们更像 Java 中的静态内部类。
class List_Node {
public:
List_Node(double val) : _info(val), _next(NULL), _prev(NULL) { }
double _info;
List_Node *_next, *_prev;
}; // end of class List_Node
private:
List_Node *_head, *_tail;
unsigned int _size;
在 private
部分声明的函数?是的!在 private
部分声明了由其他函数使用且不是接口一部分的函数。请注意,您不能不声明此类函数,即使它们不在类接口中。
void insert_first_node(double);
}; // end of List class
} // end of namespace DS
} // end of namespace CSE224
#endif
#include <iostream>
using namespace std;
#include "ds/List"
#include "ds/exceptions/List_Exceptions"
using namespace CSE224::DS::Exceptions;
namespace CSE224 {
namespace DS {
List::
List(const List& rhs) : _head(NULL), _tail(NULL), _size(0) {
List_Node *ptr = rhs._head;
for(unsigned int i = 0; i < rhs._size; i++) {
this->insert_at_end(ptr->_info);
ptr = ptr->_next;
} // end of for(unsigned int i = 0; i < rhs._size; i++)
} // end of copy constructor
现在 List
对象的节点是在堆上分配的,我们必须确保在 List
对象本身隐式或显式地被删除时,它们被返回给内存分配器。因此,我们需要编写一个析构函数来释放这些节点。请注意,一个 List
对象由两个指针组成,它们指向列表的头和尾,以及一个保存其大小的字段。这些指针直接或间接指向的节点不是 List
对象的一部分。因此,它们不会与列表对象一起自动释放。因此,我们需要一个析构函数。
请注意,我们的决定不受 List
对象本身是在堆上创建还是在栈上创建的影响。List
对象的创建位置会影响谁应该调用析构函数:无论谁是负责方(编译器或程序员),在所有可能的情况下,析构函数都会在对象释放之前被隐式调用。如果它是在堆上创建的,则程序员负责进行调用。否则,编译器会在对象作用域结束时处理这些繁琐的工作。
List::
~List(void) {
for (int i = 0; i < _size; i++)
remove_from_front();
} // end of destructor
List& List::
operator=(const List& rhs) {
if (this == &rhs) return (*this);
for(unsigned int i = _size; i > 0; i--)
this->remove_from_front();
List_Node *ptr = rhs._head;
for(unsigned int i = 0; i < rhs._size; i++) {
this->insert_at_end(ptr->_info);
ptr = ptr->_next;
} // end of for(unsigned int i = 0; i < rhs._size; i++)
if (rhs._size == 0) {
_head = _tail = NULL;
_size = 0;
} // end of if(rhs._size == 0)
return (*this);
} // end of assignment operator
bool List::
operator==(const List& rhs) {
if (_size != rhs._size) return false;
if (_size == 0 || this == &rhs) return true;
List_Node *ptr = _head;
List_Node *ptr_rhs = rhs._head;
for (unsigned int i = 0; i < _size; i++) {
if (!(ptr->_info == ptr_rhs->_info))
return false;
ptr = ptr->_next;
ptr_rhs = ptr_rhs->_next;
} // end of for(unsigned int i = 0; i < _size; i++)
return true;
} // end of equality-test operator
double List::
get_first(void) throw(List_Empty) {
if (is_empty()) throw List_Empty();
return (_head->_info);
} // end of double List::get_first(void)
double List::
get_last(void) throw(List_Empty) {
if (is_empty()) throw List_Empty();
return (_tail->_info);
} // end of double List::get_last(void)
void List::
insert_at_end(double new_item) {
if (is_empty()) insert_first_node(new_item);
else {
List_Node *new_node = new List_Node(new_item);
_tail->_next = new_node;
new_node->_prev = _tail;
_tail = new_node;
}
_size++;
} // end of void List::insert_at_end(double)
void List::
insert_in_front(double new_item) {
if (is_empty()) insert_first_node(new_item);
else {
List_Node *new_node = new List_Node(new_item);
new_node->_next = _head;
_head->_prev = new_node;
_head = new_node;
}
_size++;
} // end of void List::insert_in_front(double)
double List::
remove_from_end(void) throw(List_Empty) {
if (is_empty()) throw List_Empty();
double ret_val = _tail->_info;
List_Node *temp_node = _tail;
if (_size == 1) { head = _tail = NULL; }
else {
_tail = _tail->_prev;
_tail->_next = NULL;
}
delete temp_node;
_size--;
return ret_val;
} // end of double List::remove_from_front(void)
double List::
remove_from_front(void) throw(List_Empty) {
if (is_empty()) throw List_Empty();
double ret_val = _head->_info;
List_Node *temp_node = _head;
if (_size == 1) { _head = _tail = NULL; }
else {
_head = _head->_next;
_head->_prev = NULL;
}
delete temp_node;
_size--;
return ret_val;
} // end of double List::remove_from_front(void)
bool List::
is_empty(void) { return(_size == 0); }
unsigned int List::
size(void) { return _size; }
void List::
insert_first_node(double new_item) {
List_Node *new_node = new List_Node(new_item);
_head = _tail = new_node;
} // end of void List::insert_first_node(double)
ostream& operator<<(ostream& os, const List& rhs) {
List tmp_list = rhs;
os << "<" << rhs._size << "> <head: ";
for (int i = 0; i < rhs._size - 1; i++) {
os << tmp_list._head->_info << ", ";
tmp_list._head = tmp_list._head->_next;
} // end of for(int i = 0; i < rhs._size; i++)
if (rhs._size > 0) os << tmp_list._head->_info;
os << ": tail>";
return(os);
} // end of ostream& operator<<(ostream&, const List&)
} // end of namespace DS
} // end of namespace CSE224
#ifndef STACK_HXX
#define STACK_HXX
#include <iostream>
using namespace std;
#include "ds/exceptions/Stack_Exceptions"
using namespace CSE224::DS::Exceptions;
#include "ds/List"
namespace CSE224 {
namespace DS {
List
类提供了堆栈功能的超集。这最初可能让我们认为,我们可以定义一个新的类 Stack
,并让它(公开地)从 List
类派生。这种方法的问题是,基类的公共接口将作为派生类的公共接口的一部分暴露出来。这不是我们在这个案例中想要的:List
类提供了比我们对 Stack
类期望的更多功能。因此,我们应该采用其他方法,例如组合。
C++ 提供了一种替代方法:私有继承。使用私有继承,派生类仍然可以利用基类提供的功能,但基类接口不会通过派生类暴露。因此,Stack
类私有地继承自 List
类。
class Stack : private List {
public:
现在派生类可以重用基类功能,但不会将其暴露给用户,这种类型的继承也称为实现继承。出于类似原因,公有继承也被称为接口继承。
我们不需要编写正统规范形式的函数,因为编译器生成的版本提供了我们所需功能的等效实现。这主要是因为 Stack
的唯一数据字段是它从 List
继承的 List
子对象。
// Stack(void);
// Stack(const Stack&);
// ~Stack(void);
// Stack& operator=(const Stack&);
// bool operator==(const Stack&);
double peek(void) throw(Stack_Empty);
double pop(void) throw(Stack_Empty);
void push(double new_item);
由于以下语句,我们有选择地从私有继承的基类中暴露了一个函数。它就像 List
类中的 is_empty
函数是公有继承的一样。
using List::is_empty;
}; // end of Stack class
} // end of namespace DS
} // end of namespace CSE224
#endif
#include <iostream>
using namespace std;
#include "ds/Stack"
#include "ds/exceptions/Stack_Exceptions"
using namespace CSE224::DS::Exceptions;
namespace CSE224 {
namespace DS {
double Stack::
peek(void) throw(Stack_Empty) {
double ret_val;
我们的类的用户不应该知道我们如何实现 Stack
类。这就是为什么我们需要重新抛出 List
类抛出的异常,这样对用户来说更有意义。
try { ret_val = get_first();}
catch(List_Empty) { throw Stack_Empty(); }
return ret_val;
} // end of double Stack::peek(void)
void Stack::
push(double new_item) { List::insert_in_front(new_item); }
double Stack::
pop(void) throw(Stack_Empty) {
double ret_val;
try { ret_val = remove_from_front(); }
catch(List_Empty) { throw Stack_Empty(); }
return ret_val;
} // end of double Stack::pop(void)
} // end of namespace DS
} // end of namespace CSE224
随着多重继承的可能性出现,出现了所谓的虚继承问题。考虑图 2 中所示的类层次结构。等待您回答的问题是:一个 Politician
对象中会有多少个 Animal
子对象?从图中看,正确答案似乎是两个。但是,我们的逻辑告诉我们一个不同的故事:一个 Politician
对象中只能有一个 Animal
子对象。
无论哪一个答案是正确的,可能会有情况使其中任何一个都成为更好的选择。我们必须找到一种方法来区分这些选项。这就是虚继承的概念发挥作用的地方。我们将 Humanoid
和 Ape
类定义为从 Animal
虚派生。
示例:虚继承 |
---|
|
由于这些定义,现在在Politician
中只有一个Animal
子对象。这是通过确保将指针(而不是对象本身)插入派生类来实现的。也就是说,Politician
对象现在有两个指针,它们都指向同一个Animal
子对象,而不是包含两个Animal
子对象。
请注意,使用虚继承会导致构造函数调用顺序发生变化:虚基类始终在非虚基类之前构造,无论它们在继承层次结构中处于什么位置。
虚继承的典型应用包括实现mixin类。mixin类用于调整基类的行为,并且可以组合起来获得更专门的类。例如,使用以下代码可以创建具有不同样式的窗口:普通窗口、带菜单的窗口、带边框的窗口以及带菜单和边框的窗口。实际上,我们可以创建我们自己的mixin,例如滚动条mixin,并获得支持滚动条的这些窗口样式版本。
示例:通过虚继承实现mixin。 |
---|
|
请注意,窗口样式的数量随着mixin数量的增加呈指数增长。但是,由于虚继承,我们不必考虑每种组合。我们从基类和一些mixin开始。当我们需要更精细的窗口样式时,我们会创建一个从相关mixin类继承的新类。如果我们发现mixin类中缺少某些属性,我们可以编写我们自己的mixin并像其他mixin一样使用它。
实现多态性
[edit | edit source]在本节中,我们将探讨两种广泛使用的实现多态性的方法。值得注意的是,这两种方法都依赖于动态调度函数调用到相关的[函数]入口点。换句话说,多态性是通过动态调度来实现的。另一个要说明的点是我们用来表达多态性的工具:继承。
在不同的上下文中提及多态性、继承和动态调度可能会让一些人认为它们是无关的概念。然而,这完全是错误的。事实上,在面向对象的上下文中,这些是互补的概念,协同工作以自然的方式实现“is-a”关系。为了表达两个类之间的“is-a”关系,我们需要继承和多态性的帮助:继承用于在基类和派生类之间定义一个通用的消息协议,而多态性则需要提供行为可变性。[4] 这是通过动态调度消息来进一步实现的。
如上一节所述,没有多态性的继承会导致僵化、面向对象的解决方案。同样,仅凭多态性通常不是你想要的。因此,建议你考虑将这两个概念结合使用。
前面的说明不应该让你认为我们只能使用继承和多态性的组合。我们在软件行业的成功取决于生产可靠的、易于扩展的、高效的软件。实现这一目标的关键词是重用,上面提到的概念并非没有替代方案。除了古老的组合技术之外,我们还可以使用泛型。对类或子程序进行参数化也会让我们享受到重用的好处。前者的一个示例在参数化类型章节中给出,而后者的使用示例如下。由于这种方法,用户可以对任何数组进行排序,只要提供了组件类型的Comparator<V>
对象。
示例:Java 中的泛型方法。 |
---|
|
这种方法被称为提供参数多态性。它是多态的,因为相同的方法对不同类型的参数执行相同的操作;对于方法的用户来说,它看起来就像为每种不同类型都有单独的方法一样。[5]
在重载子程序的情况下,也可以体验到类似的效果,其中具有不同参数列表的调用将分派到具有不同签名的子程序,并且用户会因为调用看似相同的子程序而得到不同的行为。因此,重载有时被称为临时多态性。[6]
现在我们已经消除了对继承和多态性的困惑,让我们继续讨论在实现多态性中常用的技术。但在我们这样做之前,我们将列出我们演示中使用的示例类。
class B1 { ... public: virtual void f(int); void fs(int); ... ... } ; // end of base class B1 class B2 { ... public: virtual void f(int); virtual void g(void); ... ... } ; // end of base class B2 class D : public B1, public B2 { ... public: void f(int); ... ... }; // end of derived class D
vtables
[edit | edit source]vtable 技术通常用于 PC 世界的编译器,它使用一个表,该表包含函数入口地址和偏移量对的行。所有至少有一个动态分派函数的类的对象都有一个指针,称为vptr,指向该表的开头。偏移列用于调整this
指针的值。当派生类的对象通过基类的指针使用时,需要进行这种调整。以D
的定义为例,其对象内存布局如下所示。给定一个D
的对象,我们可以通过D
的指针以及D
的任何祖先类型(在本例中为D*
、B1*
和B2*
)的指针来使用它。换句话说,通过类型为B1*
的变量,我们可以操作任何从B1
派生的类的实例,在本例中为D
和B1
。因此,以下操作是可能的。
... D* d_obj = new D(...); B1* b1_obj = new B1(...); B2* b2_obj = new B2(...); ... fd(D* d_par) { ... d_par->f(...); d_par->g(...); ... } // end of ... fd(D*) ... fb1(B1* b1_par) { ... b1_par->f(...); ... ... } // end of ... fb1(B1*) ... fb2(B2* b2_par) { ... b2_par->f(...); b2_par->g(...); ... } // end of ... fb2(B2*) ... fd(d_obj) ...; ... fb1(b1_obj) ...; ... fb1(d_obj) ...; ... fb2(b2_obj) ...; ... fb2(d_obj) ...;
在派生类(D
)中重写了 f
函数,意味着调用这个版本的 f
函数可能会使用 B1
、B2
和 D
中的所有属性,这意味着该函数的接收对象必须至少是 D
对象。如前所述,这样的对象也可以通过 B2*
指针来操作。这可以通过执行上面代码片段第 7 行,紧随第 15 行的调用来体现。另外,在执行第 14 行的函数调用时,第 7 行的函数调用使用了一个 B2
对象。由于 b2_par
的类型是 B2*
,两种情况都通过 B2
对象的 vptr 字段处理。然而,在一种情况下,这个对象是 D
对象的一部分,而在另一种情况下,它是一个普通的 B2
对象。如上图所示。
B2
作为 D
对象的一部分需要我们注意。对象的起始地址(D
)和用于操作该对象的指针(B2*
)在内存中指示不同的位置。这要求为了能够使用 D
对象的所有属性,我们需要调整指针值,调整的字节数与 B2
子对象之前的字节数一样,这在我们图中被称为 delta(B2
)。
调整thunk
[edit | edit source]我们将要介绍的第二种技术可以被视为第一种技术的一种可移植性较差[7] 的优化。与 vptr 技术一样,vtable 中的一列包含指向函数的指针。但我们现在使用 thunk 而不是调整列。如果需要对指针进行调整,vtable 中唯一一列中的指向函数的指针指向由编译器生成的 thunk 代码,该代码会修改指针并跳转到函数入口。如果没有需要调整,指向函数的指针包含要调用的函数的入口地址。
多重继承导致的复杂性
[edit | edit source]观察多重[实现]继承的要求使得多态性的实现变得复杂。如果我们只考虑单继承,一个对象将始终只有一个 vptr,并且我们永远不需要对其进行任何调整。
问题:在实现多重接口继承时,我们是否会面临相同的复杂性?作为起点,考虑实现多个接口或实现从其他接口多重继承的接口的类的对象的内存布局。
构造函数和析构函数的调用顺序
[edit | edit source]对象构造涉及为对象分配内存并通过调用相关的构造函数来初始化其内容[以及根据需要获取外部资源]。这套原子操作序列在三种情况下触发
- 定义具有静态范围的变量:对于全局变量,对象的内存是在编译时在静态数据区域分配的,并且构造函数的调用[即初始化和资源获取]作为程序的第一条语句执行。如果有多个这样的变量,则构造函数的执行顺序与文本中相应的定义顺序相同。静态局部变量在两个方面有所不同:构造函数的调用是在第一次进入函数时执行一次,并且此调用不会修改函数中语句执行的顺序。
- 定义块变量:分配和初始化都在运行时进行,每次控制流到达变量定义的位置时。
- 使用 new 运算符创建对象:当控制流到达应用 new 运算符的语句时,对象被分配和初始化。
示例:具有静态范围的变量的构造顺序。 |
---|
|
→ |
In the constructor of C... // Global variable c_g First statement in f... In the constructor of C... // Static local variable c_sl Last statement in f... In main... First statement in f... Last statement in f... |
如果可能没有程序员定义的构造函数,C++ 编译器会合成一个默认构造函数,它会调用子对象的默认构造函数。对于具有基本类型字段的对象,由于基本类型不支持构造函数的概念,这意味着永远不会调用构造函数,这会导致新创建的对象处于随机状态。
作为构造的补充,对象的销毁是通过调用析构函数来完成的,然后释放对象的内存。析构函数(如果有)旨在释放即将回收的对象正在使用的资源,包括堆内存和外部资源。
静态变量的析构函数调用发生在程序的最后一条语句之后,而块变量在定义它们的块退出时被销毁。[8] 在同一作用域中存在多个变量声明的情况下,两种变量的析构函数调用的顺序与构造函数调用的顺序相反。
具有基本类型字段的对象
[edit | edit source]与 Java 和 C# 不同,在 Java 和 C# 中保证基本类型字段具有特定的初始值,C++ 不会对这些字段进行任何隐式初始化。换句话说,除非由程序员提供,否则这些字段将保持未初始化状态。
示例 |
---|
|
→ |
... In the C1 constructor taking an int... i = 3 // for o3 In the default constructor of C1 // for o In the C1 constructor taking an int... i = 1 // for o1 In the C1 constructor taking an int... i = 2 // for o2 In the destructor of C1... i = 3 // for o3 In the destructor of C1... i = 2 // for o2 In the destructor of C1... i = 1 // for o1 In the destructor of C1... i = 138645 // for o ... |
在创建复合对象的过程中,对复合对象构造函数的调用先于对子对象的构造函数调用。除非在成员初始化列表中显式地进行这些调用,否则子对象将使用默认构造函数进行初始化。
示例 |
---|
|
→ |
... In the default constructor of C1 // for o._o In the default constructor of C2 // for o In the destructor of C2 // for o In the destructor of C1 // for o._o ... |
在有多个子对象的情况下,构造函数调用按它们在类定义中出现的顺序进行。
示例:包含多个子对象的类 |
---|
|
→ |
... In the default constructor of C1 // for o._o1 In the default constructor of C1 // for o._o2 In the default constructor of C3 // for o In the destructor of C3 // for o In the destructor of C1 // for o._o2 In the destructor of C1 // for o._o1 ... |
为了定义一组属于相同类型的变量(数组变量),需要初始化数组中的每个元素。类似地,销毁该数组需要销毁数组中的每个元素。
示例 |
---|
|
→ |
... In the default constructor of C1 // for o[0]._o In the default constructor of C2 // for o[0] In the default constructor of C1 // for o[1]._o In the default constructor of C2 // for o[1] In the destructor of C2 // for o[1] In the destructor of C1 // for o[1]._o In the destructor of C2 // for o[0] In the destructor of C1 // for o[0]._o ... |
示例:包含子对象数组的类 |
---|
|
→ |
... In the default constructor of C1 // for o._o[0] In the default constructor of C1 // for o._o[1] In the default constructor of C4 // for o In the destructor of C4 // for o In the destructor of C1 // for o._o[1] In the destructor of C1 // for o._o[0] ... |
示例 |
---|
|
→ |
... In the default constructor of C1 // for o._o[0]._o In the default constructor of C2 // for o._o[0] In the default constructor of C1 // for o._o[1]._o In the default constructor of C2 // for o._o[1] In the default constructor of C5 // for o In the destructor of C5 // for o In the destructor of C2 // for o._o[1] In the destructor of C1 // for o._o[1]._o In the destructor of C2 // for o._o[0] In the destructor of C1 // for o._o[0]._o ... |
继承
[edit | edit source]将继承视为“编译器管理的组合”是弄清楚构造函数和析构函数调用顺序的关键。其他方面都相同。
示例 |
---|
|
上面片段的第一行可以被认为是由编译器转换成下面这样。注意标识符名称是任意的,不能以任何方式在类实现中引用。
类 IC1 { public: C1 _c1_part; private:
→ |
... In the default constructor of C1 // for C1 part of o1 In the default constructor of IC1 // for o1 In the destructor of IC1 // for o1 In the destructor of C1 // for C1 part of o1 ... |
示例:继承和组合。 |
---|
|
→ |
... In the default constructor of C1 // for C1 part of o1 In the default constructor of C1 // for o1._o In the default constructor of IC2 // for o1 In the destructor of IC2 // for o1 In the destructor of C1 // for o1._o In the destructor of C1 // for C1 part of o1 ... |
成员初始化列表
[edit | edit source]除非另有说明,所有隐式构造函数调用都将调用默认构造函数。此行为可以通过将成员初始化列表附加到构造函数函数头来更改。
示例 |
---|
|
为了教学目的,上面片段的第 4 行可以看成下面这样。但是,因为它用两个初始化替换了两个“初始化后跟赋值”的序列,所以使用成员初始化列表是一个更有效的选择。这是因为——即使您没有成员初始化列表——子对象的构造函数将在复合对象的构造函数之前调用,这意味着以下片段中的两行实际上是赋值,而不是初始化。[9] 在执行之前,每个子对象都将已经使用 C1
的默认构造函数进行初始化。
IC3(int i) { _c1_part = C1(1); _o = C1(1);
→ |
... In the C1 constructor taking an int // for C1 part of o1 In the C1 constructor taking an int // for o1._o In the IC3 constructor taking an int // for o1 In the destructor of IC3 // for o1 In the destructor of C1 // for o1._o In the destructor of C1 // for C1 part of o1 ... |
多重继承
[edit | edit source]基于我们对继承的非正式定义,即编译器管理的组合,我们可以将多重继承类的对象视为由多个子对象组成。因此,为了教学目的,我们可以相应地将下面片段的第 1 行视为以下内容。
类 IC4 { public: C1 _c1_part; C2 _c2_part;
示例 |
---|
class IC4 : public C1, public C2 {
public:
IC4(void) { cout << "In the default constructor of IC4" << endl; }
~IC4(void) { cout << "In the destructor of IC4" << endl; }
}; // end of class IC4
...
{
IC4 o1;
...;
}
...
|
→ |
... In the default constructor of C1 // for C1 part of o1 In the default constructor of C1 // for _o of the C2 part of o1 In the default constructor of C2 // for C2 part of o1 In the default constructor of IC4 // for o1 In the destructor of IC4 // for o1 In the destructor of C2 // for C2 part of o1 In the destructor of C1 // for _o of the C2 part of o1 In the destructor of C1 // for C1 part of o1 ... |
重新说明公式:继承是编译器管理的组合
[edit | edit source]以下列出了三对等效的类定义,旨在让您了解编译器在幕后完成的工作。在浏览代码时请记住,右栏中给出的代码只反映了编译器的行为,而不是其工作方式。
类 SC1 { ...; public: ...; void SC1_f1(...); void SC1_f2(...); ...; }; // 类 SC1 结束
公有继承允许通过派生类的接口使用基类的接口。程序员无需任何额外操作,编译器会自动处理。如果您出于某种原因想在不使用继承的情况下实现,则必须显式地公开基类的函数,并将对这些函数的调用委托给基类中的对应函数。通过私有继承和选择性公开基类接口,C++ 程序员可以减轻这种额外负担。
|
|
私有派生意味着基类中的功能无法通过派生类对象访问。但是,仍然可以在实现派生类接口中找到的函数时利用这种功能。
|
|
有时可能需要将两种情况混合使用。也就是说,基类中的一部分功能是可见的,而其余部分必须隐藏起来。这种选择性公开可以通过私有继承和 using
声明组合来实现。
|
|
虚继承
[edit | edit source]虚拟基类的对象总是在非虚拟类的对象之前构造。需要注意的是,“虚拟性”实际上是派生的属性,而不是基类本身的属性。
示例 |
---|
|
→ |
... In the default constructor of C1 In the default constructor of VC2 In the destructor of VC2 In the destructor of C1 ... |
示例 |
---|
|
→ |
... In the default constructor of C1 In the default constructor of VC2 In the default constructor of VC3 In the default constructor of VC4 In the destructor of VC4 In the destructor of VC3 In the destructor of VC2 In the destructor of C1 ... |
- ↑ 另一个选择是利用 Java 注解的代码生成方面。
- ↑ 唯一缺少的是垃圾回收。智能指针将在实现部分介绍,并提供部分解决方案。
- ↑ 重用通常指代码重用。然而,分析和设计文档、程序代码、测试用例和测试代码都是可以重用的。事实上,软件生产过程早期阶段的工件重用对生产率的影响更大。
- ↑ 我们可以通过在接口中定义公共消息协议,并让类实现这个接口来实现相同的结果。
- ↑ 注意,再一次是动态调度使魔法起作用。直到程序运行并执行第 6 行代码时,才知道
compare
消息将被分派到哪个方法。在样本 C 程序章节的回调部分中,等效 C 代码中使用指向函数的指针证明了这一点。 - ↑ 与其他使用“多态性”一词的语境不同,函数调用——通过将参数与函数的签名匹配——在编译时或链接时被解析,因此是静态分派。
- ↑ 例如,某些体系结构不允许 goto 指令进入另一个函数的主体。
- ↑ 动态分配对象的析构函数调用发生在使用
delete
的相关应用程序的点上。 - ↑ 这与我们对构造函数的定义相矛盾,我们的定义指出构造函数用于初始化对象。就 C++ 而言,在构造函数中发生的是赋值;初始化是使用成员初始化列表进行的。