成为高效程序员的一个关键就在于,当你开发程序任一部分的代码时,都能安全地忽视程序中尽可能多的其余部分。而类就是实现这一目标的首要工具。

抽象数据类型(ADT)

抽象数据类型(ADT, abstract data type)是指一些数据以及对这些数据所进行的操作的集合。

要想理解面向对象编程,首先要理解 ADT。抽象数据类型构成了”类/class”这一概念的基础,考虑类的一种方式,就是把它看做是抽象数据类型再加上继承和多态两种概念。

使用 ADT 的益处:

  • 可以隐藏实现细节
  • 改动不会影响到整个程序
  • 让接口能提供更多的信息
  • 更容易提高性能
  • 让程序的正确性更显而易见
  • 程序更具有自我说明性
  • 无须在程序内到处传递数据
  • 可以像在现实世界中那样操作实体,而不用在底层实现上操作它。

使用 ADT 的指导建议:

  • 把常见的底层数据类型创建为 ADT 并使用这些 ADT,而不再使用底层数据类型
    在 iOS 开发当中,系统类库已经帮我们提供了大量底层数据类型 ADT,我们不用再手工创建了。

  • 把像文件这样的常用对象当成 ADT
    书上针对的也是较底层的文件操作了,在 iOS 开发中已经有大量的顶层抽象 API 可以来操作文件了。

  • 简单的事物也可以当 ADT

  • 不要让 ADT 依赖于其存储介质
    尽量让类和访问器子程序的名字与存储数据的方式无关,并只提及抽象数据类型本身

良好的类接口

创建高质量的类,第一步,可能也是最重要的一步,就是创建一个好的接口。对于 iOS 开发来讲,大可以参考系统类型中的 API 接口设计,里面都是最符合 Cocoa 规范的接口设计实例。

好的抽象

类接口的抽象能力非常有价值,因为接口中的每个子程序都在朝着这个一致的目标而工作。如果类的接口不能展示出一种一致的抽象,它的内聚性就很弱。

  • 类的接口应该展现一致的抽象层次
    每一个类应该实现一个 ADT,并且仅实现这个 ADT

  • 一定要理解类所实现的抽象是什么

  • 提供成对的服务
    大多数操作都有和其相应的、相等的以及相反的操作。在设计一个类的时候,要检查每一个公用子程序,决定是否需要另一个与其互补的操作。

  • 把不相关的信息转移到其它类中

  • 尽可能让接口可编程,而不是表达语义
    一个接口中任何无法通过编译器强制实施的部分,就是一个可能被误用的部分要想办法把语义接口的元素转换为编程接口的元素,比如说用 Assets(断言)或其他的技术。

  • 谨防在修改时破坏接口的抽象

  • 不要添加与接口抽象不一致的公用成员

  • 同时考虑抽象性与内聚性

好的封装

封装是一个比抽象更强的概念。抽象通过提供一个可以让你忽略实现细节的模型来管理复杂度,而封装则强制阻止你看到细节。

这两个概念之所以相关,是因为没有封装时,抽象往往很容易被打破。

  • 尽可以地限制类和成员的可访问性
    考虑“采用哪种方式能最好地保护接口抽象的完整性?”

  • 不要公开暴露成员数据

  • 避免把私用的实现细节放入类的接口中

  • 不要对类的使用者做出任何假设

  • 避免使用友元类(就我学过的而言,我只知道在 C++ 中有友元类)

  • 不要因为一个子程序里仅使用公用子程序,就把它归入公开接口

  • 让阅读代码比编写代码更方便

  • 要格外警惕从语义上破坏封装性
    每当你发现自己是通过查看类的内部实现来得知该如何使用这个类的时候,你就不是在针对接口编程了,而是在透过接口针对内部实现编程了。

  • 留意过于紧密的耦合关系

有关设计和实现的问题

给类定义合理的接口,对于创建高质量程序起到了关键作用。然而,类内部的设计和实现也同样重要。

包含,即组合

包含是一个非常简单的概念,它表示一个类含有一个基本数据元素或对象。包含才是面向对象编程中的主力技术。

  • 通过包含来实现“有一个/has a”的关系

  • 在万不得已时通过 private 继承来实现“有一个”的关系
    在实践中,这种做法会在派生类和基类之间形成一种过于紧密的关系,从而破坏了封装性。

  • 警惕有起过约 7 个数据成员的类(我对这条表示怀疑,在我写过的代码里,很少有少于 7 个数据成员的类,不知道这条针对的是什么样的类)

