14-16 转换构造函数与显式关键字
在第10.1节——隐式类型转换中,我们介绍了类型转换和隐式类型转换的概念。当存在转换时,编译器会根据需要将一种类型的值隐式转换为另一种类型的值。
这使我们能够实现如下操作:
#include <iostream>
void printDouble(double d) // has a double parameter
{
std::cout << d;
}
int main()
{
printDouble(5); // we're supplying an int argument
return 0;
}

在上例中,printDouble函数的参数类型为double,但我们传入的参数类型却是int。由于参数类型与实际传入的参数类型不匹配,编译器将尝试判断是否能将参数类型隐式转换为函数参数类型。根据数值转换规则,整型值 5 将转换为双精度值 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) // has a Foo parameter
{
std::cout << f.getX();
}
int main()
{
printFoo(5); // we're supplying an int argument
return 0;
}
在此版本中,printFoo 函数接受 Foo 参数,但我们传入的是 int 类型的参数。由于类型不匹配,编译器将尝试隐式将 int 值 5 转换为 Foo 对象,以便函数能够被调用。
与第一个示例不同,该示例中参数和实参类型均为基本类型(因此可通过内置数值提升/转换规则进行转换),而本例中其中一种类型是程序定义类型。C++标准并未规定编译器如何将值转换为(或从)程序定义类型。
取而代之的是,编译器会检查我们是否定义了可用于执行此类转换的函数。此类函数称为用户定义转换user-defined conversion。
转换构造函数
在上例中,编译器会找到一个函数,使其能够将整型值 5 转换为 Foo 对象。该函数即为 Foo(int) 构造函数。
至此,我们通常使用构造函数显式构造对象:
Foo x { 5 }; // Explicitly convert int value 5 to a Foo
想想这会产生什么效果:我们提供了一个整数值(5),却得到了一个 Foo 对象作为返回值。
在函数调用的上下文中,我们试图解决同样的问题:
printFoo(5); // Implicitly convert int value 5 into a Foo
我们提供了一个整型值(5),期望得到一个Foo对象作为返回值。Foo(int)构造函数正是为此而设计!
因此在此情况下,当调用printFoo(5)时,参数f会通过Foo(int)构造函数使用5作为参数进行复制初始化!
顺带一提……:
在C++17之前,调用printFoo(5)时,5会通过Foo(int)构造函数隐式转换为临时Foo对象。该临时对象随后被复制构造为参数f。在C++17及后续版本中,该复制操作被强制省略。参数f直接通过值5进行复制初始化,无需调用复制构造函数(即使复制构造函数被删除仍能正常工作)。
可用于执行隐式转换的构造函数称为转换构造函数converting constructor。默认情况下,所有构造函数都是转换构造函数。
仅可应用一个用户定义的转换
现在考虑以下示例:
#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) // has an Employee parameter
{
std::cout << e.getName();
}
int main()
{
printEmployee("Joe"); // we're supplying an string literal argument
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); // now a std::string_view literal
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,却未料到编译器会默默地将我们的整型值隐式转换为Dollars对象,以满足函数调用需求。
虽然这个例子很简单,但在更大更复杂的程序中,编译器执行某些你未预期的隐式转换,导致运行时出现意外行为的情况相当常见。
更理想的做法是限制print(Dollars)函数仅接受Dollars对象调用,而非允许任何可隐式转换为Dollars的值(尤其是int这类基本类型)。此举能有效降低无意中引发错误的可能性。
显式关键字
为解决此类问题,可使用显式关键字告知编译器:构造函数不应作为转换构造函数使用。
将构造函数设为显式具有两项显著影响:
- 显式构造函数无法用于执行复制初始化或复制列表初始化。
- 显式构造函数无法用于隐式转换(因其依赖复制初始化或复制列表初始化)。
让我们将前例中的 Dollars(int) 构造函数更新为显式构造函数:
#include <iostream>
class Dollars
{
private:
int m_dollars{};
public:
explicit Dollars(int d) // now explicit
: m_dollars{ d }
{
}
int getDollars() const { return m_dollars; }
};
void print(Dollars d)
{
std::cout << "$" << d.getDollars();
}
int main()
{
print(5); // compilation error because Dollars(int) is explicit
return 0;
}

