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

25 c++ virtual-functions vtable auto c++14

n3797说:

§7.1.6.4/14:

使用占位符类型的返回类型声明的函数不应是虚拟的(10.3).

因此,以下程序格式错误:

struct s
{
    virtual auto foo()
    {
    }
};
Run Code Online (Sandbox Code Playgroud)

我能找到的所有理由都是来自n3638的这个模糊的单行:

虚拟

允许对虚函数进行返回类型推导是可能的,但这会使重写检查和vtable布局复杂化,因此似乎最好禁止这种情况.

任何人都可以提供进一步的理由或给出一个与上述引用一致的好(代码)示例吗?

das*_*ght 21

您包含的基本原理相当清楚:自然,虚函数意味着被子类覆盖,因此您作为基类的设计者应该尽可能地让继承您的类的人提供合适的覆盖.但是,如果你使用auto,找出覆盖的返回类型对于程序员来说是一项繁琐的工作.编译器的问题就少了,但是人类会有很多机会感到困惑.

例如,如果您看到如下所示的return语句

return a * 3 + b;
Run Code Online (Sandbox Code Playgroud)

你必须追溯程序回到声明的点,ab找出类型促销,并决定返回类型应该是什么.

似乎语言设计者发现这会让人感到困惑,并决定不允许这个功能.

  • guh,整个鸭子在C++垃圾中打字.赫伯正在失去它 (2认同)
  • @congusbongus除了'virtual`函数的类型是**合同的一部分** - 你不能使用C++中的`virtual`函数派生类型.你必须几乎完全匹配签名.现在,如果我们扩展协变返回类型(如`std :: function`),它会更接近(因为任何可以转换为基类返回类型的类型都是子类中的合法返回类型),但是然后仍然存在关于合同模糊的问题. (2认同)

AnT*_*AnT 15

好吧,函数的推导返回类型只在函数定义时才知道:返回类型是从return函数体内的语句推导出来的.

同时,构建了vtable,并且完全基于类定义中存在的函数声明来检查覆盖语义.这些检查从不依赖于函数定义,也从不需要查看定义.例如,该语言要求覆盖函数具有与其覆盖的函数相同的返回类型或协变返回类型.当非定义函数声明指定推导的返回类型(即auto没有尾随返回类型)时,其返回类型在该点是未知的,并且在编译器遇到函数定义之前一直未知.当返回类型未知时,不可能执行上述返回类型检查.要求编译器以某种方式将返回类型检查推迟到已知的位置,这需要对语言规范的这个基本区域进行重大的定性重新设计.(我不确定它是否可能.)

另一种选择是在"无需诊断"或"行为未定义"的全面授权下减轻编制者的负担,即将责任交给用户,但这也将构成与前者的主要偏差.语言的设计.

基本上,由于某种类似的原因,您无法将&运算符应用于声明auto f();但尚未定义的函数,如7.1.6.3/11中的示例所示.


cur*_*guy 5

auto是类型方程式中的未知类型;像往常一样,应该在某个时候定义类型。虚函数需要有一个定义,即使从未在程序中调用该函数,也总是要“使用”它。

vtable问题的简短描述

