0x06_类

类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。

定义抽象数据类型

在抽象数据类型中,由类的设计者负责考虑类的实现过程:使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类的工作细节。

设计Sales_data类

例如:

struct Sales_data {
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data &);
double avg_price() const;
std::string bookNo;
unsignd units_sold = 0;
double revenue = 0.0;
};
Sales_data add(const Sales_data &, const Sales_data &);
std::ostream &print(std::ostream &, const Sales_data &);
std::istream &read(std::istream &, Sales_data &);

定义改进的Sales_data类

定义成员函数

定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,它们的定义和声明在类的外部。

定义在类内部的函数是隐式的inline函数。

样例的isbn函数它的参数列表为空,返回值是一个string对象:

std::string isbn() const { return bookNo; }

引入this

对isbn成员函数的调用:

total.isbn();

在这里使用点运算符来访问total对象的isbn成员,然后调用它。当我们调用成员函数时,实际上是在替某个对象调用它。如果isbn指向Sales_data的成员(如bookNo),实际上它隐式地指向调用该函数的对象的成员。所以上述代码当isbn返回bookNo时,实际上它隐式地返回total.bookNo。

成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this(如total的地址)。

在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为this所指的就是这个对象。任何对类成员的直接访问都被看作this的隐式引用。当isbn使用bookNo时,它隐式地使用this所指向的成员,就像我们书写了this->bookNo一样。

this是一个常量指针,我们不允许改变this中保存的地址。

引入const成员函数

isbn函数中,紧随参数列表之后的const关键字的作用是修改隐式this指针的类型。

默认情况下,this的类型是指向类类型非常量版本的常量指针。例如在Sales_data成员函数中,this的类型是Sales_data *const。尽管this是隐式的,但它仍然需要遵循初始化规则,意味着默认情况下我们不能把this绑定到一个常量对象上,也就使得我们不能在一个常量对象上调用普通的成员函数。

我们应该把this声明成const Sales_data *const,毕竟在isbn的函数体内不会改变this所指的对象,所以把this设置为指向常量的指针有助于提高函数的灵活性。然而this是隐式的且不会出现在参数列表中,C++的做法是允许把const关键字放在成员函数的参数列表之后,此时紧跟在参数列表后面的const表示this是一个指向常量的指针,像这样使用const的成员函数被称作常量成员函数。

因为this是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。isbn可以读取它的对象的数据成员,但是不能写入新值。

常量对象、以及常量对象的引用或指针都只能调用常量成员函数。

类作用域和成员函数

类本身就是一个作用域,类的成员函数的定义嵌套在类的作用域之内。成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

在类的外部定义成员函数

当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配,即返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员被声明称常量成员函数,那么它的定义也必须在参数列表后明确执行const属性。同时,类外部定义的成员的名字必须包含它所属的类名。

double Sales_data::avg_price() const {
// ...
}

定义一个返回this对象的函数

Sales_data &Sales_data::combine(const Sales_data &rhs) {
// ...
return *this; // 返回调用该函数的对象
}

定义类相关的非成员函数

类的作者常常需要定义一些辅助函数,尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。

istream &read(istream &is, Sales_data &item) {
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
return is;
}
ostream &print(ostream &os, const Sales_data &item) {
os << item.isbn() << " " << item.units_sold << " ";
return os;
}

read和print分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,所以我们只能通过引用来传递它们。而且因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。

一般来说执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。

定义add函数

Sales_data add(const Sales_data &lhs, const Sales_data &rhs) {
Sales_data sum = lhs;
// ...
}

默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。

构造函数

每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数的名字和类命相同,和其他函数不一样的是,构造函数没有返回类型。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。不同于其他成员函数,构造函数不能被声明成const的,当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其常量属性。因此,构造函数在const对象的构造过程中可以向其写值。

默认构造函数

类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数。默认构造函数无须任何实参。如果我们的类没有显式的定义构造函数,那么编译器就会为我们隐式的定义一个默认构造函数。

