类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。
7.1定义抽象数据类型
- 定义在类内部的函数是隐式的inline函数。
- 引入this:成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。的那个我们调用一个成员函数时,用请求该函数的对象地址初始化this。例如total.isbn();则编译器负责把total的地址传递给sibn的隐式形参this,可以等价地认为编译器将该调用重写成了如下的形式:
12//伪代码,用于说明调用成员函数的实际执行过程Sales_data::isbn(&total)
其中,调用Sales_data的isbn成员时传入了total的地址。任何对类成员的直接访问都被看作this的隐式引用,也就是说,当isbn使用bookNo时,它隐式地使用this指向成员,就像我们书写了this->bookNo一样。我们可以在成员函数的内部使用this,因此尽管没有必要,但我们还是能把isbn定义成如下的形式:
123std::string isbn () const {return this ->bookNo; }//因为this的目的总是指向“这个”对象,所以this是一个常量指针//我们不允许改变this中保存的地址 - 引入const成员函数:isbn函数的另一个关键之处是紧随参数列表之后的const关键字,这里constd作用是修改隐式this指针的类型。默认情况下,this的类型是指向类类型非常量的常量指针。
- 在isbn的函数体内不会改变this所指向的对象,所以把this设置为指向常量的指针有助于提高函数的灵活性。然而,this是隐式的并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就成为我们必须面对的问题。C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟哎参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数(const member function)。可以把isbn的函数体想象成如下的形式:
1234567//伪代码,说明隐式的this指针是如何使用的//下面的代码是非法的:因为我们不能显示地定义自己的this指针//谨记此处的this是一个指向常量的指针,因为isbn是一个常量成员std::string Sales_data::isbn(const Sales_data *const this){return this->bookNo;}
常量成员函数不能改变调用它的对象的内容。上例中,isbn可以读取调用它的对象的数据成员,但是不能写入新值。 - 构造函数:每个类都定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制对象的初始化过程,这些函数叫做构造函数(constructor)。(a)构造函数的名字和类名相同,和其他函数不一样的是,构造函数没有返回类型。(b)不同于其他成员函数,构造函数不能被声明成const的。
- 合成的默认构造函数:类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。如果类没有显示的定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
NOTE:只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。
- =default:在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上=default来要求表一起生成构造函数。
- 构造函数初始值列表:
12Sales_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) { } - 拷贝、赋值和析构:除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。
- 某些类不能依赖于合成的版本:尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时。不过值得注意的是,很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的存储空间。使用vector或者string的类能避免分配和释放内存带来的复杂性。
7.2访问控制与封装
在C++语言中,我们使用访问说明符(access specifiers)加强类的封装性:定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口;定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。
- struct和class:如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public的;相反,如果我们使用class关键字,则这些成员是private。
WARNING:使用class和struct定义类唯一的区别就是默认的访问权限。
- 友元:既然Sales_data的数据成员是privated,我们的read、print和add函数也就无法正常编译了,这是因为尽管这几个函数是类的接口的一部分,但它们不是类的成员。类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。
1234567891011121314151617181920212223class Sales_data {//为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() = default;Sales_data(const std::string&s,unsigned n, double p):bookNo(s),units_sold(n),revenue(n*p) {}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:std::string bookNo;unsigned units_sold = 0;double revenue = 0.0;};//Sales_data接口的非成员组成部分的声明Sales_data add(const Sales_data&, const Sales_data&);std::istream &read(std::istream&, Sales_data&);std::ostream &print(std::ostream, const Sales_data&);
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。 - 友元的声明:友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。
7.3类的其他特性
- 可变数据成员:有时,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。一个可变数据成员(mutable data member)永远不会是const,即使它是const对象的成员。
1234567891011121314//我们将给Screen添加一个access_ctr的可变成员//通过它我们可以追踪每个Screen的成员函数被调用了多少class Screen{public:void some_member() const;private:mutable size_t access_ctr; //即使在一个const对象内也能被修改//其他成员与之前版本一致};void Screen::some_member()const{++access_ctr; //保存一个计数值,用于记录成员函数被调用的次数//该成员需要完成的其他工作}
尽管some_member是一个const成员函数,它仍然能够改变access_ctr的值 - 类数据成员的初始值:定义好Screen类之后,我们将继续定义一个窗口管理类并用它表示显示器上的一组Screen。我们希望Window_mgr类开始时总是拥有一个默认初始化的Screen。在C++11新标准中,最好的方式就是把这个默认值声明成一个类内初始值:
123456class Window_mgr{private://这个Window_mgr追踪的Screen//默认情况下,一个Window_mgr包含一个标准尺寸的空白Screenstd::vector<Screen> screens {Screen(24, 80, ' ')};};
如我们之前所知的,类内初始值必须使用=的初始化形式(初始化Screen的数据成员时所用的)或者花括号括起来的直接初始化形式(初始化screens所用的)
NOTE:当我们提供一个类内初始值时,必须以符号=或者花括号表示。
- 基于const的重载:通过区分成员函数是否是const的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向const而重载函数的原因差不多。具体说来,因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。(P248)
7.5 构造函数再探
- 构造函数的初始值有时必不可少:如果成员是const、引用,或者某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
1 2 |
//正确:显示地初始化引用和const成员 ConstRef::ConstRef(int ii):i(ii), ci(ii), ri(i) {} |
建议:使用构造函数初始值,在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。除了效率问题外更重要的是,一些数据成员必须被初始化。建议养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。
- 委托构造函数:C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数:
12345678910class 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)}//其他成员与之前版本一致};
接受istream&的构造函数也是委托构造函数,它委托给了默认构造函数,默认构造函数又接着委托给了三参数构造函数。 - 隐式地类类型转换:如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(conversion constructor)。(P263)
NOTE:能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。
在Sales_data类中,接受string的构造函数和接受istream的构造函数分别定义了从这两种类型向Sales_data隐式转换的规则。也就是说,在需要使用Sales_data的地方,我们可以使用string或者istreamz作为替代:
1 2 3 4 |
string null_book = "9-999-99999-9"; //构造一个临时的Sales_data对象 //该对象的units_sold和revenue等于0,bookNo等于null_book item.combine(null_book); |
这里我们用一个string实参调用了Sales_data的combine成员。该调用是合法的,编译器用给定的string自动创建了一个Sales_data对象。新生成的这个(临时)对象被传递给combine。
- 只允许一步类类型转换:编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以它是错误的:
12345678910//错误:需要用户定义的两种转换://(1)把“9-999-99999-9”转换成string//(2)再把这个(临时的)string转换成Sales_dataitem.combine("9-999-99999-9");//如果我们想完成上诉的调用,可以显示的把字符串转换成string或者Sales_data对象://正确:显示地转换成string,隐式地转换成Sales_dataitem.combine(string("9-999-99999-9"));//正确:隐式地转换成string,显示地转换成Sales_dataitem.combine(Sales_data("9-999-99999-9"); - 抑制构造函数定义的隐式转换:在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止:
12345678class Sales_data {public:Sales_data() = defautl;Sales_data(const std::string &s, unsigned n, double p):bookNo(s), units_sold(n), revenue(n*P) {}explicit Sales_data (const std::string &s):bookNo(s) {}explicit Sales_data (std::istream &);//其他成员和之前版本一致};
此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的两种用法都无法通过编译:
12item.combine(null_book); //错误:string构造函数是explicit的item.combine(cin); //错误:istream构造函数是explicit的
关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于隐式转换,所以无须将这些构造函数指定为explicit的。只能在类内声明构造函数是使用explicit关键字,在类外部定义时不应重复。 - explicit构造函数只能用于直接初始化:发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用explicit构造函数:
123Sales_data item1(null_book); //正确:直接初始化//错误:不能讲explicit构造函数用于拷贝形式的初始化过程Sales_data item2 = null_book;
NOTE:当我们使用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器不会在自动转换过程中使用该构造函数。 - 聚合类(aggregate class):聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是public的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有virtual函数。
12345678//下面的类是一个聚合类struct Data{int ival;string s;};//我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员//val1.ival = 0;val1.s = string("Anna")Data val1 = {0, "Anna"};7.6 类的静态成员
有时类需要它的一些成员和类本身直接相关,而不是与类的各个对象保持关联。例如一个银行账户类可能需要一个数据成员来表示当前的基准利率。
- 声明静态成员:我们通过在成员的声明之间加上关键字static使得其与类关联在一起。
12345678910class Account {public:void calculate() {amount += amount * interestRate;}static double rate () {return interestRate;}private:std::string owner;double amount;static double interestRate;static double initRate;};
类的静态成员存在于任何对象之外,对象中不能包含任何与静态数据成员有关的数据。类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this指针。 - 使用类的静态成员:我们使用作用域运算符直接访问静态成员:
12double r;r = Account::rate(); //使用作用域运算符访问静态成员
虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:
12345Account ac1;Account *ac2 = &ac1;//调用静态成员函数rate的等价形式r = ac1.rate(); //通过Account的对象或引用r = ac2->rate(); //通过指向Account对象的指针 - 定义静态成员:和其他成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复使用关键字static,该关键字只出现在类内部的声明语句。
- 因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象是被定义的。这意味着它们不是有类的构造函数初始化的。而且,一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。
- 静态成员的类内初始化:通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能在所有适合于常量表达式的地方:
12345678class Account {public:static double rate () {return interestRate;}static void rate(double);private:static constexpr int period = 30; //period是常量表达式double daily_tbl[period];};
默认情况,this的类型是指向类类型非常量的常量指针;
const成员函数,表明这个函数不会对这个类对象的数据成员(准确的说是非静态数据成员)做任何修改。
非常量版本的函数对于常量对象是不可用的,即常量对象不能调用非常量版本的函数;
而非常量对象,即可调用常量版本又可调用非常量版本的函数。
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数;explicit可以阻止该隐式转换。
类的静态成员不属于类的任何对象。这意味着它们不是由类的构造函数初始化的。