【重学 C++】06 | C++该不该使用 explicit

2023-07-08 23:11:55 浏览数 (1)

文章首发

【重学 C 】06 | C 该不该使用 explicit

引言

大家好,我是只讲技术干货的会玩code,今天是【重学C 】的第六讲,在 C 中,explicit关键字作用于类的构造函数或类型转换操作符,以禁止隐式类型转换。今天,我们来聊聊到底该不该使用explicit

explicit的作用

在C 中,默认允许隐式转换,隐式类型转换是指在表达式中自动进行的类型转换,无需显式地指定转换操作。

代码语言:c 复制
struct Im {
	Im();
	Im(int);
};

void read_im(const Im&);

int main(int argc, char const *argv[])
{

	Im i1;
	
	Im i2 = Im();
	
	Im i3 = Im(1);
	
	Im i4 = {};
	
	Im i5 = 1;
	
	Im i6 = {1};
	
	read_im({});
	
	read_im(1);
	
	read_im({1});
}

上面的i4i5i6以及后面的read_im的调用都是隐式转换,以i5为例,能够将整数1转换成Im(1)

使用explicit关键字修饰类的构造函数,禁止隐式类型转换后,在进行类型转换时必须显式地指定转换操作。

代码语言:c 复制
struct Ex {
	explicit Ex();
	explicit Ex(int);
};

void read_ex(const Ex&);

int main(int argc, char const *argv[]) {

	Ex e1;
	
	Ex e2 = Ex();
	
	Ex e3 = Ex(1);
	
	Ex e4 = 1; // error
	
	read_ex(Ex());
	
	read_ex(Ex(1));
	
	read_ex(1); // error
}

隐式转换问题

隐式转换虽然看起来比较便利,但降低了代码的可读性。并且,在一些情况下,这种转换会导致意外的结果,造成代码错误。

精度丢失

当将一个高精度的数据类型转换为低精度的类型时,可能会导致数据精度的丢失,还是以上面Im数据结构为例。

代码语言:c 复制
struct Im {
	Im();
	Im(int);
};

// 将浮点数 1.6 赋值给了 i, 丢失了小数点后的精度
Im i = 1.6;

调用目标函数混乱

假设项目中有这样一段代码

代码语言:c 复制
class Book {
	std::string title_;
	std::string author_;
public:
	Book(std::string t, std::string a) :
	title_(t), author_(a) {};
};

void add_to_library(const Book&) {
	std::cout << "call exactly fn" << std::endl;
}

template<class T = std::string>
void add_to_library(std::pair<bool, const T> param) {
	std::cout << "call template fn" << std::endl;
}

int main(int argc, char const *argv[]) {
	add_to_library({"title", "author"});
}

代码输出:

代码语言:shell复制
call exactly fn

由于Book允许隐式转换,{"title", "author"}被转换成了Book("title", "author"), 所以,最终会匹配到void add_to_library(const Book&), 目前看一切都很完美,但后面迭代后发现,Book还应该有个pages_页数的成员变量。变更后的Book类定义如下:

代码语言:c 复制
class Book {
	std::string title_;
	std::string author_;
	int pages_;
	
public:
	Book(std::string t, std::string a, int p) :
	title_(t), author_(a), pages_(p) {}
};

改完Book的定义后,直接编译代码,发现是可以编译通过的,但再看下代码输出:

代码语言:shell复制
call template fn

由于 Book增加了pages_成员变量,{"title", "author"}无法隐式转换成Book对象,所以,会继续匹配到模板函数void add_to_library(std::pair<bool, const T> param)。 这种错误比较隐晦,在编译过程中也不会有任何warning提示。

对象被错误回收

经典例子就是智能指针了,我们在《03 |手撸C 智能指针实战教程》一节中也提到过,下面我们再来回顾一下。

代码语言:c 复制
template <typename T>
class smart_ptr {
public:
	// explicit smart_ptr(T* ptr = nullptr): ptr_(ptr) {}
	smart_ptr(T* ptr = nullptr): ptr_(ptr) {}
	~smart_ptr() {
		delete ptr_;
	}

	T& operator*() const { return *ptr_; }
	T* operator->() const { return ptr_; }

private:
	T* ptr_;
}

void foo(smart_ptr<int> int_ptr) {
	// ...
}

int main() {
	int* raw_ptr = new int(42);
	// 隐式转换为 smart_ptr<int>
	foo(raw_ptr); 
	// error: raw_ptr已经被回收了
	std::cout << *raw_ptr << std::endl; 
	// ...
}

假设我们没有为smart_ptr构造函数加上explicit,原生指针raw_ptr在传给foo函数后,会被隐形转换为smart_ptr<int>foo函数调用结束后,析构入参的smart_ptr<int>时会把raw_ptr给回收掉了,所以后续对raw_ptr的调用都会失败。

operator bool 错误转换

