如何正确使用"C++ Core Guidelines: C.146: Use dynamic_cast where class hierarchy navigation is unavoidable"

How to properly use "C++ Core Guidelines: C.146: Use dynamic_cast where class hierarchy navigation is unavoidable"

动机

C++ 核心指南建议在“class 层次结构导航不可避免”时使用 dynamic_cast。这会触发 clang-tidy 抛出以下错误:Do not use static_cast to downcast from a base to a derived class; use dynamic_cast instead [cppcoreguidelines-pro-type-static-cast-downcast].

指南接着说:

Note:

Like other casts, dynamic_cast is overused. Prefer virtual functions to casting. Prefer static polymorphism to hierarchy navigation where it is possible (no run-time resolution necessary) and reasonably convenient.

我一直只是使用一个名为 Kindenum 嵌套在我的基础 class 中,并根据其种类执行 static_cast。阅读 C++ 核心指南,“……即便如此,根据我们的经验,这种“我知道我在做什么”的情况仍然是一个已知的错误来源。”建议我不应该这样做。通常,我没有任何 virtual 函数,因此 RTTI 不存在以使用 dynamic_cast(例如,我将得到 error: 'Base_discr' is not polymorphic)。我总是可以添加一个 virtual 函数,但这听起来很傻。该指南还说在考虑使用我在 Kind.

中使用的判别方法之前进行基准测试

Benchmark


enum class Kind : unsigned char {
    A,
    B,
};


class Base_virt {
public:
    Base_virt(Kind p_kind) noexcept : m_kind{p_kind}, m_x{} {}

    [[nodiscard]] inline Kind
    get_kind() const noexcept {
        return m_kind;
    }

    [[nodiscard]] inline int
    get_x() const noexcept {
        return m_x;
    }

    [[nodiscard]] virtual inline int get_y() const noexcept = 0;

private:
    Kind const m_kind;
    int m_x;
};


class A_virt final : public Base_virt {
public:
    A_virt() noexcept : Base_virt{Kind::A}, m_y{} {}

    [[nodiscard]] inline int
    get_y() const noexcept final {
        return m_y;
    }

private:
    int m_y;
};


class B_virt : public Base_virt {
  public:
    B_virt() noexcept : Base_virt{Kind::B}, m_y{} {}

  private:
    int m_y;
};


static void
virt_static_cast(benchmark::State& p_state) noexcept {
    auto const a = A_virt();
    Base_virt const* ptr = &a;

    for (auto _ : p_state) {
        benchmark::DoNotOptimize(static_cast<A_virt const*>(ptr)->get_y());
    }
}
BENCHMARK(virt_static_cast);


static void
virt_static_cast_check(benchmark::State& p_state) noexcept {
    auto const a = A_virt();
    Base_virt const* ptr = &a;

    for (auto _ : p_state) {
    if (ptr->get_kind() == Kind::A) {
        benchmark::DoNotOptimize(static_cast<A_virt const*>(ptr)->get_y());
        } else {
            int temp = 0;
        }       
    }
}
BENCHMARK(virt_static_cast_check);


static void
virt_dynamic_cast_ref(benchmark::State& p_state) {
    auto const a = A_virt();
    Base_virt const& reff = a;

    for (auto _ : p_state) {
        benchmark::DoNotOptimize(dynamic_cast<A_virt const&>(reff).get_y());
    }
}
BENCHMARK(virt_dynamic_cast_ref);


static void
virt_dynamic_cast_ptr(benchmark::State& p_state) noexcept {
    auto const a = A_virt();
    Base_virt const& reff = a;

    for (auto _ : p_state) {
        benchmark::DoNotOptimize(dynamic_cast<A_virt const*>(&reff)->get_y());
    }
}
BENCHMARK(virt_dynamic_cast_ptr);


static void
virt_dynamic_cast_ptr_check(benchmark::State& p_state) noexcept {
    auto const a = A_virt();
    Base_virt const& reff = a;

    for (auto _ : p_state) {
        if (auto ptr = dynamic_cast<A_virt const*>(&reff)) {
            benchmark::DoNotOptimize(ptr->get_y());
        } else {
            int temp = 0;
        }
    }
}
BENCHMARK(virt_dynamic_cast_ptr_check);


