为什么没有指针/引用的多态性不起作用?

我确实在 StackOverflow 上找到了一些类似标题的问题,但是当我阅读这些答案时,他们关注的是问题的不同部分,这些部分非常具体(比如 STL/Container)。

有人能告诉我,为什么必须使用指针/引用来实现多态性吗?我可以理解指针可能有所帮助,但是引用肯定只区分值传递和引用传递吧?

当然,只要在堆上分配内存,以便能够进行动态绑定,那么这就足够了。显然不是。

29231 次浏览

通过值传递对象时,通常将其放在堆栈上。把东西放到堆栈上需要知道它有多大。在使用多态性时,您知道传入对象实现了一组特定的特性,但是您通常不知道对象的大小(也不应该知道,这必然是好处的一部分)。因此,您不能将其放在堆栈上。但是,您总是知道指针的大小。

现在,不是所有的事情都要进行,还有其他情有可原的情况。在虚方法的情况下,指向对象的指针也是指向对象的 vtable 的指针,它指示方法的位置。这允许编译器查找和调用函数,而不管它使用的是什么对象。

另一个原因是,对象经常在调用库之外实现,并使用完全不同(可能不兼容)的内存管理器进行分配。它还可能包含无法复制的成员,或者如果用其他管理器复制这些成员,则会导致问题。复制可能会有副作用以及其他各种并发症。

其结果是,指针是对象上您真正正确理解的唯一位信息,并提供足够的信息来确定您需要的其他位在哪里。

“当然,只要你在堆上分配内存”——分配内存的地方与此无关。重要的是语义。例如:

Derived d;
Base* b = &d;

d在栈上(自动内存) ,但是多态性仍然可以在 b上工作。

如果没有基类指针或对派生类的引用,则多态性不起作用,因为不再有派生类。拿着

Base c = Derived();

由于 切片c对象不是 Derived,而是 Base。因此,从技术上讲,多态性仍然有效,只是您不再需要讨论 Derived对象了。

现在拿着

Base* c = new Derived();

c只是指向内存中的某个位置,您并不真正关心它实际上是 Base还是 Derived,但是对 virtual方法的调用将被动态解析。

您需要指针或引用,因为对于您感兴趣的(*)多态性类型,您需要动态类型可以不同于静态类型,换句话说,对象的真实类型不同于声明的类型。在 C + + 中,这只发生在指针或引用上。


(*)泛型(Genericity) ,模板提供的多态性类型,不需要指针或引用。

在 C + + 中,一个对象在编译时总是有一个固定的类型和大小,并且(如果它可以并且确实有地址的话)在其生存期内总是存在于一个固定的地址中。这些特性继承自 C 语言,有助于使两种语言都适合低级系统编程。(不过,所有这些都遵循“似乎”规则: 只要能够证明符合标准的编译器对符合标准的程序的任何行为没有可检测的影响,符合标准的编译器就可以自由地对代码做任何它喜欢的事情。)

C + + 中的 virtual函数被定义为基于对象的运行时类型来执行的(或多或少,不需要极端的语言律师) ; 当直接调用一个对象时,这将始终是对象的编译时类型,所以当 virtual函数以这种方式被调用时没有多态性。

请注意,这并不一定是这种情况: 具有 virtual函数的对象类型通常在 C + + 中实现,每个对象都有一个指向 virtual函数表的指针,这对于每种类型都是唯一的。如果需要的话,一个 C + + 的编译器可以对对象(比如 Base b; b = Derived())实现赋值,复制对象的内容和 virtual表指针,如果 BaseDerived都是相同的大小,那么就很容易工作了。如果两个程序的大小不一样,编译器甚至可以插入代码,让程序暂停一段任意时间,以便重新排列程序中的内存,并更新所有可能的内存引用,这种方式可以被证明对程序的语义没有可检测的影响,如果没有发现这种重新排列,则终止程序: 这将是非常低效的,但是,不能保证永远停止,显然不希望赋值操作符具有这样的特性。

因此,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 的函数。

考虑一下 little endian 体系结构: 值首先以低顺序字节存储。因此,对于任何给定的无符号整数,值0-255存储在值的第一个字节中。访问任何值的低8位只需要一个指向其地址的指针。

因此,我们可以将 uint8实现为一个类。我们知道 uint8的一个实例是... 一个字节。如果我们从它派生并生成 uint16uint32等,那么出于抽象的目的,接口保持不变,但最重要的变化是对象的具体实例的大小。

当然,如果我们实现 uint8char,大小可能是相同的,同样的 sint8

然而,uint8uint16operator=将移动不同数量的数据。

为了创建一个多态函数,我们必须能够:

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,所以多态性应该允许我们使用 B调用 fn。但是,它返回的 对象应该是一个包含单个 int 的 A

如果我们将 B的一个实例传递给这个函数,我们得到的应该只是一个没有 a,b,c 的 { int x; }

这是“切片”。

即使有了指针和引用,我们也不能免费地避免这一点:

std::vector<A*> vec;

这个向量的元素可以是指向 A的指针,也可以是从 A派生出来的指针。该语言通常通过使用“ vtable”来解决这个问题,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';
}

Http://ideone.com/g62cn0

sizeof A = 4 a->fn(): B

我们应该使用 a->operator=(b)

Http://ideone.com/vym3lp

但同样,这是将 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';
}

Http://ideone.com/dhgwun

(i被复制,但 B 的 j丢失)

这里的结论是,需要指针/引用,因为原始实例携带 会员资格信息,复制可能与之交互。

但是,这种多态性在 C + + 中并没有得到完美的解决,人们必须认识到他们有义务提供/阻止可能产生切片的操作。