第九章 内存模型和名称空间

9.1 单独编译

和C语言一样,C++也允许甚至鼓励程序员将组件函数放在独立的文件中。

可以单独编译这些文件,然后将它们链接成可执行的程序(通常,C++编译器既编译程序,也管理链接器。)如果只修改了一个文件,则可以只重新编译该文件,然后将它们与其他文件的编译版本链接。这使得大程序的管理更便捷。另外,大多数C++环境都提供了其他工具来帮助管理。例如:UNIX和Linux系统都具有make程序,可以跟踪程序依赖的文件以及这些文件的最后修改时间。运行make时,如果检测到上次编译后修改了源文件,make将记住重新构建程序所需的步骤。

#include提供的功能 :与其将结构声明加入到每一个文件中,不如将其放在头文件中,然后在每一个源代码文件中包含该头文件。这样,要修改结构声明时,只需在头文件中做一次改动即可。另外,也可以将函数原型放在头文件中。因此,可以将原来的程序分成三个部分:

  • 头文件:包含结构声明和使用这些结构的函数和原型;
  • 源代码文件:包含与结构有关的函数的代码;
  • 源代码文件:包含调用与结构相关的函数的代码。

这是一种非常有用的组织程序的策略。例如:如果编写另一个 程序时,也需要使用这些函数,则只需要包含头文件,并将函数文件添加到项目列表或make列表中即可。另外,这种组织方式也与OOP方法一致。一个文件(头文件)包含了用户定义类型的定义;另一个文件包含操纵用户定义类型的函数的代码。这两个文件组成了一个软件包,可用于各种程序中。

请不要将函数定义或变量声明放到头文件中。例如:如果在头文件中包含一个函数定义,然后在其他两个文件(属于同一个程序)中包含该头文件,则同一个程序中将包含同一个函数的两个定义,除非函数是内联的,否则这将出错。

下面列出头文件中常包含的内容:

  • 函数原型
  • 使用#define 或 const 定义的符号常量
  • 结构声明
  • 类声明
  • 模板声明
  • 内联函数

将结构声明放在头文件中是可以的,因为它们不创建变量,而只是在源代码文件中声明结构变量时,告诉编译器如何创建该结构变量。同样,模板声明 不是将被编译的代码,它们指示编译器如何生成与源代码中的函数调用相匹配的函数定义。被声明为const的数据和内联函数有特殊的链接属性。

注意:在包含头文件时,使用"corrdin.h",而不是<coodin.h>,如果文件名称包含在尖括号中,则C++编译器将在存储标准头文件的主机系统的文件中查找;但如果文件包含在双引号中,则编译器将首先查找当前的工作目录或源代码目录。如果没有在那里找到头文件,则将在标准位置查找。因此,包含自己的头文件时,应使用引号而不是尖括号。

警告:在IDE中,不要将头文件加入到项目列表中,也不要在源代码文件中使用#define来包含其他源代码文件。

// coordin.h

#ifndef COORDIN_H_
#define COORDIN_H_

struct polar
{
    double distance;		// distance from origin
    double angle;			// direction from origin
};

struct rect
{
    double x;		// horizontal distance from origin
    double y;		// vertivcal distance from origin
};

// prototypes
polar rect_to_polar(rect xypos);
void show_polar(polar dapos);

#endif

🚀头文件管理

在同一个文件中只能将同一个头文件包含一次。记住这个规则很容易,但很可能在不知情的情况下将头文件包含很多次。例如:可能使用包含了另外一个头文件的头文件。有一种标准的C/C++技术可以避免多次包含同一个头文件。它是基于预处理器编译指令#ifndef (即 if not defined) 的。下面的代码片段意味着仅当以前没有使用预处理器编译指令#define 定义名称COORDIN_H_时,才处理 #ifndef 和 # endif之间的语句:

#ifndef COORDIN_H_
......
#endif

通常,使用 #define 语句来创建符号变量,如下所示:

#define MAXIMUM 4096

但只要将#define 用于名称,就足以完成该名称的定义:

#define COORDIN_H_

使用这种技术是为了将文件内容包含在#ifndef中:

#ifndef COORDIN_H_
#define COORDIN_H_
// place include file contents here
#endif

编译器首次遇到该文件时,名称COORDIN_H_没有定义(我们根据#include 文件名来选择名称,并加上一些下划线,以创建一个在其他地方不太可能被定义的名称)。在这种情况下,编译器将查看#ifndef 和 #endif 之间的内容,并读取定义COORDIN_H_ 的一行。如果在同一个文件中遇到其他包含coordin.h 的代码,编译器将知道COORDIN_H_已经被定义了,从而跳到#endif 后面一行上。注意:这种方法并不能防止编译器将文件包含两次,而只是让它忽略除了第一次包含之外的所有内容。大多数标准C和C++头文件都能使用这种防护。否则,可能在一个文件中定义同一个结构两次,这将导致编译错误。

程序清单:file1.cpp

// file1.cpp 
#include <iostream>
#include "coordin.h"		// structure templates, function prototypes
using namespace std;
int main()
{
    rect rplace;
    polar pplace;
    
    cout << "Enter the x and y values: ";
    while(cin >> rplace.x >> rplace.y)
    {
        pplace = rect_to_polar(rplace);
        show_polar(pplace);
        cout << "Next two numbers (q to quit): ";
    }
    cout << "Byte!\n";
    return 0;
}

file2.cpp

#include <iostream>
#include <cmath>
#include "coordin.h"

polar rect_to_polar(rect xypos)
{
    using namespace std;
    polar answer;
    
    answer.distance = sqrt(xypos.x * xypos.x + sypos.y * xypos.y);
    answer.angle = atan2(xypos.y,xypos.x);
    return answer;
}

// show polar coordinates,converting angle to degrees
void show_polar (polar dapos)
{
    using namespace std;
    const double Rad_to_deg = 57.29577951;
    
    cout << "distance = " << dapos.distance;
    cout << ",angle = " << dapos.angle * Rad_to_deg;
    cout << " degrees\n";
}

多个库的链接:

C++标准允许每个编译器设计人员以他认为合适的方式实现名称修饰,因此由不同编译器创建的二进制模块(对象代码文件)很可能无法正确地链接。也就是说,两个编译器将为同一个函数生成不同的修饰名称。名称不同将使链接器无法将一个编译器生成的函数调用与另一个编译器生成的函数定义匹配。在链接编译模块时,请确保所有对象文件或库都是由同一个编译器生成的。如果有源代码,通常可以用自己的编译器重新编译源代码来消除错误。

9.2 存储持续性、作用域和链接性

内存存储:C++使用三种(在C++11中是四种)不同的方案来存储数据,这些方案的区别就在于数据保留在内存中的时间。

  • 自动存储持续性:在函数定义中声明的变量(包括函数参数)的存储持续性为自动的。它们在程序开始执行其所属的函数或代码块时被创建,在执行完函数或代码块时,它们使用的内存被释放。C++有两种存储持续性为自动的变量。
  • 静态存储持续性:在函数定义外定义的变量和使用关键字static定义的变量的存储持续性都为静态,它们在程序整个运行过程中都存在。C++有3种存储持续性为静态的变量。
  • 线程存储持续性:当前,多核处理器常见,这些CPU可同时处理多个执行任务。这让程序能够将计算机放在可并行处理的不同线程中。如果变量是使用关键字thread_local声明的,则其生命周期与所属的线程一样长。
  • 动态存储持续性:用new运算符分配的内存将一直存在,直到使用delete运算符将其释放或程序结束为止。这种内存的存储持续性为动态,有时被称为自由存储(free store)或 堆(heap)。

变量只能有一次定义。为满足这种需求,C++提供了两种变量声明。一种是定义(definition):它给变量分配存储空间另一种是声明(declaration):它不给变量分配存储空间,只是引用已有的变量。

引用声明(简称为声明)使用关键字extern,且不进行初始化;否则,声明为定义,导致分配存储空间。

double up;						// definition,up is 0
extern int blem;				// blem definied elsewhere
extern char gr = 'Z';			// definition because initialized

程序清单:

演示两个文件中使用同一个外部变量,以及自动变量将隐藏同名的全局变量。还演示如何使用关键字extern来重新声明以前定义过的外部变量,以及如何使用C++的作用域解析运算符来访问被隐藏的外部变量。

external.cpp

// external.cpp
#include <iostream>
using namespace std;
double warming = 0.3;	// external variable

// function prototype
void update(double dt);
void local();

int main()			// uses global variable
{
    cout << "Global warming is " << warming << " degrees.\n";
    update(0.1);	// call function to change warming
    cout << "Global warming is " << warming << " degrees.\n";
    local();	// call function with local warming
    cout << "Global warming is " << warming << " degrees.\n";
    return 0;
    
}

support.cpp

#include <iostream>
extern double warming;		// use warming from another file

// function prototype
void update(double dt);
void local();

using std::cout;
void update(double dt)			// modifies global variable
{
    extern double warming;		// optional redeclaration
    warming += dt;				// uses global warming
    cout << "Updating global warming to " << warming ;
    cout << " degree.\n";
}
void local()					// uses local variable
{
    double warming = 0.8;		// new variable hides external one
    cout << "Local warming = " << warming << " degree.\n";
    cout << "But global warming = " << ::warming;
    cout << " degree.\n";
}

运行结果:

Global warming is 0.3 degrees.
Updating global warming to 0.4 degree.
Global warming is 0.4 degrees.
Local warming = 0.8 degree.
But global warming = 0.4 degree.
Global warming is 0.4 degrees.

程序说明:
使用关键字extern声明变量warming,让该文件中的函数能够使用它:

extern double warming;

该声明的意思是,使用外部定义的变量warming。

C++提供了作用域解析运算符(::)。放在变量名前面时,该运算符表示使用变量的全局版本。

🚀C++在哪里查找函数

假设在程序的某个文件中调用一个函数,C++将到哪里去寻找该函数的定义呢?如果该文件的函数原型指出函数是静态的,则编译器将只在该文件中查找函数定义;否则,编译器(包括链接程序)将在所有的程序文件中查找。如果找到两个定义,编译器将发出错误信息,因为每个外部函数只能有一个定义。如果在程序文件中没有找到,编译器将在库中搜素。这意味着如果定义了一个与库函数同名的函数,编译器将使用程序员定义的版本,而不是使用库函数。有些编译器-链接程序要求显式地指出要搜素那些库。

9.3 名称空间

在C++中,名称可以是变量、函数、结构、枚举、类以及和结构的成员。当随着项目的增大,名称相互冲突的可能性也将增加。使用多个厂商的类库时,可能导致名称冲突。例如:两个库可能都定义了名为List、Tree和Node的类,但定义的方式不兼容。用户可能希望使用一个库的List类,而使用另一个库的Tree类。这种冲突被称为名称空间问题。

C++标准提供了名称空间工具,以便更好地控制名称的作用域。

C++关于全局变量和局部变量的规则定义了一种名称空间层次。每个声明区域都可以声明名称,这些名称独立于在其他声明区域中声明的名称。在一个函数中声明的局部变量不会与在另一个函数中声明的局部变量发生冲突。

1、新的名称空间特性

C++通过定义一种新的声明区域来创建命名的名称空间,这样做的目的之一是提供一个声明名称的区域,一个名称空间中的名称不会与另外一个名称空间的相同名称发生冲突,同时允许程序的其他部分使用该名称空间中声明的东西。

例如:下面的代码使用新的关键字namespace创建了两个名称空间:Jack和Jill。

namespace Jack{
    double pail;				// variable declaration
    void fetch();				// function prototype 
    int pal;					// variable declaration
    struct Well{ ... };			// structure declaration
}
namespace Jill{
    double bucket(double n){ ... }	// function definition 
    double fetch;					// variable declaration
    int pal;						// variable declaration
    struct Hill{ ... };				// structure declaration
}

名称空间可以是全局的,也可以位于另一个名称空间中,但不能位于代码块中。因此因此,在默认情况下,在名称空间中声明的名称的链接性为外部的(除非它引用了常量)。

除了用户定义的名称空间外,还存在另一个名称空间:全局名称空间(global namespace)。它对应于文件级声明区域,因此前面所说的全局变量现在被描述为位于全局名称空间中。

**任何名称空间中的名称都不会与其他名称空间中的名称发生冲突。**因此,Jack中的fetch可以与Jill中的fetch共存,Jill中的Hill可以与外部Hill共存。名称空间中的声明和定义规则同全局声明和定义规则相同。

名称空间是开放的,即可以把名称空间加入到已有的名称空间中。

例如:将名称goose添加到Jill中已有的名称列表中:

namespace Jill{
    char * goose(const char *);
}

同样,原来的Jack名称空间为fetch()函数提供了原型,可以在该文件后面(或者另外一个文件中)再次使用Jack名称空间来提供该函数的代码:

namespace Jack{
    void fetch()
    {
        ...
    }
}

当然,需要有一种方法来访问给定名称空间中的名称。最简单的方法是,通过作用域解析运算符::,使用名称空间来限定该名称

Jack::pail = 12.34;		// use  a variable
Jill::Hill mole;		// create a type Hill structure
Jack::fetch();			// use a function

未被装饰的名称(如pal)称为未限定的名称(unqualified name);包含名称空间的名称(如Jack::pail)称为限定的名称(qualified name)。

1) using 声明和 using 编译指令