class Base_discr {
public:
    Base_discr(Kind p_kind) noexcept : m_kind{p_kind}, m_x{} {}

    [[nodiscard]] inline Kind
    get_kind() const noexcept {
        return m_kind;
    }

    [[nodiscard]] inline int
    get_x() const noexcept {
        return m_x;
    }

private:
    Kind const m_kind;
    int m_x;
};


class A_discr final : public Base_discr {
public:
    A_discr() noexcept : Base_discr{Kind::A}, m_y{} {}

    [[nodiscard]] inline int
    get_y() const noexcept {
        return m_y;
    }

private:
    int m_y;
};


class B_discr : public Base_discr {
public:
    B_discr() noexcept : Base_discr{Kind::B}, m_y{} {}

private:
    int m_y;
};


static void
discr_static_cast(benchmark::State& p_state) noexcept {
    auto const a = A_discr();
    Base_discr const* ptr = &a;

    for (auto _ : p_state) {
        benchmark::DoNotOptimize(static_cast<A_discr const*>(ptr)->get_y());
    }
}
BENCHMARK(discr_static_cast);


static void
discr_static_cast_check(benchmark::State& p_state) noexcept {
    auto const a = A_discr();
    Base_discr const* ptr = &a;

    for (auto _ : p_state) {
        if (ptr->get_kind() == Kind::A) {
            benchmark::DoNotOptimize(static_cast<A_discr const*>(ptr)->get_y());
        } else {
            int temp = 0;
        }
    }
}
BENCHMARK(discr_static_cast_check);

我是基准测试的新手,所以我真的不知道自己在做什么。我注意确保 virtual 和判别式版本具有相同的内存布局,并尽力防止优化。我选择了优化级别 O1,因为任何更高的级别似乎都不具有代表性。 discr代表被歧视或被标记。 virt 代表 virtual 这是我的结果:

问题

所以,我的问题是:当 (1) 我知道派生类型,因为我在进入函数之前检查过它,以及 (2) 当我不知道派生类型时,我应该如何从基类型转换为派生类型打字呢。此外,(3) 我是否应该担心这个指南,或者我应该禁用警告?性能在这里很重要,但有时并不重要。我应该使用什么?

编辑:

使用 dynamic_cast 似乎是 correct 向下转换的答案。但是,您仍然需要知道要向下转换为什么并具有 virtual 功能。在许多情况下,如果不区分 kindtag,您将不知道派生的 class 是什么。 (4) 如果我已经必须检查我正在查看的对象的 kind 是什么,我还应该使用 dynamic_cast 吗?这不是检查同一件事两次吗? (5) 没有 tag?

有合理的方法吗

Example

考虑 class 层次结构:

class Expr {
public:
    enum class Kind : unsigned char {
        Int_lit_expr,
        Neg_expr,
        Add_expr,
        Sub_expr,
    };

    [[nodiscard]] Kind
    get_kind() const noexcept {
        return m_kind;
    }

    [[nodiscard]] bool
    is_unary() const noexcept {
        switch(get_kind()) {
            case Kind::Int_lit_expr:
            case Kind::Neg_expr:
                return true;
            default:
                return false;
        }
    }

    [[nodiscard]] bool
    is_binary() const noexcept {
        switch(get_kind()) {
            case Kind::Add_expr:
            case Kind::Sub_expr:
                return true;
            default:
                return false;
        }
    }

protected:
    explicit Expr(Kind p_kind) noexcept : m_kind{p_kind} {}

private:
    Kind const m_kind;
};


class Unary_expr : public Expr {
public:
    [[nodiscard]] Expr const*
    get_expr() const noexcept {
        return m_expr;
    }

protected:
    Unary_expr(Kind p_kind, Expr const* p_expr) noexcept :
        Expr{p_kind},
        m_expr{p_expr} {}

private:
    Expr const* const m_expr;
};


