huihut/interview

`dynamic_cast` 的描述不够严谨

Mq-b opened this issue · 10 comments

Mq-b commented

写的是:

dynamic_cast
用于多态类型的转换
执行行运行时类型检查
只适用于指针或引用
对不明确的指针的转换将失败(返回 nullptr),但不引发异常
可以在整个类层次结构中移动指针,包括向上转换、向下转换

dynamic_cast< 新类型 >( 表达式 )
如果 表达式 是到多态类型 Base 的指针或引用,且 新类型 是到 Derived 类型的指针或引用,那么会进行运行时检查

除此之外其他时候基本上是没有这种额外开销的。

并且它也可以用作其他的转换。

struct X { };
struct Y :X {};

int main() {
    Y* y = new Y;
    auto p = dynamic_cast<X*>(y); // 无虚函数 子类转父类,毫无问题。
    auto p2 = static_cast<X*>(y); //
}

无虚函数,自然没有所谓的运行时检查。

当然了,没开销的时候说明不该使用 dynamic_cast

感觉应该改成

dynamic_cast
dynamic_cast 常用于多态类型的转换,如果是多态类型的话:
执行行运行时类型检查
只适用于指针或引用
对不明确的指针的转换将失败(返回 nullptr),但不引发异常
如果转型失败且 新类型 是引用类型,那么它会抛出与类型 std::bad_cast 的处理块匹配的异常
可以在整个类层次结构中移动指针,包括向上转换、向下转换

GeeLaw commented

我觉得采用“运行时检查”的概念就很不好,混淆了语义和实现,应该抛弃之。

dynamic_cast<T *>(v) 的良构问题,忽略 cv 限定和转换为 void * 的话:

  • 如果 T 不是完整类类型或者 v 的类型不是指向完整类类型的指针,则非良构;
  • 如果转换类不变,则良构;
  • 如果从派生类到基类:
    • 如果基类无歧义且可访问,则良构;
    • 否则,非良构;
  • 其他情况:
    • 如果 v 的类型是指向多态类型的指针,则良构;
    • 否则,非良构。

良构情况的语义问题(C++ 现行标准的文本写得很复杂,因为引入了“运行时检查”的概念,下面这个版本等效):

  1. 如果 vnullptr 则结果是 nullptr,否则继续;
  2. 找出 v 的最派生对象 u
  3. 找出 u 的所有 T 基类子对象 t_1, ..., t_n
  4. 若存在惟一的 1<=i<=n 使 t_iv 有基类子对象关系(t_iv 的基类子对象或 vt_i 的基类子对象或 v 就是 t_i),则结果是指向 t_i 的指针(这里不需要 n=1);
  5. 否则,若 n=1,则结果是指向 t_1 的指针;
  6. 否则,结果是 nullptr

C++ 标准的规定:

  • 除了类不变、派生类到基类,都要求多态类型;
  • 除了类不变、派生类到基类、任意类到 void *、从 nullptr 转换,都算是“运行时检查”。

这里的重点在于:

  • 从多态类型出发的转换不一定有“运行时检查”,比如从多态类型到它的基类;
  • “运行时检查”不一定是从基类到派生类,也可以是表面上没有继承关系的类。

因此

dynamic_cast< 新类型 >( 表达式 )
如果 表达式 是到多态类型 Base 的指针或引用,且 新类型 是到 Derived 类型的指针或引用,那么会进行运行时检查。

这个说法不够全面。而

如果是多态类型的话:
执行行运行时类型检查

这个说法和现行 C++ 标准不一致。

最后,从实现效率考虑,假设编译器对 v 的情况一无所知,并采用通常的实现:

  1. 如果类不变,则没有任何运行时开销;
  2. 如果是从派生类到非 virtual 基类,且基类的 offset 是 0,则没有任何运行时开销;
  3. 如果是从派生类到非 virtual 基类,且基类的 offset 不是 0,则运行时需要判断 nullptr 并条件加减数;
  4. 如果是从派生类到 virtual 基类,则运行时需要判断 nullptr 并进行某些 indirection,这个开销可能比 5 低,也可能和 5 一起处理;
  5. 如果是其他转换,则会有较高的运行时开销。

仓库原文里

对不明确的指针的转换将失败