我们并不希望每次使用名称时都对它进行限定,因此C++提供了两种机制(using 声明和 using编译指令)来简化对名称空间的名称的使用。using声明使特定的标识符可用,using编译指令使整个名称空间可用

using声明由被限定的名称和它前面的关键字using组成:

using Jill::fetch;		// a using declaration

using 声明将特定的名称添加到它所属的声明区域中。例如main()中的using声明Jill::fetch()将fetch添加到main()定义的声明区域中。完成该声明后,便可以使用名称fetch代替Jill::fetch。

namespace Jill{
    double bucket(double n){ ... }
    double fetch;
    struct Hill { ... };
}
char fetch;
int main()
{
    using Jill::fetch;		// put fetch into local namespace 
    double fetch;			// Error! Already have a local fetch
    cin >> fetch;			// read a value into Jill::fetch
    cin >> ::fetch;			// read a value into global fetch
}

和其他局部变量一样,fetch也将覆盖同名的全局变量。

在函数的外面使用using声明,将把名称添加到全局名称空间中。

void char();
namespace Jill{
    double bucket(double n){ ... }
    double fetch;
    struct Hill { ... };
}
using Jill::fetch;			// put fetch into global namespace
int main()	
{
    cin >> fetch;			// read a value into Jill::fetch
    other();
}
void other()
{
    cout << fetch;			// display Jill::fetch
}

using 声明使一个名称可用,而using编译指令使所有的名称可用。using编译指令由名称空间名和它前面的关键字using namespace组成的。它使名称空间中的所有名称都可用,而不需要使用作用域解析运算符:

using namespace Jack;		// make all the names in Jack available

在全局声明区域中使用using编译指令,将使该名称空间的名称全局可用。

#include <iostream>
using namespace std;	

2) using 编译指令和using 声明之比较

使用using编译指令导入一个名称空间中所有的名称与使用多个using声明是不一样的,而更像是大量使用作用域解析运算符。使用using声明时,就好像声明了相应名称一样。如果某个名称已经在函数中声明了,则不能用using声明导入相同的名称。然而,使用using编译指令时,将进行名称解析,就像在包含using声明和名称空间本身的最小声明区域中声明了名称一样。

案例:如下,名称空间是全局的,如果使用using编译指令导入一个已经在函数中声明的名称,则局部名称将隐藏空间名,就像隐藏同名的全局变量一样。

namespace Jill{
    double bucket(double n){ ... }
    double fetch;
    struct Hill { ... };
}
char fetch;				// global namespace
int main()
{
    using namespace Jill;	// import all namespace names
    Hill Thrill;			// create a type Jill::Hill structure
    double water = bucket(2);	// use Jill::bucket()
    double fetch;			// not an error; hides Jill::fetch
    cin >> fetch;			// read a value into the local fetch
    cin >> ::fetch;			// read a value into global fetch
    cin >> Jill::fetch;		// read a value into Jill::fetch
}
int foom()
{
    Hill top;			// error
    Jill::Hill crest;	// valid
}

需要指出的一点,虽然函数中的using编译指令将名称空间的名称视为在函数之外声明的,但它不会使得该文件中的其他函数能够使用这些名称。因此,foom()函数不能使用未限定的标识符Hill。

注意:假设名称空间和声明区域定义了相同的名称。如果试图使用using声明将名称空间的名称导入该声明区域,则这两个名称会发生冲突,从而出错。如果使用using编译指令将该名称空间的名称导入该声明区域,则局部版本将隐藏名称空间版本。

一般来说,使用using声明比使用using编译指令更安全,这是由于它只导入指定的名称。如果该名称与局部名称发生冲突,编译器将发出指示。using编译指令导入所有名称,包括可能并不需要的名称。如果与局部名称发生冲突,则局部名称将覆盖名称版本,而编译器并不会发出警告。另外,名称空间开发性意味着名称空间的名称可能分散在多个地方,这使得难以准确知道添加了哪些名称。

2、名称空间示例

程序清单:

// namesp.h
#include <string>

namespace pers
{
    struct Person
    {
        std::string fname;
        std::string lname;
    };
    void getPerson(Person &);
    void showPerson(const Person &);
}

namespace debts
{
    using namespace pers;
    struct Debt
    {
        Person name;
        double amount;
    };
    void getDebt(Debt &);
    void showDebt(const Debt &);
    double sumDebts(const Debt ar[], int n);
}
// namesp.cpp
#include <iostream>
#include "namesp.h"

namespace pers
{
	using std::cout;
    using std::cin;
    void getPerson(Person & rp)
    {
        cout << "Enter first name: ";
        cin >> rp.fname;
        cout << "Enter last name: ";
        cin >> rp.lname;
    }
    void showPerson(const Person & rp)
    {
        std::cout << rp.lname << ", " << rp.fname;
    }
}

namespace debts
{
    void getDebt(Debt & rd)
    {
        getPerson(rd.name);
        std::cout << "Enter debt: ";
        std::cin >> rd.amount;
    }
    void showDebt(const Debt & rd)
    {
        showPerson(rd.name);
        std::cout << ": $ "<< rd.amount << std::endl;
    }
    double sumDebts (const Debt ar[], int n)
    {
        double total = 0;
        for(int i = 0; i < n ; i++)
            total += ar[i].amount;
        return total;
    }
}
// usenmsp.cpp
#include <iostream>
#include "namesp.h"
void other(void);
void another(void);
int main()
{
    using debts::Debt;
    using debts::showDebt;
    Debt golf = {{"Benny","Goatsniff"},120.0};
    showDebt(golf);
    other();
    another();
    return 0;
}
void other(void)
{
    using std::cout;
    using std::endl;
    using namespace debts;
    Person dg = {"Doodles","Glister"};
    showPerson(dg);
    cout << endl;
    Debt zippy[3];
    int i;
    for(i = 0; i < 3;i++)
        getDebt(zippy[i]);
    
    for(i = 0;i < 3; i++)
        showDebt(zippy[i]);
    cout << "Total debt: $" << sumDebts(zippy, 3) << endl;
    return;
}
void another(void)
{
    using pers::Person;
    Person collector = { "Milo", "Rightshift"};
    pers::showPerson(collector);
    std::cout << std::endl;
}

运行结果:

Goatsniff, Benny: $ 120
Glister, Doodles
Enter first name: Arabella
Enter last name: Binx
Enter debt: 100
Enter first name: Cleve
Enter last name: Delaprour
Enter debt: 120
Enter first name: Eddie
Enter last name: Fiotox
Enter debt: 200
Binx, Arabella: $ 100
Delaprour, Cleve: $ 120
Fiotox, Eddie: $ 200
Total debt: $420
Rightshift, Milo

3、名称空间及其前途

名称空间的指导原则:

  • 使用在已命名的名称空间中声明的变量,而不是使用外部全局变量。
  • 使用在已命名的名称空间中声明的变量,而不是使用静态全局变量。
  • 如果开发了一个函数库或类库,将其放在一个名称空间中。事实上,C++当前提倡将标准函数库放在名称空间std中,这种做法扩展到了来自C语言中的函数。例如:头文件math.h是与C语言兼容的,没有使用名称空间,但C++头文件cmath应将各种数学库函数放在名称空间std中。实际上,并非所有的编译器都完成了这种过渡。
  • 仅将编译指令using作为一种将旧代码转换为使用名称空间的权益之计。
  • 不要在头文件中使用using编译指令。
  • 导入名称时,首选使用作用域解析运算符或using声明的方法。
  • 对于using声明,首选将其作用域设置为局部而不是全局。、

9.4 总结

C++鼓励程序员在开发程序时使用多个文件,一种有效的组织策略是,使用头文件来定义用户类型,为操纵用户类型的函数提供函数原型;并将函数定义放在一个独立的源代码文件中。头文件和源代码文件一起定义和实现了用户定义的类型及其使用方式。最后,将main()和其他使用这些函数的函数放在第三个文件中。

C++的存储方案决定了变量保留在内存中的时间(存储连续性)以及程序的哪一部分可用访问它(作用域和链接性)。自动变量是在代码块(如函数体或函数体中的代码块)中定义的变量,仅当程序执行到包含定义的代码块时,它们才存在,并且可见。自动变量可以通过使用存储类型说明符register或根本不适应说明符来声明,没有使用说明符时,变量将默认为自动的。register说明符提示编译器,该变量的使用频率很高,但C++11掘弃了这种用法。

静态变量在整个程序执行期间都存在。对于在函数外面定义的变量,其所属文件中位于该变量的定义后面的所有函数都可用使用它(文件作用域),并可在程序的其他文件中使用(外部链接性)。另一个文件要使用这种变量,必须使用extern关键字来声明它。对于文件共享的变量,应在一个文件中包含其定义声明,并在其他文件中包含引用声明(使用extern且不初始化)。在函数的外面使用关键字static定义的变量的作用域为整个文件,但是不能用于其他文件。在 代码块中使用关键字static定义的变量被限制在该代码块内,但在整个程序执行期间,它都一直存在并且保持原值。

在默认情况下,C++函数的连接性为外部,因此可在文件间共享;但关键字static限定的函数的连接性为内部,被限制在定义它的文件中。

动态内存分配和释放是使用new和delete进行的,它使用自由存储区或堆来存储数据。调用new占用内存,而调用delete释放内存。程序使用指针来跟踪这些内存单元。

名称空间允许定义一个可在其中声明标识符的命名区域,这样做的目的是减少名称冲突,尤其当程序非常大,并使用多个厂商的代码时。可用通过使用作用域解析运算符、using声明或using编译指令,来使名称空间中的标识符可用。

第十章 对象和类

面向对象编程(OOP)是一种特殊的、设计程序的概念性方法,C++通过一些特性改进了C语言,使得应用这种方法更容易。OOP的特性:

  • 抽象
  • 封装和数据隐藏
  • 多态
  • 继承
  • 代码的可重用性

为了实现这些特性并将它们组合在一起,C++所做的最重要的改进是提供了子类。

10.1 过程性编程和面向对象编程

采用OOP方法时,首先从用户的角度考虑对象:描述对象所需的数据以及描述用户与数据交互所需要的操作。完成对接口的描述后,需要确定如何实现接口和数据存储。最后,使用新的设计方案创建出程序。

10.2 抽象和类

在计算中,为了根据信息与用户之间的接口来表示它,抽象是至关重要的。也就是说,将问题的本质特征抽象出来,并根据特征来描述解决方案。在C++中,用户定义类型指的是实现抽象接口的类设计。

基本类型完成了三项工作:

  • 决定数据对象需要的内存数量
  • 决定如何解释内存中的位(long和float在内存中占用的位数相同,但将它们转换为数值的方法不同)
  • 决定可使用数据对象执行的操纵或方法。

对于内置类型来说,有关操作的信息被内置到编译器中。但在C++中定义用户自定义的类型时,必须自己提供这些信息。付出这些劳动换来了根据实际需要定制新数据类型的强大功能和灵活性。

1、C++中的类

类是一种将抽象转换为用户定义类型的C++工具,它将数据表示和操纵数据的方法组合成一个整洁的包。

