对象如何正确地转化为布尔值
Jacksing

将对象转化为布尔值是一个非常常规的需求,在诸如 if (expr) {}while (expr) {} 这样的语句中,都会要求对象能够隐式转化为布尔值。C++ 也提供了一系列的方式用于对象的隐式或显式类型转换。但是诸如转化为 int,都是一些比较简单的操作,唯独 bool 比较特殊。在 C++ 中,有太多的类型可以跟 bool 类型相互转换,比如 int, 指针,对象等等。这也带来了一个问题,哪种转换方式才是最好的,所带来的副作用的是最小的。这个问题也叫做安全布尔问题(Safe bool)。

隐式类型转换

讨论这一切之前,先简单了解一下 C++ 的隐式转换工作规则。针对于隐式转换,C++ 定义了一套隐式转换序列:

  • 零个或一个标准转换序列
  • 零个或一个用户自定义转换序列
  • 零个或者一个标准转换序列

标准转化序列顾名思义,就是内置的一些隐式转换规则,比如 int 转为 bool,数组类型转化为指针。而用户自定义转换序列就是用户自己所定义的一系列隐式转换规则,对于第二标准转换序列,只有在实施了用户自定义转换序列之后才能运用。在官方文档中并没有给两个标准转换序列命名,所以分别按其使用的节点来称呼。

每次进行隐式转换的时候,编译器会先使用第一标准转换序列进行转换。如果无法正确进行转换,就会使用用户自定义转换序列进行转换,如果此时能够正确转换的话,就会使用第二标准转换序列进行最后的收尾工作。不过,如果是转换构造函数的话,就不会触发第二标准转换序列了。

举个例子:

1
2
3
4
5
6
class A {
operator int() const;
};

A a;
bool b = a;

在这个例子中,第一标准转换序列把 A* 转换为 const A*,用户自定义转换序列把 const A* 转换为 int,最后再经由第二转换序列,把 int 转换为 bool

弄清楚隐式转换的规则后,对后续所涉及到的隐式转换会有更好的理解。更为具体的例子,可以参考《彻底理解 c++ 的隐式类型转换

安全布尔问题

在引入安全布尔问题之前,得先回答一个问题,自定义类型为什么需要布尔值转换。一个最为常见的场景就是,我们需要去判断资源是否存在。在传统的 C 语言的做法中,都是采用指针来引用资源。所以在任意一个地方,我们只需要去判断指针是否为空,即可判断资源是否存在。

1
2
3
if (some_resource *p = get_resource()) {
// do something
}

在 C++ 中,我们会把资源放置于对象内部,使用包括 RAII 的方法来管理资源,对于资源是否可用的判断就会复杂一些。针对于这一问题,一个最为简单的方案提供一个方法,通过该方法来判断内部资源是否可用。

1
2
3
4
ResourcePtr ptr = get_resource();
if (ptr.is_valid()) {
// do something
}

这一方法最大的问题在于,不可能会存在一个约定俗成的命名用于确定资源是否可用,除了 is_valid 之外,还可以是 isValid, Valid, is_empty, empty 等等,调用者需要去记住这些方法名,才能正确的使用。这样的话,就会增加调用者的负担,而且还会增加代码的复杂度。此外,在模板编程的时候,这一方法会带来更为严峻的问题,它没法通用于所有的类型。

所以最为完美的方案就是,对象能够顺利的转换为布尔类型,调用者可以直接使用对象来进行资源的判断。这就带来我们所提及的安全布尔问题,所幸这一问题在 C++11 之后在语言层面上得到了完美的解决,但是可以了解一下,过去是如何解决这一问题的。

使用 operator bool

C++ 提供自定义类型转换的方法,所以最显而易见的方案就是利用 operator bool 来实现对象的布尔转换。

1
2
3
4
5
6
7
8
9
10
11
class Testable {
public:
Testable(bool b = true) : ok_(b) {}

operator bool() const {
return ok_;
}

private:
bool ok_;
};

在实现好 operator bool 之后,我们就可以直接使用对象来进行判断了。

1
2
3
4
Testable t;
if (t) {
// do something
}

从某种程度上来说,作为语言本身所提供的方案,这本应该是完美无缺的。但是,这一方案也存在着一些问题。在上一节提到的隐式转换规则中说到,在调用了用户自定义类型转换之后,还会有一个标准转换序列用于进行一些收尾转换工作。正是因为这一规则,会带来几个可能违背我们想法的问题。

其一就是,布尔值可以很轻易的转换为 int 类型,所以就可能会写出下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class IntPtr {
public:
IntPtr(int* p = nullptr) : ptr_(p) {}

operator bool() const {
return ptr_ != nullptr;
}

operator int* () const {
return ptr_;
}

private:
int* ptr_;
};

int main() {
int a = 10;
IntPtr ptr(&a);
if (ptr) {
int val = ptr; // 此处会出问题
}

return 0;
}

