终于有空来加入讨论啦~
这边有 markdown 好读版:https://hackmd.io/@rayshih/SyAAwbxkd
这边我也来提一下我的看法。为了阅读方便我把一些 code snippet 复制在这边:
```java=
public double shippingFee(String shipper, double length, double width, double
height, double weight) {
if (shipper.equals("black cat")) {
return // some calculation
} else if (shipper.equals("hsinchu")) {
return // some calculation
} else if (shipper.equals("post office")) {
return // some calculation
} else {
throw new IllegalArgumentException("shipper not exist");
}
}
```
然后以下是 refactor 过后的程式码片段:
首先你订了个 interface
```java=
public interface Shipper {
double calculateFee(Product product);
}
```
然后所有 shipper 都会实作这个 interface:
```java=
public class BlackCat implements Shipper {
public BlackCat() {
}
@Override
public double calculateFee(Product product) {
return // some calculation
}
}
```
于是本来的 function shippingFee 被 refactor 成这样:
```java=
public class Cart {
private final HashMap<String, Shipper> shippers = new HashMap<>() {{
put("black cat", new BlackCat());
put("hsinchu", new Hsinchu());
put("post office", new PostOffice());
}};
public double shippingFee(String shipperName, Product product) {
if (shippers.containsKey(shipperName)) {
return shippers.get(shipperName).calculateFee(product);
}
throw new IllegalArgumentException("shipper not exist");
}
}
```
首先,这根本不能算“策略模式”,只能算是一般的多型应用,不过我这边不是很想讨
论 strategy pattern 本身,有兴趣的可以去 wiki 比较一下差在哪里。
## 所以原本的 code 到底有什么问题?
基本上有两点可以讨论:
1. 不同计算方法都被写在同一个 function 里
2. 如果 caller 丢了一个不认识的 shipperName,这 function 就会丢出 exception
### 1. 不同计算方法都被写在同一个 function 里
原 solution 定义了一个 interface,所以要实作这个 function 必须建立一个 class
来实作这个 interface,所以算是有解决到这个问题。但其实单纯的为不同的 shipper
建立相对应的 function 就行了,并没有必要多一个 interface:
```java=
private double blackCatShippingFee(Product product) {
return // The calculation for black cat
}
// hsinchuShippingFee and postOfficeShippingFee are similar
public double shippingFee(String shipper, Product product) {
if (shipper.equals("black cat")) {
return this.blackCatShippingFee(product);
} else if (shipper.equals("hsinchu")) {
return this.hsinchuShippingFee(product);
} else if (shipper.equals("post office")) {
return this.postOfficeShippingFee(product);
} else {
throw new IllegalArgumentException("shipper not exist");
}
}
```
### 2. 如果 caller 丢了一个不认识的 shipperName,这 function 就会丢出
exception
假设今天,我们新增了一个货运商,工程师记得要建立一个新的 class 并实作 Shipper
interface,但是他忘了把它加入 shippers hashmap,又刚好没写测试,于是 rollout
之后就触发了 exception,就 QQ 惹。
有没有方法可以保证不会有例外呢?这问题就有点有趣了,但首先让我们先换一个语言
kotlin:
```kotlin=
sealed class Shipper {
object BlackCat: Shipper()
object Hsinchu: Shipper()
object PostOffice: Shipper()
}
data class Product(
val length: Double,
val height: Double,
val weight: Double,
)
fun shippingFee(shipper: Shipper, product: Product): Double {
return when(shipper) {
is Shipper.BlackCat -> {
// computation here
}
is Shipper.Hsinchu -> {
// computation here
}
is Shipper.PostOffice -> {
// computation here
}
}
}
fun main(args: Array<String>) {
val product = Product(7.0, 8.0, 9.0)
println(shippingFee(Shipper.BlackCat, product))
}
```
因为 kotlin 的 when 有提供 exhausive check 的功能。只要使用 sealed class,
compiler 就会帮你检查你有没有漏掉的 case。所以假设我们新增一个新的 case 像这样
:
```kotlin=
sealed class Shipper {
object BlackCat: Shipper()
object Hsinchu: Shipper()
object PostOffice: Shipper()
object Lalamove: Shipper() // 新增的部分
}
```
Compiler 会直接吐一个 error 给你:
```
main.kt:15:10: error: 'when' expression must be exhaustive, add necessary
'Lalamove' branch or 'else' branch instead
return when(shipper) {
```
这时,工程师就可以根据 compiler 的提醒来做相对应的修正。
所以其实可以去掉 exception, or can't I?
## 如果 Shipper 是由 user 输入的资料决定的呢?
因为这边 Shipper 是自定义的 data type,所以需要有个过程把 user input 转换成这
些 data type
```kotlin=
val userInput = // some function return user input
val shipper = when (userInput) {
'black cat' -> Shipper.BlackCat
'hsinchu' -> Shipper.Hsinchu
'post office' -> Shipper.PostOffice
else -> throw IllegalArgumentException('shipper not exist')
}
println(shippingFee(shipper, product))
```
只有在 shipper 事先定义好的情况下才能去掉 exception,所以如果 shipper 是由
user input 决定的话,就还是会有 user 输入没有合作的货运商的状况,这个时候还是
需要 throw exception 才行。
这时,你可能会问,既然还是要 throw exception,那有差吗?有的。
## 差别在于:例外是从哪里、何时丢出来的
想像一下如果你的 shippingFee 是在整个 callstack 里面的第十层,也就是
shipperName 就这样一路的被传了十层。一旦 exception 出现,你也要花不少时间去
trace code 才会知道这个 shipperName 是在什么情况下变成不支援的 value。
相较之下,因为上面提到的 conversion from user input to Shipper data type 本身
就是一个检查的过程,所以如果我们在 user input 之后马上做 conversion,那万一出
现了 exception,我们也可以很快地知道到底是从哪里开始错的。
## Boundary between Safe and Unsafe
这个架构基本上可以被视为两个部分
1. conversion and check
2. execution
而其中因为 exhausive check 的关系,第二部分可以说是 safe 的,相较之下第一部分
就是 unsafe。也就是我们可以通过这个手法把系统切割成 safe 跟 unsafe。我们可以
对 unsafe 的部分做更完整的测试。safe 的部分就可以相对放心。
当然,你也可以把 fee computation 放进 shipper 并封装成一个 interface/function
,不过我觉得这相对来说比较不重要,有兴趣的可以自己查查资料看要怎么做。
可惜的是 Java 并没有这样的设计,所以如果使用的是 Java,有很大的机率你还是需要
各种 throw new Exception。
## 结语
虽然现在 Design Pattern 看起来是个显学,不过在使用 Design Pattern 之前,要先能
够先了解问题本身的性质,再去看看使否有模式可以解决,不然很容易会变成为套而套,
而所谓的 refactor 也不一定能带来什么实质的效用。
另外,有一些人会倡导 Design Pattern 是 language agnostic,其实并不尽然,这篇使
用 kotlin 的 sealed class 就是一个例子。