类的定义:一般来说,由两个部分组成。

  • 类声明:以数据成员的方式描述数据部分,以成员函数(被称为方法)的方式描述公有接口。
  • 类方法定义:描述如何实现类成员函数。

简单地说,类声明提供了类的蓝图,而方法定义则提供了细节

🚀什么是接口

接口是一个共享框架,供两个系统(如在计算机和打印机之间或者用户或计算机程序之间)交互时使用;例如,用户可能是您,而程序可能是字处理器。使用字处理器时,是不能直接将脑子中想到的词传输到计算机内存中,而必须同程序提供的接口交互。当敲打键盘时,计算机将字符显示到屏幕上。程序接口将我们的意图转换为存储在计算机中的具体信息。

对于类,我们说公共接口。在这里,公众是使用类的程序,交互系统由类对象组成,而接口由编写类的人提供方法组成。接口让程序员能够编写与类对象交互的代码,从而让程序能够使用类对象。例如:要计算string对象中包含了多少个字符,我们无需打开对象,而只需使用string类提供的size()方法。类设计禁止公共用户直接访问类,但公众可以使用方法size()。方法size()是用户和string类对象之间的公共接口的组成部分。通常,方法getline()是istream类的公共接口的组成部分,使用cin的程序不是直接与cin对象内部交互来读取一行输入,而是使用getline()。

要使用某个类,必须了解其公共接口;要编写类,必须创建其公共接口。

为开发一个类并编写一个使用它的程序,需要完成多个步骤。这里将开发过程分成多个阶段,而不是一次性完成的。通常,C++程序员将接口(类定义)放在头文件中,并将实现(类方法的代码)放在源代码文件中。

程序清单:

// stock00.h
#ifndef STOCK00_H_
#define STOCK00_H_

#include <string>

class Stock				// class declaration
{
private:
    std::string company;
    long shares;
    double share_val;
    double total_val;
    void set_tot() { total_val = shares * share_val; }
public:
    void acquire(const std::string & co, long n, double pr);
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};					// note semicolon at the end

#endif

首先,C++关键字class指出这些代码定义了一个类设计(不同于在模板参数中,在这里,关键字class和typename不是同义词,不能使用typename代替class)。这种语法指出,Stock是这个类新类的类型名。该声明让我们能够声明Stock类型的变量:称为对象或实例。

例如:创建两个Stock对象,它们分别名为sally和solly:

Stock sally;
Stock solly;

1) 访问控制

关键字private 和 public描述了对类成员的访问控制。使用类对象的程序都可以直接访问公有部分,但只有通过公有成员函数来访问对象的私有成员。例如:要修改Stock类的shares成员,只能通过Stock的成员函数。因此,公有成员函数是程序和对象的私有成员之间的桥梁,提供了对象和程序之间的接口。防止程序直接访问数据被称为数据隐藏。C++还提供了第三个访问控制关键字protected。

//关键字class标识类定义      Stock:类名称成为这个用户定义的类型的名称
class Stock
{
private:				// 关键字private标识:只能通过公共成员访问的类成员(数据隐藏)
    int shares;
    
public:					// 关键字public标识组成类的公共接口的类成员(抽象)
    void sell(long num, double price);
    
}
// 类成员可以是数据类型,也可以是函数

类设计尽可能将公有接口与实现细节分开。公有接口表示设计的抽象组件。将实现细节放在一起并将它们与抽象分开被称为封装。数据隐藏(将数据放在类的私有部分中)是一种封装,将实现的细节隐藏在私有部分中,就像Stock类对set_tot()所做的那样,也是一种封装。封装的另一个例子是,将类函数定义和类声明放在不同的文件中。

🚣OOP 和 C++

OOP是一种编程风格,从某种程度上来说,它用于任何一种语言中。当然,可以将OOP思想融合到常规的C语言程序中。C++中包括了许多专门用来实现OOP方法的特性,因此它使程序员更进一步。首先,将数据表示和函数原型放在一个类声明中(而不是一个文件中),通过将所有的内容放在一个类声明中,来使描述成为一个整体。其次,让数据表示成为私有,使得数据只能被授权的函数访问。

**数据隐藏不仅可以防止直接访问数据,还让开发者(类的用户)无需了解数据是如何表示的。**例如:show()成员将显示某支股票的总价格(还有其他内容),这个值可以存储在对象中,也可以在需要时通过计算得到。从使用类的角度看,使用哪种方法没有什么区别。所需要知道的只是各种成员函数的功能:也就是说,**需要知道成员函数接受什么样的参数以及返回什么样的类型的值。**原则是将实现细节从接口设计中分离出来。如果以后找到了更好的、实现数据表示或成员函数细节的方法,可以对这些细节进行修改,而无需修改程序接口,这使程序维护起来更容易。

2)控制对成员的访问:公有还是私有

无论类成员是数据成员还是成员函数,都可以在类的公有部分或私有部分中声明它。但由于隐藏数据是OOP主要目标之一,因此数据项通常放在私有部分,组成类接口的成员函数放在公有部分;否则,就无法从程序中调用这些函数。正如Stock声明所表明的,也可以把成员函数放在私有部分中。不能直接从程序中调用这种函数,但公有方法却可以使用它们。通常,程序员使用私有成员函数来处理不属于公有接口的实现细节。

不必在类声明中使用关键字private,因为这是类对象的默认访问控制:

class World
{
    float mass;					// private by default
    char name[20];				// private by default
public:
    void tellall(void);
}

🛳类和结构

类描述看上去很像是包含成员函数以及public和private可见性标签的结构声明。实际上,C++对结构进行了扩展,使之具有与类相同的特性。它们之间唯一的区别是,结构的默认访问类型是public,而类为private。C++程序员通常使用类来实现类描述,而把结构限制为只表示纯粹的数据对象。

2、实现类成员函数

还需要创建类描述的第二部分:为那些由类声明中的原型表示的成员函数提供代码。成员函数定义与常规函数定义非常相似,它们有函数头和函数体,也可以有返回类型和参数。但是它们还有两个特殊的特征:

  • 定义成员函数时,使用**作用域解析运算符(::)**来标识函数所属的类;
  • 类方法可以访问类的private组件。

首先,成员函数的函数头使用作用域运算符解析(::)来指出函数所属的类。

例如:update()成员函数的函数头:

void Stock::update(double price)

这种表示法意味着定义的update()函数是Stock类的成员。这不仅将update()标识为成员函数,还意味着可以将另一个类的成员函数也命名为update()。例如:Buffoon()类的update()函数的函数头如下:

void Buffoon::update()

因此,作用域解析运算符确定了方法定义对应的类的身份。因此,标识符update()具有类作用域。Stock类的其他成员函数不必使用作用域解析运算符,就可以使用update()方法,这是因为它们属于同一个类,因此update()是可见的。然而,在类声明和方法定义之外使用update()时,需要采取特殊的措施。

类方法的完整名称中包括类名。我们说,Stock::update()是函数的限定名(qualified name);而简单的update()是全名的缩写(非限定名,unqualified name),它只能在类作用域中使用。

方法的第二个特点是:方法可以访问类的私有成员。

例如:show()方法可以使用这样的代码

std::cout << "Company: " << company 
    	  << " Shares: " << shares << endl
    	  << " Share Price: $" << share_val
    	  << " Total Worth: $" << total_val << endl;

其中,company、shares等都是Stock类的私有数据成员。如果试图使用非成员函数访问这些数据成,编译器禁止这样做。

程序清单:

#include <iostream>
#include "stock00.h"

void Stock::acquire(const std::string & co, long n, double pr)
{
    company = co;
    if(n < 0)
    {
        std::cout << "Number of shares can't be negative; "
            	  << company << " shares set to 0.\n";
        shares = 0;
    }
    else 
        shares = n;
    share_val = pr;
    set_tot();
}

void Stock::buy(long num, double price)
{
    if(num < 0)
    {
        std::cout << "Number of shares purchased can't be negative; "
            	  << "Transaction is aborted.\n";
    }
    else
    {
        shares += num;
        share_val = price;
        set_tot();
    }
}

void Stock::sell(long num, double price)
{
    using std::cout;
    if(num < 0)
    {
        cout << "Number of shares sold can't be negative; "
             << "Transaction is aborted.\n";
    }
    else if(num > shares)
    {
        cout << "You can't sell more than you have!"
             << "Transaction is aborted.\n";
    }
    else
    {
        shares += num;
        share_val = price;
        set_tot();        
    }
}

void Stock::update(double price)
{
    share_val = price;
    set_tot();
}

void Stock::show()
{
    std::cout << "Company: " << company
        	  << " Shares: " << shares << '\n'
        	  << " Share Price: $" << share_val
        	  << " Total Worth: $" << total_val << '\n';
}

1)内联方法:
其定义位于类声明中的函数都将自动成为内联函数,因此Stock::set_tot()是一个内联函数。类声明常将短小的成员函数作为内联函数,set_tot()符合这样的要求。

如果愿意,也可以在类声明之外定义成员函数,并使其成为内联函数。为此,只需在类实现部分中定义函数时使用inline限定符即可:

class Stock
{
private:
	...
    void set_tot();			// definition kept separate
public:
    ...
};

inline void Stock::set_tot()		// use inline in definition
{
    total_val = shares * share_val;
}

内联函数的特殊规则要求在每个使用它们的文件中都对其进行定义。确保内联定义对多文件程序中的所有文件都可用、最简便的方法是:将内联定义放在定义类的头文件中。(有的允许将内联定义放在一个独立的实现文件)

根据改写规则(rewrite rule),在类声明中定义方法 等同于用原型替换方法定义,然后在类声明的后面将定义改写为内联函数。

2)方法使用哪个对象

使用对象时最重要的一个方面:如何将类方法应用于对象。

例如:使用了一个对象的shares成员:

shares += num;

要知道这个是哪个对象,首先要看看如何创建对象。最简单的方式是声明类变量:

Stock kate, joe;

这将创建两个Stock类对象,一个为kate, 另一个为joe。

接下来,看看如何使用对象的成员函数。和使用结构成员一样,通过成员运算符:

kate.show();		// the kate object calls member function
joe.show();			// the joe object calls member function

第一条语句调用kate对象的show()成员,这意味着show()方法将把shares解释为kate.shares,将share_vla解释为kate.share_val。同样,函数调用joe.show()使show()方法将shares和share_val分别解释为joe.shares和joe.share_val。

注意:调用成员函数时,它将使用被用来调用它的对象的数据成员。

同样,函数调用kate.sell()在调用set_tot()函数时,相当于调用kate.set_tot(),这样该函数使用kate对象的数据。

所创建的每个新对象都有自己的存储空间,用于存储其内部变量和类成员但同一个类的所有对象共享同一组类方法,即每种方法只有一个副本。

例如:假设kate和joe都是Stock对象,则kate.shares将占据一个内存块,而joe.shares占用另一个内存块,但kate.show()和joe.show()都调用同一个方法,也就是说,它们将执行同一个代码块,只是将这些代码用于不同的数据。

在OOP中,调用成员函数被称为发送消息,因此将同样的消息发送给两个不同的对象将调用同一个方法,但该方法被用于两个不同的对象。

3、 使用类

C++的目标是使得使用类与使用基本的内置类型(如int 和 cahr)尽可能相同。要创建类对象,可以声明类变量,也可以使用new为类对象分配存储空间。可以将对象作为函数的参数和返回值,也可以将一个对象赋给另一个。C++提供了一些工具,可用于初始化对象,让cin和cout识别对象,甚至在相似的类对象之间进行自动类型的转换。

🛡 客户/服务器模型

OOP程序员常依照客户/服务器模型来讨论程序设计。在这个概念中,客户是使用类的程序。类声明(包括类方法)构成了服务器,它是程序可以使用的资源。客户只能通过以公有方式的接口使用服务器,这意味着客户唯一的责任是了解该接口。服务器(服务器设计人员)的责任是确保服务器根据该接口可靠并准确地执行。服务器设计人员只能修改类设计的实现细节,而不能修改接口。这样程序员独立地对客户和服务器进行改进,对服务器的修改不会对客户的行为造成意外的影响。