class Binary_expr : public Expr {
public:
    [[nodiscard]] Expr const*
    get_lhs() const noexcept {
        return m_lhs;
    }

    [[nodiscard]] Expr const*
    get_rhs() const noexcept {
        return m_rhs;
    }

protected:
    Binary_expr(Kind p_kind, Expr const* p_lhs, Expr const* p_rhs) noexcept :
        Expr{p_kind},
        m_lhs{p_lhs},
        m_rhs{p_rhs} {}

private:
    Expr const* const m_lhs;
    Expr const* const m_rhs;
};


class Add_expr : public Binary_expr {
public:
    Add_expr(Expr const* p_lhs, Expr const* p_rhs) noexcept : 
        Binary_expr{Kind::Add_expr, p_lhs, p_rhs} {}
};

现在 main():

int main() {
    auto const add = Add_expr{nullptr, nullptr};
    Expr const* const expr_ptr = &add;

    if (expr_ptr->is_unary()) {
        auto const* const expr = static_cast<Unary_expr const* const>(expr_ptr)->get_expr();
    } else if (expr_ptr->is_binary()) {
        // Here I use a static down cast after checking it is valid
        auto const* const lhs = static_cast<Binary_expr const* const>(expr_ptr)->get_lhs();
    
        // error: cannot 'dynamic_cast' 'expr_ptr' (of type 'const class Expr* const') to type 'const class Binary_expr* const' (source type is not polymorphic)
        // auto const* const rhs = dynamic_cast<Binary_expr const* const>(expr_ptr)->get_lhs();
    }
}
<source>:99:34: warning: do not use static_cast to downcast from a base to a derived class [cppcoreguidelines-pro-type-static-cast-downcast]

        auto const* const expr = static_cast<Unary_expr const* const>(expr_ptr)->get_expr();

                                 ^

我并不总是需要转换为 Add_expr。例如,我可以有一个打印出任何 Binary_expr 的函数。它只需要将其转换为 Binary_expr 即可获得 lhsrhs。要获取运算符的符号(例如“-”或“+”...),它可以打开 kind。我看不出 dynamic_cast 对我有何帮助,而且我也没有虚函数可以在 dynamic_cast 上使用。

编辑 2:

我已经发布了一个答案 get_kind() virtual,总的来说这似乎是一个很好的解决方案。但是,我现在为 vtbl_ptr 携带了大约 8 个字节,而不是标签的一个字节。从 Expr 派生的 classes 实例化的对象将远远超过任何其他对象类型。 (6) 现在是跳过 vtbl_ptr 的好时机还是我应该更喜欢安全的 dynamic_cast

如果您在编译时知道实例的类型,您可能会对此处的好奇递归模板模式感兴趣,以避免完全需要虚方法

template <typename Impl> 
class Base_virt {
public:
    Base_virt(Kind p_kind) noexcept : m_kind{p_kind}, m_x{} {}

    [[nodiscard]] inline Kind
    get_kind() const noexcept { return Impl::kind(); }

    [[nodiscard]] inline int
    get_x() const noexcept {
        return m_x;
    }

    [[nodiscard]] inline int get_y() const noexcept { 
        return static_cast<const Impl*>(this)->get_y(); 
    }

private:
    int m_x;
};


class A_virt final : public Base_virt<A_virt> {
public:
    A_virt() noexcept : Base_virt{Kind::A}, m_y{} {}

    [[nodiscard]] inline static Kind kind() { return Kind::A; }

    [[nodiscard]] inline int
    get_y() const noexcept final {
        return m_y;
    }

private:
    int m_y;
};

// Copy/paste/rename for B_virt

在那种情况下,根本不需要 dynamic_cast,因为一切都在编译时已知。您正在失去存储指向 Base_virt 的指针的可能性(除非您创建 Base_virt 派生的 BaseTag 基础 class) 调用此类方法的代码必须是模板:

template <typename Impl>
static void
crtp_cast_check(benchmark::State& p_state) noexcept {
    auto const a = A_virt();
    Base_virt<Impl> const* ptr = &a;

    for (auto _ : p_state) {
        benchmark::DoNotOptimize(ptr->get_y());
    }
}
BENCHMARK(crtp_static_cast_check<A_virt>);

