C++系统学习之七:类
类的基本思想是数据抽象和封装。
数据抽象是一种依赖于接口和实现分离的编程技术。类的接口包括用户所能执行的操作;类的实现包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节。
类要实现数据抽象和封装,需要首先定义一个抽象数据类型。在抽象数据类型中,由类的设计者负责考虑类的实现过程,使用者只需调用,而无需知道类型的工作细节。
1、定义抽象数据类型
定义成员函数
成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。定义在类的内部的函数是隐式的inline函数。
this
成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当调用一个成员函数时,用请求该函数的对象地址初始化this。
total.isbn();
则编译器负责把total的地址传递给isbn的隐式形参this.
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符。
string isbn() const { return this->bookNo; }
const成员函数
默认情况下,this的类型是指向类类型非常量版本的常量指针。例如在Sales_data成员函数中,this的类型是Sales_data *const。尽管this是成员函数的隐式的形参,但它仍然需要遵循初始化规则,我们不能把this绑定到一个常量对象上。这也使得不能在一个常量对象上调用普通的成员函数。
因此,为了提高函数的灵活性,我们要将this声明为指向常量的指针,而this在成员函数的参数列表中是隐式的,所以就在参数列表的后面加上const,用来说明this是一个指向常量的指针。把函数后面用const修饰的成员函数称为常量成员函数,在常量成员函数中不能改变调用它的对象的内容。
NOTE:常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
类作用域和成员函数
编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数。因此,成员函数体可以随意使用类中的其他成员而无须在意出现的次序。
在类的外部定义成员函数
在类外部定义成员函数时必须保证返回类型、参数列表和函数名都和类内声明保持一致,对于类内声明为常量成员函数的,类外定义时也必须在参数列表后明确指定const属性。同时,类外部定义的成员的名字必须包含它所属的类名。
1.3 定义类相关的非成员函数
类通常需要定义一些辅助函数,比如read,print等,尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。
对辅助函数的定义,通常也是将函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。
1.4 构造函数
构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同,没有返回类型,其他和其他函数一样。类可以包含多个构造函数,和其他重载函数差不多。
构造函数因为其角色既是初始化类对象,因此不能声明为const。
合成的默认构造函数
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数。默认构造函数无须任何实参。
如果我们没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。如果我们显式地定义了构造函数,编译器将不会为类生成默认构造函数,所以此时必须自己定义一个默认构造函数。
NOTE:只有当类没有声明任何构造函数,编译器才会自动地生成默认构造函数。
某些类不能依赖于合成的默认构造函数
合成的默认构造函数只适合非常简单的类,对于一个普通的类来说,必须定义它自己的默认构造函数。
- 已经定义了一些其他的构造函数,那么必须定义默认的构造函数
- 含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数,否则,在创建类的对象时就可能得到未定义的值。因为定义在块中的内置类型或复合类型的对象被默认初始化,则它们的值是未定义的
- 有的时候编译器不能为某些类合成默认的构造函数
struct Sales_data{ Sales_data() = default; Sales_data(const string &s) :bookNo(s){} Sales_data(const string &s, unsigned n, double p) :bookNo(s), units_sold(n), revenue(p*n){} Sales_data(istream &); string isbn() const { return bookNo; } Sales_data& combine(const Sales_data&); double avg_price() const; string bookNo; unsigned units_sold = 0; double revenue = 0.0; };
=default
可以通过在参数列表后面写上=default来要求编译器生成构造函数。其中=default既可以声明一起出现在类的内部,也可以作为定义出现在类的外部。如果=default在类的内部,则默认构造函数是内联的,如果在类的外部,则该成员默认情况下不是内联的。
构造函数初始化列表
Sales_data(const string &s) :bookNo(s){} Sales_data(const string &s, unsigned n, double p) :bookNo(s), units_sold(n), revenue(p*n){}
冒号与花括号之间的部分称为构造函数初始值列表。它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。
当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。
NOTE:构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
在类的外部定义构造函数
1.5 拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。
如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。
2、访问控制与封装
在C++中,使用访问说明符加强类的封装性:
- 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
- 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了类的实现细节
class和struct关键字
二者唯一的区别是:默认访问权限不一样,struct的默认权限是public,而class的默认权限是private。
2.1 友元
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或函数称为它的友元。
如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可。
类还可以把其他的类定义成友元,也可以把其他类的成员函数定义成友元。
友元函数能定义在类的内部,这样的函数是隐式内联的。
class Sales_data{
friend Sales_data add(); //友元函数
public: Sales_data() = default; Sales_data(const string &s) :bookNo(s){} Sales_data(const string &s, unsigned n, double p) :bookNo(s), units_sold(n), revenue(p*n){} Sales_data(istream &); string isbn() const { return bookNo; } Sales_data& combine(const Sales_data&); private: double avg_price() const; string bookNo; unsigned units_sold = 0; double revenue = 0.0; };
NOTE:友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。
一般在类定义开始或结束前的位置集中声明友元。
友元声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果希望类的用户能够调用某个友元函数,那么就必须在友元声明之外再专门对函数进行一次声明。
为了使友元对类的用户可见,通常把友元的声明与类本身放置在同一个头文件中(类的外部)。我们的Sales_data头文件中应该为read、print和add提供独立的声明(除了类内部的友元声明之外)。
类之间的友元关系
窗口管理的类Window_mgr的某些成员可能需要访问它管理的Screen类的内部数据,例如,Window_mgr的成员函数clear需要清理其所管理的其中某个屏幕的内容,这时候clear需要能够访问Screen的私有成员,所以需要在Screen类中将Window_mgr类指定成其友元。
class Screen { friend class Window_mgr; };
如果一个类指定了友元类,则友元类的成员函数可以访问该类包括非公有成员在内的所有成员。因此,Window_mgr的clear的函数可以定义成
void Window_mgr::clear(Screen &s) { s.content=string(s.height*s.width,' '); }
上述,在Window_mgr的成员函数clear可以访问Screen的私有数据成员。
NOTE:友元关系不存在传递性,如果Window_mgr类有其他友元,则这些友元并不能理所当然地具有访问Screen的特权。
每个类负责控制自己的友元类或友元函数。
令成员函数作为友元
除了将整个Window_mgr类作为友元之外,Screen还可以只为clear提供访问特权。
当把一个成员函数声明成友元时,必须明确指出该成员函数属于哪个类;
class Screen { friend void Window_mgr::clear(Screen &s); };
函数重载和友元
如果想把几个重载函数中的某个声明成友元,直接将该函数在类中进行友元声明即可,只有声明了的函数才能访问类的所有数据成员。
友元声明和作用域
友元的声明不是真正意义上的声明,如果要使用它还必须加上它真正意义上的函数声明才能使用。
3、类的其他特性
3.1 类成员
定义一个类型成员
除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public或者private中的一种。
class Screen { public: using pos = std::string::size_type; private: pos curser = 0; pos height = 0, width = 0; std::string content; };
NOTE:类型成员通常出现在类开始的地方。
Screen类的成员函数
class Screen { public: using pos = std::string::size_type; Screen() = default; //因为Screen有另一个构造函数, //所以本函数是必须的 Screen(pos ht, pos wd, char c) :height(ht), width(wd), content(ht*wd, c){} char get() const { return content[cursor]; } inline char get(pos ht, pos wd) const; Screen &move(pos r, pos c); private: pos curser = 0; pos height = 0, width = 0; std::string content; };
令成员作为内联函数
定义在类内部的成员函数都是自动inline的,上述Screen的构造函数和get函数默认是inline函数。
可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义。
#include"2.h" inline char Screen::get(pos r, pos c) const { pos row = r*width; return content[row + c]; }
虽然无须在声明和定义的地方同时说明inline,但这么做是合法的。不过,最好只在类外部定义的地方说明inline,这样可以使类更容易理解。
重载成员函数
成员函数也可以被重载。
可变数据成员
有时希望能修改类的某个数据成员,即使是在一个const成员函数内,那么可以通过在变量的声明中加入mutable关键字。
一个可变数据成员永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。例如可以给Screen添加一个可变成员,记录成员函数被调用的次数。
private: pos curser = 0; pos height = 0, width = 0; std::string content; mutable size_t access_cnt; ============================ #include"2.h" inline char Screen::get(pos r, pos c) const { ++access_cnt; pos row = r*width; return content[row + c]; }
类数据成员的初始值
类内初始值必须使用=的初始化形式或者花括号括起来的直接初始化形式。
3.2 返回*this的成员函数
返回*this可以把函数嵌入到一组动作序列中。
myScreen.move(4,0).set('#');
从const成员函数返回*this
一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用,不能将其嵌入到一组动作序列中。
基于const的重载
对于非常量成员函数和常量成员函数的重载,根据对象是否是const来决定调用哪个成员函数。
3.3 类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。
类的声明
可以仅声明而暂时不定义它。这种声明被称作前向声明。但对于一个类来说,在创建它们的对象之前该类必须被定义过,而不能仅仅声明它,因为编译器需要知道类的对象需要多大的存储空间。
4、类的作用域
作用域和定义在类外部的成员
一旦遇到类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。救过就是,可以直接使用类的其他成员而无须再次授权。
void Window_mgr::clear(ScreenIndex i) { Screen &s=screens[i]; s.contents=string(s.height*s.width,' '); }
上述Window_mgr::已经明确说明了后续部分处于Window_mgr作用域中了,因此使用Window_mgr的成员ScreenIndex以及screens就不需要说明Window_mgr的作用域了,但是在Window_mgr::作用域之前的函数返回类型部分就不在Window_mgr作用域内,因此如果函数返回类型部分需要用到Window_mgr中的成员,必须对该成员加以命名空间说明。
//假设返回类型是ScreenIndex则,类外部的定义应该是 Window_mgr::Screen_mgr Window_mgr::clear(ScreenIndex i) { }
4.1 名字查找与类的作用域
一般情况下,名字查找的步骤如下:
- 在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明
- 如果没找到,继续查找外层作用域
- 如果最终没有找到匹配的声明,则程序报错
但对于类内部的成员函数来说,起名字查找的步骤不一样,类的定义分两步处理:
- 首先,编译成员的声明
- 直到类全部可见后才编译函数体
NOTE:编译器处理完类中的全部声明后才会处理成员函数的定义。
用于类成员声明的名字查找
上述两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。
类型名要特殊处理
类型名的定义通常出现在类的开始处,这样能确保所有使用该类型的成员都出现在类名的定义之后。
typedef double Money; class Screen {};
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明
- 如果在成员函数内没有找到,则在类内继续查找
- 如果类内也没找到该名字的声明,则在外围作用域继续查找
当成员函数声明了与类的成员同名的成员时,成员函数中的声明将覆盖类的同名成员,如果想使用类的同名成员,此时可以加上类的名字或显式地使用this指针来强制访问类的成员。
5、再谈构造函数
5.1 构造函数初始值列表
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; }; ConstRef::ConstRef(int ii) { i=ii; //正确 ci=ii; //错误,不能给const赋值 ri=i; //错误,ri没有初始化 }
正确的构造函数应该是
CosntRef::ConstRef(int ii):i(ii),ci(ii),ri(i){}
NOTE:如果成员是const、引用、或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值为这些成员提供初值。
建议使用构造函数初始值。
成员初始化的顺序
成员的初始化顺序与它们在类定义中的出现顺序一致,构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
NOTE:最好令构造函数初始值的顺序与成员声明的顺序保持一致,如果可能的话,尽量避免使用某些成员初始化其他成员。
默认实参和构造函数
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
5.2 委托构造函数
一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些职责委托给了其他构造函数。
class Sales_data { public: Sales_data(string s,unsigned cnt, double price):bookNo(s), units_sold(cnt), revenue(cnt*price){} //其余构造函数全都委托给上面的构造函数 Sales_data():Sales(" ",0,0){} Sales_data(string s): Sales_data(s,0,0){} Sales_data(istream &is):Sales_data(){read(is,*this);} };
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在Sales_data类中,受委托的构造函数体恰好是空的。加入函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。
5.3 默认构造函数的作用
在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数
5.4 隐式的类类型转换
可以为类定义隐式转换规则。
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时把这种构造函数称为转换构造函数。
只允许一步类类型转换
编译器只会自动地执行一步类型转换
item.combine("hello!");
这个就是错误的,需要两种转换,首先将"hello!"转换成string,然后再将临时的string转换成Sales_data。
对于上述这种错误,可以显式地将“hello!”转换成string
item.combine(string("hello!"))
类类型转换不是总有效
抑制构造函数定义的隐式转换
可以通过将构造函数声明为explicit阻止构造函数的隐式转换
class Sales_data { public: Sales_data()=default; Sales_data(const string &s,unsigned n,double p):bookNo(s),units_sold(n),revenue(p*n){} explicit Sales_data(const string &s):bookNo(s){} explicit Sales_data(istream&); };
此时,没有任何构造函数能用于隐式地创建Sales_data对象,
item.combine(null_book); item.combine(cin);
这两种试图通过string和istream转换成Sales_data对象的行为都不能通过编译。
NOTE:explicit只对一个实参的构造函数有效,且只能在类内声明构造函数时才使用explicit关键字,在类外部定义时不应重复。
explicit构造函数只能用于直接初始化
Sales_data item1(null_book); //正确,直接初始化 Sales_data item2=null_book; //错误,null_book想隐式转换成Sales_data,但构造函数声明了explicit,因此不能隐式转换
为转换显式地使用构造函数
尽管编译器不会将explicit的构造函数用于隐式转换,但是可以使用这样的构造函数进行显式强制转换。
itme.combine(Sales_data(null_book)); //直接调用Sales_data构造函数 item.combine(static_cast<Sales_data>(cin)); //强制转换
5.5 聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足下面条件时,是聚合类
- 所有成员都是public
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有virtual函数
可以提供一个花括号括起来的成员初始值列表,并用它来初始化聚合类的数据成员。初始值的顺序必须和声明的顺序一致,如果初始值的数目少于类的成员的数量,后面的则默认初始化。
6、类的静态成员
类的静态成员只与类本身有关,而与类的对象无关。
声明静态成员
在声明前面加上“static”关键字,使其与类关联在一起。和其他成员一样,静态成员可以是public或private。
静态成员函数也不与任何对象绑定在一起,它们不包含this指针。静态成员函数不能声明成const的,而且也不能在static函数体内使用this指针。
使用类的静态成员
使用作用域运算符直接访问静态成员。
double r; r=Account::rate();
还可以使用类的对象、引用或指针来访问静态成员。
Account ac1; Account *ac2=&ac1; r=ac1.rate(); r=ac2->rate();
成员函数不用通过作用域运算符就能直接使用静态成员。
定义静态成员
既可以在类内也可以在类外定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,static关键字只能出现在类内部的声明语句。
一般来说,不能在类的内部初始化静态成员。相反地,必须在类的外部定义和初始化每个静态成员,且只能被定义一次。
1.h class Account { private: static double interestRate; static double initRate(); }; 1.cpp double Account::interestRate=initRate(); //定义并初始化静态成员
从类名Account开始,定义语句的剩余部分就都位于类的作用域之内了,因此,可以直接使用initRate函数,即使它是private的。
静态成员的类内初始化
即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
静态成员能用于某些场景,而普通成员不能
静态数据成员的类型可以就是它所属的类类型,而非静态数据成员则受到限制,只能声明成它所属类的指针或引用。
class Bar { private: static Bar mem1; //正确,静态成员可以是不完全类型 Bar *mem2; //正确,指针成员可以是不完全类型 Bar mem3; //错误,数据成员必须是完全类型 };
静态成员和普通成员的另一个区别就是可以使用静态成员作为默认实参
class Screen { public: Screen& clear(char=bkground); private: static const char bkground; };