訪問者模式,即 visitor pattern,是一個很常見的模式,這是因為它能有效地構建出復雜的系統。更關鍵的是,在函數式語言中,它表現起來是如此的直觀。因此,我決定利用一個簡單的例子,來談談訪問者模式,并且希望能夠通過這個例子,讓大家感受到這一模式的威力。
王垠曾在他的文章 解密“設計模式” 中提到過訪問者模式:
所謂的 visitor,本質上就是函數式語言里的含有‘模式匹配’(pattern matching)的遞歸函數。
這一定義還是非常精確的,在我們介紹完訪問者模式后,會再回顧一下這句話。
一個簡單的例子
下面我們將會利用一個小例子,介紹訪問者模式。
假設在一個二維的坐標系中,定義一個類 Point,有兩個方法,
- getDistance 用于計算 point 到原點的距離
- minus 接收一個點 p 作為參數,將兩個點的坐標相減得到一個新坐標,通過新坐標創建一個新的點
代碼如下:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
// 計算 point 到原點的距離
getDistance() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
// point 與另一個點 p 的坐標相減得到一個新坐標,通過新坐標創建一個新的點
minus (p) {
const delX = this.x - p.x;
const delY = this.y - p.y;
return new Point(delX, delY);
}
}
再定義一個基本的形狀類 Circle,Circle 有一個方法 hasPoint 用于判斷傳進來的 point 是否在 circle 的范圍內,代碼如下:
class Circle {
constructor(r) {
this.r = r;
}
// 判斷 p 是否在 circle 的范圍內
hasPoint (p) {
return p.getDistance() <= this.r;
}
}
再定義一個基本的形狀類 Square,和 Circle 一樣有一個 hasPoint 方法,代碼如下:
class Square {
constructor(s) {
this.s = s;
}
// 判斷 p 是否在 square 的范圍內
hasPoint (p) {
return (p.x <= this.s) && (p.y <= this.s);
}
}
在有了上面的幾個類定義后,我們通過下面的代碼觀察下如何使用這些類:
var p1 = new Point(1, 2);
var square1 = new Square(2);
var circle1 = new Circle(2);
square1.hasPoint(p1); // true
circle1.hasPoint(p1); // false
上面的例子雖然符合我們的期望。但是,這個系統還過于簡單。
仔細觀察就會發現,創建出來的形狀都是基于原點的。為了增加一些難度,我們新增一個類 Trans,讓形狀可以位移。注意新的類 Trans 的 hasPoint 方法的實現。
class Trans {
constructor(point, shape) {
this.point = point;
this.shape = shape;
}
hasPoint (p) {
var { point, shape } = this;
var newP = p.minus(point);
return shape.hasPoint(newP);
}
}
讓我們再添加一些簡單的例子吧。
var p1 = new Point(1, 2);
var p2 = new Point(1, 1);
var square1 = new Square(2);
var circle1 = new Circle(2);
var trans1 = new Trans(p2, circle1);
square1.hasPoint(p1); // true
trans1.hasPoint(p1); // true
通過上面的例子可以發現,傳遞給 Trans 的 shape 不僅僅只能是基本的形狀 Circle,Square,還能是位移之后的 Trans。這是因為 Trans.hasPoint 的實現是依賴傳進來的 shape.hasPoint,但是這個 shape 具體是什么它并不關心。而這正是訪問者模式的核心所在。
通過讓 Circle,Square,Trans 實現同一個方法 hasPoint,并且通過 Trans 實現形狀的組合功能,從而讓這個系統更加強大。可以想象,我們可以像定義 Trans 一樣,引入更多的轉換功能,比如實現 Rotate,Scale 類等等,并且讓各種 shape 相互組合,得到更加復雜的 shape。從而做到,在不修改原有代碼的情況下,構建出更加復雜的系統。
分析與變換
細心的讀者也許發現了,上面的例子雖然有趣,當是似乎和本文開頭所講的函數式語言關系不大,和王垠所定義的訪問者模式也不相同(甚至和 Java 中的訪問者模式也不一樣)。
這是因為,在實際開發中,為了讓系統各個部分更加清晰,尤其是大型系統,人們會更傾向于將所有的 hasPoint 方法的實現放在一起,然后將這些實現作為部件添加到 Shape 中。而完成了這一步,才算是真正實現了訪問者模式。
想在 Java 中實現訪問者模式會比較繞,所幸我們用的是 JavaScript。下面,我會將上面的例子做一些簡單的變換,使其更符合預期。但要記住,這些變換從本質上來說其實都是等價的,只是代碼形式的轉換而已。
首先,去除所有形狀中的 hasPoint 方法,并且引入一個 type 的屬性。代碼如下:
var CIRCLE = 'CIRCLE';
var SQUARE = 'SQUARE';
var TRANS = 'TRANS';
class Circle {
constructor(r) {
this.r = r;
this.type = CIRCLE;
}
}
class Square {
constructor(s) {
this.s = s;
this.type = SQUARE;
}
}
class Trans {
constructor(point, shape) {
this.point = point;
this.shape = shape;
this.type = TRANS;
}
}
然后,我們創建一個新函數,將原先所有的 hasPoint 方法集中在一起,這個函數就是訪問者模式的關鍵啦。
var hasPoint = (s, p) => {
// 利用 switch 做模式匹配
switch (s.type) {
case CIRCLE:
return p.getDistance() <= s.r;
case SQUARE:
return (p.x <= s.s) && (p.y <= s.s);
case TRANS:
var { point, shape } = s;
var newP = p.minus(point);
return hasPoint(shape, newP); // 遞歸調用 hasPoint
default:
console.error('HAS_POINT -- unexpteced type', s.type);
}
}
這個新函數只是利用 switch 將原來的 hasPoint 方法集合,但它確實符合王垠定義中的兩個關鍵點
- 模式匹配
- 遞歸
而這正是訪問者模式的特征所在。
如果你是面向對象的忠實粉絲的話,還可以添加一個抽象類,通過讓所有的 shape 都繼承這一抽象類,重新獲得原來的 hasPoint方法。代碼如下:
class AbstractShape {
hasPoint (p) { return hasPoint(this, p) }
}
最后
希望這個簡單的例子,能向大家闡明訪問者模式是如何構建復雜系統的。其關鍵是:
- 通過組合的方式構建出更加復雜的系統
- 利用遞歸達到解耦的效果
比如上面的例子,定義了組合類 Trans( 甚至 Rotate,Scale 等),讓基本類 Circle,Square 得以通過不同的組合方式構建出更加復雜的 shape。而 hasPoint 函數中的遞歸,則讓 Trans 可以不關心其接收的 shape 的類型,從而達到解耦的效果。
希望這篇文章,能夠對你有所啟發,有所幫助。