类
类的基本思想是 数据抽象 和 封装。数据抽象是一种依赖于 接口 和 实现 分离的编程技术。类的接口包括用户所能执行的操作;类的实现包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现的部分。
1 定义抽象数据类型
1.1 定义 Sales_data 类
定义和声明成员函数的方式与普通函数差不多。
- 成员函数的声明必须在类的内部,它的定义则即可以在类的内部也可以在类的外部。
- 非成员函数的定义和声明都在类的外部。
注: 定义在类内部的函数是隐式的 inline 函数。
定义成员函数
尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内,也可以定义在类外。
struct Sales_data {
// 定义和声明都在类内部
std::string isbn() const {
return bookNo;
}
// 在类内部声明,类外部定义
double avg_price() const;
};
// 在类的外部定义成员函数
double Sales_data::avg_price() const { /* ... */ }
注: 在类外部定义成员函数需要使用作用域运算符,且与类内部返回类型、参数列表、函数名都要保持一致。
this
假设定义了一个 Sales_data 对象 total。
通常情况下,我们可以通过点运算符(.),来访问 total 对象的 isbn 成员。实际上,当我们调用成员函数时, 其实是在替某个对象调用它。
成员函数通过一个名为 this 的额外隐式参数来访问调用它的那个对象,任何对类成员的直接访问都被看做 this 的隐式引用,也就是说,当 isbn 使用 bookNo 时,它隐式的使用 this 指向成员,就像书写 this -> bookNo 一样。
注: this 是一个常量指针,不允许改变 this 中保存的地址。
引入 const 成员函数
isbn 函数的另一个关键之处是紧随参数列表之后的 const 关键字,这里,const 的作用是修改隐式 this 指针的类型。
默认情况下,this 的类型时指向类类型非常量版本的常量指针,意味着我们不能把 this 绑定到一个常量对象上。这一情况也就是的我们不能在一个常量对象上调用普通的成员函数。
C++ 允许把 const 关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的 const 表示 this 是一个指向常量的指针。像这样使用 const 的成员函数被称作 常量成员函数。常量成员函数是指,函数体内不能对成员数据做任何改动,但是 mutable 和 static 类成员变量除外。
注: 常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
类作用域和成员函数
bookNo 定义在 isbn 知乎, isbn 还好能够使用 bookNo。这是因为,编译器分两步处理类:
- 首先编译成员的声明;
- 成员函数体(如果有的话)。
因此,成员函数体可以随意使用类中的其他成员而无须在一这些成员出现的次序。
定义一个返回 this 对象的函数
返回 this 对象的函数主要是可以当作,赋值运算符左侧的运算对象,右侧运算对象则是通过显式的实参被传入函数:
Sales_data& Sales_data::combine(const Sales_data &rhs) {
units_sold += rhs.units_sold; // 把rhs的成员加到this对象的成员上
revenue += rhs.revenue;
return *this; // 返回调用该函数的对象
}
当我们的交易处理程序调用此函数时:
total.combine(trans); // 更新total的当前值
// 就是把 ths 的成员添加到 this 对象的成员中
this -> units_sole += rhs.units_sold;
当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine函数必须返回引用类型。因为此时的左侧运算对象是一个Sales data的对象,所以返回类型应该是Sales_data&。
如前所述,我们无须使用隐式的this指针访问函数调用者的某个具体成员,而是需要把调用函数的对象当成一个整体来访问:
return *this; // 返回调用该函数的对象
1.2 定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。
我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。
注: 一般来说,如果非成员函数时类接口的组成部分,则这些函数的声明应该于类在一个头文件。
1.3 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同,但是构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
注: 构造函数不能被声明成 const。
默认构造函数
如果实例化对象的时候,没有为这些对象提供初始值,那么它们就是执行了默认初始化。
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做 默认构造函数。如果我们的类没有显式的定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。编译器创建的构造函数又被称为 合成的默认构造函数,其规则如下:
- 如果存在类内的初始值,用它来初始化成员。
- 否则,默认初始化该成员。
注: 默认构造函数无需任何实参。
某些类不能依赖于合成的默认构造函数
合成的默认构造函数只适合非常简单的类。对于一个普通的类来说,必须定义它自己的默认构造函数。原因如下:
- 只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。
- 合成的默认构造函数可能会执行错误的操作。如果类包含有内置类型或者复合类型的成员,则只有当这些成员全部都被赋予了类内的初始值,这个类才适合于使用合成的默认构造函数。
- 有的时候编译器不能为某些类合成默认的构造函数。如,类中包含一个其他类型的成员且这个成员的类型没有默认构造函数,嘛呢编译器将无法初始化该成员。
= default 的含义
在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = default 来要求编译器生成构造函数。如果 = default 在类的内部,则默认构造函数时内联的;如果它在类的外部,则该成员默认情况下不是内联的。
// 构造函数
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 (const std::string &s) : bookNo(s), units_sold(0), revenue(0) {}
当某个数据成员被构造函数初始值列表忽略时,它将以合成默认构造函数相同的方式隐式初始化。
1.4 拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。对象在几种情况下会被拷贝:
- 初始化变量以及以值的方式传递或返回一个对象等。
- 当我们使用了赋值运算符时会发生对象的赋值操作。
当对象不再存在时执行销毁的操作,比如一个局部对象会在创建它的块结束时被销毁,当vector对象(或者数组)销毁时存储在其中的对象也会被销毁。
如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。
某些类不能依赖于合成的版本
尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须清楚一点,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。比如说,管理动态内存的类通常不能依赖于上述操作的合成版本。
2 访问控制与封装
2.1 访问说明符
在C++语言中,我们使用 访问说明符 加强类的封装性:
-
定义在 public 说明符之后的成员在整个程序内可被访问,public 成员定义类的接口。
-
定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private 部分封装了类的实现细节。
class Sales_data {
public: // 访问说明符
/* … /
private: // 访问说明符
/ … */
}
作为接口的一部分,构造函数和部分成员函数紧跟在 public 后面;数据成员和作为实现部分的函数紧跟在 private 后面。
class 和 struct
关键字 class 和 struct 都可以定义类,如果我们使用 struct 关键字,则定义在第一个访问说明符之前的成员是 public的;相反如果我们使用 class 关键字,则这些成员是 private 的。
注: class 和 struct 的定义类的唯一区别就是默认的访问权限。
2.2 友元
解决问题:让非成员函数访问类的 private 成员。
当类的数据成员是 private 时,非类成员函数就无法对其进行访问,即使这个函数时类接口的一部分,但是它们不是类的成员。
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的 友元。如果类想把一个函数作为它的友元,只需要增加一条以 friend 关键字开始的函数声明语句即可。
class Sales_data {
// 为 Sales_data 的非成员函数所做的友元声明
friend std::istream &read(std::istream& ,book &);
public: /* ... */
private: /* ... */
}
友元声明只能出现在类定义的内部,但在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。
注1: 最好在类定义的开始或者结束前的位置集中声明友元。
注2: 友元仅仅指定访问权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们必须在友元声明之外在专门对函数进行一次声明。
封装的益处
- 确保用户代码不会无意间破坏封装对象的状态。
- 被封装的类具体实现细节可以随时改变,而无须调整用户级别的代码。
3 类的其他特性
3.1 类成员
自定义类型别名
类可以自定义某种类型在类中的别名,由类定义的类那个名字和其他成员一样存在访问限制,可以是 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,这样用户就可以使用这个名字。Screen 的用户不应该知道 Screen 使用了一个 string 对象来存放它的数据,因此通过把 pos 定义成 public 成员可以隐藏 Screen 实现的细节。
令成员作为内联函数
在类中,一些规模较小的函数适合于被声明称内联函数。定义在类内部的成员函数是自动 inline 的。我们可以在类的内部把 inline 作为声明的一部分显式地声明成员函数,同样的,也可以在类的外部用 inline 关键字修饰函数定义。
class Screen {
public:
char get() const { // 隐式内联
return contents[cursor];
}
inline char get(pos ht, pos wd) const; // 显式内联
Screen &move(pos r, pos c); // 能在之后被设为内联
private:
};
注: 虽然无须再生命和定义的地方同时说明 inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明 inline,这样可以使类更容易理解。
重载成员函数
和非常原函数一样,成员函数也可以被重载。
可变数据成员
有时,我们希望能修改类的某个数据成员,即使是在一个 const 成员函数内。这个可以通过在变量的声明中加入 mutable 关键字。
注: 常量成员函数无法在函数体内对成员数据做任何改动,但是 mutable 和 static 类成员变量除外。
一个 可变数据成员 永远不会是 const,即使它使 const 对象的成员。因此,一个 const 成员函数可以改变一个可变成员的值。
class Screen {
public:
void some_member() const;
private:
mutable size_t m_access_ctr = 0; // 即使在一个const对象内也能被修改
};
void Screen::some_member() const {
++m_access_ctr;
}
类数据成员的初始值
当我们初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。
class Window_mgr {
private:
// 默认情况下,一个 Window_mgr 包含一个标准尺寸的空白 Screen
std::vector<Screen> screens{Screen(24, 80, ' ')};
}
注: 当我们提供一个类内初始值的时候,必须以符号"="或者花括号表示。
3.2 返回 *this 的成员函数
Screen &Screen::move(pos r, pos c) {
pos row = r * width;
cursor = row + c;
return *this;
}
Screen &Screen::set(pos r, pos col, char ch) {
contents[r*width + col] = ch;
return *this;
}
返回引用的函数是左值,意味着这些函数返回的是对象而非对象的副本。如果我们把一系列这样的操作连接在一条表达式的话:
//把光标移动到一个指定的位置,然后设置该位置的字符值
myScreen.move(4.0).set('#')
这些操作将在同一个对象上执行,上述语句等价于
myScreen.move(4.0);
myScreen.set('#');
从 const 成员函数返回 *this
令 display 为一个 const 成员,此时,this 将指向一个 const 的指针而 *this是 const 对象。由此可推断,display 的返回类型应该是 const Sales_data&。所以哦我们不能把 display 嵌入到一组动作的序列中去:
// 如果 display 返回常量引用,则调用 set 将引发错误
myScreen,display(cout).set('+');
即使 myScreen 是个非常量对象,对 set 的调用也无法通过编译。问题子啊与 display 的 const 版本返回的是常量引用,而我们显然无权 set 一个常量对象。
注: 一个 const 成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。
基于 const 的重载
通过区分成员函数是否是 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;
}
};
建议: 对于公共代码使用私有功能函数,原因如下
- 一个基本的愿望是避免在多处使用同样的代码。
- 我们预期随着类的规模发展,display函数有可能变得更加复杂。此时,把相应的代码写在一处作用就比较明显。
- 我们很可能在开发过程中给do_display函数添加某些调试信息,而这些信息将在代码的最终产品版本去掉。显然,只在do_display一处添加或删除这些信息要容易一些。
- 这个额外的函数调用不会增加任何开销,因为我们在类内部定义do_display,所以它隐式地被声明成内敛函数。这样的话,调用do_display不会带来任何额外的运行开销。
3.3 类类型
每个类定义了唯一的类型,即使两个类的成员列表完全一致,它们也是不同的类型,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事。
我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,我么也可以把雷鸣跟在关键字 class 或 struct 后面:
Sales_data item1; // 默认初始化Sales_data类型的对象
class Sales_data item1; // 等价声明
类的声明
我们也能仅仅声明类而暂时不定义它。
class Screen; // Screen 类的声明
这种声明被称作前向声明,它向程序中引入名字 Screen 并且指明 Screen 是一种类类型。对于类 Screen 来说,在它声明之后定义之前是一个不完全类型,此时我们只知道 Screen 是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的场景下使用:可以定义只想这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则编译器就不能了解这样的对象需要多少存储空间,类似的,类必须首先被定义,然后才能引用或者指针访问其成员。如果类没有定义,编译器就不清楚该类到底有多少成员。
3.4 友元
类还可以把其他的类定义成友元,也可以把其他类(之前定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
类之间的友元
假设我们需要为 Window_mgr 添加一个名为 clear 的成员,它负责把一个指定的 Screen 的内容都设为空白。为了完成这一个任务,clea r需要访问 Screen 的私有成员;而要想令这种访问合法, Screen需要把Window_mgr指定成它的友元 :
class Screen{
// Window_mgr的成员可以访问Screen类的私有部分
friend class Window_mgr;
//Screen类的剩余部分
};
如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。通过上面的声明,Window_mgr 被指定为 Screen 的友元,因此我们可以将 Window_mgr 的 clear 成员写成如下形式:
void Window_mgr::clear(ScreenIndex i){
// s 是一个 Screen 的引用,指向我们想清空的那个屏幕
Screen &s = screens[i];
// 将那个选定的 Screen 重置为空白
s.contents = string(s.height * s.width, ' ');
}
注: 友元关系不存在传递性。也就是说如果 Window_mgr 有自己的友元,则这些友元并不能访问 Screen。每个类负责控制自己的友元类或友元函数。
令成员函数作为友元
除了令整个Window_mgr作为友元之外,Screen 还可以只为 clear 提供访问权限。当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:
class Screen{
//Window_mgr::clear必须在Screen类之前被声明
friend void Window_mgr::clear(ScreenIndex);
//Screen类的剩余部分
};
要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:
- 首先定义 Window_mgr 类,其中声明 clear 函数,但是不能定义它。在 clear 使用 Screen 的成员之前必须先声明 Screen。
- 接下来定义 Screen,包括对于 clear 的友元声明。
- 最后定义 clear,此时它才可以使用 Screen 的成员。
函数重载和友元
尽管重载函数的名字相同,但它们仍然时不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明:
//重载的 storeOn 函数
extern std::ostream& storeOn(std::ostream &,Screen &);
extern BitMap& storeOn(BitMap &,Screen &);
class Screen{
//storeOn 的 ostream 版本能访问 Screen 对象的私有部分
friend std::ostream& storeOn(std::ostream &,Screen &);
//...
};
Screen 类接受 ostream& 的 storeOn 函数声明成它的友元,但是接受 BitMap& 作为参数的版本仍然不能访问 Screen。
4 类的作用域
每个类都会定义自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符来访问。不论哪种情况,跟在运算符之后的名字都必须是对应类的成员。
4.1 名字查找与类的作用域
名字查找:寻找与所用名字最匹配的成名的过程。
- 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
- 如果没找到,继续查找外层作用域。
- 如果最终没有找到匹配的声明,则程序报错。
对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别。类的定义分为两步处理:
- 首先,编译成员的声明。
- 直到类全部可见才编译函数体。
注: 编译器处理完类中的全部声明后才会处理成员函数的定义。
用于类成员声明的名字查找
声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会定义该类的作用域中继续查找。例如:
typedef double Money;
string bal;
class Account{
public:
Money balance(){return bal;}
private:
Money bal;
//...
};
当编译器看到 balance 函数的声明语句时,它将在 Account 类的范围内寻找对 Money 的声明(只考虑在 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;
//...
};
即使类中定义的类型与外层作用域一致,代码也依然是错误的。
注: 类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
不建议的写法:
int height; // 定义了一个名字,稍后将在Screen中使用
class Screen {
public:
typedef std::string::size_type pos;
// 不建议的写法:为参数和成员使用同样的名字
void dummy_fcn(pos height) {
cursor = width * height; // height 是参数
}
private:
pos height = 0;
pos width = 0;
}
// 不建议的写法:成员函数中的名字不应该隐藏同名的成员
void Screen::dummy_fun(pos height){
cursor = width * this->height; // 成员 height 而不是参数列表中的参数
cursor = width * Screen::height; // 成员 height
}
// 不建议的写法:不要阴残外层作用域中可能被用到的被子
void Screen::dummy_fcn(pos height) {
cursor = width * ::height; // height 是那个全局变量
}
5 构造函数
5.1 构造函数初始值列表
构造函数的初始值有时必不可少
如果成员是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或者引用类型的唯一机会就是通过构造函数初始值,因此该构造函数的正确形式应该是:
//正确:显式地初始化引用和const成员
ConstRef::ConstRef(int ii):i(ii),ci(ii),ri(i) { }
注: 如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
建议: 使用构造函数初始值。在很多类中,初始化和赋值的区别事关底层效率问题:** 前者直接初始化数据成员,后者则先初始化在赋值。** 除了效率问题外更重要的是,一些数据成员必须被初始化。
成员初始化的顺序
成员的初始化顺序与它们在类定义中出现的顺序一致。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
class X {
int i;
int j;
public:
//未定义的:i在j之前被初始化
x(int val):j(val),i(j){ }
};
注: 最好令构造函数初始值的顺序与成员声明的顺序保持一致,如果可能的话,避免使用某些成员初始化其他成员。
默认实参和构造函数
class Sales_data {
public:
//定义默认构造函数,令其只接受一个 string 实参的构造函数功能相仿
Sales_data(std::string s="") : bookNo(s){ }
};
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
5.2 委托构造函数
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);
}
};
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交给委托者的函数体。
5.3 隐式的类类型转换
C++语言在内置类型有几种自动转换规则。实际上,我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作 转换构造函数。
string null_book = "9-999-99999-9";
// 构造一个临时的 Sales_data 对象
// 该对象的 units_sold 和 revenue 等于0,bookNo 等于 null_book
item.combine(null_book);
该构造函数通过 stiring 创建了一个(临时的)Sales_data 对象,随后将得到的对象传递给 combine。Sales_data 对象是一个临时量,一旦 combine 完成后我们就不能再访问它了。实际上,我们构建了一个对象,先将它的值加到 item 上,随后将其丢弃。
只允许一步类类型转换
编译器只会自动地执行一步类类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以是错误的:
// 错误,需要用户定义的两种转换。
// 把"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"));
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为 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 对象,之前的两种用法都无法通过编译:
item.combine(null_book); // 错误,string 构造函数是 explicit 的。
item.combine(cin); // 错误,istream 构造函数是 explicit
关键字 explicit 只对一个实参的构造函数有效。需要多个实参的构造函数不能用于隐式转换,所以无须将这些构造函数指定为 explicit 的。(只允许一步类类型转换)
注: explicit 只能出现在类内的构造函数声明处,类外不行。
explicit 构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用 explicit 构造函数,而且编译器将不会在自动初始化过程中使用该构造函数。
Sales_data item1(null_book); // 正确,直接初始化
// 错误,不能将 explicit 构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;
为转换显示的使用构造函数
尽管编译器不会将 explicit 的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:
// 正确,实参是一个显式构造的 Sales_data 对象
item.combine(Sales_data(null_book));
//正确,static_cast 可以使用 explicit 的构造函数
item.combine(static_cast<Sales_data>(cin));
注: static_cast 不包含底层 const 且具有明确定义的类型转换。
5.4 聚合类
聚合类 使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有的成员都是public的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有 virtual 函数。
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:
// 聚合类
struct Data{
int val;
string s;
};
// 初始化
Data val1 = {0, "Anna"};
注: 聚合类初始化对象的初始值顺序必须与声明的属性顺序一致。
显式地初始化类的对象成员有三个明显的缺点:
- 要求类的所有成员都是public的。
- 将正确初始化每个对象的每个成员的任务交给了类的用户。因为类的用户很容易忘记某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
- 添加或删除一个成员之后,所有的初始化语句都需要更新。
5.5 字面值常量类
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合以下要求,则它也是字面值常量类:
- 数据成员都是字面值类型。
- 类必须至少含有一个 constexpr 构造函数。
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的 constexpr 构造函数。
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象。
constexpr 构造函数
构造函数不能是 const 的,但是字面值常量类的构造函数可以是 constexpr 函数。
constexpr 构造函数可以声明成 =default的形式,或者是删除函数的形式。否则,constexpr 构造函数就必须既符合构造函数的要求(不能含有返回语句),又符合 constexpr 函数的要求(意味着它能拥有的唯一可执行语句就是返回语句。综合这两点来看,constexpr 构造函数体一般来说应该是空的。我们通过前置关键字 constexpr 就可以声明一个 constexpr 构造函数了:
class Debug{
public:
constexpr Debug(bool b=true): hw(b), io(b), other(b) {}
};
注: constexpr 构造函数必须初始化所有数据成员。
constexpr 构造函数用于生成 constexpr 对象以及 constexpr 函数的参数或返回类型:
constexpr Debug io_sub(false,true,false);
6 类的静态成员
有时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。例如,一个银行账户可能需要一个数据成员来表示当前的基准利率。在这个例子中,我们希望利率与类关联,而非与类的每个对象关联。从实现效率来讲,没必要每个对象都存储利率信息。而更加重要的的是,一旦利率浮动,我们希望所有对象都能使用新值。
声明类的静态成员
我们通过在成员的声明之前加上 static 使得其与类关联在一起。和其他成员一样,静态成员可以是 public 的或 private 的。
class Account{
public:
static double rate() {return interestRate;}
private:
static double interestRate;
};
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。它被所有该类的对象共享。
使用类的静态成员
-
我们可以使用作用域运算符直接访问类的静态成员:
double r; r = Account::rate(); // 使用作用域运算符访问静态成员
-
可以使用类的对象、引用或者指针来访问静态成员:
Account ac1; Account *ac2 = &ac1; // 调用静态成员函数 rate 的等价形式 r = ac1.rate(); // 通过 Account 的对象或引用 r = ac2->rate(); // 通过指向 Account 对象的指针
-
成员函数不用通过作用域运算符就能直接使用静态成员。
定义静态类成员
我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部声明语句。
注: 类外部的静态类成员,也需要知名成员所属的类名。static 关键字只出现在类内部的声明语句。
因为静态数据成员并不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。 这意味这它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。
注: 为了确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
静态成员的类内初始化
一般情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。
注: 即使一个常量静态数据成员在类内被初始化了,通常情况下也应该在类的外部定义一下该成员(成员的定义不能再带初始值,初始值在类内定义时提供)。
静态成员能用于某些场景,而普通成员不能
静态成员独立于任何对象,因此在某些非静态成员可能非法的场合,静态成员却可以正常地使用。静态数据成员可以是不完全类型,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员成员则受到限制,只能声明成它所属类的指针或引用。
class Bar {
private:
static Bar mem1; // 静态成员可以是不完全类型
Bar *mem2; // 正确 指针成员可以是不完全类型
Bar mem3; // 错误 数据成员必须是完全类型
};
不完全类型 指函数之外、类型的大小不能被确定的类型,只能以有限方式使用。不能定义该类型的对象。
静态数据成员和普通成员的另一个区别就是我们可以使用静态成员作为默认实参。非静态成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。
class Screen {
public:
// bkground 表示一个在类中稍后定义的静态成员
Screen& clear(char = bkground);
private:
static const char bkground;
};