第17.6章 转换运算符

English

1. 转换运算符是什么

operator T() 是一个没有返回类型、没有参数的成员函数。 它说的是:"凡是要一个 T 的地方,我能造一个出来。" 它是语言里侵入性最强的运算符,因为它不参与某一个表达式 —— 它参与每一个涉及该类型对象的表达式: 初始化、赋值、传参、返回、每个内建运算符的操作数、 if 和 while 的条件、 ?: 的两个分支,以及该对象出现的每个重载集的排序。


struct handle
{
	unsigned p{};
	operator bool() const noexcept { return p != 0; }  /* implicit: every context */
};

只写了那一行,h + 1 就能编过、h * h 也能编过、 h == "text" 也能编过, 而 f(h) 对每一个接受"bool 能转换成的东西"的 f 都变成了可行候选。调用方的代码里没有一个字提到转换, 这就是"隔着距离发生的转换"的定义。

所以本章对转换的规则比它对算术运算符的规则更窄: 只有一个转换运算符值得拥有 —— explicit operator bool —— 其它的都本该写成命名函数。

2. 值得拥有的那一个

一个句柄 —— 指针包装、文件、套接字、类 optional 的值 —— 天生带一个问题是: "里面到底有没有东西?"这个问题在 C++ 里有一个约定俗成的写法(if (h)), 值得支持。支持它的那个转换是 explicit 的, 这就把它限制在语言要求一个真值的那些上下文里 ("到 bool 的语境转换"):if、while、 for、switch 的条件, !、&&、|| 的操作数, 以及 ?: 的条件。


#include <fast_io.h>
#include <cstdint>

struct [[nodiscard]] handle
{
	::std::uintptr_t p{};

	explicit operator bool() const noexcept /* the one conversion worth having */
	{
		return p != 0;
	}

	[[nodiscard]] ::std::uintptr_t raw() const noexcept /* everything else gets a name */
	{
		return p;
	}
};