4、修改实现

之前的程序输出中,遇到一个麻烦的事:数字的格式不一致。现在可以改进实现,但保持接口不变。ostream类包含一些可用于控制格式的成员函数。使用方法setf(),便可以避免科学计数法。

std::cout.setf(std::ios_base::fixed, std::ios_base::floatfield);

这设置了cout对象的一个标记,命令cout使用定点表示法。同样,下面的语句导致cout在使用定点表示法时,显示三位小数:

std::cout.precision(3);

修改方法的实现时,不应影响客户程序的其他部分。上述格式修改将一直有效,直到再次修改,因此它们可能影响客户程序中的后续输出。所以,show()应重置格式信息,使其恢复到自己被调用前的状态。

std::streamsize prec = std::cout.precision(3);	// save preceding value 

std::cout.precision(prec);			// reset to old value

// store original flags
std::ios_base::fmtflags orig = std::cout.setf(std::ios_base::fixed);

// reset to stored values
std::cout.setf(orig, std::ios_base::floatfield);

5、小结

指定类设计的第一步是提供类声明。类声明类似于结构声明,可以包括数据成员和函数成员,声明有私有部分,在其中声明的成员只能通过成员函数进行访问;声明还具有公有部分,在其中声明的成员可被使用类对象的程序直接访问。通常,数据成员被放在私有部分中,成员函数被放在公有部分中,因此典型的类声明的格式如下:

class className
{
private:
    data member declarations
public:
    member function prototypes
}

公有部分的内容构成了设计的抽象部分:公有接口。将数据封装到私有部分中可以保护数据的完整性,这被称为数据隐藏。因此,C++通过类使得实现抽象、数据隐藏和封装等OOP特性很容易。

指定类设计的第二步是实现类成员函数。可以在类声明中提供完整的函数定义,而不是函数原型,但是通常的做法是单独提供函数定义。在这种情况下,需要使用作用域解析运算符来指出成员函数属于哪个类。

例如:假设Bozo有一个名为Retort()的成员函数,该函数返回char指针,则其函数头如下所示:

char * Bozo::Retort()

换句话来说,Retort()不仅是一个char *类型的函数,而且是一个属于Bozo类的char *函数。该函数的全名(或限定名)为Bozo::Retort()。而名称Retort()是限定名的缩写,只能在某些特定的环境中使用,如类方法的代码中。

另一种描述这种情况的方式是,名称Retort的作用域为整个类,因此在类声明和类方法之外使用该名称时,需要使用作用域解析运算符进行限定。

要创建对象(类的实例),只需将类名视为类型名即可:

Bozo bozetta;

这样做是可行的,因为类是用户定义的类型。

类成员函数(方法)可通过类对象来调用。为此,需要使用成员运算符句点:

cout << bozetta.Retort();

这将调用Retort()成员函数,每当其中的代码引用某个数据成员时,该函数都将使用bozetta对象中相应的值。

10.3 类的构造函数和析构函数

C++的目标之一是让使用类对象就像使用标准类型一样。

int year = 2001;
struct thing 
{
    char * pn;
    int m;
};
thing amabob = {"wodget",-23};				// valid initialization	
Stock hot = {"Sukie's Autos,Inc.",200,50.25};	// NO! compile error

不能这样初始化Stock对象:原因是,数据部分的访问状态是私有的,这意味着程序不能直接访问数据成员。程序只能通过成员函数来访问数据成员,因此需要设计合适的成员函数,才能成功地将对象初始化。(如果使数据成员成为公有,而不是私有,就可以按刚才介绍的方法初始化对象,但使数据成为公有的违背了类的一个主要初衷:数据隐藏)

一般来说,最好是在创建对象时对它进行初始化。例如:

Stock gift;
gift.buy(10,24.75);

就像Stock类当前的实现而言,gift对象的company成员是没有值的。

C++提供了一个特殊的成员函数:类构造函数,专门用于构造新对象、将值赋给它们的数据成员。更准确地说,C++为这些成员函数提供了名称和使用语法,而程序员需要提供方法定义。名称和类名相同。

例如:Stock类一个可能的构造函数是名为Stock()的成员函数。构造函数的原型和函数头有一个有趣的特征——虽然没有返回值,但没有被声明为void类型。实际上,构造函数没有声明类型

1、声明和定义构造函数

现在需要创建Stock的构造函数。由于需要为Stock对象提供3个值,因此应为构造函数提供3个参数。程序员可能只想设置company成员,而将其他值设置为0,这可以使用默认参数来完成。因此,原型为:

Stock(const string co, long n = 0, double pr = 0.0);
// constructor prototype with some default arguments

第一个参数是指向字符串的指针,该字符串用于初始化成员company。n和pr参数为shares和share_val成员提供值。注意:没有返回类型。原型位于类声明的公有部分。

// constructor definition
Stock::Stock(const string & co, long n, double pr)
{
    company = co;
    if( n < 0)
    {
        std::cerr << "Number of shares can't be negative; "
            	  << company << " shares set to 0.\n";
        shares = 0;
    }
    else 
        shares = n;
    share_val = pr;
    set_tot();
}

上述代码和前面的函数acquire()相同。区别在于,程序声明对象时,将自动调用构造函数

🛳成员名和参数名

不熟悉构造函数的可能会试图将类成员名称用作构造函数的参数名。

// NO!
Stock::Stock(const string & company, long shares, double share_val)
{
    ...
}

这是错误的。构造函数的参数表示的不是类成员,而是赋给类成员的值。因此,参数名不能与类成员相同,否则最终的代码将是这样的:

shares = shares;

为避免这种混乱,一种常见的做法是在数据成员名中使用m_前缀:

class Stock
{
private:
    string m_company;
    long m_shares;
}

另一种常见的做法是,在成员名中使用后缀_ :

class Stock
{
private:
    string company_;
    long shares_;
}

无论采用哪种做法,都可在公有接口中在参数名中包含company和shares。

2、使用构造函数

C++提供了两种使用构造函数来初始化对象的方式。

第一种方式是显示地调用构造函数:

Stock food = Stock("World Cabbage",250,1.25);

这将food的对象的company成员设置为字符串"World Cabbage",将shares成员设置为250,依次类推。

另一种方式是隐式的调用构造函数:

Stock garment("Furry Mason",50,2.5);

这种格式更紧凑,它与下面的显示调用等价:

Stock garment = Stock("Furry Mason",50,2.5);

每次创建类对象(甚至使用new动态分配内存)时,C++都使用类构造函数。

下面是将构造函数与new一起使用的方法:

Stock *pstock = new Stock("Electroshock Games", 18, 19.0);

这条语句创建一个Stock对象,将其初始化为参数提供的值,并将该对象的地址赋给pstock指针。在这种情况下,对象没有名称,但可以使用该指针来管理该对象。

构造函数的使用方式不同于其他类方法。一般来说,使用对象来调用方法:

stock1.show();		// stock1 object invokes show() method

但无法使用对象来调用构造函数,因为在构造函数构造出对象之前,对象是不存在的。因此构造函数被用来创建对象,而不能通过对象来调用

3、默认构造函数

默认构造函数是在未提供显式初始值时,用来创建对象的构造函数。也就是说,它是用于下面这种声明的构造函数:

Stock fluffy_the_cat;		// uses the default constructor

这条语句管用的原因在于,如果没有提供任何构造函数,则C++将自动提供默认构造函数。它是默认构造函数的隐式版本,不做任何工作。对于Stock类来说,默认构造函数可能如下:

Stock::Stock(){ }

因此将创建fluffy_the_cat对象,但不初始化其成员,这和下面的语句创建x,但没有提供值给它一样:

int x;

默认构造函数没有参数,因为声明中不包含值。

奇怪的是:==当且仅当没有定义任何构造函数时,编译器才会提供默认构造函数。为类定义了构造函数后,程序员就必须为它提供默认构造函数。==如果提供了非默认构造函数,但没有提供默认构造函数,则声明将会出错。

定义默认构造函数的方式有两种。一种是给已有构造函数的所有参数提供默认值:

Stock(const string & co = "Error", int n =0, double pr = 0.0);

另一种方式是通过函数重载来定义另一个构造函数:一个没有参数的构造函数:

Stock();

由于只能有一个默认构造函数,因此不要同时采用这两种方式。实际上,通常应初始化所有的对象,以确保所有成员一开始就有已知的合理值。因此,用户定义的默认构造函数通常给所有成员提供隐式初始值。例如:下面为Stock类定义的一个默认构造函数:

Stock::Stock()				// default constructor
{
    company = "no name";
    shares = 0;
    share_val = 0.0;
    total_val = 0.0;
}

提示:在设计类时,通常应提供对所有类成员做隐式初始化的默认构造函数。

在创建默认构造函数后,便可以声明对象变量,而不对它们进行显示初始化:

Stock first;					// calls default constructor implicitly
Stock first = Stock();			// calls it explicitly
Stock *prelief = new Stock;		// calls it implicitly

然而,不要被非默认构造函数的隐式形式所误导:

Stock first("Concrete Conglomerate");	// calls constructor
Stock second();				// declares a funtion
Stock third;				// calls default constructor

第一个声明调用非默认构造函数,即接受参数的构造函数;第二个声明指出,second()是一个返回Stock对象的函数。隐式地调用默认构造函数时,不要使用圆括号。

4、析构函数

用析构函数创建对象后,程序负责跟踪该对象,直到其过期为止。对象过期时,程序将自动调用一个特殊的成员函数,该函数是:析构函数。析构函数完成清理工作,因此实际上很有用。例如:如果构造函数使用new来分配内存,则析构函数将使用delete来释放这些内存。Stock的构造函数没有使用new,因此析构函数实际上没有需要完成的任务。在这种情况下,只需要让编译器生成一个什么都不要做的隐式析构函数即可。

和构造函数一样,析构函数的名称也很特殊:在类名前加上~。因此,Stock类的析构函数为~Stock()。另外,和构造函数一样,析构函数也可以没有返回值和声明类型。与构造函数不同的是,析构函数没有参数,因此Stock析构函数的原型必须是这样的:

~Stock();

由于Stock的析构函数不承担任何重要的工作,因此可以将它编写为不执行任何操作的函数:

Stock::~Stock()
{   
}

然而,为了方便能看出析构函数何时被调用,可以这样编写:

Stock::~Stock()			// class destructor
{   
    cout << "Bye, " << company << "!\n";
}

什么时候应调用析构函数呢?这由编译器决定的,通常不应在代码中显式地调用析构函数。如果创建的是静态存储类对象,则析构函数将在程序结束时自动被调用。如果创建的是自动存储类对象,则其析构函数将在程序执行完成代码时(该对象是在其中定义的)自动被调用。如果对象是通过new创建的,则它将驻留在栈内存或自由存储区中,当使用delete来释放内存时,其析构函数将自动被调用。

最后,程序可以创建临时对象来完成特定的操作,在这种情况下,程序将在结束对该对象的使用时自动调用其析构函数。

由于在类对象过期时析构函数将自动被调用,因此必须有一个析构函数。如果程序员没有提供析构函数,编译器将隐式地声明一个默认析构函数,并在发现导致对象被删除的代码后,提供默认析构函数的定义。

5、改进Stock类

下面将构造函数和析构函数加入到类和方法的定义中。

1)头文件

将构造函数和析构函数的原型加入到原来的类声明中。因为有构造函数,所以将acquire()函数删除。另外,用#ifndef技术来防止多重包含。

程序清单

// stock10.h
#ifndef STOCK10_H_
#define STOCK10_H_
#include <string>

class Stock
{
private:
    std::string company;
    long shares;
    double share_val;
    double total_val;
    void set_tot() { total_val = shares * share_val; }
public:
    // two constructors
    Stock();		// default constructor
    Stock(const std::string & co, long n = 0, double pr = 0.0);
    ~Stock();		// noisy destructor
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};

#endif
  1. 实现文件
// stock10.cpp
#include <iostream>
#include "stock10.h"

// constructors (verbose versions)
Stock::Stock()			// default constructor
{
    std::cout << "Default constructor called\n";
    company = "no name";
    shares = 0;
    share_val = 0.0;
    total_val = 0.0;
}

