对象设计要考虑有效范围

原文发表于2014-09-13。

现代对象设计主张“组合优于继承”。总之无论组合还是继承,对象都成了涉及多个类的复合结构。

“对象的有效范围”,是指对象从创建到丢弃(不再引用)的这段时间,不包括等待被GC销毁的时间。可以近似认为是对象的生命期。

单例对象(Singleton)的有效范围几乎是整个应用的开启时间,Socket的有效范围通常是网络连接的持续时间,而一个临时的Integer则可能瞬间就被丢弃了。Let's 注意,不同范围的对象/类,不能随意地组合/继承在一起。

1. 不同范围的对象避免打包在一起

(代码有点多,如果嫌烦可以跳过1.先看2.)

反面教材:我们来看一个客户端程序,它通过socket与某个服务器保持通信,不断发消息并收取响应。

public class Communication {
  private Topic topic;
  private Socket socket;

  public Communication(String host, int port) {
    socket = new Socket(host, port);
  }
  public void close() {
    socket.close();
  }
  public void setTopic(Topic topic) {
    this.topic = topic;
  }
  public String sendReceive(String msg) {
    return sendRecv_(topic, msg);
  }
  private String sendRecv_(Topic topic, String msg) {
    ... // 具体处理
  }
}

我们有两个主题(topic) A和B,以不同主题发的消息,服务器会做不同处理。我们一会儿用主题A发消息,一会用主题B发消息。代码如下:

Communication comm = new Communication(host, port);
comm.setTopic(A);
comm.sendReceive("Hello!");
comm.sendReceive("How are you?");

comm.setTopic(B);
comm.sendReceive("Good morning!");
comm.sendReceive("Let's begin");

comm.sendTopic(A);
comm.sendReceive("How old are you?");

切换来切换去,真麻烦。如果你不嫌麻烦,假设给Communication再加一个域"config",平均每发100条消息,要切换一次config,平均每发10条消息,要切换一次topic,还是交替进行,烦不烦!

再想想,如果多个线程在使用comm对象呢? 呵呵呵,完蛋了。

Communication的有效范围与socket一致,而topic的有效范围就小于socket了,因此topic就不该放在这个类里。虽然sendReceive()可以少填一个参数,看似方便,但是引发了更多麻烦。
对于继承也是同理,父类和子类应当有相同的有效范围。

所以还是这么写吧:

comm.sendReceive(A, "Hello!");
comm.sendReceive(A, "How are you?");
comm.sendReceive(B, "Good morning!");

稍微有点麻烦呢

或者这么写:

class CommByTopic {
  private Communication comm;
  private Topic topic;
  // 构造函数省略
  public String sendReceive(String msg) {
    return comm.sendReceive(topic, msg);
  }
}

CommByTopic onA = new CommByTopic(comm, A);
onA.sendReceive(msg);
onB.sendReceive(msg);

缺点是comm关闭后要注意不能继续使用onA。所以不要长时间持有onA对象,最好能局限在方法作用域内。

或者试试简洁的lamda~

Lamda in Java 8:

Function<String, String> onA = msg -> comm.sendReceive(A, msg);
onA.apply("Hello!");
onA.apply("How are you?");

Lamda in Scala:

val onA: String => String = comm.sendReceive(A, _)
onA("Hello!")
onA("How are you?")

// 柯里化的写法 val onA = comm.sendReceive(A) _

2. 大范围对象不要持有小范围对象

上面说的comm就是大范围对象,socket也是大范围对象,topic是小范围对象。它们生命长短不同。

如果大范围对象持有了小范围对象,你就要疲于切换,甚至担心线程安全性。反过来,小范围对象持有大范围对象,就好了。当然了,持有相同范围的对象也是好的。

对运行于IoC容器的程序尤其明显。来溜一段基于Spring MVC的应用代码:

@Component
@Scope("singleton") //单例对象
public class Manager {
  @Autowired
  private Account account;

  public void freezeAccount() {
    account.freeze();
    merge(account);
  }
}

@Component
@Scope("request") //request范围的对象
public class Account {
  ...
}

这样的代码在系统启动时就崩了,因为account还没出现。就算你给Manager标上@Lazy (延迟初始化),让它在账户A发来请求时才初始化,它也只能正确处理这次的请求。下次账户B再来请求时,它还是使用上次的A的account来操作,而不会用B的account。呵呵呵,完蛋了。
同理,session级的对象不要持有request级的对象。

对于Servlet和Filter也是如此,它们是近似于单例的对象,让它们持有一些配置数据和常量就行了,如果让它们持有当前的userId,也很危险。

再提醒一下,其实小范围对象持有大范围对象也不要滥用,一不小心就会让对象承担过多职责,有过多依赖。设计要从职责出发。

结语

之所以要从“有效范围”的角度谈对象设计的问题,就是想给大家提供一个明确可操作的分析视角,这可比“设计哲学”容易多了。

不过光会这个还不够,知识要全面。

posted @ 2020-12-25 17:23  计算法  阅读(42)  评论(0编辑  收藏  举报