继承

继承的概念是说一个类是另一个类的一种特化。继承的目的在于,通过“定义能为两个或更多个派生类提供共有元素的基类”的方式写出更精简的代码。

使用继承时需要做如下几项决策:

  • 对于每一个成员函数而言,它应该对派生类可见吗?它应该有默认的实现吗?这一默认的实现能被 override 吗?
  • 对于每一个数据成员而言(包括变量、具名常量、枚举等),它应该对派生类可见吗?

以下是关于这两个考虑事项的详细解释:

  • 用 public 继承来实现“是一个···”的关系
    基类既对派生类将会做什么设定了预期,也对派生类能怎么动作提出了限制

  • 要么使用继承并进行详细说明,要么就不要用它

  • 遵循 Liskov 替换原则
    除非派生类真的“是一个”更特殊的基类,否则不应该从基类继承。派生类必须能通过基类的接口而被使用,且使用者无须了解两者之间的差异。

  • 确保只继承需要继承的部分

  • 不要“覆盖”一个不可覆盖的成员函数
    派生类中的成员函数不要与基类中不可覆盖的成员函数重名。

  • 把共用的接口、数据及操作放到继承树中尽可能高的位置

  • 只有一个实例的类是值得怀疑的(单例模式例外)

  • 只有一个派生类的基类也值得怀疑
    为未来要做的工作着手进行准备的最好方法,并不是去创建几层额外的、“没准以后哪天就能用得上的”基类,而是让眼下的工作成员尽可能地清晰、简单、直截了当。即,不要创建任何并非绝对必要的继承结构。

  • 派生后覆盖了某个子程序,但在其中没做任何操作,这种情况也值得怀疑

  • 避免让继承体系过深

  • 尽量使用多态,避免大量的类型检查

  • 让所有的数据都是 private(而非protected)
    然而 Objective-C 当中并没有 protected,并且只能使用类扩展表示主义上的私有。

继承的规则如此之多,从侧面反应继承往往会让你和程序员的首要技术使用(即管理复杂度)背道而驰。

组合优于继承。

关于何时该使用继承,何时又该使用包含(组合):

  • 如果多个类共享数据而非行为,应该创建这些类可以包含的共用对象。
  • 如果多个类共享行为而非数据,应该让它们从共同的基类继承而来,并在基类里定义共用的子程序。
  • 如果多个类即共享数据也共享行为,应该让它们从一个共同的基类继承而来,并在基类里定义共用的数据和子程序。
  • 当你想由基类控制接口时,使用继承;当你想自己控制接口时,使用包含。

成员函数和数据成员

下面是就有效地实现成员函数和数据成员的一些指导建议:

  • 让类中子程序的数量尽可能少
  • 禁止隐式地产生你不需要的成员函数和运算符
  • 减少类所调用的不同子程序的数量
  • 对其它类的子程序的间接调用要尽可能少
  • 一般来说,应尽量减小类和类之间相互合作的范围

构造方法

关于构造方法,前几天写过一篇理解 Objective-C 中的指定构造函数,关于 Objective-C 中的构造方法理解这些最佳实践应该就够了。

创建类的理由

创建类不止是为了现实世界中的实体建模,还有其它的合理原因:

  • 对抽象对象建模
  • 降低复杂度
  • 隔离复杂度
  • 隐藏实现细节
  • 限制变化所影响的范围
  • 隐藏全局数据
  • 让参数传递更顺畅
  • 创建中心控制点
  • 让代码更易于重用
  • 为程序族做计划
  • 把相关操作放到一起
  • 实现特定的重构

应该避免的类

尽管通常情况下类是有用的,但还是要避免一些陷阱:

  • 避免创建万能类(god class)
  • 消除无关紧要的类
  • 避免用动词命名的类
    只有行为而没有数据的类往往不是一个真正的类。

要点

书上提供的本章要点:

  • 类的接口应提供一致的抽象。很问题都是由于违背该原则而引起的。
  • 类的接口应隐藏一些信息——如某个系统接口、某项设计决策、或一些实现细节。
  • 包含往往比继承更为可取——除非你要对“是一个/is a”的关系建模。
  • 继承是一种有用的工具,但它会增加复杂度,这有违于软件的首要技术使命——管理复杂度。
  • 类是管理复杂度的首要工具。要在设计类时给予足够的关注,才能实现这一目标。