协变量返回类型是vtable的一个实现问题:协变量返回是内部强大的功能(然后由任意语言规则进行cast割)。协方差仅限于派生给基转换的指针(和引用),但内部功能及其实现的难度几乎是任意转换之一:派生至基等于任意代码(派生于基限于专有基类子对象,又名非虚拟继承,则要简单得多。

在转换为共享基础子对象(也称为虚拟继承)的情况下,协方差意味着转换不仅可以更改指针的值表示形式,而且在通常情况下还可以通过信息丢失的方式更改其值。

因此,虚拟协方差(涉及虚拟继承转换的协变量返回类型)意味着在基本情况下,重写器不能与重写函数混淆。

详细说明

vtable的基础理论和主要基础

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

struct Der 
     : Primbase { // primary base 
    void foo(); // replace Primbase::foo()
    virtual void bar(); // new slot
};
Run Code Online (Sandbox Code Playgroud)

Primbase是此处的主要基础,它从派生对象的相同地址开始。这是非常重要的:对于基本库,可以使用生成的代码中的重新解释或C样式转换来完成上/下转换。单一继承对于实现者来说要容易得多,因为只有主要的基类。对于多重继承,需要进行指针运算。

只有一个vptr Der,其中一个Primbase; 有一个vtable用于Der,其布局与的vtable兼容Primbase

在这里,通常的编译器不会Der::foo()在vtable中分配另一个插槽,因为派生函数实际上是使用Primbase* this指针而不是a 调用的(假设是生成的C代码)Der*。所述Der虚表仅具有两个时隙(加上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
};
Run Code Online (Sandbox Code Playgroud)

在这里,协方差是微不足道的,因为它涉及一个主要基础。在编译的代码级别看不到任何东西。

非零偏移协方差

更复杂:

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?
};
Run Code Online (Sandbox Code Playgroud)

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

  • (定居)Basebelow *(Primbase::foo)()在整个层次结构的虚拟调用接口上:thisPrimbase*(与兼容Der*),但返回值类型不兼容(不同表示),因此派生函数实现会将转换Der*Primbase*(指针算术),并且调用者具有在上进行虚拟通话时转换回Der;

  • (引入)Der vtable中的另一个虚拟函数插槽,该函数返回a Der*

概括为共享层次结构:虚拟协方差

通常,基类的子对象由不同的派生类共享,这是虚拟的“钻石”:

struct B {};
struct L : virtual B {};
struct R : virtual B {};
struct D : L, R {};
Run Code Online (Sandbox Code Playgroud)

在此B*,根据运行时类型(通常使用vptr或对象中的内部指针/偏移量,如MSVC中那样)动态转换为。

通常,对基类子对象的此类转换会丢失信息,并且无法撤消。没有可靠B*L*降频转换。因此,(定居)选择不可用。实现将必须(引入)

示例:Vtable用于Itanium ABI中具有协变量返回类型的替代

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

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

(强调我的)

因此,当函数覆盖基类中的声明时,将比较返回类型:如果它们相似,即一个始终是另一个的主基类,换句话说,总是在偏移量0处,则没有vtable条目添加。

回到auto问题

(引入)不是一个复杂的实现选择,但是它会使vtable增长:vtable的布局取决于完成的(引入)次数。

因此,vtable的布局取决于虚函数的数量(我们从类定义中知道),协变虚函数的存在(我们只能从函数返回类型中得知)以及协方差的类型:主要协方差,非-零偏移协方差或虚拟协方差。

结论

仅在知道基类虚函数的虚拟重写器的返回类型并将指针(或引用)返回到类类型的情况下,才能确定vtable的布局。当类中存在此类重写器时,必须延迟vtable计算。

这会使实施复杂化。

注意:除了在Itanium C ++ ABI中正式定义的“主要基数”外,所有使用的术语如“虚拟协方差”均已构成。

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

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

auto 类指针的重写器(/ ref)指针返回函数

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

struct D : B {
    auto f(); // int f() 
    auto g(); // ?
};
Run Code Online (Sandbox Code Playgroud)

的类型f()完全受限,并且函数定义必须返回int

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

覆盖自动虚拟功能

考虑一个潜在的派生类D2

struct D2 : D {
    T1 f(); // T1 must be int 
    T2 g(); // ?
};
Run Code Online (Sandbox Code Playgroud)

在这里,f()可以像T1必须检查一样int约束,但不能检查约束T2,因为D::g()不知道的声明。我们所知道的是,它T2必须是指向B(可能只是B)的子类的指针。

的定义D::g()可以是协变的,并引入了更强的约束:

auto D::g() { 
    return new D;
} // covariant D* return
Run Code Online (Sandbox Code Playgroud)

因此T2必须是指向派生自的类的指针D(可能只是D)。

在看到定义之前,我们无法知道此约束。

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

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