Day 05: 物件及资料结构、边界

物件将它们的资料隐藏在抽象层後方,然後将操纵这些资料的函式暴露在外。资料结构则将资料暴露在外,且未提供有意义的函式」

「它们不仅是对立的,且本质上也是互补的」

取自: Clean Code (p.107)

CH6: 物件及资料结构

资料抽象化 (Abstraction)

  • 我们看一个简单的例子:

    public interface Vehicle
    {
        public double GetGallonsOfGasoline();
    }
    
    // vs.
    
    public interface Vehicle
    {
        public double GetPercentFuelRemaining();
    }
    
  • 上述的例子中,後者隐藏了程序的实作细节,利用更为抽象化的词汇 "PercentFuel (燃料百分比)" 取代 "GallonsOfGasoline (加仑汽油)"

  • 如果从上述例子还无法深刻体会到封装所带来的好处,不彷再思考一个问题: 你会喜欢看到手机剩下的电量百分比(%),或是看到毫安培值(mAh)?

资料/物件的反对称性 (Anti-Symmetry)

  • 再看一个经典例子:

    public class 正方形 {
        ...
    }
    
    public class 长方形 {
        ...
    }
    
    public class 圆形 {
        ...
    }
    
    // Procedural Programming
    public class 几何图形 {
        public const double PI = 3.14;
    
        // 求面积
        public double getArea(Object shapre) {
            if (shape instanceof 正方形) {
                var s = (正方形) shape;
                return s.side * s.side;
            }
            else if (shape instanceof 长方形) {
                var r = (长方形) shape;
                return r.height * r.width;
            }
            else if (shape instanceof 圆形) {
                var c = (圆形) shape;
                return PI * c.radius * c.radius;
            }
        }
    }
    
  • 上述写法为 结构化(Procedural) 导向的程序设计方式

  • 思考1: 当我们想新增 getPerimeter(Object shape) 函式来求周长时,会影响什麽?

    • 所有的图形类别都不会受影响!
  • 思考2: 当我们新增了一个图形类别(e.g., 三角形),会影响什麽?

    • 必须改变类别里的所有函式来新增三角形的处理情境!
  • 现在我们采用 物件导向(OOP) 的程序设计方式改写上述例子

    public class 正方形 implements 几何图形 {
        public double getArea();
    }
    
    public class 长方形 implements 几何图形 {
        public double getArea();
    }
    
    public class 圆形 implements 几何图形 {
        public const double PI = 3.14;
        public double getArea();
    }
    
    // Object-Oriented Programming
    public abstract class 几何图形 {
        public double getArea();
    }
    
  • 这是物件导向的写法,这里的 getArea() 方法是多型(Polymorphism)的

  • 思考1: 当我们想新增 getPerimeter(Object shape) 函式来求周长时,会影响什麽?

    • 所有的图形类别都必须被修改!
  • 思考2: 当我们新增了一个图形类别(e.g., 三角形),会影响什麽?

    • 没有任何既有函式会受到影响!

谨记开头的定义,资料和物件不仅是对立、更是互补的

「因此,使用物件导向而感到困难的事物,在结构化里却比较容易;反之亦然」

  • 补充
    上述的例子比较了 Procedural-Oriented 和 Object-Oriented 程序设计,类似的概念也可以应用在 Functional Programming 与 OOP 的比较 [1]
    OOP vs. FP

The Law of Demeter

「模组不该知道『关於它所操纵物件的内部运作』」

  • 更详细地定义:对於一个类别 C 内的方法 f 只能呼叫以下的方法
    1. C
    2. 任何由 f 所产生的物件
    3. 任何当作参数传递给 f 的物件
    4. C 类别里实体变数所持有的物件
    • 白话文: 方法不该呼叫由任何函式所回传之物件
  • 反面例子
    final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
    
    很明显地,outputDir 知道 ctxt 物件含有 options, 而 options 又包含 absolutePath。这里有太多资讯被函式预先知道
    上述的程序码又称作 火车事故(Train Wreck) ,应该加以避免一连串的连续呼叫。最好的作法是将此类程序码分割成下列形式:
    Options opts = ctxt.getOptions();
    File scratchDir = opts.getScratchDir();
    final String outputDir = scratchDir.getAbsolutePath();
    

混合体

  • 作者称 「一半物件一半资料结构」 的 object 为混合体。千万避免产生此类混合体!
    它们拥有一些重要函式,又将私有变数公用化(Public)供外部取用。这将使得程序难以添加新函式,同时,也难以增加新资料

    「也代表作者根本不确定,它们是否需要函式或型态的保护」

资料传输物件 (Data Transfer Objects, DTO)

