原文
作为软件开发者,我们学习了许多好的做法,并努力将它们应用到我们的代码中。
例如,我们学习了良好的变量和函数命名、封装、类内聚、多态性、简洁性、可读性、代码清晰性和表达性等的重要性。
如果只有一条原则需要了解,而不是大量的最佳实践,那该怎么办?
我相信这个原则是存在的:它就是尊重抽象层次。
这是所有原则中唯一的原则,因为应用它会自动应用上述所有最佳实践,甚至更多。当你遵循它时,你的代码自然而然地就写出来了。
它基于简单的概念,但花了我多年的实践和研究来正式化。不管怎样,说够了,就直接开始吧。
The What and the How
在查看调用堆栈时,很容易理解这个概念。让我们以一个处理金融产品的软件为例,其中用户想评估一个资产组合:
这个调用栈可以从下往上读取,方法如下:
- 要评估一个投资组合,必须评估每一项资产。
- 要评估一个特定的资产,说某种类型的概率必须计算。
- 为了计算这种概率,有一个模型进行数学运算,如+,-,等等。
- 这些基本的数学运算最终是发送到CPU的算术和逻辑单元的二进制运算。
很自然地会认为这个堆栈顶部的代码是低层次代码,而堆栈底部的代码是高层次代码。但是是什么东西的层次?它们是抽象的层次。
尊重抽象级别意味着给定代码片段(给定函数、接口、对象、实现)中的所有代码必须处于相同的抽象级别。换句话说,在给定的抽象级别上,一定不能有任何来自另一个抽象级别的代码。
给定的抽象级别由它做了什么来描述。例如,在堆栈的底层,所做的就是评估一个投资组合。然后,在堆栈中高于一个级别时,所做的就是评估一个资产,等等。
从一个给定的抽象级别进入下一个较低的抽象级别,低抽象级别是在表明高抽象级别如何实现的。在我们的示例中,如何评估资产是通过计算概率。如何计算一个概率是与初等数学运算,等等。
所以,在设计或编写代码时,要经常问自己的关键问题是:“我现在写在这儿的是关于什么的?”,以确定您正在编码的抽象级别,并确保您使用一致的抽象级别编写所有周围的代码。
统领一切的一条原则
我认为尊重抽象层次是编程中最重要的原则,因为它自动地暗示了许多其他最佳实践。让我们看看几种众所周知的最佳实践就是尊重抽象层次的不同形式。
多态
当你读到抽象时,首先想到的可能是多态性。
多态由抽象的隔离级别组成。
实际上,对于给定的接口(或抽象类)和具体的实现,基类是抽象的,而派生出来的实现是较低抽象的。
请注意,派生类仍然有些抽象,因为它不是用0和1表示的,但它的抽象级别低于基类。基类表示接口提供的内容,派生类表示如何实现它:
好的命名
让我们以一个负责维护值缓存的类为例。这个类允许它的客户端使用类型K的键来添加或检索类型V的值。可以使用map
现在设想一下,我们希望接口能够一次为所有存储的键提供完整的结果集。然后,我们向接口添加一个方法。这个方法应该如何命名?第一个尝试可能是“getMap”。
....
const std::map& getMap() const { return data_; }
....
但正如你可能会觉得,'getMap'不是一个好名字。之所以不是,是因为在缓存接口的抽象层中,“Map”是一个术语,描述如何而不是什么,所以不能在同一个抽象层次上。将其称为“getMap”会将几个抽象级别混合在一起。
一个简单的解决方法就是把它称为“getAllValues”。“Values”是一个与缓存接口的抽象级别一致的术语,因此是一个比“Map”更适合的名称。
良好的命名实际上是给一个符合当前使用的抽象级别的名字。这也适用于变量名。因为命名定义了抽象的层次,因此是一个非常重要的话题,我们将有一篇关于命名的专门文章(Fluent C++:如何选择好的命名)。
封装
但是,首先向类外部提供map返回值不是违反了封装吗?实际上,答案取决于返回值容器的概念在逻辑上是否是类接口抽象的一部分。
因此,破坏封装提供了超出接口抽象级别的信息。
内聚
现在,假设我们在缓存类中添加了一个新的方法来对值进行格式化:
....
static void formatValue(V&);
....
这显然是一个坏主意,因为这个类是关于缓存值的,而不是关于格式化它们的。这样做会破坏类的内聚力。就抽象而言,尽管缓存和格式化没有怎样的关系,但它们是两个不同的抽象,因为它们针对不同的事物。
因此,内聚就是在一个给定的地方只有一个抽象。
简洁,易读
让我们进入函数(或方法)级别。
继续以金融业为例,让我们考虑一下道琼斯指数或标普指数等金融指数,这些指数包含了苹果、波音或卡特彼勒等股票的集合。
假设我们想编写一个函数,在对索引进行一些检查之后,该函数触发在数据库中保存该索引。具体来说,我们想保存一个有效的指数,有效意味着有一个ID,且被市场引用,且是流动的。
功能实现的第一个尝试可以是:
void saveIndex(Index const& index)
{
if (index.hasID() && index.isQuoted() && index.isLiquid())
{
...
我们可以反对这种实现,因为它有一个相对复杂的布尔条件。为了代码简洁和可读性,一个自然的解决方法是将其组在一起并从函数中去掉:
void saveIndex(const Index& index)
{
if (isValid(index))
{
...
我们来看这个修改,它实际上是在推动一个索引如何被认为是有效的(具有一个ID、被引用、流动性)的实现,并用save的依赖项(valid)来代替它,这与save函数的抽象级别更一致。
这一点上值得注意的有趣之处是,尊重抽象级别超越了简单的代码简洁性。的确,即使有效性仅意味着拥有一个ID,我们仍然会做这个修正。这不会减少代码中键入的字符数(它甚至会提高它),但是这可以通过尊重抽象级别来提高代码的清晰度。
表现力
最后是表现力,它是Fluent C++的重点。
假设我们想从索引中删除一些本身无效的组件。
这里最好的解决方案是使用STL的remove_if算法。STL算法说明他们做什么,而不是手写循环语句来说明他们是如何实现的。通过这样做,STL算法可以提高代码的抽象级别,以匹配你调用的地方。
我们将在未来的帖子中深入探讨STL (再次——跟着我不断更新),因为它们是提高代码表现力的伟大工具。
结论
遵循尊重抽象级别的原则有助于在设计代码时做出选择,在许多方面都是如此。如果你在设计代码时考虑过这个原则,如果你经常问自己一个问题“我写在这儿的是关于什么的?”,你的代码自然会写得很好。
从这一原则中可以得出许多准则。我打算写几篇文章来说明利用它改进代码的几种方式。