這是本人(liigo)獨立實現的SGF格式圍棋棋譜檔案解析器,本文介紹其實現細節。網路上肯定可以找到完善的開源的SGF解析器,這是毋庸置疑的,我不直接使用它們,也不參考它們的實現代碼,而是自己獨立編碼實現,是有原因的,因為我想自己重複發明輪子,並且認為這樣更有助於提高我的編碼能力。(關於我的“一定要學會重複發明輪子”的不成熟的論調,今後我將會專門撰文表述。)
我(liigo)開發的這個SGF解析器,採用基於事件的簡單API,類似於XML解析器中的SAX(Simple API for XML)。這種解析器的核心是:由使用者事先提供一系列回呼函數,解析器在解析的過程中,依次調用相關的回呼函數並傳入相應參數,使用者程式在回呼函數中做出相應的處理。此類解析器屬於輕量級的解析器,解析速度快,佔用記憶體少,結構清晰易於實現,只是相對來說不如基於DOM的解析器方便使用。
SGF格式,Smart Game Format,被設計用來記錄多種遊戲類棋譜的通用格式,在圍棋領域被發揚光大,是用於描述圍棋棋譜的最重要也最通用的形式。它是純文字的、基於樹(TREE)的結構,便於識別、儲存和傳輸。其格式簡潔實用,也非常易於編程解析。SGF格式官方規範網址為:http://www.red-bean.com/sgf/。(說到圍棋棋譜,不得不讚歎一下,它只需用一幅圖就可以完整還原一盤棋從始至終的風雲變幻;作為對比,象棋一幅圖只能描述對弈中某一時刻的情境。)
SGF的主要結構由樹(GameTree)、節點序列(Sequence)、節點(Node)、屬性(Property)等組成。其中“屬性”為最重要的基本單位,它由屬性標識(PropIdent)和屬性值(PropValue)組成。由分號“;”分隔的多個屬性,稱為節點。多個節點順序排列稱為節點序列。由括弧“(”“)”括起來的節點序列,稱為樹,樹中可包含子樹。SGF的EBNF定義如下(參見http://www.red-bean.com/sgf/sgf4.html#ebnf-def):
Collection = GameTree { GameTree }<br />GameTree = "(" Sequence { GameTree } ")"<br />Sequence = Node { Node }<br />Node = ";" { Property }<br />Property = PropIdent PropValue { PropValue }<br />PropIdent = UcLetter { UcLetter }<br />PropValue = "[" CValueType "]"<br />CValueType = (ValueType | Compose)<br />ValueType = (None | Number | Real | Double | Color | SimpleText | Text | Point | Move | Stone)
以下是一個簡單的有一定代表性的SGF文本,先讓大家有一個感性認識:
(;FF[4]GM[1]SZ[19]FG[257:Figure 1]PM[1]<br />PB[Takemiya Masaki]BR[9 dan]PW[Cho Chikun]<br />WR[9 dan]RE[W+Resign]KM[5.5]TM[28800]DT[1996-10-18,19]<br />EV[21st Meijin]RO[2 (final)]SO[Go World #78]US[Arno Hollosi]<br />;B[pd];W[dp];B[pp];W[dd];B[pj];W[nc];B[oe];W[qc];B[pc];W[qd]<br />(;B[qf];W[rf];B[rg];W[re];B[qg];W[pb];B[ob];W[qb]<br />(;B[mp];W[fq];B[ci];W[cg];B[dl];W[cn];B[qo];W[ec];B[jp];W[jd]<br />;B[ei];W[eg];B[kk]LB[qq:a][dj:b][ck:c][qp:d]N[Figure 1]</p><p>;W[me]FG[257:Figure 2];B[kf];W[ke];B[lf];W[jf];B[jg]<br />(;W[mf];B[if];W[je];B[ig];W[mg];B[mj];W[mq];B[lq];W[nq]<br />(;B[lr];W[qq];B[pq];W[pr];B[rq];W[rr];B[rp];W[oq];B[mr];W[oo];B[mn]<br />(;W[nr];B[qp]LB[kd:a][kh:b]N[Figure 2]</p><p>;W[pk]FG[257:Figure 3];B[pm];W[oj];B[ok];W[qr];B[os];W[ol];B[nk];W[qj]<br />;B[pi];W[pl];B[qm];W[ns];B[sr];W[om];B[op];W[qi];B[oi]<br />(;W[rl];B[qh];W[rm];B[rn];W[ri];B[ql];W[qk];B[sm];W[sk];B[sh];W[og]<br />;B[oh];W[np];B[no];W[mm];B[nn];W[lp];B[kp];W[lo];B[ln];W[ko];B[mo]<br />;W[jo];B[km]N[Figure 3])</p><p>(;W[ql]VW[ja:ss]FG[257:Dia. 6]MN[1];B[rm];W[ph];B[oh];W[pg];B[og];W[pf]<br />;B[qh];W[qe];B[sh];W[of];B[sj]TR[oe][pd][pc][ob]LB[pe:a][sg:b][si:c]<br />N[Diagram 6]))</p><p>(;W[no]VW[jj:ss]FG[257:Dia. 5]MN[1];B[pn]N[Diagram 5]))</p><p>(;B[pr]FG[257:Dia. 4]MN[1];W[kq];B[lp];W[lr];B[jq];W[jr];B[kp];W[kr];B[ir]<br />;W[hr]LB[is:a][js:b][or:c]N[Diagram 4]))</p><p>(;W[if]FG[257:Dia. 3]MN[1];B[mf];W[ig];B[jh]LB[ki:a]N[Diagram 3]))</p><p>(;W[oc]VW[aa:sk]FG[257:Dia. 2]MN[1];B[md];W[mc];B[ld]N[Diagram 2]))</p><p>(;B[qe]VW[aa:sj]FG[257:Dia. 1]MN[1];W[re];B[qf];W[rf];B[qg];W[pb];B[ob]<br />;W[qb]LB[rg:a]N[Diagram 1]))
熟悉編寫文本解析器的程式員朋友應該都清楚,根據EBNF定義,編寫對應的解析器,是相當簡單和直觀的,貌似只是一項翻譯性的工作。本人實現SGF解析器,再次印證了這個觀點,大部分情況下,我只是按部就班地將EBNF翻譯為C語言代碼而已,呵呵。
我首先設計了“SGFParseContext”結構,用於儲存解析器工作期間的相關資料:
typedef struct _tagSGFParseContext<br />{<br /> void* pUserData;<br /> int treeIndex;</p><p> PFN_ON_TREE pfnOnTree;<br /> PFN_ON_TREE_END pfnOnTreeEnd;<br /> PFN_ON_NODE pfnOnNode;<br /> PFN_ON_NODE_END pfnOnNodeEnd;<br /> PFN_ON_PROPERTY pfnOnProperty;</p><p> char idBuffer[16];<br /> char* valueBuffer;<br /> int valueBufferSize;<br />}<br />SGFParseContext;
相應的還有初始化和清理SGFParseContext結構的函數,initSGFParseContext, cleanupSGFParseContext,皆不是本解析器的關鍵,略過不提。
接著我(liigo)設計了五個回呼函數的函數原形:
typedef void (*PFN_ON_TREE) (SGFParseContext* pContext, const char* szTreeHeader, int treeIndex);<br />typedef void (*PFN_ON_TREE_END) (SGFParseContext* pContext, int treeIndex);<br />typedef void (*PFN_ON_NODE) (SGFParseContext* pContext, const char* szNodeHeader);<br />typedef void (*PFN_ON_NODE_END) (SGFParseContext* pContext);<br />typedef void (*PFN_ON_PROPERTY) (SGFParseContext* pContext, const char* szID, const char* szValue);
這五個回呼函數,將分別在解析器解析到“樹開始”“樹結束”“節點開始”“節點結束”“遇到屬性”時,由解析器調用。解析器調用每個回呼函數時,都會傳入必要參數,供回呼函數即時取用。
下面正式開始解析工作。整個解析器被分為 parseProperty, parseNode, parseNodeSequence, parseGameTree, parseSGF 幾大部分順序解析,屬於至底向上的分析實現模式。這幾大部分,也分別對應著SGF的EBNF定義中的某一項。所有解析函數都接收參數 const char* szCollection, int fromPos,之前的解析函數將決定後續解析函數的起始解析位置。
第一步,解析屬性(parseProperty)。此處關鍵的是要定位到屬性值(szValue)開始和結束符號“[”和“],兩者之間的是屬性值,“[”之前的則是屬性標識(szID)。由於[和]之間可能存在逸出字元“/”,不能簡單地搜尋字元“]”,必須花相當篇幅的代碼處理逸出字元(我用局部變數in_escape記錄轉義狀態並進行分別處理)。此外要為提取出的屬性標識和屬性值分配足夠的儲存空間,以便傳遞到使用者回呼函數,前者不會太長使用靜態分配,後者變長則使用動態分配(同時自動預分配儲存空間,緩衝,避免頻繁申請記憶體)。代碼如下:
//Property: id[value]<br />int parseProperty(SGFParseContext* pContext, const char* szCollection, int fromPos)<br />{<br /> const char* szFromPos;<br /> int lindex;<br /> int nIDBufferSize = sizeof(pContext->idBuffer) - 1;<br /> assert(szCollection && fromPos >= 0);<br /> szFromPos = szCollection + fromPos;</p><p> lindex = findchar(szFromPos, -1, '[');<br /> assert(lindex > 0 && lindex < nIDBufferSize);<br /> if(lindex > 0 && lindex < nIDBufferSize)<br /> {<br /> memcpy(pContext->idBuffer, szFromPos, lindex);<br /> pContext->idBuffer[lindex] = '/0';</p><p> if(isTextPropertyID(pContext->idBuffer))<br /> {<br /> //parse the text or simple-text value, consider the '/' escape character<br /> const char* s = szFromPos + lindex + 1;<br /> char c;<br /> int in_escape = 0;<br /> int valuelen = 0;<br /> getEnoughBuffer(pContext, 1024);<br /> pContext->valueBuffer[0] = '/0';<br /> while(1)<br /> {<br /> c = *s;<br /> assert(c);<br /> if(!in_escape)<br /> {<br /> if(c == '//')<br /> {<br /> in_escape = 1;<br /> }<br /> else if(c == ']')<br /> {<br /> break;<br /> }<br /> else<br /> {<br /> getEnoughBuffer(pContext, valuelen + 1);<br /> pContext->valueBuffer[valuelen++] = c;<br /> }<br /> }<br /> else<br /> {<br /> //ignore the newline after '/'<br /> if(c != '/r' && c != '/n')<br /> {<br /> getEnoughBuffer(pContext, valuelen + 1);<br /> pContext->valueBuffer[valuelen++] = c;<br /> }<br /> else<br /> {<br /> char nc = *(s+1);<br /> if(nc)<br /> {<br /> if((c=='/r' && nc=='/n') || (c=='/n' && nc=='/r'))<br /> s++;<br /> }<br /> }<br /> in_escape = 0;<br /> }<br /> s++;<br /> }<br /> getEnoughBuffer(pContext, valuelen + 1);<br /> pContext->valueBuffer[valuelen] = '/0';</p><p> if(pContext->pfnOnProperty)<br /> pContext->pfnOnProperty(pContext, pContext->idBuffer, pContext->valueBuffer);</p><p> return (s - szCollection + 1);<br /> }<br /> else<br /> {<br /> int rindex = findchar(szFromPos, -1, ']');<br /> int nNeedBufferSize = rindex - lindex - 1;<br /> assert(rindex >= 0);<br /> getEnoughBuffer(pContext, nNeedBufferSize);<br /> memcpy(pContext->valueBuffer, szFromPos + lindex + 1, nNeedBufferSize);<br /> pContext->valueBuffer[nNeedBufferSize] = '/0';</p><p> if(pContext->pfnOnProperty)<br /> pContext->pfnOnProperty(pContext, pContext->idBuffer, pContext->valueBuffer);</p><p> return (fromPos + rindex + 1);<br /> }<br /> }<br /> return -1;<br />}
第二步,解析節點(parseNode)。分號“;”跟後面N個屬性,一個while迴圈調用parseProperty()逐個解析屬性即可:
//Node: ; {property}<br />int parseNode(SGFParseContext* pContext, const char* szCollection, int fromPos)<br />{<br /> const char* szFromPos = szCollection + fromPos;<br /> assert(fromPos >= 0);<br /> //assert(szFromPos[0] == ';');</p><p> if(pContext->pfnOnNode)<br /> pContext->pfnOnNode(pContext, szFromPos);</p><p> if(szFromPos[0] == ';')<br /> {<br /> fromPos++; szFromPos++;<br /> }</p><p> while(1)<br /> {<br /> fromPos += skipSpaceChars(szFromPos, NULL);<br /> if(szCollection[fromPos] == '/0' || findchar(";)(", -1, szCollection[fromPos]) >= 0)<br /> break;<br /> fromPos = parseProperty(pContext, szCollection, fromPos);<br /> szFromPos = szCollection + fromPos;<br /> }<br /> return fromPos;<br />}
第三步,解析節點序列(parseNodeSequence)。節點的順序排列,至少有一個節點,後面可能還有0個或多個節點。仍然是一個while迴圈搞定:
//NodeSequence: node{node}<br />int parseNodeSequence(SGFParseContext* pContext, const char* szCollection, int fromPos)<br />{<br /> const char* szFromPos = szCollection + fromPos;<br /> assert(fromPos >= 0);<br /> //assert(szFromPos[0] == ';');<br /> while(1)<br /> {<br /> fromPos = parseNode(pContext, szCollection, fromPos);<br /> fromPos += skipSpaceChars(szFromPos, NULL);<br /> szFromPos = szCollection + fromPos;<br /> if(szFromPos[0] != ';')<br /> {<br /> if(pContext->pfnOnNodeEnd)<br /> pContext->pfnOnNodeEnd(pContext);<br /> break;<br /> }<br /> }<br /> return fromPos;<br />}
第四步,解析樹(parseGameTree)。樹是一個嵌套結構,最外層是一對括弧“(”“)”,裡面是N個節點序列或N個嵌套的子樹。仍然用一個while迴圈搞定,遇到“(”則遞迴調用parseGameTree()解析樹或其子樹,否則調用parseNodeSequence()解析節點序列。代碼如下:
//GameTree: ( {[NodeSequence]|[GameTree]} )<br />//old GameTree: ( NodeSequence {GameTree} )<br />int parseGameTree(SGFParseContext* pContext, const char* szCollection, int fromPos)<br />{<br /> char c;<br /> const char* szFromPos = szCollection + fromPos;<br /> assert(fromPos >= 0);<br /> assert(szFromPos[0] == '(');</p><p> pContext->treeIndex++;<br /> if(pContext->pfnOnTree)<br /> pContext->pfnOnTree(pContext, szFromPos, pContext->treeIndex);</p><p> fromPos++; szFromPos++;<br /> fromPos += skipSpaceChars(szFromPos, NULL);</p><p> c = szCollection[fromPos];<br /> while(1)<br /> {<br /> if(c == '(')<br /> fromPos = parseGameTree(pContext, szCollection, fromPos);<br /> else<br /> fromPos = parseNodeSequence(pContext, szCollection, fromPos);</p><p> szFromPos = szCollection + fromPos;<br /> fromPos += skipSpaceChars(szFromPos, NULL);<br /> c = szCollection[fromPos];<br /> if(c == ')')<br /> {<br /> if(pContext->pfnOnTreeEnd)<br /> pContext->pfnOnTreeEnd(pContext, pContext->treeIndex);<br /> pContext->treeIndex--;<br /> break;<br /> }<br /> }</p><p> return (fromPos + 1);<br />}
第五步,最後一步了,解析整個SGF常值內容(parseSGF)。這是對外公開的核心介面。N個樹的順序排列,好辦呀,迴圈調用parseGameTree()順序解析各個樹不就OK了?代碼如下:
//SGFCollection: GameTree {GameTree}<br />int parseSGF(SGFParseContext* pContext, const char* szCollection, int fromPos)<br />{<br /> const char* szFromPos = szCollection + fromPos;<br /> assert(fromPos >= 0);<br /> assert(szFromPos[0] == '(');<br /> pContext->treeIndex = -1;<br /> while(1)<br /> {<br /> fromPos = parseGameTree(pContext, szCollection, fromPos);<br /> fromPos += skipSpaceChars(szFromPos, NULL);<br /> szFromPos = szCollection + fromPos;<br /> if(szFromPos[0] != '(')<br /> break;<br /> }<br /> return fromPos;<br />}
測試代碼:
int main(int argc, char *argv[])<br />{<br /> char* s;<br /> int x;<br /> SGFParseContext Context;<br /> //initSGFParseContext(&Context, onTree, onTreeEnd, onNode, onNodeEnd, onProperty, NULL);<br /> initSGFParseContext(&Context, onTree2, onTreeEnd2, onNode2, onNodeEnd2, onProperty2, NULL);</p><p> //test parse property:<br /> {<br /> s = "AB[cdef]X[xyz]";<br /> printf("/ntest parse property: ----- /n");<br /> x = parseProperty(&Context, s, 0);<br /> x = parseProperty(&Context, s, 8);<br /> s = "C[ab//]cd]";<br /> x = parseProperty(&Context, s, 0);<br /> }<br /> //test parse node:<br /> {<br /> s = ";A[a]BB[bb]C[]";<br /> printf("/ntest parse node: ----- /n");<br /> x = parseNode(&Context, s, 0);<br /> s = ";A[a];BB[bb]C[]";<br /> x = parseNode(&Context, s, 0);<br /> x = parseNodeSequence(&Context, s, 0);<br /> }<br /> //test parse tree:<br /> {<br /> printf("/ntest parse tree: ----- /n");<br /> s = "(;A[a](;C[c](X[x])Z[z]);D[d](;E[e](F[ff])))";<br /> x = parseGameTree(&Context, s, 0);<br /> }</p><p>#if 1<br /> //parse real sgf file:<br /> {<br /> int len = 0;<br /> void* data = NULL;<br /> FILE* pfile = fopen("d://x.txt", "r");<br /> printf("/n---------- test parse real sgf file: -------- /n");<br /> if(pfile)<br /> {<br /> fseek(pfile, 0, SEEK_END);<br /> len = ftell(pfile);<br /> assert(len > 0);<br /> fseek(pfile, 0, SEEK_SET);<br /> data = malloc(len);<br /> assert(data);<br /> fread(data, 1, len, pfile);</p><p> parseSGF(&Context, data, 0);</p><p> fclose(pfile);<br /> pfile = NULL;<br /> }<br /> }<br />#endif</p><p> {<br /> char c;<br /> printf("/n----- any key to exit: ----- /n");<br /> fflush(stdout);<br /> scanf("%c", &c);<br /> }<br />}
總結:整個SGF解析器結構比較清晰,只要按照EBNF定義,按部就班地逐步處理即可,不是特別複雜。但由於牽涉到文本、指標、遞迴,有許多細節需要注意。各位朋友不妨評估一下,自己需要花費多久可以寫出類似這樣一個SGF解析器?如果時間充裕,也不妨真的動手寫一下,看看是否眼高手低呢?所謂的“重複發明輪子”,並非絕對的毫無意義,至少可以鍛煉我的動手能力。
另外,有一個設計上的取捨,不知是較好還是較壞。所有的回呼函數,目前都有一個 SGFParseContext* pContext ,而此前相同位置的參數是 void* pUserData。是後來考慮到回呼函數可能需要訪問SGFParseContext中的相關資料(如在PFN_ON_NODE中讀取treeIndex),為了方便使用者使用才引入pContext參數(使用者也可以通過pUserData自行傳入pContext,終究是多了一步)。目前的做法,似乎暴露瞭解析器內部結構(SGFParseContext),又似乎增強了回呼函數的穩定性和擴充性(即使不改變函數原形也能通過pContext提供額外參數)。
雖然這個SGF解析器已應用到開源軟體“M8圍棋譜”(http://code.google.com/p/m8weiqipu/)中,並初步達到了實用目的,但並不能保證該解析器已達到工業強度,其實有不少情況尚未測試到,必然會有疏忽錯漏之處,誠請各位朋友批評指正。
另注,考慮到與現有SGF格式檔案的相容性,對SGF規範中的EBNF稍做了一定擴充。
完整原始碼請參見:
http://code.google.com/p/m8weiqipu/source/browse/trunk/sgf.h
http://code.google.com/p/m8weiqipu/source/browse/trunk/sgf.c