C++继承技术

方法覆盖

virtual关键字

只有在基类中声明为 virtual 的方法才能被派生类正确覆盖。关键字位于方法声明的开头,如下面的 Base 的修改版本所示:

class Base {
  public:
  	virtual void someMethod() {}
  protected:
  	int m_protectedInt { 0 };
  private:
  	int m_privateInt { 0 };
};

override关键字

要覆盖一个方法,你可以在派生类定义中重新声明它,就像它在基类中声明的那样,不同的是添加了 override 关键字并删除了 virtual 关键字。

class Derived : public Base {
  public:
  	void someMethod() override; // Overrides Base's someMethod()
  	virtual void someOtherMethod();
};

override 关键字的使用是可选的,但强烈建议使用。如果没有关键字,可能会意外地创建一个新的虚方法,而不是覆盖基类中的方法。

virtual是如何实现的

要了解如何避免方法隐藏,需要更多地了解 virtual 关键字的实际作用。在 C++ 中编译类时,会创建一个二进制对象,其中包含该类的所有方法。在非virtual的情况下,将控制转移到适当方法的代码直接硬编码在基于编译时类型调用方法的位置。这称为静态绑定,也称为早期绑定。

如果该方法被声明为virtual,则通过使用称为 vtable 或“虚表”的特殊内存区域调用正确的实现。每个具有一个或多个虚方法的类都有一个虚表,并且此类的每个对象都包含一个指向该虚表的指针。这个 vtable 包含指向虚方法实现的指针。这样,当在对象上调用方法时,指针跟随进入vtable,并在运行时根据对象的实际类型执行适当版本的方法。这称为动态绑定,也称为后期绑定。

为了更好地理解 vtables 如何使方法覆盖成为可能,以下面的 Base 和 Derived 类为例:

class Base {
  public:
  	virtual void func1();
  	virtual void func2();
  	void nonVirtualFunc();
};

class Derived : public Base {
  public:
  	void func2() override;
  	void nonVirtualFunc();
};

对于此示例,假设有以下两个实例:

Base myBase;
Derived myDerived;

图显示了两个实例的 vtable 外观的高级视图。 myBase 对象包含一个指向它的虚表的指针。该 vtable 有两个条目,一个用于 func1(),一个用于 func2()。这些条目指向 Base::func1() 和 Base::func2() 的实现。

myDerived 也包含指向其 vtable 的指针,该 vtable 也有两个条目,一个用于 func1(),一个用于 func2()。它的 func1() 入口指向 Base::func1(),因为 Derived 不会覆盖 func1()。另一方面,它的 func2() 入口指向 Derived::func2()。
C++继承技术_第1张图片
请注意,两个 vtable 都不包含 nonVirtualFunc() 方法的任何条目,因为该方法不是virtual。

virtual的合理性

在某些语言中,例如 Java,所有方法都是自动virtual的,因此可以正确地覆盖它们。在 C++ 中,情况并非如此。反对在 C++ 中使一切virtual的论点,以及首先创建关键字的原因,与 vtable 的开销有关。要调用虚方法,程序需要通过解引用指向要执行的适当代码的指针来执行额外的操作。在大多数情况下,这是一个很小的性能损失,但 C++ 的设计者认为让程序员决定是否需要性能损失会更好,至少在当时是这样。如果该方法永远不会被覆盖,则无需将其虚拟化并降低性能。然而,对于今天的 CPU,性能影响是以纳秒的分数来衡量的,而且未来的 CPU 会越来越小。在大多数应用程序中,使用虚方法和避免使用虚方法之间不会有可衡量的性能差异。

尽管如此,在某些特定的用例中,性能开销可能过于昂贵,你可能需要有一个选项来避免这种情况。例如,假设你有一个具有虚方法的 Point 类。如果你有另一个存储数百万甚至数十亿个点的数据结构,则在每个点上调用虚拟方法会产生巨大的开销。在这种情况下,避免在 Point 类中使用任何虚方法可能是明智的。

每个对象的内存使用量也会受到轻微影响。除了方法的实现之外,每个对象还需要一个指向它的 vtable 的指针,它占用的空间很小。在大多数情况下这不是问题。但是,有时它确实很重要。再次以 Point 类和存储数十亿点的容器为例。在这种情况下,所需的额外内存变得很重要。

虚析构函数

除非你有特殊原因不这样做或类被标记为final,否则析构函数应标记为virtual。构造函数不能也不需要是virtual的,因为你总是在创建对象时指定要构造的确切类。

阻止覆盖

除了将整个类标记为 final 之外,C++ 还允许将单个方法标记为 final。此类方法不能在进一步的派生类中被覆盖。例如,从以下派生类覆盖 someMethod() 会导致编译错误:

