7.1定义抽象类型
引入this
如果想要调用Sales_data的成员函数isbn,我们可以使用点运算符来访问对象的isbn成员,例如:
total.isbn();
在7.6节中将介绍一种例外的形式,当我们调用成员函数时,实际上是在替某个对象调用它。成员函数通过一个隐藏的this指针参数来访问调用它的对象。上述的代码中,编译器负责把total的地址隐式的传递给this指针,可以等价的认为,编译器将该调用重写成了以下形式:
//伪代码,用于说明调用成员函数的实际过程
Sales_data::isbn(&total);
引入const成员函数
默认情况下this指针是指向类类型非常量版本的常量指针,意味着我们不能把一个常量对象绑定到this指针上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。
看样子我们需要将this指针声明为const Sales_data *const,然而this指针是隐式的,并且不会出现在参数列表中。C++语言中的做法是允许把const关键字放在成员函数的参数列表之后,此时,跟在参数列表后的const表示this是一个指向常量的指针。
在类的外部定义成员函数
像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配,也就是返回类型、参数列表和函数名都必须与类的内部的声明保持一致。同时,类外部定义的成员名字必须包含它所属的类名:
double Sales_data::avg_price()const{/*...*/}
构造函数
每个类都定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化对象的数据成员,所以无论何时,只要对象被创建就会调用构造函数。
构造函数的名字和类名一样,与其他函数不同的是,构造函数没有返回类型。
如果我们没有为一个类显示的定义一个构造函数,编译器就会为我们隐式的合成一个默认构造函数。合成的默认构造函数按照以下的规则初始化数据成员:
- 如果类内存在初始值,用它来初始化成员
- 否则,默认初始化该成员
注:只有当我们没有定义构造函数时,编译器才自动合成默认构造函数。
还有另一点要注意的是,某些类不能依赖自动合成默认构造函数。一个典型的例子是:如果一个类中含有另一个类的对象,且另一个类没有默认构造函数,那么这个类也无法自动生成一个合法的默认构造函数。
拷贝赋值和析构
除了定义类的对象如何初始化外,类还需要控制拷贝、赋值和销毁对象时发生的行为。
如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。
7.2访问控制与封装
在C++中我们可以使用访问说明符来来增加类的封装性:
- 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口
- 定义在private说明符之后的成员可以被类的成员函数访问,但是不能使用该类的代码访问,private部分封装了(即隐藏了)类的细节
使用class或struct关键字
实际上我们可以使用这两个关键字中的任何一个定义类,唯一的区别是struct和class的默认访问权限不同。类可以在它的第一个访问说明符之前定义成员,若是struct关键字,则在访问说明符之前定义的成员是public的,class则相反。
友元
类可以允许其他类或者函数访问它的非公有成员,方法是另其他类或者函数成为它的友元。如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:
class Sales_data
{
friend std::istream &read(std::istream&,Sales_data&);
};
友元只能出现在类内部定义,但是在类出现的具体位置不限。一般来说,最好在类的定义开始或结束前的位置集中声明友元。
友元的声明仅仅指定了访问权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。
7.3类的其他特性
重载成员函数
和非成员函数一样,成员函数也可以被重载,只要函数之间在参数的数量和类型上有所区别就行。
类数据成员的初始值
如果我们希望某个类中开始时总是拥有一个默认初始化的成员,在C++11的新标准中,最好的方式是把这个默认值声明成一个类内初始值。
类类型
每个类定义了唯一的类型。对于两个类来说即使他们的成员完全一样,这两个类也是不同的类型。例如:
class First
{
int memi;
int getMem();
};
class Second
{
int memi;
int getMem();
};
类的声明
就像我们可以把函数的声明和定义分开一样,我们也能仅仅声明类而不定义它:
class Screen;
对于此类型来说,在它定义之前是不完全类型。不完全类型只能在非常有限的情境下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅声明,否则编译器将不知道这样的对象需要多少存储空间。特别的:因为只有当类全部完成后类才算被定义,所以一个类的成员不能是自己;然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针。
友元声明和作用域
struct X
{
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.4类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符进行访问。
一般来说,内层作用域可以重新定义外层作用域的名字,即使该名字在内层已经使用过。然而在类中,如果成员使用了外层作用域的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:
typedef double Money;
class Account
{
public:
Money balance(){return bal;}//使用外层作用域的Money
private:
typedef double Money;//错误,不能重新定义Money
Money bal;
};
7.5构造函数再探
对于任何C++类来说,构造函数都是其中重要的组成部分,本节将继续介绍构造函数的一些其他功能。
构造函数初始值列表
就对象的数据成员而言,初始化和赋值也有类似的区别。如果没有在构造函数的初始值列表中显示地初始化成员,则该成员将在构造函数体之前执行默认初始化。
有时,我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或引用的话,必须将其初始化:
class ConstRef
{
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
//错误,ci和ri必须被初始化
ConstRef::ConstRef(int ii)
{
i = ii;//正确
ci = ii;//错误,不能给const赋值
ri = i;//错误,ri没被初始化
}
成员初始化的具体顺序
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。成员初始化顺序与他们在类定义中出现的顺序一致,第一个成员先被初始化,然后第二个,以此类推。
委托构造函数
C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
class Sales_data
{
public:
//非委托构造函数使用对应的实参初始化成员
Sales_data(std::string s,unsigned cnt,double price):
booksNo(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是类型名、
需要注意的是,在实际中如果定义了其他构造函数,那么最好也提供一个默认构造函数
隐式的类类型转换
如果构造函数只接受一个实参,则它实际上定义了转换此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数。
在Sales_data类中,接受string的构造函数和接受istream的构造函数分别定义了从这两种类型向Sales_data隐式转换的规则。也就是说,在需要使用Sales_data的地方,可以使用string或者istream对象替代:
string nnull_book = "9-999-99999-9";
//构造一个临时的Sales_data对象
//该对象的units_sold和revenue等于0,bookNo等于null_book
item.combine(sull_book);
但是上述转换只允许一步类类型转换,例如下面的代码使用了两步隐式转换,所以它是错误的:
//错误,需要用户定义的两种转换:
//(1)把“9-999-99999-9”转换成string
//(2)把这个临时的string转换成Sales_data
item.combine("9-999-99999-9")
抑制构造函数定义的隐式转换
在要求隐式转换的上下文中,我们可以通过将构造函数声明成explicit加以阻止:
explicit Sales_data(const std::string &s):bookNo(s){}
此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的用法都无法通过编译:
item.combine(null_book);//错误,string构造函数是explicit的
注:explicit构造函数只能用于直接初始化,例如:
Sales_data item1(null_book);//正确,直接初始化
Sales_data item2 = null_book;//错误,不能将explicit构造函数用于拷贝形式的初始化
尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显示的强制进行转换:
item.combine(Sales_data(null_book));//正确,实参是一个显示构造的Sales_data对象
聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是public的
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有virtual函数(在十五章中详细讲解)
例如,下面的类是一个聚合类:
class Data
{
int ival;
string s;
};
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员
//val1.ival = 0;val1.s = string("Anna");
Data val1 = {0,"Anna"};
注:初始值的顺序必须与声明的顺序一致
字面值常量类
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合以下要求,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型
- 类必须至少含有一个constexpr构造函数
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象
constexpr构造函数必须既符合构造函数的要求(意味着不能有返回语句),又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点可知,constexpr构造函数体一般是空的。
7.6类的静态成员
有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。我们通过在成员的声明之前加上关键字static使得其与类关联在一起:
class Account
{
publid:
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();
};
类的静态成员存在于任何对象之外,对象中不包含任何与静态成员有关的数据。因此每个Account对象将包含两个数据成员:owner和amount。只存在一个interestRate对象而且他被所有Account对象共享。
定义静态成员
和其他成员函数类似,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复关键字static,该关键字只能出现在类内部的声明语句。