为什么虚函数不能使用返回类型推断?

33

n3797 文件的 § 7.1.6.4/14 节:

使用占位类型作为返回类型声明的函数不得是虚函数 (10.3)。

因此,以下程序是非法的:

struct s
{
    virtual auto foo()
    {
    }
};

关于这个原理,我所能找到的只有来自n3638的这个含糊的一句话:

虚拟

允许虚拟函数返回类型推导是可行的,但这将使覆盖检查和虚函数表布局变得复杂,因此禁止这样做似乎更好。

是否有人可以提供进一步的原理或给出与上述引用相符的良好(代码)示例?

3个回答

26
你所提供的理由相当清晰:虚函数自然是要被子类重写的,因此作为基类的设计者应该尽可能地方便那些继承你的类的人提供适合的重写。然而,如果您使用auto关键字,对于程序员来说,确定覆盖返回类型将变得很繁琐。编译器可能不会有太多问题,但人类却有许多机会变得困惑。
例如,如果你看到一个像这样的返回语句:
return a * 3 + b;

你需要追踪程序回到 ab 的声明点,弄清楚类型提升并决定返回类型。

看起来语言设计者们意识到这会相当令人困惑,因此决定不允许使用这个特性。


虽然这样的功能确实会让一些人感到非常困惑,但它违背了Herb对auto用法的看法(即不关心类型,只关心是否符合“鸭子测试”)。当然,在C++用户中,这是一个极具争议的问题。尽管如此,我倾向于相信AndreyT所说的真正原因。 - congusbongus
2
忘掉那些关于C++中鸭子类型的废话吧。Herb正在失去理智。 - Lightness Races in Orbit
3
除了虚函数的类型是其合约的一部分之外,您不能在C++中进行鸭式派生。您必须几乎完全匹配签名。如果我们像std::function那样扩展协变返回类型,则会更接近(因为任何可以转换为基类返回类型的类型都将是子类中的合法返回类型),但即使如此,仍然存在关于合约模糊的问题。 - Yakk - Adam Nevraumont

17

关于函数的推导返回类型,它只有在函数定义的时候才能被确定:其返回类型是通过函数体中的return语句来推导的。

与此同时,虚表(vtable)是基于类定义中的函数声明建立的,覆盖语义也是基于这些声明进行检查的。这些检查永远不依赖于函数的定义,并且从未需要查看它们的定义。例如,语言要求重写的函数具有与其覆盖的函数相同的返回类型或协变返回类型。当非定义函数声明指定了推导返回类型(即使用没有后置返回类型的auto),其返回类型在该点是未知的,并且在编译器遇到函数定义之前始终保持未知。当返回类型是未知的时候,就无法执行先前提到的返回类型检查。要求编译器将返回类型检查推迟到它变得已知的时候,需要对语言规范的这个基本领域进行重大修改。(我不确定这是否可行。)

另一个选择就是在“不需要诊断”或“行为未定义”的总体要求下,解除编译器的负担,即将责任交给用户,但这也构成了语言先前设计的重大偏移。

基本上,由于类似的原因,你无法将&运算符应用于被声明为auto f();但尚未定义的函数,就像7.1.6.3/11中的例子一样。


1
“当返回类型未知时,无法执行覆盖检查。” 我不太确定您在这里的意思:函数签名确定函数是否覆盖了基类的虚函数;返回类型不是非模板函数签名的一部分。 - dyp
1
虽然这可能是一个不要为编译器编写者制造过于复杂的事情的论点,但我想知道是否有技术原因导致返回类型推断不能用于虚函数。(即,只要您提供定义,以便调用此特定函数的所有翻译单元都可以看到它) - dyp
1
@dyp:几乎没有什么是不可能的,但我认为在这种情况下,将通用动态库支持添加到语言中可能会比较困难。至少从实际角度来看是这样的。 - Cheers and hth. - Alf
1
@curiousguy:在现代C++翻译哲学(基于独立翻译单元的基础)中,这是不可能实现的。这将需要完全拆除该哲学,并实现超越翻译单元边界的全局错误检查机制。C++将成为一种非常非常不同的语言。 - AnT stands with Russia
@AnT 或许我们之间存在误解。我甚至不知道一个“auto”函数在变成“已知”,“定义”,“缩减”之前应该被称为什么?直到函数声明被“定义”,它才能够被使用,就像一个普通的“未定义”的“auto”声明一样。 - curiousguy
显示剩余5条评论

6
auto是类型方程中的未知类型;通常情况下,应该在某个时刻定义该类型。虚函数需要有一个定义,即使该函数在程序中从未被调用,它也总是“被使用”的。

vtable问题的简要描述

协变返回类型是vtable的一种实现问题:协变返回类型是一种内部强大的特性(然后被任意语言规则削弱)。协变性仅限于指针(和引用)派生到基础转换,但内部强大性和难度几乎等同于任意转换:派生到基础相当于任意代码(派生到基础仅限于独占基类子对象,也就是非虚继承,将会简单得多)。

在共享基类子对象(也称虚继承)转换的情况下,协变性意味着转换不仅可以改变指针的值表示,而且还以信息丢失的方式改变了其值,在一般情况下。

因此,虚协变性(涉及虚继承转换的协变返回类型)意味着在主基类情况下,覆盖者不能与被覆盖的函数混淆。

详细解释

vtable和主基类的基本理论

struct Primbase {
    virtual void foo(); // new
};

struct Der 
     : Primbase { // primary base 
    void foo(); // replace Primbase::foo()
    virtual void bar(); // new slot
};

Primbase是主要基类,它从派生对象的相同地址开始。这非常重要:对于主要基类,上/下转换可以在生成的代码中使用reinterpret或C样式转换来完成。单继承对于实现者来说更容易,因为只有主要基类。对于多重继承,需要指针算术。