我们原本是想在判断了 ptr 是否为空之后,再通过 * 操作符获取到其中的值。但是由于忽略了 * 操作符,导致这段代码虽然可以通过编译,但是 val 的值只会是 0 和 1。显然这是不符合预期的。而且问题也很隐晦,很难发现。这里便是第二转换序列进行了一些收尾工作,将 bool 类型转换为 int 类型。

其次就是,像文件句柄或网络连接这样的资源之间是不可以互相比较的。但是由于定义了 operator bool,使得这些资源在语言层面上能够进行比较,这显然也是不合理的。比如下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class IntPtr {
public:
IntPtr(int* p = nullptr) : ptr_(p) {}

operator bool() const {
return ptr_ != nullptr;
}

private:
int* ptr_;
};

int main() {
IntPtr ptr1, ptr2;
if (ptr1 == ptr2) {
// do something
}

return 0;
}

ptr1ptr2 作为一个指向 int 值的对象,它们之间的比较是没有任何意义的。但编译器并不会报错,而且继续跑下来,直到运行时才会出现问题。

使用 operator!

要在实现布尔值转换的基础上,解决上述两个问题,operator! 应该是一个比较不错的解决方案。具体可以看下面的代码实现:

对于布尔值而言,置否是一个很常见的操作。所以也可以利用其来实现布尔值的转换。这个方案可以规避

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class IntPtr {
public:
IntPtr(int* p = nullptr) : ptr_(p) {}

bool operator!() {
return !ptr_;
}

private:
int* ptr_;
};

int main() {
IntPtr ptr1, ptr2;
if (!!ptr1) {
// do something
}

if (!ptr1 == !ptr2) {
// do something
}

return 0;
}

这一方法最大的问题在于不够直观,每次判断时至少都需要使用一个感叹号。而且并没有解决可比较的问题。

使用 operator void*

在上文提到过,C 语言使用指针来进行资源的可用性判断是一个很不错的方式。所以一个转换思路就是借助于这一现成方案,在各式指针中,void * 指针除了用于进行类型转换,基本就没有其他的操作了,所以可以将对象隐式转换为 void * 指针。

1
2
3
4
5
6
7
8
9
10
11
12
class IntPtr {
public:
IntPtr(int* p = nullptr) : ptr_(p) {}

operator void* const () {
if (ptr_) return ptr_;
else return nullptr;
}

private:
int* ptr_;
};

这个方案看起来是比较完美的,上文所提到的各类问题基本都可以完美的避过。所以标准库中,std::cout 就采用了这一种方案用于隐式转换。但是它同时也暗含着一个比较严重的问题,像下面语句是完全合法的。

1
2
IntPtr ptr;
detele ptr;

根据墨菲定律,可能出问题的话,就一定会出问题。所以还是需要探讨一个更完美的方案。

使用内部 class 指针进行转换

使用内部 class 来进行布尔值的隐式转换也是一个不错的思路,具体的做法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class IntPtr {
public:
IntPtr(int* p = nullptr) : ptr_(p) {}

public:
class test_class;

operator const test_class* () const {
return ptr_ ? reinterpret_cast<const test_class*>(this) : 0;
}

private:
int* ptr_;
};

在类的内部创建一个不需要实现的内部类,而后提供一个方法,通过该方法进行类型转换。因为类没有实现,所以类型转换的目标是指向该内部类的指针。虽然这一方案能够实现布尔值转换并进行判断,但是这种方案一样存在着不容忽视的问题。

1
2
3
4
5
6
7
8
IntPtr p1, p2;
if (p1 == p2) {
// do something
}

if (p1 < p2) {
// do something
}

指向数据的指针是具备比较大小的能力的,这个能力在这一方案下反倒成了一个问题。所以该方案的可用性倒不如上面提到的一些方案。

解决方案

explicit 转换函数

从 C++11 开始,这一关键词被引入到转换函数上,用于在语言层面上禁止隐式转换和拷贝初始化。这一关键词的引入,可以在相关草案上看到比较完整的提议论述。

对于开发者而言,这个关键词所带来的好处不言而喻,与本文最相关的一点就是,布尔值的隐式转换问题从此有了一个完美的官方标准方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

class IntPtr {
public:
IntPtr(int* p = nullptr) : ptr_(p) {}

public:
class test_class;

explicit operator bool() const {
return ptr_;
}

private:
int* ptr_;
};

只需要添加一个关键词,即可将类的布尔值隐式转换牢牢限制在判断语句内。

旧版本的解决方案

如果是一个 C++11 之前的代码,如何实现一个比较完美的布尔值隐式转换方式呢?上文提到了四种解决方案,从直接使用语言提供的方案,到利用空指针和指向数据的指针。除了这些之外,还缺乏了一个指向函数的指针