编译器创建的构造函数又被称为合成的默认构造函数。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员。
  • 否则,默认初始化该成员。

某些类不能依赖于合成地默认构造函数

合成的默认构造函数只适合非常简单的类。对于普通的类来说,必须定义它自己的默认构造函数。原因:

  1. 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义,否则类将没有默认构造函数。依据是:如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。
  2. 对于某些类来说,合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或复合类型(数组和指针)的对象被默认初始化,则它们的值将是未定义的。其同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数,否则,用户在创建类的对象时就可能得到未定义的值。
  3. 有时编译器不能为某些类合成默认的构造函数。如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数。

定义Sales_data的构造函数

struct Sales_data {
Sales_data() = default;
Sales_data(const std::string &s) : bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p) :
bookNo(s), units_sold(n), revenue(p * n) { }
Sales_data(std::istream &);
// ...
}

=default的含义

因为该构造函数不接受任何实参,所以它是一个默认构造函数。我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数,我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。

如果我们需要默认的行为,那么可以通过在参数列表后写上= default来要求编译器生成构造函数。其中=default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。如果在类的内部,则默认构造函数是内联的;如果在类的外部,则该成员默认情况下是不内联的。

上面的默认构造函数之所以对Sales_data有效,是因为我们为内置类型的数据成员提供了初始值,如果不支持类内初始值,就应该使用构造函数初始值列表来初始化类的每个成员。

构造函数初始值列表

另外两个构造函数冒号以及花括号之间的代码称为构造函数初始值列表,它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来(或者花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。

当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。此例中,这样的成员使用类内初始值初始化。

通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在,我们就能确保为成员赋予了一个正确的值。不过如果编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

在类外部定义构造函数

与其他几个构造函数不同,以istream为参数的构造函数需要执行一些实际的操作。

Sales_data::Sales_data(std::istream &is) {
read(is, *this); // read函数的作用是从is中读取一条交易信息然后存入this对象中
}

拷贝、赋值和析构

除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等。当我们使用了赋值运算符时会发生对象的赋值操作。当对象不再存在时执行销毁的操作,如一个局部对象会在创建它的块结束时被销毁,当vector对象(或数组)销毁时存储在其中的对象也会被销毁。

如果我们不主动定义这些操作,则编译器将替我们合成它们。但对于某些类来说合成的版本无法正常工作,特别的是,当类需要分配类对象之外的资源时,合成版本往往失效。

访问控制与封装

在C++中,我们使用访问说明符加强类的封装性:

  • 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
  • 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。

再一次定义Sales_data类:

class Sales_data {
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p) :
bookNo(s), units_sold(n), revenue(p * n) { }
Sales_data(const std::string &s) : bookNo(s) { }
Sales_data(std::istream &);
std::string isbn() const { return bookNo; }
Sales_data &combine(const Sales_data &);
private:
double avg_price() const {
return units_sold ? revenue/units_sold : 0;
}
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};

作为接口的一部分,构造函数和部分成员函数紧跟在public说明符之后;而数据成员和作为实现部分的函数则跟在private说明符后面。

一个类可以包含0个或多个访问说明符,每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾处为止。

使用class或struct关键字

我们可以使用两个关键字中的任何一个定义类,唯一的区别是,struct和class的默认访问权限不一样。

类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。如果我们使用struct关键字,则成员默认是public的;如果使用class关键字,则是private的。

友元

既然Sales_data的数据成员是private的,我们的read、print和add函数也就无法正常编译了,这是因为尽管这几个函数是类的接口的一部分,但它们不是类的成员。

类可以允许其他类或函数访问它的非共有成员,方法是令其他类或函数成为它的友元。如果类想把一个函数作为它的友元。如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:

class Sales_data {
friend Sales_data add(const Sales_data &, const Sales_data &);
friend std::istream &read(std::istream &, Sales_data &);
friend std::ostream &print(std::ostream &, const Sales_data &);
public:
// ...
};
Sales_data add(const Sales_data &, const Sales_data &);
std::istream &read(std::istream &, Sales_data &);
std::iostream &print(std::ostream &, const Sales_data &);

