《C++Primer》第十二章-类-学习笔记(1)
文章目录
日志:
1,2020-03-02 笔者提交文章的初版V1.0
作者按:
最近在学习C++ primer,初步打算把所学的记录下来。
传送门/推广
《C++Primer》第二章-变量和基本类型-学习笔记(1)
《C++Primer》第三章-标准库类型-学习笔记(1)
《C++Primer》第八章-标准 IO 库-学习笔记(1)
摘要
类是 C++ 中最重要的特征。C++ 语言的早期版本被命名为“带类的 C(C with Classes)”,以强调类机制的中心作用。随着语言的演变,创建类的配套支持也在不断增加。语言设计的主要目标
也变成提供这样一些特性:允许程序定义自己的类型,它们用起来与内置类型一样容易和直观。
C++ 程序中,类都是至关重要的:我们能够使用类来定义为要解决的问题定制的数据类型,从而得到更加易于编写和理解的应用程序。设计良好的类类型可以像内置类型一样容易使用。
类定义了数据成员
和函数成员
:数据成员
用于存储与该类类型的对象相关联的状态
,而函数成员
则负责执行赋予数据意义的操作。通过类我们能够将实现
和接口
分离,用接口指定类所支持的操作,而实现的细节只需类的实现者了解或关心。这种分离可以减少使编程冗长乏味和容易出错的那些繁琐工作。
本节介绍如何定义类,包括类的使用中非常基本的主题:类作用域
、数据隐藏
和构造函数
。此外,还介绍了类的一些新特征:友元
、使用隐含的 this 指针
,以及静态(static)和可变(mutable)成员
的作用。
在 C++ 中,用类来定义自己的抽象数据类型(abstract data types)。通过定义类型来对应所要解决的问题中的各种概念,可以使我们更容易编写、调试和修改程序。数据抽象能够隐藏对象的内部表示,同时仍然允许执行对象的公有(public)操作。 抽象数据类型是面向对象编程和泛型编程的基础。
类的定义和声明
最简单地说,类就是定义了一个新的类型和一个新作用域。接下来的说明都是围绕着Sales_item 类来举例子的:
//Sales_item 类:
class Sales_item {
public:
// operations on Sales_item objects
double avg_price() const; //常量成员函数!
bool same_isbn(const Sales_item &rhs) const
{ return isbn == rhs.isbn; }
// default constructor needed to initialize members of built-in type
Sales_item(): units_sold(0), revenue(0.0) { }
private:
std::string isbn;
unsigned units_sold;
double revenue;
};
double Sales_item::avg_price() const
{
if (units_sold)
return revenue/units_sold;
else
return 0;
}
类成员
每个类可以没有成员,也可以定义多个成员,成员可以是数据、函数或类型别名。
一个类可以包含若干公有的、私有的和受保护的部分。
- 类成员的访问限制是通过在类主体内部对各个区域标记
public
、private
、protected
来指定的。关键字 public、private、protected 称为访问修饰符
。 - 一个类可以有多个 public、protected 或 private 标记区域。每个标记区域在下一个标记区域开始之前或者在遇到类主体结束右括号之前都是有效的。
- 成员和类的
默认访问修饰符是 private
。 - 在 public 部分定义的成员可被使用该类的所有代码访问;在 private 部分定义的成员可被其他的类成员访问。
- 所有成员必须在类的内部声明,一旦类定义完成后,就没有任何方式可以增加成员了。
构造函数
创建一个类类型的对象时,编译器会自动使用一个构造函数来初始化该对象。构造函数
是一个特殊的、与类同名的成员函数
,用于给每个数据成员设置适当的初始值。
构造函数一般就使用一个构造函数初始化列表
,来初始化对象的数据成员:
// default constructor needed to initialize members of built-in type
Sales_item(): units_sold(0), revenue(0.0) { }
成员函数
在类内部,声明成员函数
是必需的,而定义成员函数
则是可选的。在类内部定义的函数默认为 inline
。
在类外部定义的成员函数
必须指明它们是在类的作用域中。
Sales_item::avg_price 的定义使用作用域操作符
来指明这是Sales_item 类中 avg_price 函数的定义。
成员函数有一个附加的隐含实参
,将函数绑定到调用函数的对象——当我们编写下面的函数时:
trans.avg_price()
就是在调用名 trans 的对象的 avg_price 函数。如果 trans 是一个Sales_item 对象,则在 avg_price 函数内部对 Sales_item 类成员引用就是对trans 成员的引用。
将关键字 const 加在形参表之后,就可以将成员函数声明为常量
:
double avg_price() const;
const 成员不能改变其所操作的对象的数据成员。const 必须同时出现在声明和定义中,若只出现在其中一处,就会出现一个编译时错误。
数据抽象和封装
类背后蕴涵的基本思想是数据抽象
和封装
。
数据抽象
是一种依赖于接口和实现分离的编程(和设计)技术。类设计者必须关心类是如何实现的,但使用该类的程序员不必了解这些细节。相反,使用一个类型的程序员仅需了解类型的接口,他们可以抽象地考虑该类型做什么,而不必具体地考虑该类型如何工作。
封装
是一项低层次的元素组合起来的形成新的、高层次实体的技术。函数是封装的一种形式:函数所执行的细节行为被封装在函数本身这个更大的实体中。被封装的元素隐藏了它们的实现细节——可以调用一个函数但不能访问它所执行的语句。同样地,类也是一个封装的实体:它代表若干成员的聚焦,大多数(良好设计的)类类型隐藏了实现该类型的成员。
标准库类型 vector
同时具备数据抽象和封装的特性。在使用方面它是抽象的,只需考虑它的接口,即它能执行的操作。它又是封装的,因为我们既无法了解该类型如何表示的细节,也无法访问其任意的实现制品。数组
在概念上类似于 vector,但既不是抽象的,也不是封装的。可以通过访问存放数组的内存来直接操纵数组。
数据抽象和封装提供了两个重要优点:
- 避免类内部出现无意的、可能破坏对象状态的用户级错误。
- 随时间推移可以根据需求改变或缺陷(bug)报告来完美类实现,而无须改变用户级代码。
类中同一类型的多个数据成员
类的数据成员的声明类似于普通变量的声明。如果一个类具有多个同一类型的数据成员,则这些成员可以在一个成员声明中指定,这种情况下,成员声明和普通变量声明是相同的。
class Screen { //Screen 的类型表示计算机上的窗口
public:
// interface member functions
private:
std::string contents; //保存窗口内容的 string 成员
std::string::size_type cursor;//指定光标当前停留的字符
std::string::size_type height, width; //指定窗口的高度和宽度
}
使用类型别名来简化类
除了定义数据和函数成员之外,类还可以定义自己的局部类型名字
。如果为std::string::size_type 提供一个类型别名,那么 Screen 类将是一个更好的抽象:
class Screen {
public:
// interface member functions
typedef std::string::size_type index;
private:
std::string contents;
index cursor;
index height, width;
};
类所定义的类型名
遵循任何其他成员的标准访问控制。将 index 的定义放在类的 public 部分,是因为希望用户使用这个名字。
类的成员函数可被重载
这些类之所以简单,另一个方面也是因为它们只定义了几个成员函数。特别地,这些类都不需要定义其任意成员函数的重载版本。然而,像非成员函数一样,成员函数也可以被重载。
重载操作符
有特殊规则,是个例外,成员函数只能重载本类的其他成员函数
。类的成员函数与普通的非成员函数以及在其他类中声明的函数不相关,也不能重载它们。
(重载成员函数的规则)
重载的成员函数和普通函数应用相同的规则:两个重载成员的形参数量和类型不能完全相同。
调用非成员重载函数所用到的函数匹配过程
也应用于重载成员函数的调用。
定义重载成员函数
为了举例说明重载,可以给出 Screen 类的两个重载成员,用于从窗口返回一个特定字符。两个重载成员中,一个版本返回由当前光标指示的字符,另一个返回指定行列处的字符:
class Screen {
public:
typedef std::string::size_type index;
// return character at the cursor or at a given position
char get() const { return contents[cursor]; }
char get(index ht, index wd) const;
// remaining members
private:
std::string contents;
index cursor;
index height, width;
};
与任意的重载函数一样,给指定的函数调用提供适当数目或类型的实参来选择运行哪个版本:
Screen myscreen;
char ch = myscreen.get();// calls Screen::get()
ch = myscreen.get(0,0); // calls Screen::get(index, index)
显式指定 inline 成员函数
在类内部定义的成员函数,例如上面代码中不接受实参的 get 成员,将自动作为inline 处理。也就是说,当它们被调用时,编译器将试图在同一行内扩展该函数。也可以显式地将成员函数声明为 inline
:
class Screen {
public:
typedef std::string::size_type index;
// implicitly inline when defined inside the class declaration
char get() const { return contents[cursor]; }
// explicitly declared as inline; will be defined outside the
class declaration
inline char get(index ht, index wd) const;
// inline not specified in class declaration, but can be defined inline later
index get_cursor() const;
// ...
};
// inline declared in the class declaration; no need to repeat on the definition
char Screen::get(index r, index c) const
{
index row = r * width; // compute the row location
return contents[row + c]; // offset by c to fetch specified character
}
// not declared as inline in the class declaration, but ok to make inline in definition
inline Screen::index Screen::get_cursor() const //在类定义外部的函数定义上指定 inline
{
return cursor;
}
可以在类定义体内部指定一个成员为inline,作为其声明的一部分。或者,也可以在类定义外部的函数定义上指定 inline。在声明和定义处指定 inline都是合法的。在类的外部定义 inline 的一个好处是可以使得类比较容易阅读。
像其他 inline 一样,inline 成员函数的定义必须在调用该函数的每个源文件中是可见的。不在类定义体内定义的 inline成员函数,其定义通常应放在有类定义的同一头文件中。
类声明与类定义
一旦遇到右花括号,类的定义就结束了。并且一旦定义了类,那以我们就知道了所有的类成员,以及存储该类的对象所需的存储空间。在一个给定的源文件中,一个类只能被定义一次。如果在多个文件中定义一个类,那么每个文件中的定义必须是完全相同的。
可以声明一个类而不定义它:
class Screen; // declaration of the Screen class
这个声明,有时称为前向声明(forward declaraton)
,在程序中引入了类类型的 Screen。在声明之后、定义之前,类 Screen 是一个不完全类型(incompete type)
,即已知 Screen 是一个类型,但不知道包含哪些成员。
不完全类型(incomplete type)
只能以有限方式使用。不能定义该类型的对象。不完全类型的用途
:用于定义指向该类型的指针及引用,或者用于声明(而不是定义)使用该类型作为形参类型或返回类型的函数。
在创建类的对象之前,必须完整地定义该类。必须定义类,而不只是声明类,这样,编译器就会给类的对象预定相应的存储空间。同样地,在使用引用或指针访问类的成员之前,必须已经定义类。
为类的成员使用类声明
只有当类定义已经在前面出现过,数据成员才能被指定为该类类型。如果该类型是不完全类型
,那么数据成员只能是指向该类类型的指针或引用。
因为只有当类定义体完成后才能定义类,因此类不能具有自身类型的数据成员。然而,只要类名一出现就可以认为该类已声明。因此,类的数据成员可以是指向自身类型的指针或引用:
class LinkScreen {
Screen window;
LinkScreen *next; //类的数据成员可以是指向自身类型的指针或引用
LinkScreen *prev;
};
类的前身声明一般用来编写相互依赖的类。
类对象
定义一个类时,也就是定义了一个类型。一旦定义了类,就可以定义该类型的对象。定义对象时,将为其分配存储空间,但(一般而言)定义类型时不进行存储分配:
class Sales_item {
public:
// operations on Sales_item objects
private:
std::string isbn;
unsigned units_sold;
double revenue;
};
定义了一个新的类型,但没有进行存储分配。当我们定义一个对象Sales_item item;
时,编译器分配了足以容纳一个 Sales_item 对象的存储空间。item 指的就是那个存储空间。每个对象具有自己的类数据成员的副本。修改 item 的数据成员不会改变任何其他 Sales_item 对象的数据成员。
定义类类型的对象
定义了一个类类型之后,可以按以下两种方式使用。
- 将类的名字直接用作类型名。
- 指定关键字 class 或 struct,后面跟着类的名字:
Sales_item item1; // default initialized object of type
Sales_item
class Sales_item item1; // equivalent definition of item1
两种引用类类型方法是等价的。第二种方法是从 C 继承而来的,在 C++ 中仍然有效。第一种更为简练,由 C++ 语言引入,使得类类型更容易使用。
为什么类的定义以分号结束
类的定义分号结束。分号是必需的,因为在类定义之后可以接一个对象定义列表。定义必须以分号结束:
class Sales_item { /* ... */ };
class Sales_item { /* ... */ } accum, trans;
通常,将对象定义成类定义的一部分是个坏主意。这样做,会使所发生的操作难以理解。对读者而言,将两个不同的实体(类和变量)组合在一个语句中,也会令人迷惑不解。
隐含的 this 指针
成员函数具有一个附加的隐含形参
,即指向该类对象的一个指针。这个隐含形参命名为this
,与调用成员函数的对象绑定在一起。成员函数不能定义 this 形参,而是由编译器隐含地定义。成员函数的函数体可以显式使用 this 指针,但不是必须这么做。如果对类成员的引用没有限定,编译器会将这种引用处理成通过 this 指针的引用。
何时使用 this 指针
尽管在成员函数内部显式引用 this 通常是不必要的,但有一种情况下必须这样做:当我们需要将一个对象作为整体引用而不是引用对象的一个成员时。最常见的情况是在这样的函数中使用 this:该函数返回对调用该函数的对象的引用。
某种类可能具有某些操作,这些操作应该返回引用,Screen 类就是这样的一个类。迄今为止,我们的类只有一对 get 操作。逻辑上,我们可以添加下面的操作。
• 一对 set 操作,将特定字符或光标指向的字符设置为给定值。
• 一个 move 操作,给定两个 index 值,将光标移至新位置。
理想情况下,希望用户能够将这些操作的序列连接成一个单独的表达式:
// move cursor to given position, and set that character
myScreen.move(4,0).set('#'); //myScreen.move(4,0)返回的是Screen&对象爱,所以可以这么操作
这个语句等价于:
myScreen.move(4,0); //下面一个代码块会讲
myScreen.set('#');
返回 *this
在单个表达式中调用 move 和 set 操作时,每个操作必须返回一个引用,该引用指向执行操作的那个对象:
class Screen {
public:
// interface member functions
Screen& move(index r, index c);
Screen& set(char);
Screen& set(index, index, char);
// other members as before
};
注意,这些函数的返回类型是 Screen&,指明该成员函数返回对其自身类类型的对象的引用。每个函数都返回调用自己的那个对象。使用 this 指针来访问该对象。
下面是对两个新成员的实现:
Screen& Screen::set(char c)
{
contents[cursor] = c;
return *this; //一个对象作为整体引用而不是引用对象的一个成员
}
Screen& Screen::move(index r, index c)
{
index row = r * width; // row location
cursor = row + c;
return *this;
}
函数中唯一需要关注的部分是 return 语句。在这两个操作中,每个函数都返回 *this。在这些函数中,this 是一个指向非常量 Screen 的指针。如同任意的指针一样,可以通过对 this 指针解引用来访问 this 指向的对象。
从 const 成员函数返回 *this
普通的非 const 成员函数中,this 的类型
是一个指向类类型对象的 const指针
。可以改变 this 所指向的值,但不能改变 this 所保存的地址。
在 const 成员函数中,this 的类型
是一个指向 const 类类型对象的const 指针
。既不能改变 this 所指向的对象,也不能改变 this 所保存的地址。不能从 const 成员函数返回指向类对象的普通引用。const 成员函数只能返回 *this
作为一个 const 引用。
例如,我们可以给 Screen 类增加一个 display 操作。这个函数应该在给定的 ostream 上打印 contents。逻辑上,这个操作应该是一个 const 成员。打印 contents 不会改变对象。如果将 display 作为 Screen 的 const 成员,则 display 内部的 this 指针将是一个 const Screen* 型的 const。
然而,与 move 和 set 操作一样,我们希望能够在一个操作序列中使用display:
// move cursor to given position, set that character and display thescreen
myScreen.move(4,0).set('#').display(cout); //非 const 对象上调用const 成员函数
这个用法暗示了 display 应该返回一个 Screen 引用,并接受一个ostream 引用。如果 display 是一个 const 成员,则它的返回类型必须是const Screen&。
不幸的是,这个设计存在一个问题。如果将 display 定义为 const 成员,就可以在非 const 对象上调用 display,但不能将对 display 的调用嵌入到一个长表达式
中。下面的代码将是非法的:
Screen myScreen;
// this code fails if display is a const member function
// display return a const reference; we cannot call set on a const
myScreen.display().set('*'); //display 返回的对象是 const 类类型对象,不能在 const 对象上调用 set
//const对象只能使用 const 成员
问题在于这个表达式是在由 display 返回的对象
上运行 set。该对象是const,因为 display 将其对象作为 const 返回。我们不能在 const 对象上调用 set。
基于 const 的重载
为了解决上面一节这个问题,我们必须定义两个 display 操作:一个是 const,另一个不是 const。基于成员函数是否为 const,可以重载一个成员函数;同样地,基于一个指针形参是否指向 const可以重载一个函数。const对象只能使用 const 成员。非 const 对象可以使用任一成员,但非 const 版本是一个更好的匹配。
在此,我们将定义一个名为 do_display 的 private 成员来打印 Screen。每个 display 操作都将调用此函数,然后返回调用自己的那个对象:
class Screen {
public:
// interface member functions
// 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 //const成员函数返回值类型是const对象的引用
{ do_display(os); return *this; }
private:
// single function to do the work of displaying a Screen,
// will be called by the display operations
void do_display(std::ostream &os) const //const成员函数
{ os << contents; }
// as before
};
现在,当我们将 display 嵌入到一个长表达式中时,将调用非 const 版本。当我们 display 一个 const 对象时,就调用 const 版本:
Screen myScreen(5,3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // calls nonconst version 非 const 对象可以使用任一成员,但非 const 版本是一个更好的匹配
blank.display(cout); // calls const version const对象只能使用 const 成员
可变数据成员
有时(但不是很经常),我们希望类的数据成员(甚至在 const 成员函数内)可以修改。这可以通过将它们声明为 mutable 来实现。
可变数据成员(mutable data member)
永远都不能为 const,甚至当它是const 对象的成员时也如此。因此,const 成员函数可以改变 mutable 成员。要将数据成员声明为可变的,必须将关键字 mutable 放在成员声明之前:
class Screen {
public:
// interface member functions
private:
mutable size_t access_ctr; // may change in a const members
// other data members as before
};
我们给 Screen 添加了一个新的可变数据成员 access_ctr。使用access_ctr 来跟踪调用 Screen 成员函数的频繁程度:
void Screen::do_display(std::ostream& os) const
{
++access_ctr; // keep count of calls to any member function
os << contents;
}
尽管 do_display 是 const,它也可以增加 access_ctr。该成员是可变成员,所以,任意成员函数,包括 const 函数,都可以改变 access_ctr 的值。
编程角色的不同类别
程序员经常会将运行应用程序的人看作“用户”
。
- 应用程序为最终“使用”它的用户而设计,并响应用户的反馈而完善。
- 类也类似:类的设计者为类的“用户”设计并实现类。在这种情况下,“用户”是程序员,而不是应用程序的最终用户。
一方面,成功的应用程序的创建者会很好地理解和实现用户的需求。同样地,良好设计的、实用的类,其设计也要贴近类用户的需求。
另一方面,类的设计者
与类的实现者之间
的区别,也反映了应用程序的用户与设计和实现者之间的区分。用户
只关心应用程序能否以合理的费用满足他们的需求。同样地,类的使用者
只关心它的接口。好的类设计者会定义直观和易用的类接口,而使用者只关心类中影响他们使用的部分实现。如果类的实现速度太慢或给类的使用者加上负担,则必然引起使用者的关注。在良好设计的类中,只有类的设计者会关心实现。
在简单的应用程序中,类的使用者和设计者也许是同一个人。即使在这种情况下,保持角色区分也是有益的。设计类的接口时,设计者应该考虑的是如何方便类的使用;使用类的时候,设计者就不应该考虑类如何工作。
注意,C++ 程序员经常会将应用程序的用户和类的使用者都称为“用户”。
任何不会修改数据成员(即函数中的变量)的函数都应该声明为const 类型。如果在编写const 成员函数时,不慎修改了数据成员,或者调用了其它非const 成员函数,编译器将指出错误,这无疑会提高程序的健壮性。