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;
}

image

在上例中,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;
}

image

在此版本中,我们将 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;
}

image

此方法之所以可行,是因为现在只需进行一次用户定义的转换(从 std::string_view 到 Employee)。

  1. 显式构造 Employee 对象而非隐式创建:
int main()
{
    printEmployee(Employee{ "Joe" });

    return 0;
}

image

这同样有效,因为现在只需进行一次用户定义的转换(从字符串字面量转换为用于初始化 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 对象。因此,该程序输出:

image

尽管这可能是调用者的意图,但由于调用者并未提供任何明确指示,因此难以断定其真实意图。完全有可能调用者以为这会输出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;
}

image

由于编译器无法再将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;
}

image
image
image

或许令人惊讶的是,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 的效率较低。

最佳实践:
默认将所有接受单个参数的构造函数设为显式。若类型间隐式转换在语义等价且性能可接受的情况下,可考虑取消构造函数的显式标记。

复制构造函数和移动构造函数无需显式标记,因其不涉及类型转换。

posted @ 2025-12-30 18:05  游翔  阅读(10)  评论(0)    收藏  举报