用C语言实现SGF格式围棋棋谱解析器
这是本人(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):
以下是一个简单的有一定代表性的SGF文本,先让大家有一个感性认识:
熟悉编写文本解析器的程序员朋友应该都清楚,根据EBNF定义,编写对应的解析器,是相当简单和直观的,貌似只是一项翻译性的工作。本人实现SGF解析器,再次印证了这个观点,大部分情况下,我只是按部就班地将EBNF翻译为C语言代码而已,呵呵。
我首先设计了“SGFParseContext”结构,用于保存解析器工作期间的相关数据:
相应的还有初始化和清理SGFParseContext结构的函数,initSGFParseContext, cleanupSGFParseContext,皆不是本解析器的关键,略过不提。
接着我(liigo)设计了五个回调函数的函数原形:
这五个回调函数,将分别在解析器解析到“树开始”“树结束”“节点开始”“节点结束”“遇到属性”时,由解析器调用。解析器调用每个回调函数时,都会传入必需的参数,供回调函数即时取用。
下面正式开始解析工作。整个解析器被分为 parseProperty, parseNode, parseNodeSequence, parseGameTree, parseSGF 几大部分顺序解析,属于至底向上的分析实现模式。这几大部分,也分别对应着SGF的EBNF定义中的某一项。所有解析函数都接收参数 const char* szCollection, int fromPos,之前的解析函数将决定后续解析函数的起始解析位置。
第一步,解析属性(parseProperty)。此处关键的是要定位到属性值(szValue)开始和结束符号“[”和“],两者之间的是属性值,“[”之前的则是属性标识(szID)。由于[和]之间可能存在转义字符“\”,不能简单地搜索字符“]”,必须花相当篇幅的代码处理转义字符(我用局部变量in_escape记录转义状态并进行分别处理)。此外要为提取出的属性标识和属性值分配足够的存储空间,以便传递到用户回调函数,前者不会太长使用静态分配,后者变长则使用动态分配(同时自动预分配存储空间,缓存,避免频繁申请内存)。代码如下:
第二步,解析节点(parseNode)。分号“;”跟后面N个属性,一个while循环调用parseProperty()逐个解析属性即可:
第三步,解析节点序列(parseNodeSequence)。节点的顺序排列,至少有一个节点,后面可能还有0个或多个节点。仍然是一个while循环搞定:
第四步,解析树(parseGameTree)。树是一个嵌套结构,最外层是一对括号“(”“)”,里面是N个节点序列或N个嵌套的子树。仍然用一个while循环搞定,遇到“(”则递归调用parseGameTree()解析树或其子树,否则调用parseNodeSequence()解析节点序列。代码如下:
第五步,最后一步了,解析整个SGF文本内容(parseSGF)。这是对外公开的核心接口。N个树的顺序排列,好办呀,循环调用parseGameTree()顺序解析各个树不就OK了?代码如下:
测试代码:
总结:整个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