跟平时所使用的指针不同,指向函数的指针式不一样完全不一样的存在。在 C++ FAQ 中,作者有提及,指向成员函数的指针并不单纯的只是一个指针,其更像是一个特殊的数据结构。而且,在语言的标准中,函数和数据的存放位置并没有做定义,因此,指向函数的指针和指向数据的指针不能够互相转换。(1 2)。也正因如此,针对于指向数据的指针的那些操作,对于指向函数的指针来说,都是不可行的。

所以,可以利用指向成员函数的指针来完成这个布尔值的隐式转换操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Testable {
bool ok_;

typedef void (Testable::* bool_type)() const;
void this_type_does_not_support_comparisons() const {}

public:
explicit Testable(bool b = true) : ok_(b) {}

operator bool_type() const {
return ok_ == true ?
&Testable::this_type_does_not_support_comparisons : 0;
}
};

我们需要定义一个成员函数指针 bool_typebool_type 的细节并不需要对外暴露,同时,还需要定义一个实现该成员函数指针类型的成员函数 this_type_does_not_support_comparisons。之后就就需要再实现一个类型转换函数,将对象转换为 bool_type。因为 bool_type 是一个指针,所以编译器能够自动对它进行布尔值的转换。

这一方案基本继承了转换为 void* 的方案,只不过,这里的 void* 被替换为了 bool_type。同时,因为 bool_type 是一个成员函数指针,所以还能规避 void* 会被 delete 的问题。

不过该方案现在还并非完美,因为隐式转换的目标是一个指针,所以不同的 Testable 对象依旧是可以互相比较的。这个问题其实是一个非常严重的语义问题,所以还是需要再进一步完善。

1
2
3
4
5
6
7
8
9
10
Testable test;
Testable test2;

if (test1 == test2) {
// do something
}

if (test != test2) {
// do something
}

对于这个问题的最优解法就是运算符重载,只需要让使用者在调用了 == 后无法通过编译,就可以一劳永逸的解决这个问题。具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
template <typename T>
bool operator!=(const Testable& lhs, const T& rhs) {
lhs.this_type_does_not_support_comparisons();
return false;
}
template <typename T>
bool operator==(const Testable& lhs, const T& rhs) {
lhs.this_type_does_not_support_comparisons();
return false;
}

我们在声明 bool_type 的实现函数时,将其访问权限设置为了 private,这样就无法通过外界进行访问。因此在重载 == 的时候,可以通过调用一个无法访问的函数的方式来阻止编译。

因此,通过上述的操作,就可以在并不带来副作用的情况下,完美解决 Testable 对象的比较问题。

旧方案的推广方案

既然实现方案有了,接下来就是如何将其变为一个库,供各路调用者直接调用。一个简单的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class safe_bool_base {
private:
safe_bool_base() {}
safe_bool_base(const safe_bool_base&) {}
safe_bool_base& operator=(const safe_bool_base&) { return *this; }
~safe_bool_base() {}

protected:
typedef void (safe_bool_base::* bool_type)() const;
void this_type_does_not_support_comparisons() const {}
};

template <typename T = void> class safe_bool : public safe_bool_base {
public:
operator bool_type() const {
return (static_cast<const T*>(this))->boolean_test()
? &safe_bool_base::this_type_does_not_support_comparisons : 0;
}
protected:
~safe_bool() {}
};

template<> class safe_bool<void> : public safe_bool_base {
public:
operator bool_type() const {
return boolean_test() == true ?
&safe_bool_base::this_type_does_not_support_comparisons : 0;
}
protected:
virtual bool boolean_test() const = 0;
virtual ~safe_bool() {}
};

template <typename T, typename U>
void operator==(const safe_bool<T>& lhs, const safe_bool<U>& rhs) {
lhs.this_type_does_not_support_comparisons();
return false;
}

template <typename T, typename U>
void operator!=(const safe_bool<T>& lhs, const safe_bool<U>& rhs) {
lhs.this_type_does_not_support_comparisons();
return false;
}

// 使用
class Testable_with_virtual : public safe_bool<> {
protected:
bool boolean_test() const {
// Perform Boolean logic here
}
};

class Testable_without_virtual :
public safe_bool <Testable_without_virtual> {
public:
bool boolean_test() const {
// Perform Boolean logic here
}
};

这里比较有意思的是,原文作者提供了两种调用方案。一种是通过继承 safe_bool 类,另一种是通过继承 safe_bool 模板类,这两者的区别就在于虚函数的调用开销。

汇总

这个问题有意思的地方在于,它是一个非常简单的问题,但是却有着非常多的解决方案。这些解决方案的优劣性也各不相同,但是都能够解决这个问题。这也是 C++ 的一个特点,它的灵活性非常强,但是也因此导致了很多的问题。这也是为什么 C++ 有很多的编码规范,以及很多的编码规范的原因。原文详见:Safe Bool Idiom

  • 本文标题:对象如何正确地转化为布尔值
  • 本文作者:Jacksing
  • 创建时间:2022-12-08 23:20:56
  • 本文链接:https://wzzzx.github.io/C/the-safe-bool-idiom/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论