C++ Tricks (2)


关于转换构造函数,发现李教授书上还有有一些错误的,这里做一下整理。

转换构造函数

不以说明符explicit声明的构造函数被称为转换构造函数(Converting Constructor)。

特别地,在C++11标准前,转换构造函数要求以单个参数调用。例如:

class Foo {
public:
	int x;
	Foo();					// since C++11, also a Defualt Constructor
    Foo(int x);
	Foo(int x, int y);		// since C++11
};

若函数有说明符explicit声明,则不为转换构造函数(部分资料称之为显式构造函数,并将转换构造函数称为非显式构造函数)。例如:

class Bar {
public:
	int x;
	explicit Bar(int x);
	explicit Bar();
	explicit Bar(int x, int y);
};

对应于上述类Foo,以下构造方式均可进行。

Foo f1;				//默认初始化
Foo f2(2);			//直接初始化
Foo f3 = 3;			//复制初始化
Foo f4{4, 5};		//直接初始化(C++11后为列表初始化)
Foo f5 = {4, 5};	//复制初始化(C++11后为列表初始化)
Foo f6 = (Foo)1;	//直接初始化
Foo f7 = Foo(1);	//直接初始化

隐式转换

当一个类存在转换构造函数时,该类就存在了隐式转换的方式。

例如,给出DotLine的简单类:

struct Dot {
    double x;
    double y;
    Dot() = default;
    Dot(double x, double y) {
        this->x = x;
        this->y = y;
    }
};

struct Line {
    Dot x;
    Dot y;
    Line() = default;
    Line(Dot x, Dot y) {
        this->x = x;
        this->y = y;
    }
    void Print() {
        std::cout << "(" << x.x << "," << x.y << ")"
            	  << "(" << y.x << "," << y.y << ")";
    }
};

若我们初始化一条(1,2)​,​(3,4)为端点的直线(?这句话有点怪,能懂就行),有以下两种方式。

  • 显式转换:Line l(Dot(1,2),Dot(3,4));

  • 隐式转换:Line l = {{1,2},{3,4}};

但当一个构造函数的参数对应的类型转换是没有明确意义的,此时隐式转换(转换构造)的发生不是我们希望的,此时就需要用explicit说明符取消,避免类型转换被误用。

例如对如下字符串类:

struct String {
	String(int n);
	...
}

此处我们想用String:String(int)来作为字符串长度的开辟方式,但是就会有以下神秘的现象

String s1(10);	//Ok,是我们想要的形式
String s2 = 10;	//OK, 隐式转换

此处s2的构造就显得很奇怪,因此此处字符串类传int参数的构造函数不应该设置为转换构造函数,需要加上explicit说明符,改后如下:

struct String {
	explicit String(int n);
	...
}
int main() {
    String s1(10);		//Ok,是我们想要的形式
	//String s2 = 10;	//编译不通过,不能隐式转换
}

🤔 若成员函数在类内声明,类外定义,则explicit关键词应当写在类定义中的函数原型声明处。

复制初始化

以上所提到的语法中有如Foo f = other的形式,它是复制初始化(Copy-Initialization)的一种,在复制初始化过程中,虽然语句中有=运算符,但并不调用operator=函数,而是调用构造函数进行初始化。

具体地,复制初始化在下列情况进行:

  • 当声明非引用类型 T 的具名变量,带有以等号后随一个表达式所构成的初始化器时;
  • 当声明标量类型T的具名变量,带有以等号后随一个花括号环绕的表达式所构成的初始化器时(注意:从 C++11 开始,这被归类为列表初始化,且不允许窄化转换);
  • 当按值传递参数到函数时;
  • 当从具有返回值的函数返回时;
  • 当按值抛出或捕获异常时;
  • 作为聚合初始化的一部分,以初始化提供了初始化器的每个元素;

我们给出最一般的形式T object = other

则如果 T是类类型,且otherT或是从T派生的类,则检测 T 的转换构造函数,并由重载决议选择最佳匹配。然后调用构造函数以初始化该对象。

如果T是类类型,且other不是T或是从T派生的类;或如果T是非类类型,other是类类型,则检测能够从other类型转换到T的隐式转换(用户自定义),并通过重载决议选择最佳者。该转换的结果如果使用转换构造函数则为纯右值临时量,且随后被用于直接初始化该对象。

Tother均不是类类型,则调用标准转换。

来一点具体的:

struct Foo {
	Foo(int x);
    A(const A &other);
    A(const A &&other);
};

Foo func(A a) { return a; }

int main() {
    A a1 = 1;			//转换构造(other为整型)
    A a2 = a1;			//复制构造(other为左值)
    A a3 = func(a1);	//移动构造(other为右值)
    return 0;
}

注意这里转换构造,实质上是先从1转换构造为一个纯右值临时量,然后初始化a1,但过程中发现并没有调用移动构造函数,根据cppreference的解释是最后的直接初始化会被优化掉,而直接将转换结果构造于分配给目标对象的内存之中,但即便不会使用,也要求适合的构造函数(移动或复制)是可以访问的。

而从编译器角度来看会更加容易理解,编写简单类:

struct Foo {
    Foo(int x);
};

int main() {
    Foo f1 = Foo(1);
    Foo f2 = 1;
    return 0;
}

编译后的汇编代码及其对应关系为:

Foo类汇编代码

不难看出,从编译器角度上,Foo f1 = Foo(1)Foo f2 = 1是一样的。

🤔 更深入的可以看一下我在Stack Overflow上的自问自答


Author: Luminolt
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Luminolt !
  TOC