简介
类的基本思想是数据抽象data abstraction
和封装encapsulation
。数据抽象是一种依赖于接口interface
和实现implementation
分离的编程技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
定义抽象数据类型
定义一个Sales_data
类,数据成员包括:
-
bookNo
:string
类型,表示ISBN
编号 -
units_sold
:unsigned
类型,表示销量 -
revenue
:double
类型,表示总销售收入
成员函数包括:
-
combine
: -
isbn
: -
avg_price
:返回售出书籍的平均价格,这个函数的目的并非通用,所以它属于类的实现的一部分而非接口的一部分
成员函数的声明必须在类的内部,它的定义既可以在内部也可以在外部。作为接口组成部分的非成员函数,例如
add
、read
和
struct Sales_data {
// 关于Sales_data对象的操作
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
// 数据成员
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// Sales_data的非成员接口函数
Sales_data add (const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, const Sales_data&);
定义在类内部的函数是隐式的
inline
函数。
1. 定义成员函数
- 所有的成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外
-
this
指针:成员函数会通过一个名为this
的额外的隐式参数来访问调用它的那个对象,当我们调用一个成员函数时用请求该函数的对象地址初始化this
,因此我们可以在成员函数直接使用该函数对象的成员,而无需通过成员访问运算符来做到这点。需要注意的是this
是一个常量指针,我们不允许改变this
中保存的地址。 -
const
成员函数:上面isbn
函数在参数列表之后有一个const
关键字,这里const
的作用是修改隐式this
指针的类型。默认情况下this
的类型是指向类类型非常量版本的常量指针,这意味着默认情况下我们不能把this
指针绑定到一个常量对象上。如果isbn
是一个普通函数且this
是一个普通的指针参数,那么我们应该把this
声明成const Sale_data *const
,毕竟在函数内部不会改变this
所指的对象。 - 在类的外部定义成员函数需要使用作用域运算符
::
- 定义一个返回
this
对象的函数:return *this;
2. 定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,比如add
、read
和print
等,尽管这些函数定义的操作从概念上来说属于类的接口组成部分,但它们实际上并不属于类本身。
非成员函数无法通过
this
指针访问类的成员。
3. 构造函数
类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数被称为构造函数constructor
。无论何时只要类的对象被创建,就会执行构造函数。
- 默认构造函数:如果存在类内的初始值,则用它初始化成员;不存在的话执行默认初始化。
自动合成的默认构造函数只适合非常简单地类,对于一个普通的类必须定义它自己的默认构造函数。原因有三个:
- 只有在类没有声明任何构造函数时,编译器才会自动地生成默认构造函数,一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。这是因为一旦一个类在某种情况下需要控制对象初始化,那么该类可能在所有情况下都需要控制。
- 对于某些类,合成的默认构造函数可能执行错误的操作:如果定义在块中的内置类型或符合类型(比如数组和指针)的对象被默认初始化,则它们的值可能是未定义的。
- 编译器可能不能为某些类合成默认的构造函数:如果类中包含一个其他类类型的成员并且这个成员的类型没有默认构造函数,那么编译器无法初始化该成员。
struct Sales_data {
// 新增的构造函数
Sales_data() = default; // 没有参数=默认构造函数; =default表示要求编译器生成构造函数
Sales_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) { } // 冒号和花括号之间的为构造函数初始值列表
Sales_data(std::istream &);
// 之前的成员
...
}
4. 拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还要控制拷贝、赋值和销毁对象时发生的行为。
- 拷贝:初始化变量;以值的方式传递或返回一个对象
- 赋值:使用了赋值运算符
- 销毁:当对象不再存在时执行的操作,比如一个局部对象会在创建它的块结束时销毁,当
vector
对象或数组销毁时存储在其中的对象也会被销毁。
一般情况下编译器能替我们合成拷贝、赋值和析构的操作,但是当类需要分配类对象之外的资源时,自动合成的版本往往会失效。不过很多动态内存的类能(并且应该)使用
vector
或者string
对象管理必要的存储空间,可以避免分配和释放内存带来的复杂性。
访问控制和封装
在C++
中,我们通过访问说明符access specifiers
加强类的封装性:
-
public
:该成员在整个程序内可被访问,public
成员定义类的接口 -
private
:可以被类的成员函数访问,但是不能被使用该类的代码访问,private
部分封装(即隐藏)了类的实现细节
struct
和class
都可以用于定义类,只不过它们的默认访问权限不太一样。struct
关键字定义在第一个访问说明符之前的成员是public
的,但是class
关键字定义在第一个访问说明符之前的成员是private
的。
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数称为它的友元friend
。
类成员
1. 定义一个类型成员
除了定义数据和函数成员外,类可以自定义某种类型在类中的别名。同样的可以添加访问限制,public
意味着用户也可以使用这个别名,这个特性还是比较方便的。
用来定义类型的成员(比如
pos
)必须先定义后使用,这一点与普通成员有所区别,这一点与类的作用域相关。
class Screen {
public:
typedef std::string::size_type pos; // 用户也可以使用这个别名
// 等价于using pos = std::string::size_type;
private:
pos cursor = 0;
pos height = 0;
std::string contents;
}
2. 令成员作为内联函数
在类中,有一些规模较小的函数适合被声明成内联函数,需要注意的是定义在类内部的函数是自动被inline
的,当然我们也可以在类的外部用inline
关键字修饰函数的定义。
和其他函数不一样,
inline
和cinstexpr
函数可以在程序中多次定义,毕竟编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。不过对于某个给定的内联函数或者constexpr
函数而言,它的多个定义必须一致。基于这个原因,constexpr
和内联函数经常被定义在头文件中。这里inline
成员函数基于相同的原因也应该和相应的类定义在头一个头文件中。
3. 可变数据成员
有时候我们希望能够修改类的某个数据成员,即使是在一个const
成员函数中,可以在变量的声明中加入mutable
关键字实现。举个例子,我们给Screen
添加一个名为access_ctr
的可变成员,通过它我们可以追踪每个Screen
的成员函数被调用了多少次:
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr; // 即使是在一个const对象内也能修改
};
void Scree::some_member() const
{
++access_ctr; // 记录成员函数被调用的次数
// 该函数的具体逻辑
}
4. 数据成员为类时的初始值
在定义好Screen
类后我们将继续定义一个窗口管理类并用它表示显示器上的一组Screen
(即元素为Screen
的一个vector
)。默认情况下我们希望Window_mgr
类开始时总是拥有一个默认初始化的Screen
,在C++11
新标准中,最好的方法是把这个默认值声明成一个类内初始值。
class Window_mgr {
private:
// 默认情况下,一个Window_mgr包含一个标准尺寸的空白Screen
std::vector screens{Screen(24, 80, ' ')};
};
返回*this的成员函数
inline Screen &Screen::set(char c)
{
contents[cursor] = c;
return *this;
}
inline Scrren &Screen::set(pos r, pos col, char ch)
{
contents[r*width + col] = ch;
return *this;
}
这两个成员函数的返回值是调用set
对象的引用,返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。如果我们不是返回引用的话,返回值将是*this
的副本,这种情况下调用set
只能改变临时副本。举例来说:
// 返回*this函数的调用方式
myScreen.set('#');
// 如果set函数返回Screen而非Screen&
Screen temp = myScreen.set('#')
注意,如果一个const
成员函数以引用的形式返回*this
,那么它的返回类型将是常量引用。比如我们添加display
成员函数用于打印Screen
的内容,我们希望这个函数能和set
函数一样出现在同一序列中,因此它也应该返回它对象的引用。从逻辑上来说display
函数并不需要改变Screen
的内容,因此我们令它是一个const
成员,此时this
是一个指向const
的指针,因此display
返回类型应该是const Screen&
。下面这种写法是无法编译通过的:
Screen myScreen;
// 如果display返回常量引用,则调用set将引发错误
myScreen.display(cout).set('#');
基于const的重载
通过区分成员函数是否是
const
的,我们可以对其进行重载。因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const
成员函数。虽然可以在非常量对象上调用常量版本或者非常量版本,但显然非常量版本是一个更好的匹配。
class Screen {
public:
// 根据对象是否是const重载了display函数
Screen &display(std::ostream &os)
{ do_display(os); return *this; }
const Screen &display(std::ostream &os) const
{ do_display(os); return *this; }
private:
// 负责显示Screen的内容
void do_display(std::ostream &os) const { os << contents; }
}
当我们在某个对象上调用display
时,该对象是否是const
决定了应该调用哪个版本:
Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // 调用非常量版本
blank.display(cout); // 调用常量版本
类类型
- 即使两个类的成员列表完全一致,它们也是不同的类型
- 当我们只声明类但未定义时,它是一个不完全类型。比如
class Screen;
这条语句我们只能知道Screen
是一个类类型但是不知道它包含哪些成员。对于一个类来说,我们创建它的对象之前它必须被定义过否则编译器无法知道它需要多少存储空间。 - 因为只有当类全部完成后类才算被定义,因此一个类的成员类型不能是它自己;然而一个类的名字出现后它就被认为是声明过了,因此类允许包含指向它自身类型的引用或者指针。
友元
1. 类之间的友元关系
我们设计一个Window_mgr
类,该类的某些成员可能需要访问它管理的Screen
类的内部数据,假设我们添加一个clear
成员函数负责把一个指定的Screen
的内容清空。这意味着这个函数需要访问Screen
的私有成员:
class Screen {
friend class Window_mgr;
}
友元不具有传递性:如果
Window_mgr
有自己的友元,那这些友元不能理由应当访问Screen
的私有成员
2. 令成员函数作为友元
class Screen {
// Window_mgr::clear必须在Screen类之前被声明
friend Window_mgr::clear(ScreenIndex);
}
类的作用域
1. 作用域和定义在类外部的成员
- 一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名
- 当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外,这时候返回类型必须指明它是哪个类的成员
2. 名字查找和类的作用域
- 名字查找过程:在名字所在的块中寻找声明语句,只考虑在名字的使用之前出现的声明;如果没找到继续查找外层作用域;最终没找到匹配的声明则报错
- 类的定义:首先编译成员的声明;直到类全部可见后才编译函数体
- 成员函数中使用的名字查找过程:在成员函数内查找名字的声明,只考虑在名字的使用之前出现的声明;如果在成员函数内没有找到,则再类内继续查找所有成员;如果类内也没找到该名字的声明那么在成员函数定义之前的作用域内继续查找
构造函数
1. 构造函数的初始值列表
// 一般的构造函数写法: 是用初始值列表
Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), unit_sold(n), revenue(p*n) { }
// 构造函数的另一种写法:合法但是草率,对数据成员执行赋值操作
Sales_data(const std::string &s, unsigned n, double p)
{
bookNo = s;
unit_sold = n;
revenue = p*n;
}
大多数时候我们可以忽略数据成员初始化和赋值之间的差异,但是在成员是const
或者是引用的话必须将其初始化,当成员属于某种类型并且该类没有定义默认构造函数时,也必须将其初始化。
// ci和ri必须被初始化,因为它们不能接受赋值,如果不提供构造函数初始值的话会报错
class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
}
// 我们初始化const或者引用类型的数据成员唯一机会是通过构造函数初始值
ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(i) { }
建议使用构造函数初始值:在很多类中初始化和赋值事关底层效率问题,前者直接初始化数据成员,后者则先初始化再赋值。
2. 成员初始化的顺序
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序,成员初始化顺序和它们在类定义中出现的顺序一致。最好令构造函数初始值的顺序与成员声明的顺序一致,而且可能的话尽量避免使用某些成员初始化其他成员。
3. 委托构造函数
C++11
新标准使我们可以定义委托构造函数,使用它所属类的其他构造函数执行它的初始化过程。
class 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) { }
}
4. 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化的发生条件:
- 我们在块作用域中不使用任何初始值定义一个非静态变量或者数组时
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时
值初始化的发生条件:
- 在数组初始化的过程如果我们提供的初始值数量少于数组的大小时
- 当我们不使用初始值定义一个局部静态变量时
- 当我们书写形如
T()
的表达式显式地请求值初始化时,其中T
是类型名
聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式,它满足:
- 所有成员都是
public
的 - 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有
virtual
函数
类的静态成员
有时候类需要它的一些成员与类本身直接相关而不是与类的各个对象保持关联。
1. 声明静态成员
class Account {
public:
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();
}
只存在一个interestRate
对象且这个对象被所有的Account
对象共享。
2. 使用类的静态成员
可以使用作用域运算符直接访问静态成员:
double r;
r = Account::rate();
// 虽然静态成员不属于类的某个对象,但是我们仍然可以用类的对象、引用、指针来访问静态成员:
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();
3. 定义静态成员
- 在类的外部定义静态成员时不可重复
static
关键字,该关键字只能出现在类内部的声明语句 - 静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的,这意味着它们不是由类的构造函数初始化的。而且一般来说我们不能在类的内部初始化静态成员,必须在类的外部定义和初始化每个静态成员。
- 类似于全局变量,静态数据成员定义在任何函数之外,因此一旦它被定义,就将一直存在于程序的整个生命周期中
- 要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中
4. 静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化,但是我们可以为静态成员提供const
整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
,初始值必须是常量表达式。
class Account {
public:
static double rate() { return interestRate; }
static void rate(double);
private:
static constexpr int period = 30; // period是常量表达式, 用一个初始化了的静态数据成员指定数组成员的维度
double daily_tbl[period];
};
5. 静态成员可用于某些场景,而普通成员不行
静态成员独立于任何对象,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。
- 静态数据成员可以是不完全类型,非静态数据成员只能声明它所属类的引用或者指针
- 我们可以使用静态成员作为默认实参
class Screen {
public:
// bkground表示一个在类中稍后定义的静态成员
Screen &clear(char = bkground);
private:
static const char bkground;
}