对象和类(过程性编程和面向对象编程)

📅 发布时间:2026/7/12 10:28:19 👁️ 浏览次数:
对象和类(过程性编程和面向对象编程)
过程性编程和面向对象编程虽然本书前面偶尔探讨过OOP 在编程方面的前景但讨论的更多的还是诸如C、Pascal 和BASIC 等语言的标准过程性方法。下面来看一个例子它揭示了OOP 的观点与过程性编程的差别。Genre Giants 垒球队的一名新成员被要求记录球队的统计数据。很自然会求助于计算机来完成这项工作。如果是一位过程性程序员可能会这样考虑我要输入每名选手的姓名、击球次数、击中次数、命中率命中率指的是选手正式的击球次数除以击中次数当选手在垒上或被罚出局时击球停止但某些情况不计作正式击球次数如选手走步时以及其他重要的基本统计数据。之所以使用计算机是为了简化工作因此让它来计算某些数据如命中率。另外我还希望程序能够显示这些结果。应如何组织呢我想我能正确地完成这项工作并使用了函数。是的我让main( )调用一个函数来获取输入调用另一个函数来进行计算然后再调用第三个函数来显示结果。那么获得下一场比赛的数据后又该做什么呢我不想再从头开始可以添加一个函数来更新统计数据。可能需要在main( )中提供一个菜单选择是输入、计算、更新还是显示数据。则如何表示这些数据呢可以用一个字符串数组来存储选手的姓名用另一个数组存储每一位选手的击球数再用一个数组存储击中数目等等。这种方法太不灵活了可以设计一个结构来存储每位选手的所有信息然后用这种结构组成的数组来表示整个球队。总之采用过程性编程方法时首先考虑要遵循的步骤然后考虑如何表示这些数据并不需要程序一直运行用户可能希望能够将数据存储在一个文件中然后从这个文件中读取数据。如果换成一位OOP 程序员又将如何呢首先考虑数据——不仅要考虑如何表示数据还要考虑如何使用数据我要跟踪的是什么当然是选手。因此要有一个对象表示整个选手的各个方面而不仅仅是命中率或击球次数。是的这将是基本数据单元——一个表示选手的姓名和统计数据的对象。我需要一些处理该对象的方法。首先需要一种将基本信息加入到该单元中的方法其次计算机应计算一些东西如命中率因此需要添加一些执行计算的方法。程序应自动完成这些计算而无需用户干涉。另外还需要一些更新和显示信息的方法。所以用户与数据交互的方式有三种初始化、更新和报告——这就是用户接口。总之采用OOP 方法时首先从用户的角度考虑对象——描述对象所需的数据以及描述用户与数据交互所需的操作。完成对接口的描述后需要确定如何实现接口和数据存储。最后使用新的设计方案创建出程序。抽象和类生活中充满复杂性处理复杂性的方法之一是简化和抽象。人的身体是由无数个原子组成的而一些学者认为人的思想是由半自主的主体组成的。但将人自己看作一个实体将简单得多。在计算中为了根据信息与用户之间的接口来表示它抽象是至关重要的。也就是说将问题的本质特征抽象出来并根据特征来描述解决方案。在垒球统计数据示例中接口描述了用户如何初始化、更新和显示数据。抽象是通往用户定义类型的捷径在C中用户定义类型指的是实现抽象接口的类设计。类型是什么我们来看看是什么构成了类型。例如讨厌鬼是什么受流行的固定模式影响可能会指出讨厌鬼的一些外表特点胖、戴黑宽边眼镜、兜里插满钢笔等。稍加思索后又可能觉得从行为上定义讨厌鬼可能更合适如他或她是如何应对尴尬的社交场面的。如果将这种类比扩展到过程性语言如C 语言我们得到类似的情形。首先倾向于根据数据的外观在内存中如何存储来考虑数据类型。例如char占用1 个字节的内存而double 通常占用8 个字节的内存。但是稍加思索就会发现也可以根据要对它执行的操作来定义数据类型。例如int 类型可以使用所有的算术运算可对整数执行加、减、乘、除运算还可以对它们使用求模运算符%。而指针需要的内存数量很可能与int 相同甚至可能在内部被表示为整数。但不能对指针执行与整数相同的运算。例如不能将两个指针相乘这种运算没有意义的因此C没有实现这种运算。因此将变量声明为int 或float 指针时不仅仅是分配内存还规定了可对变量执行的操作。总之指定基本类型完成了三项工作决定数据对象需要的内存数量决定如何解释内存中的位long 和float 在内存中占用的位数相同但将它们转换为数值的方法不同决定可使用数据对象执行的操作或方法。对于内置类型来说有关操作的信息被内置到编译器中。但在C中定义用户自定义的类型时必须自己提供这些信息。付出这些劳动换来了根据实际需要定制新数据类型的强大功能和灵活性。C中的类类是一种将抽象转换为用户定义类型的C工具它将数据表示和操纵数据的方法组合成一个整洁的包。下面来看一个表示股票的类。首先必须考虑如何表示股票。可以将一股作为基本单元定义一个表示一股股票的类。然而这意味着需要100 个对象才能表示100 股这不现实。相反可以将某人当前持有的某种股票作为一个基本单元数据表示中包含他持有的股票数量。一种比较现实的方法是必须记录最初购买价格和购买日期用于计算纳税等内容。另外还必须管理诸如如拆股等事件。首次定义类就考虑这么多因素有些困难因此我们对其进行简化。具体地说应该将可执行的操作限制为获得股票增持卖出股票更新股票价格显示关于所持股票的信息。可以根据上述清单定义stock 类的公有接口如果您有兴趣还可以添加其他特性。为支持该接口需要存储一些信息。我们再次进行简化。例如不考虑标准的美式股票计价方式八分之一美元的倍数。显然纽约证券交易所一定看到过本书以前的版本中关于简化的论述因为它已经决定将系统转换为书中采用的方式。我们将存储下面的信息公司名称所持股票的数量每股的价格股票总值。接下来定义类。一般来说类规范由两个部分组成。类声明以数据成员的方式描述数据部分以成员函数被称为方法的方式描述公有接口。类方法定义描述如何实现类成员函数。简单地说类声明提供了类的蓝图而方法定义则提供了细节。为开发一个类并编写一个使用它的程序需要完成多个步骤。这里将开发过程分成多个阶段而不是一次性完成。通常C程序员将接口类定义放在头文件中并将实现类方法的代码放在源代码文件中。这里采用这种典型做法。程序清单10.1 是第一个阶段的代码它是Stock 类的类声明。这个文件按第9 章介绍的那样使用了#ifndef 等来访问多次包含同一个文件。为帮助识别类本书遵循一种常见但不通用的约定——将类名首字母大写。您将发现程序清单10.1看起来就像一个结构声明只是还包括成员函数、公有部分和私有部分等内容。稍后将对该声明进行改进所以不要将它用作模型但先来看一看该定义的工作方式。//stock00.h #ifndef STOCK00_H #define STOCK00_H_ # includestring class Stock { 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(); }; #endif稍后将详细介绍类的细节但先看一下更通用的特性。首先C关键字class 指出这些代码定义了一个类设计不同于在模板参数中在这里关键字class 和typename 不是同义词不能使用typename代替class。这种语法指出Stock 是这个新类的类型名。该声明让我们能够声明Stock 类型的变量——称为对象或实例。每个对象都表示一支股票。例如下面的声明创建两个Stock 对象它们分别名为sally和sollyStock sally; Stock solly;例如sally 对象可以表示Sally 持有的某公司股票。接下来要存储的数据以类数据成员如company 和shares的形式出现。例如sally 的company成员存储了公司名称share 成员存储了Sally 持有的股票数量share_val 成员存储了每股的价格total_val 成员存储了股票总价格。同样要执行的操作以类函数成员方法如sell( )和update( )的形式出现。成员函数可以就地定义如set_tot( )也可以用原型表示如其他成员函数。其他成员函数的完整定义稍后将介绍它们包含在实现文件中但对于描述函数接口而言原型足够了。将数据和方法组合成一个单元是类最吸引人的特性。有了这种设计创建Stock 对象时将自动制定使用对象的规则。istream 和ostream 类有成员函数如get( )和getline( )而Stock 类声明中的函数原型说明了成员函数是如何建立的。例如头文件iostream 将getline( )的原型放在istream 类的声明中。1访问控制类设计尽可能将公有接口与实现细节分开。公有接口表示设计的抽象组件。将实现细节放在一起并将它们与抽象分开被称为封装。数据隐藏将数据放在类的私有部分中是一种封装将实现的细节隐藏在私有部分中就像Stock 类对set_tot( )所做的那样也是一种封装。封装的另一个例子是将类函数定义和类声明放在不同的文件中。数据隐藏不仅可以防止直接访问数据还让开发者类的用户无需了解数据是如何被表示的。例如show( )成员将显示某支股票的总价格还有其他内容这个值可以存储在对象中上述代码正是这样做的也可以在需要时通过计算得到。从使用类的角度看使用哪种方法没有什么区别。所需要知道的只是各种成员函数的功能也就是说需要知道成员函数接受什么样的参数以及返回什么类型的值。原则是将实现细节从接口设计中分离出来。如果以后找到了更好的、实现数据表示或成员函数细节的方法可以对这些细节进行修改而无需修改程序接口这使程序维护起来更容易。2控制对成员的访问公有还是私有无论类成员是数据成员还是成员函数都可以在类的公有部分或私有部分中声明它。但由于隐藏数据是OOP 主要的目标之一因此数据项通常放在私有部分组成类接口的成员函数放在公有部分否则就无法从程序中调用这些函数。正如Stock 声明所表明的也可以把成员函数放在私有部分中。不能直接从程序中调用这种函数但公有方法却可以使用它们。通常程序员使用私有成员函数来处理不属于公有接口的实现细节。不必在类声明中使用关键字private因为这是类对象的默认访问控制class World { float mass; chhar name[20]; public: void tellall(void); };然而为强调数据隐藏的概念本书显式地使用了private。实现类成员函数还需要创建类描述的第二部分为那些由类声明中的原型表示的成员函数提供代码。成员函数定义与常规函数定义非常相似它们有函数头和函数体也可以有返回类型和参数。但是它们还有两个特殊的特征定义成员函数时使用作用域解析运算符::来标识函数所属的类类方法可以访问类的private 组件。首先成员函数的函数头使用作用域运算符解析::来指出函数所属的类。例如update( )成员函数的函数头如下void Stock::update(double price)这种表示法意味着我们定义的update( )函数是Stock 类的成员。这不仅将update( )标识为成员函数还意味着我们可以将另一个类的成员函数也命名为update( )。例如Buffoon( )类的update( )函数的函数头如下void Buffoon::update()因此作用域解析运算符确定了方法定义对应的类的身份。我们说标识符update( )具有类作用域classscope。Stock 类的其他成员函数不必使用作用域解析运算符就可以使用update( )方法这是因为它们属于同一个类因此update( )是可见的。然而在类声明和方法定义之外使用update( )时需要采取特殊的措施稍后将作介绍。类方法的完整名称中包括类名。我们说Stock::update( )是函数的限定名qualified name而简单的update( )是全名的缩写非限定名unqualified name它只能在类作用域中使用。方法的第二个特点是方法可以访问类的私有成员。例如show( )方法可以使用这样的代码std::coutCompany:company Shhares:sharesendl Share Price:$shhare_val Total Worth:$total_valendl;其中company、shares 等都是Stock 类的私有数据成员。如果试图使用非成员函数访问这些数据成员编译器禁止这样做但第11 章中将介绍的友元函数例外。了解这两点后就可以实现类方法了如程序清单10.2 所示。这里将它们放在了一个独立的实现文件中因此需要包含头文件stock00.h让编译器能够访问类定义。为让您获得更多有关名称空间的经验在有些方法中使用了限定符std::在其他方法中则使用了using 声明。#includeiostream #include stock00.h void Stock::acquire(const std::string co, long n, double pr) { company co; if (n 0) { std::cout Number of shares cant be negative; company shares set to 0.\n; } else shares n; share_val pr; set_tot(); } void Stock::buy(long num, double price) { if (num 0) { std::cout Number of shares purchased cant 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 cant be negative.; cout Transaction is aborted.\n; } else if(numshares) { cout You cant 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成员函数说明acquire( )函数管理对某个公司股票的首次购买而buy( )和sell( )管理增加或减少持有的股票。方法buy( )和sell( )确保买入或卖出的股数不为负。另外如果用户试图卖出超过他持有的股票数量则sell( )函数将结束这次交易。这种使数据私有并限于对公有函数访问的技术允许我们能够控制数据如何被使用在这个例子中它允许我们加入这些安全防护措施避免不适当的交易。4 个成员函数设置或重新设置了total_val 成员值。这个类并非将计算代码编写4 次而是让每个函数都调用set_tot( )函数。由于set_tot( )只是实现代码的一种方式而不是公有接口的组成部分因此这个类将其声明为私有成员函数即编写这个类的人可以使用它但编写代码来使用这个类的人不能使用。如果计算代码很长则这种方法还可以省去许多输入代码的工作并可节省空间。然而这种方法的主要价值在于通过使用函数调用而不是每次重新输入计算代码可以确保执行的计算完全相同。另外如果必须修订计算代码在这个例子中这种可能性不大则只需在一个地方进行修改即可。2内联方法其定义位于类声明中的函数都将自动成为内联函数因此Stock::set_tot( )是一个内联函数。类声明常将短小的成员函数作为内联函数set_tot( )符合这样的要求。如果愿意也可以在类声明之外定义成员函数并使其成为内联函数。为此只需在类实现部分中定义函数时使用inline 限定符即可class Stock { private: ... void set_tot(); public: ... }; inline void Stock::set_tot() { total_valshares*share_val; }内联函数的特殊规则要求在每个使用它们的文件中都对其进行定义。确保内联定义对多文件程序中的所有文件都可用的、最简便的方法是将内联定义放在定义类的头文件中有些开发系统包含智能链接程序允许将内联定义放在一个独立的实现文件。顺便说一句根据改写规则rewrite rule在类声明中定义方法等同于用原型替换方法定义然后在类声明的后面将定义改写为内联函数。也就是说程序清单10.1 中set_tot( )的内联定义与上述代码定义紧跟在类声明之后是等价的。3方法使用哪个对象下面介绍使用对象时最重要的一个方面如何将类方法应用于对象。下面的代码使用了一个对象的shares 成员sharesnum;是哪个对象呢问得好要回答这个问题首先来看看如何创建对象。最简单的方式是声明类变量Stock kate,joe;这将创建两个Stock 类对象一个为kate另一个为joe。接下来看看如何使用对象的成员函数。和使用结构成员一样通过成员运算符kate.show(); joe.show();第1 条语句调用kate 对象的show( )成员。这意味着show( )方法将把shares 解释为kate.shares将share_vla 解释为kate.share_val。同样函数调用joe.show( )使show( )方法将shares 和share_val 分别解释为joe.share 和joe.share_val。注意调用成员函数时它将使用被用来调用它的对象的数据成员。同样函数调用kate.sell( )在调用set_tot( )函数时相当于调用kate.set_tot( )这样该函数将使用kate对象的数据。所创建的每个新对象都有自己的存储空间用于存储其内部变量和类成员但同一个类的所有对象共享同一组类方法即每种方法只有一个副本。例如假设kate 和joe 都是Stock 对象则kate.shares将占据一个内存块而joe.shares 占用另一个内存块但kate.show( )和joe.show( )都调用同一个方法也就是说它们将执行同一个代码块只是将这些代码用于不同的数据。在OOP 中调用成员函数被称为发送消息因此将同样的消息发送给两个不同的对象将调用同一个方法但该方法被用于两个不同的对象参见图10.2。使用类知道如何定义类及其方法后来创建一个程序它创建并使用类对象。C的目标是使得使用类与使用基本的内置类型如int 和char尽可能相同。要创建类对象可以声明类变量也可以使用new 为类对象分配存储空间。可以将对象作为函数的参数和返回值也可以将一个对象赋给另一个。C提供了一些工具可用于初始化对象、让cin 和cout 识别对象甚至在相似的类对象之间进行自动类型转换。虽然要做到这些工作还需要一段时间但可以先从比较简单的属性着手。实际上您已经知道如何声明类对象和调用成员函数。程序清单10.3 提供了一个使用上述接口和实现文件的程序它创建了一个名为fluffy_the_cat 的Stock 对象。该程序非常简单但确实测试了这个类的特性。要编译该程序可使用用于多文件程序的方法这在第1 章和第9 章介绍过。具体地说将其与stock00.cpp 一起编译并确保stock00.h 位于当前文件夹中。#include iostream #include stock00.h int main() { Stock fluffy_the_cat; fluffy_the_cat.acquire(NanoSmart, 1000, 10.0); fluffy_the_cat.show(); fluffy_the_cat.buy(15, 18.125); fluffy_the_cat.show(); fluffy_the_cat.sell(400, 20.00); fluffy_the_cat.show(); fluffy_the_cat.buy(300000, 40.125); fluffy_the_cat.show(); fluffy_the_cat.sell(300000, 0.125); fluffy_the_cat.show(); return 0; }修改实现在前面的程序输出中可能有一个方面让您恼火——数字的格式不一致。现在可以改进实现但保持接口不变。ostream 类包含一些可用于控制格式的成员函数。这里不做太详细的探索只需像在程序清单8.8 那样使用方法setf()便可避免科学计数法std::cout.setf(std::ios_base::fixed,std::ios_base::floatfield);这设置了cout 对象的一个标记命令cout 使用定点表示法。同样下面的语句导致cout 在使用定点表示法时显示三位小数std::cout.precision(3);第17 章将介绍这方面的更多细节。可在方法show()中使用这些工具来控制格式但还有一点需要考虑。修改方法的实现时不应影响客户程序的其他部分。上述格式修改将一直有效直到您再次修改因此它们可能影响客户程序中的后续输出。因此show()应重置格式信息使其恢复到自己被调用前的状态。为此可以像程序清单8.8 那样使用返回的值std::streamsize prec std::cout.precision(prec); std::cout.precision(prec): std::ios_base::fmtflags origstd::cout.setf(std::ios_base::fixed); std::cout.setf(orig,std::ios_base::floatfield);您可能还记得fmtflags 是在ios_base 类中定义的一种类型而ios_base 类又是在名称空间std 中定义的因此orig 的类型名非常长。其次orig 存储了所有的标记而重置语句使用这些信息来重置floatfield而floatfield 包含定点表示法标记和科学表示法标记。第三请不要过多考虑这里的细节。这里的要旨是将修改限定在实现文件中以免影响程序的其他方面。根据上面的介绍可在实现文件中将方法show()的定义修改成如下所示void Stock::show() { using std::cout; using std::ios_base; ios_base::fmtflags orig cout.setf(ios_base::fixed,ios_base::floatfield); std::streamsize preccout.precision(3); coutCompany:company Shares:shares\n; coutShare Price:$share_val; cout.precision(2); coutTotal Worthhh:$total_val\n; cout.setf(orig,ios_base::floatfield); cout.precision(prec); }