1. 抽象
与现实事物进行类比,将最便于理解“抽象”原则。电视是一种大多数家庭都有的简单科技产品。读者很熟悉其功能:可将其打开或关闭、调换频道、调节音量,还可以添加附属组件,如扬声器、数字视频录像机和蓝光播放器。然而,你能解释这个黑盒子的工作原理吗?也就是说,知道电视机如何从空中或电缆中接收信号、转换信号并在屏幕上显示吗?大多数人肯定解释不了电视机的工作原理,但可以使用它。这是由于电视机明确地将内部的实现与外部的接口分离开来。我们通过接口与电视机进行交互:开关、频道变换器和音量控制器。我们不知道也不关心电视机的工作原理,我们不关心它是使用了阴极射线管技术还是其他技术在屏幕上生成图像,这无关紧要,因为不会影响接口。
1.1 抽象的作用
在软件中也有类似的抽象原则。可使用代码而不必了解底层的实现。在此有一个简单示例,程序可调用在<cmath>头文件中声明的sqrt()函数,而不需要知道这个函数使用什么算法求平方根。实际上,平方根计算的底层实现可能因库版本而异;但只要接口不变,函数调用就可以照常运行。抽象原则也可以扩展到类。例如,可使用ostream类的cout对象将数据传输到标准输出:
std::cout << "This call will display this line of text.\n";
在这行代码中,使用cout插入运算符<<的已经编写好的接口输出了一个字符数组。然而,不需要知道cout如何将文本输出到用户屏幕,只需要了解公有接口。cout的底层实现可随意改动,只要公开的行为和接口保持不变即可。
1.2 在设计中使用抽象
应该设计函数和类,使自己和其他程序员可以使用它们,而不需要知道底层的实现。为说明暴露在实现之外和隐藏在接口之后设计的不同,再次考虑前面的国际象棋程序。假定使用一个指向ChessPiece对象的二维指针数组实现象棋的棋盘。可以这样声明并使用棋盘:
ChessPiece* chessBoard[8][8] {}; // zero-initialized array.
...
chessBoard[0][0] = new Rook{};
然而,这种方法并没有用到抽象概念。每个使用象棋棋盘的程序员都知道这是一个二维数组。将该实现转换为其他类型比较难,因为需要改变整个程序中每一处用到棋盘的代码。棋盘的使用者也必须恰当地管理内存。在此没有将实现与接口分开。
更好的方法是将棋盘建立为类。这样就可以暴露接口,并隐藏底层实现。下面给出ChessBoard类的示例:
class ChessBoard {
public:
void setPieceAt(size_t x, size_t y, ChessPiece* piece);
ChessPiece* getPieceAt(size_t x, size_t y) const;
bool isEmpty(size_t x, size_t y) const;
private:
// Private implementation details...
}
注意,该接口并不决定底层实现方法。ChessBoard可以是一个二维数组,但是接口对此并没有要求。改变实现并不需要改变接口。此外,这个实现还可提供更多功能,如边界检测。
从这个示例可以了解到,抽象是C++程序设计中的重要技术。
2. 重用
C++设计的第二个基本原则是重用。用现实世界做类比同样有助于理解这个概念。假定你放弃了编程生涯,而选择自己更喜欢的面包师工作。第一天,面包师主管让你烤饼干。为完成任务,你找到了巧克力饼干的配方,混合原料,在饼干盘上把饼干成型,并将盘子放入烤箱。面包房主管对结果感到十分满意。
现在,很明显,你没有自己做一个烤箱来烘烤饼干,也没有亲自制作黄油、磨制面粉、制作巧克力片。你可能觉得这匪夷所思:“这还用做?”如果你真的是一位厨师,当然是这样:但如果你是一位编写烘培模拟游戏的程序员,又会怎样?在此情况下,你不希望编写程序的全部组件,从巧克力片到烤箱;而是查找可重用的代码以节约时间,或许同事编写了一个烹饪模拟程序,其中有很好的烤箱代码。或许这些代码并不能完成你需要的所有操作,但你可以修改这些代码,并添加必要的功能。
另一件你认为理所当然的事情是,你采用饼干的某个配方而不是自己做一个配方,这也是不言而喻的。然而,在C++编程中并非如此。尽管在C++中不断涌现处理问题的标准方法,但许多程序员仍然在每个设计中无谓地重造这些策略。
使用已有代码的思想并非首次出现。使用cout输出,就已经在重用代码了。你并没有编写将数据输出到屏幕的代码,而使用已有的cout实现这项任务。
遗憾的是,并非所有程序员都利用已有的代码。设计时应该考虑已有的代码,并在适当时重用它们。
2.1 编写可重用的代码
重用的设计思想适用于自己编写和使用的代码。应该设计程序,以重用类、算法和数据结构。自己和同事应能在当前项目和今后的项目中重用这些组件。通常,应该避免设计只适用于当前情况的特定代码。
在C++中,模板是一种编写多用途代码的语言技术。考虑编写一个可用于任何类型的二维棋盘游戏的泛型GameBoard类模板,而不是像前面那样编写一个存储ChessPiece的特定ChessBoard类。只需要修改类的声明,在接口中将棋子当作模板参数PieceType而不是固定类型。这个模板如下所示,如果在此之前没有见过这种语法,不要着急!后面将详细讲解。
template <typename PieceType>
class GameBoard {
public:
void setPieceAt(size_t x, size_t y, PieceType* piece);
PieceType* getPieceAt(size_t x, size_t y) const;
bool isEmpty(size_t x, size_y) const;
private:
// Private implementation details...
}
在接口中完成如上简单修改后,现在有了一个可用于任何二维棋盘游戏的泛型游戏棋盘类。尽管代码的变动很简单,但在设计阶段做这样的决定非常重要,以便能有效且高效地实现代码。
2.2 重用设计
学习C++语言与成为优秀的C++程序员是两码事。如果你坐下来,阅读C++标准,记住每个事实,那么你对C++的了解程度将与其他人差不多。但只有分析代码,并编写自己的程序,积累了一定的经验后,才可能成为优秀的程序员。原因在于,C++语法以原始形式定义了该语言的作用,但并未指定每项功能的使用方式。
如面包师示例所示,重新发明每道菜的配方是可笑的。然而,程序员在设计期间却常犯类似的错误。他们不是使用已有的“配方”或模式,而是在每次设计程序时都重造这些技术。
随着C++语言使用经验的增加,C++程序员自己总结出使用该语言功能的方式。C++社区通常已经构建起利用该语言的标准方式,一些方式是正规的,一些则是不正规。后面讲详细介绍该语言的可重用模式——设计模式。你可能已经熟悉其中的一些模式,这些只是平日里司空见惯的解决方案的正式化产物。其他方案描述你在过去遇到的问题的新解决方案。还有一些则以全新方式思考程序的结构。
例如,假定要设计一个国际象棋程序:使用一个ErrorLogger对象将不同组件发生的所有错误都按顺序写入一个日志文件。当试着设计ErrorLogger类时,你意识到只想在一个程序中有一个ErrorLogger示实例。还要使程序的多个组件都能使用这个ErrorLogger实例,即所有组件都想要使用同一个ErrorLogger服务。实现此类服务机制的一个标准策略是使用依赖注入。使用依赖注入时,为每个服务创建一个接口,并将组件需要的接口注入组件。因此,此时良好的设计应该使用“依赖注入”模式。
你必须熟悉这些模式和技术,根据特定设计问题选择正确的解决方案。在C++中,还可以使用更多的技术和模式。
参考
[比] 马克·格雷戈勒著 程序喵大人 惠惠 墨梵 译 C++20高级编程(第五版)