这很可能被编译为对 for(auto _ : p_state) b::dno(m_y) 的海峡调用。 这种方法的不便之处在于膨胀的二进制 space (您将拥有与子类型一样多的函数实例),但它是最快的,因为编译器将在编译时推断类型。

使用 BaseTag 方法,它看起来像:

   class BaseTag { virtual Kind get_kind() const = 0; }; 
   // No virtual destructor here, since you aren't supposed to manipulate instance via this type

   template <typename Impl>
   class Base_virt : BaseTag { ... same as previous definition ... };

   // Benchmark method become
   void virt_bench(BaseTag & base) {
     // This is the only penalty with a virtual method:
     switch(base.get_kind()) {

       case Kind::A : static_cast<A_virt&>(base).get_y(); break;
       case Kind::B : static_cast<B_virt&>(base).get_y(); break;
       ...etc...
       default: assert(false); break; // At least you'll get a runtime error if you forget to update this table for new Kind
     }
     // In that case, there is 0 advantage not to make get_y() virtual, but
     // if you have plenty of "pseudo-virtual" method, it'll become more 
     // interesting to consult the virtual table only once for get_kind 
     // instead of for each method
   }

   template <typename Class>
   void static_bench(Class & inst) {
     // Lame code:
     inst.get_y();
   }

   A_virt a;
   B_virt b;

   virt_bench(a);
   virt_bench(b);

   // vs
   static_bench(a);
   static_bench(b);

抱歉上面的伪代码,但你会明白的。

请注意,像上面这样混合动态继承和静态继承会使代码维护成为负担(如果添加新类型,则需要修复所有 switch table),因此必须为代码中非常小的性能敏感部分保留它。