Stock::Stock(const std::string & co, long n, double pr)
{
    std::cout << "Constructor using " << co << " called\n";
    company = co;
    
    if(n < 0) 
    {
        std::cout << "Number of shares can't be negative; "
            	  << company << " shares set to 0.\n";
        shares = 0;
    }
    else
        shares = n;
    share_val = pr;
    set_tot();
}

// class destructor
Stock::~Stock()
{
    std::cout << "Bye, " << company << "!\n";
}

// other methods
void Stock::buy(long num, double price)
{
    if(num < 0)
    {
        std::cout << "Number of shares purchased can't be negative. "
            	  << "Transaction is aborted.\n";
    }
    else
    {
        shares += num;
        share_val = price;
        set_tot();
    }
}

void Stock::sell(long num, double price)
{
    using std::cout;
    if(num < 0)
    {
       cout << "Number of shares sold can't be negative. "
            << "Transaction is aborted.\n";
    }
    else if (num > shares)
    {
        cout << "You can't sell more than you have! "
             << "Transaction is aborted.\n";
    }
    else
    {
        shares -= num;
        share_val = price;
        set_tot();
    }
}

void Stock::update(double price)
{
    share_val = price;
    set_tot();
}

void Stock::show()
{
    using std::cout;
    using std::ios_base;
    //set format to #.###
    ios_base::fmtflags orig = 
        cout.setf(ios_base::fixed, ios_base::floatfield);
    std::streamsize prec = cout.precision(3);
    
    cout << "Company: " << company
         << " Shares: " << shares << '\n';
    cout << " Share Price: $" << share_val;
    // set format to #.##
    cout.precision(2);
    cout << " Total Worth: $" << total_val << '\n';
    
    // restore original format
    cout.setf(orig, ios_base::floatfield);
    cout.precision(prec);
}
  1. 客户文件
// usestock.cpp
#include <iostream>
#include "stock10.h"

int main()
{
    {
        using std::cout;
        cout << "Using constructors to create new object\n";
        Stock stock1("NanoSmart", 12, 20.0);			// syntax 1
        stock1.show();
        Stock stock2 = Stock ("Boffo Object", 2, 2.0);	// syntax 2
        stock2.show();

        cout << "Assigning stock1 to stock2:\n";
        stock2 = stock1;
        cout << "Listing stock1 and stock2:\n";
        stock1.show();
        stock2.show();

        cout << "Using a constructor to create an object\n";
        stock1 = Stock("Nifty Foods", 10, 50.0);		// temp object
        cout << "Revised stock1:\n";
        stock1.show();
        cout << "Done\n";

        return 0;
    }

}

运行结果:

Using constructors to create new object
Constructor using NanoSmart called
Company: NanoSmart Shares: 12
 Share Price: $20.000 Total Worth: $240.00
Constructor using Boffo Object called
Company: Boffo Object Shares: 2
 Share Price: $2.000 Total Worth: $4.00
Assigning stock1 to stock2:
Listing stock1 and stock2:
Company: NanoSmart Shares: 12
 Share Price: $20.000 Total Worth: $240.00
Company: NanoSmart Shares: 12
 Share Price: $20.000 Total Worth: $240.00
Using a constructor to create an object
Constructor using Nifty Foods called
Bye, Nifty Foods!
Revised stock1:
Company: Nifty Foods Shares: 10
 Share Price: $50.000 Total Worth: $500.00
Done
Bye, NanoSmart!
Bye, Nifty Foods!

提示:main()的开头和结尾多了一个大括号。诸如stock1和stock2等自动变量将在程序退出其定义所属代码块时消失。如果没有这些大括号,代码块将为整个main(),因此仅当main()执行完毕后,才会调用析构函数。在窗口环境中,这意味着将在两个析构函数调用前关闭,导致无法看到最后两条消息。但添加这些大括号后,最后两个析构函数调用将将在到达返回语句前指向,从而显示相应的消息。

注意:如果既可以通过初始化,也可以通过赋值来设置对象的值,则应采用初始化方式。通常这种方式的效率更高。

4)C++11 列表初始化

在C++11中,可将列表初始化语法用于类,只要提供与某个构造函数的参数列表匹配的内容,并用大括号将它们括起:

Stock hot_tip = {"Derivatives Plus Plus", 100, 45.0};
Stock jock {"Sport Age Storage, Inc"};
Stock temp{};

在前两个声明中,用大括号括起来的列表与下面的构造函数匹配:

Stock::Stock(const std::string & co, long n = 0, double pr = 0.0);

因此,将使用该构造函数来创建这两个对象。创建对象jock时,第二和第三个参数将为默认值0和0.0。第三个声明与默认构造函数匹配,因此将使用该构造函数创建对象temp。

另外,C++11还提供了名为std::initialize_list的类,可将其用作函数参数或方法参数的类型。这个类可表示任意长度的列表,只要所有列表项的类型都相同或可转换为相同的类型。

5)const 成员函数

const Stock land = Stock("Kludgehorn Properties");
land.show();

对于当前的C++来说,编译器将拒绝第二行。因为show()的代码无法确保调用对象不被修改,调用对象和const一样,不应被修改。以前会将函数参数声明为const引用或指向const的指针来解决这种问题。但这里存在语法问题:show()方法没有任何参数。相反,它所使用的对象是由方法调用隐式地提供的。C++的解决方法是将const关键字放在函数的括号后面,也就是说,show()声明应像这样:

void show() const;		// promises not to change invoking object

同样,函数定义的开头应像这样:

void stock::show() const		// promises not to change invoking object

以这种方式声明和定义的类函数被称为const成员函数。就像应尽可能将const引用和指针用作函数形参一样,只要类方法不修改调用对象,就应将其声明为const。

6、 构造函数和析构函数小结

构造函数是一种特殊的类成员函数,在创建类对象时被调用。构造函数的名称和类名相同,但通过函数重载,可以创建多个同名的构造函数,条件是每个函数的特征标(参数列表)都不同。另外,构造函数没有声明类型。通常,构造函数用于初始化类对象的成员,初始化应与构造函数的列表匹配。

例如:假设Bozo类的构造函数的原型如:

Bozo(const char * fname, const char * lname);	// constructor prototype 

则可以使用它来初始化新对象:

Bozo bozetta = bozo("Bozetta","Biggens");		// primary form
Bozo fufu ("Fufu","O'Dweeb");					// short form
Bozo *pc = new Bozo("Popo","Le Peu");			// dynamic object

如果编译器支持C++11,则可使用列表初始化:

Bozo bozetta = bozo("Bozetta","Biggens");		// C++11
Bozo fufu ("Fufu","O'Dweeb");					// C++11
Bozo *pc = new Bozo("Popo","Le Peu");			// C++11

如果构造函数只有一个参数,则将对象初始化为一个与参数的类型相同的值时,该构造函数将被调用。

例如:假设有这样一个构造函数原型:

cout << trip;

则可以使用下面的任何一种形式来初始化对象:

Bozo dribble = bozo(44);	// primary form
Bozo roon(66);				// secondary form
Bozo tubby = 32;			// special form for one-argument constructors

警告:接受一个参数的构造函数允许使用赋值语法将对象初始化为一个值。

Classname object = value;

这种特性可能导致问题。

默认构造函数没有参数,因此如果创建对象时没有进行显示地初始化,则将调用默认的构造函数。如果程序中没有提供任何构造函数,则编译器会为程序定义一个默认构造函数;否则,必须自己提供默认构造函数。默认构造函数可以没有任何参数;如果有,则必须给所有参数都提供默认值:

Bozo();									// default constructor prototype
Bistro (const char * s = "Chez Zero");	// default for Bistro class

对于未初始化的对象,程序将使用默认构造函数来创建:

Bozo bubi;					// use default
Bozo *pb = new Bozo;		// use default

就像对像被创建时程序将调用构造函数一样,当对象被删除时,程序将调用析构函数。每个类都只能有一个析构函数。析构函数没有返回类型(连void都没有),也没有参数,其名称为类名称前加上~

例如:Bozo类的析构函数的原型如:

~Bozo();		// class destructor

如果构造函数使用了new,则必须提供使用delete的析构函数。

10.4 this 指针

对于Stock类,每个类成员函数都只涉及一个对象,即调用它的对象。但有时候方法可能涉及到两个对象,在这种情况下需要使用C++的this指针。

虽然Stock类声明可以显示数据,但它缺乏分析能力。要让程序知道存储的数据,最直接的方式是让方法返回一个值。为此,通常使用内联代码,如下所示:

class Stock
{
private:
    double total_val;
public:
    double total() const { return total_val; }
};

就直接程序访问而言,上述定义实际上是使total_val为只读的。也就是说,可以使用方法total_val()来获得total_val的值,但这个类没有提供专门用于重新设置total_val的值的方法。

C++可以使用被称为this的特殊指针。this指针指向用来调用成员函数的对象(this被作为隐藏参数传递给方法)。例如:函数调用stock1.topval(stock2)将this设置为stock1对象的地址,使得这个指针可用于topval()方法。同样函数调用stock2.topval(stock1)将this设置为stock2对象的地址。一般来说,所有的类方法都将this指针设置为调用它的对象的地址。(使用->运算符,通过指针来访问结构成员同样也适用于类成员)

注意:每个成员函数(包括构造函数和析构函数)都有一个this指针。this指针指向调用对象。如果方法需要引用整个调用对象,则可以使用表达式 *this。在函数的括号后面使用const限定符将this限定为const,这样将不能使用this来修改对象的值。

然而,要返回的并不是this,因为this是对象的地址,而是对象本身,即 *this(将解除引用运算符*用于指针,将得到指针指向的值)。现在,可以将 *this作为调用对象的别名来完成前面的方法定义。

const Stock & Stock::topval(const Stock & s) const
{
    if (s.total_val > total_val)
        return s;					// argument object
    else
        return *this;				// invoking object
}

返回类型为引用意味着返回的是调用对象本身,而不是副本。

程序清单:

// stock20.h
#ifndef STOCK20_H_
#define STOCK20_H_
#include <string>

class Stock
{
private:
    std::string company;
    int shares;
    double share_val;
    double total_val;
    void set_tot() { total_val = shares * share_val; }
public:
    Stock();				// default constructor
    Stock(const std::string & co, long n = 0, double pr = 0.0);
    ~Stock();		// do-nothing destructor
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show() const;
    const Stock & topval(const Stock & s) const;
};

#endif
// stock20.cpp
#include <iostream>
#include "stock20.h"

// constructors 
Stock::Stock()			// default constructor
{
    company = "no name";
    shares = 0;
    share_val = 0.0;
    total_val = 0.0;
}

Stock::Stock(const std::string & co, long n, double pr)
{
    company = co;

    if(n < 0)
    {
        std::cout << "Number of shares can't be negative; "
                  << company << " shares set to 0.\n";
        shares = 0;
    }
    else
        shares = n;
    share_val = pr;
    set_tot();
}

// class destructor
Stock::~Stock()				// quiet class destructor
{
}

// other methods
void Stock::buy(long num, double price)
{
    if(num < 0)
    {
        std::cout << "Number of shares purchased can't be negative. "
                  << "Transaction is aborted.\n";
    }
    else
    {
        shares += num;
        share_val = price;
        set_tot();
    }
}

void Stock::sell(long num, double price)
{
    using std::cout;
    if(num < 0)
    {
       cout << "Number of shares sold can't be negative. "
            << "Transaction is aborted.\n";
    }
    else if (num > shares)
    {
        cout << "You can't sell more than you have! "
             << "Transaction is aborted.\n";
    }
    else
    {
        shares -= num;
        share_val = price;
        set_tot();
    }
}

void Stock::update(double price)
{
    share_val = price;
    set_tot();
}

void Stock::show() const
{
    using std::cout;
    using std::ios_base;
    //set format to #.###
    ios_base::fmtflags orig =
        cout.setf(ios_base::fixed, ios_base::floatfield);
    std::streamsize prec = cout.precision(3);

    cout << "Company: " << company
         << " Shares: " << shares << '\n';
    cout << " Share Price: $" << share_val;
    // set format to #.##
    cout.precision(2);
    cout << " Total Worth: $" << total_val << '\n';

    // restore original format
    cout.setf(orig, ios_base::floatfield);
    cout.precision(prec);
}