友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。

一般来说,最好在类定义开始或结束前的位置集中声明友元。

友元的声明

友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明,如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外专门对函数进行一次声明。

为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。

类的其他特性

类成员再探

定义一对相互关联的类,分别是Screen和Window_mgr。

定义一个类型成员

除了定义数据和函数成员之外,类可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public或者private中的一种。

class Screen {
public:
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};

我们在Screen的public部分定义了pos,这样用户就可以使用这个名字。

关于pos的声明有两点需要注意:

  1. 我们使用了typedef,也可以等价地使用类型别名。using pos = std::string::size_type;
  2. 用来定义类型的成员必须先定义后使用。因此类型成员通常出现在类开始的地方。

令成员作为内联函数

在类中,常有一些规模较小的函数适合于被声明成内联函数。定义在类内部的成员函数自动是inline的。我们可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义:

class Screen {
public:
typedef std::string::size_type pos;
Screen() = default;
// cursor被其类内初始化值初始化为0
Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) { }
char get() const { return contents[cursor]; } // 读取光标处字符,隐式内联
inline char get(pos ht, pos wd) const; // 显式内联
Screen &move(pos r, posc); // 能在之后被设为内联
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
char Screen::get(pos r, pos c) const {
pos row = r * width;
return contents[row + c];
}
inline Screen &Screen::move(pos r, pos c) {
pos row = r * width;
cursor = row + c;
return *this;
}

虽然无须在声明和定义的地方同时说明inline,但这样做其实是合法的。最好只在类外部定义的地方说明inline。

inline成员函数也应该与相应的类定义在同一个头文件中。

可变数据成员

有时希望能修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字来实现。

一个可变数据成员永远不会是const,即使它是const对象的成员。

class Screen {
// ...
private:
mutable size_t access_ctr;
};
void Screen::some_member() const {
++access_ctr;
}

尽管some_member是一个const成员函数,它仍然能够改变access_ctr的值。该成员是个可变成员,因此任何成员函数,包括const函数在内都能改变它的值。

类数据成员的初始值

定义一个窗口管理类表示显示器上的一组Screen。默认情况下,希望Window_mgr类开始时总有一个默认初始化的Screen。在C++11新标准中,最好的方式就是把这个默认值声明成一个类内初始值。

class Window_mgr {
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};

当初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。此例中使用一个单独的元素值对vector成员执行了列表初始化,这个Screen的值被传递给vector<Screen>的构造函数,从而创建了一个单元素的vector对象。

类内初始值必须使用=的初始化形式或花括号括起来的直接初始化形式。

返回*this的成员函数

class Screen {
public:
screen &set(char);
Screen &set(pos, pos, char);
};
inline Screen &Screen::set(char c) {
contents[cursor] = c;
return *this;
}
inline Screen &Screen::set(pos r, pos col, char ch) {
contents[r * width + col] = ch;
return *this;
}

返回引用的函数是左值的,意味着这些函数返回的是对象而非对象的副本。

myScreen.move(4, 0).set('#');

这些操作将在同一个对象上执行。如果定义的返回类型不是引用,则move的返回值将是*this的副本,因此连续调用时,set只能改变临时副本,而不能改变myScreen的值。

从const成员函数返回*this

添加一个名为display的操作,负责打印Screen的内容。从逻辑上说可以令display为一个const成员函数,此时,this将是一个指向const的指针而*this是const对象。由此推断,display的返回类型应该是const Sales_data &。然而,此时我们将不能把display嵌入到一组动作的序列中去:

myScreen.display(cout).set('*');

即使myScreen是个非常量对象,对set的调用也无法通过编译。问题在于display的const版本返回的是常量引用,显然无权set一个常量对象。

基于const的重载

通过区分函数是否是const的,可以对其进行重载。因为非常量版本的函数对于常量对象是不可用的,所以只能在一个常量对象上调用const成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。