由于编译器无法再将Dollars(int)用作转换构造函数,因此无法找到将5转换为Dollars的方法。最终将导致编译错误。
对于声明(在类内部)与定义(在类外部)分离的构造函数,显式关键字仅用于声明部分。
显式构造函数可用于直接初始化和直接列表初始化
显式构造函数仍可用于直接初始化和直接列表初始化:
// Assume Dollars(int) is explicit
int main()
{
Dollars d1(5); // ok
Dollars d2{5}; // ok
}
现在,让我们回到之前的示例,当时我们显式声明了Dollars(int)构造函数,因此以下代码会引发编译错误:
print(5); // compilation error because Dollars(int) is explicit
如果我们实际想用整数值5调用print(),但构造函数是显式的怎么办?解决方法很简单:与其让编译器将5隐式转换为可传递给print()的Dollars对象,不如我们自己显式定义Dollars对象:
print(Dollars{5}); // ok: explicitly create a Dollars
这是允许的,因为我们仍可使用显式构造函数对对象进行列表初始化。由于我们已显式构造了Dollars对象,参数类型与构造函数参数类型匹配,因此无需进行类型转换!
这不仅能编译运行,更能清晰传达我们的意图——明确表明本意是使用Dollars对象调用该函数。
需注意static_cast返回的是直接初始化的对象,因此在转换过程中会考虑显式构造函数:
print(static_cast<Dollars>(5)); // ok: static_cast will use explicit constructors
按值返回与显式构造函数
当函数返回值与函数返回类型不匹配时,将发生隐式转换。与按值传递类似,此类转换无法使用显式构造函数。
以下程序展示了多种返回值变体及其结果:
#include <iostream>
class Foo
{
public:
explicit Foo() // note: explicit (just for sake of example)
{
}
explicit Foo(int x) // note: explicit
{
}
};
Foo getFoo()
{
// explicit Foo() cases
return Foo{ }; // ok
return { }; // error: can't implicitly convert initializer list to Foo
// explicit Foo(int) cases
return 5; // error: can't implicitly convert int to Foo
return Foo{ 5 }; // ok
return { 5 }; // error: can't implicitly convert initializer list to Foo
}
int main()
{
return 0;
}



或许令人惊讶的是,return { 5 } 被视为一种转换。
显式使用的最佳实践
现代最佳实践是默认将任何接受单个参数的构造函数显式化。这包括具有多个参数的构造函数,其中大部分或全部参数具有默认值。此举将禁止编译器使用该构造函数进行隐式转换。若需要隐式转换,编译器仅会考虑非显式构造函数。若找不到可执行转换的非显式构造函数,编译器将报错。
若特定场景确实需要隐式转换,可通过直接列表初始化将隐式转换显式化,操作非常简单。
以下情况不应should not显式化:
- 复制(及移动)构造函数(因其不执行转换操作)。
通常不显式化are typically not的情况:
- 无参数的默认构造函数(因其仅用于将{}转换为默认对象,通常无需限制)。
- 仅接受多个参数的构造函数(因这类构造函数通常不作为转换候选项)。
但若需要,可将上述构造函数标记为显式,以阻止空参数列表和多参数列表的隐式转换。
通常应should usually显式定义以下构造函数:
- 接受单个参数的构造函数。
某些情况下,将单参数构造函数设为显式确实合理。当同时满足以下条件时:
- 构造对象与参数值语义等价
- 转换操作高效
例如std::string_view接受C风格字符串参数的构造函数未显式标记,因为通常允许C风格字符串被视为std::string_view。相反地,std::string 构造函数若接受 std::string_view 则标记为显式构造函数,因为虽然 std::string 值与 std::string_view 值在语义上等价,但构造 std::string 的效率较低。
最佳实践:
默认将所有接受单个参数的构造函数设为显式。若类型间隐式转换在语义等价且性能可接受的情况下,可考虑取消构造函数的显式标记。复制构造函数和移动构造函数无需显式标记,因其不涉及类型转换。

浙公网安备 33010602011771号