Java 有哪些不好的设计?

转载出处:https://www.zhihu.com/question/25372706/answer/2260404942

我来说说OOP吧

刚学 Java 的时候,我觉得面向对象编程(OOP)真牛逼,用面向对象方式写出来的代码是最好的代码。但是随着项目越做越多,代码越写越多,我发现 OOP 不是万能的,盲目的迷信追求 OOP 会有代价。

今天这篇文章我不是说面向对象不好,只是希望大家不要过度神话它,更不要人云亦云。

大家都听说过

但其实这个说法有问题。面向对象的思想里没有任何继承和多态的概念,正确的说法是:

这三大特性是面向对象语言的特性,而不是面向对象理念本身的。

面向对象语言是面向对象设计思想的一种实现,面向对象语言为了能在真实世界使用,其必须经过一些拓展和妥协,而问题也就随着这些拓展和妥协而来。

1. 继承带来的也可能是无以复加的痛苦

在实际开发中,我们无论谁写代码,都要考虑代码的复用性。面向对象的编程语言作为给开发人员使用的工具,它也必须考虑到复用性。

所以,在面向对象编程语言里,对面向对象的基础思想做了拓展,搞出了继承这个概念。

继承就具体实现来说,就是子类拥有父类的所有非 private 的属性和方法。继承的出现能够最大化的代码复用。

当项目里一个类已经有了我们需要的属性和方法,而我们现在的需求只是在这个已有类的基础上有些许的不同,我们只需要继承这个类,仅把这少许的不同在子类中实现即可。

但是如果你用了继承,你就引入了问题。

继承的出现天然会使得子类和父类紧耦合。也就是说,父类和子类是紧密关联的,牵一发动全身。

如果现实世界里,所有业务模型都是有层次的,而且层次井然有序,是一颗天然的树,那这种紧耦合没有什么问题。

但是现实的需求可不是吃干饭的!

咱们看看这样一种情况。假设现在我们一家只有两口人,即只有父亲和孩子,那么类继承模型很容易模拟这种情况:
在这里插入图片描述
我们在现实生活里,往往是三口之家:
在这里插入图片描述
那这就有问题了。就像小时候经常有人会问孩子,你觉得你是爸爸的孩子,还是妈妈的孩子啊?如果你要用 Java 的规矩回答,只能从是爸爸或者妈妈里选一个,那么完蛋了。回答爸爸的孩子,妈妈不高兴;回答妈妈的孩子,问题更严重,隔壁老王?

但是,如果像 C++ 那样,你说我既是爸爸的孩子也是妈妈的孩子,也有问题。

假设爸爸类里有个方法叫说话,妈妈类也有个方法叫说话,你作为继承了他们的孩子类,自然也会拥有说话这个方法。问题来了,你所拥有的的说话这个方法到底来源于谁?

另外咱们说了,继承会把子类和父类紧耦合,一旦业务模型失配,就会造成问题。

这里给出一个维基百科举的经典例子,来说明一下:

class Super {

  private int counter = 0;

  void inc1() {
    counter++;
  }

  void inc2() {
    counter++;
  }

}

class Sub extends Super {

  @Override
  void inc2() {
    inc1();
  }

}

你看,子类覆盖了父类的 inc2 方法,但是这个 inc2 方法依赖于父类 inc1 的实现。

如果父类的 inc1 逻辑发生变化了,变成下面这样

class Super {

  private int counter = 0;

  void inc1() {
    inc2();
  }

  void inc2() {
    counter++;
  }
}

这就会出现 stack overflow 的异常,因为出现了无限递归。

所以,当我们在子类里,依赖了父类方法作为子类业务逻辑的一个关键步骤的时候,当父类的逻辑修改的时候,必须联动修改所有依赖父类相关逻辑的子类,否则就可能引发严重的问题。

用继承,本来是想少写点代码少加点班,结果……用网上看到的一句话说就是:

一日为父,终生是祖宗。

像这种情况该怎么办?

现在只要是个正经的介绍面向对象的技术文章或者书籍里,只要是涉及到继承的,都会加这么句话:

尽量选择对象组合的设计方式。

在《阿里巴巴Java开发手册》中就有一条:
在这里插入图片描述
组合和继承的区别如下:
在这里插入图片描述
其实我认为继承和组合各有优缺点,如果两个类确实非常紧密,就是存在层次关系,用继承没问题。

之所以有“组合优于继承”这个说法,我个人感觉是组合更灵活,而且能防止被人滥用,用不好的话轻则类的层次失控,重则很可能就把整个项目的代码质量给腐蚀了。

