闲登小阁看新晴。
explicit
修饰符是C++11引入的两个短小精悍,而又对编程风格产生较大影响的特性,可谓之“新晴”。
C之阙漏:隐式转换
C语言中类型的转换只分显式和隐式两种。其中隐式转换则在参数传递、赋值(含初始化)等操作时自动完成毕竟作为POP语言并没有对象、及其关联的构造/析构等概念。
1 | int x = 3.14; //Implicit conversion from `double` to `int` |
隐式转换虽在程序设计中带来了不少便利,但其亦引来了最为诟病的一点——难以达成精确的类型转换控制/约束。
C中如strcpy
的库函数函数大量应用了无符号整型作为形参类型/返回值,但编程中经常需要将有符号整型传进传出点名,许多时候因图方便就没有进行严格的转换检查,同int
抛给size_t
或者反之fopen
不看errno
等“陋习”共为许多C语言代码埋下了祸根:
1 | int x = strlen(str); |
先不论第一行size_t
向int
的截断毕竟相比之下已经算小问题了;
第二行中若x < 3
,则传入strcpy
的长度转为size_t
后产生无符号下溢出(unsigned
underflow),进而导致内存“无限”访问而导致段错误/UB。
显然,在类型转换的便捷性和类型安全性之间,现代C++亟需找到一个新的平衡。
C++11的答案——explicit
语法
explicit
关键字可用于修饰构造函数和转换构造函数(conversion
constructor,即形如operator X()
的成员函数)。
回顾二者的联系和区别:
属性 | 构造函数 | 转换构造函数 |
---|---|---|
函数签名 | X(...) |
operator Y() |
属性 | 特殊成员函数 | 普通成员函数 |
可为虚函数? | 否(因为调用时对象的建立尚未完成) | 是 |
形参列表 | 可有任意数量形参 | 必定为空 |
可否重载? | 可以参数列表差分重载 | 只能以const 属性重载 |
返回类型? | 没有返回类型,在构造新对象时自动调用 | 不显式声明返回类型,返回的是类Y 的一个新对象 |
本质 | 将其它对象(形参列表)转换为类X 的对象 |
将类X 的对象转换为类Y 的对象 |
其它 | 可以= default; 声明;编译器会为一个类自动生成默认构造函数(若没有声明构造函数) |
explicit
作为修饰符应出现在函数名之前,形如explicit MyClass()
、explicit operator bool()
。
同为前缀修饰符,
constexpr
和explicit
的顺序没有限制,但出于语义和可读性的考虑,笔者较倾向explicit
在前的写法。
作用
被explicit
修饰的构造函数和转换构造函数不能被隐式调用。
“隐式调用”的概念:C++中每次进行“类型转换”时(参考上述列表的“本质”一栏),编译器都会将之转化为对构造/转换构造函数的重载决议(若无法选择合适的函数,则将产生编译错误)。
即使是内置类型(或“平凡类型”,trivial-types),其截断、拓展等本质上也可以看作一种对相应函数调用的重载决议。
不能“隐式调用”的内涵可参见下述示例:
1 | class X |
在函数返回值的应用中也非常广泛,用于明确“是否真的需要转换”或“是否知晓正在转换”的语义:
1 |
|
不知各位有没有发现上述代码中,堪称“蝶影纷堕”的初始化方式:等号、大括号、小括号齐“上阵”,它们的作用时而无异,时而又大相径庭。
显然又可以挖一个大坑——C++花样初始化的盘点,欢迎催更
特例
C++中有大量场景“需要”使用bool
类型的变量。
为保证在这类场景(所谓 "type bool
is
expected")中,相应向bool
类型的转换能更加“流利”地进行,引出了C++中explicit
的一个“特例”:
对于表达式e
,只要bool t(e);
语句是
良构(well-formed)(简单理解为可通过编译),则说明e
可以在上下文中("contextually")隐式地被转化为bool
,此时explicit operator bool
亦会被考虑在内。
适用于这个“特例”的场景包括条件表达式(if
/while
/for
)、逻辑运算符(||
/&&
/!
/?:
)、static_assert
/noexcept
/explicit
的操作数。
总结来说,若一个类的对象可以被“有条件”地作为bool
使用,则其可以选择声明一个explicit operator bool
,或以operator int
一类的其它非explicit
转换函数占位。
1 | class MyInt |
C++中,从一个类型向另一个类型的转换的“决策”为以下三步某种意义上是一个NFA(非确定有限自动机):
先执行0/1次 标准转换顺序 (standard conversion
sequence),再进行一次 用户自定义的转换 (user-defined
conversion),最后再进行0/1次 标准转换顺序
(当且仅当用户自定义转换被调用时才可触发)。
其中“标准转换顺序”指“内置”的类型转换,如熟知的普通整型/浮点型互转(含截断)、整型/浮点型转bool
(或反之)、指针转bool
。
1 |
|
这对于许多希望只“有条件”地用于测试true
/false
的类型来说极为有用比如:std::cin
就有过这么一段黑历史
1 | int main() |
是不是觉得后面一行莫名其妙?实际上是因为C++11前,没有explicit
关键字导致std::istream
使用了一个非常奇怪的workaround:
1 | std::istream::operator void*() const; |
其选择提供一个operator void*
来规避bool
赋值的问题,副作用就是让它变得“可删除了”乱删“野”指针,UB警告。C++11后(及其后新的相关类)均改用explicit operator bool
。
同样“别扭”的还有使用private
+不给定义来“封印”函数(C++11后用= delete
一键解决)。
有关上述内容的“天书”般全解,可移步cppreference。
应用指北
显然任何需要“阻挡”无意间隐式转换场景中,explicit
均可大展身手——其多用于需要保证类型安全时。
“联动”一个比较简单的例子:众所周知以下代码在java中不能通过编译
1 | float x = 3.14; //Error: ...? |
类似的“截断”在C/C++中常只以
很容易被忽略/关闭的Warning的形式出现突击检查之谁在编译选项里加了;尽管主流编译器都提供了将之视为Error的选项,但其只能覆盖基本类型转换的截断。-Wno-narrowing
简单来说,explicit
应用在构造函数中可以“防外”,转换构造函数则是“防内”。
explicit
可以同时应用之于二者,亦可只应用于一端,创造更灵巧于enum class
的类(enum class
可视为向底层类型的构造函数和转换构造函数均为explicit
的类);甚至可以只阻隔一部分的构造函数,而允许另一部分通过,以更适配实际应用场景。
如,对于自定义的大整数类MyInt
,即可存在以下几方面的考量:
MyInt
可表示整数的取值范围是极广的,但int
只限于 \(-2^{31} ~ 2^{31}-1\),故从允许拓展而不允许截断的角度出发,构造函数不应声明为explicit
;同时我们又希望保留这个转换构造的窗口(但需要调用者明示之,以确定其知会截断风险),则可以声明explicit
的operator int() const
;
- 设计算术运算符重载时,我们希望只在
MyInt
的逻辑下进行(以保证设计语义一致并避免逻辑混乱、难以维护),故只会声明形如MyInt operator+(MyInt x,MyInt y);
的重载,显然此时不应将构造函数声明为explicit
否则将无法如此“丝滑”地调用。
二元算术运算符建议都声明为“传值进,传值出”:参见C++——右值引用与移动语义。
简而言之,其动机在于可以充分利用移动语义的优势,能在无需编写模板/大量重载的情况下最好地优化各种情况(临时对象,常对象,普通对象等)的空间应用。
1 |
|
实例
以笔者的AES-Integrals为例。
为在密码学应用的代码维护中充分发挥Modern
C++的优势,笔者选择了推翻既有的直接视unsigned char
/uint8_t
为
"Byte"
的做法,亦未使用C++17提供的enum class byte
,而选择另起炉灶,自立一派——自定义struct byte
(可见于aes.h中),封装unsigned char
并定义相应的重载等,其中转换构造函数就利用了单向/部分explicit
:
1 | using uc = unsigned char; |
此处笔者倾向于强调byte
作为一个自定义类的特殊性,设置了较高的“准入门槛”,即用explicit
修饰byte::byte(unsigned char)
,以达成避免普通的字面量和struct byte
混合位运算的语义混乱。结合自定义的字面量操作符operator""_t
,效果形如:
1 | //byte x = 3; //Not compile |
C++20:带条件的explicit
C++20中,explicit
关键字变得更加灵活:声明时可以写出explicit(expr)
,其中expr
为bool
类型的编译期常量(compile-time
constant)。
当expr
解析为true
时,该函数视为被explicit
修饰,否则视为未被explicit
修饰。
相当于单纯的
explicit
等价于explicit(true)
,参考noexcept
和noexcept(expr)
的用法间联系。
其作用亦和noexcept
一类有相通之处,即用于模板编程中和:requires
和noexcept
等一众神魔乱舞
1 | template<class Ty> |
什么?你还不知道requires
?嘿嘿,又可以打广告啦:欢迎移步C++——concepts
结语
C++进化中许多语义检查的设计哲学之一,即为保证语义安全的“目标”同时,提供尽可能便捷的灵活性选项。explicit
、= delete;
、[[fallthrough]]
、[[nodiscard]]
等一众attributes即为其实例——所谓“有求者自取之”。
灵活应用explicit
的目的同const
、&&
等所趋目标是一致的:作为镇寰宇玉门之守,不在最终生成的可执行代码中,而在保护这片代码不受有意无意的破坏侵蚀。
由于C/C++的类型转换同
return
、引用等一样是个极大的课题,加之笔者认识有限,上述内容或存诸多疏漏之处。如有出错,请见谅并欢迎指正之;亦可关注还在咕咕咕的C++——万类霜天竞自由