class Screen {
public:
Screen &display(std::ostream &os {
do_display(os);
return *this;
}
const Screen &display(std::ostream &os) const {
do_display(os);
return *this;
}
private:
void do_display(std::ostream &os) const {
os << contents;
}
};

当一个成员调用另外一个成员时,this指针在其中隐式地传递。因此当display调用do_display时,它的this指针隐式地传递给do_display。而当display的非常量版本调用do_display时,它的this指针将隐式地从指向非常量的指针转换成指向常量的指针。

当do_display完成后,display函数各自返回解引用this所得的对象。非常量版本中,this指向一个非常量对象,因此display返回一个普通(非常量)引用;而const成员则返回一个常量引用。

当在某个对象上调用display时,该对象是否是const决定了应该调用display的哪个版本。

类类型

即使两个类的成员列表完全一致,它们也是不同的类型。我们可以把类名作为类型的名字使用,从而直接指向类类型,或者也可以把类名跟在关键字class或struct后面。

类的声明

就像可以把函数的声明和定义分离开一样,我们也能仅仅声明类而暂时不定义它。

class Screen; // Screen类的声明

这种声明有时被称作前向声明,它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型。即此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。

不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。

对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则编译器无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或者指针访问其成员。

因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然后,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:

class Link_screen {
Screen window;
Link_screen *next;
Link_screen *prev;
};

友元再探

类还可以把其他的类定义成友元,也可以把其他类的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。

类之间的友元关系

Window_mgr类的某些成员可能需要访问它管理的Screen类的内部数据,如clear函数负责把一个指定的Screen的内容都设为空白。为了完成这一任务,clear需要访问Screen的私有成员;而要想令这种访问合法,Screen需要把Window_mgr指定成它的友元。

class Screen {
friend class Window_mgr;
};

如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非共有成员在内的所有成员。必须要注意的一点是,友元关系不存在传递性,即如果Window_mgr有它自己的友元,则这些友元并不能直接具有访问Screen的特权。

class Window_mgr {
public:
using ScreenIndex = std::vector<Screen>::size_type;
void clear(ScreenIndex);
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
void Window_mgr::clear(ScreenIndex i) {
Screen &s = screens[i];
s.contents = string(s.height * s.width, ' ');
}

令成员函数作为友元

除了令整个Window_mgr作为友元之外,Screen还可以只为clear提供访问权限。当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:

class Screen {
friend void Window_mgr::clear(ScreenIndex);
};

要想令某个成员函数作为友元,必须仔细组织程序的结构以满足声明和定义的彼此依赖关系:

  • 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear中使用Screen的成员之前必须先声明Screen。
  • 接下来定义Screen,包括对于clear的友元声明。
  • 最后定义clear,此时它才可以使用Screen的成员。

尽管重载函数的名字相同,但它们仍然是不同的函数,因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数的每一个分别声明。

友元声明和作用域

类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的,然后友元本身不一定真的声明在当前作用域中。

甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:

struct X {
friend void f() { /* 友元函数可以定义在类的内部 */ }
X() { f(); } // 错误:f还没有被声明
void g();
void h();
};
void X::g() { return f(); } // 错误:f还没有被声明
void f();
void X::h() { return f(); } // 正确:现在f的声明在作用域中了

关于这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明。

类的作用域

每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符访问。

Screen::pos ht = 24, wd = 80;
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get();
c = p->get();

作用域和定义在类外部的成员

在类的外部,成员的名字被隐藏起来了。一旦遇到了类名,定义的剩余部分就在类的作用域之内了(包括参数列表和函数体),结果就是,我们可以直接使用类的其他成员而无须再次授权。

void Window_mgr::clear(ScreenIndex i) {
Screen &s = screens[i];
s.contents = string(s.height * s.width, ' ');
}

因为编译器在处理参数列表之前已经明确了我们当前正位于Window_mgr类的作用域中,所以不必再专门说明ScreenIndex是Window_mgr类定义的。出于同样的原因,编译器也能直到函数体中用到的screens也是在Window_mgr类中定义的。

另一方面,函数的返回类型通常在函数名之前,因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。

class Window_mgr {
public:
using ScreenIndex = std::vector<Screen>::size_type;
ScreenIndex addScreen(const Screen &);
};
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s) {
screens.push_back(s);
return screens.size() - 1;
}

因为返回类型出现在类名之前,所以实际上它是位于Window_mgr类的作用域之外的。在这种情况下,要想使用ScreenIndex作为返回类型,必须明确执行哪个类定义了它。

名字查找与类的作用域

目前为止,编写的程序中,名字查找(寻找与所用名字最匹配的声明的过程)比较直截了当:

