2010年1月17日
#
在我自己写的一个工厂类实现中,每个产品会注册创建接口到这个工厂类。工厂类使用这些
注册进来的创建接口来完成产品的创建。其结构大致如下:
product *factory::create( long product_type )
{
creator c = m_creators[product_type];
return c();
}
factory::instance().register( PRODUCT_A_TYPE, productA::create );
...
factory::instance().create( PRODUCT_A_TYPE );
这个很普通的工厂实现中,需要写上很多注册代码。每次添加新的产品种类时,也需要修改
这些的注册代码。而恰好,这些注册代码可能会被放在一个统一的地方。为了消除这个地方
,我使用了偶然间看到的<Modern C++ design>里的做法:
const bool _local = factory::instance().register( PRODUCT_A_TYPE,...
也就是说,通过对全局常量_local的自动初始化,来自动完成对该产品的注册。
结果,因为这些代码全部被放置于一个静态库。最终的代码文件结构大致为:
lib
- product_a.cpp : 定义了全局常量_local
- product_a.h
- factory.cpp
- factory.h
exe
- main.cpp
现在看起来世界很美,因为factory甚至不知道世界上还有个跟上层逻辑相关的product_a。
这种模块耦合几乎为0的结构让我窃喜。
悲剧的事情首先发生于,开VC调试器,发现打在product_a.cpp里的断点失效。就是那个总
是提示说没有为该文件加载调试符号。开始还不在意,以为又是代码和调试符号文件不匹配
的原因,折腾了好久,不得其果。
后来分析了下,发现这个调试提示,就像我开着调试器打开了一个非本工程的代码文件,而
断点就打在这个文件里一样。也就是说,VC把我product_a.cpp当成不是这个工程里的代码
文件。
按照这个思路写些实验代码,最终发现问题所在:VC链接器根本没链接进product_a.cpp里
的代码。表现出来的情况就是,该编译单元里的全局常量(全局变量一样)根本没有得到初
始化,因为我跟到factory::register并没有被调用到。为什么VC不链接这个编译单元对应
的目标文件?或者说,为什么VC不初始化这个全局常量?
原因就在于,product_a.cpp太独立了。一个在整个编译链接阶段都无法确定该文件是否被
使用的文件,VC就直接不链接了。相反,当在factory.cpp里写下类似代码:
void test()
{
product_a obj;
}
虽然说test函数不会被调用,一切情况也变得正常了。好了,不扯了,给最后我的结论:
1、如果静态库中某个编译单元在编译阶段被确认为它并没有被外部使用,那么当这个静态
库被链接进可执行文件时,链接器忽略掉该编译单元里的代码,那么,链接器自然也不会为
该编译单元里出现的全局变量常量生成初始化代码(关于这部分初始化代码可以阅读
<linker and loader>一书);
2、上面那条结论存在一种传染性,意思是,当可执行文件里的代码使用到静态库中文件A里
的代码,A里又有地方使用到B里的代码,那么B依然会被链接。这种依赖性,应该可以让编
译器在编译阶段就发现(显然,上面我举的例子里,factory只有在运行期间才会依赖到
product_a.cpp里的代码)
很早前在折腾挂起LUA脚本支持时,接触到lua_yield这个函数。lua manual中给的解释是:
This function should only be called as the return expression of a C function。
而这个函数一般是在一个注册到LUA环境中的C函数里被调用。lua_CFunction要求的原型里
,函数的返回值必须返回要返回到LUA脚本中的值的个数。也就是说,在一个不需要挂起的
lua_CFunction实现里,也就是一个不需要return lua_yield(...的实现里,我应该return
一个返回值个数。
但是为什么调用lua_yield就必须放在return表达式里?当时很天真,没去深究,反正发现
不按照lua manual里说的做就是不行。而且关键是,lua manual就不告诉你为什么。
最近突然就想到这个问题,决定去搞清楚这个问题。侯捷说了,源码面前了无秘密。我甚至
在看代码之前,还琢磨着LUA是不是操作了堆栈(系统堆栈)之类的东西。结果随便跟了下
代码真的让我很汗颜。有时候人犯傻了真的是一个悲剧。诺简单的一个问题会被人搞得很神
秘:
解释执行调用一个注册进LUA的lua_CFunction是在ldo.c里的luaD_precall函数里,有如下
代码:
n = (*curr_func(L)->c.f)(L); /* do the actual call */
lua_lock(L);
if (n < 0) /* yielding? */
return PCRYIELD;
else {
luaD_poscall(L, L->top - n);
return PCRC;
}
多的我就不说了,别人注释写得很清楚了,注册进去的lua_CFunction如果返回值小于0,这
个函数就向上层返回PCRYIELD,从名字就可看出是告诉上层需要YIELD。再找到lua_yield函
数的实现,恰好该函数就返回-1。
要再往上层跟,会到lvm.c里luaV_execute函数,看起来应该就是虚拟机在解释执行指令:
case OP_CALL: {
int b = GETARG_B(i);
int nresults = GETARG_C(i) - 1;
if (b != 0) L->top = ra+b; /* else previous instruction set top */
L->savedpc = pc;
switch (luaD_precall(L, ra, nresults)) {
case PCRLUA: {
nexeccalls++;
goto reentry; /* restart luaV_execute over new Lua function */
}
case PCRC: {
/* it was a C function (`precall' called it); adjust results */
if (nresults >= 0) L->top = L->ci->top;
base = L->base;
continue;
对于PCRYIELD返回值,直接忽略处理了。
2009年8月23日
#
用途
在一个UI与逻辑模块交互比较多的程序中,因为并不想让两个模块发生太大的耦合,基本目标是
可以完全不改代码地换一个UI。逻辑模块需要在产生一些事件后通知到UI模块,并且在这个通知
里携带足够多的信息(数据)给接收通知的模块,例如UI模块。逻辑模块还可能被放置于与UI模
块不同的线程里。
最初的结构
最开始我直接采用最简单的方法,逻辑模块保存一个UI模块传过来的listener。当有事件发生时,
就回调相应的接口将此通知传出去。大致结构如下:

/**//// Logic
class EventNotify

{
public:
virtual void OnEnterRgn( Player *player, long rgn_id );
};


/**//// UI
class EventNotifyImpl : public EventNotify

{
};


/**//// Logic
GetEventNotify()->OnEnterRgn( player, rgn_id );

但是,在代码越写越多之后,逻辑模块需要通知的事件越来越多之后,EventNotify这个类开始
膨胀:接口变多了、不同接口定义的参数看起来也越来越恶心了。
改进
于是我决定将各种事件通知统一化:
struct Event


{
long type; // 事件类型
// 附属参数
};
这样,逻辑模块只需要创建事件结构,两个模块间的通信就只需要一个接口即可:
void OnNotify( const Event &event );
但是问题又来了,不同的事件类型携带的附属参数(数据)不一样。也许,可以使用一个序列化
的组件,将各种数据先序列化,然后在事件处理模块对应地取数据出来。这样做总感觉有点大动
干戈了。当然,也可以使用C语言里的不定参数去解决,如:
void OnNotify( long event_type, ... )
其实,我需要的就是一个可以表面上类型一样,但其内部保存的数据却多样的东西。这样一想,
模块就能让事情简单化:
template <typename P1, typename P2>
class Param


{
public:
Param( P1 p1, P2 p2 ) : _p1( p1 ), _p2( p2 )

{
}
P1 _p1;
P2 _p2;
};

template <typename P1, typename P2>
void OnNotify( long event_type, Param<P1, P2> param );

GetNotify()->OnNotify( ET_ENTER_RGN, Param<Player*, long>( player, rgn_id ) );
GetNotify()->OnNotify( ET_MOVE, Param<long, long>( x, y ) );

在上面这个例子中,虽然通过Param的包装,逻辑模块可以在事件通知里放置任意类型的数据,但
毕竟只支持2个参数。实际上为了实现支持多个参数(起码得有15个),还是免不了自己实现多个
参数的Param。
幸亏我以前写过宏递归产生代码的东西,可以自动地生成这种情况下诸如Param1、Param2的代码。
如:
#define CREATE_PARAM( n ) \
template <DEF_PARAM( n )> \
struct Param##n \

{ \
DEF_PARAM_TYPE( n ); \
Param##n( DEF_FUNC_PARAM( n ) ) \

{ \
DEF_MEM_VAR_ASSIGN( n ); \
} \
DEF_VAR_DEF( n ); \
}

CREATE_PARAM( 1 );
CREATE_PARAM( 2 );

即可生成Param1和Param2的版本。其实这样定义了Param1、Param2的东西之后,又使得OnNotify
的参数不是特定的了。虽然可以把Param也泛化,但是在逻辑层写过多的模板代码,总感觉不好。
于是又想到以前写的一个东西,可以把各种类型包装成一种类型---对于外界而言:any。any在
boost中有提到,我只是实现了个简单的版本。any的大致实现手法就是在内部通过多态机制将各
种类型在某种程度上隐藏,如:
class base_type

{
public:
virtual ~base_type()

{
}
virtual base_type *clone() const = 0;
};
template <typename _Tp>
class var_holder : public base_type

{
public:
typedef _Tp type;
typedef var_holder<type> self;
public:
var_holder( const type &t ) : _t( t )

{
}

base_type *clone() const

{
return new self( _t );
}
public:
type _t;
}

这样,any类通过一个base_type类,利用C++多态机制即可将类型隐藏于var_holder里。那么,
最终的事件通知接口成为下面的样子:
void OnNotify( long type, any data );
OnNotify( ET_ENTER_RGN, any( create_param( player, rgn_id ) ) );其中,create_param
是一个辅助函数,用于创建各种Param对象。
事实上,实现各种ParamN版本,让其名字不一样其实有点不妥。还有一种方法可以让Param的名字
只有一个,那就是模板偏特化。例如:
template <typename _Tp>
struct Param;

template <>
struct Param<void()>;

template <typename P1>
struct Param<void(P1)>

template <typename P1, typename P2>
struct Param<void(P1,P2)>

这种方法主要是通过组合出一种函数类型,来实现偏特化。因为我觉得构造一个函数类型给主模版,
并不是一种合情理的事情。但是,即使使用偏特化来让Param名字看起来只有一个,但对于不同的
实例化版本,还是不同的类型,所以还是需要any来包装。
实际使用
实际使用起来让我觉得非常赏心悦目。上面做的这些事情,实际上是做了一个不同模块间零耦合
通信的通道(零耦合似乎有点过激)。现在逻辑模块通知UI模块,只需要定义新的事件类型,在
两边分别写通知和处理通知的代码即可。
PS:
针对一些评论,我再解释下。其实any只是用于包装Param列表而已,这里也可以用void*,再转成
Param*。在这里过多地关注是用any*还是用void*其实偏离了本文的重点。本文的重点其实是Param:
OnNotify( NT_ENTER_RGN, ang( create_param( player, rgn_id ) ) );

->
void OnNotify( long type, any data )


{
Param2<Player*, long> ParamType;
ParamType *p = any_cast<ParamType>( &data );
Player *player = p->p1;
long rgn_id = p->p2;
}


下载相关代码
2009年8月15日
#
早就听说bcb(borland c++ builder)是一个强大的RAD开发工具,也早就听说曾经的borland搞出的编译器堪称经典。
恰好最近在做一个GUI工具,想在界面开发上尽量快一点。每一次用上MFC都让我觉得浑身难受,总有些常用的
界面功能它就是没有。在接口实现上,MFC基本上就只是封装了WIN API而已。想想世界上还有什么强大的GUI库,
找了一下,其实不管GUI库封装的怎么样,我更多地还是需要一个工具,能够快速地堆积出界面。
于是,在网上下载了被国人精简了的bcb2009。然后,噩梦开始了。首先,我需要把逻辑层代码(也就是实现具体
功能的那一层)移植到BCB下。然后得到了很多和语法相关的编译错误:
1.
E2397: Template argument cannot have static or local linkage
这个错误发生于:
void func()
{
struct Info
{
};
std::queue<Info> abc;
}
它的意思是,模板参数必须是全局链接的,总之它不允许std::queue的参数是一个在函数内部临时定义
的类型(谁来告诉我这是C++标准)。
2.
E2357 Reference initialized with 'FileLoader::RawData', needs lvalue of type 'FileLoader::RawData'
这个错误发生于:
FileLoader::RawData FileLoader::GetRawData() const;
FileLoader::RawData &raw = loader.GetRawData(); //不能用引用
很久没看C++书,所以,谁又来告诉我C++标准里,这里到底能不能用引用?
3.
E2515 Cannot explicitly specialize a member of a generic template class
这个错误发生的情景更复杂些:
template <typename _Tp>
class Test
{
template <typename _U>
class Other;
template <>
class Other<void>
{
};
};
意思是说,我不能在一个模板类里特化成员模板类。谁又来告诉我标准规定的是什么?
4.
void func( Obj &a )
{
}
func( Obj() );这个也被视为错误。必须得在调用func之前自己定义个临时变量。
5.
我曾经留下了关于宏递归的一些代码,被用在我写的lua-binder和lua-caller中自动生成代码。这下好了,
BCB开始警告我,我的这些宏不能工作了。它和MSVC在某些事情上分歧可真是大:
#define PARAM( n ) ,typename P##n //注意这个宏包含一个逗号
#define CHR( x, y ) CHR1( x, y )
#define CHR1( x, y ) x##y
#define BCB_ERROR( a, b ) CHR( a, b )
BCB_ERROR( 1, PARAM( 1 ) ) 当这样使用宏时,基于我在GNU C上看到的关于宏的规则,会先展开
PARAM(1),于是得到BCB_ERROR( 1, ,typename P2 )。然后,BCB认为PARAM(1)展开的逗号需要参与
BCB_ERROR的展开了。于是,我的整个宏库无法工作了。
关于这个问题,我直接用MSVC写了个生成器,让MSVC替我生成各种参数的lua-binder和lua-caller,然后
写成外部头文件,最后直接在BCB里包含了这些头文件。从而使我的lua-binder和lua-caller可以继续使用。
然后,我的1W多行代码终于在BCB下50多个WARNINGS的提示下编译成功了。怀揣着兴奋的心情,想自己终
于可以rapid开发界面了。创建了个VCL FORM APPLICATION,噩梦又开始了:
1.
BCB莫名其妙地在我编译一个CPP文件时给出如下提示:
F1004 Internal compiler error at 0x59b4ea8 with base 0x5980000
看起来像是BCB的编译器给崩溃了。囧。google了一下,发现不是我人品问题,很多人遇到相同的问题。
别人给出的解决方案是:restart your bcb。从昨天晚上到现在为止,这个错误发生了好几次。
2.
new std::ofstream();会让程序崩溃,往不该写的地方写了东西。我就奇怪了,你BCB自己带的C++IO实现,
难道还有BUG?再次google,还真发现是BCB自己的BUG,并且在几个版本之前就存在这个BUG。那个天真
的老外还说希望在BCB2009下能被修复。修改方案如下:
1)xlocale文件里把这句话注释了:*(size_t *)&table_size = 1 << CHAR_BIT;
2)xlocale里把成员_Id_cnt访问属性改为public,然后在自己的文件里定义一次。
3.
程序终于可以运行了。但是BCB的IDE环境总是不那么贴心。我移动了几个窗口改成我习惯的样子,但是一重启
居然又恢复成default(难道是因为盗版)。它的智能提示似乎总是跟着鼠标指针,有时候指向某个符号,鼠标
就显示忙。为了提示某个类的成员,某个函数的原型,BCB偶尔都会卡一下。其实我不介意我的编辑器没有这
些提示功能,在MSVC下我也从不用VA来帮我写代码。我甚至不厌其烦地在VIM下敲代码切窗口去看函数原型,
但是,你他妈作为一个IDE就得像个IDE的样子,要不,你干脆关掉所有功能,别给我卡就行了。
这个时候我开始怀疑选择BCB会不会是一个错误的开始,或者说在使用某个东西时,总会带着使用其他同类东西
的感觉甚至偏见去看待这个新事物。但是,在我想坚持继续使用BCB时,我一compile,它又提示我:
F1004 Internal compiler error at 0x59b4ea8 with base 0x5980000
2009年6月28日
#
仔细想想能导致一个C++程序崩溃的几乎90%原因都是跟指针有关。空指针野指针,一不小心
程序就崩了。写C++程序的人基本上都知道这个问题。在我们周围避免这些问题的常规方法
也很多,诸如auto_ptr(及其他基于template的原始指针wrapper)、SAFE_DELETE。当然也
会有很多人在实现一个函数时会很勤劳地对每一个parameter进行合法判断。
其实,我们都知道,auto_ptr这些东西始终是无法避免野指针和空指针带来的灾难。
SAFE_DELETE也不能阻止别人使用这个空指针。
在我看过的一些开源项目的代码中,这些代码给人的感觉就是别人总能详细地掌控各种资源
(包括指针及其他变量)的使用情况。相比之下,公司隔壁组的老大则显得保守很多。他要
求我们几乎要对所有指针的使用进行空值判断(野指针也判断不了),当然,各种成员变量
也要进行即使现在看上去没多大用的初始化。
也许,这样做后程序是不会挂掉了。但是,就我们的观点来看,这样反而会隐藏一些BUG。
为什么我们不能详尽地去管理一个指针?一个指针变为空了,总是因为在这之前发生了错误
。当然,野指针本身就是愚蠢代码产生的东西,这里没必要讨论。空指针之所以为空,也是
因为在很多时候我们把空作为失败/错误/无效的标志。
恰好上周我的一些代码就真的在空指针上出现了问题。外网的服务器随时会因为玩家的一些
临界操作行为而崩溃掉。虽然我通过修改脚本来屏蔽这个问题(因为不能说停机维护就停机
维护),但是总感觉程序是不安全的。人不吃点教训绝对不学乖。
后来我对这个问题彻底思考了一下。很多程序员都自认聪明。在写C++程序时,我从来不提
供没用的public接口,尤其是set/get。我也从来不对没必要的成员变量进行初始化。我给
的理由是对于这些东西我都有很清晰的把握,我为什么要做stupid的事情?
但是,我几乎从来没有界定,指针在哪些情况下需要去判断为空?函数的参数绝对不需要。
假如函数的参数就是个空指针,那是client程序员的责任。仅供模块内使用的指针(包含其
他资源)在内部使用时也不需要去判断。如果去判断了,那说明你对你自己写的模块都缺乏
精确的把握,证明你的设计思维不够清晰。
什么时候需要判断?当指针依赖于外部环境时,例如读配置文件、载入资源,因为外部因素
不确定不在自己控制范围内,那么进行判断。同样,当使用了其他模块返回的指针值时,也
需要判断。这个其实和“外部环境”属于同一种情况。因为我们对其他模块也不清楚,更为
隐蔽的是(随着其他模块的改变,将来会在你的模块里爆发崩溃错误),其他模块由别人维
护,其变化更不受自己控制。之前我对这一点界定不是很清楚,这也是我犯错的原因。
现在想想,像游戏服务器这种程序,里面塞着各种各样的游戏功能。无论是哪一个模块出现
个空指针访问出错的问题,都会直接让服务器崩掉。关键是这个结果经常伴随着玩家的损失
。所以理想状态下,把每一个模块都放置在单独的进程里,确实是很有好处的。
2009年5月28日
#
要给项目中增加一个新的模块,需要先在服务器端做一些图片处理相关的工作。本来,对图片
做一些诸如ALPHA混合旋转缩放的操作,在游戏客户端应该是很容易的事。但是这事要在服务
器做,就不得不引入一些第三方库。反正我们的服务器运行于WINDOWS下,这里又需要处理
JPG图片的加载,我就考虑到了GDI+。
在这之前对GDI+没有过任何接触。直接翻了MSDN,还好居然有个一系列的usage。GDI+的Image
本身支持JPG的直接载入。但是并没有我理想中的CreateFromMemory( const void *buf )接口。
看起来唯一可以从内存创建Gdiplus::Image对象的方法是从一个叫IStream*的COM东西。我揣摩
微软为什么没有提供我理想中的那个接口,或者说要把GDI+设计成这样,可能还是考虑到对多语
言的支持。于是问题转换为如何将一个C语言的const void*转换为IStream*。我甚至在开始的时候
感觉到是不是要自己实现个Stream。后来在google上找到了一个似乎是标准的方法:首先创建个
HGLOBAL对象,然后通过GlobalLock就可以将一个C的const void*直接memcpy到这个HGLOBAL
里,最后,通过CreateStreamOnHGlobal这样的接口就可以得到一个IStream。
恶心的是,基于之前对服务器内存使用的优化,我现在对于内存的使用非常敏感(谁说现在内存
大了就可以任意malloc了??)。上面那个过程对于资源的管理在MSDN文档中似乎显得有点
模糊。CreateStreamOnHGlobal函数的第二个参数指定当IStream->Release的时候,是否会自动
删除这个HGLOBAL对象。我虽然对COM不懂,但也知道它的对象是基于一种引用计数的管理方式。
逐字看了下文档,发现一个final单词,原来是IStream->Release最后一次释放时,会同时释放掉
这个HGLOBAL对象。更让人发指的是,我猜测Image( IStream * )来创建Image时,Image又
会对这个IStream进行一次AddRef。我发觉MSDN对于Gdiplus::Image::FromStream函数的说明
也有点模糊。我揣摩使用FromStream获得的Image*,是否需要手动去delete?这个地方的内存
资源管理,一定得搞个水落石出。结果是,FromStream的实现就是简单地new了个Image。而
Image内部肯定会对IStream进行AddRef,并且,如果在Image销毁前销毁这个HGLOBAL,这个
Image基本也就废了。
也就是说,Image本身不对HGLOBAL中的图片数据进行复制。囧。别想让我再写个wrap class把
HGLOBAL和Image纠结在一起,简单考虑,将CreateStreamOnHGlobal第二个参数设为TRUE。
要将一个Image保存为一段内存,也比较麻烦。我的方法和google上的相同。当然,微软的库依
然让我在很多细节上栽跟斗(如前所说,可能这是基于多语言支持的考虑)。首先需要创建个空
的IStream,即CreateStreamOnHGlobal第一个参数为NULL。然后将Image Save到这个IStream。
再根据该IStream::Seek获取其大小,自己再分配段内存,最后IStream::Read读取进来。同样,
需要注意相关内存资源的管理。
下午简单把以上两个过程简单封装了下。
下载代码。
2009年5月12日
#
上午公司断网,晚上失眠头痛没精神,于是随便打开了DNF游戏目录下的资源文件。以
前一直对提取游戏资源存在好奇,需要对一些关键字节猜测其加密方式。
DNF游戏目录下soundpacks下的npk文件看起来似乎比较简单,这里直接给出文件格式,
懒得写分析思路了。
文件开头的十六个字节是一个固定字符串:NeoplePack_Bill\0。
接下来四个字节表示本npk文件里打包了多少个WAV文件。npk文件是一个包含了很多声
音或者图片的打包文件。类似这种打包文件,一般文件头都会保存一个文件列表。而这个列
表里又会附加上偏移量和大小等信息。
接下来的数据就是这里所说的列表。每一个列表项包含三个数据域:偏移、大小、文件
名。如下示意:
NeoplPack_Bill\0 (16 bytes)
file_count( 4 bytes)
item1:offset(4 bytes), size(4 bytes)
item2:offset(4 bytes), size(4 bytes)
...
itemn:offset(4 bytes), size(4 bytes)
...
文件列表之后,就是具体的每个文件的内容。开始我还在担心npk会为每一个声音文件
加密。或者只保存声音文件的具体数据,而声音文件文件头则只保存一份(因为所有文件的
文件头很有可能全部是一样的)。后来稍微搜索了下WAV的格式,只需要比对下npk中某一个
文件内容的头部是否和WAV格式的头部相同,就可以基本断定其是否加密。
结果是,npk对包内的每一个WAV文件没做加密。
然后立即写了个程序,根据文件列表中的偏移值和大小值,将每一个WAV单独取出来,就
OK了。
完整的格式为:
NeoplPack_Bill\0 (16 bytes)
file_count( 4 bytes)
item1:offset(4 bytes), size(4 bytes)
item2:offset(4 bytes), size(4 bytes)
...
itemn:offset(4 bytes), size(4 bytes)
file1
file2
...
filen
我想图片资源也应该差不多,不过图片资源肯定要复杂些。下午公司网络好了,网上搜
索了下,发现居然已经有了DNF资源提取工具了,唉。
提供下源代码和MingW编译好的可执行文件,另声明:本文及相关工具代码只作学习研究
用,任何后果与作者无关。
2009年3月26日
#
kl中的错误处理
之前我一直说错误处理是kl里的软肋,由于一直在关注一些具体功能的改进,也没有对
这方面进行改善。
我这里所说的错误处理,包括语言本身和作为库本身两方面。
语言本身指的是对于脚本代码里的各种语法错误、运行时错误等的处理。好的处理应该
不仅仅可以报告错误,而且还能忽视错误让处理过程继续。
而把kl解释器作为一个库使用时,库本身也应该对一些错误情况进行报告。
整体上,kl简单地通过回调函数指针来把错误信息传给库的应用层。而因为我希望整个
kl实现的几层(词法分析、语法分析、符号表、解释器等)可以尽可能地独立。例如虽然语
法分析依赖于词法分析(依赖于词法分析提供的接口),但是因为词法分析并不对语法分析
依赖,所以完全可以把词法分析模块拿出来单独使用。所以,在日志方面,我几乎为每一层
都附加了个error_log函数指针。
而用户层在通过kllib层使用整个库时,传入的回调函数会被间接地传到词法分析层。
实际上,当kl作为一个库时,kllib正是用于桥接库本身和用户层的bridge。
另一方面,语言本身在处理错误的脚本代码时,错误分为几大类型层次:
1.词法错误 lex error,如扫描字符串出错
2.语法错误 syntax error,整理语法树时出错
3.运行时错误 runtime error,在解释执行代码时出错
4.库错误 lib error,发生在kllib这个bridge层的错误
kl在报告错误信息时,会首先附加该错误是什么类型的错误。
这里最麻烦的是语法错误的处理。因为语法分析时发生错误的可能性最大,错误类型也
有很多。例如你少写了分号,少写了括号,都会导致错误。这个阶段发生错误不仅要求能准
确报告错误,还需要忽略错误让整个过程尽量正确地下去。
语法分析阶段最根本的就是符号推导(单就kl的实现而言),所谓的符号推导是这样一
个过程,例如有赋值语句:a = 1;语法分析时,语法分析器希望(所谓的推导)等号后面会
是一个表达式,当分析完了表达式后,又希望接下来的符号(token)是分号作为该语句的结
束。
所以,klparser.c中的syn_match正是完成这个过程。每次你传入你希望的符号,例如
分号,该函数就检查词法分析中当前符号(token)是否是分号。当然,对于正确的脚本代码,
它是一个分号,但是如果是错误的代码,syn_match就会打印诸如:
>>syntax error->unexpected token-> ....
即当前的符号是不被期望的。
上面完成了错误的检测。对于错误的忽略,或者更高级点地对错误的校正,kl中处理得
比较简单,即:直接消耗掉这个不是期望中的符号。例如:
a = 1 /* 忘加了分号 */
b = 1;
上面两句代码被处理时,在处理完a=1后,发现当前的符号(token)b(是一个ID token)不
是期望(expect)中的分号,首先报告b不是期望的符号,然后kl直接掠过b,获取下个符号=。
然后处理a=1这个过程结束。当然,下次处理其他语句时,发现=符号,又会继续发生错误。
错误信息中比较重要的还有行号信息。之前kl这方面一直存在BUG,我在写贪食蛇例子
的时候每次新加代码都不敢加太多。因为解释器报告的错误行号总是错误的,我只能靠有没
有错误来找错误,而不能通过错误信息找错误。
行号信息被保存在词法分析状态中(lexState:lineno),语法分析中获取token时,会取
出当前的行号,保存到语法树树节点中。因为包括解释模块都是基于树节点的,所以词法分
析语法分析解释器三层都可以准确报告行号。
但是之前解释器报告的行号始终很诡异。症结在于我在载入脚本代码文件时,以rb方式
载入,即二进制形式。于是,在windows下,每行文本尾都会有\r\n两个字符。而在词法分
析阶段对于行号的增加是:
case '\n':
case '\r':
ls->lineno ++;
不同OS对于文本文件的换行所添加的字符都不一样,例如windows用\r\n,unix系用\n
,貌似Mac用\r。所以,词法分析这里写应该可以准确地处理行号。
但是对于windows,这里就直接将行号增加了两次,所以也就导致了行号出错的问题。查
了下文档,发现以文本方式打开文件("r"),调用fread函数读入文件内容时,就会自动把
\r\n替换为\n。
代码改后,又出问题。这个时候,通过fseek和ftell获取到的文件尺寸,貌似包括了
\r\n,而fread出来的内容却因为替换\r\n为\n而没有这么多。
不过文件载入不属于kl库本身,kl只接收以字符串形式表示的脚本代码,所以也算不了
核心问题。
同样,最新代码可以从google SVN获取。当然,我也在考虑是否换一个新的项目地址。
2009年3月25日
#
貌似最近CPPBLOG写一门脚本语言比较流行,连我这种山寨程序员都搞出一个像C又像
BASIC的所谓脚本语言,可见其流行程度。
这个kl脚本例子,是一个具有基本功能的贪食蛇游戏。这个例子中使用了两个插件:
HGE引擎、以及一个撇脚的二维数组插件。因为kl对于数组的实现不是那么漂亮,而我实在
不想因为加入二维数组的支持而让代码看起来更乱,所以直接不支持这个特性。考虑到二维
数组的应用在一些小游戏中还是比较重要(例如这个贪食蛇,总需要个容器去保存游戏区域
的属性),所以撇脚地加了个支持number的二维数组插件。
HGE插件我只port了部分接口,也就是注册了一部分函数到脚本里,提供基本的贴图功
能。(port--我实在找不到一个合适的词语来形容这种行为---HGE到一门脚本语言里,我似
乎做过几次)
不知道有没必要提供贪食蛇的实现算法,这似乎说出来有点弱智。- - 不过为了方便别
人阅读kl脚本代码,我还是稍微讲一下。游戏中使用一个二维数组保存整个游戏区域,所谓
的游戏区域就是蛇可以活动到的地方。每一个二维数组元素对应游戏区域中的一个格子,姑
且称为tile。每个tile有一个整数值表示其属性,如BODY、WALL、FOOD、NONE。蛇体的移动
归根结底就是蛇头和蛇尾的移动。蛇头和蛇尾属性一样,但是蛇头负责把所经过的tile设置
为BODY,而蛇尾则把经过的tile设置为NONE。蛇头的移动方向靠玩家控制,每次蛇头转弯时
,都会记录一个转弯点到一个队列。转弯点包括转弯XY坐标以及转向的方向。蛇尾每次移动
时都会检查是否到达了一个转弯点,是的话就设置自己的移动方向为该转弯点记录的方向。
虽然我写了kl这个脚本语言,但是语言特性并不是我设计的。我只是取了C语言的一些
特性。所以在写这个sample的时候,我对于kl这个脚本语言的感觉,就是一个像basic的C。
因为它太单一,就像BASIC一样只拥有语言的一些基本功能,不能定义复杂的结构,没有天
生的对各种数据结构的支持(例如某些语言直接有list, tuple之类)。
以前中学的时候在电子词典上用GVBASIC写小游戏,当时除了BASIC什么也不知道。今天
写这个贪食蛇例子,感觉就像以前用BASIC。
回头说说一些kl脚本里的特性。从这个例子里(见下载包里的snake.kl),诸如while,
for,if...else if...被支持(之前发布的版本里还不支持for和else if)。全局变量支持
赋初值(上个版本不支持)。当然,还演示了如何使用插件函数。
但是,仍有一些特性在我的懒惰之下被置之不理。例如return后必须跟一个表达式,这
意味着单纯的return;将被视为语法错误。对于if( a && b ),kl会计算所有的表达式,而
别的语言也许会在a会false后不计算b,这也许不算个问题,但起码我还没修正。还有,kl
内部对于错误的报告依然没被修复,少打一个分号你会得到一系列错误的报告,但是却没有
准确的行号。甚至,你会看到解释器崩掉。不要紧,在我心里,它作为当年电子词典上那个
GVBASIC而言,已经很强大的了。:DD
最近接触了很多UNIX和GNU之类的东西,发觉没有提供版权说明的‘开源’,原来都是伪
开源。虽然我也想按照GNU编码标准里所说为kl的发布包里附加Changelog之类的说明,但是
出于懒惰,还是以后再说吧。同样,这次提供的下载里包含了一些编译好的东西,所以我不
保证它在你的机器上依然可以运行。我使用了MingW来编译这些,并且提供有点丑陋的Makefile。
HGE使用了1.81版本。
贴张图给懒得下载的人:
下载例子,包含脚本代码。
如果要获取kl实现代码,建议从我在google的SVN获取:
http://code.google.com/p/klcommon/
2009年3月12日
#
author: Kevin Lynx email: zmhn320#163.com date: 3.12.2009
脚本与C语言交互
这其实是这一系列的最后一篇,因为我觉得没什么其他需要写的了。
一般而言,脚本语言同C语言交互,包括在C语言中注册C函数到脚本,从而扩展脚本的
功能,以及在C语言中调用脚本函数。
为了扩展脚本的功能,这里引入插件的概念。kl在这方面大致上实现得和lua相似。kl
支持静态插件和动态插件。
在C语言中调用脚本函数,kl中提供了一些简单的接口用于满足需求。
静态插件
静态插件其意思是在C代码中注册函数到脚本中,并随脚本库一起编译链接成最终执行
程序。因为其绑定是在开发一个程序的过程中,所以被称为静态的。
一个插件函数,指的是可以被注册进脚本的C函数。这种函数必须原型一样,在kl中这
个函数的原型为:typedef struct TValue (*kl_func)( ArgType arg_list );
当你定义了一个这样的原型的函数时,可以通过kl库提供的:
int kl_register( struct klState *kl, kl_func f, const char *name )来注册该
函数到kl脚本中。该函数参数很简单,第三个参数指定注册进脚本中时的名字。
原理比较简单:在解释器中保存着一个插件符号表,该符号表的符号名就是这个函数提
供的名字,符号对应的值就是第二个参数,也就是插件函数的函数地址。
解释器解释到函数调用时,先从插件符号表中查找,如果找到符号,就将符号的值转换
为插件函数,并调用之。
插件函数的参数其实是一个参数链表。脚本里调用插件函数时,所传递的参数将被解释
器整理成参数链表并传递给插件函数。kl库中(集中在kllib.h中)提供了一些方便的接口用
于获取每个参数。
插件函数的返回值也将被解释器转换为脚本内部识别的格式,并在必要的时候参与运算
。
动态插件
动态插件同静态插件的运作方式相同,所不同的是动态插件的插件函数被放在动态运行
时库里,例如windows下的dll。
kl插件编写标准里要求每个动态插件必须提供一个lib_open函数。kl解释器(或者kl库
--当被用作库时)载入一个动态插件时,会直接调用lib_open函数。lib_open函数的主要目
的就是把该插件中的所有函数都注册进脚本里。
因为动态插件在设计之初没有被考虑,所以我并没有为kl加入一些原生的关键字用于导
入动态插件,例如import、require之类。我在静态插件层次提供了这个功能。即我提供了
一个libloader静态插件,链接进kl解释器程序。该静态插件提供脚本一个名为import的函
数。该函数负责动态载入dll之类的动态库,并调用里面的lib_open函数完成动态插件的注
册。
C程序里调用脚本函数
这个比较简单,通常C语言想调用一个脚本函数时,会传入脚本函数名。因为脚本函数名
都保存在全局符号表里,kl库从全局符号表找到该函数符号,并转换其值为语法树节点指针
,然后传入解释器模块解释执行。
kl库提供struct TValue kl_call( struct klState *kl, const char *name, ArgType args );
用于在C里调用脚本函数。
代码导读
kllib.h/kllib.c作为一个桥接层,用于封装其他模块可以提供给外部模块使用的接口,
如果将kl作为一个库使用,用户代码大部分时候只需要使用kllib.h中提供出来的接口。
源码目录plugin下的kllibbase.c中提供了静态插件的例子,kllibloader.c提供了装载
动态插件的功能。
源码目录plugin/hge目录下是一个封装2D游戏引擎HGE部分接口到kl脚本中的动态插件
例子。
源码目录test/kl.c是一个简单的kl解释程序,它用于执行一段kl代码。这个程序同之前
说的解释器不是同一回事。当我说到解释器时,它通常指的是klinterpret.c中实现的解释
模块,而解释器程序则指的是一个使用了kl库的独立解释器可执行程序。