类的基本思想是数据抽象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
函数。
this
指针:成员函数会通过一个名为this
的额外的隐式参数来访问调用它的那个对象,当我们调用一个成员函数时用请求该函数的对象地址初始化this
,因此我们可以在成员函数直接使用该函数对象的成员,而无需通过成员访问运算符来做到这点。需要注意的是this
是一个常量指针,我们不允许改变this
中保存的地址。const
成员函数:上面isbn
函数在参数列表之后有一个const
关键字,这里const
的作用是修改隐式this
指针的类型。默认情况下this
的类型是指向类类型非常量版本的常量指针,这意味着默认情况下我们不能把this
指针绑定到一个常量对象上。如果isbn
是一个普通函数且this
是一个普通的指针参数,那么我们应该把this
声明成const Sale_data *const
,毕竟在函数内部不会改变this
所指的对象。::
this
对象的函数:return *this;
类的作者常常需要定义一些辅助函数,比如add
、read
和print
等,尽管这些函数定义的操作从概念上来说属于类的接口组成部分,但它们实际上并不属于类本身。
非成员函数无法通过
this
指针访问类的成员。
类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数被称为构造函数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 &);
// 之前的成员
...
}
除了定义类的对象如何初始化之外,类还要控制拷贝、赋值和销毁对象时发生的行为。
vector
对象或数组销毁时存储在其中的对象也会被销毁。一般情况下编译器能替我们合成拷贝、赋值和析构的操作,但是当类需要分配类对象之外的资源时,自动合成的版本往往会失效。不过很多动态内存的类能(并且应该)使用
vector
或者string
对象管理必要的存储空间,可以避免分配和释放内存带来的复杂性。
在C++
中,我们通过访问说明符access specifiers
加强类的封装性:
public
:该成员在整个程序内可被访问,public
成员定义类的接口private
:可以被类的成员函数访问,但是不能被使用该类的代码访问,private
部分封装(即隐藏)了类的实现细节
struct
和class
都可以用于定义类,只不过它们的默认访问权限不太一样。struct
关键字定义在第一个访问说明符之前的成员是public
的,但是class
关键字定义在第一个访问说明符之前的成员是private
的。
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数称为它的友元friend
。
除了定义数据和函数成员外,类可以自定义某种类型在类中的别名。同样的可以添加访问限制,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;
}
在类中,有一些规模较小的函数适合被声明成内联函数,需要注意的是定义在类内部的函数是自动被inline
的,当然我们也可以在类的外部用inline
关键字修饰函数的定义。
和其他函数不一样,
inline
和cinstexpr
函数可以在程序中多次定义,毕竟编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。不过对于某个给定的内联函数或者constexpr
函数而言,它的多个定义必须一致。基于这个原因,constexpr
和内联函数经常被定义在头文件中。这里inline
成员函数基于相同的原因也应该和相应的类定义在头一个头文件中。
有时候我们希望能够修改类的某个数据成员,即使是在一个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; // 记录成员函数被调用的次数
// 该函数的具体逻辑
}
在定义好Screen
类后我们将继续定义一个窗口管理类并用它表示显示器上的一组Screen
(即元素为Screen
的一个vector
)。默认情况下我们希望Window_mgr
类开始时总是拥有一个默认初始化的Screen
,在C++11
新标准中,最好的方法是把这个默认值声明成一个类内初始值。
class Window_mgr {
private:
// 默认情况下,一个Window_mgr包含一个标准尺寸的空白Screen
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
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
成员函数。虽然可以在非常量对象上调用常量版本或者非常量版本,但显然非常量版本是一个更好的匹配。
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
是一个类类型但是不知道它包含哪些成员。对于一个类来说,我们创建它的对象之前它必须被定义过否则编译器无法知道它需要多少存储空间。我们设计一个Window_mgr
类,该类的某些成员可能需要访问它管理的Screen
类的内部数据,假设我们添加一个clear
成员函数负责把一个指定的Screen
的内容清空。这意味着这个函数需要访问Screen
的私有成员:
class Screen {
friend class Window_mgr;
}
友元不具有传递性:如果
Window_mgr
有自己的友元,那这些友元不能理由应当访问Screen
的私有成员
class Screen {
// Window_mgr::clear必须在Screen类之前被声明
friend Window_mgr::clear(ScreenIndex);
}
// 一般的构造函数写法: 是用初始值列表
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) { }
建议使用构造函数初始值:在很多类中初始化和赋值事关底层效率问题,前者直接初始化数据成员,后者则先初始化再赋值。
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序,成员初始化顺序和它们在类定义中出现的顺序一致。最好令构造函数初始值的顺序与成员声明的顺序一致,而且可能的话尽量避免使用某些成员初始化其他成员。
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) { }
}
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化的发生条件:
值初始化的发生条件:
T()
的表达式显式地请求值初始化时,其中T
是类型名聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式,它满足:
public
的virtual
函数有时候类需要它的一些成员与类本身直接相关而不是与类的各个对象保持关联。
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
对象共享。
可以使用作用域运算符直接访问静态成员:
double r;
r = Account::rate();
// 虽然静态成员不属于类的某个对象,但是我们仍然可以用类的对象、引用、指针来访问静态成员:
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();
static
关键字,该关键字只能出现在类内部的声明语句通常情况下,类的静态成员不应该在类的内部初始化,但是我们可以为静态成员提供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];
};
静态成员独立于任何对象,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。
class Screen {
public:
// bkground表示一个在类中稍后定义的静态成员
Screen &clear(char = bkground);
private:
static const char bkground;
}