Der中只有一个vptr,即Primbase的vptr;Der有一个vtable,与Primbase的vtable布局兼容。

在这里,通常的编译器不会为Der::foo()在vtable中分配另一个插槽,因为派生函数实际上是使用Primbase*this指针而不是Der*调用的(在假设的生成的C代码中)。Der vtable仅有两个插槽(加上RTTI数据)。

主协变

现在我们添加一些简单的协变:

struct Primbase {
    virtual Primbase *foo(); // new slot in vtable
};

struct Der 
     : Primbase { // primary base 
    Der *foo(); // replaces Primbase::foo() in vtable
    virtual void bar(); // new slot
};

在这里,协方差是微不足道的,因为它涉及到一个主要基础。在编译代码层面上没有什么可看的。

非零偏移协方差

更为复杂:

struct Basebelow {
    virtual void bar(); // new slot
};

struct Primbase {
    virtual Basebelow *foo(); // new
};

struct Der 
     : Primbase, // primary base 
       Basebelow { // base at a non zero offset
    Der *foo(); // new slot?
};

这里的Der*的表示方式与其基类子对象指针Basebelow*的表示方式不同。有两种实现选择:

  • (settle) 在整个层次结构中使用Basebelow *(Primbase::foo)()虚调用接口:this是一个Primbase*(与Der*兼容),但返回值类型不兼容(表示不同),因此派生函数实现将把Der*转换为Primbase*(指针算术),并且在对Der进行虚拟调用时,调用者将会转换回来;

  • (introduce)Der的vtable中引入另一个虚函数槽,用于返回一个Der*

在共享层次结构中推广:虚协变

在一般情况下,基类子对象由不同的派生类共享,这是虚“菱形”:

struct B {};
struct L : virtual B {};
struct R : virtual B {};
struct D : L, R {};

这里转换为B*是动态的,基于运行时类型(通常使用vptr,或者对象中的内部指针/偏移量,如在MSVC中)。

一般而言,这样的基类子对象转换会丢失信息,并且无法撤销。没有可靠的B*L*向下转换。因此,“解决”选项不可用。实现将不得不“引入”。

示例:Itanium ABI中具有协变返回类型的重写的Vtable

Itanium C++ ABI描述了vtable的布局。以下是关于为派生类引入vtable条目的规则(特别是具有主要基类的派生类):

对于在类中声明的任何虚拟函数,都存在一个条目,无论它是新函数还是覆盖基类函数,除非它覆盖来自主要基类的函数,并且它们返回类型之间不需要调整。

(强调是我的)

当函数覆盖基类中的声明时,将比较返回类型:如果它们相似,也就是说,一个始终是另一个的主要基类,换句话说,始终在偏移量0处,那么不会添加vtable条目。
回到auto问题 (introduce)并不是一个复杂的实现选择,但它会使得vtable增长:vtable的布局由(introduce)的数量决定。
因此,vtable的布局由虚函数的数量(我们从类定义中知道),协变虚函数的存在(我们只能从函数返回类型中知道)和协变类型(原始协变、非零偏移协变或虚协变)来确定。
结论
只有知道返回指向类类型的指针(或引用)的基类虚拟函数的虚拟重写者的返回类型,才能确定vtable的布局。当类中存在这样的重写者时,vtable计算必须延迟。
这将使实现变得复杂。

注意:所使用的“虚拟协方差”等术语都是虚构的,除了“primary base”这个术语在Itanium C++ ABI中有正式定义。

编辑:为什么我认为约束检查不是问题

协变约束的检查不是问题,不会破坏分离编译或C++模型:

auto覆盖一个类指针(/ref)指针返回函数

struct B {
    virtual int f();
    virtual B *g();
};

struct D : B {
    auto f(); // int f() 
    auto g(); // ?
};

f()的类型已完全确定,函数定义必须返回一个int

g()的返回类型部分受限:它可以是B*或某个derived_from_B*。检查将在定义点发生。

自动虚函数的重写

考虑一个潜在的派生类D2

struct D2 : D {
    T1 f(); // T1 must be int 
    T2 g(); // ?
};

这里可以检查对f()的约束,因为T1必须是int,但无法检查对T2的约束,因为未知D::g()的声明。我们只知道T2必须是指向B子类(可能只是B)的指针。
D::g()的定义可以是协变的,并引入更强的约束:
auto D::g() { 
    return new D;
} // covariant D* return

因此,T2 必须是指向从 D 派生的类(可能仅为 D)的指针。

在看到定义之前,我们无法知道这个限制条件。

由于无法在查看定义之前检查覆盖声明,因此必须拒绝它

为简单起见,我认为也应该拒绝 f()


这个问题与vtables无关。在生成vtables时,所有的auto出现都已经被推断好了。虽然你提到了检查声明时增加了复杂度,但这是一个可以解决的问题。不允许在那里使用auto只是一个设计选择。 - idmean
编译器将从返回语句中推断方法的返回类型。该方法的返回类型不是“auto”,而是编译器将确定的实际类型(https://en.cppreference.com/w/cpp/language/function#Return_type_deduction)。这将在语法/语义分析期间发生。虚函数表将在代码生成期间生成,因此实际类型已知。另请参见https://en.cppreference.com/w/cpp/language/translation_phases。 - idmean
正如我在答案中所解释的那样,我在这里看到的问题是vtable的生成。生成vtable需要完整的类型信息。那么vtable是什么时候生成的呢? - curiousguy
@idmean,您提出了支持这些推断返回类型的ABI建议是什么? - curiousguy
你不能像那样通过继承来实际使用reinterpret_cast(除了标准布局类型的平凡情况)。 - Davis Herring
显示剩余2条评论

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