  • 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
  • 如果没找到,继续查找外层作用域。
  • 如果最终没有找到匹配的声明,则程序报错。

对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别,类的定义分两步处理:

  • 首先,编译成员的声明。
  • 直到类全部可见后才编译函数体。

按照这种两阶段的方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。

用于类成员声明的名字查找

这种两阶段的处理方式值适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的外部作用域中继续查找。

typedef double Money;
string ba;
class Account {
public:
Money balance() { return bal; }
private:
Money bal;
};

当编译器看到balance函数的声明语句时,它将在Account类的范围内寻找对Money的声明。编译器只考虑Account中在使用Money之前出现的声明,因为没找到匹配的成员,所以编译器会接着到Account的外层作用域中查找。编译器会找到Money的typedef语句,该类型被用作balance函数的返回类型以及数据成员bal的类型。另一方面,balance函数体在整个类可见后才被处理,因此,该函数的return语句返回名为bal的成员,而非外层作用域的string对象。

类型名要特殊处理

一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。

typedef double Money;
class Account {
public:
Money balance() { return bal; } // 使用外层作用域的Money
private:
typedef double Money; // 错误:不能重新定义Money
Money bal;
};

类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。

成员定义中的普通块作用域的名字查找

成员函数中使用的名字按照如下方式解析:

  • 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
  • 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
  • 如果类内也没有找到该名字的声明,在成员函数定义之前的作用域内继续查找。

类作用域之后,在外围的作用域中查找

如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。如果内外层作用域对象名字一样,而需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求:

// 不建议的写法:不要隐藏外层作用域中可能被用到的名字
void Screen::dummy_fcn(pos height) {
cursor = width * ::height;
}

在文件中名字出现处对其进行解析

当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。

int height;
class Screen {
public:
typedef std::string::size_type pos;
void setHeight(pos);
pos height = 0;
};
Screen::pos verify(Screen::pos);
void Screen::setHeight(pos var) {
height = verify(var);
}

全局函数verify的声明在Screen类的定义之前是不可见的。然而,名字查找的第三步包括了成员函数出现之前的全局作用域。此例中,verify的声明位于setHeight的定义之前,因此可正常被使用。

构造函数再探

构造函数初始值列表

当我们定义变量时习惯于立即对其进行初始化,而非先定义、再赋值:

string foo = "Hello World!"; // 定义并初始化
string bar; // 默认初始化成空string对象
bar = "Hello World!"; // 为bar赋一个新值

就对象的数据成员而言,初始化和赋值也有类似的区别。如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。

// Sales_data构造函数的一种写法,虽然合法但比较草率:没有使用构造函数初始值
Sales_data:Sales_data(const string &s, unsigned cnt, double price) {
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}

这段代码与使用构造函数初始值列表的作用是相同的,区别是原来的版本初始化了它的数据成员,而这个版本是对数据成员执行了赋值操作。这一区别会造成什么影响依赖于数据成员的类型。

构造函数的初始值有时必不可少

有时可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。

class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};

和其他常量对象或者引用一样,成员ci和ri都必须被初始化。因此,如果我们没有为它们提供构造函数初始值的话将引发错误:

