return a * 3 + b;
你需要追踪程序回到 a
和 b
的声明点,弄清楚类型提升并决定返回类型。
看起来语言设计者们意识到这会相当令人困惑,因此决定不允许使用这个特性。
关于函数的推导返回类型,它只有在函数定义的时候才能被确定:其返回类型是通过函数体中的return
语句来推导的。
与此同时,虚表(vtable)是基于类定义中的函数声明建立的,覆盖语义也是基于这些声明进行检查的。这些检查永远不依赖于函数的定义,并且从未需要查看它们的定义。例如,语言要求重写的函数具有与其覆盖的函数相同的返回类型或协变返回类型。当非定义函数声明指定了推导返回类型(即使用没有后置返回类型的auto
),其返回类型在该点是未知的,并且在编译器遇到函数定义之前始终保持未知。当返回类型是未知的时候,就无法执行先前提到的返回类型检查。要求编译器将返回类型检查推迟到它变得已知的时候,需要对语言规范的这个基本领域进行重大修改。(我不确定这是否可行。)
另一个选择就是在“不需要诊断”或“行为未定义”的总体要求下,解除编译器的负担,即将责任交给用户,但这也构成了语言先前设计的重大偏移。
基本上,由于类似的原因,你无法将&
运算符应用于被声明为auto f();
但尚未定义的函数,就像7.1.6.3/11中的例子一样。
auto
是类型方程中的未知类型;通常情况下,应该在某个时刻定义该类型。虚函数需要有一个定义,即使该函数在程序中从未被调用,它也总是“被使用”的。
协变返回类型是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 C++ ABI描述了vtable的布局。以下是关于为派生类引入vtable条目的规则(特别是具有主要基类的派生类):
对于在类中声明的任何虚拟函数,都存在一个条目,无论它是新函数还是覆盖基类函数,除非它覆盖来自主要基类的函数,并且它们返回类型之间不需要调整。
(强调是我的)
当函数覆盖基类中的声明时,将比较返回类型:如果它们相似,也就是说,一个始终是另一个的主要基类,换句话说,始终在偏移量0处,那么不会添加vtable条目。auto
问题
(introduce)并不是一个复杂的实现选择,但它会使得vtable增长:vtable的布局由(introduce)的数量决定。注意:所使用的“虚拟协方差”等术语都是虚构的,除了“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(); // ?
};
auto D::g() {
return new D;
} // covariant D* return
因此,T2
必须是指向从 D
派生的类(可能仅为 D
)的指针。
在看到定义之前,我们无法知道这个限制条件。
由于无法在查看定义之前检查覆盖声明,因此必须拒绝它。
为简单起见,我认为也应该拒绝 f()
。
auto
出现都已经被推断好了。虽然你提到了检查声明时增加了复杂度,但这是一个可以解决的问题。不允许在那里使用auto只是一个设计选择。 - idmeanreinterpret_cast
(除了标准布局类型的平凡情况)。 - Davis Herring
std::function
那样扩展协变返回类型,则会更接近(因为任何可以转换为基类返回类型的类型都将是子类中的合法返回类型),但即使如此,仍然存在关于合约模糊的问题。 - Yakk - Adam Nevraumont