const Stock & Stock::topval(const Stock & s) const
{
    if (s.total_val > total_val)
        return s;
    else
        return *this;
}

10 .5 对象数组

和Stock一样,用户通常要创建同一个类的多个对象。可以创建独立对象变量,但创建对象数组将更合适。声明对象数组的方法与声明标准类型数组相同。

Stock mystuff[4];		// creates an array of 4 Stock objects

当程序创建未被显式初始化的类对象时,总是调用默认构造函数。上述声明要求,这个类要么没有显式地定义任何构造函数(在这种情况下,将使用不执行任何操作的隐式默认构造函数),要么定义了一个显示默认构造函数(就像这个例子)。每个元素(mystuff[0]、mystuff[1]等)都是Stock对象。

可以使用Stock方法:

mystuff[0].update();  				// apply update() to 1st element
mystuff[3].show();					// apply show() to 4st element
const Stock * tops = mystuff[2].topval(mystuff[1]);
//compare 3rd and 2rd elements and set tops to point at the one with a higher total value

可以用构造函数来初始化数组元素。在这种情况下,必须为每个元素调用构造函数:

const int STKS = 4;
Stock stock[STkS] = {
    Stock("NanoSmart",12.5,20),
    Stock("Boffo Object", 200, 2.0),
    Stock("Monolithic Obelisks", 130, 3.25),
    Stock("Fleep Enetrprises", 60, 6.5)
};

这里的代码使用标准格式对数组进行初始化:用括号括起的、以逗号分隔的值列表。其中,每次构造函数调用表示一个值。如果类包含多个构造函数,则可以对不同的元素使用不同的构造函数:

const int STKS = 10;
Stock stocks[SKTS] = {
    Stock("NanoSmart", 12.5, 20),   
    Stock(),
    Stock("Monolithic Obelisks", 130, 3.25),
};

上述代码使用Stock(const string & co, long n, double pr)初始化stock[0]和stock[2],使用构造函数Stock()初始化stock[1]。由于该声明只初始化了数组的部分元素,因此余下的7个元素将使用默认构造函数进行初始化。

初始化对象数组的方案时,首先使用默认构造函数创建数组元素,然后花括号中的构造函数将创建临时对象,然后将临时对象的内容复制到相应的元素中。因此,要创建类对象数组,则这个类必须有默认构造函数。

程序清单:

// usestock2.cpp
#include <iostream>
#include "stock20.h"

const int STKS = 4;
int main()
{
    // create an array of initialized objects
    Stock stocks[STKS] = {
    Stock("NanoSmart", 12, 20),
    Stock("Boffo Object", 200, 2.0),
    Stock("Monolithic Obelisks", 130, 3.25),
    Stock("Fleep Enetrprises", 60, 6.5)
	};
    
    std::cout << "Stock holdings:\n";
    int st;
    for(st = 0; st < STKS; st++)
        stocks[st].show();
    const Stock * top = &stocks[0];
    for(st = 1; st < STKS; st++)
        top = &top->topval(stocks[st]);
    std::cout << "\nMost valuable holding:\n";
    top->show();
    return 0;
}

运行输出:

Stock holdings:
Company: NanoSmart Shares: 12
 Share Price: $20.000 Total Worth: $240.00
Company: Boffo Object Shares: 200
 Share Price: $2.000 Total Worth: $400.00
Company: Monolithic Obelisks Shares: 130
 Share Price: $3.250 Total Worth: $422.50
Company: Fleep Enetrprises Shares: 60
 Share Price: $6.500 Total Worth: $390.00

Most valuable holding:
Company: Monolithic Obelisks Shares: 130
 Share Price: $3.250 Total Worth: $422.50

需要注意一点的是,大部分工作是在类设计中完成的,完成类设计后,编写程序工作本身便相当的简单。

顺便说一句,知道this指针就可以更深入了解C++的工作方式。

例如:最初的UNIX实现使用C++前端cfront将C++程序转换为C程序。处理方法的定义时,只需将下面这个的C++方法定义:

void Stock::show() const
{ 
    cout << "Company: " << company
         << " Shares: " << shares << '\n';
		 << " Share Price: $" << share_val
         << " Total Worth: $" << total_val << '\n';
}

转换为下面的C-风格定义:

void show(const Stock * this) 
{ 
    cout << "Company: " << this->company
         << " Shares: " << this->shares << '\n';
		 << " Share Price: $" << this->share_val
         << " Total Worth: $" << this->total_val << '\n';
}

即将Stock::限定符转换为函数参数(指向Stock的指针),然后用这个指针来访问类成员。

同样,该前端将下面的函数调用:

top.show();

转换为:

show(&top);

这样,将调用对象的地址赋给了this指针。

10.6 类作用域

在类中定义的名称(如类数据成员名和类成员函数名)的作用域都为整个类,作用域为整个类的名称只在该类中是已知的,在类外是不可知的。因此,可以在不同类中使用相同的类成员名而不会引起冲突。例如:Stock类的shares成员 不同于JobRide类的shares成员。另外,类作用域意味着不能从外部直接访问类的成员,公有成员函数也是如此。也就是说,要调用公有成员函数,必须通过对象。

Stock sleeper("Exclusive Ore", 100, 0.25);	//create object
sleeper.show();				// use object to invoke a member function
show();						// invalid -- can't call method directly

同样,在定义成员函数时,必须使用作用域解析运算符:

void Stock::update(double price)
{
    ...
}

总之,在类声明或成员函数定义中,可以使用未修饰的成员名称(未限定的名称),就像sell()调用set_tot()成员函数时那样。构造函数名称在被调用时,才能被识别,因为它的名称与类名相同。在其他情况下,使用类成员名时,必须根据上下文使用直接成员运算符(.)、间接成员运算符(->)或作用域解析运算符(::)。

演示:如何访问具有类作用域的标识符。

class IK
{
private:
    int fuss;		// fuss has class scope
public:
    IK(int f = 9){fuss = f;}		// fuss is in scope
    void ViewIk() const;			// ViewIk has class scope
};
void IK::VIew() const			// IK:: places ViewIK into IK scope
{
    cout << fuss  << endl;		// fuss in scope within class methods
}
...
int main()
{
    IK * pik = new IK;
    IK ee = IK(8);		// constructor in scope because has class name
    ee.ViewIK();		// class object brings ViewIk into scope
    pik->ViewIk();		// pointer-to-IK brings ViewIk into scope
}

10.7 抽象数据类型

Stock类非常具体,然而,程序员常常通过定义类来表示更通用的概念。例如:就实现计算机专家们所说的抽象数据类型(abstract data type, ADT)而言,使用类是一种非常好的方式。顾名思义,ADT以通用的方式描述数据类型,而没有引入语言或实现细节。例如:通过使用栈,可以以这样的方式存储数据,即总是从堆顶添加或删除数据。例如:C++程序员使用栈类管理自动变量。当新的自动变量被生成后,它们被添加到堆顶;消亡时,从栈中删除它们。

栈的特征,首先,栈存储了多个数据项;其次,栈由可对它执行的操作来描述。

  • 可创建空栈
  • 可将数据项添加到堆顶(压入)
  • 可从栈顶删除数据项(弹出)
  • 可查看栈是否填满
  • 可查看栈是否为空

可以将上述描述转换为一个类声明,其中公有成员函数提供了表示栈操作的接口,而私有数据成员负责存储栈数据。类概念非常适合于ADT方法。

私有部分必须表明数据存储的方式。例如:可以使用常规数组、动态分配数组或更高级的数据结构(如链表)。然而,公有接口应隐藏数据表示,而通用的术语来表达,如创建栈、压入等。

程序清单:

// stack.h
#ifndef STACK_H_
#define STACK_H_

typedef unsigned long Item;

class Stack
{
private:
    enum {MAX = 10};	// constant specific to class
    Item items[MAX];	// holds stack items
    int top;			// index for top stack item
public:
    Stack();
    bool isempty() const;
    bool isfull() const;
    // push() returns false if stack already is full,true otherwise
    bool push(const Item & item);		// add item to stack
    // pop() returns false if stack already is empty,true oherwise
    bool pop(Item & item);              // pop top into item
};

#endif

私有部分表明,栈是使用数组实现的;而公有部分隐藏了这一点。因此,可以使用动态数组来代替数组,而不会改变类的接口。这意味着修改栈的实现后,不需要重新编写使用栈的程序,而只需要重新编译栈代码,并将其与已有的程序代码链接起来即可。

接口是冗余的,因为pop()和push()返回有关栈状态的信息(满或空),而不是void类型。

接下来需要实现类方法:

// stack.cpp
#include "stack.h"
Stack::Stack()			// create an empty stack
{
    top = 0;
}
bool Stack::isempty() const
{
    return top == 0;
}
bool Stack::isfull() const
{
    return top == MAX;
}
bool Stack::push(const Item & item)
{
    if (top < MAX)
    {
        items[top++] = item;
        return true;
    }
    else
        return false;
}
bool Stack::pop(Item & item)
{
    if(top > 0)
    {
        item = items[--top];
        return true;
    }
    else
        return false;
}

默认构造函数确保所有栈被创建时都为空。pop()和push()的代码确保栈顶被正确地处理。

假设要创建一个独立数组来表示栈,创建一个独立变量来表示栈顶索引。则每次创建新栈时,都必须确保代码是正确的。没有私有数据提供的保护,则很可能由于无意修改了数据而导致程序出现非常严重的故障。

// stacker.cpp
#include <iostream>
#include <cctype>			// or ctype.h
#include "stack.h"
int main()
{
    using namespace std;
    Stack st;				// create an empty stack
    char ch;
    unsigned long po;
    cout << "Please enter A to add a purchase order,\n"
         << "P to process a PO, or Q to quit.\n";
    while (cin >> ch && toupper(ch) != 'Q')
    {
        while(cin.get() != '\n')
            continue;
        if(!isalpha(ch))
        {
            cout << '\a';
            continue;
        }
        switch(ch)
        {
            case 'A':
            case 'a': cout << "Enter a PO number to add: ";
                cin >> po;
                if(st.isfull())
                    cout << "stack already full\n";
                else
                    st.push(po);
                break;
            case 'P':
            case 'p': if(st.isempty())
                         cout << "stack a;ready empty\n";
                      else{
                          st.pop(po);
                          cout << "PO #" << po << " popped\n";
                      }
                      break;
        }
        cout << "Please enter A to add a purchase prder,\n"
             << "P to process a PO, or Q to quit.\n";
    }
    cout << "Bye\n";
    return 0;   
}

运行结果:

Please enter A to add a purchase order,
P to process a PO, or Q to quit.
A
Enter a PO number to add: 17885
Please enter A to add a purchase prder,
P to process a PO, or Q to quit.
P
PO #17885 popped
Please enter A to add a purchase prder,
P to process a PO, or Q to quit.
A
Enter a PO number to add: 17965
Please enter A to add a purchase prder,
P to process a PO, or Q to quit.
A
Enter a PO number to add: 18002
Please enter A to add a purchase prder,
P to process a PO, or Q to quit.
P
PO #18002 popped
Please enter A to add a purchase prder,
P to process a PO, or Q to quit.
P
PO #17965 popped
Please enter A to add a purchase prder,
P to process a PO, or Q to quit.
P
stack a;ready empty
Please enter A to add a purchase prder,
P to process a PO, or Q to quit.
Q
Bye

10.8 总结

面向对象编程强调的是程序如何表示数据。使用OOP方法解决编程问题的第一步是根据它与程序之间的接口来描述数据,从而指定如何使用数据。然后,设计一个类来实现该接口。一般来说,私有数据成员存储信息,公有成员函数(又称为方法)提供访问数据的唯一途径。类将数据和方法组合成一个单元,其私有性实现数据隐藏。

通常,将类声明分成两部分组成,这两部分通常保存在不同的文件中。类声明(包括由函数原型表示的方法)应放到头文件中。定义成员函数的源代码放在方法文件中。这样便将接口描述与实现细节分开了。从理论上说,只需知道公有接口就可以使用类。当然,可以查看实现方法(除非只提供了编译形式),但程序不依赖于其实现细节。只要程序和类只通过定义接口的方法进行通信,程序员就可以随意地对任何部分做独立的改进,而不必担心这样做会导致意外的不良影响。