class Base {
  public:
  	virtual ~Base() = default;
  	virtual void someMethod();
};

class Derived : public Base {
  public:
  	void someMethod() override final;
};

class DerivedDerived : public Derived {
  public:
  	void someMethod() override; // Compilation error.
};

更改覆盖方法的返回类型

在 C++ 中,覆盖方法可以更改返回类型,只要原始返回类型是类的指针或引用,而新的返回类型是子类的指针或引用即可。这种类型称为协变返回类型

确定是否可以更改覆盖方法的返回类型的一个好方法是考虑现有代码是否仍然有效;这称为里氏替换原则 (LSP)

将虚基类方法的重载添加到派生类

可以向派生类添加新的虚基类方法重载。也就是说,你可以在派生类中添加一个虚方法的重载,并带有一个新的原型,但继续继承基类版本。此技术使用 using 声明在派生类中显式包含方法的基类定义。这是一个例子:

class Base {
  public:
  	virtual void someMethod();
};

class Derived : public Base {
  public:
  	using Base::someMethod; // Explicitly inherits the Base version.
  	virtual void someMethod(int i); // Adds a new overload of someMethod().
  	virtual void someOtherMethod();
};

继承的构造函数

在上一节中,你看到了使用 using 声明在派生类中显式包含基类定义的方法。这对于普通的类方法非常有效,但它也适用于构造函数,允许从基类继承构造函数。查看 Base 和 Derived 类的以下定义:

class Base {
  public:
  	virtual ~Base() = default;
  	Base() = default;
  	Base(std::string_view str);
};

class Derived : public Base {
  public:
  	Derived(int i);
};

你只能用提供的 Base 构造函数构造 Base 对象,可以是默认构造函数,也可以是带有 string_view 参数的构造函数。另一方面,构造 Derived 对象只能使用提供的 Derived 构造函数发生,它需要一个整数作为参数。您不能使用默认构造函数或接受 Base 类中定义的 string_view 的构造函数来构造 Derived 对象。这是一个例子:

Base base { "Hello" }; // OK, calls string_view Base ctor.
Derived derived1 { 1 }; // OK, calls integer Derived ctor.
Derived derived2 { "Hello" }; // Error, Derived does not inherit string_view ctor.
Derived derived3; // Error, Derived does not have a default ctor.

如果你想要使用基于 string_view 的 Base 构造函数构造 Derived 对象,你可以在 Derived 类中显式继承 Base 构造函数,如下所示:

class Derived : public Base {
  public:
  	using Base::Base;
  	Derived(int i);
};

using 声明继承了父类的所有构造函数。

基类方法有默认参数

派生类和基类都可以有不同的默认参数,但使用的参数取决于变量的声明类型,而不是底层对象。下面是派生类的一个简单示例,它在重写的方法中提供不同的默认参数:

class Base {
  public:
  	virtual ~Base() = default;
  	virtual void go(int i = 2) {
  		cout << "Base's go with i=" << i << endl;
  	}
};

class Derived : public Base {
  public:
  	void go(int i = 7) override {
  		cout << "Derived's go with i=" << i << endl;
  	} 
};

如果在 Derived 对象上调用 go(),则使用默认参数 7 执行 Derived 版本的 go()。如果在 Base 对象上调用 go(),则使用默认参数 7 调用 Base 版本的 go() 2. 但是(这是奇怪的部分),如果在真正指向 Derived 对象的 Base 指针或 Base 引用上调用 go(),则会调用 Derived 的 go() 版本,但 Base 的默认参数为 2。这行为如以下示例所示:

Base myBase;
Derived myDerived;
Base& myBaseReferenceToDerived { myDerived };
myBase.go();
myDerived.go();
myBaseReferenceToDerived.go();

这段代码的输出如下:

Base's go with i=2
Derived's go with i=7
Derived's go with i=2

这种行为的原因是 C++ 使用表达式的编译时类型来绑定默认参数,而不是运行时类型。默认参数在 C++ 中不是“继承的”。如果此示例中的派生类未能像其父类那样提供默认参数,它将使用新的非零参数版本重载 go() 方法。

类型转化

static_cast

你可以使用 static_cast() 来执行语言直接支持的显式转换。例如,如果你编写的算术表达式需要将 int 转换为 double 以避免整数除法,请使用 static_cast()。在此示例中,仅将 static_cast() 与 i 一起使用就足够了,因为这会使两个操作数之一成为双精度数,确保 C++ 执行浮点除法。

static_cast() 的另一个用途是在继承层次结构中执行向下转型,如本例所示:

class Base {
  public:
  	virtual ~Base() = default;
};

class Derived : public Base {
  public:
  	virtual ~Derived() = default;
};