// 错误:ci和ri必须被初始化
ConstRef::ConstRef(int ii) {
i = ii; // 正确
ci = ii; // 错误:不能给const赋值
ri = i; // 错误:ri没被初始化
}

随着构造函数体一开始执行,初始化就完成了。我们初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始值,因此该构造函数的正确形式应该是:

ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(i) { }

如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

成员初始化的顺序

显然,在构造函数初始值中每个成员只能出现一次。构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。成员的初始化顺序与它们在类定义中出现的顺序一致:第一个成员先被初始化,然后第二个。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

一般来说,初始化的顺序没有什么特别要求,不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键:

class X {
int i;
int j;
public:
X(int val) : j(val), i(j) { }
};

在此例中,仿佛是先用val初始化了j,然后用j初始化i。实际上,i先被初始化,因此这个初始化值的效果是试图使用未定义的值j初始化i。

最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员,而是用构造函数的参数作为成员的初始值。

默认实参和构造函数

Sales_data默认构造函数的行为与只接受一个string实参的构造函数差不多。唯一的区别是接受string实参的构造函数使用这个实参初始化bookNo,而默认构造函数(隐式地)使用string的默认构造函数初始化bookNo。可以把它重写成一个使用默认实参的构造函数:

class Sales_data {
public:
// 定义默认构造函数,令其与只接受一个string实参的构造函数功能相同
Sales_data(std::string s = "") : bookNo(s) { }
// 其他构造函数与之前一致
Sales_data(std::string s, unsigned cnt, double rev) :
bookNo(s), units_sold(cnt), revenue(rev * cnt) { }
Sales_data(std::istream &is) { read(is, *this); }
// 其他成员与之前的版本一致
};

因为我们不提供实参也能调用上述的构造函数,所以该构造函数实际上为我们的类提供了默认构造函数。

委托构造函数

C++11新标准扩展了构造函数初始值的功能,使得可以定义委托构造函数。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身,类名后面紧跟圆括号括起来的参数列表,且必须与类中另外一个构造函数匹配。

class Sales_data {
public:
// 非委托构造函数使用对应的实参初始化成员
Sales_data(std::string s, unsigned cnt, double price):
bookNo(s), units_sold(cnt), revenue(cnt * price) { }
// 其余构造函数全都委托给另一个构造函数
Sales_data() : Sales_data("", 0, 0) { }
Sales_data(std::string s) : Sales_data(s, 0, 0) { }
Sales_data(std::istream &is) : Sales_data()
{ read(is, *this); }
};

当一个构造函数委托给另一个构造函数时,被委托的构造函数的初始值列表和函数体被依次执行。然后控制权才会交还给委托者的函数体。

默认构造函数的作用

当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:

  • 当在块作用域不使用任何初始值定义一个非静态变量或者数组。
  • 当一个类本身含有类类型的成员且使用合成的默认构造函数。
  • 当类类型的成员没有在构造函数初始化列表中显式地初始化时。

值初始化在以下情况下发生:

  • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
  • 当我们不使用初始值定义一个局部静态变量时。
  • 当我们通过书写形如T()的表达式显式地请求值初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小),它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化。

类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。不明显的一种情况是类的某些数据成员缺少默认构造函数:

class NoDefault {
public:
NoDefault(const std::string &);
};
struct A {
NoDefault my_mem;
};
A a; // 错误:不能为A合成构造函数
struct B {
B() {}
NoDefault b_member; // 错误:b_member没有初始值
};

使用默认构造函数

下面的obj声明可以正常编译通过:

Sales_data obj(); // 正确:定义了一个函数而非对象
if (obj.isbn() == Primer_5th_ed.isbn()); // 错误:obj是一个函数

但当我们试图使用obj时,编译器将报错。问题在于,尽管我们想声明一个默认初始化的对象,obj实际的含义却是一个不接受任何参数的函数并且返回值是Sales_data类型的对象。

如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名后的空的括号对:

Sales_data obj;

隐式的类类型转换