2. 封装如同带有漏洞的封印,可能会逃逸出魔王

封装,说白了就是把属性、方法,封到一个对象里,这是面向对象的核心理念。

嘴上叫封装,却开了个缝儿。

我们知道,项目是既要兼顾代码质量,还要兼顾运行性能的。不可能说为了提升什么松耦合、高内聚,就不管不顾性能了。

事情就坏在了这个兼顾性能这里。面向对象里,以上帝角度看,系统就是对象和对象之间的关系构造成的网络。

就拿咱们上面谈到的组合关系来说,组合关系的实现就是通过把一个对象当成另一个对象的属性来实现的。

在这里插入图片描述
上面这图就叫做 A 和 B 之间是组合关系。想用 A 对象里的 B 对象,代码这么写:

A a = new A();
B b = a.getB();

好,我们要问了,这个从 A 中获取的 B,是 B 对象的实例还是实例的一个引用指针呢?

必然是引用指针吧,这是最基础的知识。诺,问题来了,引用指针是可以修改的。

b.getS(); //原来是Hello World
b.setS("World");//直接改成World

原来 B 中有个字段 s,值是个 “Hello World”,我直接可以用代码改成“World”。

如果这次修改随意在个犄角旮旯里,A 能知道吗?A 蒙在鼓里,还以为一切尽在把控当中呢。

你看,封装的缝儿出来了吧。说句实话,就这种鬼操作,是非常难以排查的。

像这种封装了,但是又没封装的问题,我只想说“封装的挺好的,下次别封装了”。

3. 多态好,但可能是面向对象的贪天之功

再说说多态。

其实,面向对象中的多态使用,才是面向对象语言最被认可的地方。因为有了多态,代码才能保证在业务需求多变的情况下,保证了项目的相对稳定。

可是,多态不是面向对象独有的啊。面向过程,函数式编程也可以:面向过程里,C 语言可以靠虚函数去在运行时加载对应的函数实现去实现多态。函数式编程也可以通过组合函数去实现多态。

所以,面向对象连多态这种优势都不独特了。

4. 服务端业务变了,人们的观点发生变化了

在说服务端业务的变化之前,我想先普及两个概念,即有状态的服务和无状态的服务。

有状态的服务就是说,服务需要暂时存一些和客户端相关的数据,以便客户端后续发来的请求可以和客户端前面发的请求通过服务器端关联起来,从而共同完成一项业务。

无状态服务是说,服务端不存储任何和客户端相关的数据,客户端每次请求,服务端都认为这是个新客户端,和以前的请求无任何关系。

用现实生活举例的话,有状态服务就是你去一家健身房,第一次去的时候花了一笔钱办了一张健身卡,你以后每次去健身,有卡就不用再掏钱了。

无状态服务就是,你没办卡,每次去都和第一次去一样现掏钱。

那么,无状态服务和有状态服务和面向对象的衰落又有什么关系呢?在如今的年代,分布式、微服务大行其道。一个有状态的服务是不容易做分布式和做弹性伸缩的。

当年,大家做有多个步骤的业务的时候,为了保证业务数据不会因为用户偶然的关闭浏览器或者浏览器崩溃等问题而丢失,往往会把上一个步骤的信息存在服务端的 session 里,而现在则会倾向考虑把信息放在客户端的本地存储上。

我举个例子,假设现在有个需求,要在后台系统新增加一个功能:用户信息管理。其中有个需求要求这样操作,录入用户信息分成两步。

  • 第一步,录入用户的基本信息:姓名、手机号、年龄……
  • 第二步,录入额外信息:家庭成员、教育经历、工作经历……

出于信息完整度的考虑,业务要求这两步应该是一个完整的事务。要么都成功,要么都失败。

从技术实现上讲,如果是多年以前,我们会在第一步的时候,把商户的基本信息做成表单提交,然后为了保证不会因为用户误关闭浏览器等意外问题丢失中间的数据,保存在对应的 session 中后,在第二步信息提交后,合并起来一起存入到数据库中。

但是,现在的技术趋势是,做任何事情,尽量让服务器端无状态,也就是不存储客户端相关数据。

此时,这个需求的解决方案就是,当第一步填写商户信息完成后,直接把数据存储在客户端的本地存储里又或者直接就存在 cookie 里,在第二步填写内容完毕后,联合存在客户端的信息一起提交到服务器端,然后存入数据库。

