行爲模式--字節碼

理論要點

  • 什麼是字節碼模式:將行爲編碼爲虛擬機器上的指令,來賦予其數據的靈活性。從而讓數據易於修改,易於加載,並與其餘可執行部分相隔離。c++

  • 要點
    1,字節碼模式:指令集定義了可執行的底層操做。一系列的指令被編碼爲字節序列。 虛擬機使用中間值堆棧依次執行這些指令。 經過組合指令,能夠定義複雜的高層行爲。json

    2,能夠理解爲項目中的轉表工具,將excel中的數據轉爲二進制數據,並讀取到工程中。還有如在項目中使用protobuf,json,xml等都是這麼個思路。數組

    3,字節碼相似GOF的解釋器模式,這兩種方式都能讓咱們將數據與行爲相組合。其實不少時候都是二者一塊兒使用。用來構造字節碼的工具會有內部的對象樹,而爲了編譯到字節碼,咱們須要遞歸回溯整棵樹,就像用解釋器模式去解釋它同樣。惟一的不一樣在於,並非當即執行一段行爲,而是生成整個字節碼再執行。安全

  • 使用場合
    像C++這樣的編譯型語言開發遊戲,一個功能剛開發完甚至還沒開發完,策劃又該需求了,這裏調下數值,那裏調下表現,等等諸如此類,猶如屢見不鮮。
    而若是是大型項目,改需求難點不是實現需求,而是煩人的編譯時間,並且對於上線的項目,我甚至得從新出包發佈。更恐怖的若是遇到error,我程序就直接over了。bash

    咱們是否是能夠想個辦法,把需求行爲和遊戲主邏輯隔開,這樣底層不變,上層行爲經過中間層傳遞給底層使用,這時,即便上層行爲要修改或是有bug都不影響咱們底層代碼的運行。這有點像數據,若是能在分離的數據文件中定義行爲,遊戲引擎還能加載並「執行」它們,就能夠實現全部目標- - -即把行爲轉換爲數據使用。dom

    這其實有點相似,「開發咱們本身的程序語言」,想一想,咱們本身定義一套指令集,分別實現編譯器和解釋器,編譯器負責把咱們自定義的語言轉換爲相似機器碼的字節碼,這種字節碼而後經過咱們的解釋器解析爲機器碼。若是實現了這些,那麼咱們自定義的語言就真的能被計算機識別使用了,到時再把它以本身名字命名。想一想是否是很激動!(像如今不少腳步語言,如Lua等都是這樣發展而來的)工具

    但須要注意,這種中間字節碼的形式比直接本地機器碼代碼慢,因此最好不要用於引擎對性能敏感的部分。性能

代碼分析

1,實現本身語言前,咱們先溫習下與之相似的GOF的解釋器模式:假設咱們要讓語言支持這樣的算術表達式:(1 + 2) * (3 - 4)
咱們的作法是把每塊表達式,每條語言規則,都封裝到對象中去,那麼最終就會變成以下這樣的抽象語法樹。
這裏寫圖片描述
建立這棵樹是編譯器的工做,咱們這裏講的解釋器模式與這無關。怎麼造成的先無論,下面咱們來看看怎麼去解析這顆樹,即實現解釋器:
首先,咱們定義全部表達式的基本接口:編碼

class Expression
{
public:
  virtual ~Expression() {}
  virtual double evaluate() = 0;    //求值
};

而後,實現數字派生類:lua

class NumberExpression : public Expression
{
public:
  NumberExpression(double value)
  : value_(value)
  {}

  virtual double evaluate()
  {
    return value_;
  }

private:
  double value_;
};

最後就是運算符了,下面是加法的實現:

class AdditionExpression : public Expression
{
public:
  AdditionExpression(Expression* left, Expression* right)
  : left_(left),
    right_(right)
  {}

  virtual double evaluate()
  {
    // 計算操做數
    double left = left_->evaluate();
    double right = right_->evaluate();

    // 把它們加起來
    return left + right;
  }

private:
  Expression* left_;
  Expression* right_;
};

好,這樣支持簡單的算術計算的解釋器就實現了。然而,很明顯它是把每種指令都封裝成對象,想一想若是處理的問題比較複雜,那麼會是個龐大的語法樹,層次很,深,並且須要new不少對象。這樣明顯效率很差且耗內存。

2,上面咱們已經實現瞭解釋器模式,它的優勢就是安全,由於它的語法行爲是咱們本身定義的,不直接接觸底層。可是缺點也很明顯,就是效率低、耗內存。

再想一想咱們現有的語言C++,它是直接把這些指令編譯成機器碼,機器碼是一組密集的,線性的,底層的指令,硬件直接識別,它效率飛快。然而,咱們總不能期望要用戶編寫機器碼吧,這確定不現實,也不安全(硬件直接識別,因此不安全)。

那咱們想過沒有,若是把它們二者結合起來,不是直接加載機器碼,而是定義本身的虛擬機器碼呢? 而後,在遊戲中寫個小模擬器。 這與機器碼相似,密集,線性,相對底層,但支持規則都是咱們本身定義的,因此能夠放心地將其放入沙箱。

