完成上面的分析與設計之後,小陳感覺已經成竹在胸勝利在望了。他知道,只要完成了程序中的類以及類之間關系的分析和設計,整個程序就相當於已經完成了一大半。接下來的工作,不過就是依葫蘆畫瓢,用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),自然會有人來敲門的。從看似枯燥乏味的程序代碼中,我們也能感悟出人生哲理,真是人生如代碼,代碼似人生。