Clean Code - 对象和数据结构

程序媛和她的猫

共 7202字,需浏览 15分钟

 · 2022-03-26

1、数据抽象

不曝露数据细节,更愿意以抽象形态表述数据

代码 1

public class Point{
    public double x;
    public double y;
}

代码 2

public interface Point{
    double getX();
    double getY();
    void setCartesian(double x, double y);
    
    double getR();
    double getTheta();
    void setPolar(double r, double theta);
}

上面两段代码都表示笛卡尔儿平面上的一个点,你觉得哪个代码更好些?

答案是代码 2,为什么代码 2 更好一些,原因有以下两个方面。

首先代码 1 没有封装。
代码 1 中的 x 和 y 是完全暴露的,任何人都可以直接访问和设置新的值。
代码 2 中加了一层封装,你只能通过get方法获取坐标值和set方法设置原子坐标值。

其次是具象与抽象。
代码 1 是具象的一个点,只能表示在直角坐标系中的一个点。
代码 2 是抽象的一个点,既可以表示直角坐标系中的一个点,也可表示极坐标系中的一个点。

2、数据、对象的反对称性(过程式代码和面向对象代码的反对称性)

我们用一个例子来说明这个规则的含义。

现在我们有一个需求,需要绘制三种几何图形,分别是正方形、长方形和圆形,并计算每个图形的面积。

代码 1 是过程式代码,每个形状类都是简单的数据结构,只负责存储数据,不具备行为。具体的计算行为放在了 Geometry 类中。

代码 1

public class Square{
    public Point topLeft;
    public double side;
}

public class Rectangle{
    public Point topLeft;
    public double width;
    public double height;
}

public class Circle{
    public Point center;
    public double radius;
}

public class Geometry{
    public final double PI = 3.14159265358;
    
    public double area(Object shape) throws NoSuchShapeException{
        if(shape instanceof Square){
            Square square = (Square)shape;
            return square.side * square.side;
        } else if (shape instanceof Rectangle){
            Rectangle rec = (Rectangle)shape;
            return rec.width * rec.hight;
        } else if (shape instanceof Circle){
            Circle cir = (Circle)shape;
            return PI * cir.radius * cir.radius;
        }
        throw new NoSuchShapeException();
    }
}

代码 2 是面向对象代码。每个形状类是一个对象,不仅存储数据,还包含其行为。这三个形状类都实现 Shape 接口,每种形状类都有各自的计算面积的方法。

代码 2

public interface Shape{
    double area();
}

public class Square implements Shape{
    private Point topLeft;
    private double side;
    
    public double area(){
        return side*side;
    }
}

public class Rectangle implements Shape{
    private Point topLeft;
    private double width;
    private double height;
    
    public double area(){
        return width * height;
    }
}

public class Circle implements Shape{
    private static final double PI = 3.14159265358
    
    private Point topLeft;
    private double radius;
    
    public double area{
        return PI * radius * radius;
    }
}

有人可能会说,既然 Java 是一门面向对象开发语言,那肯定代码 2 要优于代码 1,我们在平常的开发过程中,要多写代码 2 这种面向对象代码,少写代码 1 这种过程式代码。

但是!!!并不是这样的!实际情况是,在某种情况下,过程式代码优于面向对象代码,在另一种情况下,面向对象代码优于过程式代码。我们是选择过程式代码还是面向对象代码,需要根据具体情况具体分析

举个栗子!现在增加了一个需求,计算这三种图形的周长。如果是代码 1 的话,只需要给 Geometry 类添加一个 perimeter() 函数,三个形状类不会受到任何影响。如果是代码 2 的话,需要给每个形状类都添加一个 perimeter() 函数。

现在增加另外一个需求,需要添加一个新形状。如果是代码 2 的话,只需要添加一个新的形状类,既有的形状类不会受到任何影响。如果是代码 1 的话,Geometry 类的每个函数都要修改,增加一个 if else 分支。

所以,我们得出以下结论:
过程式代码便于在不改动既有数据结构的前提下添加新的函数,而面向对象代码便于在不改动既有函数的前提下添加新类

所以,对于面向对象较难的事,对于过程式代码却较容易,反之亦然!这就是过程式代码和面向对象代码的反对称性由于数据结构是过程式代码的基本单元,对象是面向对象代码的基本单元,所以,这也可以说是数据和对象的反对称性

在任何复杂系统,都会有需要添加新数据类型而不是新函数的时候,这时,对象和面向对象就比较适合。另一方面,也会有想要添加新函数而不是数据类型的时候,在这种情况下,数据结构和过程式代码更合适

3、得墨忒耳定律

著名的得墨忒耳定律认为,模块不应该了解它所操作对象的内部情形

即类  C  的方法  f  只应该调用以下对象的方法:

  • C
public class C{
  public void f1(){
    f2();
  }
  
  public void f2(){
  
  }
}
  • 由  f  创建的对象
public class C1{
  public void f(){
    C2 c2 = new C2();
    c2.f();
  }
}

public class C2{
  public void f(){
    /*...*/
  }
}
  • 作为参数传给  f  的对象
public class C1{
  public void f(C2 c2){
    c2.f();
  }
}

public class C2{
  public void f(){
    /*...*/
  }
}
  • 由  C  的实体变量持有的对象