int main()
{
	try
	{
		handle empty{};
		handle full{0x1234uz};

		::fast_io::io::println("empty          ", ::fast_io::mnp::boolalpha(static_cast<bool>(empty)));
		::fast_io::io::println("full           ", ::fast_io::mnp::boolalpha(static_cast<bool>(full)));
		::fast_io::io::println("!empty         ", ::fast_io::mnp::boolalpha(!empty));
		::fast_io::io::println("full || empty  ", ::fast_io::mnp::boolalpha(full || empty));
		::fast_io::io::println("full.raw()     ", full.raw());
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

empty          false
full           true
!empty         true
full || empty  true
full.raw()     4660

注意程序为了打印需要写什么:static_cast<bool>(empty)。 这不是仪式 —— explicit 转换恰好只允许用在语言要求真值的地方, 而 println 要的是"一个可格式化的值",不是真值。 这个限制正在履行它的职责:顺手的写法能用,顺带出来的写法不能。

对这个运算符有三条要求,三条都容易搞错:

3. explicit 拒绝做什么

同一个句柄,一行本该编不过的代码 —— 它确实编不过。 下面是编译器的输出,其中重复的源码回显已省略:


struct handle
{
	unsigned p{};
	explicit operator bool() const noexcept { return p != 0; }
};

int f(handle h) noexcept
{
	return h + 1;   /* explicit conversions do not apply here */
}

e1.cc: In function ‘int f(handle)’:
e1.cc:9:18: error: no match for ‘operator+’ (operand types are ‘handle’ and ‘int’)
    9 |         return h + 1;   /* explicit conversions do not apply here */
      |                ~ ^ ~
      |                |   |
      |                |   int
      |                handle
  • there is 1 candidate
    • candidate 1: ‘operator+(int, int)’ (built-in)
      • no known conversion for argument 1 from ‘handle’ to ‘int’

以及那个看起来无害的比较:


bool f(handle h) noexcept
{
	return h == 1;   /* explicit conversions do not apply here either */
}

e4.cc: In function ‘bool f(handle)’:
e4.cc:9:18: error: no match for ‘operator==’ (operand types are ‘handle’ and ‘int’)
    9 |         return h == 1;   /* explicit conversions do not apply here either */
      |                ~ ^~ ~
      |                |    |
      |                |    int
      |                handle
  • there is 1 candidate
    • candidate 1: ‘operator==(int, int)’ (built-in)
      • no known conversion for argument 1 from ‘handle’ to ‘int’

这就是 explicit 的意义:这个接口在它专属的那一个上下文里可用, 而在其它地方都是隐形的。这些诊断本身就是特性。

4. 两个可以被测量出来的 bug

现在去掉 explicit,并且换个方向去掉: 让类型能转换成指针,再让两个数值类型都能转换成 int。 两次编译都成功,两个程序都是错的。


#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>
#include <cstddef>

struct cpath /* implicit conversion to the bytes of the path */
{
	char const *s{};
	operator char const *() const noexcept { return s; }
};

struct bytes /* implicit conversion to an int */
{
	int v{};
	operator int() const noexcept { return v; }
};

struct items /* a different quantity, the same implicit conversion */
{
	int v{};
	operator int() const noexcept { return v; }
};

int main()
{
	try
	{
		char buffer[16]{}; /* the same ten bytes as the literal, in a buffer we own */
		for (::std::size_t i{}; i != 10; ++i)
		{
			buffer[i] = "/etc/hosts"[i];
		}
		cpath p{buffer};

		::fast_io::io::println("p == \"/etc/hosts\"  ", ::fast_io::mnp::boolalpha(p == "/etc/hosts"));

		bytes b{100};
		items it{3};
		::fast_io::io::println("b + it            ", b + it);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

p == "/etc/hosts"  false
b + it            103

第一行。p 和那个字面量装着同样的十个字节, 而 == 回答 false, 因为有了这个转换,p == "/etc/hosts" 根本就不是路径之间的比较: 它是两个 char const * 的内建比较,也就是两个地址之间的比较。 没有警告、没有诊断、测试也不会失败 —— 除非那个测试把路径和"恰好是同一个对象的字面量"拿来比。

第二行。100 字节加 3 个项目等于 103,而类型系统无话可说, 因为两个类型都自愿变成 int。 这正是 枚举类 的强类型要防住的错误,现在它从转换运算符这扇后门又回来了。

这两个 bug 都不稀奇。它们距离"评审会通过的代码"都只差一个被删掉的词, 这正是本章要把这个词要回来的原因。

5. 两个转换,其中一个多余

一个类型同时有 operator bool 和 operator int, 在调用点并不会产生歧义错误;编译器直接挑一个。这个程序把它的选择打印出来:


#include <fast_io.h>

struct flag
{
	bool b{};
	operator bool() const noexcept { return b; }
	operator int() const noexcept { return static_cast<int>(b); }
};

int main()
{
	try
	{
		flag x{true};
		::fast_io::io::println("x == 1  ", ::fast_io::mnp::boolalpha(x == 1));
		::fast_io::io::println("x == 2  ", ::fast_io::mnp::boolalpha(x == 2));
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

x == 1  true
x == 2  false

x == 2 回答 false,只有当编译器是通过 operator int 走到这个比较的(也就是 1 == 2)才可能; 如果走的是 operator bool,那会是 true == true, 答案就该是 true。所以第二个比较的含义是作者从没写过的, 而语言里没有任何东西报告这次替换。 一个类型一旦有不止一种"变成别的东西"的方式, 每一个提到它的表达式都得靠人重新读一遍。

6. 用命名函数走出去

修法不是"把这个转换改成 explicit",而是"不要有这个转换"。 给类型命名访问器,让比较发生在"知道怎么比较内容"的类型上。 同一个程序,删掉转换之后:


#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>
#include <cstddef>

struct path /* no conversion operator: one named way out */
{
	char const *s{};
	::std::size_t n{};

	[[nodiscard]] ::fast_io::string_view view() const noexcept
	{
		return ::fast_io::string_view{s, n};
	}
};

struct byte_count /* a count of bytes, not a number */
{
	::std::uint_least64_t n{};

	[[nodiscard]] ::std::uint_least64_t count() const noexcept
	{
		return n;
	}
};

int main()
{
	try
	{
		char buffer[16]{};
		for (::std::size_t i{}; i != 10; ++i)
		{
			buffer[i] = "/etc/hosts"[i];
		}

		path p{buffer, 10};
		::fast_io::io::println("p.view() == \"/etc/hosts\"  ",
							   ::fast_io::mnp::boolalpha(p.view() == ::fast_io::string_view{"/etc/hosts"}));

		byte_count b{100};
		::fast_io::io::println("b.count()                ", b.count());

		::fast_io::string a{::fast_io::string_view{"abc"}};
		::fast_io::string c{::fast_io::string_view{"abc"}};
		::fast_io::io::println("a == c                   ", ::fast_io::mnp::boolalpha(a == c));
		::fast_io::io::println("a == \"abc\"               ", ::fast_io::mnp::boolalpha(a == "abc"));
		::fast_io::io::println("a.size()                 ", a.size());
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

p.view() == "/etc/hosts"  true
b.count()                100
a == c                   true
a == "abc"               true
a.size()                 3

现在每一行都是真的,而且每一行都说明了它在做什么。 p.view() 返回一个 string_view, 那种类型的 == 比较字节,所以这个比较是关于路径的。 b.count() 返回一个数,而 b + 1 现在会是编译错误,而不是 101:


struct byte_count
{
	unsigned n{};
	unsigned count() const noexcept { return n; }
};

int f(byte_count b) noexcept
{
	return b + 1;
}

e2.cc: In function ‘int f(byte_count)’:
e2.cc:9:18: error: no match for ‘operator+’ (operand types are ‘byte_count’ and ‘int’)
    9 |         return b + 1;
      |                ~ ^ ~
      |                |   |
      |                |   int
      |                byte_count

那个错误就是修复。错误从运行期(在那里它打印 103) 搬到了编译期(在那里它打印文件名和行号)。

7. fast_io 是怎么做的

::fast_io::string 根本没有转换运算符:没有 operator char const *,没有 operator ::fast_io::string_view。 要字节就调 c_str() 或 data();要长度就调 size()(include/fast_io_dsal/impl/string.h,第 246 行和第 267 行)。 它也没有 operator+,理由见 第17.2章 的测量。

与此同时,比较却不需要任何转换就能用, 因为那些重载是照着"本来就知道怎么比较字节"的类型写的:


inline constexpr bool operator==(::fast_io::containers::basic_string<chtype, allocator1> const &lhs, ::fast_io::containers::basic_string<chtype, allocator2> const &rhs) noexcept
inline constexpr bool operator==(::fast_io::containers::basic_string<chtype, allocator1> const &lhs, ::fast_io::containers::basic_string_view<chtype> rhs) noexcept
inline constexpr bool operator==(::fast_io::containers::basic_string<char_type, allocator1> a, char_type const (&buffer)[n]) noexcept

三个重载,三个决定,只在一个地方做一次: 两个字符串按内容比较,字符串和视图按内容比较,字符串和字面量按内容比较 —— 最后一个通过引用数组,所以字面量永远不会退化成指针, 上面那个指针比较的 bug 也就无法从一个 ::fast_io::string 走进去。 这就是"类型拒绝被转换"时,一个比较运算符该有的样子: 库必须把三种情况写下来,而不是让编译器挑一个内建运算符然后祈祷。

序关系是同样的形状:字符串/字符串、字符串/视图、字符串/字面量都提供了 operator<=>,返回的是这些比较本来就具有的类别 (第17.4章)。

8. 转换构造函数:同一个决定,反着做

单参数构造函数是一个朝着反方向的转换运算符:它决定什么能变成这个类型。 这里的默认值取向和内建的相反,理由却相同:


struct metres
{
	double m{};

	explicit metres(double value) noexcept : m{value} {} /* you must say what unit you mean */
};

struct milliseconds
{
	unsigned long long ms{};

	explicit milliseconds(unsigned long long value) noexcept : ms{value} {}
};

[[nodiscard]] milliseconds from_seconds(unsigned long long s) noexcept /* a name for a conversion that has arithmetic in it */
{
	return milliseconds{s * 1000uz};
}

explicit metres(3.0) 说明了它转换的是什么; metres m = 3.0; 没有说明,因此被拒绝。 当转换里含有规则 —— 单位、舍入、失效方式 —— 就给这个转换一个函数名, 因为规则就住在名字里。 from_seconds 不可能被误认为"给时长赋一个数", 而那正是时长类型上的 operator int 所邀请的错误 (第13.6章)。

模板形式同理。模板的转换运算符


template <typename T>
operator T() const noexcept;   /* every overload set in the program now considers this */

是最难推理的一种转换,因为它让这个对象成为程序里每一个调用、每一种类型下的候选。 如果你非有不可,就把它标成 explicit。 如果能不拥有它,就别拥有它 —— 本章的姊妹页 第17.1章 对一般的模板运算符给的是同样的建议。

9. 经验法则