**类是用户定义的类型,对象是类的实例。**这意味着对象是这种类型的变量,例如:由new按类描述分配的内存。C++试图让用户定义的类型尽可能与标准型类似,因此可以声明对象、指向对象的指针和对象数组。可以按值传递对象、将对象作为函数返回值、将一个对象赋给同类型的另一个对象。如果提供了构造函数,则在创建对象时,可以初始化对象。如果提供了析构函数方法,则在对象消亡后,程序将执行该函数。

**每个对象都存储自己的数据,而共享类方法。**如果mr_object是对象名,try_me()是成员函数,则可以使用成员运算符句点调用成员函数:mr_object.try_me()。在OOP中,这种函数调用被称为将try_me消息发送给mr_object对象。在try_me()方法中引用类型数据成员时,将使用mr_object对象相应的数据成员。同样,函数调用i_object.try_me()将访问i_object对象的数据成员。

如果希望成员函数对多个对象进行操作,可以将额外的对象作为参数传递给它。如果方法需要显示地引用调用它的对象,则可以使用this指针。由于this指针被设置为调用对象的地址,因此*this是该对象的别名。

类很适合用于描述ADT。公有成员函数接口提供了ADT描述的服务,类的私有部分和类方法的代码提供了实现,这些实现对类的客户隐藏。

第十一章 使用类

“轻松地使用这种语言。不要觉得必须使用所有的特性,不要在第一次学习时就试图使用所有的特性。”

11.1 运算符重载

运算符重载是一种形式的C++多态。运算符重载将重载的概念扩展到运算符上,允许赋予C++运算符多种含义。实际上,很多C++运算符已经被重载。例如:将*运算符用于地址,将得到存储在这个地址中的值;但将它用于两个数字时,得到的将是它们的乘积。C++根据操作数的数目和类型来决定采用哪种操作。

C++允许将运算符重载扩展到用户定义的类型,例如:允许使用+将两个对象相加。编译器将根据操作数的数目和类型决定使用哪种加法定义。重载运算符可使代码看起来更自然。

例如:将两个数组相加是一种常见的运算。通常,需要使用下面的for循环来实现:

for(int i = 0; i < 20; i++)
    evening[i] = sam[i] + janet[i];		// add element by element

但在C++中,可以定义一个表示数组的类,并重载+运算符。于是便可以有这样的语句:

evening = sam + janet;			// add two array objects

这种简单的加法表示法隐藏了内部机理,并强调了实质,这是OOP的另一个目标。

要重载运算符,需使用被称为运算符函数的特殊函数形式。运算符函数的格式如下:

operatorop(argument-list)

例如:operator + () 重载 + 运算符, operator *() 重载 *运算符。op必须是有效的C++运算符,不能虚构一个新的符号。例如:不能有operator@()这样的函数,因为C++中没有@运算符。

例如,假设有一个对象Salesperson 类,并为它定义了一个operator+()成员函数,以重载+运算符,以便能够将两个Saleperson对象的销售额相加,则如果district2、sid 和 sara 都是Saleperson类对象,便可以这样编写:

district2 = sid + sara;

编译器发现,操作数是Saleperson类对象,因此使用相应的运算符函数替换上述运算符:

district2 = sid.operator + (sara);

然后该函数将隐式地使用sid(因为它调用了方法),而显式地使用sara对象(因为它被作为参数传递)来计算总和,并返回这个值。当然最重要的是,可以使用简便的+运算符表示法,而不必要使用笨拙的函数表示法。

11.2 计算时间:一个运算符重载示例

程序清单:

// mytime0.h
#ifndef MYTIME0_H_
#define MYTIME0_H_

class Time
{
private:
    int hours;
    int minutes;
public:
    Time();
    Time(int h, int m = 0);
    void AddMin(int m);
    void AddHr(int h);
    void Reset(int h = 0, int m = 0);
    Time Sum(const Time & t) const;
    void Show() const;
};

#endif

Time类提供了用于调整和重新设置时间、显示时间、将两个时间相加的方法。

// mytime0.cpp
#include <iostream>
#include "mytime0.h"

Time::Time()
{
    hours = minutes = 0;
}
Time::Time(int h, int m)
{
    hours = h;
    minutes = m;
}
void Time::AddMin(int m)
{
    minutes += m;
    hours += minutes / 60;
    minutes %= 60;
}

void Time::AddHr(int h)
{
    hours += h;
}
void Time::Reset(int h,int m)
{
    hours = h;
    minutes = m;
}
Time Time::Sum(const Time & t) const
{
    Time sum;
    sum.minutes = minutes + t.minutes;
    sum.hours = hours + t.hours + sum.minutes / 60;
    sum.minutes %= 60;
    return sum;
}
void Time::Show() const
{
    std::cout << hours << " hours, " << minutes << " minutes";
}

看一下Sum()函数的代码。注意参数是引用,但返回类型却不是引用。将参数声明为引用的目的是为了提供效率。如果按值传递Time对象,代码的功能将相同,但传递引用,速度将更快,使用的内存将更少。

然而,返回值不能是引用,因为函数将创建一个新的Time对象(sum),来表示另外两个Time对象的和。返回对象将创建对象的副本,而调用函数可以使用它。然而,如果返回类型为Time &。则引用的将是sum对象。但由于sum对象是局部变量,在函数结束时将被删除,因此引用将指向一个不存在的对象。使用返回类型Time意味着程序将在删除sum之前构造它的拷贝,调用函数将得到该函数的拷贝。

警告:不要返回指向局部变量或临时对象的引用。函数执行完毕后,局部变量和临时对象将消失,引用将指向不存在的数据。

// usetime0.cpp
#include <iostream>
#include "mytime0.h"

int main()
{
    using std::cout;
    using std::endl;
    Time planning;
    Time coding(2,40);
    Time fixing(5,55);
    Time total;
    
    cout << "planning time = ";
    planning.Show();
    cout << endl;
    
    cout << "coding time = ";
    coding.Show();
    cout << endl;
    
    cout << "fixing time = ";
    fixing.Show();
    cout << endl;
    
    total = coding.Sum(fixing);
    cout << "coding.Sum(fixing) = ";
    total.Show();
    cout << endl;
    
    return 0;
}

运行结果:

planning time = 0 hours, 0 minutes
coding time = 2 hours, 40 minutes
fixing time = 5 hours, 55 minutes
coding.Sum(fixing) = 8 hours, 35 minutes

1)添加加法运算符

将Time类转换为重载的加法运算符很容易,只要将Sum()的名称改为operator + ()即可。这样做是对的,只要把运算符放到operator的后面,并将结果用作方法名即可。

程序清单:

// mytime1.h
#ifndef MYTIME1_H_
#define MYTIME1_H_

class Time
{
private:
    int hours;
    int minutes;
public:
    Time();
    Time(int h, int m = 0);
    void AddMin(int m);
    void AddHr(int h);
    void Reset(int h = 0, int m = 0);
    Time operator+(const Time & t) const;
    void Show() const;
};

#endif
// mytime1.cpp
#include <iostream>
#include "mytime1.h"

Time::Time()
{
    hours = minutes = 0;
}
Time::Time(int h, int m)
{
    hours = h;
    minutes = m;
}
void Time::AddMin(int m)
{
    minutes += m;
    hours += minutes / 60;
    minutes %= 60;
}

void Time::AddHr(int h)
{
    hours += h;
}
void Time::Reset(int h,int m)
{
    hours = h;
    minutes = m;
}

Time Time::operator+(const Time & t) const
{
    Time sum;
    sum.minutes = minutes + t.minutes;
    sum.hours = hours + t.hours + sum.minutes / 60;
    sum.minutes %= 60;
    return sum;
}

void Time::Show() const
{
    std::cout << hours << " hours, " << minutes << " minutes";
}

和Sum()一样,operator + ()也是由Time对象调用的,它将第二个Time对象作为参数,并返回一个Time对象,因此,可以像调用Sum()那样来调用operator +()方法:

total = coding.operator + (fixing);		// function notation

但将该方法命令为operator + () 后,也可以使用运算符表示法:

total = coding + fixing;			// operator notation

这两种表示法都将调用operator + ()方法。注意:在运算符表示法中,运算符左侧的对象(为coding)是调用对象,运算符右边的对象(为fixing)是作为参数被传递的对象。

// usetime1.cpp
#include <iostream>
#include "mytime1.h"

int main()
{
    using std::cout;
    using std::endl;
    Time planning;
    Time coding(2,40);
    Time fixing(5,55);
    Time total;
    
    cout << "planning time = ";
    planning.Show();
    cout << endl;
    
    cout << "coding time = ";
    coding.Show();
    cout << endl;
    
    cout << "fixing time = ";
    fixing.Show();
    cout << endl;
    
 	total = coding + fixing;
    //operator notation
    cout << "coding + fixing = ";
    total.Show();
    cout << endl;
    
    Time morefixing(3,28);
    cout << "more fixing time = ";
    morefixing.Show();
    cout << endl;
    total = morefixing.operator+(total);
    // function notation
    cout << "morefixing.operator + (total) = ";
    total.Show();
    cout << endl;
    
    return 0;
}

运算结果:

planning time = 0 hours, 0 minutes
coding time = 2 hours, 40 minutes
fixing time = 5 hours, 55 minutes
coding + fixing = 8 hours, 35 minutes
more fixing time = 3 hours, 28 minutes
morefixing.operator + (total) = 12 hours, 3 minutes

总之,operator + () 函数的名称使得可以使用函数表示法或运算符表示法来调用它。编译器将根据操作数的类型来确定如何操作。

2)重载限制

多数C++运算符都可以用这样的方式重载。重载的运算符不必是成员函数,但必须至少有一个操作数是用户定义的类型。

C++对用户定义的运算符重载的限制:

  1. 重载后的运算符必须至少有一个操作数是用户定义的类型,这将防止用户为标准类型重载运算符。因此,不能将减法运算符(-)重载为计算两个double值的和,而不是它们的差。虽然这种限制将对创造性有所影响,但可以确保程序正常的运行。

  2. 使用运算符时不能违反运算符原来的句法规则。例如:不能将求摸运算符(%)重载成使用一个操作数。

    int x;
    Time shiva;
    % x;				// invalid for modulus operator
    % shiva;			// invalid for overloaded operator
    

    同样,不能修改运算符的优先级。

  3. 不能创建新运算符。例如:不能定义operator **()函数来表示求幂。

  4. 不能重载下面的运算符:

    • sizeof : sizeof运算符
    • . : 成员运算符
    • .* : 成员指针运算符
    • :: :作用域解析运算符
    • ?: :条件运算符
    • typeid : 一个RTTI运算符
    • const_cast : 强调类型转换运算符
    • dynamic_cast : 强调类型转换运算符
    • static_cast : 强制类型转换运算符
  5. 大多数运算符都可以通过成员或非成员函数进行重载,但下面的运算符只能通过成员函数进行重载。

    • = :赋值运算符
    • ( ) :函数调用运算符
    • [ ] :下标运算符
    • -> :通过指针访问类成员的运算符

11.3 友元

C++控制对类对象私有部分的访问。通常,公有类方法提供唯一的访问途径,但是有时候这种限制太严格了,以至于不适合特定的编程问题。在这种情况下,C++提供了另外一种形式的访问权限:友元。友元有三种:

  • 友元函数
  • 友元类
  • 友元成员函数

通过让函数成为类的友元,可以赋予该函数与类的成员函数相同的访问权限。

1、创建友元

创建友元函数的第一步是将原型放在类声明中,并在原型声明前加上关键字friend:

friend Time operator * (double m, const Time & t);	
// goes in class declaration

该原型意味着下面两点:

  • 虽然 operator *()函数是在类声明中声明的,但它不是成员函数,因此不能使用成员运算符来调用;
  • 虽然operator *()函数不是成员函数,但它与成员函数的访问权限相同。

第二步是编写函数定义,因为它不是成员函数,所有不要使用Time:: 限定符。另外,不要在定义中使用关键字 friend ,定义应该如下:

