繁体   English   中英

为什么没有指针/引用就不能工作多态?

[英]Why doesn't polymorphism work without pointers/references?

我确实在 StackOverflow 上发现了一些标题相似的问题,但是当我阅读答案时,他们关注的是问题的不同部分,这些部分非常具体(例如 STL/容器)。

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

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

“当然,只要您在堆上分配内存” - 分配内存的位置与它无关。 这都是关于语义的。 举个例子:

Derived d;
Base* b = &d;

d在堆栈上(自动内存),但多态性仍然适用于b

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

Base c = Derived();

c对象不是Derived ,而是Base ,因为slicing 所以,从技术上讲,多态性仍然有效,只是你不再需要谈论Derived对象。

现在拿

Base* c = new Derived();

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

在 C++ 中,对象始终具有在编译时已知的固定类型和大小,并且(如果可以并且确实获取了其地址)在其生命周期内始终存在于固定地址。 这些是从 C 继承的特性,有助于使这两种语言都适用于低级系统编程。 (不过,所有这些都受制于 as-if, 规则:只要可以证明它对保证的符合标准的程序的任何行为没有可检测的影响,符合标准的编译器就可以自由地对代码做任何事情。按标准。)

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

考虑小端架构:值首先存储低位字节。 因此,对于任何给定的无符号整数,值 0-255 都存储在值的第一个字节中。 访问任何值的低 8 位只需要一个指向其地址的指针。

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

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

但是, uint8uint16operator=将移动不同数量的数据。

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

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

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

这就是“切片”。

即使使用指针和引用,我们也不会免费避免这种情况。 考虑:

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';
}          

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++ 中并没有得到完美的解决,人们必须认识到他们有义务提供/阻止可能产生切片的动作。

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


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

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

现在,并不是所有的东西都在堆栈上,还有其他情有可原的情况。 在虚方法的情况下,指向对象的指针也是指向对象的 vtable(s) 的指针,它指示方法的位置。 这允许编译器查找和调用函数,而不管它正在使用什么对象。

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

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM