转换构造函数和 explicit 关键字

在隐式类型转换中,我们介绍了类型转换以及隐式类型转换的概念。如果存在某种转换,编译器会在需要时将一种类型的值隐式转换为另一种类型的值。

这使得我们可以做如下操作:

#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_viewEmployee)。

显式构造一个 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) //

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"