int main() {
	Base* b { nullptr };
	Derived* d { new Derived{} };
	b = d; // Don't need a cast to go up the inheritance hierarchy.
	d = static_cast<Derived*>(b); // Need a cast to go down the hierarchy.
	Base base;
	Derived derived;
	Base& br { derived };
	Derived& dr { static_cast<Derived&>(br) };
}

这些转换适用于指针和引用。他们不适用对象本身。

请注意,这些使用 static_cast() 的转换不执行运行时类型检查。它们允许将任何 Base 指针转换为 Derived 指针,或将 Base 引用转换为 Derived 引用,即使 Base 在运行时确实不是 Derived。例如,以下代码可以编译和执行,但使用指针 d 可能会导致潜在的灾难性故障,包括对象边界外的内存覆盖。要通过运行时类型检查安全地执行转换,请使用 dynamic_cast()

Base* b { new Base{} };
Derived* d { static_cast<Derived*>(b) };

reinterpret_cast()

与 static_cast() 相比,reinterpret_cast() 更强大一些,同时也更不安全。可以使用它来执行一些 C++ 类型规则在技术上不允许但在某些情况下对程序员可能有意义的强制转换。例如,你可以将对一种类型的引用转换为对另一种类型的引用,即使这些类型不相关。类似地,您可以将指针类型转换为任何其他指针类型,即使它们在继承层次结构中无关。这通常用于将指针转换为 void*。这可以隐式完成,因此不需要显式转换。但是,将 void* 转换回正确类型的指针需要 reinterpret_cast()。 void* 指针只是指向内存中某个位置的指针。没有类型信息与 void* 指针关联。这里有些例子:

class X {};
class Y {};

int main() {
	X x;
	Y y;
	X* xp { &x };
	Y* yp { &y };
	// Need reinterpret_cast for pointer conversion from unrelated classes
	// static_cast doesn't work.
	xp = reinterpret_cast<X*>(yp);
	// No cast required for conversion from pointer to void*
	void* p { xp };
	// Need reinterpret_cast for pointer conversion from void*
	xp = reinterpret_cast<X*>(p);
	// Need reinterpret_cast for reference conversion from unrelated classes
	// static_cast doesn't work.
	X& xr { x };
	Y& yr { reinterpret_cast<Y&>(x) };
}

reinterpret_cast() 并不是万能的;它对什么可以投射到什么有很多限制。这些限制不会在本文中进一步讨论,因为我建议你明智地使用这些类型的转换。

std::bit_cast()

C++20 引入了在 中定义的 std::bit_cast()。这是标准库中唯一的转化;其他强制转换是 C++ 语言本身的一部分。 bit_cast() 类似于 reinterpret_cast(),但它创建一个给定目标类型的新对象,并将位从源对象复制到这个新对象。它有效地将源对象的位解释为目标对象的位。 bit_cast() 要求源对象和目标对象的大小相同,并且两者都可以轻松复制。这是一个例子:

float asFloat { 1.23f };
auto asUint { bit_cast<unsigned int>(asFloat) };
if (bit_cast<float>(asUint) == asFloat) {
	cout << "Roundtrip success." << endl;
}

bit_cast() 的一个用例是普通可复制类型的二进制 I/O。例如,你可以将此类类型的各个字节写入文件。当你将文件读回内存时,您可以使用 bit_cast() 来正确解释从文件中读取的字节。

dynamic_cast()

dynamic_cast() 提供对继承层次结构中的转换的运行时检查。可以使用它来转换指针或引用。 dynamic_cast() 在运行时检查底层对象的运行时类型信息。如果转换没有意义,dynamic_cast() 返回空指针(对于指针版本)或抛出 std::bad_cast 异常(对于引用版本)。

请记住,运行时类型信息存储在对象的 vtable 中。因此,要使用 dynamic_cast(),你的类必须至少有一个虚方法。如果没有 vtable,尝试使用 dynamic_cast() 将导致编译错误。例如,Microsoft Visual C++ 会出现以下错误:

error C2683: 'dynamic_cast' : 'MyClass' is not a polymorphic type.

Summary of Casts

情景 转化
删除常量特性 const_cast()
语言支持的显式转换(例如,int 到 double,int 到 bool) static_cast()
用户定义的构造函数或转换支持的显式转换 static_cast()
一个类的对象到另一个(不相关的)类的对象 bit_cast()
一个类的对象指针指向同一继承层次结构中另一个类的对象指针 dynamic_cast() 或 static_cast()
一个类对象的引用转化到同一继承层次结构中另一个类对象的引用 dynamic_cast() 或 static_cast()
类型指针指向不相关的类型指针 reinterpret_cast()
类型引用到不相关的类型引用 reinterpret_cast()
函数指针到函数指针 reinterpret_cast()

你可能感兴趣的:(C++,c++,开发语言)