所以,你看到了,现在大家的趋势就是服务器端都在转向无状态服务,哪怕以前是有状态的服务,也会通过一些增加客户端参数等手段,去改造为无状态服务。

说了这么多,那这种技术趋势的变化对我们的面向对象有什么影响呢?

影响在于,服务端现在越来越变得往单纯的处理数据这个方向发展。当仅处理数据的时候,服务器端真正的需求其实就是计算,然后就是为了大幅度提升计算速度,而带来的并行化需求。

而面向对象这种方式和我们当今的技术趋势是有一些冲突的。

首先就是确定性的冲突。

我们的首要需求从以前重度处理业务状态加业务数据变成了业务数据的计算,而计算是需要确定性的:即给定相同的输入,经过服务器端相同的逻辑处理后,应该给定相同的输出。

而面向对象这种方式,出身在有状态服务大行其道的年代,它会优先考虑业务逻辑的调度,其次才是计算,所以,面向对象是拥有状态的。面向对象的状态就是它的字段值。这些字段值,如果单纯的从计算数据角度看,他们不仅无意义了,反而还引入了风险。

比如,我们不小心把一个对象的状态给共享出去了,那当我们用同样的输入计算的时候,很可能由于状态的变化,导致了不同的输出结果,最后就是项目出了问题。

其次,由于计算我们对性能更加看重了,又由于无状态服务的大量使用,所以,并行的重要性也远远超出了以前。而并行,要求的是结构的开放,和更加严格的无状态化,而面向对象,恰恰严重依赖于状态,并且,他还把这种状态依赖封装在了复杂的对象关系里。

A 状态依赖于 B 的状态,B 的状态又依赖于 C,而这些依赖,全部被封装在了 D 对象的实现细节里,这种严重的反并行也是现在越来越多人开始反感面向对象的重要原因。

结尾

说了这么多面向对象的坏话,其实真的是面向对象自身的问题吗?并不是。

面向对象编程语言只是一种工具,工具的使用的好坏还是要靠人的,不可能每个人能把一套工具用的完美无缺。

我们不能一味的依靠面向对象,认为面向对象就是最棒的,也不能发现面向对象可能应付不了某些业务场景了,就开始极端地摒弃它。

我们要灵活地,合理地使用任何我们可以使用的编程思想、编程工具,积极地去拥抱变化。

Java在8之前没有trait/mixin这样子的默认接口实现–这点相较于Scala和Ruby是设计不足的地方吧。**在8的时候加的default,**就是为了让接口能提供一个默认实现。

Java的泛型不支持基本类型,需要基本类型的话就要装箱拆箱,空间跟性能都比直接用基本类型糟。Java也不支持struct–这点相较于C#也算是不足的地方吧。struct好的地方在于,你能够直接从栈中获取数据,而不需要再到堆中去找。

觉得Java比较糟的是泛型,为了向前兼容,在编译时做检查,使用类型擦除来实现泛型。在运行无法获取类型信息,所以要创建泛型对象只能传入类对象,再newInstance()。要泛型数组就要new 其他基类对象再强转。

Java的日期接口设计得很糟糕,这点都说了。

Java的集合接口设计很糟糕。宽接口在Collocation中随处可见。ArrayList跟LinkedList,还有Stack。原本不该有的一些操作,在这些集合中都有了。

Java编程思想里面也有讲到Java设计不好的地方,看了之后再做评价如何?

非常多。

昨天正好跟同事聊到了 JDK 其中几个点,这里说一下。值得事先声明的是,我将讨论的是 JDK 的缺陷,语言语法层面的缺陷是另一个角度的问题。

一、Optional 默认是不可序列化的。

我们看一下 Optional 类的代码

public final class Optional<T> {
  private final T value;
}

这里的 T 是没有任何限制的,意即可以为任意的类型。好了,我们知道 Java 默认的序列化机制需要显示标识实现 Serializable 接口,否则在序列化时就会报错。这样的 Optional 定义会导致任何带有 Optional 字段的类在 Optional 字段取值不为 null 的时候无法序列化。

例如下面的代码会抛出不可序列化异常

public class MyObject implements Serializable {

    private Optional<String> s;

    public static void main(String[] args) throws Exception {
        final MyObject o = new MyObject();
        o.s = Optional.empty();

        try (ObjectOutputStream oos = new ObjectOutputStream(new ByteArrayOutputStream())) {
            oos.writeObject(o); // java.io.NotSerializableException: java.util.Optional
        }
    }

}

