类的基本思想:数据抽象、封装。
数据抽象:一种依赖于接口和实现分离的编程技术。其中,类的接口包括用户所能执行的操作;类的实现包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装:实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型。在抽象数据类型中,由类的设计者负责考虑类的实现过程,类的用户不能访问其数据成员。
1.定义抽象数据类型
(1)设计Sales_data类
使用改进的Sales_data类
(2)定义改进的Sales_data类
类由数据成员和成员函数组成。
成员函数的声明必须在类的内部,它的定义既可以在类的内部也可以在类的外部。
作为接口组成部分的非成员函数,它们的定义和声明都在类的外部。
定义在类内部的函数是隐式的inline函数。
-
定义成员函数
成员函数的声明必须在类的内部,它的定义既可以在类的内部也可以在类的外部。 -
引入this
使用点运算符来访问类对象的成员。
当我们调用成员函数时,实际上是在替某个对象调用它。成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。
如,当我们调用total.isbn()时,编译器负责把total的地址传递给isbn的隐式形参this,即调用Sales_data::isbn(&total)。
isbn的定义其实是:std::string isbn( ) const {return this->bookNo;}。但在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符,称为this的隐式引用。因为this总是指向“这个”对象,所以this是一个常量指针,不允许改变this中保存的地址。 -
引入const成员函数
在isbn紧随形参列表之后的const关键字,const的作用是修改隐式指针this的类型,即由Sales_data * const(指向类类型非常量版本的常量指针)变为const Sales_data * const,有助于提高函数的灵活性。像这样使用const的成员函数被称为常量成员函数。
常量对象,以及常量对象的引用或指针都只能调用常量成员函数。 -
类作用域和成员函数
类本身就是一个作用域,雷瑟成员函数的定义嵌套在类的作用域之内。
编译器分两步处理类:先编译成员的声明,再编译成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。 -
在类的外部定义成员函数
类外部定义的成员函数的名字必须包含它所属的类名。
-
定义一个返回this对象的函数
(3)定义类相关的非成员函数
如果函数在概念上属于类但是不定义在类中,则它一般应与类声明在同一个头文件中。
- 定义read和print函数
- 定义add函数
(4)构造函数
每个类分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。
构造函数的名字和类名相同。构造函数没有返回类型。类可以包含多个构造函数,但不同构造函数之间必须在参数数量或参数类型上有所区别。
构造函数不能被声明成const的。
基本形式:类名 ( 形参列表 ) : 初始化列表 { };
-
合成的默认构造函数
在没有定义构造函数的类中,编译器会隐式地定义一个默认构造函数,执行默认初始化。编译器创建的构造函数又被称为合成的默认构造函数。 -
某些类不能依赖于合成的默认构造函数
合成的默认构造函数只适合非常简单的类。对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:
一是编译器只有在发现类不包含任何构造函数的情况下才会生成默认构造函数;
二是合成的默认构造函数可能执行错误的操作(对一些复合类型来说);
三是有时候编译器不能为某些类合成默认的构造函数。 -
定义Sales_data的构造函数
-
=default的含义
-
构造函数初始值列表
冒号以及冒号和花括号之间的代码为构造函数初始值列表。它负责为新创建的对象的一个或几个数据成员赋初值,括号内即为成员的初始值。 -
在类的外部定义构造函数
上面的第4个构造函数在类外定义:
(5)拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。
拷贝:当初始化变量或者以值的方式传递或返回一个对象时
赋值:当使用赋值运算符时
销毁:当对象不再存在时
如果我们不主动定义这些操作,编译器将替我们合成它们。
但是,某些类不能依赖于合成的版本。如当类需要分配类对象之外的资源时,合成的版本常常会失效。
2.访问控制与封装
目前,类还没有封装,用户可以直达类对象的内部并控制它的具体实现细节。我们可以使用访问说明符加强类的封装性:
- public:定义在pubilc说明符之后的成员在整个程序内可被访问,public成员定义类的接口;
- private:定义在pubilc说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了类的实现细节。
(1)使用class或struct关键字
类可以在它的第一个访问说明符之前定义成员,struct和class的默认访问权限不同,struct默认public,class默认private。
(2)友元
类可以允许其他类或函数访问它的非公有成员,方法是使用friend关键字令其他类或函数成为它的友元。
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员,也不受它所在区域访问控制级别的约束。
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。
3.类的其他特性
(1)类成员再探
新定义Screen和Window_mgr类为例。
- 定义一个类型成员
Screen的用户不应该知道Screen使用了一个string对象来存放它的数据,因此通过把pos定义成public成员可以隐藏Screen实现读的细节。
用来定义类型的成员必须先定义后使用。
//Screen表示显示器中的一个窗口
class Screen {
public:
typedef string::size_type pos; //自定义一个类型成员
private:
pos cursor = 0; //光标
pos height = 0, width = 0; //高度、宽度
string contents; //内容
};
- Screen类的成员函数
添加两个构造函数以及读取给定位置字符和移动光标的成员函数:
class Screen {
public:
typedef string::size_type pos; //自定义一个类型成员
//构造函数
Screen() = default;
Screen(pos ht, pos wd, char c) :height(ht), width(wd), contents(ht*wd, c) {};
//成员函数
char get() const { //隐式内联,根据光标读取字符(声明+定义)
return contents[cursor];
}
inline char get(pos ht, pos wd) const; //显式内联(声明)
Screen &move(pos r, pos c); //能在之后被设为内联
private:
pos cursor = 0; //光标
pos height = 0, width = 0; //高度、宽度
string contents; //内容
};
- 令成员作为内联函数
定义在类内部的成员函数是自动inline的。对于想显式内联的函数,我们可以再类的内部把inline作为声明的一部分显式地声明成员函数,也可以在类的外部用inline关键字修饰函数地定义:
//类外定义成员函数, 类外部定义的成员函数的名字必须包含它所属的类名
//读取给定行号列号的字符函数,已声明inline
char Screen::get(pos r, pos c) const {
pos row = r*width;
return contents[row + c];
}
//移动光标至指定的行号列号函数,定义时用inline修饰
inline Screen &Screen::move(pos r, pos c) {
pos row = r*width;
cursor = row + c;
return *this; //编译器把Screen对象的地址传给this
}
-
重载成员函数
函数之间参数的数量或参数的类型不同。 -
可变数据成员
有时,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。可以将此数据成员声明为可变数据成员:mutable 变量类型 变量名;
可变数据成员永远不会是const,任何成员函数,包括const函数都能改变它的值。
-
类数据成员的初始值
定义一个窗口管理类Window_mgr,这个类将包含一个Screen类型的vector,每个元素表示一个特定的Screen。默认情况下,我们希望Window_mgr类开始时总有一个默认初始化的Screen,作为类内初始值。
/****** 窗口管理类Window_mgr ******/
class Window_mgr {
private:
vector<Screen> screens = vector<Screen> {Screen(24, 80, ' ')}; //默认包含一个标准尺寸的空白Screen
};
当我们提供一个类内初始值时,必须以符号=或者花括号表示。
(2)返回this的成员函数
添加设置光标所在位置或者其他任意给定位置的字符的函数:
我们的move和set的返回值都是调用的对象的引用。返回的引用的函数是左值的,所以我们可以将一系列操作在同一对象上执行:
假如我们定义的函数的返回类型不是引用,则move的返回值将是this的副本,因此调用set时只能改变临时副本,不能改变myscreen的值。如果返回类型为void,则不能把多个调用连接起来。
-
从const成员函数返回*this
添加display操作来打印Screen的内容。从逻辑上来说,显式一个Screen不需要改变它的内容,因此我们令display为一个const成员,此时this将是一个指向const的指针。
-
基于const的重载
通过区分成员函数是否是const的,我们可以对其进行重载。
(3)类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,它们也是不同的类型。
- 类的声明
可以仅声明类而暂不定义它。这种声明被称为前向声明,这种类型叫不完全类型。
不完全类型的使用场景:可以定义指向这种类型的指针或引用,也可以声明(不能定义)以不完全类型作为参数或返回类型的函数。
== 类允许包含指向它自身类型的引用或指针。==
(4)友元再探
类可以把普通的非成员函数定义成友元,也可以把其他的类定义成友元,还可以把其他类(之前已定义过的)的成员函数定义成友元。
友元函数可以定义在类的内部,隐式内联。
- 类之间的友元关系
给Windows_mgr类加一个clear成员,把指定的Screen的内容设为空白。clear需要访问Screen的私有成员,我们需要把Windows_mgr指定为Screen的友元:
class Screen {
//友元,可以访问Screen类的私有部分
friend class Window_mgr
public:
...
private:
...
};
如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。
Windows_mgr的clear成员:
class Window_mgr {
public:
//窗口中每个屏幕的编号
using ScreenIndex = vector<Screen>::size_type;
//将指定的Screen重置为空白
void clear(ScreenIndex);
private:
vector<Screen> screens = vector<Screen> {Screen(24, 80, ' ')}; //默认包含一个标准尺寸的空白Screen
};
//类外定义成员函数
//将指定的Screen重置为空白
void Window_mgr::clear(ScreenIndex i) {
Screen &s = screens[i];
s.contents = string(s.height*s.width, ' ');
}
如果clear不是Screen的友元,上面的代码将无法通过编译,因为clear将不能访问Screen的height、width和contents成员。
友元关系不存在传递性,每个类负责控制自己的友元类或友元函数。
- 令成员函数作为友元
- 函数重载和友元
如果一个类想把一组重载函数声明成它的友元,需要对这组函数中的每一个分别声明。 - 友元声明和作用域
4. 类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型的成员则使用作用域运算符访问。
- 作用域和定义在类外部的成员
一个类就是一个作用域,当我们在类的外部定义成员函数时必须同时提供类名和函数名。
一旦遇到了类名,定义的剩余部分(参数列表和函数体)就在类的作用域之内了,我们可以直接使用类的其他成员而无须再次授权了。
不过,函数的返回类型通常出现在函数名之前,因此当成员函数定义在类的外部时,返回类型中使用的名字位于类的作用域之外。返回类型必须指明它是哪个类的成员。
(1)名字查找与类的作用域
-
用于类成员声明的名字查找
-
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。 -
成员定义中的普通块作用域的名字查找
-
类作用域之后,在外围作用域中查找
-
在文件中名字的出现处对其进行解析
5.构造函数再探
(1)构造函数初始值列表
如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。
-
构造函数的初始值有时必不可少
如果成员是const或引用的话,必须将其初始化;当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。
我们初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始值。 -
成员初始化的顺序
成员初始化的顺序与它们在类定义中出现的顺序一致,不是按构造函数初始值列表的顺序。构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。 -
默认实参和构造函数
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
(2)委托构造函数
(3)默认构造函数的作用
在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。
区分默认构造函数和默认初始化的对象:
Sales_data obj(); //默认构造函数
Sales_data obj; //默认初始化的对象
(4)隐式的类类型转换
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数。
-
只允许一步类型转换
-
类类型转换不是总有效
-
抑制构造函数定义的隐式转换
可以通过将构造函数声明为explicit阻止隐式类型转换:
关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式类型转换,所以无需将这些构造函数指定为explicit的。只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复。 -
explicit构造函数只能用于直接初始化(不能用于拷贝初始化)
当我们用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。 -
为转换显式地使用构造函数
-
标准库中含有显式构造函数的类
接受一个单参数的const char*的string构造函数不是explicit的;
接受一个容量参数的vector构造函数是explicit的。
(5)聚合类
//定义英雄结构体(聚合类)
struct Hero {
string name;
int age;
string sex;
};
//聚合类的初始化方式:花括号括起来的成员初始值列表
Hero h1={"张飞", 20, "男"};
(6)字面值常量类
除了算术类型、引用和指针外,某些类也是字面值类型。
- constexpr构造函数
尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。
constexpr构造函数可以声明成=default的形式(或删除函数的形式)。否则,constexpr构造函数就必须既符合构造函数的要求(不能包含返回语句),又符合constexpr函数的要求(它能拥有的唯一可执行语句就是返回语句)。所以,constexpr构造函数体一般来说应该是空的。
6.类的静态成员
有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。
-
声明静态成员
通过在成员的声明之前加上关键字static使其与类关联在一起。静态成员可以是public的或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。
静态成员函数也不与任何对象绑定在一起,它们不包含this指针。静态成员函数不能声明成const的,而且我们也不能在static函数体内使用this指针。 -
使用类的静态成员
①使用作用域运算符直接访问静态成员
②虽然静态成员不属于类的某个对象,但我们仍可以使用类的对象、引用或指针来访问静态成员
③成员函数不用通过作用域运算符就能直接使用静态成员 -
定义静态成员
既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句。
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。所以它们不是由类的构造函数初始化的。一般来说,我们不能在类的内部初始化静态成员。必须在类的外部定义和初始化每个静态成员,基本形式:数据类型 类名::成员 = 值; 。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。 -
静态成员的类内初始化
-
静态成员能用于某些场景,而普通成员不能
①静态数据成员可以是不完全类型
②可以使用静态成员作为默认实参