在隐式类型转换中,我们介绍了类型转换以及隐式类型转换的概念。如果存在某种转换,编译器会在需要时将一种类型的值隐式转换为另一种类型的值。
这使得我们可以做如下操作:
#include <iostream>
void printDouble(double d) // 参数为 double 类型
{
std::cout << d;
}
int main()
{
printDouble(5); // 我们传递了一个 int 类型的参数
return 0;
}
在上述示例中,printDouble
函数的参数类型为 double
,但我们传递了一个 int
类型的参数。由于参数类型和参数类型不匹配,编译器会检查是否可以将参数的类型隐式转换为参数的类型。在这种情况下,根据数值转换规则,int
类型的值 5 将被转换为 double
类型的值 5.0。由于我们是通过值传递参数,参数 d
将使用该值进行拷贝初始化。
用户定义的转换
现在考虑以下类似的示例:
#include <iostream>
class Foo
{
private:
int m_x{};
public:
Foo(int x)
: m_x{ x }
{
}
int getX() const { return m_x; }
};
void printFoo(Foo f) // 参数为 Foo 类型
{
std::cout << f.getX();
}
int main()
{
printFoo(5); // 我们传递了一个 int 类型的参数
return 0;
}
在这个版本中,printFoo
的参数类型为 Foo
,但我们传递了一个 int
类型的参数。由于这些类型不匹配,编译器会尝试将 int
类型的值 5 隐式转换为 Foo
对象,以便调用该函数。
与第一个示例不同的是,参数类型和参数类型都不是基本类型(因此不能使用内置的数值提升/转换规则进行转换),在这种情况下,其中一种类型是程序定义的类型。C++ 标准没有具体规则来告诉编译器如何将值转换为(或从)程序定义的类型。
相反,编译器会查找我们是否定义了某些函数,以便它能够执行这样的转换。这种函数称为用户定义的转换。
转换构造函数
在上述示例中,编译器会找到一个函数,使其能够将 int
类型的值 5 转换为 Foo
对象。该函数是 Foo(int)
构造函数。
到目前为止,我们通常使用构造函数来显式构造对象:
Foo x { 5 }; // 显式将 `int` 类型的值 5 转换为 `Foo`
思考一下这做了什么:我们提供了一个 int
类型的值(5),并得到了一个 Foo
对象作为返回。
在函数调用的上下文中,我们正在解决相同的问题:
printFoo(5); // 隐式将 `int` 类型的值 5 转换为 `Foo`
我们提供了一个 int
类型的值(5),并且我们希望得到一个 Foo
对象作为返回。Foo(int)
构造函数正是为此而设计的!
因此,在这种情况下,当调用 printFoo(5)
时,参数 f
将使用 Foo(int)
构造函数和参数 5 进行拷贝初始化!
顺便说一下……
在 C++17 之前,当调用 printFoo(5)
时,5 会隐式转换为一个临时 Foo
,使用 Foo(int)
构造函数。然后,这个临时 Foo
会被拷贝构造到参数 f
中。
从 C++17 开始,拷贝被强制省略。参数 f
将使用值 5 进行拷贝初始化,而无需调用拷贝构造函数(即使拷贝构造函数被删除,它也能正常工作)。
可以用于执行隐式转换的构造函数称为转换构造函数。默认情况下,所有构造函数都是转换构造函数。
只允许应用一个用户定义的转换
现在考虑以下示例:
#include <iostream>
#include <string>
#include <string_view>
class Employee
{
private:
std::string m_name{};
public:
Employee(std::string_view name)
: m_name{ name }
{
}
const std::string& getName() const { return m_name; }
};
void printEmployee(Employee e) // 参数为 Employee 类型
{
std::cout << e.getName();
}
int main()
{
printEmployee("Joe"); // 我们传递了一个 C 风格的字符串字面量作为参数
return 0;
}
在这个版本中,我们将 Foo
类替换为 Employee
类。printEmployee
的参数类型为 Employee
,而我们传递了一个 C 风格的字符串字面量。并且我们有一个转换构造函数:Employee(std::string_view)
。
你可能会惊讶地发现,这个版本无法编译。原因很简单:只允许应用一个用户定义的转换来执行隐式转换,而这个示例需要两个。首先,我们的 C 风格字符串字面量需要被转换为 std::string_view
(使用 std::string_view
的转换构造函数),然后我们的 std::string_view
需要被转换为 Employee
(使用 Employee(std::string_view)
转换构造函数)。
有两种方法可以使这个示例工作:
使用 std::string_view
字面量:
int main()
{
using namespace std::literals;
printEmployee("Joe"sv); // 现在是一个 `std::string_view` 字面量
return 0;
}
这是因为现在只需要一个用户定义的转换(从 std::string_view
到 Employee
)。
显式构造一个 Employee
,而不是隐式创建一个:
int main()
{
printEmployee(Employee{ "Joe" });
return 0;
}
这也有效,因为现在只需要一个用户定义的转换(从字符串字面量到用于初始化 Employee
对象的 std::string_view
)。将我们显式构造的 Employee
对象传递给函数不需要发生第二次转换。
这个后者的示例引出了一个有用的技巧:将隐式转换轻松转换为显式定义是很容易的。我们稍后会看到更多这样的示例。
关键见解
通过使用直接列表初始化(或直接初始化),可以轻松地将隐式转换转换为显式定义。
转换构造函数出错时
考虑以下程序:
#include <iostream>
class Dollars
{
private:
int m_dollars{};
public:
Dollars(int d)
: m_dollars{ d }
{
}
int getDollars() const { return m_dollars; }
};
void print(Dollars d)
{
std::cout << "$" << d.getDollars();
}
int main()
{
print(5);
return 0;
}
当我们调用 print(5)
时,Dollars(int)
转换构造函数将被用来将 5 转换为一个 Dollars
对象。因此,这个程序会打印:
$5
尽管这可能是调用者的意图,但由于调用者没有提供任何表明这正是他们真正想要的迹象,因此很难确定。完全有可能调用者以为这会打印 5,并没有预料到编译器会默默地、隐式地将我们的 int
值转换为 Dollars
对象,以便满足这个函数调用。
虽然这个示例很简单,但在更大、更复杂的程序中,很容易被编译器执行的某些隐式转换所惊讶,而这些转换是你没有预料到的,从而导致运行时出现意外行为。
如果我们的 print(Dollars)
函数只能用 Dollars
对象调用,而不是任何可以隐式转换为 Dollars
的值(尤其是像 int
这样的基本类型),那会更好。这将减少意外错误的可能性。
explicit
关键字
为了解决此类问题,我们可以使用 explicit
关键字告诉编译器某个构造函数不应被用作转换构造函数。
将构造函数声明为 explicit
有两个显著的后果:
- 显式构造函数不能用于拷贝初始化或拷贝列表初始化。
- 显式构造函数不能用于隐式转换(因为这使用了拷贝初始化或拷贝列表初始化)。
让我们将前面示例中的 Dollars(int)
构造函数更新为显式构造函数:
#include <iostream>
class Dollars
{
private:
int m_dollars{};
public:
explicit Dollars(int d) //