Chaofan

For the next train

作者: qcf

  • 汶川地震八年记

    看见今天各大新闻的头条,不知不觉,那场地震已经过去八年了。

    尽管身为离四川很近的重庆人,然而每次跟别人聊起地震的时候,我都无比确信我那个时候真的毫无震感。而一切的记忆都是那么清晰而真切,虽说不上就像在昨日发生一般,却也成了生命光影里深深定格的镜头。那个时候刚刚上美术课,被我们调侃过无数次的美术老师让我去隔壁的办公室拿一盒彩色粉笔。还没等我走到办公室门口,就见到人们一窝蜂冲了出去往楼下跑。我当时并不知道怎么回事,也确未感知到地震的来临,所以满脸不解的表情,甚至还以为是马蜂来袭,同学们集体外撤。大概等到人已经冲出一半了,我才听见有人喊到一声「地震了」。此刻的我虽然紧张却更不愿往下跑了——老师不是教育过我们,地震来临要躲在桌子下面吗?不过几乎所有人都跑出去了,外加城乡结合部小学的建筑质量确实也不太能让人信赖,我还是跟着下楼,成了最后出去的人。

    可能我对事故的嗅觉从来都是人群里最迟钝的那一类,地震是,外滩踩踏亦是。大家都下撤到教学楼下的操场以后,老师开始点名,看有没有遗漏的人。(尽管我一直觉得,如果地震真的把那教学楼震踏了,我们在球场上根本不能幸免)点名完毕后就组织回家,联系家长。我走出学校,径直走向家附近的一家商店,将近十个中老年人围着一个电视机说着什么。我也凑上去看,才知道刚刚是四川汶川地震了。电视台一次又一次修正地震的震级,不过对当时的我来说,就是知道那很厉害而已了,而对于震源地的死亡,也实在没有概念。

    我清楚地记得那天的一切,记得我回去还玩了一把无双大蛇,还看了一集记单词教程。还有工人们坐在路边的聊天。我都记得。不过,对于不幸的朋友而言,可能所有的记忆到那一刻就完全停止了。一个小学生大概真的不太容易明白如此巨大地震的后果。意识到这是一件会打破正常学校生活秩序的大事以后,心里反而有些许奇怪的兴奋感。当然,那段时间的电视新闻永远只有这一个主题。无数的故事,生离死别,当时的我不太能体会这种沉重,但对失去亲人的悲怆,我也有具体的想象。

    重庆人都喜欢开玩笑说重庆是一个安全的地方,水火不侵。不过一当有传言曰大余震要来了,市民们也紧张得很。那天晚上社区的家家户户都收拾睡具到球场上过了一夜。我睡不着,于是夜游,那经历甚是有趣。当然夜游的经历不只这一次,不过每次,似乎都跟死亡有那么点点关联。

    再然后就是全国哀悼日了。那几天网游什么的都是不能玩的。唔,不过我也没有什么兴趣。小伙伴在楼下唤我出来,做什么我忘了,大概也是吹牛吧。毕竟那是一个放学之后到同学家门口就能吹一个小时的年纪啊。

    说了这么多,似乎都是无病呻吟,没什么实在的意义。是。只是借此回忆一下,回忆一下那时那个对初中生活还有充分幻想的我自己。毕竟这个时间节点实在是太特殊。当然,许多无辜的生命,就突然停止在那个时刻,他们的故事再也没有机会被续写。活着的人呢,也许素不相识,因为一场地震而相遇。人生实在是太神奇。地震灾后的修复也许早已完成,但它的回音,似乎远未结束。

    谁也不知道未来会发生什么。

  • cJSON源码分析-接口

    最近因为各种忙碌,博客一直没有更新。下半学期的ACM/ICPC报名已经过去了,我没有参加,因为觉得自己对这种比赛没有什么特别的兴趣。其实话说回来,要和人「当面比」的事情,我很多都不喜欢。大概是因为从小到大的不自信导致的。这边Rails的项目快要写完了,收获不少,会找个时候专门用一篇博文记述。喜欢编程,所以就总是停不下来,想找点事情做。大家都说,提升编程能力要做项目。这话不假。但是编程就像写作文,初中生洋洋洒洒写个小说,很可能只会被成年人看作是幼稚文章。编程作为一门手艺,阅读他人的源码也是很重要的。感谢自由软件运动让我们有大量的源码可以用来学习,也感谢GitHub这样的平台能让我们更加方便地获取和发布源代码。所以最近可能会陆陆续续地更新一些程序的分析,也借这个过程提高一下自己细粒度层面的编程水平。希望不会烂尾。(flag已立!)

    要分析呢,就从最熟悉的语言开始吧,也就是C咯。然后找一个简单的开源项目。好啦,中央已经决定了,就是cJSON了。同济2014级软件学院的C语言期末作业就是要求写一个cJSON解析器,后来我才发现那个提供的头文件就是cJSON里的……

    软件的第一要义就是,它创造出来必须有用。那cJSON,顾名思义,就是一个程序,它能够:

    1. 将文本化的JSON转化为C语言可以操作的数据结构(解析)
    2. 将内存里的数据结构输出为格式化的JSON字符串(序列化)

    具体JSON是什么格式,不用我再多说了吧。在JavaScript流行的今天,JSON由于其简单的语法和支持嵌套的特点,得到了广泛应用。许多框架的配置文件什么的,都是用的JSON格式。也有许多人试图用JSON逐渐取代XML语言。

    我们先来看源码的文件结构:

    • cJSON.c
    • cJSON.h
    • test.c
    • README.md
    • LICENSE
    • CMakeLists.txt
    • Makefile
    • cJSON_Utils.h
    • cJSON_Utils.c
    • test_utils.c
    • test/

    其中末尾带util的都是针对JSON做的一些扩展(RFC6901和RFC6902),我们先略去不谈。其实作为库,核心部分就两个文件,一个cJSON.h声明,一个cJSON.c实现。那么我们就先来看看头文件,cJSON到底提供了哪些接口。

    /* cJSON Types: */
    #define cJSON_False  (1 << 0)
    #define cJSON_True   (1 << 1)
    #define cJSON_NULL   (1 << 2)
    #define cJSON_Number (1 << 3)
    #define cJSON_String (1 << 4)
    #define cJSON_Array  (1 << 5)
    #define cJSON_Object (1 << 6)
    
    #define cJSON_IsReference 256
    #define cJSON_StringIsConst 512
    
    /* The cJSON structure: */
    typedef struct cJSON {
        struct cJSON *next,*prev;   /* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
        struct cJSON *child;        /* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
    
        int type;                   /* The type of the item, as above. */
    
        char *valuestring;          /* The item's string, if type==cJSON_String */
        int valueint;               /* The item's number, if type==cJSON_Number */
        double valuedouble;         /* The item's number, if type==cJSON_Number */
    
        char *string;               /* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
    } cJSON;
    
    typedef struct cJSON_Hooks {
          void *(*malloc_fn)(size_t sz);
          void (*free_fn)(void *ptr);
    } cJSON_Hooks;

    整个分成三个部分,一个是标记Type的宏,包括了cJSON结构体里type成员的所有取值。它在这里额外增加了IsReference和StringIsConst两个类型标记。我们注意到作者表示cJSON的类型不是用的正常的自然数的顺序排布,而是利用位移运算构成了等比数列。为什么要这样呢?因为这样的话一个类型就可以和IsReference和StringIsConst叠加了。这是C语言里的常用技巧。

    再往下,我们可以看到作者定义了一个叫做cJSON_Hooks的结构,包含了malloc_fn和free_fn两个函数指针作为成员。很容易看出来这两个函数指针的原型也刚好对应malloc和free的函数原型。虽然还没有开始阅读源代码,不过我们自信地猜想,这个结构的作用类似于C++ STL中的allocator,负责标准分配之外的分配方式。话说我一开始写maolang的容器的时候也用过这个方法,但是后来觉得太累赘而放弃了。

    再看下面的代码。

    /* Supply malloc, realloc and free functions to cJSON */
    extern void cJSON_InitHooks(cJSON_Hooks* hooks);
    
    /* Supply a block of JSON, and this returns a cJSON object you can interrogate. Call cJSON_Delete when finished. */
    extern cJSON *cJSON_Parse(const char *value);
    /* Render a cJSON entity to text for transfer/storage. Free the char* when finished. */
    extern char  *cJSON_Print(cJSON *item);
    /* ... */
    
    /* Returns the number of items in an array (or object). */
    extern int    cJSON_GetArraySize(cJSON *array);
    /* ... */
    /* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
    extern const char *cJSON_GetErrorPtr(void);

    限于篇幅,把更多的函数声明省略了。函数声明前面加上extern关键字是可选的,仅仅是标注它是一个外部链接的函数而已。然后是一堆用以创建、删除、插入、修改JSON结构的函数,更详细的内容在头文件里。

    /* Duplicate a cJSON item */
    extern cJSON *cJSON_Duplicate(cJSON *item,int recurse);
    /* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
    need to be released. With recurse!=0, it will duplicate any children connected to the item.
    The item->next and ->prev pointers are always zero on return from Duplicate. */

    复制的函数有一个额外参数,表示是否选择递归复制(深拷贝)。这里还有一些函数,我们分析实现的时候再说。在头文件的最后还有一个有趣的宏:

    /* Macro for iterating over an array */
    #define cJSON_ArrayForEach(pos, head) \
        for(pos = (head)->child; pos != NULL; pos = pos->next)

    虽然仅仅是简陋的宏替换,不过还真是搞出了现代语言的感觉呢。

    看完头文件之后,我们发现,cJSON这个简单的解析器,名堂却不小,提供了不少实用的接口。至于这些接口内部实现的细节,我们下一篇文章再来讨论啦。

  • 计协,南京和其他

    这篇日志本来是想周四晚上写的,结果拖着拖着拖到了现在。

    虽然学籍上仍然是个大一学生,但是已经是在计算机协会的第四个学期了。计协的活动,前前后后在时间线上串起来,大概就是我将近两年的大学生活。大一的时候,班上女生多,上海女生尤其多,本来班级活动也少,大家待了大半个学期都不认识。计协反而成了寄托。如今回头看那个时候的照片,还会惊叹——原来这些事情都是第一个学期发生的啊!要知道,彼时我还每周认真写高数作业,还没有想好要去软件还是计算机。往后的日子就如眨眼般过去了,连我自己也到了要去嘉定的时候。

    大一初到上海,陌生的城市,陌生的人,未知的未来,都在不断地敲打自己。那个时候被「抓进」医院住院,在病房里坐着看着窗外的赤峰路。嘉定是什么样子呢?大四的时候……哦大四还好遥远呢?殊不知,如果我没有留级的话,这四年已将近走完一半了。实话说,在我不知道同济计协的时候我就有加入这类组织的想法了。我记得初中的时候学校就有跟编程有关的社团,可惜那是高中部没法加入。在网上发现一位高我一年级的VB高手,两人还一见如故惺惺相惜。可惜后来断了联系,也不知他现在在哪里。高中的时候呢,觉得老师太蠢,于是把OI放弃了,后来还是有那么点后悔的。可是后悔这种东西没有意义,让我回到那个时候再选一次大概还是一样的决定。

    说来好笑,在病房里看贴吧关于百团大战的贴子,把所有跟计算机有关的社团全部默默记了下来。所以第一次百团的时候一下看到了计协就报了名。有趣的是,我拿传单的瞬间还刚好被拍下来了。

    面试的时间在十月七日,如今都还历历在目。国庆第一天就去了南京,第一次一个人坐火车还是挺有意思的体验。第一个夜晚到了南京还不觉得,第二天开始就有了一种深深的无力感,用自己的话说就像是在住院。现在想来,大概是因为第一次来到一个谁也不认识的城市,住在酒店里,感到自己对身边的一切毫无掌控力。人类就是这样盲目乐观的动物,看上去,一切都秩序井然。殊不知人类创造的很多规则和理念都是很脆弱的。大众都为大城市的繁华而惊叹,倘若是战争一来,几千万人的大城市一失去秩序就是人类历史上难以想象的灾难。地狱当然是存在的,比如1945年的柏林。

    又说远了。面试当然是没问题,见着这些学长还挺激动。那个学期的活动还挺丰富的,从义诊到活动周到计协十周年,我还混过一个小组长当,哈哈哈。后来是下学期的参观七牛,计协一刻钟……说到这里语词混乱也不知道该再讲什么了。只是,真的想感叹一下时间的流逝。一年前我就告诉自己要好好抓紧时间多看点书,结果一年过去了还是明日复明日明日何其多。可惜没有办法,谁叫我是大一呢?谁叫我还要学这么多不得不学的乱七八糟的课呢?哎,已经是毕业生的心了,却还是个新生的籍。

    过去了两天,当时的那种心情已经被冲淡了许多。那天晚上看到老照片,真的好想好想感叹。室友问我,「你对这个社团看来很有感情啊」。「当然」。

  • C语言里的void类型

    今天去图书馆坐了坐,看罢《Essential C++》,觉得过分基础,实在没什么意思。碰巧包里还有一本神作《C标准库》,详述了实现ANSI C标准库的所有过程。第一章讲的便是assert.h的实现。这个宏本身没有什么难度,无非是在一个函数的基础上包装一下,说不定GCC或者Clang直接把这个函数做成builtin了。不过不管是书上还是musl库的代码,都有一个让我注意到的地方:

    #ifdef NDEBUG
    #define    assert(x) (void)0
    #else
    #define assert(x) ((void)((x) || (__assert_fail(#x, __FILE__, __LINE__, __func__),0)))
    #endif

    这里的这个__assert_fail就如同我上文所说,是一个用于输出内容的函数,作用是输出错误信息然后调用abort函数退出。

    关键在于这里有个奇怪的(void)0表达式。首先我们可以判定表达式的类型是void,对吧?不过这个void类型的表达式有什么意义呢?我们学习C语言的教材对这一点基本都语焉不详。我尝试了一下,把(void)0赋给一个int类型的变量,编译器是这样给我抱怨的:

    test.c:3:9: error: initializing 'int' with an expression of incompatible type 'void'
      int i = (void)0;
          ^   ~~~~~~~

    反正意思就是无法把这个类型转换成匹配的int啦。

    那我们转念一想,尝试用void来定义一个变量呢?得到这样的错误提示:

    test.c:4:10: error: variable has incomplete type 'void'
      void j;
           ^

    等等!incomplete type?哪里见过这个?对啦!如果在一个结构体内部定义一个以这个结构体为类型的对象,编译器就会抱这个错误,提示类型还没有定义完。所以写链表或者二叉树的时候,里面存储的其实是「指向节点的指针」。

    继续带着疑惑,我查询了C11的标准草案(正式版是收费的,不过两者在这些基础问题上相差无几),其中有三处提到了我想知道的「void类型」:

    …The void type comprises an empty set of values; it is an incomplete object type that cannot be completed…

    …An lvalue is an expression (with an object type other than void) that potentially designates an object…

    The (nonexistent) value of a void expression (an expression that has type void) shall not be used in any way, and implicit or explicit conversions (except to void) shall not be applied to such an expression. If an expression of any other type is evaluated as a void expression, its value or designator is discarded. (A void expression is evaluated for its side effects.)

    而《C程序设计语言》里这样描述:

    void对象的(不存在的)值不能够以任何方式使用,也不能被显式或隐式转换为任一非空类型。因为空(void)表达式表示一个不存在的值,这样的表达式只可以用在不需要值的地方,例如作为一个表达式语句(参见A.9.2节)或作为逗号运算符的左操作数(参见A.7.18节)。

    可以通过强制类型转换将表达式转换为void类型。例如,在表达式语句中,一个空的强制类型转换将丢掉函数调用的返回值。

    这下终于明白了!那么稍微总结一下:

    1. 在C语言中,void可以作为一个合法表达式的类型,亦即它在语法结构里可以作为一个表达式
    2. void类型的表达式不能转换为其他任何类型的表达式,也就没有了赋值的可能
    3. 编译器会特殊看待void类型,将其作为一个「未被定义完整」的类型,也就没有了定义变量的可能
    4. 尽管void未被定义完整,但是如同其他结构体一样,我们是可以正常使用void*的,并且直接对一个void*解引用,结果是void类型的表达式
    5. void类型和其他类型不相容,但是该有的表达式副作用还是会有

    可以说这里面的逻辑是非常自洽且合理的。所以不得不佩服设计C语言和C++的人,这些概念就像物理定律,看上去复杂,但是用这套逻辑推导下去很多看似不同的东西都可以得到统一解释。

    现在回头看assert宏的实现代码,不难理解啦。因为__assert_fail函数的返回值类型是void,但是它需要被用在一个逻辑表达式里。于是它巧妙地结合了逗号运算符,配合短路求值的规定实现了assert需要的效果。至于把最后的表达式类型也转换为void,是为了不让它作为值被赋给变量。而在定义了NDEBUG的状态下,用(void)0占位也比什么也不写来得好,编译器会提示类型不相容,而直接替换成空的话,在代码复杂的地方错误类型也许会莫名其妙。

  • 要开始做点实事了

    这个寒假过得太快,一转眼,又是开学了。

    本来进学生会是为了写二手书管理系统,结果到现在封面文章代码什么都干过,结果二手书还是没写好,真是有趣。开学的这段时间恰逢女生节,忙得飞起,连高数作业都没得交。收获其中之一是人生中第一次当了第一次面试官,哈哈。

    我这个人就是喜欢拖延,或者说懒。写这个文章的目的也是想好好告诉自己,是时候每天记录一点东西了。技术也好,非技术也好。这个博客访问量最大的时候是上学期C语言大作业完成期间。当时贴了一点东西上去,没想到还真的有人在答辩的时候照抄过去了。我不想把这个博客当成一个纯粹装逼的地方。因为说实话,这样子装逼也装不了几次。而且大部分人其实也会厌烦的啊。

    好吧,想去参加GSoC的一个项目,关于FreeBSD的(真是合我的技能点啊)。目标大致是把一个叫musl的C库从Linux移植到BSD上,大概也要让它在Clang下能过编译。这下有活干,有得学了。

    好吧今天就这样。一打开博客半天加载不出来吓死我了。结果发现是博客会去自动加载googleapis的字体,想了一下发现还是直接改apache的配置文件最好。不过现在累了,还是明天干吧。有个例子在这里

    希望这是一个新的开始。

  • C语言要考试了,你应该问问自己什么?

    呐,这次我又来装逼了。我保证,这是这学期最后一次。

    如果从我在百度C++吧的第一次发言(2009年7月2日)到今天,日子已经过去了2380天,然而我之前还没有考过一次C语言。好慌……好了好了,打住,不然待会儿真被当成装逼了。不过我说我初学的时候用while包住过main你信?至少你没有这样干过吧。

    那么,我尽力,把自己带入2380个日子前,努力以一个初学者的视角来想一下C语言到底包含着什么知识点。我承认这种方法充满了应试色彩,而且对于上机写代码这种形式的考试可能并没有十分显著的作用。不过我是真的希望每个看到这篇文章的读者,即使以后不靠C语言吃饭,写不出优秀的C代码,也至少能对C语言有一个正确的理解。那我即使是背上装逼的骂名,也心满意足了。(其实我会告诉你我是因为不想复习物理了吗)

    关于新手指引之类的内容,我都写在了那个「C指引」的文档里,点击打开它的GitHub页面。外加精力有限,因此不想再重复那些内容。所以我会列一些问题,并且不会给出答案。希望能迫使你思考。这些问题可能会带有我自己的主观色彩,不喜欢就点关闭咯。

    先从初级开始。

    1. C语言代码文件的后缀名是什么?这类文件可以用哪些工具打开和浏览?
    2. 什么是可执行文件?它需要另外的软件打开它吗?
    3. C语言文件从源代码到可执行文件的过程叫做什么?这个过程中发生过什么?
    4. 如果我要输出Hello, world!,我需要在程序的第一行写上什么?如果不写有什么后果?
    5. 为什么程序执行时候的窗口会一闪而过?那个黑色的窗口究竟是什么?
    6. main是什么?我把代码写在main外面会有什么后果?
    7. main前面应该是void还是int?
    8. 变量的存在有什么意义?C语言里的变量有哪些类型?
    9. 我怎么去获取来自用户在命令行的输入?接受输入的函数跟输出的函数在调用方式上有什么区别?
    10. 既然一个字符串在代码里不能跨很多行书写,那么我怎么在字符串里表示换行?制表符呢?
    11. while语句和do…while语句的区别在哪里?
    12. switch…case语句里的break有什么作用?它和if语句比有什么局限性?
    13. 对于if语句括号里的内容来说,一个等号和两个等号有什么差别?两个等号中间能有空格吗?
    14. 如果变量a是double类型,那a=5/2之后a的值应该是多少?

    什么,觉得太简单了是吧?那来中级的问题:

    1. 如下的代码会按何种方式执行?会输出什么结果?
    int sample = 1, ok = 0;
    if (sample == 1)
        if (ok)
            puts("Ok.\n");
    else
        puts("No.\n");
    1. goto语句是如何使用的?为什么我们提倡不使用goto语句?
    2. 什么时候程序需要函数声明?函数声明应该放在哪里?
    3. 到底什么是EOF?EOF可以用在我们输入的什么地方?
    4. i++和++i有什么不同?i+++++i这种表达式有意义吗?
    5. 应该如何安全地读取一个文件里的内容?写文件呢?
    6. 说出数组和指针的区别(至少三个)
    7. 字符类型有数值吗?strcmp的返回值有什么含义?
    8. static和extern关键字有什么用?

    啊哈,还是不满足吗?我们来看看高级篇:

    1. 为什么在main函数里定义一个非常大的数组,程序可能会崩掉,而我放到外面就不会了?
    2. char *const和const char*的区别在哪里?对于声明const char *s=”abcd”,这个指针s到底「指向」什么地方?
    3. 如果我像这样定义:
    typedef char* sptr;
    const sptr b;
    这里的b到底是什么类型?是const char*吗?
    1. 考虑这样一个结构体:
    struct sample {
        int  a;
        char b;
    };

    sizeof(struct sample)的值会是多少?为什么?

  • 记一次失败的装逼经历

    呐,文如标题,这次要说的是一次十分失败的装逼经历,或者说,一场惨痛的人生教训。有那么严重么?至少,对于新年的第一天和第二天的我来说,是这样的。

    讲道理,在我看来,其实装逼本身就可以说是失败的。真正的强者不需要装逼,他们的强势永远都是一种自然的流露。当然,在弱者,或者说特定圈子之外的人,可能也会把这样的行为当成是装逼。就像,知乎上的很多习惯和流露的价值观,对于知乎社区的活跃用户来说,是无比正常的事情。但是对于一些微博或者贴吧上的网友而言,这些思维方式和语言习惯必是装逼无疑。

    那我为什么还要说自己是装逼呢?

    废话,因为我失败了啊。

    事情是这样的,我再叙述一遍。软件学院大一上的C期末作业要求我们写一个解释器,要解释的语言被称作Mao,包含int和double两类变量的声明、表达式求值和print变量。其实一开始接到这个项目我是很高兴的。因为比起去年的JSON Parser来说,这个项目可以扩展的地方太多了。语言确实没有办法描述我的心情。那是一个星期二,下午无课,我跑到图书馆看了一个下午的编译原理。

    「我要做一门真正的编程语言。」

    从那天开始,直到2015年结束,可以说,我的脑子里从来没有忘过这事,偶尔梦里也梦到过。以至于我11月去无锡给我朋友过生日的时候,在路上因为突然想通了Mao语言的上下文无关文法,而突然哈哈大笑起来。

    然后就是紧锣密鼓的设计和写代码。其实我不是一个执行力很强的人,很多想法都停留在脑海里要拖延很久才会去实现。这带来了很多问题。递归下降的语法解析器我从十二月下旬才开始写,由于是第一次写,代码删删改改,自己觉得满意了,结果对了(我当时是怎么判断出结果对的呢?)就停下来了。好,停下来之后又开始写对象系统,整个活生生朝着PHP的方向做。直到2015年的最后一天,我把对象的运算做好了之后,输入1+1+1测试,然后……

    是的,我就是这么后知后觉。

    我写的递归下降语法分析器,在解析表达式的时候,因为要考虑优先级,所以把包含相同优先级的运算式,划到上下文无关文法中同一类的非终结符里。举个例子,就像很多编译原理教材都会讲的一样,两个表达式加减的结果叫做term,两个表达式乘除的结果叫做factor,一个term可以这样定义:

    term = term op-1 factor
         | factor
    
    op-1 = '+'
         | '-'

    在理论上,这是一个十分完美的结构,从这样的语法结构构造出的表达式树,也可以按照正确的执行顺序进行计算。比方说1-1-1,先算左边的1-1得0,再做0-1,最后得结果是-1. 因此第二个减号是树根,第一个减号是左子树的树根。这是左结合性的运算符。那么对于右结合性的运算符,比如乘方符号^,或者说赋值号=,这些是先算右边的,那么我们修改一下语法即可:

    assignment = identifier assign-op assignment
               | expr
    
    power = expr exponent-op power
          | expr
    
    assign-op = '='
    
    exponent-op = '^'

    当我们计算表达式2^3^4的时候,顺序并不是跟加减乘除一样的,而是先算右边的3^4,得81,然后整个表达式的值是2^81,结果就不说了,因为写不下。

    但是这样做有一个很大的缺陷,那就是我们的LL(k)解析器无法解析左递归的情况,会陷入无限循环。什么是左递归呢?就是上文提到的描绘左结合性运算符的文法形式。一个term是一个term加或减一个factor,但是这个term到底到哪里为止呢?我们的递归下降解析器,至少是理论上,做不到的。所以从文法上,我们需要移除左递归。当然,如果解析器是从右向左读的,那么也处理不了右递归。

    移除左递归的过程可以说是范式化的,引入一个新的非终结符,修改一下规则,像这样:

    term = factor term-rest
    
    term-rest = null
              | op-1 term

    这个看上去也很好啊,能够解决我们的问题。然而两者合在一起,问题出现了——移除左递归之后,程序解析到的树,结合性出现了问题!而事实上,我写得更蠢:

    term = factor op-1 factor
         | factor

    这样的递归下降解析器根本无法解析1+1+1这样的式子!它到第二个1处就停下来了!而当我发现这个问题的时候,时间已然是2015年的最后一个晚上了。

    没有办法了,第二天早上起来试图改进,发现越改问题越过。中午决定放弃,推倒表达式解析重来。其他像for、while等写好的语法也暂时搁置下来。撸了一个下午到晚上,在1月2日前完成了作业。

    然而好景不长,第二天晚上当我写好文档准备交的时候,又发现bug了!我的程序不能正确处理表达式开头的负号。简单说就是,我的程序会自动“分划”输入的表达式。为了解决负号的问题,程序会判断当前表达式开头是不是有负号,如果有的话,就整个表达式取负。出发点是对的,但是后面一遇到加减或者各种叠加的括号,问题就来了,比如会把-1+2的结果当成-3.

    剩下二十分钟,真没办法了,先交吧。

    然后我改到了两点。

    讲道理,我感觉自己真的没有资格参加答辩。

    就是这样。

  • 半颗卤蛋

    真是诸事不顺的一天。

    首先是中午去吃饭的时候中途突然下了雨。回来之后改代码也各种不对。好不容易等到下午可以抢票的时候,一打开客户端,下单都点了,准备支付的时候发现卡里余额不足。支付宝现在不支持银行卡和账户余额混合支付了。于是我只有去充值。充值完发现票已经被抢完了。然后写物理实验报告把手抄了个麻,匆匆忙忙跑去上物理实验课。上完去吃饭掏出手机看群,发现大麦又放票了。简直了。冲回去又打开抢,这次是显示有,结果一点击又没了。真是哔了狗了。

    晚上突然发现OJ又多了个新作业,写链表的。本来觉得,哼,简单嘛。结果小bug没发现,测试数据也是奇葩,20个,全是Runtime Error,服。发现大家平安夜都出去玩去了没人提交,我一个人在那个位置停留了很久。截个图纪念下嘛。

    好,这不截图不要紧,一截图发空间就有好事的人散布出去了,于是我就理所当然地成为了“王”。呵呵呵呵。不过我这个人,自认为,至少在自己的专业方面,还是不怕什么打脸的。有不对就说出来嘛,有什么不好意思的,又不是什么三流职高里流传的黑客江湖。

    平安夜其实真没什么意思,反正也不放假,不如元旦来得给力。出去走了走,吃了面,面里放了好久没吃过的卤蛋,虽然只有半颗,味道不错。讲道理,在这边放不放假,节不节日,真的都没有什么感觉了。还是回家好。

    至少回家,我可以想吃多少卤蛋吃多少。

    是吧?

  • 关于命令行参数的那些事

    事情是这样的。这学期的C语言课程期末的大作业(说实话我不太愿意称其为“项目”……),就像我的前几篇博文里提到的,要求编写一个程序,可以读入一串字符,包含声明变量、求值、打印的功能。如何进行词法分析和构建表达式树的过程在前两篇文章我已经说过了,这里不再赘述。那么很多同学问起来的一个问题是,在作业的要求里面,有一项是“从第一个命令行参数读入文件名”——这个到底是什么意思?

    有些同学把它理解成了从标准输入获取一个文件名的字符串。大错特错啦。不过也难怪,我们这个年代的人小时候接触DOS的机会不多了,可能从小到大根本就没有在计算机上敲过命令,以至于第一次写C程序的时候看到那个黑漆漆的窗口还大失所望。

    不过现在好了,至少你晓得了电脑上某一个“角落”存在着这样一个“黑框”,你可以在上面输入东西。在Windows系统上,我们可以通过开始-运行(没有开始菜单的话就按Win+R键),弹出“运行”窗口,输入cmd然后回车。这样我们就打开了一个“命令提示符”窗口。(什么,你用的是Linux?用Linux还不知道命令参数是什么,我劝你还是卸了吧)

    这个窗口在Windows NT系统里可以作为对DOS界面的一种模拟,因为不是所有的Windows应用程序都有图形界面的,比如我们现在写的C程序就没有,所谓的标准输入和输出只能在cmd模拟器里面跑。(如果你对命令行窗口仍有疑惑,可以参考我在知乎上的这个回答)在黑窗下,我们可以尝试敲敲一些命令:

    • dir,列出当前所在目录下的文件
    • cd,进入某个目录,cd ..表示回到上一层目录
    • 盘符,进入某个分区,比如输入D:回车表示进入D盘
    • exit,退出命令提示符
    • del,删除某个文件(注意可没有回收站让你反悔)
    • 把文件拖动到命令窗口,命令会自动显示这个文件的路径,直接输入文件名回车系统会调用适合的程序打开它

    好了,基本的操作我们已经熟悉了。这样已经够了。对啊,比如说我们用del命令的时候,del a.txt,这里的a.txt就是del命令的命令行参数!在Windows下,我们的程序最后会编译成某个.exe文件,IDE执行它是自动启动的,不会给我们输入命令行参数的机会(讲道理的话是可以设置的)。假设我们编译成某个可执行程序,放在当前所在的目录下,比如说,叫mao.exe吧,然后源文件名叫做mao.mao,我们这样输入:

    mao.exe mao.mao

    系统会给mao.exe应用程序的main函数传入argv参数。不过前提是你要写对main函数的原型,像这样:

    int main(int argc, char *argv[])
    {
        /* 代码在这里 */
        return 0;
    }

    那么对于上面的例子,我们的两个参数的值应该是这样的:argc=2,argv[0]="mao.exe",argv[1]="mao.mao".(唔也有可能是完整路径,懒得测了)然后我们打开文件的话,像这样打开就行了:

    #include <stdio.h>
    
    int main(int argc, char *argv[])
    {
        if (argc &gt; 1) {
            FILE *fp;
            if ((fp = fopen(argv[1], "r")) == NULL) {
                printf("Open file failed.\n");
            } else {
                /* 打开之后的操作在这里 */
                fclose(fp);
                /* 不要忘记了关闭 */
            }
        } else {
            printf("Not enough parameters!\n");
        }
        return 0;
    }

    当然我的代码仅仅只作为一个参考,抄去说不定程序会崩哦。

    好了,如何操作我们就先说到这里。你可能会纳闷——为什么要弄得这么麻烦?

    • 普通回答:为了让同学们熟悉一下文件操作和命令行参数的知识,提高自己查找知识解决问题的能力
    • 二逼回答:因为助教用的是Linux,这样做会很方便
    • 文艺回答:每一个C程序员都要对Unix系系统有亲近感,这是培养亲近感的很好的方式

    好吧,今天就说到这里,如果你们对Linux有兴趣,下次可以专门聊聊。

  • 构建Mao语言运行器-语法树

    好了,前面我们说到了词法分析的过程。如果你按照我的思路进行的话,现在你已经可以把源代码转换成一个一个的词法单元,也就是所谓的token了。接下来要做的就是按照我们人为设定好的语法规则把这些词法单元组织起来。我们前面说过,Mao语言的语法结构极其简单,只有声明变量、表达式和print输出三个部分。而表达式所包含的内容也只包括加减乘除和括号。更关键的是它是保证输入的代码不会出错的。(当然你要满足程序对于健壮性的要求那就是另一回事了)

    首先从变量的声明开始讨论起。Mao语言里只有int和double两种类型。所以我们可以一句一句地读取,直至分号结束。

    struct token tkindex;
    while (!istokenend(tkindex = gettoken())) {
        /* 你要记住这是伪代码,不要问我这个函数怎么来的 */
        if (tkindex.type == TOKEN_INT) {
            while (!istokenend(tkindex = gettoken())) {
                if (tkindex.type == TOKEN_VARIABLE) {
                    /* 把这个名字和类型加到变量列表里 */
                } else if (tkindex.type == TOKEN_COMMA) {
                    /* 如果是逗号,继续前进 */
                    continue;
                } else if (tkindex.type == TOKEN_SEMICOLON) {
                    /* 遇见分号,声明语句结束,循环停止 */
                    break;
                }
            }
        } else if (tkindex.type == TOKEN_DOUBLE) {
            /* 跟上面一样玩 */
        } else if (/* 其他类型 */) {
            /* 其他的处理代码 */
        }
    }

    为什么可以写得这么简单?因为变量声明的时候保证没有带等号的初始化语句哦。

    变量定义还是很简单的事情,重头戏在于表达式的求值。就算是后面的print语句,你不能保证它不会给你来个print(a+b)或者直接print(1*3/2),所以要先把表达式求值做好才能实现输出语句。(注:有同学指出需求文件里只说了print不会输出表达式,只会输出变量,这样就更容易咯)

    如何给表达式求值呢?参考文档里给的暗示方法,是把中缀表达式转换为后缀表达式(逆波兰表示法),其中要用到所谓的调度场算法。然后利用栈来求解。这个方法不难,不过我们这里为了程序的可扩展性和方便理解,决定采用树的方式来实现。并且这里的树还是最简单的一种情况,即二叉树。如图:

    语法树例子

    所以,你以为我会这样老老实实写个结构:

    struct expr_node {
        int type;
        char * var_name;
        union {
            int ival;
            double dval;
        };
        struct expr_node * left_child;
        struct expr_node * right_child;
    };

    哼哼,想错了。在这个话题上,为了说明问题,我们用不着那么麻烦。

    我在软件工程学生如何学好C语言?这个问题的回答里面说过,理解数据结构不要过分机械地非要做一堆结构体和指针来模拟。计算机科学的核心是抽象。把握好抽象然后再来特化到适合特定场合的结构。所以呢,还是用数组吧。

    什么?数组不是顺序存储的结构吗?naive,我们可以这样定义:

    • 数组的第一个元素,可以看作整个树的头
    • 对于任意第n个元素,它的左子节点是第2n个元素,右子节点是第2n+1个元素
    • 类似地,对于任意第n(不论奇偶)个元素,它的父节点编号是n/2

    对于这棵树,我们可以稍微分析一下。这棵二叉树每一“层”包含的元素数量是2^n个。所以我们给这个等比数列求一个和,结果就是2^n – 1次方。也就是说对于一个10层的二叉树,我们需要1023个空间的数组。其实也不是很大,而且我们也不太可能出现一个需要10层的表达式数……好吧,如果你实在不放心,C99标准支持以变量初始化数组,自己先感受一下再开数组吧。

    好了,数据结构准备好了,我们可以开始构建这个树了。讲道理说语法树有点言过其实,我们这里构建树的过程连什么LL(1)都用不到。我们来思考一下,假如我是一台计算机,我要怎么计算公式k * (a+c) + (j=3)呢?

    你也许会说,我们从k开始,k右边的第一个符号就作为树根。但是加减乘除是有优先级的,最后算的应该是加号。注意,我们给这个表达式树求值是自上而下递归求值的,我们把下层计算好之后再把结果返回给上层来计算。所以我们应该以哪个符号开始呢?嗯,第一个优先级最低的符号。在这里,我们的最低优先级的符号是等号,其次是加减,最后是乘除。

    等等,貌似有点不对。对于上面的那个等式,如果我们要搜到第一个优先级最低的符号也就是等号,那逻辑不对啊。嗯,因为上面我们没有考虑到括号。所以修正一下上面的规则——第一个括号之外的优先级最低的符号。你需要定义一个变量来记录当前是否外面还有括号。唔,是“第一个”吗?我想应该是最后一个吧,因为在你搜索到最后一个优先级最低的符号之前,是不知道当前位置上的符号是不是最后一个的。像a=1+1这种当然好判断,有等号就行了,可是比如a*21/b+11我怎么知道后面还有没有更低的呢?所以,应该改成——最后一个(括号之外的(优先级最低的(符号)))。(打括号是为了好断句)

    好了好了,我们已经把“寻找树根”的任务搞定了。任务已经完成了一半了。什么,你不相信?我们构造这个树是递归的喂!我们只要找到树根,然后正确分划,这个树就搞定了。这就是递归的力量。So,我们再来讨论分划的问题。其实分划挺简单的,我们已经找到了那个“树根”的token,然后parse它左边的部分和右边的部分。

    空话说得太多没用,我们来讨论下具体的实现。根据上文的思考,我们可以总结出从token流产生语法树的若干规则:

    • 如果整个串中只有一个token,是数或者变量,那么这个表达式树的值就是这个数或者变量的值
    • 否则,当前表达式树的值是左子树的值同右子树的值经过计算的结果,计算的方式由树根类型决定
    • 树根定义为当前token流中不在任何括号内的最后一个优先级最低的符号,左子树是从token流开始一直到树根的所有内容,右子树同理
    • 如果有括号,把括号内内容当作一个整体

    由于叙述需要严谨性,所以听起来很复杂。其实整个的实现过程非常的清晰明了,我们来举个例子说明:

    对于表达式foo = sample * (3 + 5)而言,我们的搜索过程是这样:

    1. 从左至右找到最后一个不在括号内的最低优先级符号“=”,将其作为树根。
    2. 树根左边的子token流里只有一个变量foo,所以左子树的值就是foo,停止。
    3. 树根右边最后一个不在任何括号内优先级最低的运算符是“*”,将其作为子树的树根。
    4. 子树树根的左边的token流也只有一个内容,就是sample变量,所以同样停止搜索。
    5. “*”右边的括号是一个整体,我们在括号内部搜索不在任何“括号”(打引号是因为这里的“不在”是指不在这个括号内部的另外的括号里)内的最低优先级运算符,找到了,是“+”。
    6. “+”左右都各是一个数,所以这两个子树停止搜索。

    如果把这棵二叉树表达为一个数组,我们的数组结构是这样(编号表示下标,C语言的话要全部减1):

    1. 符号 =
    2. 变量 foo
    3. 符号 *
    4. (空)
    5. (空)
    6. 变量 sample
    7. 符号 +
    8. (空)
    9. (空)
    10. (空)
    11. (空)
    12. (空)
    13. (空)
    14. 数值 3
    15. 数值 5

    之所以会有那么多空位是因为数或变量是没有子节点的,而数组的话必须要把它们的空间留足。好了,我们解决了理论上生成树的问题,如何用代码来实现呢?首先要面对的一个问题是,不管我们如何递归处理这个表达式,它始终都是一个整体,比如上面,当我们在创建符号“*”的左右子树的时候,为什么不会搜索到“=”那里去呢?所以我们需要两个变量,来标记我们可以搜索的token范围。如果你觉得我说的话很晦涩的话,那是因为我没说清楚。想想子字符串吧,一样的道理。

    struct expr_node tmp_tree[128];
    struct token stream[1000];
    /* stream数组假设已经经过了词法分析阶段的输入 */
    /* 为了说明问题,我才使用的全局变量 */
    void create_tree(size_t tkstart, size_t tkend, size_t tree_root)
    {
        if (tkstart == tkend) {
        /* 如果整个流只有一个内容 */
            if (stream[tkstart].type == TOKEN_INTNUM) {
                tmp_tree[tree_root].type = TOKEN_INTNUM;
                tmp_tree[tree_root].var_name = NULL;
                tmp_tree[tree_root].ival = atoi(stream[tkstart]);
                /* 结构体中的匿名联合体是C11标准支持的新语法,可以直接访问它的内部成员 */
                /* 这里我们假设在词法分析阶段对于数保存的是它们的字符串形式 */
            } else if (stream[tkstart].type == TOKEN_DOUBLENUM) {
                /* 同样处理double的情况 */
            } else if (stream[tkstart].type == TOKEN_VARIABLE) {
                /* 变量的情况也是类似,只不过需要保存它们的名字 */
            }
        }
    
        int parentheses = 0;
        size_t lowest_token = tkstart;
        /* 在没有出问题的情况下,第一个token不可能是运算符,所以可以专门规定非运算符的运算符优先级最高 */
    
        for (size_t i = tkstart; i &lt;= tkend; ++i) {
            if (is_operator(stream[i])) {
                if (priority(stream[i]) &lt; priority(stream[lowest_token] &&
                    !parentheses) {
                    /* is_operator和priority都是自己定义的宏 */
                    lowest_token = i;
                }
            } else if (stream[i].type == TOKEN_LPAREN) {
                /* 左括弧,标记变量加一 */
                ++parentheses;
            } else if (stream[i].type == TOKEN_RPAREN) {
                --parentheses;
            }
        }
    
        create_tree(tkstart, lowest_token - 1, tree_root * 2);
        create_tree(lowest_token + 1, tkend, tree_root * 2 + 1);
    }

    这段构造树的代码原理清晰,但是其实它是有问题的:

    • 如果一个表达式整个被括号包住,那么它是无法处理的,因为自始至终变量parentheses不为0
    • Mao语言的文档里有写y=(c+6)*-(1+1)这样的表达式,而我们的程序无法处理两个符号连在一起的情况

    解决第一个问题,可以用一个简单粗暴但是很脏的办法。所有带括号的表达式,最后都会递归到一个括号从前往后包住的形式。所以如果我们发现第一个token是左括号,我们识别一下是不是从头到尾都被包住了,如果是,就再去掉括号往下识别。

    第二个问题,要解决需要我们在识别lowest_token的时候修改一下算法。所谓’+’和’-‘,如果前面有其他非括号的符号,那必然是正负号,否则是加减号。所以,如果发现加减的上一个有运算符,那就把正负号连上作为子树。然后,如果开头是加减号,那就自动补0.

    计算的话,很简单的。用一个switch…case语句分开判断当前是什么符号,default表示是数或变量。类型转换的事情交给编译器就好,你要负责的只是判断这个变量应该是什么类型。什么,一个函数不能返回多种类型的值?那可以返回struct嘛,哈哈哈……