C++语言在内置类型之间定义了几种自动转换规则,也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数。在Sales_data类中,接受string的构造函数和istream的构造函数分别定义了从这两种类型向Sales_data隐式转换的规则。即在需要使用Sales_data的地方,我们可以用string或者istream作为替代。

string null_book = "9-999-99999-9";
// 构造一个临时的Sales_data对象
// 该对象的units_sold和revenue等于0,bookNo等于null_book
item.combind(null_book);

在这里我们用一个string实参调用了Sales_data的combine成员。该调用是合法的,编译器用给定的string自动创建了一个Sales_data对象。新生成的临时对象被传递给combine。因为combine的参数是一个常量引用,所以我们可以给该参数传递一个临时量。

只允许一步类类型转换

编译器只会自动地执行一步类型转换。下面的代码隐式的使用了两种转换规则,所以它是错误的:

// 把"9-999-99999-9"转换成string
// 再把临时的string转换成Sales_data
item.combine("9-999-99999-9");

如果想完成上述调用,可以显式地把字符串转换成string或者Sales_data对象:

// 显式地转换成string,隐式地转换成Sales_data
item.combine(string("9-999-99999-9"));
// 隐式地转换成string,显式地转换成Sales_data
item.combine(Sales_data("9-999-99999-9"));

类类型转换不总是有效

是否需要从string到Sales_data的转换依赖于我们对用户使用该转换的看法。此例中,这种转换可能是对的,null_book中的string可能表示了一个不存在的ISBN编号。

另一个是从istream到Sales_data的转换:

item.combine(cin);

这段代码隐式地把cin转换成Sales_data,这个转换执行了接受一个istream的Sales_data构造函数。该构造函数通过读取标准输入创建了一个临时的Sales_data对象,随后将得到的对象传递给combine。实际上,我们创建了一个对象,先将它的值加到item中,随后将其丢弃。

抑制构造函数定义的隐式转换

在有隐式转换的程序上下文中,可以通过将构造函数声明为explicit加以阻止:

class Sales_data {
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p) :
bookNo(s), units_sold(n), revenue(p * n) { }
explicit Sales_data(const std::string &s) : bookNo(s) { }
explicit Sales_data(std::istream &);
};

此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的两种用法都无法通过编译。关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit。只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复。

// 错误:explicit关键字只允许出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream &is) {
read(is, *this);
}

explicit构造函数只能用于直接初始化

发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时我们只能使用直接初始化而不能使用explicit构造函数:

Sales_data item1(null_book); // 正确:直接初始化
Sales_data item2 = null_book; // 错误:不能将explicit构造函数用于拷贝形式的初始化过程

为转换显式地使用构造函数

尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:

// 正确:实参是一个显式构造的Sales_data对象
item.combine(Sales_data(null_book));
// 正确:static_cast可以使用explicit的构造函数
item.combine(static_cast<Sales_data>(cin));

标准库中含有显式构造函数的类

  • 接受一个单参数的const char *的string构造函数不是explicit的。
  • 接受一个容量参数的vector构造函数是explicit的。

聚合类

聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:

  • 所有成员都是public的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有virtual函数。

如下面的类是一个聚合类:

struct Data {
int ival;
string s;
};

我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:

Data val1 = {0, "Anna"};

初始值的顺序必须与声明的顺序一致,即第一个成员的初始值要放在第一个,以此类推。

与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的的元素个数绝对不能超过类的成员数量。

字面值常量类

constexpr函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。

数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

  • 数据成员都必须是字面值类型
  • 类必须至少含有一个constexpr构造函数。
  • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

constexpr构造函数

尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。

constexpr构造函数可以声明成=default的形式(或者是删除函数的形式)。否则,constexpr构造函数就必须既符合构造函数的要求(不能包含返回语句),又符合constexpr函数的要求(拥有的唯一可执行语句就是返回语句),constexpr构造函数体一般来说应该是空的。通过前置关键字constexpr就可以声明一个constexpr构造函数。

