目录
在C++ 语言中,我们使用类定义自己的数据类型。通过定义新的类型来反映待解决问题中的各种概念,可以使我们更容易编写、调试和修改程序。
类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装。需要首先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。
7.1 定义抽象数据类型
在第 1 章中使用的 Sales_item 类是一个抽象数据类型,我们通过它的接口来使用一个 Sales_item 对象。我们不能访问 Sales_item 对象的数据成员,事实上,我们根本不知道这个类有哪些数据成员。
与之相反, Sales_data 类不是一个抽象数据类型。它允许类的用户直接访问它的数据成员,并且要求由用户来编写操作。要想把 Sales_data 变成抽象数据类型,我们需要定义一些操作以供类的用户使用。一旦 Sales_data 定义了它自己的操作,我们就可以封装(隐藏)它的数据成员了。
7.1.1 设计 Sales_data 类
我们的最终目的是令 Sales_data 支持与 Sales_item 类完全一样的操作集合。Sales_item 类有一个名为 isbn 的成员函数(member function),并且支持+、=、+=、<< 和>>运算符。
Sales_data 的接口应该包含以下操作:
- 一个 isbn 成员函数,用于返回对象的 ISBN 编号
- 一个 combine 成员函数,用于将一个 Sales_data 对象加到另一个对象上
- 一个名为 add 的函数,执行两个 Sales_data 对象的加法
- 一个 read 函数,将数据从 istream 读入到 Sales_data 对象中
- 一个 print 函数,将 Sales_data 对象的值输出到 ostream
使用改进的 Sales_data 类
在考虑如何实现我们的类之前,首先来看看应该如何使用上面这些接口函数。、
Sales_data total; //保存当前求和结果的变量
if(read(cin, total)) { //读入第一笔交易
Sales_data trans; //保存下一条交易数据的变量
while(read(cin, trans)) { //读入剩余的交易
if(total.isbn() == trans.isbn()) //检查 isbn
total.combine(trans); //更新变量 total 当前的值
else {
print(cout, total) << endl; //输出结果
total = trans; //处理下一本书
}
}
print(cout, total) << endl; //输出最后一条交易
} else { //没有输入任何信息
cerr << "No data?!" << endl; //通知用户
}
7.1.2 定义改进的 Sales_data 类
定义成员函数
尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。对于 Sales_data类来说, isbn 函数定义在了类内,而 combine 和 avg_price 定义在了类外。
//首先介绍 isbn 函数,它的参数列表为空,返回值是一个 string 对象
std::string isbn() const { return bookNo; }
关于 isbn 函数一件有意思的事情是:它是如何获得 bookNo 成员所依赖的对象的呢?
引入 this
让我们再一次观察对 isbn 成员函数的调用:
total.isbn();
在这里,我们使用了点运算符来访问 total 对象的 isbn 成员,然后调用它。
7.6节 将介绍一种例外的形式,当我们调用成员函数时,实际上是在替某个对象调用它。如果 isbn 指向 Sales_data 的成员(例如 bookNo),则它隐式地指向调用该函数的对象的成员。在上面所示的调用中,当 isbn 返回 bookNo 时,实际上它隐式地返回 total.bookNo。
成员函数通过一个名为 this 的额外的隐私参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化 this。例如,上面的调用可以重写为:
//伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&total)
其中,调用 Sales_data 的 isbn 成员时传入了 total 的地址。
任何对类成员的直接访问都被看作 this 的隐式引用,也就是说,当 isbn 使用 bookNo 时,它隐式地使用 this 指向的成员,就像我们书写了 this->bookNo 一样。
因为 this 的目的总是指向“这个”对象,所以 this 是个常量指针。
引入 const 成员函数
使用 const 的成员函数被称作常量成员函数(const member function)。
常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
类作用域和成员函数
即使 bookNo 定义在 isbn 之后,isbn也还是能够使用 bookNo。这是因为,编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
在类的外部定义成员函数
在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。
定义一个返回 this 对象的函数
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold += rhs.units_sold; //把 rhs 的成员加到 this 对象的成员上
revenue += rhs.revenue;
return *this //返回调用该函数的对象
}
return 语句解引用 this 指针以获得执行该函数的对象,换句话说,上面的这个调用返回 total 的引用。
7.1.2 节练习
//练习7.4:编写一个名为 Person 的类,使其表示人员的姓名和住址。
//使用 string 对象存放这些元素
class Person
{
private:
string m_name;//姓名
string m_address;//地址
public:
//练习7.5:在你的 Person 类中提供一些操作使其能够返回姓名和住址
//这些函数是否应该是 const 的呢?解释原因。
//应该,因为返回姓名和地址都只是读取数据成员的值,不会做任何改变
string getName() const { return m_name; }//返回姓名
string getAddress()const { return m_address; }//返回地址
};
7.1.3 定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,比如 add、read 和 print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成成分,但它们实际上并不属于类本身。
如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。
定义 read 和 print 函数
istream &read(istream &is, Sales_data &item)
{
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " <<item.avg_price();
return os;
}
关于上面的函数有两点是非常重要的。第一点,read 和 print 分别接受一个各自 IO类型的引用作为其参数,这是因为 IO类属于不能被拷贝的类型。因此只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量引用。
第二点,print 函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码决定是否换行。
7.1.4 节练习
//练习7.9:添加读取和打印Person对象的操作
istream &read(istream &is, Person &per)
{
is >> per.m_name >> per.m_address;
return is;
}
ostream &print(ostream &os, const Person &per)
{
os<< per.m_name << per.m_address;
return os;
}
read 函数的返回类型是std::istream &,所以 read(cin, data1)的返回值可以继续作为外层 read 函数的实参使用。该条件检验读入 data1 和 data2 的过程是否正确,如果正确,条件满足;否则条件不满足。
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;一个类可以包含多个构造函数,不同构造函数之间必须在参数数量或参数类型上有所区别。
不同于其他成员函数,构造函数不能被声明成 const 的。当我们创建类的一个 const 对象时,知道构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在 const 对象的构造过程中可以向其写值。
昨天写的忘记保存,结果就没了。。。。
7.3.2 返回 *this 的成员函数
继续添加一些函数,它们负责设置光标所在位置的字符或者其他任一给定位置的字符:
//设置光标所在位置的字符
Screen &set(char c);
Screen &set(pos r, pos col, char ch);
Screen & Screen::set(char c)
{
// TODO: 在此处插入 return 语句
contents[cursor] = c;//设置当前光标所在位置的新值
return *this; 将 this 对象作为左值返回
}
Screen & Screen::set(pos r, pos col, char ch)
{
// TODO: 在此处插入 return 语句
contents[r*width + col] = ch;//设置给定位置的新值
return *this; //调用 this 对象作为左值返回
}
和 move 操作一样, set 成员的返回值是调用 set 的对象的引用。返回引用的函数是左值(6.3.2 节)的,意味着这些函数返回的是对象本身而非对象的副本。如果我们把一系列这样的操作连接在一条表达式中的话:
//把光标移动到一个指定的位置,然后设置该位置的字符值
myScreen.move(4,0).set('#');
这些操作将在同一个对象上执行。上述的语句等价于:
myScreen.move(4,0);
myScreen.set('#');
如果我们令 move 和 set 返回 Screen 而非 Screen&,则上述语句的行为将大不相同:
//如果 move 返回 Screen 而非 Screen&
Screen temp = myScreen.move(4,0);//对返回值进行拷贝
temp.set('#');//不会改变 myScreen 的contents
从 const 成员函数返回 *this
接下来,我们继续添加一个名为 display 的操作,它负责打印 Screen 的内容。我们希望这个函数能和 move 以及 set 出现在同一序列中,因此类似于 move 和 set,display 函数也应该返回执行它的对象的引用。
从逻辑上来说,显示一个 Screen 不需要改变它的内容,因此我们令 display 为一个 const 成员,此时,this 将是一个指向 const 的指针,而 *this 是 const 对象。由此推断,display 的返回类型应该是 const Screen&。然而 ,如果真的令 display 返回一个 const 的引用,则我们不能将不能把 display嵌入到一组动作序列中去:
Screen myScreen;
//如果 display 返回常量引用,则调用 set 将引发错误
myScreen.display(cout).set('*');
即使 myScreen 是个非常量对象,对 setd 调用也无法通过编译。问题在于 display 的 const 版本返回的是常量引用,而我们显然无权 set 一个常量对象。
一个 const 成员函数如果以引用的形式返回 *this ,那么它的返回类型将是常量引用。
基于 const 的重载
通过区分成员函数是否是 const 的,我们可以对其进行重载。
public:
//根据对象是否是 const 重载了 display 函数
Screen &display(ostream &os)
{
do_display(os); return *this;
}
const Screen &display(ostream &os) const
{
do_display(os); return *this;
}
private:
void do_display(ostream &os) const { os << contents; }
当 do_display 完成后,display 函数各自返回解引用 this 所得的对象。在非常量版本中, this 指向一个非常量对象,因此 display 返回一个普通的(非常量)引用;而 const 成员则返回一个常量引用。
建议:对于公共代码使用私有功能函数
为什么要定义一个单独的 do_display 函数?主要是出于以下原因:
- 一个基本的愿望是避免在多处使用同样的代码。
- 我们预期随着类的规模的发展,display 函数有可能变得更加复杂,此时,把相应的操作写在一处而非两处的作用就比较明显了。
- 我们很可能在开发过程中给 do_display 函数添加某些调试信息,而这些调试信息将在代码的最终产品版本中去掉。只在 do_display 一处添加或删除这些信息要更容易一些。
- 这个额外的函数调用不会增加任何开销。因为我们在类内部定义了 do_display ,所以它隐式地被声明成内联函数。这样的话,调用 do_display 就不会带来任何额外的运行时开销。
7.3.2 节练习
练习 7.27:给i自己的 Screen 类添加 move、set 和 display 函数,通过执行下面的代码检验你的类是否正确。
Screen.h
#pragma once
#include<iostream>
#include<string>
using namespace std;
class Screen
{
public:
Screen() = default;//因为Screen 有另一个构造函数,所有本函数是必需的
//cursor被其类内初始值初始化为0
Screen(int ht, int wd, char c) :height(ht), width(wd), contents(ht*wd, c) {}
//读取光标处的字符 隐式内联
char get() const
{
return contents[cursor];
}
//显式内联
inline char get(unsigned ht, unsigned wd) const;
//能在之后被设为内联
//可以在函数的定义处指定inline
Screen &move(unsigned r, unsigned c)
{
// TODO: 在此处插入 return 语句
unsigned row = r * width;
cursor = row + c;
return *this;
}
//设置光标所在位置的字符
Screen &set(char ch);
Screen &set(unsigned r, unsigned c, char ch);
//根据对象是否是 const 重载了 display 函数
Screen &display(ostream &os)
{
do_display(os); return *this;
}
const Screen &display(ostream &os) const
{
do_display(os); return *this;
}
public:
void some_member() const;
private:
mutable size_t access_ctr;//即使在一个 const 对象内也能被修改
void do_display(ostream &os) const { os << contents; }
private:
unsigned cursor = 0;
unsigned height = 0, width = 0;
string contents;
};
Screen.cpp
#include"Screen.h"
//在类的内部声明成 inline
char Screen::get(unsigned r, unsigned c) const
{
unsigned row = r * width;
return contents[row + c];
return 0;
}
Screen & Screen::set(char ch)
{
// TODO: 在此处插入 return 语句
contents[cursor] = ch;//设置当前光标所在位置的新值
return *this; 将 this 对象作为左值返回
}
Screen & Screen::set(unsigned r, unsigned c, char ch)
{
// TODO: 在此处插入 return 语句
contents[r*width + c] = ch;//设置给定位置的新值
return *this; //调用 this 对象作为左值返回
}
void Screen::some_member() const
{
++access_ctr;//保存一个计数值,用于记录成员函数被调用的次数
}
检验代码 test.cpp
#include<iostream>
#include"Screen.h"
int main()
{
Screen myScreen(5,5, 'X');
myScreen.move(4, 0).set('#').display(cout);
cout << "\n";
myScreen.display(cout);
cout << "\n";
}
运行结果
练习7.28:如果move、set 和 display 函数的返回类型不是 Screen& 而是 Screen ,则在上一个练习中将会发生什么?
返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。如果我们把一系列这样的操作连在一起的话,所有这些操作将在同一个对象上执行。
相反,如果我们把 move、set 和 display 函数的返回类型改成 Screen,则上述函数各自只返回一个临时副本,不会改变 myScreen 的值。
练习7.29:修改你的Screen类,并检查程序的运行结果。
修改程序后,得到的运行结果为:
和之前有细微的差别。
练习7.30:通过 this 指针使用成员的做法虽然合法,但是有点多余。讨论显式地使用指针访问成员的优缺点。
通过 this 指针访问成员的优点是,可以非常明确地指出访问的是对象的成员,并且可以在成员函数中使用与数据成员同名的形参;缺点是显得多余,代码不够简洁。
7.3.3 类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。例如:
struct First{
int memi;
int getMem();
};
struct Second{
int memi;
int getMem();
};
First obj1;
Second obj2 = obj1; //错误,obj1 和 obj2 的类型不同
类的声明
就像可以把函数的声明和定义分离开来一样(6.1.2节),我们也能仅仅声明类而暂时不定义它:
class Screen; //Screen 类的声明
这种声明有时被称作前向声明(foeward declaration),它向程序中引入了名字 Screen 并且指明 Screen 是一种类类型。对于类型 Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知 Screen 是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
7.3.3 节练习
//练习7.31:定义一对类 X 和 Y,其中 X 包含一个指向 Y 的指针,而 Y 包含一个类型为 X 的对象
class Y;
class X
{
Y* y;
};
class Y
{
X x;
};
7.3.4 友元再探
类之间的友元关系
举个友元类的例子,我们的 Windoe_mgr (7.3.1节)的某些成员可能需要访问它管理的 Screen 类的内部数据。例如,假设我们需要为 Windoe_mgr 添加一个名为 clear 的成员,它负责把一个指定的 Screen 的内容都设为空白。为了完成这一任务,clear 需要访问 Screen 的私有成员;而要想令这种访问合法,Screen 需要把 Window_mgr 指定成它的友元:
class Screen
{
//Window_mgr 的成员可以访问 Screen 类的私有部分
friend class Window_mgr;
};
如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。
Window_mgr 的 clear 成员写成如下形式:
class Window_mgr
{
public:
//按照编号将指定的 Screen 重置为空白
void clear(unsigned index);
private:
//默认情况下,一个Window_mgr包含一个标准尺寸的空白Screen
vector<Screen> screens{ Screen(24,80,' ') };
};
Window_mgr.cpp
#include "Window_mgr.h"
void Window_mgr::clear(unsigned index)
{
//s 是一个 Screen 的引用,指向我们想清空的那个屏幕
Screen &s = screens[index];
//将那个选定的 Screens 重置为空白
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(unsigned);
};
要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:
- 首先定义 Window_mgr 类,其中声明 clear 函数,但是不能定义它。在 clear 使用 Screen 的成员之前必须先声明 Screen。
- 接下来定义 Screen,包括对于 clear 的声明。
- 最后定义 clear ,此时它才可以使用 Screen 的成员。
函数重载和友元
尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当第一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中(7.2.1节)。
甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅用声明友元的类的成员调用该友元函数,它也必须是被声明过的。
class X
{
public:
friend void f(){/*友元函数可以定义在类的内部*/ }
X() { f(); } //错误:f 还没有被声明
void g();
void h();
};
void X::g() { return f(); }//错误:f 还没有被声明
void f(); //声明那个定义在X中的函数
void X::h() { return f(); } //正确:现在f 的声明在作用域中了
注意,有的编译器并不强制执行上述关于友元的限定规则。
7.3.4 节练习
练习 7.32:定义你自己的 Screen 和 Window_mgr,其中 clear 是 Window_mgr 的成员,是 Screen 的友元。
class Window_mgr
{
public:
//按照编号将指定的 Screen 重置为空白
void clear(unsigned index);
private:
//默认情况下,一个Window_mgr包含一个标准尺寸的空白Screen
vector<Screen> screens{ Screen(24,80,' ') };
};
#include"Window_mgr.h"
class Screen
{
//Window_mgr::clear 必须在 Screen类之前被声明
friend void Window_mgr::clear(unsigned index);
};
7.4 类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符访问。
作用域和定义在类外部的成员
一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名(7.1.2节)。在类的外部,成员的名字被隐藏起来了。
一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无须再次授权了。
另一方面,函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。
7.4 节练习
练习 7.33:如果我们给 Screen 添加一个如下所示的 size 成员将发生什么情况?如果出现了问题,请尝试修改它。
7.4.1 名字查找与类的作用域
在目前为止,我们编写的程序中,名字查找(name lookup)(寻找与所用名字最匹配的声明的过程)的过程比较直截了当:
用于类成员声明的名字查找
这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明中使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。例如:
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。
typedef double Money;
class Acount {
public:
Money balance() { return bal; }//使用外层作用域的Money
private:
typedef double Money; //错误:不能重新定义 Money
Money bal;
};
需要特别注意的是,即使 Account 中定义的 Money 类型与外层作用域一致,上述代码仍然是错误的。
尽管重新定义类型名字是一种错误的行为,但是编译器并不为此负责。一些编译器仍将顺利通过这样的代码,而忽略代码有错的事实。
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
成员定义中的普通块作用域的名字查找
类作用域之后,在外围的作用域中查找
如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。
如果外层作用域中对象被同名成员隐藏掉了,可以显式地通过作用域运算符来进行访问。
在文件中名字的出现处对其进行解析
7.4.1 节练习
练习 7.34:如果我们把 Screen 类的 pos 的 typedef 放在类的最后一行会发生什么情况?
这样做会导致编译出错。
练习 7.35:解释这段代码的含义,说明其中的 Tyoe 和 initVal 分别使用了哪个定义。如果代码存在错误,尝试修改它。
7.5 构造函数再探
7.5.1 构造函数初始值列表
当我们定义变量时习惯于立即对其进行初始化,而非先定义、再赋值:
string foo = "hello"; //定义并初始化
string bar; //默认初始化成空 string 对象
bar = "hello"; //为 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) :i(ii), ci(ii), ri(i) {}
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(int ii) :i(ii), ci(ii), ri(i) {}
int main()
建议:使用构造函数初始值
在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。
除了效率问题外更重要的是,一些数据成员必须被初始化。养成使用构造函数初始值的习惯,能避免某些意想不到的编译错误。
成员初始化的顺序
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
成员初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值类别中初始值的前后位置关系不会影响实际的初始化顺序。
一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了。
class A
{
int i;
int j;
public:
//未定义的:i 在 j 之前被初始化
A(int val):j(val),i(j){}
};
在此例中,从构造函数初始值的形式上来看仿佛是先用 val初始化了 j,再用 j 初始化 i。实际上, i 先被初始化,因此这个初始值的效果是试图使用未定义的值 j 初始化 i !、
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
如果可以的话, 最好用构造函数的参数作为成员的初始值,而尽量避免使用同一个对象的其他成员。这样就不必考虑成员的初始化顺序。比如写成这样:
A(int val):j(val),i(j){}
默认实参和构造函数
7.5.1 节练习
练习7.36:下面的初始值是错误的,请找出问题所在并尝试修改它。
//练习7.36:下面的初始值是错误的,请找出问题所在并修改它
struct X
{
X(int i,int j):base(i),rem(base % j){}
int rem, base;
};
在类 X 中,两个数据成员出现的顺序是 rem 在前,base 在后,所以当执行 X 对象的初始化操作时先初始化 rem。如上述代码所述,初始化 rem 要用到 base 的值,而此时 base 尚未被初始化,因此会出现错误。
修改:只需把变量 rem 和 base 的次序调换过来即可。为:int base,rem;
练习 7.37:使用本节提供的 Sales_data 类,确定初始化下面的变量时分别使用了哪个构造函数,然后罗列出每个对象所有数据成员的值。
Sales_data first_item(cin);//接受了 std::istream& 参数的构造函数,该对象的值依赖于用户的输入
int main()
{
Sales_data next;//使用了Sales_data 的默认构造函数,其中 string 类型的成员 bookNo 默认初始 化为空字符串,其他几个成员使用类内初始值初始化为0
Sales_data last("9-999-99999-9");//使用了接受 const string& 参数的构造函数,其中 bookNo 使用实参初始化为"9-999-99999-9",其他几个成员使用类内初始值初始化为0
}
练习 7.38:有些情况下我们希望提供 cin 作为接受 istream& 参数的构造函数的默认实参,请声明这样的构造函数。
Sales_data(std::istream &is = std::cin){is >> *this;}
练习7.39:如果接受 string 的构造函数和接受 istream& 的构造函数都使用默认实参,这种行为合法吗?如果不,为什么?
如果我们为构造函数的全部形参都提供了默认实参(包括为只接受一个形参的构造函数提供默认实参),则该构造函数同时具备了默认构造函数的作用。此时即使我们不提供任何实参地创建类的对象,也可以找到可用的构造函数。
然而,如果按照本题的叙述,我们为两个构造函数同样都赋予了默认实参,则这两个构造函数都具有了默认构造函数的作用。一旦我们不提供任何实参地创建类的对象,则编译器无法判断这两个(重载的)构造函数哪个更好,从而出现了二义性。
练习7.40:从下面的抽象概念中选择一个,思考这样的类需要哪些数据成员,提供一组合理的构造函数并阐明这样做的原因。
//练习7.40:
class Book
{
private:
string Name, ISBN, Author, Publisher;
double price;
public:
Book() = default;
Book(const string &name,const string &isbn,const string &author,const string &publisher,double p)
:Name(name),ISBN(isbn),Author(author),Publisher(publisher),price(p){}
Book(std::istream &is) { is >> *this; }
};
7.5.2 委托构造函数
C++11 新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数体内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
使用委托构造函数重写 Sales_data 类:
//使用委托构造函数重写 Sales_data 类
class Sales_data
{
private:
string bookNo;
unsigned units_sold, revenue;
public:
//非委托构造函数使用对应的实参初始化成员
Sales_data(string s,unsigned cnt,double price):
bookNo(s),units_sold(cnt),revenue(cnt*price){}
//其余构造函数全都委托给另一个构造函数
Sales_data() :Sales_data("", 0, 0){}
Sales_data(string s):Sales_data(s,0,0){}
Sales_data(istream &is) :Sales_data() { read(is, *this); }
};
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在 Sales_data 类中,受委托的构造函数体恰好是空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交换给委托者的函数体。
7.5.2 节练习
练习 7.41:使用委托构造函数重新编写你的 Sales_data 类,给每个构造函数体添加一条语句,令其一旦执行就打印一条信息。用各种可能的方式分别创建 Sales_data 对象,认真研究每次输出的信息直到你确实理解了委托构造函数的执行顺序位置。
#include<iostream>
#include<string>
using namespace std;
class Sales_data
{
//声明友元函数
friend istream &read(istream &is, Sales_data &item);
friend ostream &print(ostream &os, const Sales_data &item);
public:
//委托构造函数
Sales_data(const string &book, unsigned num, double sellp, double salep)
:bookNo(book), units_sold(num), sellingprice(sellp), saleprice(salep)
{
if (sellingprice)
discount = saleprice / sellingprice;
cout << "该构造函数接受书号、销售量、原价、实际售价四个信息" << endl;
}
Sales_data() :Sales_data("", 0, 0,0)
{
cout << "该构造函数无须接受任何信息" << endl;
}
Sales_data(const string &book) :Sales_data(book,0, 0, 0)
{
cout << "该构造函数接受书号信息" << endl;
}
Sales_data(istream &is) :Sales_data()
{
read(is, *this);
cout << "该构造函数接受用户输入的信息" << endl;
}
private:
string bookNo; //书籍编号
unsigned units_sold = 0; //销售量
double sellingprice = 0.0; //原始价格
double saleprice = 0.0; //实售价格
double discount = 0.0; //折扣
};
istream &read(istream &is, Sales_data &item)
{
is >> item.bookNo >> item.units_sold >> item.sellingprice >> item.saleprice;
return is;
}
ostream &print(ostream &os, const Sales_data &item)
{
os << item.bookNo << " " << item.units_sold << " " << item.sellingprice << " "
<< item.saleprice<< " " << item.discount;
return os;
}
int main()
{
Sales_data first("987-7-121-1553-2", 85, 128, 109);
Sales_data second;
Sales_data third("987-25154-11-1");
Sales_data last(cin);
return 0;
}
7.5.3 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:
- 当我们在块作用域内不适应任何初始值定义一个非静态变量(2.2.1节)或者数组时(3.5.1节)。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时(7.1.4节)。
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时(7.1.4节)。
值初始化在以下情况发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时(3.5.1节)。
- 当我们不使用初始值定义一个局部静态变量时(6.1.1节)。
- 当我们书写形如T()的表达式显式地请求值初始化时,其中 T 是类型名(vector 的一个构造函数只接受一个实参用于说明 vector 大小(3.3.1节),它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。
类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。
不那么明显的一种情况是类的某些数据成员缺少默认构造函数:
//类的某些数据成员缺少默认构造函数的情况:
class NoDefault
{
public:
NoDefault(const string&);
//还有其他成员,但是没有其他构造函数了
};
struct A
{
NoDefault my_mem;
};
A a; //错误:不能为A合成构造函数
struct B
{
B(){}//错误:b_member 没有初始值
NoDefault b_member;
};
在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。
使用默认构造函数
下面的 obj 声明可以正常编译通过:
Sales_data obj(); //正确:定义了一个函数而非对象
if (obj.isbn() == Primer_5th_ed.isbn());//错误:obj是一个函数
但当我们试图使用 obj 时,编译器将报错,提示我们不能对函数使用成员访问运算符。问题在于,尽管我们想声明一个默认初始化的对象,obj 实际的含义却是一个不接收任何参数的函数并且其返回值是 Sales_data 类型的对象。
如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对:
//正确:obj 是个默认初始化的对象
Sales_data obj;
7.5.3 节练习
//练习7.43:假定有一个名为 NoDefault 的类,它有一个接受 int 的构造函数,
//但是没有默认构造函数。定义类 C,C有一个 NoDefault 类型的成员,定义 C的默认构造函数
class NoDefault
{
public:
NoDefault(int i)
{
val = i;
}
//还有其他成员,但是没有其他构造函数了
int val;
};
class C
{
public:
NoDefault m;
//必须显式地调用 NoDefault 的带参构造函数初始化 m
C(int i=0):m(i){}
};
int main()
{
C c;//使用了C的默认构造函数
cout << c.m.val << endl;
return 0;
}
练习 7.44:下面这条声明合法吗?如果不,为什么?
vector<NoDefault> vec(10);
上述语句的含义是创建一个 vector 对象 vec,该对象包含 10 个元素,每个元素的类型都是 NoDefault 且执行默认初始化。然而,因为我们在类NoDefault 的定义中没有涉及默认构造函数,所以所需的默认初始化过程无法执行。编译器会报错。
练习7.45:如果在上一个练习中定义的 vector 的元素类型是 C,则声明合法吗?为什么?
合法,因为我们给类型C 定义了带参数的默认构造函数,它可以完成声明语句所需的默认初始化操作。
练习 7.46:下面哪些论断是不正确的?为什么?
(a)一个类必须至少提供一个构造函数。
(b)默认构造函数是参数列表为空的构造函数。
(c)如果对于类来说不存在有意义的默认值,则类不应该提供默认构造函数。
(d)如果类没有定义默认构造函数,则编译器将为其生成一个并把每个数据成员初始化成相应类型的默认值。
(a)是错误的,类可以不提供任何构造函数,这时编译器自动实现一个合成的默认构造函数。
(b)错误,如果某个构造函数包含若干形参,但是同时为这些形参都提供了默认实参,则该构造函数也具备默认构造函数的功能。
(c)错误,如果一个类没有默认构造函数,也就是说我们定义了该类的某些构造函数但是没有为其设计默认构造函数,则当编译器确实需要隐式地使用默认构造函数时,该类无法使用。所以一般情况下,都应该为类构建一个默认构造函数。
(d)错误,对于编译器合成的默认构造函数来说,类类型的成员执行各自所属类的默认构造函数,内置类型和复合类型的成员只对定义在全局作用域中的对象执行初始化。
7.5.4 隐式的类类型转换
4.11节曾经介绍过C++语言在内置类型之间定义了几种自动转换规则。同样的,我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称为转换构造函数(converting constructor)。
只允许一步类型转换
编译器只会自动地执行一步类型转换。
类类型转换不是总有效
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为 explicit 加以阻止。
explicit 构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用 = )(3.2.1 节)。此时,我们只能使用直接初始化而不能使用 explicit 构造函数。
Sales_data item1(null_book); //正确:直接初始化
//错误:不能将 explicit 构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;
为转换显式地使用构造函数
标准库中含有显式构造函数的类
我们用过的一些标准库中的类含有单参数的构造函数:
- 接受一个单参数的 const char* 的 string 构造函数 不是 explicit 的
- 接受一个容量参数的 vector 构造函数是 explicit的。
7.5.4 节练习
练习 7.47:说明接受一个 string 参数的 Sales_data 构造函数是否应该是 explicit 的,并解释这样做的优点。
应该是,否则编译器可能会自动把一个 string 对象转换成 Sales_data 对象,这种做法显得有些随意,某些时候会与程序员的初衷相违背。
使用 explicit 的优点是避免因隐式 类类型转换而带来意想不到的错误,缺点是当用户的确需要这样的类类型转换时,不得不使用略显烦琐的方式来实现。
练习 7.48:假定 Sales_data 的构造函数不是 explicit 的,则下述定义将执行什么样的操作?如果 Sales_data的构造函数是 explicit 的,又会发生什么?
string null_isbn("9-999-99999-9");
Sales_data item1(null_isbn);
Sales_data item2("9-999-99999-9");
第一行创建了一个 string 对象,第二行和第三行都是调用 Sales_data 的构造函数(该构造函数接受一个 string)创建它的对象。此处无须任何类型转换,所以不论 Sales_data 的构造函数是不是 explicit 的,item1 和 item2 都能被正确地创建,它们的 bookNo成员都是9-999-99999-9,其他成员都是 0 。
练习 7.49:对于 combine 函数的三种不同声明,当我们调用 i.combine(s) 时分别发生什么情况?其中 i 是一个 Sales_data,而 s 是一个 string 对象。
(a) Sales_data &combine(Sales_data);
(b) Sales_data &combine(Sales_data&);
(c) Sales_data &combine(const Sales_data&) const;
(a)正确。编译器首先用给定的 string 对象 s 自动创建一个 Sales_data 对象,然后这个新生成的临时对象传给 combine 的形参(类型是 Sales_data),函数正确执行并返回结果。
(b)无法编译通过,因为 combine 函数的参数是一个非常量引用,而 s 是一个 string 对象,编译器用 s 自动创建一个 Sales_data 临时对象,但是这个新生成的临时对象无法传递给 combine 所需的非常量引用。如果我们把函数声明修改为 Sales_data &combine(const Sales_data&); 就可以了。
(c)无法编译通过,因为我们把 combine 声明成了常量成员函数,所以该函数无法修改数据成员的值。
7.5.5 聚合类
聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是 public 的。
- 没有定义任何构造函数。
- 没有类内初始值(参见2.6.1节)。
- 没有基类,也没有 virtual 函数。
例如,下面这个类就是一个聚合类:
struct Data
{
int ival;
string s;
};
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:
//vall.ival = 0;vall.s = string("Anna")
Data vall = {0, "Anna"};
初始值的顺序必须与声明的顺序一致。
与初始化数组元素的规则(3.5.1节)一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。
值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点:
- 要求类的所有成员都是 public 的。
- 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
- 添加或删除一个成员之后,所有的初始化语句都需要更新。
7.5.5 节练习
练习 7.52:解释下面的初始化过程。如果存在问题,尝试修改它。
Sales_data item = {"9-778-22222", 25, 15.99);
去掉数据成员 units_sold 和 revenue 的类内初始值。
7.5.6 字面值常量
在6.5.2节中我们提到过 constexpr 函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有 constexpr 函数成员。这样的成员必须符合 constexpr 函数的所有要求,它们是隐式 const 的。
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型。
- 类必须至少含有一个 constexpr构造函数。
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式(2.4.4节);或者成员属于某种类类型,则初始值必须使用成员自己的 constexpr 构造函数。
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象
constexpr
尽管构造函数不能是 const 的,但是字面值常量类的构造函数可以是 constexpr函数。事实上,一个字面值常量类必须至少提供一个 constexpr 构造函数。
constexpr 构造函数可以声明成 = default 的形式(或者是删除函数的形式(13.1.6节))。否则,constexpr 构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又符合 constexpr 函数的要求(意味着它能拥有的唯一可执行语句就是返回语句(6.5.2节))。综合这两点可知,constexpr 构造函数体一般来说应该是空的。
7.5.6 节练习
练习 7.54:Debug 中以 set_开头的成员应该被声明成 constexpr 吗?如果不,为什么?
这些以 set_ 开头的成员不能声明成 constexpr ,这些函数的作用是设置数据成员的值,而 constexpr 函数只能包含 return 语句,不允许执行其他任务。
7.6 类的静态成员
有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。
声明静态成员
我们通过在成员的声明之前加上关键字 static 使得其与类关联在一起。
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。
使用类的静态成员
我们使用作用域运算符直接访问静态成员。
double r;
r = Account::rate(); //使用作用域运算符访问静态成员
虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:
Account ac1;
Account ac2;
//调用静态成员函数 rate 的等价形式
r = ac1.rate(); //通过Account 的对象或引用
r = ac2->rate(); //通过指向 Account 对象的指针
成员函数可以直接使用静态成员,不需要作用域运算符。
定义静态成员
和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复 static 关键字,该关键字只出现在类内部的声明语句。
和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static 关键字则只出现在类内部的声明语句中。
一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。一个静态数据成员只能定义一次。一旦它被定义,就将一直存在于程序的整个生命周期中。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr(7.5.6节)。初始值必须是常量表达式。
静态成员能用于某些场景,而普通成员不能
静态成员独立于任何成员。因此,在某些非静态成员可能非法的场合,静态成员却可以正常地使用。举个例子,静态数据成员可以是不完全类型(7.3.3节)。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用。
class Bar
{
public:
// ...
private:
static Bar mem1; //正确:静态成员可以是不完全类型
Bar *mem2; //正确:指针成员可以是不完全类型
Bar mem3; //错误:数据成员必须是完全类型
};
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参。非静态数据成员不能作为实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。
7.6 节练习
练习7.56:什么是类的静态成员?它有何优点?静态成员与普通成员有何区别?
静态成员是指声明语句之前带有关键字 static 关键字的类成员,静态成员不是任意单独对象的组成部分,而是由该类的全体对象所共享。
静态成员的优点包括:作用域位于类的范围之内,避免与其他类的成员或者全局作用域的名字冲突;可以是私有成员,而全局对象不可以;通过阅读程序可以非常容易地看出静态成员与特定类关联,使得程序的含义清晰明了。
静态成员与普通成员的区别主要体现在普通成员与类的对象关联,是某个具体对象的组成部分;而静态成员不从属于任何具体的对象,它由该类的所有对象共享。另外,还有一个细微的区别,静态成员可以作为默认实参,而普通数据成员不能作为默认实参。
练习 7.57:编写你自己的 Account 类。
class Account
{
private:
string strName;
double dAmount = 0.0;
static double dRate;
};
练习 7.58:下面的静态数据成员的声明和定义由错误吗?请解释原因。
//Example.h
class Example
{
public:
static double rate = 6.5;
static const int vecSize = 20;
static vector<double> vec(vecSize);
};
//Example.cpp
#include"Example.h"
double Example::rate;
vector<double> Example::vec;
在类的内部,rate 和 vec 的初始化是错误的,因为除了静态常量成员之外,其他静态成员不能在类的内部初始化。另外,Example.cpp 文件的两条语句也是错误的,因为我们在这里必须给出静态成员的初始值。