用訪問(wèn)者模式遍歷樹(shù)狀結(jié)構(gòu)

在寫(xiě)編譯器和解釋器的過(guò)程中履磨,我們需要遍歷抽象語(yǔ)法樹(shù)并完成某些操作诵冒,比如生成目標(biāo)代碼。所有的語(yǔ)法類都繼承自同一個(gè)基類仑濒,但對(duì)每個(gè)語(yǔ)法類的操作都不同。比如生成 if 語(yǔ)句目標(biāo)代碼的方法與生成 while 語(yǔ)句目標(biāo)代碼的方法就不一樣偷遗。而且墩瞳,這些操作還需要可擴(kuò)展性。比如氏豌,我們除了生成目標(biāo)代碼喉酌,還希望寫(xiě)一個(gè)代碼優(yōu)化器。同樣是遍歷抽象語(yǔ)法樹(shù)泵喘,代碼優(yōu)化器對(duì)語(yǔ)法類的操作顯然與生成目標(biāo)代碼完全不同泪电。

我們可以將這種訪問(wèn)模式抽象成一種通用的模型,它滿足以下兩點(diǎn):

  1. 需要訪問(wèn)的一組類型有公共的基類纪铺;
  2. 對(duì)類型的訪問(wèn)操作必須是可以擴(kuò)展的相速。

如果我們的訪問(wèn)模式符合這種類型,就可以使用訪問(wèn)者模式(Visitor Pattern)來(lái)實(shí)現(xiàn)鲜锚。

訪問(wèn)者模式解決了什么問(wèn)題

在 C++ 中和蚪,如果不使用 RTTI止状,我們無(wú)法在運(yùn)行時(shí)知道某個(gè)對(duì)象的具體類型。即使可以使用 RTTI 攒霹,但因?yàn)榫幾g器實(shí)現(xiàn)的原因怯疤,RTTI 的效率通常也難以接受。為了提供某種操作催束,通常的做法是在基類中提供一個(gè)純虛函數(shù)接口集峦,并且在子類中實(shí)現(xiàn)這個(gè)純虛函數(shù)。

以抽象語(yǔ)法樹(shù)為例抠刺。我們假設(shè)在某種語(yǔ)言中塔淤,只有整數(shù)節(jié)點(diǎn) IntegerNode 和加法節(jié)點(diǎn) SumNode 兩種語(yǔ)法,它們都繼承自公共基類 Node速妖。為了支持求值(evaluation)操作高蜂,我們可以在公共基類 Node 中提供純虛函數(shù)接口 Evaluate,并在整數(shù)節(jié)點(diǎn)和加法節(jié)點(diǎn)中分別實(shí)現(xiàn)求值方法罕容。代碼如下:

class Node {
 public:
  virtual ~Node() {}
  virtual int Evaluate() = 0;
};

class IntegerNode : public Node {
 public:
  Integer(int value) : value_(value) {}
  int Value() { return value_; }
  int Evaluate() override { 
    return Value();
  }
 private:
  int value_;
};

class SumNode : public Node {
 public:
  SumNode(Node* a, Node* b) : a_(a), b_(b) {}
  Node* A() { return a_; }
  Node* B() { return b_; }
  int Evaluate() override {
    return A().Evaluate() + B().Evaluate(); 
  }
 private:
  Node* a_;
  Node* b_;
};

在目前看來(lái)备恤,虛函數(shù)機(jī)制已經(jīng)很好地滿足了我們的需求。然而锦秒,很快我們又有了新的需求:將抽象語(yǔ)法樹(shù)打印出來(lái)露泊。為此,我們可以提供一個(gè)新的接口 Print 來(lái)實(shí)現(xiàn)這個(gè)操作旅择。增加的代碼如下:

class Node {
 public:
  virtual void Print() = 0;
};

class IntegerNode : public Node {
 public:
  void Print() override {
    std::cout << Value();
  }
};

class SumNode : public Node {
 public:
  void Print() override {
    std::cout << "(";
    A().Print();
    std::cout << "+";
    B().Print();
    std::cout << ")";
  }
};

經(jīng)過(guò)簡(jiǎn)單的修改惭笑,新的需求被滿足了。然而好景不長(zhǎng)生真,我們又有了新的需求:將抽象語(yǔ)法樹(shù)存儲(chǔ)到文件中沉噩。

