Clean Code 第六章:对象和数据结构 --阅读与讨论

尼克徐 发布于 2015年11月25日
无人欣赏。

alt text

图片来源:Objects and Data Structures

一,数据抽象

将变量设置为私有(Private),主要是不想让其他人依赖这些变量。

所以,不要随便给变量添加赋值方法和取值方法(set/get方法),这样其实是把私有变量公之于众。

隐藏变量和实现,并不是在变量与外界之间放一个函数层那么简单。隐藏关乎抽象。

类并不简单地用赋值方法和取值方法将其变量推向外间,而是暴露抽象接口,以便用户无需了解数据的实现而能操作数据本体。

要以什么方式呈现对象所包含的数据,需要做严肃的思考。随便加赋值方法和取值方法,是最坏的选择。

二,数据、对象的反对称性

有两种表示和操作数据的方式,分别是:

面向对象方式,把数据隐藏在抽象之后,曝露其操作数据的函数。

面向过程方式,只曝露其数据,没有提供有意义的函数(数据结构方面,请类比于使用C语言的struct类)。

这两种定义,它们是对立的,差异虽微小,但却有深远的含义。

比如以下代码,就是面向过程式的:

public class Square { 
    public Point topLeft; public double side;
}
public class Rectangle { 
    public Point topLeft; public double height; 
    public double width;
}
public class Circle { 
    public Point center; 
    public double radius;
}
public class Geometry {
    public final double PI = 3.141592653589793;
    public double area(Object shape) throws NoSuchShapeException {
        if (shape instanceof Square) { 
            Square s = (Square)shape; 
            return s.side * s.side;
        } else if (shape instanceof Rectangle) { 
            Rectangle r = (Rectangle)shape; 
            return r.height * r.width;
        } else if (shape instanceof Circle) {
            Circle c = (Circle)shape;
            return PI * c.radius * c.radius; 
        }
        throw new NoSuchShapeException(); 
    }
}

以上的面向对象的代码,如果给Geometry类添加一个primeter()函数,其他的形状类都不受影响!

如果添加一个形状类,则需要修改Geometry类里所有的函数来处理它。

以下代码,是面向对象式的,

面向对象式的代码,如果添加一个新的形状类,则现有的函数一个也不会受到影响,但是添加一个新函数时(在接口Shape类里),所有的形状类都需要修改!

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 height;
    private double width;
    public double area() { 
        return height * width;
    } 
}
public class Circle implements Shape { 
    private Point center;
    private double radius;
    public final double PI = 3.141592653589793;
    public double area() {
        return PI * radius * radius;
    } 
}

这里我们看到了这两种定义的本质,它们是截然对立的,这说明了面向对象和面向过程的本质区别:

面向过程的代码,便于不改动既有数据结构(struct)的前提下,添加新函数。

面向对象的代码,便于不改动既有函数的前提下,添加新类。

反过来讲也说得通:

过程式的代码难以添加新的数据结构,因为必须修改所有的函数。

面向对象的代码难以添加新函数,因为必须修改所有类。

所以,大体上,对面向对象较难的事情,对于面向过程却较容易,反之亦然。

在任何一个复杂系统种,都会有添加新数据类型而不是添加新函数的时候,这时,面向对象就很适合。

另一方面,也有添加新函数而不是数据类型的时候,此时面向过程代码和数据结构更合适。

三,得墨忒耳定律

得墨忒耳定律(The Law of Demeter)认为,模块不应了解它所操作对象的内部情形。

对象隐藏数据,曝露操作,这意味着对象不应通过存取器曝露其内部数据结构,因这更象曝露而非隐藏其内部结构。

更准确地说,得墨忒耳定律认为,类C的方法f,只能调用以下的方法:

C内部的方法
由f创建的对象的方法
作为参数传递给f的对象的方法
由C的实体变量持有的对象的方法

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

换言之,只跟朋友谈话,不与陌生人谈话。

以下代码违反了得墨忒耳定律,因为它调用了getOption()返回的对象里的getScratchDir()函数,又调用了getScratchDir()返回值的getAbsolutePath()方法。

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

这类代码通常被称作“火车失事”,因为它看起来就象一列火车。

这种串联调用通常被认为是肮脏的风格并应该避免。

改成以下代码,效果会好些:

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

但对于一个函数来说,里面调用的方法包括这么多对象的引用,它“知道”的太多了。

如果是这样的代码,就不会有对得墨忒耳定律的违反,

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

另外,也可以封装一个方法,象这样:

ctxt.getAbsolutePathOfScratchDirectoryOption();

另外我们还要看,需要这个绝对路径的目的是什么,比如,如果该代码的目的,是创建一个文件:

String outFile = outputDir + "/" + className.replace('.', '/') + ".class"; 
FileOutputStream fout = new FileOutputStream(outFile); 
BufferedOutputStream bos = new BufferedOutputStream(fout);

那么我们干脆让ctxt对象来做这件事情:

BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

看起来就好多了。

四,混杂风格

有时候看类的定义,又曝露属性读写方法,又有方法定义,这是一半面向对象,一半面向过程的结构。

无论出于什么样的初衷,公共的属性读写方法,都会诱导外部函数修改属性,并以面向过程的方式使用该类。

这样的类,增加了添加新函数的难度,也增加了添加新数据结构的难度,两面不讨好。

五,数据传送对象

面向过程的数据结构,是只有属性和存取方法,没有函数的类。这种数据结构有时被称之为“数据传送对象”,简称DTO(Data Transfer Objects)。

在与数据库通信,或解析Socket信息时,经常使用它。

public class Address { 
    private String street; 
    private String streetExtra; 
    private String city; 
    private String state; 
    private String zip;
    public Address(String street, String streetExtra, String city, String state, String zip) {
        this.street = street; 
        this.streetExtra = streetExtra; 
        this.city = city;
        this.state = state;
        this.zip = zip;
    }
    public String getStreet() { 
        return street;
    }
    public String getStreetExtra() { 
        return streetExtra;
    }
    public String getCity() { 
        return city;
    }
    public String getState() { 
        return state;
    }
    public String getZip() { 
        return zip;
    } 
}

讨论问题:

1, 你同意本文关于面向对象和面向过程的代码的看法吗?如果有分歧,你的看法是什么?

如何加入Root Cause读书会:请加我的微信brainstudio,我建立了一个群,方便通知各位写读后感及讨论。

Clean Code所有讨论章节链接

共3条回复
rompage 回复于 2015年11月26日

图形类的举例不大恰当,shape应该是接口的形式,只提供一个area的纯虚函数才是。 anyway,相当赞的文章!👍

nkduqi 回复于 2015年11月26日

面向对象:当需要再接口中增加函数时,说明之前的设计出了问题;我基本同意文中的观点,但是还想强调另外一点,即面向对象更重要的是“信息隐藏”,将变化影响的范围尽可能缩小。

文中的例子提到,如果给Shape接口里加函数很难,但是另一方面接口的特性会强制(编译器会提示)我们改掉所有实现了这个接口的类,但是如果是面向过程的写法,在Geometry类里少写个方法编译器也不会报错。总的来说,这可能是语言特性的区别,好的语言会引导和监督使用者写出优秀的代码。

cnsoft 回复于 2015年11月29日

其实系统领域里 对象有自己固有的模型和结构. 有时侯不是去设计. 而是去分析. 然后根据功能进行选择. 因为如果不那样写, 就会很蹩脚.

登录 或者 注册