虚函数 C#

3

我理解什么是虚函数,但我不明白它们在内部是如何运作的?

class Animal
{
    virtual string Eat()
    {
        return @"Eat undefined";
    }
}

class Human : Animal
{
    override string Eat()
    {
         return @"Eat like a Human";
    }
}


class Dog : Animal
{
    new string Eat()
    {
         return @"Eat like a Dog";
    }
}

static void Main()
{
    Animal _animal = new Human();
    Console.WriteLine(_animal.Eat());
    _animal = new Dog();
    Console.WriteLine(_animal.Eat());
}

上述输出结果如下:
Eat like a Human
Eat undefined

在上面的代码中,_animal是Animal类型的,引用了Human对象或Dog对象。这意味着什么?我知道在内存中,_animal包含一个地址,将指向Human或Dog对象。它如何决定调用哪个函数?在第一种情况下,我覆盖了父类的实现,因此调用了子类的实现,但在第二种情况下,我使用了new,因此调用了父类的实现。请问您能解释一下底层发生了什么吗?
谢谢您提前的帮助, Nick

1
你知道Eric Lippert正在撰写关于这个主题的博客系列吗?请参考http://blogs.msdn.com/b/ericlippert/archive/2011/03/17/implementing-the-virtual-method-pattern-in-c-part-one.aspx。 - Learner
感谢学习者。我正在跟随 :) - Nishant
3个回答

17

它的工作原理是这样的。想象一下编译器将你的类重写成了这个样子:

class VTable
{
    public VTable(Func<Animal, string> eat)
    {
        this.AnimalEat = eat;
    }
    public readonly Func<Animal, string> AnimalEat;
}

class Animal
{
    private static AnimalVTable = new VTable(Animal.AnimalEat);
    private static string AnimalEat(Animal _this)
    { 
        return "undefined"; 
    }
    public VTable VTable;
    public static Animal CreateAnimal() 
    { 
        return new Animal() 
            { VTable = AnimalVTable }; 
    }
}

class Human : Animal
{
    private static HumanVTable = new VTable(Human.HumanEat); 
    private static string HumanEat(Animal _this)
    {
        return "human"; 
    }
    public static Human CreateHuman()
    {
        return new Human() 
            { VTable = HumanVTable };
    }
}

class Dog : Animal
{
    public static string DogEat(Dog _this) { return "dog"; }
    public static Dog CreateDog()
    {
        return new Dog() 
            { VTable = AnimalVTable } ;
    }
}

现在考虑以下这些调用:

Animal animal;
Dog dog;
animal = new Human();
animal.Eat();
animal = new Animal();
animal.Eat();
dog = new Dog();
dog.Eat();
animal = dog;
animal.Eat();
编译器的推理如下:如果接收器的类型是Animal,则对Eat的调用必须是animal.VTable.AnimalEat。如果接收器的类型是Dog,则调用必须是DogEat。因此,编译器将它们写成以下形式:
Animal animal;
Dog dog;
animal = Human.CreateHuman(); // sets the VTable field to HumanVTable
animal.VTable.AnimalEat(animal); // calls HumanVTable.AnimalEat
animal = Animal.CreateAnimal(); // sets the VTable field to AnimalVTable
animal.VTable.AnimalEat(animal); // calls AnimalVTable.AnimalEat
dog = Dog.CreateDog(); // sets the VTable field to AnimalVTable
Dog.DogEat(dog); // calls DogEat, obviously
animal = dog;
animal.VTable.AnimalEat(animal); // calls AnimalVTable.AnimalEat

这正是它的工作原理。编译器在幕后为您生成虚函数表,基于重载解析规则,在编译时决定是否通过虚函数表进行调用。

内存分配器在创建对象时设置虚函数表。(我的示意图在这方面是错误的,因为虚函数表是在构造函数调用之前设置的,而不是之后。)

虚方法的"this"实际上被秘密地传递作为方法的一个隐式形式参数。

有意义吗?