这句话本身就很不明确:

  • 类不变、派生类到基类,和最派生对象的其他基类子对象没有关系;
  • 基类到派生类,因为语义里步骤 4 的规则,如果 v 指向的对象确实是某个派生类对象的一部分,则以被转换的指针所指向的对象为基类子对象的情况优先,这时最派生对象可以含有其他 T 基类子对象;
  • 其他情况,最派生对象必须有惟一的 T 基类子对象。

应该特别注意,从基类到派生类,有两种模式(取决于 v 具体指向最派生对象的哪个基类子对象)。

仓库原文里

可以在整个类层次结构中移动指针,包括向上转换、向下转换

不够全面——可以向上、向下、旁支转换。


还应该注意,派生类转基类,如果基类是 virtual 无歧义可访问,那么 static_cast 不可以,但 dynamic_cast 可以,此时也不需要派生类是多态类型(具有 virtual 基类并不会导致类型成为多态类型,只有 virtual 函数才会导致)。

Mq-b commented

除了类不变、派生类到基类、任意类到 void *、从 nullptr 转换,都算是“运行时检查”。

学到了🤣。

不过能详细聊一下嘛,以及

“运行时检查”不一定是从基类到派生类,也可以是表面上没有继承关系的类

能举个例子嘛?

@GeeLaw

其实另外一部分就提到了

不够全面——可以向上、向下、旁支转换。

如下:

struct B1 { virtual f1();};
struct B2 { virtual f2();};
struct D: B1, B2 {};

void fun(B1 *p) {
    auto p = dynamic_cast<B2*>(p);
}

此处从B1*B2*的sidecast就是

“运行时检查”不一定是从基类到派生类,也可以是表面上没有继承关系的类

这个转换要藉由struct D这样的旁支选择无歧义的时候才能进行

GeeLaw commented

@Mq-b 后面问题的例子:

struct B1 { virtual ~B1(); };
struct B2 { };
struct D : B1, B2 { };

D d;
B1 *b1 = &d;
// B1 和 B2 表面上没有继承关系
// b1 是 B1 * 而 B1 是多态类型
// b1 指向对象的最派生对象是 d
// d 里面的 b1 是公开基类 B1 的子对象
// d 有无歧义基类 B2 且 B2 是 D 的公开基类
// 转换得到这个 B2 基类子对象的指针
B2 *b2 = dynamic_cast<B2 *>(b1);

@dynilath 的例子没有体现 B2 不需要是多态类型,另外

这个转换要藉由struct D这样的旁支选择无歧义的时候才能进行

准确来说,是不考虑访问性时选择无歧义基类关系公开,注意有惟一公开基类、不惟一基类的时候,转换失败。

第一部分的问题,单纯是 C++ 标准,见 expr.dynamic.cast,下面假设 dynamic_cast<C *>(v)vV *CV 都是完整类类型且 v 不等于 nullptr,无关的内容都略:

  1. 略;
  2. 略;
  3. 如果 C 就是 V,则结果是 v
  4. 如果 C = BV = D 的基类,那么结果是 v 的惟一 B 基类子对象;如果 D 的基类 B 歧义或者不可访问,则程序非良构;
  5. 否则,V 必须是多态类型;
  6. 略(从 nullptr 转换);
  7. 略(转换到 void *);
  8. 进行“运行时检查”,设 v 指向对象的最派生对象是 u
    a. 如果 v 指向 u 的某个 C 基类子对象 c 的某个公开基类子对象(用 v <= c <= u 表示),且不存在不是 cc' 使 v <= c' <= uc'C,则结果是指向 c 的指针;
    b. 如果 v 指向 u 的某个公开基类子对象,且 u 具有惟一的 C 基类子对象 c,且 C 是公开基类,则结果是指向 c 的指针;
  9. 略。

这里比较变态的点是关于 public 和可访问性的细节,我的“等效”表述里面忘记考虑。But still, 这个标准相当难读,而且有不少“陷阱”。

上面的叙述里“运行时检查”排除了 2、3、6、7。

考虑到“运行时检查”出现在标准文本中,我们是不是该考虑下提个编辑 issue@GeeLaw

我懒,而且这只是不幸的选词,而不是技术问题。

而且这只是不幸的选词,而不是技术问题。

……正因此我建议通过 editorial issue 处理,如果是技术问题反而不该这么做。