在滿足新的需求之前,請(qǐng)我們稍作停留柱蟀,反思一下不斷加虛函數(shù)的方式是否存在不足:

  1. 每次新增功能屁擅,都需要修改 Node 的接口,如果這樣下去产弹,最后 Node 和相關(guān)的具體類會(huì)變得臃腫不堪;
  2. 求值和打印操作實(shí)際上并不必須是 Node 的一部分弯囊,因?yàn)槲覀兛梢园l(fā)現(xiàn)這兩個(gè)函數(shù)僅僅使用抽象語(yǔ)法樹(shù)提供的公共接口就完成了相應(yīng)的功能痰哨。

這正是訪問(wèn)者模式所要解決的問(wèn)題:

  1. 增加新的訪問(wèn)者(也就是新的功能)時(shí)不需要修改 Node 的接口;
  2. 訪問(wèn)者的實(shí)現(xiàn)和 Node 的實(shí)現(xiàn)分離匾嘱。

訪問(wèn)者模式想要達(dá)到的效果是什么

在使用訪問(wèn)者模式重構(gòu)我們的抽象語(yǔ)法樹(shù)之前斤斧,請(qǐng)思考以下我們希望達(dá)到的效果是什么。我們可以脫離 C++ 的限制霎烙,假設(shè)有一種語(yǔ)言有我們想要的所有語(yǔ)法功能撬讽,在此條件下我們?cè)撊绾螌?xiě)求值函數(shù)呢蕊连?

假設(shè)我們所用的 C++ 提供了判斷某個(gè)對(duì)象是否是某個(gè)類型的操作符 instanceof ,一種實(shí)現(xiàn)求值函數(shù)的方法可能是:

int Evaluate(Node* node) {
  if (node instanceof IntegerNode) {
    return static_cast<IntegerNode*>(node)->Value();
  } else {
    SumNode* snode = static_cast<SumNode*>(node);
    return Evaluate(snode->A()) + Evaluate(snode->B());
  }
}

如果我們想要實(shí)現(xiàn)打印函數(shù)游昼,也完全不需要修改 Node 的定義甘苍,而只需要新增一個(gè) Print 函數(shù),實(shí)現(xiàn)方法與 Evaluate 函數(shù)類似烘豌。

然而载庭,現(xiàn)實(shí)的情況是我們并沒(méi)有類似于 instanceof 的操作符來(lái)判斷對(duì)象的類型。(RTTI 確實(shí)可以用于在運(yùn)行時(shí)判斷對(duì)象類型廊佩,但是 RTTI 的效率十分低下囚聚,而且有些工程會(huì)禁用 RTTI,因此我們只能另辟蹊徑)

如何繞過(guò) C++ 的這個(gè)限制呢标锄?

如果繼續(xù)從如何讓 Evaluate 知道 node 的具體類型這個(gè)角度思考顽铸,我們總會(huì)走上實(shí)現(xiàn)一套粗糙而且錯(cuò)誤百出的動(dòng)態(tài)類型系統(tǒng)的道路。因此料皇,不妨換個(gè)角度去想谓松,那就是 node 所指向的對(duì)象在運(yùn)行時(shí)永遠(yuǎn)知道自己是什么類型 ,這是顯而易見(jiàn)的瓶蝴。如果能夠讓 node 所指向的對(duì)象去完成對(duì)應(yīng)的操作毒返,問(wèn)題就解決了。那這樣是不是又繞回文章剛開(kāi)始處的實(shí)現(xiàn)方案了呢舷手?

并不是這樣拧簸。因?yàn)檫@一次,我們并不關(guān)注于某一種訪問(wèn)方式男窟,而是嘗試實(shí)現(xiàn)一套通用的訪問(wèn)方式盆赤。當(dāng)我們提到某一種通用的抽象的時(shí)候,繼承就呼之欲出了歉眷。

訪問(wèn)者類型

我們首先引入訪問(wèn)者類型 NodeVisitor

class NodeVisitor {
 public:
  virtual ~NodeVisitor();
  void Visit(Node* node) {
    node->Accept(this);
  }
  virtual void VisitImpl(IntegerNode* node) = 0;
  virtual void VisitImpl(SumNode* node) = 0;
};

訪問(wèn)者 NodeVisitor 提供了一個(gè)接口函數(shù) Visit牺六,用于訪問(wèn)一個(gè) Node 對(duì)象。它還提供了兩個(gè)純虛函數(shù)汗捡,分別對(duì)應(yīng)于訪問(wèn) IntegerNode 和 SumNode 的具體實(shí)現(xiàn)淑际,需要由子類提供具體的操作。

