跳到主要內容

以Boost.Spirit實作算式計算機

Spirit是開源碼C++程式庫Boost裡的一個項目,大量使用了C++ Template Meta-Programming實作技術。它提供了一個用來實作語言剖析器的框架,就好像lex/yacc一樣是專門用來製作語言剖析器的工具。但是Spirit因為應用了C++ Template Meta-Programming的技術,使得我們可以在C++程式碼中寫出相當於EBNF 的描述且可直接編譯並執行,也因為如此Spirit是一套既神奇又威力強大的工具程式庫。

按照慣例,前面我們已經以手工的方法,以及lex/yacc的方式實作了算式計算機,這次我們要使用Spirit來作個簡單的算式計算機。

EBNF表示式

雖然前面我們已經提到過Spirit的神奇的能力,也就是可以直接把EBNF式直接以程式碼的形式寫在C++程式碼內,但是因為受限於C++程式語言的語言,有些表式法需要作些調動,這裡面主要有幾個地方的語法需要注意。

A B

上面是原EBNF表示式的寫法,用來表示一個序列,A之後會有個B。而在Spirit裡需改寫成如下的形式。

A >> B

看起來沒有什麼太大的不同,也可以很容易的理解這是表示在A之後跟著B的序列。再來是*號的用法。

A*

在EBNF內,在符號A之後加個*號,表示A的數量是可重複的,可以是0個以上的A所構成的序列。而在Spirit中需改寫成如下形式。

*A

正好相反,需要把*號擺到符號的前面,因為在C++裡面並沒有任何後序式的*號運算元。不過就算改成前置式也不會影響到原來所代表的意義,並不會造成什麼衝突,容易理解。

A?

這是表示符號A是可有可無,也就是A的數量可以是0個或一個。以下改成以Spirit的寫法。

!A

如你所見到的,問號變成驚嘆號,同時也提到前面變成前置式。那麼一個以上的表示式呢?一樣使用加號,也一樣改成前置式即可,如下所示。

+A

現在來看一個簡單的範例,如下所示是EBNF式,用來表示以逗號分隔的數字的EBNF表示式。
S := INTEGER (',' INTEGER)*

改成Spirit的形式。
S = INTEGER >> *(',' INTEGER);

很直接,也不需要再多作說明。不過上面的式子還不是最終可用的版本,底下還會再陸續交待清楚。

Parser

在上面我們所看到的EBNF表示S式中的INTEGER以及逗號是終端符號。根據我們在lex及yacc的教學中知道,這些終端符號是由lex或具有lex功能,也就是字彙剖析器所解析出來再傳遞給語法剖析器的。在Spirit中對應於yacc工具的語法剖析器功能就是在上一小節中,可以直接在C++程式碼中寫下EBNF表示式的功能,而對應lex的字彙剖析器的功能就是這一小節要介紹的內容。

在Spirit中提供了許多的Parser,這裡的Parser指的是字彙剖析器,像是整數的剖析器,浮點數的剖析器,字串的剖析器,或字元的剖析器等等,這些Parser可以和上面的EBNF表示式一樣混合在一起直接寫在程式碼內。例如上例的INTEGER就可以使用整數Parser來代換,而逗號就用字元的Parser來代換。

以下列出常用的Parser。
int_p             整數
real_p            浮點數
ch_p              字元
str_p             字串
range_p           字元範圍
chseq_p           字元集
anychar_p         任何字元
alnum_p           英數字元
alpha_p           英文字母
space_p           空白字元
底下舉幾個簡單的範例來作說明如何使用這些parser。左邊是常規表示式,而右邊是Spirit的寫法。
abc               ch_p('a') >> ch_p('b') >> ch_p('c')
abc*              ch_p('a') >> ch_p('b') >> *ch_p('c')
abc+              ch_p('a') >> ch_p('b') >> +ch_p('c')
a(bc)+            ch_p('a') >> +(ch_p('b') >> ch_p('c'))
a(bc)?            ch_p('a') >> !(ch_p('b') >> ch_p('c'))
[abc]             chseq_p("abc")
[a-z]             range_p('a','z')
看過以上幾個簡單的範例之後,相信對於Spirit的Parser有了基本的了解。現在我們可以把S寫成完整的Spirit式EBNF表示式了,如下所示。
S = int_p >> *(ch_p(',') >> int_p);
Spirit可以讓我們直接在C++程式裡面使用EBNF表示式,我們要怎麼在程式裡面使用S呢?S在程式裡面代表什麼?