C 中,有种operator TypeName()的语法,用来将对象转换为指定的TypeName类型。

代码语言:c 复制
class Foo {
public:
	operator bool() const {
		return true;
	}

	operator int() const {
		return 1;
	}
};

int main(int argc, char const *argv[]) {
	Foo foo;
	// ok
	bool a = foo; 
	// ok
	int b = foo; 
}

这种类型转换一般没什么意义,反而会增加代码可读性。而且,有些时候可能还会出现一些不容易发现的错误。

代码语言:c 复制
Foo foo1;
Foo foo2;

if (foo1 = foo2) {
	std::cout << "foo1 equal foo2" << std::endl;
}

这段代码,我们本意是想要判断 foo1foo2是否相等,但少写了一个=, 由于 Foo能隐式转换成bool类型,所以表达式foo1 = foo2的结果永远是 true

所以一般不建议使用operator Typename()。如果确实有需要,使用前先考虑是否可以加上explicit禁止隐式转换,尤其是operator bool(),C 为布尔转换留了"后门"。

代码语言:c 复制
class ExFoo {
public:
	explicit operator bool() const {
		return true;
	}
};

int main(int argc, char const *argv[]) {
	ExFoo foo1;
	// ok
	if (foo1) {
		std::cout << "..." << std::endl;
	}
	
	// error
	bool a = foo1;
}

即使使用explicit,还是可以使用foo1 ? xxx : yyy 这种方便的三元运算符。同时禁止了bool a = foo1这种无意义并且有隐患的类型转换。

所以,大部分情况下,我们都推荐使用explicit禁止默认的隐式转换,可以使代码更加健壮,降低潜在的错误和意外行为的风险。

当然,有几种特殊的情况,允许隐式转换是比较合适的。

隐式转换合理使用场景

拷贝构造函数和移动构造函数

对于拷贝构造函数和移动构造函数,我们通常希望它们能够在需要时自动调用,以便进行对象的拷贝和移动操作。如果将explicit应用于拷贝构造函数和移动构造函数,将会禁止编译器自动调用这些构造函数。

代码语言:c 复制
class Foo {
public:
	explicit Foo(Foo f) {
		std::cout << "foo copy" << std::endl;
	}
};

void test(Foo f);

int main(int argc, char const *argv[]) {
	Foo f1;
	// error
	test(f1);
}

上面例子中,test函数使用传值方式传递Foo对象,在函数调用时,会触发拷贝构造函数,但由于将拷贝构造函数定义为 explicit,编译器将无法隐式调用拷贝构造函数。所以会编译失败。

单入参std::initializer_list的构造函数

std::initializer_list 是 C 11 中引入的一种特殊类型,用于简化在初始化对象时传递初始化列表的过程。提供了一种简洁的语法来初始化容器、类和其他支持初始化列表的对象。下面是一个简单的使用例子:

代码语言:c 复制
class MyClass {
public:
	MyClass(std::initializer_list<int> numbers) {
		// 构造函数的实现
	}
};

int main() {
	MyClass obj = {1, 2, 3, 4, 5}; // 使用初始化列表语法进行隐式转换
}

对于带有std::initializer_list类型参数的构造函数,也不推荐使用explicit关键字。因为使用std::initializer_list作为构造函数的入参,就是为了方便初始化对象。如果将MyClass的构造函数标记为explicit,则在创建obj对象时,将需要显式地调用构造函数,如MyClass obj({1, 2, 3, 4, 5});。这样会增加代码的冗余,降低了代码的可读性。

同类型的扩展类

对于有些自定义对象,我们需要尽量避免它与同类型对象的差异,比如 intuint32uint64,这些类型之间都能相互转换。假如我们要再定义一个BigInt,这个时候,允许BigInt与那些原生整数类型相互转换是比较合理的。

小结

  • explicit 关键字用于禁止隐式类型转换,在进行类型转换时必须显式地指定转换操作。
  • 隐式转换可能导致精度丢失、调用目标函数混乱、对象被错误回收以及operator bool错误转换等问题。绝大多数情况下,我们都优先考虑禁止隐式转换。
  • 在拷贝构造函数和移动构造函数中,不推荐使用 explicit,以便编译器可以自动调用这些构造函数。
  • 对于带有单入参std::initializer_list的构造函数,也不推荐使用explicit,以方便使用初始化列表语法进行隐式转换。
  • 同类型的扩展类,为了避免差异化,隐式转换会更合适。

<center> END </center>

【往期推荐】

【重学C 】01| C 如何进行内存资源管理?

【重学C 】02 | 脱离指针陷阱:深入浅出 C 智能指针

【重学C 】03 | 手撸C 智能指针实战教程

【重学C 】04 | 说透C 右值引用、移动语义、完美转发(上)

【重学C 】05 | 说透C 右值引用、移动语义、完美转发(下)

0 人点赞