完成上面的分析与设计之后,小陈感觉已经成竹在胸胜利在望了。他知道,只要完成了程序中的类以及类之间关系的分析和设计,整个程序就相当于已经完成了一大半。接下来的工作,不过就是依葫芦画瓢,用C++这种开发语言将之前的设计结果表达出来,形成具体的程序而已。
按照之前的设计结果,小陈决定首先实现最基础的Employee类:
// SalarySys.cpp #include <ctime> // 使用其中的时间函数 #include <string> // 使用字符串对象 using namespace std; // 枚举员工的级别 enum EmpLevel { enumOfficer = 1, // 高级员工 enumStaff = 2 // 一般员工 }; // 员工类 class Employee { public: // 构造函数,根据员工的姓名和入职年份构造对象 Employee(string strName,int nY) :m_strName(strName),m_nYear(nY) // 设定员工的姓名和入职年份 {} // Employee类的行为,这些行为都是供外界调用的接口, // 所以将其访问级别设定为public public: // 获得员工姓名 string GetName() const { return m_strName; } // 获得员工入职年份 int GetYear() const { return m_nYear; } // 获得员工级别 EmpLevel GetLevel() const { return m_nLevel; } // 获得员工工资,因为这个行为同具体的员工类相关, // 不同的派生类有不同的行为(计算方法),所以在基类Employee中只是 // 用纯虚函数表示接口,具体行为由其派生类实现 virtual int GetSalary() = 0; // GetWorkTime()只是供自身和自己的派生类似用,所以将其 // 访问级别设定为protected protected: // 获得在职时间,也就是现在年份减去入职年份 int GetWorkTime() const { // 获得现在的年份 time_t t = time(0); struct tm* now = localtime(&t); // time()函数获得的时间是以1900年为起点,所以这里需要 // 加上1900。同时,不满一年按照一年计算,所以最后要加1 return now->tm_year + 1900 - m_nYear + 1; } // Employee类的属性 // 因为这些属性也同样应当是其派生类具有的,需要由基类遗传给 // 它的派生类,所以这里使用protected访问级别,允许其派生类继承这些属性 protected: string m_strName; // 姓名 int m_nYear; // 入职年份 EmpLevel m_nLevel; // 级别 };
完成Employee类的实现后,就好比造房子打好了地基,小陈接着在其基础上,派生出具体的员工类Officer和Staff,分别完成具体的工资计算:
// … // 高级员工类 // 因为高级员工也是员工的“一种”,所以它可以从Employee类采用public派生 class Officer : public Employee { public: // 构造函数 // 直接调用基类Employee的构造函数,完成相同部分属性的构建 Officer(string strName, int nY) :Employee(strName,nY) { // 进行派生类独有的构建工作,设定员工的特定级别 m_nLevel = enumOfficer; } public: // 对基类的纯虚函数进行重写,具体实现员工计算工资的行为 virtual int GetSalary() override { // 对于高级员工,每年涨5000元工资 return GetWorkTime()*5000; } }; // 普通员工类 class Staff : public Employee { public: Staff(string strName, int nY) :Employee(strName,nY) { m_nLevel = enumStaff; } public: // 不同的派生类对相同的行为有不同的实现, // 这就是类的多态机制的体现 virtual int GetSalary() override { // 普通员工,每年涨1000元工资 return GetWorkTime()*1000; } };
在员工类及其派生类的实现中,全面体现了面向对象的三大特征。首先,我们将所有员工,包括高级员工和普通员工的共有属性和行为封装成员工类Employee这个基类,这里体现的是类对属性和行为的封装;然后使用面向对象的继承机制从员工类Employee中派生出高级员工类Officer和普通员工类Staff,这样使得这两个派生类可以复用基类的代码,例如员工的姓名和入职时间等共有属性,以及供外界访问的GetName()等接口函数,派生类无须重复定义而通过继承就直接拥有了。派生类所要做的,只是实现自己特有的属性和行为。例如,两个派生类各自对工资的计算方式不同,所以利用面向对象的多态机制,它们对基类提供的用于计算工资的GetSalary()纯虚函数进行重写,各自完成了自己特殊的工资计算方式。
完成了具体的员工类的实现,接下来就是用它们创建具体的员工对象并交由最核心的SalarySys类对其进行管理。按照前面的设计,小陈用一个数组来保存这些员工对象的指针,同时又分别实现了SalarySys类的其他行为,完成对这些员工对象的输入、查询和输出:
// 引入需要的头文件 #include <iostream> // 屏幕输入输出 #include <fstream> // 文件输入输出 #include <climits> // 引入INT_MAX // … // 定义SalarySys中数组的最大数据量, // 也就是SalarySys最多能处理多少个员工数据 const int MAX = 100000; // 工资管理类SalarySys class SalarySys { public: // 构造函数,对属性进行初始化 SalarySys() :m_nCount(0), // 设定当前数据量为0 m_strFileName("SalaryData.txt") // 设定员工数据文件名 { // 对数组进行初始化,使得数组中都是nullptr for(long i = 0; i < MAX; ++i) { m_arrEmp[i] = nullptr; } // 读取员工数据文件 Read(); } // 析构函数,完成清理工作 ~SalarySys() { // 将员工数据写入文件,以备下次读取 Write(); // 释放数组中已经创建的员工对象 for(long i = 0; i < m_nCount; ++i) { delete m_arrEmp[i]; // 释放对象 m_arrEmp[i] = nullptr; // 将指针设置为nullptr } } // SalarySys的公有行为 public: // 从员工数据文件读取已经输入的数据 int Read() { // 用于文件读取的中间临时变量 string strName = ""; int nLevel = 0; int nYear = 0; // 读取的数据个数 int i = 0; // 打开数据文件 ifstream in(m_strFileName); if(in.is_open()) // 判断是否成功打开 { // 如果打开文件成功,构造无限循环进行读取 while(true) { // 分别读取姓名、级别和入职年份 in>>strName>>nLevel>>nYear; // 判断是否读取正确,如果读取错误, // 例如读取到达文件末尾,则结束读取 if(!in) break; // 跳出读取循环 // 根据读取的员工级别,分别创建不同的员工对象, // 并保存到m_arrEmp数组进行管理 if( enumOfficer == nLevel) { // 根据员工姓名和入职年份,创建高级员工对象 m_arrEmp[i] = new Officer(strName,nYear); ++i; // 记录已经读取的数据数量 } else if ( enumStaff == nLevel) { m_arrEmp[i] = new Staff(strName,nYear); ++i; // 记录已经读取的数据数量 } // 如果读取的数量大于数组容量,则结束读取,否则继续下一次读取 if(i >= MAX) break; } // 读取完毕,关闭文件 in.close(); } // 输出读取结果并返回读取的数据个数 cout<<"已读取"<<i<<"个员工数据"<<endl; m_nCount = i; // 记录数组中有效数据的个数 return i; } // 将员工数据写入文件 void Write() { // 打开数据文件作为输出 ofstream o(m_strFileName); if(o.is_open()) { // 如果成功打开文件,则利用for循环逐个输出数组中保存的数据 for(int i = 0;i < m_nCount; ++i) { Employee* p = m_arrEmp[i]; // 输出各个员工的各项属性,以Tab间隔 o<<p->GetName()<<"/t" // 名字 <<p->GetLevel()<<"/t" //级别 <<p->GetYear()<<endl; // 入职年份 } // 输出完毕,关闭文件 o.close(); } } // 手工输入员工数据 int Input() { // 提示输入 cout<<"请输入员工信息(名字 级别(1-一般员工,2-高级员工) 入职年份),例如:Wanggang 1 1982"<<endl; cout<<"-1表示输入结束"<<endl; // 新输入的数据保存在数组已有数据之后, // 所以这里将已有数据个数m_nCount作为输入起点 // 又因为i在for循环之后还需要用到,所以定义在for循环之前 int i = m_nCount; for(; i < MAX; ++i) // 初始化语句留空 { // 利用for循环逐个输入 cout<<"请输入"<<i<<"号员工的信息:"<<endl; // 根据输入的数据创建具体的员工对象,并保存到数组 string strName = ""; int nL = 0; int nY = 0; // 获取用户输入 cin>>strName>>nL>>nY; // 对输入情况进行判断处理 if(!cin) // 如果输入错误,则重新输入 { cout<<"输入错误,请重新输入"<<endl; cin.clear(); // 清理输入标志位 cin.sync(); // 清空键盘缓冲区 --i; // 本次输入作废,不计算在内 continue; // 直接开始下一次输入循环 } else // 输入正确 { // 检查是否输入结束 if("-1" == strName) { break; // 结束输入循环 } // 根据输入的数据,创建具体的员工对象并保存到数组 if(enumOfficer == nL) m_arrEmp[i] = new Officer(strName,nY); else if(enumStaff == nL) m_arrEmp[i] = new Staff(strName,nY); else // 员工级别输入错误 { cout<<"错误的员工级别,请重新输入"<<endl; --i; cin.clear(); // 清理输入标志位 cin.sync(); // 清空键盘缓冲区 continue; } } } // 输入完毕,调整当前数组中的数据量 m_nCount = i; // 返回本次输入完成后的数据个数 return m_nCount; } // 获得最高工资的员工对象 Employee* GetMax() { // 表示结果的指针,初始值为nullptr Employee* pMax = nullptr; // 设定一个假想的当前最大值,也就是最小的int类型数据值 int nMax = INT_MIN; // 用for循环遍历数组中的每一个对象 for(int i = 0;i < m_nCount; ++i) { // 如果当前对象的工资高于当前最大值nMax,则将当前对象的工资 // 作为新的当前最大值,并将当前对象的指针作为结果保存 // 这里使用的是基类Employeed 的指针调用GetSalry()虚函数来获得 // 当前对象的工资,而实际上,它将动态地调用这个指针所指向的实际对象的 // 相应函数来完成工资的计算。换言之,如果这个指针指向的是Officer对象, // 就会调用Officer类的GetSalary()函数,如果指向的是Staff对象, // 就会调用Staff类的GetSalary()函数。这样就实现了不同等级 // 的员工,不同的工资计算方式,使用统一的调用方式。 if(m_arrEmp[i]->GetSalary() > nMax) { // 则将当前对象记录为结果对象 pMax = m_arrEmp[i]; // 并将当前对象的工资记录为当前最大值 nMax = pMax->GetSalary(); } } // 返回指向拥有最高工资的员工对象的指针 return pMax; } // 查询员工工资 void Find() { // 构造无限循环进行查询 while(true) { // 查询的姓名 string strName = ""; // 输入提示 cout<<"请输入要查询的员工名字(-1表示结束查询):"<<endl; // 获取用户输入的员工姓名 cin>>strName; // 对用户输入进行检查 if(!cin) // 如果输入错误,提示重新输入 { cout<<"输入错误,请重新输入"<<endl; cin.clear(); cin.sync(); continue; // 开始下一次查询 } else if("-1" == strName) // 如果查询结束 { // 查询结束,用break结束查询循环 cout<<"查询完毕,感谢使用!"<<endl; break; } // 记录是否找到查询的员工 bool bFind = false; // 用for循环遍历所有员工对象,逐个进行比对查找 for(int i = 0;i < m_nCount;++i) { // 获得指向当前对象的指针 Employee* p = m_arrEmp[i]; // 判断当前对象的名字是否与查询条件相同 if(strName == p->GetName()) { // 输出符合查询条件的员工信息 cout<<"员工姓名:"<<p->GetName()<<endl; cout<<"员工工资:"<<p->GetSalary()<<endl; bFind = true; // 记录本次查询成功 break; // 跳出for循环结束查询 // 结束循环 } } // 如果本次没有找到,则提示用户重新输入 if(!bFind) { cout<<"无法找到名字为"<<strName<<"的员工。"<<endl; cout<<"请核对姓名,重新输入"<<endl; } } } // SlarySys类的属性 // 因为这些属性都只是供SalarySys类访问, // 所以其访问级别设定为private private: // 数据文件名,为了防止被错误修改,所以使用const关键字修饰 // 使用const修饰的成员变量,必须在类构造函数的初始化列表中进行初始化 // 在C++11中,也可以在定义时直接赋值初始化 const string m_strFileName; Employee* m_arrEmp[MAX]; // 保存员工对象指针的数组 int m_nCount; // 数组中已有的员工对象数 };
完成了工资系统类SalarySys之后,实际上就是万事俱备,只欠东风了。接下来就只需要在主函数中运用上面创建的这些类来完成需求设计中的各个用例,那就大功告成了:
// … int main() { // 创建一个SalarySys对象 // 在构造函数中,它会首先去读取数据文件中的员工数据, // 完成““从文件读取”这一用例 SalarySys sys; // 让用户输入数据,完成“手工输入”用例 sys.Input(); // 调用SalarySys的GetMax()函数获得工资最高的员工对象, // 完成“计算最大值”用例 Employee* pMax = sys.GetMax(); if(nullptr != pMax) { cout<<"工资最高的员工是:"<<endl; cout<<"名字:"<<pMax->GetName()<<endl; cout<<"工资:"<<pMax->GetSalary()<<endl; } // 调用SalarySys类的Find()函数,完成“查询工资”用例 sys.Find(); // 最后,当sys对象析构的时候,会调用自己的Write()函数, // 完成“输出数据到文件”用例 return 0; }
有了面向对象思想和类的帮助,短短的几百行代码,小陈就完成了一个功能强大的工资程序。从这里小陈也体会到,用面向对象思想进行分析与设计,更加接近于我们分析问题、解决问题的思维习惯,这使得工资程序的设计更加直观、更加自然,程序结构也更加清晰,实现起来自然也就更加容易了。封装,可以让函数和它所操作的数据捆绑在一起成为对象,可以起到很好的数据保护的作用;继承,可以复用共同的属性和行为,起到代码复用的作用。同时还可以很方便地对其进行扩展,从而支持更多更新的需求;多态,让我们可以以一致的调用方式,实现不同的操作行为。从而使得我们在设计中考虑得更多的是接口问题,而不用担心后面的实现问题。
当小陈自信满满地将改写后的工资程序拿给老板使用以后,老板更是赞不绝口:
“不错不错,不仅能动态地计算各种员工的工资,并且时间变化以后,工资也会跟着变化。可以统计最高工资员工的姓名,查询的时候,也可以根据名字进行查询。我想要的功能都很好地实现了嘛,干得不错,啊哈哈……,下个月,涨工资,啊哈哈哈……”
当再次听到老板的“涨工资”时,小陈已经没有先前那么激动了,他反问了一句:
“真的?”
“当然是真的,”老板立刻掩饰说,“我什么时候说话算数啊!”
听到这话,小陈也不去戳穿老板的伪装。现在在他看来,学好C++比涨工资更加重要,现在他已经越来越感受到C++的魅力,已经开始爱上C++了。
设计模式:像建筑师一样思考
上面的工资程序是否已经太过复杂,让你的头感到有点隐隐作痛?
如果是,那么你一定需要来一片程序员专用的特效止痛片——设计模式。
设计模式(Design Pattern)是由Erich Gamma等4人在90年代从建筑设计领域引入到软件设计领域的一个概念。他们发现,在建筑领域存在这样一种复用设计方案的方法,那就是在某些外部环境相似,功能需求相同的地方,建筑师们所采用的设计方案也是相似的,一个地方的设计方案同时可以在另外一个相似的地方复用。这样就大大提高了设计的效率节约了成本。他们将这一复用设计的方法从建筑领域引入到软件设计领域,从而提出了设计模式的概念。他们总结了软件设计领域中最常见的23种模式,使其成为那些在软体设计中普遍存在(反复出现)的各种问题的解决方案。并且,这些解决方案是经过实践检验的,当我们在开发中遇到(因为这些问题的普遍性,我们也一定会经常遇到)相似的问题时,只要直接采用这些解决方案,复用前人的设计成果就可以很好地解决今人的问题,这样可以节约设计成本,大大提高我们的开发效率。
那么,设计模式是如何做到这一点的呢?设计模式并不直接用来完成代码的编写,而是描述在各种不同情况下,要怎样解决问题的一种方案。面向对象设计模式通常以类或对象来描述其中的各个实体之间的关系和相互作用,但不涉及用来完成应用程序的特定类或对象。设计模式能使不稳定依赖于相对稳定、具体依赖于相对抽象,尽量避免会引起麻烦的紧耦合,以增强软件设计适应变化的能力。这样可以让我们的软件具有良好的结构,能够适应外部需求的变化,能够避免软件因为不断增加新功能而显得过于臃肿,最后陷入需求变化的深渊。另外一方面,设计模式都是前人优秀设计成果的总结,在面对相似问题的时候,直接复用这些经过实践检验的设计方案,不仅可以保证我们设计的质量,还可以节省设计时间,提高开发效率。从某种意义上说,设计模式可以说是程序员们的止痛药——再也没有需求变化带来的痛苦。
为了让大家真正地感受到设计模式的魅力,我们来看一看众多设计模式当中最简单的一个模式——单件模式(Singleton Pattern)。顾名思义,单件模式就是让某个类在任何时候都只能创建唯一的一个对象。这样的需求看起来比较特殊,但是有这种需求的场景却非常广泛,比如,我们要设计开发一个打印程序,我们只希望有一个Print Spooler对象,以避免两个打印动作同时输送至打印机中;在数据库连接中,我们也同样希望在程序中只有唯一的一个数据库连接以节省资源;在上面工资程序中的SalarySys类,也同样需要保证它在整个程序中只有唯一的一个实例对象,要不然每个人的工资在不同的SalarySys对象中就可能会产生冲突;甚至在一个家庭中,我们都是一个老公只能有一个老婆,如果有多个老婆肯定会出问题。单件模式,就是用来保证对象能够被创建并且只能够被创建一次。在程序中,所有客户使用的对象都是唯一的一个对象。
我们都知道,对象的创建是通过构造函数来完成的,所以单件模式的实现关键是将类的构造函数设定为private访问级别,让外界无法通过构造函数自由地创建这个类的对象。取而代之的是,它会提供一个公有的静态的创建函数来负责对象的创建,而在这个创建函数中,我们就可以判断唯一的对象是否已经创建。如果尚未创建,则调用自己的构造函数创建对象并返回,如果已经创建,则直接返回已经创建的对象。这样,就保证了这个类的对象的唯一性。例如,我们可以用单件模式来改写上面例子中的SalarySys类,以保证SalarySys对象在程序中的唯一性:
// 使用单件模式实现的SalarySys类 class SalarySys { // 省略SalarySys类的其他属性和行为 //... // 将构造函数私有化(private) private: SalarySys() :m_nCount(0), m_strFileName("SalaryData.txt") { // … } public: // 提供一个公有的(public,为了让客户能够访问)静态的(static,为了让 // 客户可以在不创建对象的情况下直接访问)创建函数, // 供外界获取SalarySys的唯一对象 // 在这个函数中,对对象的创建行为进行控制,以保证对象的唯一性 static SalarySys* getInstance() { // 如果唯一的实例对象还没有创建,则创建实例对象 if ( nullptr == m_pInstance ) m_pInstance = new SalarySys(); // 如果已经创建实例对象,则直接返回这个实例对象 return m_pInstance; }; private: // 静态的对象指针,指向唯一的实例对象 // 为静态的唯一实例对象指针赋初始值,表示对象尚未创建 static SalarySys* m_pInstance = nullptr; }; // … int main() { // 第一次调用getInstance()函数,唯一的SalarySys对象尚未创建, // 则创建相应的对象并返回指向这个对象的指针 SalarySys* pSalarySys1 = SalarySys::getInstance(); // … // 第二次调用getInstance()函数,这时SalarySys的对象已经创建, // 则不再创建新对象而直接返回指向那个已创建对象的指针,保证对象的唯一性 SalarySys* pSalarySys2 = SalarySys::getInstance(); // … // 释放已创建的对象, pSalarySys1和pSalarySys2指向的是同一个对象, // 使用pSalarySys1或pSalarySys2释放这个对象是等效的,并只需要释放一次 delete pSalarySys1; pSalarySys1 = pSalarySys2 = nullptr; return 0; }
经过单件模式的改写,SalarySys类的构造函数已经变成私有的,在主函数中就不能直接使用new关键字来创建一个实例对象,而只能通过它提供的公有的getInstance()函数来获得这个类的唯一实例对象。这里需要注意的是,为了实现单件模式,我们在SalarySys的m_pInstance成员变量和getInstance()成员函数前都加上了static关键字对其进行修饰,这表示这个成员变量和成员函数都将是静态的,我们可以通过类作用域符号(“::”)直接访问类的静态成员而无需任何类的实例对象。静态成员的这种特性,为我们以私有的构造函数之外的成员函数来创建类的对象提供了可能。同时,在getInstance()函数中我们可以对对象的创建行为进行控制:如果对象尚未创建,则创建对象;如果对象已经创建完成,则直接返回已经创建完成的对象,这样就有效地保证了其实例对象的唯一性。
纵观整个单件模式,它的实现关键是将构造函数私有化(用private修饰),这才构成了这个对象只能自己构建自己,防止了外界创建这个类的对象,将创建对象的权利收归自己所有。通过这样将自己封闭起来,也就只能孤孤单单一个人了。这个模式对于那些仍在过“光棍节”的朋友同样有启发意义,我们之所以是单件,并不是我们无法创建对象,只是因为我们自己把自己封闭(private)起来了,而要想摆脱单件的状态,只需要把我们的心敞开(public),自然会有人来敲门的。从看似枯燥乏味的程序代码中,我们也能感悟出人生哲理,真是人生如代码,代码似人生。