咱們將這個小模擬器稱爲虛擬機(或簡稱「VM」),它運行的虛擬機器碼叫作字節碼。 它有數據的靈活性和易用性,但比解釋器模式有更好的性能。

這聽起來有點高端嚇人。不過下面通過我通俗易懂的解剖完後,其實擁有本身的語言也就那麼回事!(咱們風靡全球的lua腳步其實就是這個原理實現的,即便你不打算開發本身的語言,也至少能對lua語言有更多的瞭解)

好,下面咱們就以一個通俗易懂的遊戲情境來帶你走進字節碼模式,看看我是怎麼實現這個能解析字節碼的虛擬機的。
假設咱們遊戲中有兩隻怪物,他們能夠互相攻擊,釋放法術。絕大多數法術是會改變怪物身上的某個狀態,咱們就從一組狀態開始:若是是c++會這麼設置:

void setHealth(int wizard, int amount);       //設置生命值
void setWisdom(int wizard, int amount);       //設置智力值
void setAgility(int wizard, int amount);      //設置敏捷值

參數wizard是怪物目標,好比說用0表明玩家,用1表明對手。參數amount是改變對應屬性的具體數值。
上面只有數值上的變化,這樣的法術未免太單調,咱們再加些特效做用:

void playSound(int soundId);              //播發音效
void spawnParticles(int particleType);    //播發粒子特效

好,上面這是咱們硬代碼的通常形式了,看看咱們怎麼來一步一步把這些API抽離出來,讓它用咱們本身定義的指令去解析執行。
首先,咱們的目標是實現本身的解析器。好,那麼咱們需求解析什麼呢?固然是上面API操做的全部狀態,咱們要把這些狀態定義成指令。咱們能夠這樣枚舉它們:

enum Instruction
{
  INST_SET_HEALTH      = 0x00,
  INST_SET_WISDOM      = 0x01,
  INST_SET_AGILITY     = 0x02,
  INST_PLAY_SOUND      = 0x03,
  INST_SPAWN_PARTICLES = 0x04
};

一個法術就是一系列這樣的指令,每一個指令對應一個行爲操做。以下:

switch (instruction)
{
  case INST_SET_HEALTH:
    setHealth(0, 100);
    break;

  case INST_SET_WISDOM:
    setWisdom(0, 100);
    break;

  case INST_SET_AGILITY:
    setAgility(0, 100);
    break;

  case INST_PLAY_SOUND:
    playSound(SOUND_BANG);
    break;

  case INST_SPAWN_PARTICLES:
    spawnParticles(PARTICLE_FLAME);
    break;
}

若是我把這套東西封裝好,那麼之後咱們的法術代碼就變成了一個個字節列表,列表保存的就是這些狀態的枚舉值,其實這就是所謂的字節碼。
好,如今咱們就實現咱們的第一個虛擬機,像這樣它就能解析一個完整的法術:

class VM
{
public:
  void interpret(char bytecode[], int size)
  {
    for (int i = 0; i < size; i++)
    {
      char instruction = bytecode[i];
      switch (instruction)
      {
        // 每條指令的跳轉分支……
      }
    }
  }
};

恩,是否是以爲這個虛擬機過於死板了,沒錯,它不能設置參數,咱們不能設定攻擊對手的法術,也不能減小狀態上限。咱們只能播放聲音!
那要這個虛擬機生動起來,就得支持怎麼引入參數?其實上面定義的狀態集 + 這裏的參數 = 數據。咱們只要處理如何序列化的操做這個數據,其實這個過程就是咱們的解析,並且釋放法術這個行爲也已經完美轉換成純數據了。那麼如今就只有一個問題了,就是究竟怎麼序列式操做這個數據呢?沒錯,堆棧。經過push,pop與外界交互數據,把全部數據用堆棧來保存。先咱們來定義一個堆棧:

class VM
{
public:
  VM()
  : stackSize_(0)
  {}

  // 其餘代碼……

private:
  static const int MAX_STACK = 128;
  int stackSize_;
  int stack_[MAX_STACK];
};

到時咱們的一個技能對應的一組連續字節碼就保存到這個stack_堆棧數組中,遍歷這個堆棧過程就是執行技能的過程。
接着來看怎麼加入(push),和取出(pop)數據:

class VM
{
private:
  void push(int value)
  {
    // 檢查棧溢出
    assert(stackSize_ < MAX_STACK);
    stack_[stackSize_++] = value;
  }

  int pop()
  {
    // 保證棧不是空的
    assert(stackSize_ > 0);
    return stack_[--stackSize_];
  }

  // 其他的代碼
};

如今支持引入參數的指令執行操做就會這樣從堆棧中pop取了:

switch (instruction)
{
  case INST_SET_HEALTH:
  {
    int amount = pop();
    int wizard = pop();
    setHealth(wizard, amount);
    break;
  }

  case INST_SET_WISDOM:
  case INST_SET_AGILITY:
    // 像上面同樣……

  case INST_PLAY_SOUND:
    playSound(pop());
    break;

  case INST_SPAWN_PARTICLES:
    spawnParticles(pop());
    break;
}