Time operator*(double m, const Time & t)
{
    Time result;
    long totalminutes = t.hours * mult * 60 + t.minutes * mult;
    result.hours = totalminutes / 60;
    result.minutes = totalminutes & 60;
    return result;
}

有了该声明和定义后,下面的语句:

A = 2.75 * B;

将转换为如下语句,从而调用刚才定义的非成员友元函数:

A = operator * (2.75, B);

总之,类的友元函数是非成员函数,其访问权限与成员函数相同。

🛳友元是否有悖于OOP

2、 常有的友元: 重载<< 运算符

一个很有用的类特性是,可以对<<运算符进行重载,使之能与cout一起来显示对象的内容。

假设trip是一个Time对象。为显示Time的值,前面使用的是Show()。然而,如果可以像下面这种操作将更好:

cout << trip;	// make cout recognize Time class?

之所以可以这样做,是因为<<是可被重载的C++运算符之一。实际上,它已经被重载很多次了。最初,<<运算符是C和C++的位运算符,将值中的位左移。ostream类对该运算符进行了重载,将其转换位一个输出工具。cout是一个ostream对象,它是智能的,能够识别所有的C++基本类型。这是因为对于每种基本类型,ostream类声明中都包含了相应的重载的operator<<( )定义。也就是说,一个定义使用int参数,一个定义使用double参数,等等。

11.4 重载运算符:作为成员函数还是非成员函数

对于很多运算符来说,可以选择使用成员函数或非成员函数来实现运算符重载。一般来说,非成员函数应是友元函数。这样它才能直接访问类的私有数据

例如:Time类的加法运算符在Time类声明中的原型如下:

Time operator + (const Time & t) const; 	// member version

这个类也可以使用下面的原型:

// nonmember version
friend Time operator + (const Time & t1, const Time & t2);

加法运算符需要两个操作数。对于成员函数 版本来说,一个操作数通过this指针隐式地传递,另一个操作数作为函数参数显式地传递;对于友元版本来说,两个操作数都作为参数来传递。

注意:非成员版本的重载运算符函数所需的形参数目与运算符使用的操作数数目相同;而成员版本所需的参数数目少一个,因为其中的一个操作数是被隐式地传递的调用对象。

这两个原型都与表达式T2+T3匹配,其中T2和T3都是Time类型的对象。也就是说,编译器将下面的语句:

T1 = T2 + T3;

转换为下面两个的任何一个:

T1 = T2.operator + (T3);		// member function
T1 = operator + (T2, T3);		// nonmember function

记住,在定义运算符时,必须选择其中的一种格式,而不能同时选择这两种格式。因为这两种格式都与同一个表达式匹配,同时定义这种格式将被视为二义性错误,导致编译错误。

11.5 类的自动转换和强制类型转换

程序清单:

// stonewt.h
#ifndef STONEWT_H_
#define STONEWT_H_

class Stonewt
{   
private:
    enum {Lbs_per_stn = 14};		// pounds per stone
    int stone;						// whole stones
    double pds_left;				// fractional pounds
    double pounds;					// entire weight in pounds
public:
    Stonewt(double lbs);			// constructor for double pounds
    Stonewt(int stn, double lbs);	// constructor for stone , lbs
    Stonewt();						// default constructor
    ~Stonewt();
    
    void show_lbs() const;			// show weight in pounds format
    void show_stn() const;			// show weight in stone format
};

#endif
// stonewt.cpp
#include <iostream>
using std::cout;
#include "stonewt.h"

// constructor Stonewt object from double value
Stonewt::Stonewt(double lbs)
{
    stone = int (lbs) / Lbs_per_stn;		// integer division
    pds_left = int (lbs) % Lbs_per_stn + lbs - int(lbs);
    pounds = lbs;
}

// construct Stonewt object from stone, double values
Stonewt::Stonewt(int stn, double lbs)
{
    stone = stn;
    pds_left = lbs;
    pounds = stn * Lbs_per_stn + lbs;
}

Stonewt::Stonewt()			// default constructor, wt = 0
{
    stone = pounds = pds_left = 0;
}

Stonewt::~Stonewt()			// destructor
{
    
}

// show weight in stones
void Stonewt::show_stn() const
{
    cout << stone << " stone, " << pds_left << " pounds\n";
}

// show weight in pounds 
void Stonewt::show_lbs() const
{
    cout << pounds << " pounds\n";
}
// stone.cpp
#include <iostream>
using std::cout;
#include "stonewt.h"
void display(const Stonewt & st, int n);
int main()
{
    Stonewt incognito = 275;
    Stonewt wolfe(285.7);
    Stonewt taft(21, 8);
    
    cout << "The celebrity weighed ";
    incognito.show_stn();
    cout << "The detective weighed ";
    wolfe.show_stn();
    cout << "The President weighed ";
    taft.show_stn();
    incognito = 276.8;
    taft = 325;
    cout << "After dinner, the celebrity weighed ";
    incognito.show_stn();
    cout << "After dinner, the President weighed ";
    taft.show_lbs();
    display(taft, 2);
    cout << "The wrestler weight even more.\n";
    display(422,2);
    cout << "No stone left unearned\n";
    return 0;
}

void display(const Stonewt & st, int n)
{
    for(int i = 0; i < n; i++)
    {
        cout << "Wow! ";
        st.show_stn();
    }
}

输出结果:

The celebrity weighed 19 stone, 9 pounds
The detective weighed 20 stone, 5.7 pounds
The President weighed 21 stone, 8 pounds
After dinner, the celebrity weighed 19 stone, 10.8 pounds
After dinner, the President weighed 325 pounds
Wow! 23 stone, 3 pounds
Wow! 23 stone, 3 pounds
The wrestler weight even more.
Wow! 30 stone, 2 pounds
Wow! 30 stone, 2 pounds
No stone left unearned

1、 转换函数

转换函数是用户定义的强制类转换,可以像使用强制类型转换那样使用它们。

例如:如果定义了从Stonewt到double的转换函数,就可以使用下面的转换:

Stonewt wolfe(285.7);
double host = double(wolfe);		// syntax #1
double thinker = (double) wolfe;	// syntax #2

也可以让编译器来决定如何做:

Stonewt wells(20, 3);
double star = wells;		// implicit use of conversion function

编译器发现,右侧是Stonewt类型,而左侧是double类型,因此它将查看程序员是否定义了与此匹配的转换函数。(如果没有找到这样的定义,编译器将生成错误的消息,指出无法将Stonewt赋给double)

要转换为typeName类型,需要使用这种形式的转换函数:

operator typeName();

请注意以下几点:

  • 转换函数必须是类方法
  • 转换函数不能指定返回类型
  • 转换函数不能有参数

例如:转换为double类型的函数的原型为:

operator double();

typeName(这里为double)指出了要转换成的类型,因此不需要指定返回类型。转换函数是类方法意味着:它需要通过类对象来调用,从而告知函数要转换的值。因此,函数不需要参数。

要添加将stone_wt对象转换为int类型和double类型的函数,需要将下面的原型添加到类声明中:

operator int();
operator double();

修改后的类声明:

程序清单:

// stonewt1.h
#ifndef STONEWT_H_
#define STONEWT_H_

class Stonewt
{   
private:
    enum {Lbs_per_stn = 14};		// pounds per stone
    int stone;						// whole stones
    double pds_left;				// fractional pounds
    double pounds;					// entire weight in pounds
public:
    Stonewt(double lbs);			// constructor for double pounds
    Stonewt(int stn, double lbs);	// constructor for stone , lbs
    Stonewt();						// default constructor
    ~Stonewt();
    
    void show_lbs() const;			// show weight in pounds format
    void show_stn() const;			// show weight in stone format
    
    // conversion functions
    operator int() const;
	operator double() const;
};

#endif
// stonewt1.cpp
#include <iostream>
using std::cout;
#include "stonewt1.h"

// constructor Stonewt object from double value
Stonewt::Stonewt(double lbs)
{
    stone = int (lbs) / Lbs_per_stn;		// integer division
    pds_left = int (lbs) % Lbs_per_stn + lbs - int(lbs);
    pounds = lbs;
}

// construct Stonewt object from stone, double values
Stonewt::Stonewt(int stn, double lbs)
{
    stone = stn;
    pds_left = lbs;
    pounds = stn * Lbs_per_stn + lbs;
}

Stonewt::Stonewt()			// default constructor, wt = 0
{
    stone = pounds = pds_left = 0;
}

Stonewt::~Stonewt()			// destructor
{
    
}

// show weight in stones
void Stonewt::show_stn() const
{
    cout << stone << " stone, " << pds_left << " pounds\n";
}

// show weight in pounds 
void Stonewt::show_lbs() const
{
    cout << pounds << " pounds\n";
}

// conversion function
Stonewt::operator int() const
{
    return int (pounds + 0.5);
}

Stonewt::operator double() const
{
    return pounds;
}
// stone1.cpp
#include <iostream>
using std::cout;
#include "stonewt1.h"
int main()
{
    Stonewt poppins(9, 2.8);
    double p_wt = poppins;
    
    cout << "Convert to double => ";
    cout << "Poppins: " << p_wt << " pounds.\n";
    cout << "Covert to int => ";
    cout << "Poppins: " << int (poppins) << " pounds.\n";
    return 0;
}

输出结果:

Convert to double => Poppins: 128.8 pounds.
Covert to int => Poppins: 129 pounds.

2、转换函数和友元函数

下面为Stonewt类重载加法运算符,在讨论Time类时指出过,可以使用成员函数或友元函数来重载加法。可以使用下面的成员函数实现加法:

Stonewt Stonewt::operator+(const Stonewt & st) const
{
    double pds = pounds + st.pounds;
    Stonewt sum(pds);
    return sum;
}

也可以将加法作为友元函数来实现:

Stonewt operator+(const Stonewt & st,const Stonewt & st2) 
{
    double pds = st1.pounds + st2.pounds;
    Stonewt sum(pds);
    return sum;
}

注意:可以提供方法定义或友元函数定义,但不能都提供。上面任何一种格式都允许这样做:

Stonewt jennySt(9, 12);
Stonewt bennySt(12, 8);
Stonewt total;
total = jennySt + bennySt;

11.6 总结

一般来说,访问私有类成员的唯一方法是使用类方法。C++使用友元函数来避开这种限制。要让函数成为友元,需要在类声明该函数,并在声明前面加上关键字friend。

C++扩展了对运算符的重载,允许自定义特殊的运算符函数,这种函数描述了特定的运算符与类之间的关系。运算符函数可以是类成员函数,也可以是友元函数。要调用运算符函数,可以直接调用该函数,也可以以通常的句法使用被重载的运算符。对于运算符op,其运算符函数的格式如下:

operatorop(argument-list)

argument-list表示该运算符的操作数。如果运算符函数是类成员函数,则第一个操作数是调用对象,它不在argument-list中。

最常见的运算符重载任务之一是定义<<运算符,使之可与cout一起使用,来显示对象的内容。要让ostream对象成为第一个操作数,需要将运算符函数定义为友元;要使重新定义的运算符能与其自身拼接,需要将返回类型声明为ostream &。

下面的通用格式能够满足这种要求:

ostream & operator << (ostream & os, const c_name & obj)
{
    os << ...;			// display object contents
    return os;
}

然而,如果类包含这样的方法,它返回需要显示的数据成员的值,则可以使用这些方法,无需在operator<<()中直接访问这些成员。在这种情况下,函数不必是友元。

C++允许指定在类和基本类型之间转换为类。如果将类型与该参数相同的值赋给对象,则C++将自动调用该构造函数。

例如:假设有一个String类,它包含一个将 char *值作为其唯一参数的构造函数,那么如果bean是String对象,则可以使用下面的语句:

bean = "pinto";	// converts type char * to type String

然而,如果在该构造函数的声明前加上了关键字explicit,则该构造函数将只能用于显示转换:

bean = String("pinto");	// converts type char * to type String explicitly

要将类对象转换为其他类型,必须定义转换函数,指出如何进行这种转换。转换函数必须是成员函数。将类对象转换为typeName类型的转换函数的原型如下:

operator typeName();

注意:转换函数没有返回类型、没有参数,但必须返回转换后的值(虽然没有声明返回类型)。