我确实在 SO 上发现了一些标题相似的问题——但是当我阅读答案时,他们关注的是问题的不同部分,这些部分非常具体(例如 STL/容器)。
有人可以告诉我为什么必须使用指针/引用来实现多态性吗?我可以理解指针可能会有所帮助-但肯定引用仅区分按值传递和按引用传递?
当然,只要您在堆上分配内存-以便您可以进行动态绑定,那么这就足够了-显然不是。
我确实在 SO 上发现了一些标题相似的问题——但是当我阅读答案时,他们关注的是问题的不同部分,这些部分非常具体(例如 STL/容器)。
有人可以告诉我为什么必须使用指针/引用来实现多态性吗?我可以理解指针可能会有所帮助-但肯定引用仅区分按值传递和按引用传递?
当然,只要您在堆上分配内存-以便您可以进行动态绑定,那么这就足够了-显然不是。
“当然,只要您在堆上分配内存” - 分配内存的位置与它无关。这都是关于语义的。举个例子:
Derived d;
Base* b = &d;
d
在堆栈上(自动内存),但多态性仍然适用于b
.
如果您没有基类指针或对派生类的引用,则多态性不起作用,因为您不再拥有派生类。拿
Base c = Derived();
由于切片,c
对象不是 a Derived
,而是 a 。所以,从技术上讲,多态性仍然有效,只是你不再有对象可谈。Base
Derived
现在拿
Base* c = new Derived();
c
只是指向内存中的某个地方,你并不关心它实际上是 aBase
还是 a Derived
,但是对方法的调用virtual
将被动态解析。
在 C++ 中,对象始终具有在编译时已知的固定类型和大小,并且(如果可以并且确实获取了其地址)在其生命周期内始终存在于固定地址。这些是从 C 继承的特性,有助于使这两种语言都适用于低级系统编程。(不过,所有这些都受制于 as-if, 规则:只要可以证明它对保证的符合标准的程序的任何行为没有可检测到的影响,符合标准的编译器就可以自由地对代码做任何事情。按标准。)
C++ 中的virtual
函数被定义为(或多或少地,不需要极端的语言律师)基于对象的运行时类型执行;virtual
当直接在对象上调用时,这将始终是对象的编译时类型,因此以这种方式调用函数时没有多态性。
请注意,这不一定是这种情况:具有virtual
函数的对象类型通常在 C++ 中实现,每个对象都有一个指向函数表的指针,该virtual
函数表对每种类型都是唯一的。如果有这种倾向,C++ 的一些假设变体的编译器可以实现对对象Base b; b = Derived()
的赋值(例如virtual
Base
Derived
大小相同。在两者大小不同的情况下,编译器甚至可以插入将程序暂停任意时间的代码,以便重新排列程序中的内存并以可能的方式更新对该内存的所有可能引用证明对程序的语义没有可检测到的影响,如果找不到这样的重新排列,则终止程序:但是,这将非常低效,并且不能保证永远停止,显然不是赋值运算符想要的特性有。
因此,代替上述内容,C++ 中的多态性是通过允许对对象的引用和指针来引用和指向其声明的编译时类型及其任何子类型的对象来实现的。当virtual
通过引用或指针调用函数时,编译器无法证明所引用或指向的对象是具有该virtual
函数的特定已知实现的运行时类型,编译器插入查找正确的代码virtual
调用运行时的函数。它也不必是这样:引用和指针可以被定义为非多态的(不允许它们引用或指向其声明类型的子类型)并迫使程序员想出实现多态的替代方法. 后者显然是可能的,因为它一直都是用 C 语言完成的,但在那时完全没有理由拥有一门新语言。
总之,C++ 的语义被设计为允许面向对象多态性的高级抽象和封装,同时仍然保留使其适用于低水平发展。您可以轻松设计一种具有其他语义的语言,但它不会是 C++,并且会有不同的优点和缺点。
我发现理解这样分配时调用了复制构造函数真的很有帮助:
class Base { };
class Derived : public Base { };
Derived x; /* Derived type object created */
Base y = x; /* Copy is made (using Base's copy constructor), so y really is of type Base. Copy can cause "slicing" btw. */
由于 y 是 Base 类的实际对象,而不是原来的对象,因此在 this 上调用的函数是 Base 的函数。
考虑小端架构:值首先存储低位字节。因此,对于任何给定的无符号整数,值 0-255 都存储在值的第一个字节中。访问任何值的低 8 位只需要一个指向其地址的指针。
所以我们可以uint8
作为一个类来实现。我们知道一个实例uint8
是……一个字节。如果我们从它派生并产生uint16
,uint32
等,则出于抽象目的,接口保持不变,但最重要的变化是对象的具体实例的大小。
当然,如果我们实现uint8
和char
,大小可能相同,同样sint8
。
但是,operator=
ofuint8
并且uint16
将要移动不同数量的数据。
为了创建多态函数,我们必须能够:
a/ 通过将数据复制到正确大小和布局的新位置来按值接收参数, b/ 获取指向对象位置的指针, c/ 获取对对象实例的引用,
我们可以使用模板来实现a,因此多态可以在没有指针和引用的情况下工作,但是如果我们不计算模板,那么让我们考虑如果我们实现uint128
并将它传递给期望的函数会发生uint8
什么?答案:复制 8 位而不是 128 位。
那么,如果我们让我们的多态函数接受uint128
并传递给它一个uint8
. 如果uint8
不幸地找到了我们正在复制的内容,我们的函数将尝试复制 128 个字节,其中 127 个字节在我们可访问的内存之外 -> 崩溃。
考虑以下:
class A { int x; };
A fn(A a)
{
return a;
}
class B : public A {
uint64_t a, b, c;
B(int x_, uint64_t a_, uint64_t b_, uint64_t c_)
: A(x_), a(a_), b(b_), c(c_) {}
};
B b1 { 10, 1, 2, 3 };
B b2 = fn(b1);
// b2.x == 10, but a, b and c?
在fn
编译的时候,对B
. 然而,B
是从A
多态性派生而来的,应该允许我们可以fn
用 a调用B
。但是,它返回的对象应该是一个A
包含单个 int 的对象。
如果我们将一个实例传递B
给这个函数,我们得到的应该只是{ int x; }
没有 a、b、c 的 a。
这就是“切片”。
即使使用指针和引用,我们也不会免费避免这种情况。考虑:
std::vector<A*> vec;
这个向量的元素可以是指向A
或派生自 的指针A
。该语言通常通过使用“vtable”来解决这个问题,这是对对象实例的一个小补充,它标识类型并为虚函数提供函数指针。你可以把它想象成这样:
template<class T>
struct PolymorphicObject {
T::vtable* __vtptr;
T __instance;
};
不是每个对象都有自己独特的 vtable,而是类拥有它们,并且对象实例仅指向相关的 vtable。
现在的问题不是切片而是类型正确性:
struct A { virtual const char* fn() { return "A"; } };
struct B : public A { virtual const char* fn() { return "B"; } };
#include <iostream>
#include <cstring>
int main()
{
A* a = new A();
B* b = new B();
memcpy(a, b, sizeof(A));
std::cout << "sizeof A = " << sizeof(A)
<< " a->fn(): " << a->fn() << '\n';
}
sizeof A = 4 a->fn(): B
我们应该做的是使用a->operator=(b)
但同样,这是将 A 复制到 A ,因此会发生切片:
struct A { int i; A(int i_) : i(i_) {} virtual const char* fn() { return "A"; } };
struct B : public A {
int j;
B(int i_) : A(i_), j(i_ + 10) {}
virtual const char* fn() { return "B"; }
};
#include <iostream>
#include <cstring>
int main()
{
A* a = new A(1);
B* b = new B(2);
*a = *b; // aka a->operator=(static_cast<A*>(*b));
std::cout << "sizeof A = " << sizeof(A)
<< ", a->i = " << a->i << ", a->fn(): " << a->fn() << '\n';
}
(i
被复制,但 Bj
丢失)
这里的结论是指针/引用是必需的,因为原始实例携带了复制可能与之交互的成员信息。
而且,这种多态性在 C++ 中并没有得到完美的解决,人们必须认识到他们有义务提供/阻止可能产生切片的动作。
您需要指针或引用,因为对于您感兴趣的多态性 (*),您需要动态类型与静态类型不同,换句话说,对象的真实类型与声明的类型不同。在 C++ 中,这只发生在指针或引用中。
(*) 泛型,模板提供的多态类型,不需要指针或引用。
当一个对象按值传递时,它通常被放入堆栈。将某些东西放入堆栈需要知道它有多大。使用多态性时,您知道传入的对象实现了一组特定的功能,但您通常不知道对象的大小(您也不应该,必然,这是好处的一部分)。因此,您不能将其放入堆栈中。但是,您总是知道指针的大小。
现在,并不是所有的东西都在堆栈上,还有其他情有可原的情况。在虚方法的情况下,指向对象的指针也是指向对象的 vtable(s) 的指针,它指示方法的位置。这允许编译器查找和调用函数,而不管它正在使用什么对象。
另一个原因是对象通常是在调用库之外实现的,并且分配有完全不同(并且可能不兼容)的内存管理器。它还可能包含无法复制的成员,或者如果他们被不同的经理复制会导致问题。复制和各种其他并发症可能会产生副作用。
结果是指针是您真正正确理解的对象的唯一信息,并提供了足够的信息来确定您需要的其他位在哪里。