这就导致在持久化对象或者在现在分布式场景下传输对象的时候,我们只能标注类的字段为 @Nullable 来提示使用方处理 null 的场景,而完全失去了 Optional 本身强制检查的意义和该类所提供的 map & flatMap 等方便的方法。

即使在 Optional 文档所说的作为方法返回值类型的场景,一旦涉及远程方法调用,返回值传输也是一个问题。因此,不少框架都定义了自己的可序列化的 Optional 类型或者拥抱 Scala 的 Option 类型。

二、Future 只能通过 get 来阻塞获取

这个问题在 Java 8 里倒是稍微解决了一点,有了 CompletableFuture 之后好歹串接 Future 的计算可以舒服一点。在没有 CompletableFuture 的黑暗日子里,Java 的 Future 只能通过在某个线程里阻塞的 get 来等待结果。

这就导致想要获取 Future 的开发者都要手动管理一个用于阻塞获取 Future 结果的线程或者 Executor 对象。否则,在当前线程阻塞获取就失去了一切 Future 并发的意义。那么问题来了,我用 Future 就是为了快速组合异步结果,挂载异步动作,为什么底下线程调度的细节我还要亲自去管理呢?这样把底层的细节暴露出来强制用户去编写代码,真的好吗?

不出意外的,各大框架和工具箱在 CompletableFuture 之前又定义了自己的 Future 类型,例如 Netty 的 ChannelFuture 类型,Guava 的 ListenableFuture 等等,或者拥抱 Scala 的 Future 类型。

相比之下,Scala 的 Future 类型在 Try 类型的加持下有比较完整的 API 列表,而 Java 的 CompletableFuture 在第一版的时候甚至只实现了构造成功的实例,而不能构造失败的实例,直到 Java 9 才被补齐,并且在 Java 12 中才补齐了失败情况下的 bind 函数即 exceptionallyCompose 方法。同时,缺少 Try 类型的支持,在同时处理成功 & 失败的场景时,只能采用类似 Go 语言的双值取一的方式来区分,这样的方式是缺少类型安全的保障的。

三、函数式接口大部分不可抛出受检异常

这个也是常见的蛋疼问题了。我们知道 Java 是通过函数式接口来实现名词借代为动词的,常见的接口包括 Runnable 和 Callable 以及 Java 8 的 Function & Consumer 等等。这里面,除了 Callable 可以抛出任意的受检异常意外,所有的接口的唯一方法都是不允许抛出异常的。

这就很麻烦了,比方说你有一个接受这些函数式接口的方法

void myLovelyFn(Function<?, ?> fn);

在你尝试调用这个方法的时候,如果 fn 是一个方法引用而且它被标注会抛出受检异常,你是不能像这样写的

// OUT fn(IN in) throws Exception;
myLovelyFn(someObj::fn);

而是必须手动捕获异常并转换为非受检异常或者处理

myLovelyFn(in -> {
  try {
    return someObj.fn(in);
  } catch (Exception e) {
    throw new RuntimeException(e);
    // or do something to heal it
  }
});

当然,王垠之流的人会告诉你,这就是受检异常的意义,你必须显式的检查并处理它。可是好多遗留代码,就是瞎写的方法签名,还有一堆设计糟糕的 JDK 方法或者依赖的三方库方法,你拿到所谓的受检异常,根本啥也做不了,只能往上抛,在某些关键节点打点日志了事。

所以从注重实效的角度来讲,这样强制检查的背景下,这些函数式接口的方法定义居然不允许抛出受检异常,真的导致不少接口使用起来要带上 try-catch 块,每写一次,痛苦一分。

不出意外的,框架们又提供了工具来绕过这个限制。例如 hazelcast 项目的 FunctionEx 类型

@FunctionalInterface
public interface FunctionEx<T, R> extends Function<T, R>, Serializable {

    /**
     * Exception-declaring version of {@link Function#apply}.
     * @throws Exception in case of any exceptional case
     */
    R applyEx(T t) throws Exception;

    @Override
    default R apply(T t) {
        try {
            return applyEx(t);
        } catch (Exception e) {
            throw ExceptionUtil.sneakyThrow(e);
        }
    }

    // ...
}

hazelcast 是一个分布式的存储框架,因此它还实现了 Serializable 接口,以使得动作能够在不同的进程之间序列化传递。

这样在 JDK 中缺失的支持,每个框架实现一遍,且不说实现各不相同,就是相同,类型全限定名也不一样。因此这些实现大多数还是框架内部使用,一旦开放为 API 接口,实际业务开发过程中,各个框架之间的互操作也将成为挥之不去的噩梦。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值