12.3. 类作用域
每个类都定义了自己的新作用域和唯一的类型。在类的定义体内声明类成员,将成员名引入类的作用域。两个不同的类具有两个的类作用域。
<Note>:即使两个类具有完全相同的成员列表,它们也是不同的类型。每个类的成员不同于任何其他类(或任何其他作用域)的成员。
例如
class First { public: int memi; double memd; }; class Second { public: int memi; double memd; }; First obj1; Second obj2 = obj1; // error: obj1 and obj2 have different types使用类的成员
在类作用域之外,成员只能通过对象或指针分别使用成员访问操作符 . 或 -> 来访问。这些操作符左边的操作数分别是一个类对象或指向类对象的指针。跟在操作符后面的成员名字必须在相关联的类的作用域中声明:
Class obj; // Class is some class type Class *ptr = &obj; // member is a data member of that class ptr->member; // fetches member from the object to which ptr points obj.member; // fetches member from the object named obj // memfcn is a function member of that class ptr->memfcn(); // runs memfcn on the object to which ptr points obj.memfcn(); // runs memfcn on the object named obj一些成员使用成员访问操作符来访问,另一些直接通过类使用作用域操作符(::)来访问。一般的数据或函数成员必须通过对象来访问。定义类型的成员,如 Screen::index,使用作用域操作符来访问。
作用域与成员定义
尽管成员是在类的定义体之外定义的,但成员定义就好像它们是在类的作用域中一样。回忆一下,出现在类的定义体之外的成员定义必须指明成员出现在哪个类中:
double Sales_item::avg_price() const { if (units_sold) return revenue/units_sold; else return 0; }
在这里,我们用完全限定名 Sales_item::avg_price 来指出这是类 Sales_item 作用域中的 avg_price 成员的定义。一旦看到成员的完全限定名,就知道该定义是在类作用域中。因为该定义是在类作用域中,所以我们可以引用 revenue 或 units_sold,而不必写 this->revenue 或 this->units_sold。
形参表和函数体处于类作用域中
在定义于类外部的成员函数中,形参表和成员函数体都出现在成员名之后。这些都是在类作用域中定义,所以可以不用限定而引用其他成员。例如,类 Screen 中 get 的二形参版本的定义:
char Screen::get(index r, index c) const { index row = r * width; // compute the row location return contents[row + c]; // offset by c to fetch specified character }
该函数用 Screen 内定义的 index 类型来指定其形参类型。因为形参表是在 Screen 类的作用域内,所以不必指明我们想要的是 Screen::index。
我们想要的是定义在当前类作用域中的,这是隐含的。同样,使用 index、width 和 contents 时指的都是 Screen 类中声明的名字。
函数返回类型不一定在类作用域中
与形参类型相比,返回类型出现在成员名字前面。如果函数在类定义体之外定义,则用于返回类型的名字在类作用域之外。
如果返回类型使用由类定义的类型,则必须使用完全限定名。例如,考虑 get_cursor 函数:
class Screen { public: typedef std::string::size_type index; index get_cursor() const; }; inline Screen::index Screen::get_cursor() const { return cursor; }该函数的返回类型是 index,这是在 Screen 类内部定义的一个类型名。如果在类定义体之外定义 get_cursor,则在函数名被处理之前,代码在不在类作用域内。当看到返回类型时,其名字是在类作用域之外使用。必须用完全限定的类型名 Screen::index 来指定所需要的 index 是在类 Screen 中定义的名字。
12.3.1. 类作用域中的名字查找
迄今为止,在我们所编写的程序中,名字查找(寻找与给定的名字使用相匹配的声明的过程)是相对直接的。
首先,在使用该名字的块中查找名字的声明。只考虑在该项使用之前声明的名字。
如果找不到该名字,则在包围的作用域中查找。
如果找不到任何声明,则程序出错。在 C++ 程序中,所有名字必须在使用之前声明。
类作用域也许表现得有点不同,但实际上遵循同一规则。可能引起混淆的是函数中名字确定的方式,而该函数是在类定义体内定义的。
<Note>:类定义实际上是在两个阶段中处理:
当然,类作用域中使用的名字并非必须是类成员名。类作用域中的名字查找也会发生在其他作用域中声明的名字 。在名字查找期间,如果类作用域中使用的名字不能确定为类成员名,则在包含该类或成员定义的作用域中查找,以便找到该名字的声明。
首先,编译成员声明;
只有在所有成员出现之后,才编译它们的定义本身。
类成员声明的名字查找
按以下方式确定在类成员的声明中用到的名字。
检查出现在名字使用之前的类成员的声明。
如果第 1 步查找不成功,则检查包含类定义的作用域中出现的声明以及出现在类定义之前的声明。
例如:
typedef double Money; class Account { public: Money balance() { return bal; } private: Money bal; // ... };
在处理 balance 函数的声明时,编译器首先在类 Account 的作用域中查找 Money 的声明。编译器只考虑出现在 Money 使用之前的声明。因为找不到任何成员声明,编译器随后在全局作用域中查找 Money 的声明。只考虑出现在类 Account 的定义之前的声明。找到全局的类型别名 Money 的声明,并将它用作函数 balance 的返回类型和数据成员 bal 的类型。
<Note>:必须在类中先定义类型名字,才能将它们用作数据成员的类型,或者成员函数的返回类型或形参类型。
编译器按照成员声明在类中出现的次序来处理它们。通常,名字必须在使用之前进行定义。而且,一旦一个名字被用作类型名,该名字就不能被重复定义:
typedef double Money; class Account { public: Money balance() { return bal; } // uses global definition of Money private: // error: cannot change meaning of Money typedef long double Money; Money bal; // ... };
类成员定义中的名字查找
按以下方式确定在成员函数的函数体中用到的名字。
首先检查成员函数局部作用域中的声明。
如果在成员函数中找不到该名字的声明,则检查对所有类成员的声明。
如果在类中找不到该名字的声明,则检查在此成员函数定义之前的作用域中出现的声明。
类成员遵循常规的块作用域名字查找
<Beware>:例示名字查找的程序经常不得不依赖一些坏习惯。下面的几个程序故意包含了坏的风格。
下面的函数使用了相同的名字来表示形参和成员,这是通常应该避免的。这样做的目的是展示如何确定名字:
// Note: This code is for illustration purposes only and reflects bad practice // It is a bad idea to use the same name for a parameter and a member int height; class Screen { public: void dummy_fcn(index height) { cursor = width * height; // which height? The parameter } private: index cursor; index height, width; };
查找 dummy_fcn 的定义中使用的名字 height 的声明时,编译器首先在该函数的局部作用域中查找。函数的局部作用域中声明了一个函数形参。dummy_fcn 的函数体中使用的名字 height 指的就是这个形参声明。
在本例中,height 形参屏蔽名为 height 的成员。
<Note>:尽管类的成员被屏蔽了,但仍然可以通过用类名来限定成员名或显式使用 this 指针来使用它。
如果我们想覆盖常规的查找规则,应该这样做:
// bad practice: Names local to member functions shouldn't hide member names void dummy_fcn(index height) { cursor = width * this->height; // member height // alternative way to indicate the member cursor = width * Screen::height; // member height }函数作用域之后,在类作用域中查找
如果想要使用 height 成员,更好的方式也许是为形参取一个不同的名字:
// good practice: Don't use member name for a parameter or other local variable void dummy_fcn(index ht) { cursor = width * height; // member height }现在当编译器查找名字 height 时,它将不会在函数内查找该名字。编译器接着会在 Screen 类中查找。因为 height 是在成员函数内部使用,所以编译器在所有成员声明中查找。尽管 height 是先在 dummy_fcn 中使用,然后再声明,编译器还是确定这里用的是名为 height 的数据成员。类作用域之后,在外围作用域中查找
如果编译器不能在函数或类作用域中找到,就在外围作用域中查找。在本例子中,出现在 Screen 定义之前的全局作用域中声明了一个名为 height 的全局声明。然而,该对象被屏蔽了。
<Note>:
尽管全局对象被屏蔽了,但通过用全局作用域确定操作符来限定名字,仍然可以使用它。
// bad practice: Don't hide names that are needed from surrounding scopes void dummy_fcn(index height) { cursor = width * ::height;// which height? The global one }
在文件中名字的出现处确定名字
当成员定义在类定义的外部时,名字查找的第 3 步不仅要考虑在 Screen 类定义之前的全局作用域中的声明,而且要考虑在成员函数定义之前出现的全局作用域声明。例如:
class Screen { public: // ... void setHeight(index); private: index height; }; Screen::index verify(Screen::index); void Screen::setHeight(index var) { // var: refers to the parameter // height: refers to the class member // verify: refers to the global function height = verify(var); }
注意,全局函数 verify 的声明在 Screen 类定义之前是不可见的。然而,名字查找的第 3 步要考虑那些出现在成员定义之前的外围作用域声明,并找到全局函数 verify 的声明。
12.4. 构造函数
构造函数是特殊的成员函数,只要创建类类型的新对象,都要执行构造函数。构造函数的工作是保证每个对象的数据成员具有合适的初始值。
class Sales_item { public: // operations on Sales_itemobjects // default constructor needed to initialize members of built-in type Sales_item(): units_sold(0), revenue(0.0) { } private: std::string isbn; unsigned units_sold; double revenue; };
这个构造函数使用构造函数初始化列表来初始化 units_sold 和 revenue 成员。isbn 成员由 string 的默认构造函数隐式初始化为空串。
构造函数的名字与类的名字相同,并且不能指定返回类型。像其他任何函数一样,它们可以没有形参,也可以定义多个形参。
构造函数可以被重载
可以为一个类声明的构造函数的数量没有限制,只要每个构造函数的形参表是唯一的。我们如何才能知道应该定义哪个或多少个构造函数?一般而言,不同的构造函数允许用户指定不同的方式来初始化数据成员。例如,逻辑上可以通过提供两个额外的构造函数来扩展 Sales_item 类:一个允许用户提供 isbn 的初始值,另一个允许用户通过读取 istream 对象来初始化对象:
class Sales_item; // other members as before public: // added constructors to initialize from a string or an istream Sales_item(const std::string&); Sales_item(std::istream&); Sales_item(); };
实参决定使用哪个构造函数
我们的类现在定义了三个构造函数。在定义新对象时,可以使用这些构造函数中的任意一个:
// uses the default constructor: // isbn is the empty string; units_soldand revenue are 0 Sales_item empty; // specifies an explicit isbn; units_soldand revenue are 0 Sales_item Primer_3rd_Ed("0-201-82470-1"); // reads values from the standard input into isbn, units_sold, and revenue Sales_item Primer_4th_ed(cin);
用于初始化一个对象的实参类型决定使用哪个构造函数。在 empty 的定义中,没有初始化式,所以运行默认构造函数。接受一个 string 实参的构造函数用于初始化Primer_3rd_ed;接受一个 istream 引用的构造函数初始化 Primer_4th_ed。
构造函数自动执行
只要创建该类型的一个对象,编译器就运行一个构造函数:
// constructor that takes a string used to create and initialize variable Sales_item Primer_2nd_ed("0-201-54848-8"); // default constructor used to initialize unnamed object on the heap Sales_item *p = new Sales_item();
第一种情况下,运行接受一个 string 实参的构造函数,来初始化变量 Primer_2nd_ed。第二种情况下,动态分配一个新的 Sales_item 对象。假定分配成功,则通过运行默认构造函数初始化该对象。
用于 const 对象的构造函数
构造函数不能声明为 const :
class Sales_item {
public:
Sales_item() const; // error
};
const 构造函数是不必要的。创建类类型的 const 对象时,运行一个普通构造函数来初始化该 const 对象。构造函数的工作是初始化对象。不管对象是否为 const,都用一个构造函数来初始化化该对象。
12.4.1. 构造函数初始化式
与任何其他函数一样,构造函数具有名字、形参表和函数体。与其他函数不同的是,构造函数也可以包含一个构造函数初始化列表:
// recommended way to write constructors using a constructor initializer
Sales_item::Sales_item(const string &book):
isbn(book), units_sold(0), revenue(0.0) { }
构造函数初始化列表以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个数据成员后面跟一个放在圆括号中的初始化式。这个构造函数将 isbn 成员初始化为 book 形参的值,将 units_sold 和 revenue 初始化为 0。与任意的成员函数一样,构造函数可以定义在类的内部或外部。构造函数初始化只在构造函数的定义中而不是声明中指定。
<Beware>:构造函数初始化列表是许多相当有经验的 C++ 程序员都没有掌握的一个特性。
构造函数初始化列表难以理解的一个原因在于,省略初始化列表在构造函数的函数体内对数据成员赋值是合法的。例如,可以将接受一个 string 的 Sales_item 构造函数编写为:
// legal but sloppier way to write the constructor: // no constructor initializer Sales_item::Sales_item(const string &book) { isbn = book; units_sold = 0; revenue = 0.0; }
这个构造函数给类 Sales_item 的成员赋值,但没有进行显式初始化。不管是否有显式的初始化式,在执行构造函数之前,要初始化 isbn 成员。这个构造函数隐式使用默认的 string 构造函数来初始化 isbn。执行构造函数的函数体时,isbn 成员已经有值了。该值被构造函数函数体中的赋值所覆盖。
从概念上讲,可以认为构造函数分两个阶段执行:(1)初始化阶段;(2)普通的计算阶段。计算阶段由构造函数函数体中的所有语句组成。
<Note>:不管成员是否在构造函数初始化列表中显式初始化,类类型的数据成员总是在初始化阶段初始化。初始化发生在计算阶段开始之前。
在构造函数初始化列表中没有显式提及的每个成员,使用与初始化变量相同的规则来进行初始化。运行该类型的默认构造函数,来初始化类类型的数据成员。
内置或复合类型的成员的初始值依赖于对象的作用域:在局部作用域中这些成员不被初始化,而在全局作用域中它们被初始化为 0。
在本节中编写的两个 Sales_item 构造函数版本具有同样的效果:无论是在构造函数初始化列表中初始化成员,还是在构造函数函数体中对它们赋值,最终结果是相同的。
构造函数执行结束后,三个数据成员保存同样的值。
不同之外在于,使用构造函数初始化列表的版本初始化数据成员,没有定义初始化列表的构造函数版本在构造函数函数体中对数据成员赋值。
有时需要构造函数初始化列表
这个区别的重要性取决于数据成员的类型。如果没有为类成员提供初始化式,则编译器会隐式地使用成员类型的默认构造函数。如果那个类没有默认构造函数,则编译器尝试使用默认构造函数将会失败。在这种情况下,为了初始化数据成员,必须提供初始化式。
<Note>:
有些成员必须在构造函数初始化列表中进行初始化。对于这样的成员,在构造函数函数体中对它们赋值不起作用。没有默认构造函数的类类型的成员,以及 const或引用类型的成员,不管是哪种类型,都必须在构造函数初始化列表中进行初始化。
因为内置类型的成员不进行隐式初始化,所以对这些成员是进行初始化还是赋值似乎都无关紧要。除了两个例外,对非类类型的数据成员进行赋值或使用初始化式在结果和性能上都是等价的。例如,下面的构造函数是错误的:
class ConstRef { public: ConstRef(int ii); private: int i; const int ci; int &ri; }; // no explicit constructor initializer: error ri is uninitialized ConstRef::ConstRef(int ii) { // assignments: i = ii; // ok ci = ii; // error: cannot assign to a const ri = i; // assigns to ri which was not bound to an object }
记住,可以初始化 const 对象或引用类型的对象,但不能对它们赋值。在开始执行构造函数的函数体之前,要完成初始化。初始化const 或引用类型数据成员的唯一机会是构造函数初始化列表中。编写该构造函数的正确方式为
// ok: explicitly initialize reference and const members ConstRef::ConstRef(int ii): i(ii), ci(i), ri(ii) { }
建议:使用构造函数初始化列表
在许多类中,初始化和赋值严格来讲都是低效率的:数据成员可能已经被直接初始化了,还要对它进行初始化和赋值。比较率问题更重要的是,某些数据成员必须要初始化,这是一个事实。<Note>:
必须对任何 const 或引用类型成员以及没有默认构造函数的类类型的任何成员使用初始化式。
当类成员需要使用初始化列表时,通过常规地使用构造函数初始化列表,就可以避免发生编译时错误。
成员初始化的次序
每个成员在构造函数初始化列表中只能指定一次,这不会令人惊讶。毕竟,给一个成员两个初始值意味着什么?也许更令人惊讶的是,构造函数初始化列表仅指定用于初始化成员的值,并不指定这些初始化执行的次序。成员被初始化的次序就是定义成员的次序。第一个成员首先被初始化,然后是第二个,依次类推。
初始化的次序常常无关紧要。然而,如果一个成员是根据其他成员而初始化,则成员初始化的次序是至关重要的。
考虑下面的类:
class X {
int i;
int j;
public:
// run-time error: i is initialized before j
X(int val): j(val), i(j) { }
};
在这种情况下,构造函数初始化列表看起来似乎是用val 初始化 j,然后再用 j 来初始化 i。然而,i 首先被初始化。这个初始化列表的效果是用尚未初始化的 j 值来初始化 i!
如果数据成员在构造函数初始化列表中的列出次序与成员被声明的次序不同,那么有的编译器非常友好,会给出一个警告。
按照与成员声明一致的次序编写构造函数初始化列表是个好主意。此外,尽可能避免使用成员来初始化其他成员。
一般情况下,通过(重复)使用构造函数的形参而不是使用对象的数据成员,可以避免由初始化式的执行次序而引起的任何问题。例如,下面这样为 X 编写构造函数可能更好:
X(int val): i(val), j(val) { }
在这个版本中,i 和 j 初始化的次序就是无关紧要的。
初始化式可以是任意表达式
一个初始化式可以是任意复杂的表达式。例如,可以给 Sales_item 类一个新的构造函数,该构造函数接受一个 string 表示 isbn,一个 usigned 表示售出书的数目,一个double 表示每本书的售出价格:
Sales_item(const std::string &book, int cnt, double price): isbn(book), units_sold(cnt), revenue(cnt * price) { }
revenue 的初始化式使用表示价格和售出数目的形参来计算对象的 revenue 成员。
类类型的数据成员的初始化式
初始化类类型的成员时,要指定实参并传递给成员类型的一个构造函数。可以使用该类型的任意构造函数。例如,Sales_item 类可以使用任意一个 string 构造函数来初始化isbn。也可以用 ISBN 取值的极限值来表示 isbn 的默认值,而不是用空字符串。可以将 isbn 初始化为由 10 个 9 构成的串:
// alternative definition for Sales_item default constructor Sales_item(): isbn(10, '9'), units_sold(0), revenue(0.0) {}
这个初始化式使用 string 构造函数,接受一个计数值和一个字符,并生成一个 string,来保存重复指定次数的字符。
12.4.2. 默认实参与构造函数
再来看看默认构造函数和接受一个 string 的构造函数的定义:
Sales_item(const std::string &book): isbn(book), units_sold(0), revenue(0.0) { } Sales_item(): units_sold(0), revenue(0.0) { }
这两个构造函数几乎是相同的:唯一的区别在于,接受一个 string 形参的构造函数使用该形参来初始化 isbn。默认构造函数(隐式地)使用 string 的默认构造函数来初始化isbn。
可以通过为 string 初始化式提供一个默认实参将这些构造函数组合起来:
class Sales_item { public: // default argument for book is the empty string Sales_item(const std::string &book = ""): isbn(book), units_sold(0), revenue(0.0) { } Sales_item(std::istream &is); // as before };
在这里,我们只定义了两个构造函数,其中一个为其形参提供一个默认实参。对于下面的任一定义,将执行为其 string 形参接受默认实参的那个构造函数:
Sales_item empty; Sales_item Primer_3rd_Ed("0-201-82470-1");
在 empty 的情况下,使用默认实参,而 Primer_3rd_ed 提供了一个显式实参。
类的两个版本提供同一接口:给定一个 string 或不给定初始化式,它们都将一个 Sales_item 初始化为相同的值。
我们更喜欢使用默认实参,因为它减少代码重复。
12.4.3. 默认构造函数
只要定义一个对象时没有提供初始化式,就使用默认构造函数。为所有形参提供默认实参的构造函数也定义了默认构造函数。
合成的默认构造函数
一个类哪怕只定义了一个构造函数,编译器也不会再生成默认构造函数。这条规则的根据是,如果一个类在某种情况下需要控制对象初始化,则该类很可能在所有情况下都需要控制。只有当一个类没有定义构造函数时,编译器才会自动生成一个默认构造函数。
合成的默认构造函数(synthesized default constructor)使用与变量初始化相同的规则来初始化成员。具有类类型的成员通过运行各自的默认构造函数来进行初始化。内置和复合类型的成员,如指针和数组,只对定义在全局作用域中的对象才初始化。当对象定义在局部作用域中时,内置或复合类型的成员不进行初始化。如果类包含内置或复合类型的成员,则该类不应该依赖于合成的默认构造函数。它应该定义自己的构造函数来初始化这些成员。
此外,每个构造函数应该为每个内置或复合类型的成员提供初始化式。
没有初始化内置或复合类型成员的构造函数,将使那些成员处于未定义的状态。除了作为赋值的目标之外,以任何方式使用一个未定义的成员都是错误的。
如果每个构造函数将每个成员设置为明确的已知状态,则成员函数可以区分空对象和具有实际值的对象。
类通常应定义一个默认构造函数
在某些情况下,默认构造函数是由编译器隐式应用的。如果类没有默认构造函数,则该类就不能用在这些环境中。为了例示需要默认构造函数的情况,假定有一个 NoDefault类, 它没有定义自己的默认构造函数,却有一个接受一个 string 实参的构造函数。因为该类定义了一个构造函数,因此编译器将不合成默认构造函数。 NoDefault 没有默认构造函数,意味着:-
具有 NoDefault 成员的每个类的每个构造函数,必须通过传递一个初始的 string 值给 NoDefault 构造函数来显式地初始化 NoDefault 成员。
-
编译器将不会为具有 NoDefault 类型成员的类合成默认构造函数。如果这样的类希望提供默认构造函数,就必须显式地定义,并且默认构造函数必须显式地初始化其NoDefault 成员。
-
NoDefault 类型不能用作动态分配数组的元素类型。
-
NoDefault 类型的静态分配数组必须为每个元素提供一个显式的初始化式。
-
如果有一个保存 NoDefault 对象的容器,例如 vector,就不能使用接受容器大小而没有同时提供一个元素初始化式的构造函数。
实际上,如果定义了其他构造函数,则提供一个默认构造函数几乎总是对的。通常,在默认构造函数中给成员提供的初始值应该指出该对象是“空”的。
使用默认构造函数
<Beware>:初级 C++ 程序员常犯的一个错误是,采用以下方式声明一个用默认构造函数初始化的对象:
// oops! declares a function, not an object
Sales_item myobj();
编译 myobj 的声明没有问题。然而,当我们试图使用 myobj 时
Sales_item myobj(); // ok: but defines a function, not an object if (myobj.same_isbn(Primer_3rd_ed)) // error: myobj is a function
编译器会指出不能将成员访问符号用于一个函数!问题在于 myobj 的定义被编译器解释为一个函数的声明,该函数不接受参数并返回一个 Sales_item 类型的对象——与我们的意图大相径庭!使用默认构造函数定义一个对象的正确方式是去掉最后的空括号:
// ok: defines a class object ...
Sales_item myobj;
另一方面,下面这段代码也是正确的:
// ok: create an unnamed, empty Sales_itemand use to initialize myobj Sales_item myobj = Sales_item();
在这里,我们创建并初始化一个 Sales_item 对象,然后用它来按值初始化 myobj。编译器通过运行 Sales_item 的默认构造函数来按值初始化一个 Sales_item。
12.4.4. 隐式类类型转换
在介绍过,C++ 语言定义了内置类型之间的几个自动转换。也可以定义如何将其他类型的对象隐式转换为我们的类类型,或将我们的类类型的对象隐式转换为其他类型。 为了定义到类类型的隐式转换,需要定义合适的构造函数。<Note>:
可以用单个实参来调用的构造函数定义了从形参类型到该类类型的一个隐式转换。
让我们再看看定义了两个构造函数的 Sales_item 版本:
class Sales_item { public: // default argument for book is the empty string Sales_item(const std::string &book = ""): isbn(book), units_sold(0), revenue(0.0) { } Sales_item(std::istream &is); // as before };
这里的每个构造函数都定义了一个隐式转换。因此,在期待一个 Sales_item 类型对象的地方,可以使用一个 string 或一个 istream:
string null_book = "9-999-99999-9"; // ok: builds a Sales_itemwith 0 units_soldand revenue from // and isbn equal to null_book item.same_isbn(null_book);
这段程序使用一个 string 类型对象作为实参传给 Sales_item 的 same_isbn 函数。该函数期待一个 Sales_item 对象作为实参。编译器使用接受一个 string 的 Sales_item 构造函数从 null_book 生成一个新的 Sales_item 对象。新生成的(临时的)Sales_item 被传递给 same_isbn。
这个行为是否我们想要的,依赖于我们认为用户将如何使用这个转换。在这种情况下,它可能是一个好主意。book 中的 string 可能代表一个不存在的 ISBN,对 same_isbn 的调用可以检测 item 中的 Sales_item 是否表示一个空的 Sales_item。另一方面,用户也许在 null_book 上错误地调用了 same_isbn。
更成问题的是从 istream 到 Sales_item 的转换:
// ok: uses the Sales_item istream constructor to build an object // to pass to same_isbn item.same_isbn(cin);
这段代码将 cin 隐式转换为 Sales_item。这个转换执行接受一个 istream 的 Sales_item 构造函数。该构造函数通过读标准输入来创建一个(临时的) Sales_item 对象。然后该对象被传递给 same_isbn。
这个 Sales_item 对象是一个临时对象。一旦 same_isbn 结束,就不能再访问它。实际上,我们构造了一个在测试完成后被丢弃的对象。这个行为几乎肯定是一个错误。
抑制由构造函数定义的隐式转换
可以通过将构造函数声明为 explicit,来防止在需要隐式转换的上下文中使用构造函数:
class Sales_item { public: // default argument for book is the empty string explicit Sales_item(const std::string &book = ""): isbn(book), units_sold(0), revenue(0.0) { } explicit Sales_item(std::istream &is); // as before };
explicit 关键字只能用于类内部的构造函数声明上。在类的定义体外部所做的定义上不再重复它:
// error: explicit allowed only on constructor declaration in class header explicit Sales_item::Sales_item(istream& is) { is >> *this; // uses Sales_iteminput operator to read the members }
现在,两个构造函数都不能用于隐式地创建对象。前两个使用都不能编译:
item.same_isbn(null_book); // error: string constructor is explicit item.same_isbn(cin); // error: istream constructor is explicit
<Note>:当构造函数被声明 explicit 时,编译器将不使用它作为转换操作符。
为转换而显式地使用构造函数
只要显式地按下面这样做,就可以用显式的构造函数来生成转换:
string null_book = "9-999-99999-9"; // ok: builds a Sales_itemwith 0 units_soldand revenue from // and isbn equal to null_book item.same_isbn(Sales_item(null_book));
在这段代码中,从 null_book 创建一个 Sales_item。尽管构造函数为显式的,但这个用法是允许的。显式使用构造函数只是中止了隐式地使用构造函数。任何构造函数都可以用来显式地创建临时对象。
通常,除非有明显的理由想要定义隐式转换,否则,单形参构造函数应该为 explicit。将构造函数设置为 explicit 可以避免错误,并且当转换有用时,用户可以显式地构造对象。 |
12.4.5. 类成员的显式初始化
尽管大多数对象可以通过运行适当的构造函数进行初始化,但是直接初始化简单的非抽象类的数据成员仍是可能的。对于没有定义构造函数并且其全体数据成员均为 public 的类,可以采用与初始化数组元素相同的方式初始化其成员:
struct Data { int ival; char *ptr; }; // val1.ival = 0; val1.ptr = 0 Data val1 = { 0, 0 }; // val2.ival = 1024; // val2.ptr = "Anna Livia Plurabelle" Data val2 = { 1024, "Anna Livia Plurabelle" };
根据数据成员的声明次序来使用初始化式。例如,因为 ival 在 ptr 之前声明,所以下面的用法是错误的:
// error: can't use "Anna Livia Plurabelle" to initialize the int ival Data val2 = { "Anna Livia Plurabelle" , 1024 };
这种形式的初始化从 C 继承而来,支持与 C 程序兼容。显式初始化类类型对象的成员有三个重大的缺点。
-
要求类的全体数据成员都是 public。
-
将初始化每个对象的每个成员的负担放在程序员身上。这样的初始化是乏味且易于出错的,因为容易遗忘初始化式或提供不适当的初始化式。
-
如果增加或删除一个成员,必须找到所有的初始化并正确更新。
定义和使用构造函数几乎总是较好的。当我们为自己定义的类型提供一个默认构造函数时,允许编译器自动运行那个构造函数,以保证每个类对象在初次使用之前正确地初始化。