文章首发
【重学 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});
}
上面的i4
、i5
、i6
以及后面的read_im
的调用都是隐式转换,以i5
为例,能够将整数1
转换成Im(1)
。
使用explicit
关键字修饰类的构造函数,禁止隐式类型转换后,在进行类型转换时必须显式地指定转换操作。
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
数据结构为例。
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
类定义如下:
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
的定义后,直接编译代码,发现是可以编译通过的,但再看下代码输出:
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类型。
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;
}
这段代码,我们本意是想要判断 foo1
与foo2
是否相等,但少写了一个=
, 由于 Foo
能隐式转换成bool
类型,所以表达式foo1 = foo2
的结果永远是 true
。
所以一般不建议使用operator Typename()
。如果确实有需要,使用前先考虑是否可以加上explicit
禁止隐式转换,尤其是operator bool()
,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
应用于拷贝构造函数和移动构造函数,将会禁止编译器自动调用这些构造函数。
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 中引入的一种特殊类型,用于简化在初始化对象时传递初始化列表的过程。提供了一种简洁的语法来初始化容器、类和其他支持初始化列表的对象。下面是一个简单的使用例子:
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});
。这样会增加代码的冗余,降低了代码的可读性。
同类型的扩展类
对于有些自定义对象,我们需要尽量避免它与同类型对象的差异,比如 int
、uint32
、uint64
,这些类型之间都能相互转换。假如我们要再定义一个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 右值引用、移动语义、完美转发(下)