Part I: The Basics
Chapter 7. Classes
类 (classes) 的基本思想是数据抽象和封装。
数据抽象 (data abstraction) 是一种依赖于接口和实现的分离的编程(和设计)技术。
类的接口 (interface) 由该类的用户可以执行的操作组成。
类的实现 (implementation) 包括类的数据成员,构成接口的函数体以及定义该类所需的任何私有函数。
封装 (encapsulation) 强制将类的接口和实现分开。封装的类会隐藏其实现:该类的用户可以使用接口,但无法访问实现。
使用数据抽象和封装的类定义抽象数据类型 (abstract data type)。使用该类的程序员不必知道该类型如何工作,他们可以抽象地思考类型的作用。
7.1 定义抽象数据类型
第2章中的 Sales_data 类不是抽象数据类型。它允许用户访问其数据成员,并要求用户编写操作。
要使 Sales_data 成为抽象类型,需要定义可供用户使用的操作。一旦 Sales_data 定义了它自己的操作,就可封装(隐藏)它的数据成员。
设计 Sales_data 类
Sales_data 类接口应包含以下操作:
isbn
成员函数,返回对象的 ISBNcombine
成员函数,将一个 Sales_data 对象加到另一个对象上add
函数,两个 Sales_data 对象相加read
函数,从 istream 读取数据到一个 Sales_data 对象中print
函数,输出一个 Sales_data 对象的值到 ostream 中
使用改进的 Sales_data 类
Sales_data total; // variable to hold the running sum
if (read(cin, total)) { // read the first transaction
Sales_data trans; // variable to hold data for the next transaction
while(read(cin, trans)) { // read the remaining transactions
if (total.isbn() == trans.isbn()) // check the isbns
total.combine(trans); // update the running total
else {
print(cout, total) << endl; // print the results
total = trans; // process the next book
}
}
print(cout, total) << endl; // print the last transaction
} else { // there was no input
cerr << "No data?!" << endl; // notify the user
}
定义改进的 Sales_data 类
struct Sales_data {
// new members: operations on Sales_data objects
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
// data members are unchanged from § 2.6.1 (p. 72)
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// nonmember Sales_data interface functions
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
成员函数的声明必须在类的内部,它可以定义在类的内部或外部。
非成员函数,作为接口的组成部分,其声明和定义都在类的外部。
定义在类内部的函数是隐式内联的。
引入 this
std::string isbn() const { return bookNo; }
成员函数通过一个额外的形参 this
来访问被调用的对象。当调用成员函数时,this
初始化为请求该函数的对象的地址。
例如,当调用 total.isbn()
时,编译器将 total 的地址传递给 isbn 的隐式 this
形参。
// pseudo-code illustration of how a call to a member function is translated
Sales_data::isbn(&total)
this
形参是隐式定义的。虽然没有必要,但在成员函数体内部使用 this
是合法的:
std::string isbn() const { return this->bookNo; }
因为 this
总是指向“这个”对象,this
是一个 const 指针。不能改变 this
保存的地址。
引入 const 成员函数
isbn 函数的形参列表之后是关键字 const
。这里的 const
的作用是修改隐式 this
指针的类型。
默认情况下,this
的类型是一个 const 指针,指向类类型的 非const 版本。
例如,在 Sales_data
成员函数中,this
的类型默认是 Sales_data *const
。此时,不能在 const 对象上调用普通成员函数。
常量成员函数 (const member functions):成员函数的形参列表后面加上 const
。此处的 const
表明 this
是指向常量的指针。
例如,isbn 函数中的 this
类型是 const Sales_data *const
。
在类的外部定义成员函数
double Sales_data::avg_price() const {
if (units_sold)
return revenue/units_sold;
else
return 0;
}
定义一个返回“这个”对象的函数
Sales_data& Sales_data::combine(const Sales_data &rhs) {
units_sold += rhs.units_sold; // add the members of rhs into
revenue += rhs.revenue; // the members of "this" object
return *this; // return the object on which the function was called
}
total.combine(trans); // update the running total
注意函数的返回类型和返回语句。
定义类相关的非成员函数
尽管 add 等函数定义的操作从概念上来说是类的接口的部分,但它们并不是类本身的部分。
一般来说,如果非成员函数是类的接口的组成部分,应该声明在类本身的头文件中。
定义 read 和 print 函数
// input transactions contain ISBN, number of copies sold, and sales price
istream &read(istream &is, Sales_data &item) {
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item) {
os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price();
return os;
}
read 和 print 函数分别接受各自 IO 类类型的引用作为其参数。因为 IO 类不是能被拷贝的类型,所以只能通过引用传递它们。另外,读取或写入流会改变流,所以函数接受普通引用,而不是常量引用。
print 函数没有输出换行符。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样用户代码可以自己决定是否需要换行。
定义 add 函数
Sales_data add(const Sales_data &lhs, const Sales_data &rhs) {
Sales_data sum = lhs; // copy data members from lhs into sum
sum.combine(rhs); // add data members from rhs into sum
return sum;
}
构造函数
构造函数 (constructors):类为了控制对象初始化的一个或多个特殊成员函数。任务:初始化类对象的数据成员。无论何时,只要类类型的对象被创建,构造函数就会运行。
构造函数的名字与类的名字相同,没有返回类型。
构造函数不能被声明为 const
。当创建类类型的一个 const
对象时,直到构造函数完成该对象的初始化后,对象才获取 const
属性。因此,构造函数可以在 const
对象的构造过程中向其写入数据。
合成的默认构造函数
Sales_data 类没有定义任何构造函数,但使用了该类对象的程序可以正确的编译和运行。
默认构造函数 (default constructors):控制默认初始化的构造函数。默认构造函数无须任何实参。
如果类没有显式定义任何构造函数,那么编译器会隐式地定义一个默认构造函数。
合成的默认构造函数 (synthesized default constructor):编译器生成的构造函数。
对于大多数类来说,这个合成的默认构造函数会使用下面的方式初始化类的数据成员:
①如果存在类内初始值,用它来初始化成员;
②否则,默认初始化该成员。
某些类不能依赖于合成的默认构造函数
类必须定义它自己的默认构造函数。原因如下:
- 只有在类没有定义任何构造函数时,编译器才会生成默认构造函数。如果定义了其他构造函数,而没有定义默认构造函数,那么类就没有默认构造函数。这条规则的依据:如果类在某种情况下需要控制对象初始化,那么类很可能在所有情况下都需要控制。
- 对于某些类来说,合成的默认构造函数会执行错误操作。如果定义在块内的内置或复合类型(比如数组或指针)的对象被默认初始化,它们的值是未定义的。
- 有时编译器不能为某些类合成默认构造函数。例如,一个类有一个成员是类类型,且这个成员的类类型没有默认构造函数,那么编译器不能初始化该成员。
定义 Sales_data 的构造函数
struct Sales_data {
// constructors added
Sales_data() = 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 &);
// other members as before
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;
};
在C++11标准中,可以通过在形参列表后面写上 = 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) { }
冒号以及冒号与花括号之间的代码,这部分称为构造函数初始值列表 (constructor initializer list),为创建的对象的一个或多个数据成员指定初始值。
// has the same behavior as the original constructor defined above
Sales_data(const std::string &s): bookNo(s), units_sold(0), revenue(0){ }
构造函数不应覆盖类内初始值,除非使用不同的初始值。
如果不能使用类内初始值,每个构造函数应该显式初始化每个内置类型的成员。
在类的外部定义构造函数
Sales_data::Sales_data(std::istream &is) {
read(is, *this); // read will read a transaction from is into this object
}
复制、赋值和析构
一般来说,编译器生成的版本将对对象的每个成员执行复制、赋值和销毁操作。
尽管编译器可以合成复制、赋值和析构操作,但对某些类来说,默认版本无法正确工作。
很多需要动态内存的类可以(一般应该)使用 vector
或 string
管理必要的存储空间。使用 vector
或 string
的类可以避免分配和释放内存带来的复杂性。
7.2 访问控制和封装
在C++语言中,使用访问说明符 (access specifiers) 加强封装:
- 定义在
public
说明符之后的成员在整个程序内可被访问。public
成员定义类的接口。 - 定义在
private
说明符之后的成员可以被类的成员函数访问,但不能被使用该类的代码访问。private
部分封装(即隐藏)实现细节。
class Sales_data {
public: // access specifier added
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { }
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: // access specifier added
double avg_price() const { return units_sold ? revenue/units_sold : 0; }
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
使用 struct 或 class 关键字
使用 class
和 struct
定义类的唯一区别是默认访问级别。
如果使用 struct
关键字,定义在第一个类型说明符前面的成员是 public
;
如果使用 class
关键字,定义在第一个类型说明符前面的成员是 private
。
友元
类可以允许其他的类或函数访问它的非共有成员,方法是令那个类或函数成为它的友元 (friend)。
class Sales_data {
// friend declarations for nonmember Sales_data operations added
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&);
// other members and access specifiers as before
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { }
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;
};
// declarations for nonmember parts of the Sales_data interface
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 类的其他特性
类成员再探
定义一对相互关联的类 Screen
和 Window_mgr
来展示类的特性。
定义一个类型成员
class Screen {
public:
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
将 pos 定义在 Screen 的 public 部分,这样用户就可以使用这个名字。Screen 的用户不应该知道 Screen 使用了一个 string 对象存放它的数据。通过将 pos 定义成 public 成员,隐藏 Screen 的实现细节。
// alternative way to declare a type member using a type alias
using pos = std::string::size_type;
与普通成员不同,定义类型的成员必须出现在被使用之前。因此,类型成员通常出现在类开始的地方。
Screen 类的成员函数
class Screen {
public:
typedef std::string::size_type pos;
Screen() = default; // needed because Screen has another constructor
// cursor initialized to 0 by its in-class initializer
Screen(pos ht, pos wd, char c): height(ht), width(wd), contents(ht * wd, c) { }
char get() const // get the character at the cursor
{ return contents[cursor]; } // implicitly inline
inline char get(pos ht, pos wd) const; // explicitly inline
Screen &move(pos r, pos c); // can be made inline later
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
令成员 inline
可以在类的内部将一个成员函数显式声明为 inline
。同样,可以在类的外部使用 inline
修饰函数定义。
inline // we can specify inline on the definition
Screen &Screen::move(pos r, pos c) {
pos row = r * width; // compute the row location
cursor = row + c ; // move cursor to the column within that row
return *this; // return this object as an lvalue
}
char Screen::get(pos r, pos c) const // declared as inline in the class
{
pos row = r * width; // compute row location
return contents[row + c]; // return character at the given column
}
虽然无需同时在声明和定义时说明 inline
,但这种做法是合法的。不过,最好只在类外面的定义说明 inline
,这使类更容易阅读。
注意:inline
成员函数应该与相应的类定义在相同的头文件中。
重载成员函数
Screen myscreen;
char ch = myscreen.get();// calls Screen::get()
ch = myscreen.get(0,0); // calls Screen::get(pos, pos)
mutable 数据成员
可变数据成员 (mutable data member) 永远不会是 const
,即使它是 const
对象的一个成员。因此,const
成员函数可以更改一个 mutable
成员。
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr; // may change even in a const object
// other members as before
};
void Screen::some_member() const {
++access_ctr; // keep a count of the calls to any member function
// whatever other work this member needs to do
}
类类型数据成员的初始值
在C++11标准中,默认初始化类类型数据成员的最好的方式,是将其指定为类内初始值。
class Window_mgr {
private:
// Screens this Window_mgr is tracking
// by default, a Window_mgr has one standard sized blank Screen
std::vector<Screen> screens{Screen(24, 80, ' ') };
};
类内初始值必须使用 =
形式的初始化,或者花括号括起来的直接初始化形式。
返回 this 的函数
class Screen {
public:
Screen &set(char);
Screen &set(pos, pos, char); // other members as before
};
inline Screen &Screen::set(char c) {
contents[cursor] = c; // set the new value at the current cursor location
return *this; // return this object as an lvalue
}
inline Screen &Screen::set(pos r, pos col, char ch) {
contents[r*width + col] = ch; // set specified location to given value
return *this; // return this object as an lvalue
}
set 成员返回的是被调用对象的引用。返回引用的函数是左值,意味着它们返回对象本身,而不是对象的副本。
// move the cursor to a given position, and set that character
myScreen.move(4,0).set('#');
从 const 成员函数返回 this
Screen myScreen;
// if display returns a const reference, the call to set is an error
myScreen.display(cout).set('*');
如果一个 const 成员函数返回 *this
作为引用,它的返回类型是 const 引用。
基于 const 的重载
class Screen {
public:
// display overloaded on whether the object is const or not
Screen &display(std::ostream &os) { do_display(os); return *this; }
const Screen &display(std::ostream &os) const { do_display(os); return *this; }
private:
// function to do the work of displaying a Screen
void do_display(std::ostream &os) const {os << contents;}
// other members as before
};
Screen myScreen(5,3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // calls non const version
blank.display(cout); // calls const version
建议:对于公共代码使用私有功能函数
定义一个单独的 do_display
操作的原因:
- 避免在多处使用同样的代码。
- 我们预期随着类的发展,
display
操作将变得更加复杂。这时,把相应的操作写在一处而非两处的作用更明显。 - 我们可能想在开发期间将调试信息添加到
do_display
中,而在最终产品代码版本中删除这些信息。如果只需要更改do_display
的定义以添加或删除调试代码,这样做会更容易。 - 这个额外的函数调用不会增加运行时开销。因为
do_display
是在类内定义的,它是隐式inline
的。
在实践中,设计良好的 C++程序往往含有大量类似于 do_display
的小函数,它们被调用来完成某些其他函数的“实际”工作。
类类型
每个类定义唯一的类型。即使两个类有相同的成员列表,它们也是不同的类型。
struct First {
int memi;
int getMem();
};
struct Second {
int memi;
int getMem();
};
First obj1;
Second obj2 = obj1; // error: obj1 and obj2 have different types
可以使用类名作为类型名,直接指向类类型。或者,在关键字 class 或 struct 后面使用类名。
Sales_data item1; // default-initialized object of type Sales_data
class Sales_data item1; // equivalent declaration
第二种方法继承自 C 语言,在 C++ 中同样有效。
类声明
可以在没有定义类的情况下声明类:
class Screen; // declaration of the Screen class
这个声明有时被称作前向声明 (forward declaration),引入名字 Screen 到程序中,并指明 Screen 是一种类类型。
在声明之后,且在看到定义之前,类型 Screen 是一个不完全声明 (incomplete type) —— 已经知道 Screen 是一个类类型,但不知道这个类型包含什么成员。
使用不完全类型的情况:可以定义指向这种类型的指针或引用,可以声明(但不能定义)使用不完全类型作为形参或返回类型的函数。
在创建类类型的对象之前必须定义该类,否则编译器不知道对象需要多少存储空间。
因为直到类的主体完成才是定义了类,所以一个类的数据成员类型不能是它自己。
但是,一旦一个类名可见,它就被认为是已声明(但尚未定义)。因此,类的数据成员类型可以是指向其自身类型的指针或引用:
class Link_screen {
Screen window;
Link_screen *next;
Link_screen *prev;
};
友元再探
类可以定义普通的非成员函数作为友元。
类还可以使其他的类成为其友元,或者可以声明其他类(已定义)特定的成员函数作为友元。
友元函数可以定义在类的内部。这样的函数是隐式内联的。
类之间的友元关系
class Screen {
// Window_mgr members can access the private parts of class Screen
friend class Window_mgr;
// ... rest of the Screen class
};
如果一个类指定了友元类,则友元类的成员函数可以访问该类的所有成员,包括非公有成员。
class Window_mgr {
public:
// location ID for each screen on the window
using ScreenIndex = std::vector<Screen>::size_type;
// reset the Screen at the given position to all blanks
void clear(ScreenIndex);
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
void Window_mgr::clear(ScreenIndex i) {
// s is a reference to the Screen we want to clear
Screen &s = screens[i]; // reset the contents of that Screen to all blanks
s.contents = string(s.height * s.width, ' ');
}
注意:友元关系不是传递性的。即,如果类 Window_mgr 有自己的友元,这些友元没有访问 Screen 的特权。
令成员函数作为友元
当把一个成员函数声明为友元时,必须指明函数是哪个类的成员。
class Screen {
// Window_mgr::clear must have been declared before class Screen
friend void Window_mgr::clear(ScreenIndex);
// ... rest of the Screen class
};
令成员函数作为友元,需要仔细组织程序的构造,以适应声明和定义之间的相互依赖性。
在本例中,必须按以下顺序编写程序:
- 首先,定义 Window_mgr 类,它声明但不能定义 clear。在 clear 使用 Screen 的成员之前必须声明 Screen。
- 接着,定义 Screen 类,包括对于 clear 的友元声明。
- 最后,定义 clear,此时它可以使用 Screen 的成员。
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在友元声明中时,这个名字隐式地假定在当前作用域中。然而,友元本身实际上不是声明在那个作用域中。
即使在类中定义了这个函数,仍必须在类的外部提供该函数的声明,以使该函数可见。即使只是从声明友元的类的成员中调用友元,也必须存在声明:
struct X {
friend void f() { /* friend function can be defined in the class body */ }
X() { f(); } // error: no declaration for f
void g();
void h();
};
void X::g() { return f(); } // error: f hasn't been declared
void f(); // declares the function defined inside X
void X::h() { return f(); } // ok: declaration for f is now in scope
友元声明影响访问权限,但不是普通意义上的声明。
练习:Person 类
// person.h
class Person {
friend std::ostream &print(std::ostream&, const Person&);
friend std::istream &read(std::istream&, Person&);
public:
Person() = default;
Person(const std::string &s): name(s) { }
Person(const std::string &sn, const std::string &sa): name(sn), address(sa) { }
explicit Person(std::istream &);
std::string getName() const { return name; }
std::string getAddress() const {return address; }
private:
std::string name;
std::string address;
};
// nonmember Sales_data interface functions
std::ostream &print(std::ostream&, const Person&);
std::istream &read(std::istream&, Person&);
// person.cpp
#include <iostream>
#include "person.h"
using namespace std;
Person::Person(std::istream &is) {
read(is, *this);
}
ostream &print(ostream &os, const Person &item) {
os << item.getName() << " " << item.getAddress();
return os;
}
istream &read(istream &is, Person &item) {
is >> item.name >> item.address;
return is;
}
学习目录:【C++ primer】目录