函數(shù) Visit 將自己傳遞給了 Node 類型的 Accept 方法扇住,這正是訪問(wèn)者模式的精妙之處:在 Accept 方法內(nèi)部春缕,Node 對(duì)象會(huì)明確地知道自己是什么類型,因此它能夠反過(guò)來(lái)調(diào)用正確的訪問(wèn)方法艘蹋。具體的實(shí)現(xiàn)如下:

class Node {
 public:
  virtual void Accept(NodeVisitor* visitor) = 0;
};

class IntegerNode : public Node {
 public:
  void Accept(NodeVisitor* visitor) override {
    // 調(diào)用的是 NodeVisitor::VisitImpl(IntegerNode*)
    visitor->VisitImpl(this);
  }
};

class SumNode : public Node {
 public:
  void Accept(NodeVisitor* visitor) override {
    // 調(diào)用的是 NodeVisitor::VisitImpl(SumNode*)
    visitor->VisitImpl(this);
  }
};

我們可以在訪問(wèn)者模式的基礎(chǔ)上锄贼,實(shí)現(xiàn) Evaluate 功能:

class EvaluateVisitor : public NodeVisitor {
 public:
  EvaluateVisitor() : result_(0) {}
  int Result() { return result_; }
  void VisitImpl(IntegerNode* node) {
    result_ = node->Value();
  }
  void VisitImpl(SumNode* node) {
    Visit(node->A());
    int a_result = result_;
    Visit(node->B());
    result_ += a_result;
  }
 private:
  int result_;
};

int Evaluate(Node* node) {
  EvaluateVisitor visitor;
  visitor.Visit(node);
  return visitor.Result();
}

其他的語(yǔ)言是如何處理這類問(wèn)題的

我們之所以繞了一大圈,根本的原因是無(wú)法在運(yùn)行時(shí)知道某個(gè)對(duì)象的具體類型女阀。

在絕大多數(shù)動(dòng)態(tài)類型語(yǔ)言中宅荤,因?yàn)榭梢栽谶\(yùn)行時(shí)判斷對(duì)象類型屑迂,所以不會(huì)遇到此類問(wèn)題。在 JavaScript 中冯键,我們可以使用 obj instanceof Class 來(lái)判斷 obj 是否是 Class 類型惹盼;在 Python 中搭盾,我們可以使用全局函數(shù) isinstance(obj, Class) 來(lái)完成同樣的功能谱仪。因?yàn)?Java 也支持 instanceof 操作符服爷,所以也不會(huì)遇到問(wèn)題陆赋。但考慮到 instanceof 常常不被建議使用愚隧,很多時(shí)候我們?nèi)匀粫?huì)實(shí)現(xiàn)訪問(wèn)者模式们妥。這類方法不被建議的原因是拥刻,instanceof 類的處理方式不是類型安全的潭辈。如果我們?cè)趯?xiě)這個(gè)函數(shù)時(shí)忘記了某個(gè)子類所袁,只有在程序執(zhí)行的時(shí)候才會(huì)發(fā)現(xiàn)盏档。而訪問(wèn)者模式會(huì)在編譯時(shí)就因?yàn)槟硞€(gè)純虛函數(shù)沒(méi)有在子類中被實(shí)現(xiàn)而報(bào)錯(cuò)。

在另一方面燥爷,現(xiàn)代的靜態(tài)類型語(yǔ)言都對(duì)數(shù)據(jù)變體(Data Variant) 有各種各樣的支持蜈亩。數(shù)據(jù)變體與模式匹配(pattern matching)特性相結(jié)合后,可以非常漂亮地解決此類問(wèn)題前翎。所謂數(shù)據(jù)變體稚配,指的正是抽象語(yǔ)法樹(shù)這類數(shù)據(jù)類型。它們?cè)诒举|(zhì)上是同一種類型港华,即抽象語(yǔ)法樹(shù)道川,但各自是不同的變體,即整數(shù)節(jié)點(diǎn)加法節(jié)點(diǎn)立宜。類似的例子還包括二叉樹(shù)類型冒萄,它可以有兩種變體,空節(jié)點(diǎn)非空節(jié)點(diǎn)橙数。模式匹配可以根據(jù)某個(gè)對(duì)象在運(yùn)行時(shí)的實(shí)際類型來(lái)執(zhí)行正確的操作尊流。通過(guò)將數(shù)據(jù)變體與模式匹配結(jié)合,我們就可以寫(xiě)出類型安全的 instanceof 代碼灯帮。