public class C1{
  private List names = new ArrayList();
  
  public void f(String name){
    names.add(name);
  }
}

方法不应调用由任何函数返回的对象的方法。

下列代码违反了得墨忒耳定律,因为它调用了 getOptions() 函数返回的对象的 getScratchDir() 函数,又调用了 getScratchDir() 函数返回的对象的 getAbsolutePath() 函数。

final String outputDir = ctxt.getOptions().getScartchDir().getAbsolutePath();

这类代码常被称作火车失事,因为涉及到多个函数的级联调用,一旦出了问题,不知问题出在哪。最好做类似如下的切分:

Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();

优化后的代码是否违反了得墨忒耳定律呢?

这要看 ctxt、Options、ScratchDir 是对象还是数据结构,如果是对象,就违反了得墨忒耳定律,因为模块知道它要操作的所有对象的内部情形(为什么这么说呢?看优化后的代码,模块知道 ctxt 对象包含有多个选项,每个选项都有一个临时目录,而每个临时目录都有一个绝对路径。模块对于它要操作的这三个对象,每个对象内部有啥,了解地清清楚楚)

如果是数据结构,由于数据结构只包含数据,没有什么行为,则他们自然会暴露其内部数据结构,得墨忒耳定律也就失效了。

如果是数据结构,就应该这样写代码:

final String outputDir = ctxt.options.scratchDir.absolutePath;

如果是对象,这段代码违反了得墨忒耳定律,那如何优化让其不违反这个定律呢?我们可以将这段代码的逻辑全部抽取到 ctxt 的某个函数中,此时 ctxt 隐藏了内部结构,防止当前函数因浏览它不该知道的对象而违反得墨忒耳定律

BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

4、数据传送对象

DTO(数据传送对象)是一种只有公共变量,没有函数(这个函数是指除 get、set 之外的函数)的类。常见的数据传送对象还有 Bean,这种结构有赋值器和取值器操作私有变量。

见下面的示例 1 和示例 2 ,二者都是 DTO,区别就是示例 1 只有数据,由于数据权限是 public ,我们可以直接读取数据或者给数据赋值,示例 2 使用 private 隐藏数据,然后使用取值器 get 和赋值器 set 操作这些私有变量。

示例 1

public class Address{
  public String street;
  public String city;
  public String state;
  public String province;
  
  public Address(String street, String city, String state, String province){
    this.street = street;
    this.city = city;
    this.state = state;
    this.province = province;
  }
}

示例 2

public class Address{
    private String street;
    private String city;
    private String state;
    private String province;
    
    public Address(String street, String city, String state, String province){
        this.street = street;
        this.city = city;
        this.state = state;
        this.province = province;
    }
    
    public void setStreet(String street){
        this.street = street;
    }
    
    public String getStreet(){
        return stree;
    }
    
    public void setCity(String city){
        this.city = city;
    }
    
    public String getCity(){
        return city;
    }
    
    public void setState(String state){
        this.state = state;
    }
    
    public String getState(){
        return state;
    }
    
    public void setProvince(String province){
        this.province = province;
    }
    
    public String getProvince(){
        return province;
    }
}

DTO 是非常有用的结构,尤其是在与数据库通信,或解析套接字传递信息之类的场景之中。

数据传送对象应该是简单的数据结构,不应该包含业务逻辑如果对象有较多需要处理的业务逻辑,应当新建类来包含业务逻辑、隐藏内部数据进行处理。

举个栗子来解释一下,对于 Address 我们有查找地址 find() 和保存地址 save() 的需求,如果把这两个业务逻辑写入 Address 类中,Address 就不能说是一个数据传送对象了。

反例

public class Address{
    private String street;
    private String city;
    private String state;
    private String province;
    
    public Address(String street, String city, String state, String province){
        this.street = street;
        this.city = city;
        this.state = state;
        this.province = province;
    }
    
    // 所有属性的 set、get 方法
    
    public Address find(){
    /*...*/
    return address;
  }
  
  public void set(String street, String city, String state, String province){
    Address address = new Address();
    address.setStreet(street);
    address.setCity(city);
    address.setState(state);
    address.setProvince(province);
  }
}

想要保证 Address 是一个数据传送对象,那么这两个业务逻辑就不应该写到 Address 类中,我们可以这样操作,Adderss 类依旧保持上述示例 2 的样子,然后创建一个新的类,在这个新的类中编写这两个业务逻辑。

正例

public class AddressOperator{
  public Address find(){
    /*...*/
    return address;
  }
  
  public void set(String street, String city, String state, String province){
    Address address = new Address();
    address.setStreet(street);
    address.setCity(city);
    address.setState(state);
    address.setProvince(province);
  }
}

5、小结

对象曝露行为,隐藏数据。便于添加新对象类型而无需修改既有行为,同时也难以在既有对象中添加新的行为。
数据结构曝露数据,没有明显的行为,便于向既有数据结构添加新的行为,同时也难以向既有函数添加新的数据结构。

在任何系统中,我们有时会希望能够灵活地添加新数据类型,所以更喜欢在这部分使用对象和面向对象代码。另外一些时候,我们希望能灵活地添加新行为,这时我们更喜欢使用数据结构和过程式代码。优秀的软件开发者能够根据手边工作的性质灵活地选择其中一种手段。


浏览 68
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报