如何正确使用《C++ 核心指南:C.146:在类层次结构导航不可避免的地方使用 dynamic_cast》 [英] How to properly use "C++ Core Guidelines: C.146: Use dynamic_cast where class hierarchy navigation is unavoidable"
问题描述
动机
C++ 核心指南建议在无法避免类层次结构导航"时使用 dynamic_cast
.这会触发 clang-tidy 抛出以下错误: Do not use static_cast to downcast from a base to aderived class;改用 dynamic_cast [cppcoreguidelines-pro-type-static-cast-downcast]
.
指南继续说:
<块引用>问题
所以,我的问题是:当 (1) 我知道派生类型,因为我在进入函数之前检查了它,以及 (2) 当我还不知道派生类型时,我应该如何从基类转换为派生类型.此外,(3)我应该担心这个指南,还是应该禁用警告?性能在这里很重要,但有时并不重要.我应该使用什么?
使用 dynamic_cast
似乎是向下转换的正确答案.但是,您仍然需要知道要向下转换的内容并拥有 virtual
功能.在许多情况下,如果不使用诸如kind
或tag
之类的区别,您不知道派生类是什么.(4) 如果我已经必须检查我正在查看的对象的 kind
是什么,我还应该使用 dynamic_cast
吗?这不是两次检查同一件事吗?(5) 没有 tag
有没有合理的方法可以做到这一点?
示例
考虑 class
层次结构:
class Expr {上市:枚举类种类:无符号字符{int_lit_expr,Neg_expr,添加_表达式,子表达式,};[[nodiscard]] 种类get_kind() const noexcept {返回 m_kind;}[[nodiscard]] 布尔值is_unary() const noexcept {开关(get_kind()){case Kind::Int_lit_expr:case Kind::Neg_expr:返回真;默认:返回假;}}[[nodiscard]] 布尔值is_binary() const noexcept {开关(get_kind()){case Kind::Add_expr:case Kind::Sub_expr:返回真;默认:返回假;}}受保护:显式表达式(Kind p_kind) noexcept : m_kind{p_kind} {}私人的:种类 const m_kind;};一元表达式类:公共表达式{上市:[[nodiscard]] Expr const*get_expr() const noexcept {返回 m_expr;}受保护:Unary_expr(Kind p_kind, Expr const* p_expr) noexcept :表达式{p_kind},m_expr{p_expr} {}私人的:表达式 const* const m_expr;};类 Binary_expr : 公共 Expr {上市:[[nodiscard]] Expr const*get_lhs() const noexcept {返回 m_lhs;}[[nodiscard]] Expr const*get_rhs() const noexcept {返回 m_rhs;}受保护:Binary_expr(Kind p_kind, Expr const* p_lhs, Expr const* p_rhs) noexcept :表达式{p_kind},m_lhs{p_lhs},m_rhs{p_rhs} {}私人的:表达式 const* const m_lhs;表达式 const* const m_rhs;};类 Add_expr : public Binary_expr {上市: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;如果 (expr_ptr->is_unary()) {auto const* const expr = static_cast(expr_ptr)->get_expr();} else if (expr_ptr->is_binary()) {//这里我在检查有效后使用静态向下转换auto const* const lhs = static_cast(expr_ptr)->get_lhs();//错误:不能 'dynamic_cast' 'expr_ptr'(类型为 'const class Expr* const')到类型 'const class Binary_expr* const'(源类型不是多态的)//auto const* const rhs = dynamic_cast(expr_ptr)->get_lhs();}}
我并不总是需要转换为 Add_expr
.例如,我可以有一个函数来打印任何 Binary_expr
.只需将其转换为Binary_expr
即可获得lhs
和rhs
.要获取运算符的符号(例如-"或+"...),它可以打开 kind
.我不知道 dynamic_cast
将如何帮助我,而且我也没有使用 dynamic_cast
的虚函数.
编辑 2:
我已经发布了一个get_kind()
virtual
的答案,总的来说,这似乎是一个很好的解决方案.但是,我现在为 vtbl_ptr
携带了大约 8 个字节,而不是一个用于标记的字节.从 class
es 派生自 Expr
实例化的对象将远远超过任何其他对象类型.(6) 现在是跳过 vtbl_ptr
的好时机还是我应该更喜欢 dynamic_cast
的安全性?
我认为本指南的重要部分是关于类层次导航不可避免的地方"的部分.这里的基本观点是,如果您想要经常进行这种类型的选角,那么很可能您的设计有问题.要么你选择了错误的做某事的方式,要么你把自己设计成一个角落.
过度使用 OOP 就是这种情况的一个例子.让我们以 Expr
为例,它是表达式树中的一个节点.你可以问它是二元运算、一元运算还是零元运算(仅供参考:文字值是零元,不是一元.它们不带参数).
您过度使用 OOP 的地方是试图为每个运算符提供自己的类类型.加法运算符和乘法运算符有什么区别?优先级?那是语法问题;一旦你构建了表达式树,它就无关紧要了.唯一真正关心特定二元运算符的操作是在您对其进行评估时.即使在进行评估时,唯一特殊的部分是当您获取操作数的评估结果并将其输入到将产生此操作结果的代码中时.所有二元运算的其他一切都相同.
所以你有一个不同的二元运算的函数.如果只有一个函数会发生变化,那么您真的不需要为此而使用不同的类型.在一般的 BinaryOp
类中,不同的二元运算符是不同的值更合理.UnaryOp
和 NullaryOp
s 也是如此.
所以在这个例子中,任何给定的节点只有 3 种可能的类型.将其作为 variant
处理是非常合理的.所以 Expr
可以只包含其中之一,每个操作数类型都有零个或多个指向其子 Expr
元素的指针.Expr
上可能有一个通用接口,用于获取子项的数量、遍历子项等.不同的 Op
类型可以通过简单的访问者提供这些实现.
大多数情况下,当您开始想要进行向下转换时,这些情况可以使用其他机制更好、更干净地解决.如果您正在构建没有 virtual
函数的层次结构,其中接收基类的代码已经知道大部分或所有可能的派生类,那么您很可能真的在编写 variant 的粗略形式代码>.
Motivation
The C++ Core Guidelines recommends using dynamic_cast
when "class hierarchy navigation is unavoidable." This triggers clang-tidy to throw the following error: Do not use static_cast to downcast from a base to a derived class; use dynamic_cast instead [cppcoreguidelines-pro-type-static-cast-downcast]
.
The guidelines go on to say:
Note:
Like other casts,
dynamic_cast
is overused. Prefervirtual
functions to casting. Prefer static polymorphism to hierarchy navigation where it is possible (no run-time resolution necessary) and reasonably convenient.
I have always just used an enum
named Kind
nested in my base class, and performed a static_cast
based on its kind. Reading C++ Core Guidelines, "...Even so, in our experience such "I know what I'm doing" situations are still a known bug source." suggests that I should not be doing this. Often, I don't have any virtual
functions so RTTI is not present to use dynamic_cast
(e.g. I will get error: 'Base_discr' is not polymorphic
). I can always add a virtual
function, but that sounds silly. The guideline also says to benchmark before considering using the discriminant approach that I use with 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);
I am new to benchmarking, so I don't really know what I am doing. I took care to make sure that virtual
and discriminant versions have the same memory layout and tried my best to prevent optimizations. I went with optimization level O1
since anything higher didn't seem representative. discr
stands for discriminated or tagged. virt
stands for virtual
Here are my results:
Questions
So, my questions are: How should I cast from a base to a derived type when (1) I know the derived type because I checked it before entering the function and (2) when I do not know the derived type yet. Additionally, (3) Should I even be worried about this guideline, or should I disable the warning? Performance matters here, but sometimes it does not. What should I be using?
EDIT:
Using dynamic_cast
seems to be the correct answer for downcasting. However, you still need to know what you are downcasting to and have a virtual
function. In many cases, you do not know without a discriminate such as kind
or tag
what the derived class is. (4) In the case where I already have to check what the kind
of object I am looking at, should I still be using dynamic_cast
? Is this not checking the same thing twice? (5) Is there a reasonable way to do this without a tag
?
Example
Consider the class
hierarchy:
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} {}
};
Now in 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();
^
Not always will I need to cast to an Add_expr
. For example, I could have a function that prints out any Binary_expr
. It only need to cast it to Binary_expr
to get the lhs
and rhs
. To get the symbol of the operator (e.g. '-' or '+' ...) it can switch on the kind
. I don't see how dynamic_cast
will help me here and I also have no virtual functions to use dynamic_cast
on.
EDIT 2:
I have posted an answer making get_kind()
virtual
, this seems to be a good solution in general. However, I am now carrying around 8 bytes for a vtbl_ptr
instead of a byte for a tag. Object instantiated from class
es derived from Expr
will far exceed any other object types. (6) Is this a good time to skip the vtbl_ptr
or should I prefer the safety of dynamic_cast
?
I think the important part of this guideline is the part about "where class hierarchy navigation is unavoidable". The basic point here being that, if you're wanting to do this kind of casting a lot, then odds are good that there is something wrong with your design. Either you picked the wrong way to do something or you designed yourself into a corner.
Overuse of OOP is one example of such a thing. Let's take your example of Expr
, which is a node in an expression tree. And you can ask it questions like whether it is a binary operation, unary operation, or a nullary operation (FYI: literal values are nullary, not unary. They take no arguments).
Where you've overused OOP was in trying to give each operator its own class type. What is the difference between an addition operator and a multiplication operator? Precedence? That's a matter for the grammar; it's irrelevant once you've built the expression tree. The only operation that really cares about the specific binary operator is when you evaluate it. And even when doing evaluation, the only part that's special is when you take the results of the evaluation of the operands and feed it into the code that's going to produce the result of this operation. Everything else is the same for all binary operations.
So you have one function that is different for various binary operations. If there's just one function that changes, you really don't need different types just for that. It's much more reasonable for the different binary operators to be different values within a general BinaryOp
class. The same goes for UnaryOp
and NullaryOp
s.
So within this example, there are only 3 possible types for any given node. And that's very reasonable to deal with as a variant<NullaryOp, UnaryOp, BinaryOp>
. So an Expr
can just contain one of those, with each operand type having zero or more pointers to its child Expr
elements. There could be a generic interface on Expr
for getting the number of children, iterating through the children, etc. And the different Op
types can provide the implementations for these through simple visitors.
Most cases when you start to want to do downcasting and such things are cases that can be solved better and more cleanly using other mechanisms. If you're building hierarchies without virtual
functions, where code receiving base classes already knows most or all of the possible derived classes, odds are good that you're really writing a crude form of variant
.
这篇关于如何正确使用《C++ 核心指南:C.146:在类层次结构导航不可避免的地方使用 dynamic_cast》的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!