7
@Eric:嘿,Eric,我注意到你把自己的信息从资深开发人员更新为首席开发人员。我三天没上过SO,所以我猜你是在周末更新的 :O 只是想恭喜你的新职位/晋升,如果我可以这么说的话,你真的值得拥有这个职位。此外,这是否意味着所有与C#有关的事情都要通过你处理?我希望如此,因为在我看来,你是最优秀的开发人员之一。 - Joan Venge
@Joan:谢谢你的赞美之词。当然,我在C#方面远未达到最终水平。我是团队中比较初级的成员之一;我只有15年的编程语言设计和实现经验。我与安德斯•海尔斯伯格、尼尔•盖夫特和彼得•戈尔德等人共事,这些人比我资深得多。 - Eric Lippert
3
@Eric:如果你仍然认为自己是初级的话,那真是挺有趣的,我不知道在你们下面的人会是什么 :O 就我工作的地方来说,即使不是一家软件公司,负责人也很高层。但我猜在像微软这样的软件巨头公司里,中间的等级会更多。无论如何,能够和你们交流将是一次非常宝贵的经历。期待着看到你晋升并获得幸福。 - Joan Venge
对于那些更熟悉C++的人,值得指出的是Animal.VTable将是一个VReference。C++编译器通常使用VPointer,但VReference也可以正常工作。在任何情况下,它都引用或指向与对象的实际类型相对应的静态VTable,而不是您通过调用函数的变量或表达式的类型。 - Kevin Cathcart
@Eric:没错,这绝对有意义。现在我更清楚了,非常感谢你。 - Nishant
这只是通知大家Eric Lippert正在撰写有关此主题的博客系列。请在http://blogs.msdn.com/b/ericlippert/archive/2011/03/17/implementing-the-virtual-method-pattern-in-c-part-one.aspx上享受它。 - Learner

0

我理解在内存中,_animal 包含一个地址,该地址将指向 Human 或 Dog 对象。它如何决定调用哪个函数。

与数据一样,代码也有一个地址。

因此,解决这个问题的典型方法是让 HumanDog 对象包含其方法代码的地址。这有时被称为使用 vtable。在像 C 或 C++ 这样的语言中,这个概念也直接暴露为所谓的 function pointer

现在,你提到了 C#,它具有相当高级的类型系统,在其中对象类型也可以在运行时区分.... 因此,实现细节可能会有所不同于传统方法。但是,对于你的问题,函数指针/v-table 的概念是一种方法,如果 .NET 偏离这种方法太多,那么我会感到惊讶。


谢谢。你能帮我解决内存分配的问题吗?你说代码也有一个地址。在这种情况下,内存是如何分配的?在Dog类中,它仅仅隐藏了基类方法,而在Human类中则进行了重写。 - Nishant
@nick - 当你的EXE或DLL被加载时,操作系统会管理其代码的内存。(在C#的情况下,还涉及JIT,因此.NET运行时也会管理所涉及的内存。) - asveikau
回应你最后一句话中的观点:接口的工作原理可能会让你有些惊讶。Jitter(即时编译器)通常为类层次结构上的虚方法调用生成“经典”的虚函数表。然而,对于接口方法的虚拟调用所生成的代码则要稍微复杂一些。它不同于C++编译器产生的典型间接虚函数表。 - Eric Lippert

0
在C#中,派生类必须为从基类继承的任何重写方法提供override修饰符。
Animal _animal = new Human();

不仅仅是 Human 对象被构造了。它们是两个子对象。一个是 Animal 子对象,另一个是 Human 子对象。

Console.WriteLine(_animal.Eat());

当调用 _animal.Eat(); 时,运行时会检查基类方法(即 Eat())是否在派生类中被重写。由于它被重写了,相应的派生类方法被调用。因此输出为 -
Eat like a Human

但是,在这种情况下 -

_animal = new Dog();
Console.WriteLine(_animal.Eat());

Dog 类中,没有重写 Eat() 方法。因此,调用的是基类方法本身。同时进行这种检查的原因是因为在基类中,Eat() 被声明为虚函数,调用机制在运行时决定。总之,虚函数调用机制是一种运行时机制。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接