在經(jīng)典的函數(shù)式編程語(yǔ)言 OCaml 中崖技,我們可以將整個(gè) Evaluate 簡(jiǎn)潔地寫(xiě)成這樣(用 eval 代替長(zhǎng)名字 evaluate):

type node = 
  | Integer of int
  | Sum of node * node
;;

let rec eval n = match n with 
  | Integer i -> i
  | Sum (a, b) -> (eval a) + (eval b)
;;

對(duì)于完全不了解 OCaml 語(yǔ)法的人,也可以直觀地猜測(cè)出這段代碼的功能钟哥,這正是函數(shù)式編程直觀性的體現(xiàn)迎献。

總結(jié)

如果我們需要解決的設(shè)計(jì)難題滿足以下兩點(diǎn),就可以使用訪問(wèn)者模式:

  1. 多個(gè)類型有公共基類瞪醋;
  2. 每個(gè)類型的訪問(wèn)操作都不同,而且訪問(wèn)操作需要可擴(kuò)展性装诡。

訪問(wèn)者模式的實(shí)現(xiàn)分成三步:

  1. 定義訪問(wèn)者抽象基類银受,它提供一個(gè)對(duì)外的訪問(wèn)接口和一組接受具體類型的訪問(wèn)方法(與具體類型一一對(duì)應(yīng))践盼;
  2. 公共基類提供接受訪問(wèn)者類型的接口,并且在子類中實(shí)現(xiàn)這一接口宾巍,每個(gè)子類反過(guò)來(lái)調(diào)用訪問(wèn)者的對(duì)應(yīng)訪問(wèn)方法咕幻。
  3. 實(shí)現(xiàn)具體的訪問(wèn)者類型。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末顶霞,一起剝皮案震驚了整個(gè)濱河市肄程,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌选浑,老刑警劉巖蓝厌,帶你破解...
    沈念sama閱讀 210,914評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異古徒,居然都是意外死亡拓提,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,935評(píng)論 2 383
  • 文/潘曉璐 我一進(jìn)店門(mén)隧膘,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)代态,“玉大人,你說(shuō)我怎么就攤上這事疹吃”囊桑” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 156,531評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵萨驶,是天一觀的道長(zhǎng)歉摧。 經(jīng)常有香客問(wèn)我,道長(zhǎng)篡撵,這世上最難降的妖魔是什么判莉? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 56,309評(píng)論 1 282
  • 正文 為了忘掉前任,我火速辦了婚禮育谬,結(jié)果婚禮上券盅,老公的妹妹穿的比我還像新娘。我一直安慰自己膛檀,他們只是感情好锰镀,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,381評(píng)論 5 384
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著咖刃,像睡著了一般泳炉。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上嚎杨,一...
    開(kāi)封第一講書(shū)人閱讀 49,730評(píng)論 1 289
  • 那天花鹅,我揣著相機(jī)與錄音,去河邊找鬼枫浙。 笑死刨肃,一個(gè)胖子當(dāng)著我的面吹牛古拴,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播真友,決...
    沈念sama閱讀 38,882評(píng)論 3 404
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼黄痪,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了盔然?” 一聲冷哼從身側(cè)響起桅打,我...
    開(kāi)封第一講書(shū)人閱讀 37,643評(píng)論 0 266
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎愈案,沒(méi)想到半個(gè)月后挺尾,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,095評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡刻帚,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,448評(píng)論 2 325
  • 正文 我和宋清朗相戀三年潦嘶,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片崇众。...
    茶點(diǎn)故事閱讀 38,566評(píng)論 1 339
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡掂僵,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出顷歌,到底是詐尸還是另有隱情锰蓬,我是刑警寧澤,帶...
    沈念sama閱讀 34,253評(píng)論 4 328
  • 正文 年R本政府宣布眯漩,位于F島的核電站芹扭,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏赦抖。R本人自食惡果不足惜舱卡,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,829評(píng)論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望队萤。 院中可真熱鬧轮锥,春花似錦、人聲如沸要尔。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,715評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)赵辕。三九已至既绩,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間还惠,已是汗流浹背饲握。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,945評(píng)論 1 264
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人救欧。 一個(gè)月前我還...
    沈念sama閱讀 46,248評(píng)論 2 360
  • 正文 我出身青樓歪今,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親颜矿。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,440評(píng)論 2 348