「最佳的资料结构形式,是一个类别里只有公用变数,没有任何函式」

  • 此类资料结构通常称为 资料传输物件 (DTO)
    补充: [2] 除了 DTO 之外,还有 BO、DAO、VO...等不同的资料传输物件,是很好的解耦方式。但使用上可能要注意过度设计(Over-Design)的议题 (e.g., 前端想多一个 Input 栏位存进 DB,这中间的过程至少需要经过 3 层的资料传输物件...)

    https://ithelp.ithome.com.tw/upload/images/20211113/20138643SVgxMSwhvg.png

小结

  • 物件与资料结构的适用情境
    • 「弹性的增加新资料型态」 => 物件 (Object)
    • 「弹性的增加新行为」 => 资料结构 (Struct)

「优秀的软件开发者能理解其个中原因,在不带有偏颇的情况下,选择最适合的方法来完成手中的工作」

取自: Clean Code (p.114)


CH8: 边界 (Boundary)

「有时候我们买下第三方软件套件,或使用开放原始码套件。不管为了哪种原因,我们都必须将这些外来的程序码整洁地整合到我们的程序码中」

取自: Clean Code (p.127)

使用第三方软件的程序码

  • 接下来以 java.util.Map 作为例子,这是一个提供 Hash 资料结构相关功能的介面
    假设我们需要将 Sensor (感测器) 存放进 HashMap 的资料结构中,最简单的写法如下

    // 宣告
    Map sensors = new HashMap()
    
    // 其他程序需要存取时...
    Sensor s = (Sensor) sensors.get(sensorId);
    

    上述写法的问题是,客户端 (Client) 程序必须负责把来自 Map 介面里的物件,手动转型成正确的资料型态。这并不是整洁好读的程序码

    接着我们透过泛型(Generics),改写成

    // 宣告
    Map<Sensor> sensors = new HashMap<Sensor>()
    
    // 其他程序需要存取时...
    Sensor s = sensors.get(sensorId);
    

    上述写法的可读性有显着改善,但仍然有个问题:Map 介面改变时,系统里会有很多地方也需要连带修正

    使用 Map 更整洁的作法如下

    public class Sensors
    {
        private Map _sensors = new HashMap()
    
        public Sensor getById(string id)
        {
            return (Sensor) sensors.get(id);
        }
        // ...
    }
    

    使用者无需关心实作细节是否使用泛型,因为边界上的介面 (Map) 被封装了。转型和型态管理都在 Sensors 类别内部处理了

「避免在公用 API 里回传介面,或将介面当作参数传递给 API」

Adapter Pattern: 切分「已知」和「未知」

  • 作者曾经是某无线电通讯系统的专案成员之一,里面有一个子系统 (Transmitter) 尚未被其他协作者设计出来
  • 为避免开发受阻,於是撰写了通讯的介面,并实作了 FakeTransmitter (类似 Mock Server) 类别,使开发能够继续。并透过 TransmitterAdapter 来封装其他团队尚未实作好的 Transmitter API
  • 未来,当 API 实作完成或升级时,唯一需要被修改的地方只有 Adapter
    物件配接器模式

小结

「在边界的程序码必须能清楚的分割,并定义预期的测试。避免让我们的程序过度使用第三方软件的特殊之处。最好是依靠 (Depend) 在可以控制的程序上,免得最後反倒受它控制」

「只在最少处引用第三方软件」

取自: Clean Code (p.135)

本章主要在说明该如何与第三方套件解耦,避免第三方软件的改变影响到本身的系统。相关概念也可以衍生到团队协作时,透过边界来切分已知和未知 (Adapter Pattern)。之後在 Clean Architecture 篇会从软件架构层面探讨「边界」


Reference

  1. FP vs OOP | For Dummies
  2. 彻底搞懂DAO,PO,BO,DTO,VO,DO
  3. 一篇文章讲清楚VO,BO,PO,DO,DTO的区别
  4. Adapter
  5. 配接器模式
  6. Clean Code 心得 – Chapter8 边界

<<:  DAY18-JAVA的抽象类别(1)

>>:  [Day5] 自我必备驱动力:以终为始

DAY16-Style Components

前言: 今天我们要来介绍React里很强大的一个工具!没错就是Style Components!废...

成为工具人应有的工具包-26 ShellExView

ShellExView 今天来认识的小工具是看 Shell 的(猜测 ShellExView She...

KSP 的实作方向

这系列的文章不会讲完全部 KSP 的实作,毕竟我也还正在实作中,不过实作的方向应该是跟前几篇讲的差不...

裸机Hyperviser之间比较

但市面上的裸机Hyperviser还有其他选择(ESXI, Proxmox VE…),为何独锺unR...

110/01 - 什麽!startActivityForResult 被标记弃用?

讲到硬体就会用到权限控制,然後一定会用onActivityResult和startActivityF...