一种可能的解决方案是使 get_kind() 成为 virtual 函数。然后您可以使用 dynamic_cast。如果您要调用很多 virtual 函数,您可以将其向下转换为最派生的 class 以便优化器可以优化 virtual 调用。您还需要使用 virtual 继承(例如 class Unary_expr : public virtual Expr {}; 如果您在基 class 中没有任何数据成员来正确使用内存。有一个指向 vtable在 64 位机器上占用 8 个字节,因此您可能被迫使用 discriminate 来减少每个对象的大小(但这显然只有在绝对不使用 virtual 函数的情况下才有意义)。

此方法解决了指南中提出的以下问题:

...Even so, in our experience such "I know what I'm doing" situations are still a known bug source.

@xryl669 指出“好奇的递归模板模式”或 CRTP 可用于消除在运行时检查类型的需要,如果您知道您正在处理的是什么类型。他也介绍了一个问题和方法的解决方案,所以你一定要看看他的答案。

这是我发现有用的另一个关于 CRTP 的资源:The cost of dynamic (virtual calls) vs. static (CRTP) dispatch in C++

class Expr {
public:
    enum class Kind : unsigned char {
        Int_lit_expr,
        Neg_expr,
        Add_expr,
        Sub_expr,
    };

    [[nodiscard]] virtual Kind get_kind() const noexcept = 0;

    [[nodiscard]] virtual bool
    is_unary() const noexcept {
        return false;
    }

    [[nodiscard]] virtual bool
    is_binary() const noexcept {
        return false;
    }
};


class Unary_expr : public virtual Expr {
public:
    [[nodiscard]] bool
    is_unary() const noexcept final {
        return true;
    }

    [[nodiscard]] Expr const*
    get_expr() const noexcept {
        return m_expr;
    }

protected:
    explicit Unary_expr(Expr const* p_expr) noexcept : m_expr{p_expr} {}

private:
    Expr const* const m_expr;
};


class Binary_expr : public virtual Expr {
public:
    [[nodiscard]] bool
    is_binary() const noexcept final {
        return true;
    }

    [[nodiscard]] Expr const*
    get_lhs() const noexcept {
        return m_lhs;
    }

    [[nodiscard]] Expr const*
    get_rhs() const noexcept {
        return m_rhs;
    }

protected:
    Binary_expr(Expr const* p_lhs, Expr const* p_rhs) noexcept : m_lhs{p_lhs}, m_rhs{p_rhs} {}

private:
    Expr const* const m_lhs;
    Expr const* const m_rhs;
};


class Add_expr final : public Binary_expr {
public:
    Add_expr(Expr const* p_lhs, Expr const* p_rhs) noexcept : Binary_expr{p_lhs, p_rhs} {}

    [[nodiscard]] Kind get_kind() const noexcept final {
        return Kind::Add_expr;
    }
};


int main() {
    auto const add = Add_expr{nullptr, nullptr};
    Expr const* const expr_ptr = &add;

    if (expr_ptr->is_unary()) {
        // NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores): it is just an example
        auto const* const expr = dynamic_cast<Unary_expr const* const>(expr_ptr)->get_expr();

    } else if (expr_ptr->is_binary()) {
        // NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores): it is just an example
        auto const* const lhs = dynamic_cast<Binary_expr const* const>(expr_ptr)->get_lhs();
    
        // NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores): it is just an example
        auto const* const rhs = dynamic_cast<Binary_expr const* const>(expr_ptr)->get_lhs();
    }
}

我认为本指南的重要部分是关于“class 层次结构导航不可避免的地方”的部分。这里的基本要点是,如果您想要 经常进行这种转换,那么很有可能您的设计存在问题。要么你选择了错误的做事方式,要么你把自己设计成了一个角落。

过度使用 OOP 就是这样的一个例子。让我们以 Expr 为例,它是表达式树中的一个节点。你可以问它一些问题,比如它是二元运算、一元运算还是零元运算(仅供参考:字面值是零元的,而不是一元的。它们不带参数)。

您过度使用 OOP 的地方是试图为每个运算符提供自己的 class 类型。加法运算符和乘法运算符有什么区别?优先?那是语法问题;一旦你构建了表达式树,它就无关紧要了。唯一真正关心特定二元运算符的操作是在评估它时。即使在进行评估时,唯一特殊的部分是当您获取操作数的评估结果并将其提供给将产生此操作结果的代码时。所有二进制操作的其他所有内容都相同。

因此,对于各种二元运算,您拥有一个不同的函数。如果只有一个功能发生变化,那么您真的不需要为此使用不同的类型。不同的二元运算符在一般 BinaryOp class 中具有不同的值更为合理。 UnaryOpNullaryOp 也是如此。

所以在这个例子中,任何给定的节点只有 3 种可能的类型。作为 variant<NullaryOp, UnaryOp, BinaryOp> 来处理是非常合理的。因此 Expr 可以只包含其中一个,每个操作数类型都有零个或多个指向其子 Expr 元素的指针。 Expr 上可能有一个通用接口,用于获取子项的数量、遍历子项等。不同的 Op 类型可以通过简单的访问者提供这些实现。

大多数情况下,当你开始想要进行向下转型时,这些事情是可以使用其他机制更好、更干净地解决的。如果你在没有 virtual 函数的情况下构建层次结构,其中接收基 classes 的代码已经知道大部分或所有可能的派生 classes,那么你真的在写一个variant.

的粗略形式

这些论点都很棒,但也有一些情况无法应用这些解决方案。一个例子是经验丰富的 JNI 规范。索尼事后将 C++ 包装器添加到官方 Java 本机接口。例如,他们定义 GetObjectField() 方法即 returns jobject。但是如果 field 是一个数组,你必须 castjbyteArray,例如能够使用 GetArrayLength().

JNI 无法使用dynamic_cast。备选方案是 C-style cast 或 static_cast,我相信后者更安全,或者至少更清洁比

(jbyteArray)env->CallObjectMethod(myObject, toByteArray_MethodID);

要在 Android Studio 中抑制单行警告,请使用 NOLINT:

auto byteArray = static_cast<jbyteArray>(env->CallObjectMethod(myObject, toByteArray_MethodID)); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)

或者,设置

#pragma ide diagnostic ignored "cppcoreguidelines-pro-type-static-cast-downcast"

对于文件或块