class Debug {
public:
constexpr Debug(bool b = true) : hw(b), io(b), other(b) { }
constexpr Debug(bool h, bool i, bool o) :
hw(h), io(i), other(o) { }
constexpr bool any() { return hw || io || other; }
void set_io(bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bol b) { hw = b; }
private:
bool hw;
bool io;
bool other;
};

constexpr构造函数必须初始化所有数据成员,初始值要么使用constexpr构造函数,要么是一条常量表达式。

constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型:

constexpr Debug io_sub(false, true, false);
if (io_sub.any()) {
// ...
}
constexpr Debug prod(false);
if (prod.any()) {
// ...
}

类的静态成员

有时类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。

声明静态成员

我们通过在成员的声明之前加上关键字static使得其与类关联在一起。和其他成员一样,静态成员可以是public的或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。

class Account {
public:
void calculate() { amount += amount * interestRate; }
static double rate() { return interestRate; }
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};

类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此,只存在一个interestRate对象而且它被所有Account对象共享。

类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果,静态成员函数不能声明为const的,而且我们也不能在static函数体内使用this指针。

使用类的静态成员

使用作用域运算符直接访问静态成员:

double r;
r = Account::rate(); // 使用作用域运算符访问静态成员

虽然静态成员不属于类的某个而对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:

Account ac1;
Account *ac2 = &acl;
r = ac1.rate(); // 通过Account的对象或引用
r = ac2->rate(); // 通过指向Account对象的指针

成员函数不同通过作用域运算符就能直接使用静态成员:

class Account {
public:
void calculate() { amount += amount * interestRate; }
private:
static double interestRate;
};

定义静态成员

和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句:

void Account::rate(double newRate) {
interestRate = newRate;
}

因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类对象时被定义的。这意味着它们不是由类的构造函数初始化的。一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。

类似于全局变量,静态数据成员定义在任何函数之外,因此它一旦被定义,就将一直存在于程序的整个生命周期中。

我们定义静态数据成员的方式和在类的外部定义成员函数差不多。我们需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字:

double Account::interestRate = initRate();

语句定义了名为interestRate的对象,该对象是类Account的静态成员,其类型是double。从类名开始,这条定义语句的剩余部分就都位于类的作用域之内了。因此,我们可以直接使用initRate函数。注意,虽然initRate是私有的,我们也能用它初始化interestRate。和其他成员的定义一样,interestRate的定义也可以访问类的私有成员。

静态成员的类内初始化

通常情况下,类的静态成员不应该在类的内部初始化,然而我们可以为静态成员提供const的整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。如:我们可以用一个初始化了的静态数据成员指定数组成员的维度:

class Account {
public:
static double rate() { return interestRate; }
static void rate(double);
private:
static constexpr int period = 30;
double daily_tbl[period];
};

如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的const或constexpr static不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。如果period的唯一用途就是定义daily_tbl的维度,则不需要在Account外面专门定义period。此时,如果我们忽略了这条定义,那么对程序非常微小的改动也可能造成编译错误,因为程序找不到该成员的定义语句。如当需要把Account::period传递给一个接受const int &的函数时,必须定义period。

如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了:

// 一个不带初始值的静态成员的定义,初始值在类的定义内提供
constexpr int Account::period;

即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

静态成员能用于某些场景,而普通成员不能

静态成员独立于任何对象。因此,在某些非静态数据成员可能非法的场景,静态成员可以正常地使用。如,静态成员可以是不完全类型。特别地,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明称它所属类的指针或引用:

class Bar {
public:
// ...
private:
static Bar mem1; // 正确:静态成员可以是不完全类型
Bar *mem2; // 正确:指针成员可以是不完全类型
Bar mem3; // 错误:数据成员必须是完全类型
};

静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参:

class Screen {
public:
// bkground表示一个在类中稍后定义的静态成员
Screen &clear(char = bkground);
private:
static const char bkground;
};

非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。

posted @   Pannnn  阅读(78)  评论(0编辑  收藏  举报
编辑推荐:
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
-->
点击右上角即可分享
微信分享提示