而後,咱們還得爲添加數據(push)也定義一個指令,接着上面枚舉INST_LITERAL = 0x05:

case INST_LITERAL:
{
  //bytecode字節碼數組,它就是咱們的行爲,咱們這個虛擬機全部工做就是在解析它
  int value = bytecode[++i];     
  push(value);
  break;
}

好,這個第二次改進的虛擬機就搞完了。而後咱們來個遊戲實際情形分析下它的工做原理:「假設咱們此次的技能操做是要給玩家本身加10點生命值。」這個行爲對應的字節碼數據就是這樣:[0x05, 0, 0x05, 10, 0x00]。下面是示意圖分析:
首先從空棧開始,虛擬機指向字節碼數組的第一個:
這裏寫圖片描述
而後,它執行第一個指令「INST_LITERAL」,把下一個字節0壓入堆棧。
這裏寫圖片描述
接着執行第二個」INST_LITERAL」,把下一個字節10壓入堆棧。
這裏寫圖片描述
最後執行」INST_SET_HEALTH」。它會彈出10存進amount,彈出0存進wizard。而後用這兩個參數調用setHealth()。這就實現了咱們爲本身加血行爲的整套字節碼解析流程。

嗯,乍看上去貌似很完美了!不過還缺些行爲,咱們只有set屬性,而沒有get屬性是否是?咱們能夠引入參數把屬性設置爲咱們想要的值,這更像是數據。而若是我想把智力屬性值設置爲它的生命值,那這又怎麼操做呢?很明顯,咱們得再添加些get指令:

case INST_GET_HEALTH:
{
  int wizard = pop();
  push(getHealth(wizard));
  break;
}

case INST_GET_WISDOM:
case INST_GET_AGILITY:
  // 你知道思路了吧……

如今,咱們的虛擬機已經能夠支持設置任意屬性,獲得當前屬性,播發音效,粒子特效。好,咱們接着再來豐富下它,讓它支持更多的行爲。接下來,咱們添加一個算術指令,讓它具備計算能力:

case INST_ADD:
{
  int b = pop();
  int a = pop();
  push(a + b);
  break;
}

算計指令就相似這樣了,那麼有了這些咱們能實現什麼複雜點的行爲呢?來看看這麼個示例:假設咱們但願有個法術,能讓玩家的血量增長敏捷和智慧的平均值。 用代碼表示以下:

setHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2);

好,假設玩家目前有45點血量,7點敏捷,和11點智慧。 咱們來看看實現這麼個行爲,咱們是用的一組什麼字節碼數組和對應堆棧的數據變化,下面是演示這個過程的示例圖:
這裏寫圖片描述
左邊是指令,右邊是對應的堆棧數據變化。這麼個過程咱們就計算出了玩家的血量增長敏捷和智慧的平均值。

我能夠繼續下去,添加愈來愈多的指令,可是時候適可而止了。 如上所述,咱們已經有了一個可愛的小虛擬機,可使用簡單,緊湊的數據格式,定義開放的行爲。 雖然「字節碼」和「虛擬機」的聽起來很嚇人,但你能夠看到它們每每簡單到只需棧,循環,和switch語句。

再回過頭來想一想,是否是實現本身的虛擬機其實也就那麼回事,首先咱們對須要處理的問題定義好指令集合(枚舉),從而某個行爲就能夠用這些對應指令來表示(字節碼數組),而後用堆棧來遍歷這組字節碼(push存儲數據,pop使用數據),執行對應操做。

3,到目前爲止,咱們虛擬機(解釋器)已經講完了,可是對應的編譯器呢?咱們上面全部操做的字節碼數組都是本身手動寫的,這樣確定不實用。很明顯,咱們須要寫個上層的圖形工具(編譯器),就算不是程序的人也能夠輕鬆使用這個工具編輯行爲,而後生成對應的字節碼數組。相似這樣的東西:
這裏寫圖片描述
你能夠作個這樣的工具,用戶經過單擊拖動小盒子,下拉菜單項,或任何有意義的行爲建立「腳本」,從而建立行爲。
這樣作的好處是,你的UI能夠保證用戶沒法建立「無效的」程序。 與其向他們噴射錯誤警告,不如主動關閉按鈕或提供默認值, 以確保他們創造的東西在任什麼時候間點上都有效。


這篇已經太長了,其實到目前,關於怎麼實現自定義語言的編譯器,解釋器原理已經講得很清楚了。相信你只要認真看完了的,實現一個小型自定義語言是徹底沒壓力的了。
好比遊戲裏的技能系統,咱們恰好是否是能夠作個工具讓策劃本身去編輯行爲,而後生成字節碼,最後咱們的虛擬機去解析執行。這套東西作好後,咱們程序裏將沒有一個硬代碼,全部工做無非是策劃的編輯事情了。這時咱們就能夠輕鬆的去倒杯咖啡,看着策劃們調它們要的效果,而這再也與咱們無關~

相信理解好這章並運用到你項目中後,必定能讓你代碼提示一大波氣質~~ 哈哈,結束!

相關文章
相關標籤/搜索