在Spirit中S代表的是一個Rule,它的型別是rule<>,這是一般的表示式類型,事實上在Spirit裡一個rule<>已經是一個可以工作的Parser了。S完整的定義如下。
rule<> S = int_p >> *(ch_p(',') >> int_p);
在Spirit中有一個parse函數,使用parse函數我們就可以使用S來解析資料,使用方法如下。
bool parse_number(char const* str)
{
  return parse(str, S, space_p).full;
}
parse的第一個參數是文字串流來源,第二個參數就是我們提供的rule(parse),而第三個參數也是個Rule,是專門使用來過濾空白字元的,一般我們直接拿space_p來使用就足夠了,如果還有更複雜的過濾需求的話再另外提供即可。

最後parse函數的回傳值是一個叫作parse_info的結構,我們在上例裡面只拿它的full欄位來使用。full可以告訴我們解析的動作是否正常完成,即從資料來源的開頭一直解析到資料結尾都沒有任何錯誤發生。

動作程式碼

現在我們已經知道怎麼使用Spirit來實作一個Parser了,不過這個Parser還不怎麼有用,因為它還只能夠檢查文字資料是不是符合我們的語法。至少還得像lex/yacc一樣還能插入我們自己的動作程式碼到Parser裡面,這樣這個Parser才能真正為我們作點什麼。

繼續拿上面的例子來作說明。
S = int_p >> *(',' >> int_p);
假如每當我們的Parser解析出一個數字時,我們的程式想要獲得通知以便把這個數字儲存起來或者直接拿它來作點運算。在yacc裡面我們會在二個int_p後面插入一段動作程式碼,而在Spirit中也是一樣的作法,而且也很直覺。
S = int_p[&f] >> *(',' >> int_p[&f]);
在二個int_p的後面我們各加了一對方括號,而在方括號中是個&f。f是個函數,我們使用這樣的語法告訴Spirit當解析到一個int時呼叫我們的動作程式碼f函數。f函數有一個int的參數,我們可以直接使用這個參數,如果我們現在是使用ch_p則參數變為char,依此類推。

而你也可以使用一般型的動作程式碼函數,它的原型如下。
template<typename Iterator>
void func(Iterator first, Iterator last);
Iterator對應資料流的類型,例如char*。這個函數帶有二個參數,分別是解析出來的token在資料流中的開始及結尾。

你也可以一次使用好幾個動作程式碼在同一個位置,像下面的例子一次呼叫三個函數。如果指定二個以上的動作程式碼的話,它們會以左至右的次序被呼叫。
S = int_p[&f1][&f2][&f3];
呼叫函式也能是個functor(仿函式),它的原型如下。
struct myfunctor
{
  template<typename Iterator>
  void operator()(Iterator first, Iterator last) const;
};
使用方法如下。
S = int_p[myfunctor()];

實作

到此為此,我們已經學習了如何使用Spirit來製作Parser的相關知識,現在我們可以正式拿Spirit來實作一個算式計算機。

根據前面的介紹,首先我們把表示算式計算機的EBNF表示式改成以Spirit形式的表示式,工作就已經算是完成一大半,如下所示。
rule<> group, factor, term, expression;

