14.16 — 转换构造函数和 explicit 关键字

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 类型的实参。因为参数类型和实参类型不匹配,编译器将查看是否可以隐式地将实参类型转换为参数类型。在这种情况下,使用数字转换规则,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) // 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++ 标准没有具体的规则告诉编译器如何将值转换为(或从)程序定义的类型。

相反,编译器会查看我们是否定义了一些它可以用来执行此类转换的函数。这样的函数称为用户定义的转换

转换构造函数

在上面的示例中,编译器将找到一个函数,允许它将 int 值 5 转换为 Foo 对象。该函数是 Foo(int) 构造函数。

到目前为止,我们通常使用构造函数来显式构造对象。

    Foo x { 5 }; // Explicitly convert int value 5 to a Foo

思考一下这做了什么:我们提供一个 int 值(5),并返回一个 Foo 对象。

在函数调用的上下文中,我们正在尝试解决相同的问题:

    printFoo(5); // Implicitly convert int value 5 into a Foo

我们提供一个 int 值(5),并且我们想要一个 Foo 对象作为回报。Foo(int) 构造函数正是为此而设计的!

因此,在这种情况下,当调用 printFoo(5) 时,参数 f 使用 Foo(int) 构造函数,以 5 作为实参进行拷贝初始化!

题外话…

在 C++17 之前,当调用 printFoo(5) 时,5 会使用 Foo(int) 构造函数隐式转换为一个临时 Foo 对象。然后,这个临时 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) // 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) 转换构造函数)。

有两种方法可以使此示例正常工作:

  1. 使用 std::string_view 字面量
int main()
{
    using namespace std::literals;
    printEmployee( "Joe"sv); // now a std::string_view literal

    return 0;
}

这之所以可行,是因为现在只需要一个用户定义的转换(从 std::string_viewEmployee)。

  1. 显式构造 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) // 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 的方法。因此,它将生成一个编译错误。

对于具有单独声明(在类内部)和定义(在类外部)的构造函数,explicit 关键字仅用于声明。

显式构造函数可用于直接初始化和直接列表初始化

显式构造函数仍然可以用于直接初始化和直接列表初始化

// 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

如果我们确实想用 int5 调用 print(),但构造函数是显式的,该怎么办?解决方法很简单:我们可以显式地定义 Dollars 对象,而不是让编译器隐式地将 5 转换为可以传递给 print()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 } 被认为是一种转换。

使用 explicit 的最佳实践

现代的最佳实践是默认将接受单个参数的任何构造函数声明为 explicit。这包括具有多个参数(其中大多数或所有参数都有默认值)的构造函数。这将阻止编译器将该构造函数用于隐式转换。如果需要隐式转换,则只会考虑非显式构造函数。如果找不到执行转换的非显式构造函数,编译器将报错。

如果在特定情况下确实需要这种转换,那么通过直接列表初始化将隐式转换转换为显式定义是微不足道的。

以下不应显式声明:

  • 拷贝(和移动)构造函数(因为它们不执行转换)。

以下通常不显式声明:

  • 没有参数的默认构造函数(因为这些构造函数只用于将 {} 转换为默认对象,这通常是我们不需要限制的)。
  • 只接受多个参数的构造函数(因为这些构造函数通常无论如何都不是转换的候选者)。

但是,如果你愿意,可以将上述标记为 explicit,以防止使用空和多参数列表进行隐式转换。

以下通常应显式声明:

  • 接受单个参数的构造函数。

在某些情况下,将单参数构造函数设为非显式是有意义的。当以下所有条件都为真时,这会很有用:

  • 构造的对象在语义上等同于实参值。
  • 转换性能良好。

例如,接受 C 风格字符串参数的 std::string_view 构造函数不是显式的,因为不太可能出现我们不愿意将 C 风格字符串视为 std::string_view 的情况。相反,接受 std::string_viewstd::string 构造函数被标记为显式的,因为虽然 std::string 值在语义上等同于 std::string_view 值,但构造 std::string 的性能不佳。

最佳实践

默认情况下,将任何接受单个参数的构造函数声明为 explicit。如果类型之间的隐式转换在语义上等价且性能良好,你可以考虑将构造函数声明为非 explicit

不要将拷贝或移动构造函数声明为显式,因为它们不执行转换。

guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
222 条评论
最新
最早 最多投票
内联反馈
查看所有评论