group = ch_p(',') >> expression >> ch_p(',');
factor = int_p | group;
term = factor >> *((ch_p('*') >> factor) | (ch_p('/') >> factor);
expression = term >> *((ch_p('+' >> term) | (ch_p('-') >> term)
接著再加上parse函式的使用,就已經是一個可以運作的算式計算機程式了,剩下的只要再補上動作程式碼,用來處理真正的數值運算,整個程式就可以完成。底下列出不包含動作程式碼,但可以實際動作的程式列表。
#include <iostream>
using namespace std;

#include "boost/spirit/core.hpp"
using namespace boost::spirit;

int main()
{
  rule<phrase_scanner_t> group, factor, term, expression;

  group = ch_p('(') >> expression >> ch_p(')');
  factor = int_p | group;
  term = factor >> *((ch_p('*') >> factor) | (ch_p('/') >> factor));
  expression = term >> *((ch_p('+') >> term) | (ch_p('-') >> term));

  string str;
  while (getline(cin, str))
  {
    if (str.empty() || str[0] == 'q' || str[0] == 'Q')
      break;

    if (parse(str.c_str(), expression, space_p).full)
    { // Parsing succeeded
      cout << "Parsing succeeded\n";
    }
    else
    { // Parsing failed
      break;
    }
  }
  return 0;
}
把這個程式拿來編譯後執行起來看看,它可以用來檢驗一個算式的正確性。接著,我們要加上動作程式碼,讓它可以作真正的運算,計算出最後的結果。

前面我們學過動作程式碼的形式及如何撰寫動作程式碼,底下我們就以前面所學寫下四個分別用來計算加減乘除以及一個用來儲存得到的數字的動作程式碼,另外再配合一個用來模擬堆疊的vector容器。
vector<int> v;

void push_int(int i)
{
  v.push_back(i);
}

void do_add(char const* first, char const* last)
{
  int a, b;
  a = v.back(), v.pop_back();
  b = v.back(), v.pop_back();
  v.push_back(b + a);
}

void do_sub(char const* first, char const* last)
{
  int a, b;
  a = v.back(), v.pop_back();
  b = v.back(), v.pop_back();
  v.push_back(b - a);
}

void do_mul(char const* first, char const* last)
{
  int a, b;
  a = v.back(), v.pop_back();
  b = v.back(), v.pop_back();
  v.push_back(b * a);
}

void do_div(char const* first, char const* last)
{
  int a, b;
  a = v.back(), v.pop_back();
  b = v.back(), v.pop_back();
  v.push_back(b / a);
}
這裡所使用的方法和我們在學習資料結構與演算法時用來處理算式的方法一樣,也就是將算式由中序式轉換為後序式,然後再配合堆疊,就可以很容易的把算式結果計算出來。這部份的原理就不再這裡再複述一次,有興趣的話自行參考相關資料。

最後我們再把動作程式碼插入到rule內,整個Parser就完成。如下所示。
group = '(' >> expression >> ')';
factor = int_p[&push_int] | group;
term = factor >> *(('*' >> factor)[&do_mul] | ('/' >> factor)[&do_div]);
expression = term >> *('+' >> term)[&do_add] | ('-' >> term)[&do_sub]);
另外補充一下。在上面這幾行的rule裡面就沒有再看到ch_p的使用,那是因為Spirit已經有對一些operator作針對字元參數的覆載(overload),所以我們就可以省略掉ch_p。不過在有些情況下,C++編譯器還是會找不到正確的operator函式,當這樣的情況發生時,我們只需明確的寫上ch_p即可。或者為了避免這樣的情況發生,你也可以一律都用統一的寫法加上ch_p。

留言

這個網誌中的熱門文章

KillSudoku 4顆星精彩數獨詳解 - 鍊技巧

這題數獨(sudoku)題目估計為4+顆星,有點難度。解題需要應用多種技巧,過程非常精彩有趣,是個好題。 底下使用 KillSudoku 作詳細圖解。 1,使用基本排除法則,可以簡單填入6個數字。到此為止,開始使用 候選數法 來解題。如下所示,為填入6個數字後的狀態圖。 2,如下圖,使用進階排除法,在第9列和第4行可以先排除幾個候選數。 3,如圖,在第2行有一個 Naked Subset (3,4),可以對3,4候選數作排除。附帶提一下,反過來看在同一行裡面也可以說有另一個Hidden Subset(2,5,8)存在。Naked Subset和Hidden Subset常是一體二面同時存在,只不過對我們來說,Naked Subset是相對比較容易看的出來。 排除第2行的3,4後,又可以對第2列以外的3作排除,如下圖。 4,接著,在第5行又發現了一個 Naked Subset (3,7,8)。 對第5行三個Subset以外的候選數3,7,8作排除後,又接著產生可以對第5行以外的3作排除。 5,這一題解到此為止,開始進入高潮。大部份能解到3顆星題目的人,猜想應該就此卡住。以下開始需要應用更高級的鍊技巧,才能夠繼續進行。 應用X-Chains鍊技巧,可以找到一條由4條強連結組成的鍊,可以排除候選數2。這裡的鍊指的是由2條以上的強連結組成,而所謂的強連結是指在同一行、或同一列或同一個Box裡,由唯二的候選數構成的連結。如上圖中的第9行中,只有二個2,這二個2構成一條強連結。為什麼說這是一條強連結?因為在這條連結的AB二個端點中,肯定會有一個2存在,要麼是A點要麼是B點。鍊技巧就是將多條強連結串連起來作候選數排除的技巧,而X-Chains是高級的鍊技巧裡面的基本技巧。 接上圖,這樣一來就又可以應用基本排除方法,填入3個數字,如下圖所示。 6,接下來就是本題最精彩的部份,以下需要連續找到3條鍊,才能繼續往下解。 7,找出3條鍊後,剩下來的部份就沒什麼特別的了,只需要應用基本法就能把所有剩餘數字填完。

生成小鎮1010 - 用 Ollama 製作一個在本機執行的生成式文字冒險小遊戲

  生成小鎮 1010 是一個利用在本機執行的大語言模型,動態的生成遊戲地圖及根據玩家互動生成故事劇情及任務內容的文字冒險小遊戲。 完成環境建置後,就可以到  Ollama 官網  下載模型,在本機開始測試遊戲。 處理器(CPU) 13th Gen Intel(R) Core(TM) i5-13500H 2.60 GHz 記憶體(RAM) 40.0 GB 顯示卡 Intel(R) Iris(R) Xe Graphics 128 M 系統 Windows 11 家用版 24H2 這是我的筆電規格。在我的筆電上跑  gemma3:4b-it-q4_K_M  模型,勉強可以玩。如果你有更強的硬體,就能夠利用更大的模型,得到更好的遊戲內容體驗。 gemma 是 Google 的開放的整合了視覺理解能力多模態大語言模型。4b: 是模型參數大小,表示 40 憶個參數。q4_K_M: 是一種減少模型大小和提高效率的技術,它涉及到將模型中的數值精度降低,並減少了對記憶體和計算資源的需求。 你也可以根據自己的需要或喜好,選擇下載不同模型。 [ GitHub ] 1. 準備工作 1.1 什麼是 Ollama Ollama 是一個可以在本機運行大型語言模型(LLM)如 LLaMA、Mistral、Gemma 等的開源工具。它的目的是讓開發者和使用者可以更輕鬆地在自己的電腦上執行和使用這些 AI 模型,而不需要依賴雲端服務。Ollama 的特點是: 本地運行模型  你可以直接在 Windows、macOS 或 Linux 上下載並運行語言模型,無需連網使用 API。 簡單的 CLI 工具  安裝後只需要輸入像 ollama run llama3 這樣的指令就可以開始對話。 支援多種模型  包含 Meta 的 LLaMA、Mistral、Gemma、Deepseek 等模型,也可以自訂模型。 開發整合容易  提供簡單的 API(RESTful),方便整合到你自己的應用程式、網頁或手機 App 中。 節省成本與提高隱私  不用連接 OpenAI、Anthropic 或其他雲端 LLM,降低使用成本,也不會洩漏資料。 1.2 安裝 Ollama 訪問  Ollama 官網 ,根據你的作業系...

單人撲克牌遊戲 - 蒙地卡羅

新增一個簡單的單人撲克牌遊戲: 蒙地卡羅 ,簡單介紹一下玩法。 下載 事先排列好5x5張牌。 每次移動一張可以配對的牌,並消除這對牌。在上下、左右及斜向相隣的二張牌,只要擁有同樣數字(不計花色),即可配對。 消除二張配對的牌後,剩餘的牌以往左往上的方式補滿空隙,接著在發新牌補滿後面的空格。 重覆步驟2~3,直到沒有牌可以配對及發完所有牌為止。 結果有二種。一個是勝利,成功的消除掉所有牌。另一個是Gameover沒有牌可以再作配對。